From 478b9f70545467937297a827fc01a38f608ad891 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 11:23:49 +0000 Subject: [PATCH 01/13] Bind notification_type as a parameter in unsubscribe SQL The unauthenticated unsubscribe endpoint interpolated the token's notification_type into the jsonb_set path and key. Pass it as a bound parameter instead. Co-Authored-By: Claude Opus 5.5 --- backend/api/src/unsubscribe.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/src/unsubscribe.ts b/backend/api/src/unsubscribe.ts index 3d58b358..533f77c3 100644 --- a/backend/api/src/unsubscribe.ts +++ b/backend/api/src/unsubscribe.ts @@ -56,11 +56,11 @@ export const unsubscribe: APIHandler<'unsubscribe/:token'> = async ({token}, _au `UPDATE private_users SET data = jsonb_set( data, - '{notificationPreferences,${notification_type}}', - (COALESCE(data->'notificationPreferences'->'${notification_type}', '["email", "browser", "mobile"]'::jsonb) - 'email') + array['notificationPreferences', $2::text], + (COALESCE(data->'notificationPreferences'->$2::text, '["email", "browser", "mobile"]'::jsonb) - 'email') ) WHERE id = $1`, - [user_id], + [user_id, notification_type], ) } else { // Unsubscribe from all emails by adding email to opt_out_all From ec94298487400b33cfe7a77d68b32e3fcd65d5f0 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 11:25:51 +0000 Subject: [PATCH 02/13] Enforce endorsement switch and two-way blocks when creating profile comments create-comment never checked the target's comments_enabled setting, and only rejected authors the target had blocked, not targets the author had blocked. Check the switch (the owner is exempt) and use the shared assertNotBlocked. Co-Authored-By: Claude Opus 5.5 --- backend/api/src/create-comment.ts | 18 +++- .../tests/unit/create-comment.unit.test.ts | 84 ++++++++++++++++--- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/backend/api/src/create-comment.ts b/backend/api/src/create-comment.ts index d1396e42..bd2ff193 100644 --- a/backend/api/src/create-comment.ts +++ b/backend/api/src/create-comment.ts @@ -1,4 +1,5 @@ import {type JSONContent} from '@tiptap/core' +import {assertNotBlocked} from 'api/helpers/blocks' import {MAX_COMMENT_JSON_LENGTH, validateCommentAuthor} from 'api/helpers/comment' import {APIErrors, APIHandler} from 'api/helpers/endpoint' import {Notification} from 'common/notifications' @@ -57,10 +58,19 @@ export const createComment: APIHandler<'create-comment'> = async ( const validateComment = async (userId: string, creatorId: string, content: JSONContent) => { const {creator} = await validateCommentAuthor(creatorId, content) - const otherUser = await getPrivateUser(userId) - if (!otherUser) throw APIErrors.notFound('Other user not found') - if (otherUser.blockedUserIds.includes(creatorId)) { - throw APIErrors.notFound('User has blocked you') + const pg = createSupabaseDirectClient() + const profile = await pg.oneOrNone<{comments_enabled: boolean}>( + `select comments_enabled from profiles where user_id = $1`, + [userId], + ) + if (!profile) throw APIErrors.notFound('Other user not found') + + // Blocks run both ways (see `helpers/blocks`): the author can't endorse someone they blocked either. + await assertNotBlocked(creatorId, [userId]) + + // The switch reads "endorsements from others", so the owner can still post on their own profile. + if (creatorId !== userId && !profile.comments_enabled) { + throw APIErrors.forbidden('This person has turned off endorsements') } return {content, creator} diff --git a/backend/api/tests/unit/create-comment.unit.test.ts b/backend/api/tests/unit/create-comment.unit.test.ts index b5eaf884..b29d1ab4 100644 --- a/backend/api/tests/unit/create-comment.unit.test.ts +++ b/backend/api/tests/unit/create-comment.unit.test.ts @@ -24,6 +24,7 @@ describe('createComment', () => { jest.resetAllMocks() mockPg = { one: jest.fn(), + oneOrNone: jest.fn(), } ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) }) @@ -82,9 +83,10 @@ describe('createComment', () => { ;(sharedUtils.getUser as jest.Mock) .mockResolvedValueOnce(mockCreator) .mockResolvedValueOnce(mockOnUser) - ;(sharedUtils.getPrivateUser as jest.Mock) - .mockResolvedValueOnce(mockUserId) - .mockResolvedValueOnce(mockOnUser) + ;(sharedUtils.getPrivateUser as jest.Mock).mockImplementation(async (id: string) => + id === mockOnUser.id ? mockOnUser : {id, blockedUserIds: []}, + ) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: true}) ;(mockPg.one as jest.Mock).mockResolvedValue(mockComment) ;(notificationPrefs.getNotificationDestinationsForUser as jest.Mock).mockReturnValue( mockNotificationDestination, @@ -97,9 +99,11 @@ describe('createComment', () => { expect(sharedUtils.getUser).toBeCalledTimes(2) expect(sharedUtils.getUser).toHaveBeenNthCalledWith(1, mockAuth.uid) expect(sharedUtils.getUser).toHaveBeenNthCalledWith(2, mockUserId.userId) - expect(sharedUtils.getPrivateUser).toBeCalledTimes(2) - expect(sharedUtils.getPrivateUser).toHaveBeenNthCalledWith(1, mockProps.userId) - expect(sharedUtils.getPrivateUser).toHaveBeenNthCalledWith(2, mockOnUser.id) + expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('select comments_enabled from profiles'), [ + mockProps.userId, + ]) + expect(sharedUtils.getPrivateUser).toHaveBeenCalledWith(mockAuth.uid) + expect(sharedUtils.getPrivateUser).toHaveBeenCalledWith(mockOnUser.id) expect(mockPg.one).toBeCalledTimes(1) expect(mockPg.one).toBeCalledWith(sqlMatch('insert into profile_comments'), [ mockCreator.id, @@ -172,8 +176,11 @@ describe('createComment', () => { .mockResolvedValueOnce(mockCreator) .mockResolvedValueOnce(false) ;(sharedUtils.getPrivateUser as jest.Mock).mockResolvedValue(mockUserId) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: true}) - expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError('User not found') + await expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( + 'User not found', + ) }) it('throw if there is no account associated with the authId', async () => { @@ -298,9 +305,9 @@ describe('createComment', () => { } ;(sharedUtils.getUser as jest.Mock).mockResolvedValueOnce(mockCreator) - ;(sharedUtils.getPrivateUser as jest.Mock).mockResolvedValue(null) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(null) - expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( + await expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( 'Other user not found', ) }) @@ -344,11 +351,64 @@ describe('createComment', () => { } ;(sharedUtils.getUser as jest.Mock).mockResolvedValueOnce(mockCreator) - ;(sharedUtils.getPrivateUser as jest.Mock).mockResolvedValue(mockUserId) + ;(sharedUtils.getPrivateUser as jest.Mock).mockImplementation(async (id: string) => + id === mockUserId.userId ? mockUserId : {id, blockedUserIds: []}, + ) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: true}) - expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( - 'User has blocked you', + await expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( + 'You can no longer interact with this person', + ) + expect(mockPg.one).not.toHaveBeenCalled() + }) + + it('throw if you have blocked the user', async () => { + const mockAuth = {uid: '321'} as AuthedUser + const mockCreator = {id: '321', name: 'Mock Creator', username: 'mock.creator'} + const mockProps = { + userId: '123', + content: { + type: 'doc', + content: [{type: 'paragraph', content: [{type: 'text', text: 'Hi'}]}], + }, + replyToCommentId: undefined, + } as any + + ;(sharedUtils.getUser as jest.Mock).mockResolvedValueOnce(mockCreator) + ;(sharedUtils.getPrivateUser as jest.Mock).mockImplementation(async (id: string) => + id === mockAuth.uid ? {id, blockedUserIds: ['123']} : {id, blockedUserIds: []}, + ) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: true}) + + await expect(createComment(mockProps, mockAuth, {} as any)).rejects.toThrowError( + 'You can no longer interact with this person', + ) + expect(mockPg.one).not.toHaveBeenCalled() + }) + + it('throw if the profile has endorsements turned off', async () => { + const mockAuth = {uid: '321'} as AuthedUser + const mockCreator = {id: '321', name: 'Mock Creator', username: 'mock.creator'} + const mockProps = { + userId: '123', + content: { + type: 'doc', + content: [{type: 'paragraph', content: [{type: 'text', text: 'Hi'}]}], + }, + replyToCommentId: undefined, + } as any + + ;(sharedUtils.getUser as jest.Mock).mockResolvedValueOnce(mockCreator) + ;(sharedUtils.getPrivateUser as jest.Mock).mockImplementation(async (id: string) => ({ + id, + blockedUserIds: [], + })) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: false}) + + await expect(createComment(mockProps, mockAuth, {} as any)).rejects.toThrowError( + 'This person has turned off endorsements', ) + expect(mockPg.one).not.toHaveBeenCalled() }) it('throw if the comment is too long', async () => { From 7206696258e27c037032035aa3b6f2598e43bb88 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 12:37:12 +0000 Subject: [PATCH 03/13] Only accept photo URLs we host A member could set their avatar or photos to an image on their own server. Raw tags and the new-message email then load it straight from viewers' browsers and mail clients, revealing their IP addresses and acting as a read receipt. isOwnImageUrl accepts only objects in our own Storage buckets (checked by bucket, since the Google storage hosts serve anyone's) and /images/ on our own site. It is enforced on pinned_url, photo_urls and me/update avatarUrl, used when an import picks a photo to pin, and applied at render time in the avatar widget, the raw tags and the emails so rows saved before this change are neutralised too. Co-Authored-By: Claude Opus 5.5 --- backend/api/src/llm-extract-profile.ts | 5 ++ backend/email/emails/new-message.tsx | 5 +- backend/email/emails/utils.tsx | 6 +- backend/shared/src/profiles/rehost-images.ts | 7 +- .../tests/unit/rehost-images.unit.test.ts | 9 ++- common/src/api/schema.ts | 3 +- common/src/api/zod-types.ts | 14 +++- common/src/util/image-url.ts | 69 +++++++++++++++++++ common/tests/unit/image-url.test.ts | 56 +++++++++++++++ web/components/events/user-link.tsx | 6 +- .../messaging/send-message-button.tsx | 6 +- web/components/widgets/avatar.tsx | 8 ++- 12 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 common/src/util/image-url.ts create mode 100644 common/tests/unit/image-url.test.ts diff --git a/backend/api/src/llm-extract-profile.ts b/backend/api/src/llm-extract-profile.ts index a072a922..7fdcd950 100644 --- a/backend/api/src/llm-extract-profile.ts +++ b/backend/api/src/llm-extract-profile.ts @@ -30,6 +30,7 @@ import {OPTION_TABLES} from 'common/profiles/constants' import {ProfileWithoutUser} from 'common/profiles/profile' import {SITE_ORDER} from 'common/socials' import {cleanUsername} from 'common/util/clean-username' +import {isOwnImageUrl} from 'common/util/image-url' import {removeNullOrUndefinedProps} from 'common/util/object' import {parseJsonContentToText, textToJSONContent} from 'common/util/parse' import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time' @@ -428,6 +429,10 @@ async function processAndCache( callLLM(content, locale, source), bio ? rehostExternalImages(bio, imageFolderName) : undefined, ]) + // The model can answer with any URL it saw on the page. Only a photo we host may become the + // avatar: anything else would be loaded from its owner's server by everyone who views the + // profile, and the profile schema refuses it anyway (see `isOwnImageUrl`). + if (!isOwnImageUrl(profile.pinned_url)) profile.pinned_url = undefined if (rehostedBio) { console.log('rehosted bio') profile.bio = rehostedBio diff --git a/backend/email/emails/new-message.tsx b/backend/email/emails/new-message.tsx index f0c10d67..95ebfaf8 100644 --- a/backend/email/emails/new-message.tsx +++ b/backend/email/emails/new-message.tsx @@ -2,6 +2,7 @@ import {Img, Link, Section, Text} from '@react-email/components' import {DEPLOYED_WEB_URL, DOMAIN} from 'common/envs/constants' import {type ProfileRow} from 'common/profiles/profile' import {type User} from 'common/user' +import {ownImageUrlOrUndefined} from 'common/util/image-url' import { Actions, CTAButton, @@ -45,7 +46,9 @@ export const NewMessageEmail = ({ const profileUrl = `https://${DOMAIN}/${fromUser.username}` // Every client renders reliably, none of them render a CSS-generated placeholder — so an absent // avatar falls back to the same asset the web app uses rather than to a broken image icon. - const avatarUrl = fromUser.avatarUrl + // Only a photo we host: a mail client loads it the moment the email is opened, so one on the + // sender's own server would hand them the reader's IP and a read receipt (see `isOwnImageUrl`). + const avatarUrl = ownImageUrlOrUndefined(fromUser.avatarUrl) const t = createT(locale) // Age and city are the two facts a reader can act on at a glance; anything more turns the card into a diff --git a/backend/email/emails/utils.tsx b/backend/email/emails/utils.tsx index 09171068..6bca3ba2 100644 --- a/backend/email/emails/utils.tsx +++ b/backend/email/emails/utils.tsx @@ -13,6 +13,7 @@ import { Text, } from '@react-email/components' import {DEPLOYED_WEB_URL} from 'common/envs/constants' +import {ownImageUrlOrUndefined} from 'common/util/image-url' import React, {type CSSProperties, type ReactNode} from 'react' import {createT} from 'shared/locale' @@ -90,9 +91,8 @@ export const fonts = { * silhouette that reads as an empty circle sitting next to members who do have a photo. */ export const avatarSrc = (avatarUrl: string | null | undefined, name?: string | null) => - avatarUrl && avatarUrl.length > 0 - ? avatarUrl - : `https://ui-avatars.com/api/?name=${encodeURIComponent((name || 'U')[0])}&size=128&format=png` + ownImageUrlOrUndefined(avatarUrl) ?? + `https://ui-avatars.com/api/?name=${encodeURIComponent((name || 'U')[0])}&size=128&format=png` // ─── Shell ──────────────────────────────────────────────────────────────────── diff --git a/backend/shared/src/profiles/rehost-images.ts b/backend/shared/src/profiles/rehost-images.ts index ff4f6aa3..0a77600b 100644 --- a/backend/shared/src/profiles/rehost-images.ts +++ b/backend/shared/src/profiles/rehost-images.ts @@ -1,5 +1,6 @@ import {JSONContent} from '@tiptap/core' import {FIREBASE_STORAGE_URL} from 'common/envs/constants' +import {isOwnImageUrl} from 'common/util/image-url' import {YEAR_SECONDS} from 'common/util/time' import {createHash} from 'crypto' import {uniq} from 'lodash' @@ -183,10 +184,12 @@ async function fetchImage(src: string) { /** * The first image in the document that we serve ourselves — the one worth pinning as the profile * photo. Deliberately never an image left hotlinked: an avatar is rendered through next/image - * everywhere, which 400s on any host missing from `remotePatterns`. + * everywhere, which 400s on any host missing from `remotePatterns`. Stricter than {@link isOwnHost}: + * a page can embed an image from somebody else's bucket on the same Google host, and the profile + * schema refuses that as a `pinned_url` (see `isOwnImageUrl`). */ export function firstOwnedImageSrc(content: JSONContent): string | undefined { - return collectImageSrcs(content).find(isOwnHost) + return collectImageSrcs(content).find(isOwnImageUrl) } function shouldRehost(src: string): boolean { diff --git a/backend/shared/tests/unit/rehost-images.unit.test.ts b/backend/shared/tests/unit/rehost-images.unit.test.ts index b00fb292..97a90ff7 100644 --- a/backend/shared/tests/unit/rehost-images.unit.test.ts +++ b/backend/shared/tests/unit/rehost-images.unit.test.ts @@ -255,11 +255,18 @@ describe('rehostExternalImages', () => { describe('firstOwnedImageSrc', () => { it('returns the first image we serve ourselves', async () => { - const ours = 'https://firebasestorage.googleapis.com/v0/b/b/o/user-images%2Fa%2Fb.jpg?alt=media' + const ours = + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/user-images%2Fa%2Fb.jpg?alt=media' expect(firstOwnedImageSrc(docWithImages('https://awlego.com/a.jpg', ours))).toEqual(ours) }) + it("skips an image in somebody else's bucket on the same Google host", async () => { + const theirs = 'https://firebasestorage.googleapis.com/v0/b/someone-else/o/a.jpg?alt=media' + + expect(firstOwnedImageSrc(docWithImages(theirs))).toBeUndefined() + }) + it('never returns an image still hosted elsewhere', async () => { // An avatar goes through next/image, which rejects any host not in `remotePatterns`. expect(firstOwnedImageSrc(docWithImages('https://awlego.com/a.jpg'))).toBeUndefined() diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index b2686016..dcf91a7f 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -3,6 +3,7 @@ import { arraybeSchema, contentSchema, dateSchema, + ownImageUrl, profileSchema, zBoolean, } from 'common/api/zod-types' @@ -478,7 +479,7 @@ export const API = (_apiTypeCheck = { props: z.object({ name: z.string().trim().min(1).optional(), username: z.string().trim().min(1).optional(), - avatarUrl: z.string().optional(), + avatarUrl: ownImageUrl.optional(), }), returns: {} as FullUser, summary: 'Update authenticated user profile and settings', diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts index aa09ec23..92e14e18 100644 --- a/common/src/api/zod-types.ts +++ b/common/src/api/zod-types.ts @@ -3,8 +3,17 @@ import {FEED_VISIBILITY_LEVELS} from 'common/feed/feed' import {MAX_PREF_MAX_DISTANCE, MIN_PREF_MAX_DISTANCE} from 'common/max-distance' import {MAX_PROFILE_AGE, MIN_PROFILE_AGE, STORED_BIRTH_DATE_REGEX} from 'common/profiles/birth-date' import {arrify} from 'common/util/array' +import {isOwnImageUrl} from 'common/util/image-url' import {z} from 'zod' +/** + * A photo URL other members will load: only images we host, so a member cannot point theirs at a + * server that logs who views it. See {@link isOwnImageUrl}. + */ +export const ownImageUrl = z + .string() + .refine(isOwnImageUrl, {message: 'Photos must be uploaded to Compass'}) + /* GET request array can be like ?a=1 or ?a=1&a=2 */ export const arraybeSchema = z.array(z.string()).or(z.string()).transform(arrify) @@ -69,8 +78,9 @@ export const profileSchema = z.object({ geodb_city_id: z.string().optional().nullable(), languages: z.array(z.string()).optional().nullable(), looking_for_matches: zBoolean.optional(), - photo_urls: z.array(z.string()).optional().nullable(), - pinned_url: z.string().optional().nullable(), + photo_urls: z.array(ownImageUrl).optional().nullable(), + // Empty means "no photo", as for the other optional strings here. + pinned_url: ownImageUrl.or(z.literal('')).optional().nullable(), pref_age_max: z.number().min(18).max(100).optional().nullable(), pref_age_min: z.number().min(18).max(100).optional().nullable(), pref_gender: genderTypes.optional().nullable(), diff --git a/common/src/util/image-url.ts b/common/src/util/image-url.ts new file mode 100644 index 00000000..e0e677fe --- /dev/null +++ b/common/src/util/image-url.ts @@ -0,0 +1,69 @@ +import {FIREBASE_STORAGE_URL, WEB_URL} from 'common/envs/constants' +import {DEV_CONFIG} from 'common/envs/dev' +import {PROD_CONFIG} from 'common/envs/prod' +import {uniq} from 'lodash' + +/** + * Buckets whose objects are ours. Both environments, not just the current one: dev data is seeded + * with prod photo URLs, and either way nobody outside the project can read the bucket's access logs. + */ +const OWN_BUCKETS = uniq([ + PROD_CONFIG.firebaseConfig.storageBucket, + DEV_CONFIG.firebaseConfig.storageBucket, +]) + +const OWN_WEB_ORIGINS = uniq([ + 'https://compassmeet.com', + 'https://www.compassmeet.com', + new URL(WEB_URL).origin, +]) + +const STORAGE_ORIGIN = new URL(FIREBASE_STORAGE_URL).origin + +/** + * Whether `src` is an image we serve ourselves, and so is safe to put in an `` other members (or + * their mail client) will load. + * + * A member's photo URLs are rendered for everyone who views them. If they could point at their own + * server, every viewer's browser would request it: that reveals the viewer's IP address and, in the + * new-message email, works as a read receipt. So only these are accepted: + * - objects in one of our own Storage buckets — checked by bucket, not just by host, because + * `firebasestorage.googleapis.com` and `storage.googleapis.com` serve *anyone's* bucket, and the + * bucket's owner sees its access logs; + * - static assets under `/images/` on our own site (the default avatar, the showcase portraits), + * absolute or root-relative. Any other path on our site is refused: one that redirects would lead + * the image request straight back off-site. + */ +export function isOwnImageUrl(src: string | null | undefined): boolean { + if (!src) return false + let url: URL + try { + // The base only matters for root-relative paths. `//evil.com/x` and `/\evil.com/x` parse to + // evil.com, so they fall through to the host checks below and are refused there. + url = new URL(src, WEB_URL) + } catch { + return false + } + + const segments = url.pathname.split('/').filter(Boolean) + + // Compared by origin, so the scheme counts too: only the Storage emulator and the local dev server + // are plain http. + if (url.origin === STORAGE_ORIGIN || url.origin === 'https://firebasestorage.googleapis.com') { + // `/v0/b//o/` from the SDKs, `//` from `generateAvatarUrl`. + const bucket = segments[0] === 'v0' && segments[1] === 'b' ? segments[2] : segments[0] + return OWN_BUCKETS.includes(bucket) + } + if (url.origin === 'https://storage.googleapis.com') { + return OWN_BUCKETS.includes(segments[0]) + } + if (OWN_WEB_ORIGINS.includes(url.origin)) { + return segments[0] === 'images' + } + return false +} + +/** `src` if it is one of ours (see {@link isOwnImageUrl}), otherwise undefined. */ +export function ownImageUrlOrUndefined(src: string | null | undefined): string | undefined { + return src && isOwnImageUrl(src) ? src : undefined +} diff --git a/common/tests/unit/image-url.test.ts b/common/tests/unit/image-url.test.ts new file mode 100644 index 00000000..35cd74df --- /dev/null +++ b/common/tests/unit/image-url.test.ts @@ -0,0 +1,56 @@ +import {profileSchema} from 'common/api/zod-types' +import {isOwnImageUrl} from 'common/util/image-url' + +describe('isOwnImageUrl', () => { + it.each([ + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/user-images%2Fa%2Fb.jpg?alt=media&token=x', + 'https://firebasestorage.googleapis.com/v0/b/compass-57c3c.firebasestorage.app/o/a.jpg?alt=media', + // `generateAvatarUrl`'s shape + 'https://firebasestorage.googleapis.com/compass-130ba.firebasestorage.app/user-images/uid.png', + 'https://storage.googleapis.com/compass-130ba.firebasestorage.app/user-images/uid.png', + 'https://www.compassmeet.com/images/default-avatar.png', + 'https://compassmeet.com/images/default-avatar.png', + '/images/showcase/alexmorel-1.jpg', + ])('accepts our own image %s', (src) => { + expect(isOwnImageUrl(src)).toBe(true) + }) + + it.each([ + 'https://attacker.example/pixel.png', + 'http://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/a.jpg', + // Same Google hosts, somebody else's bucket — they can read its access logs + 'https://firebasestorage.googleapis.com/v0/b/attacker-bucket/o/a.jpg?alt=media', + 'https://storage.googleapis.com/attacker-bucket/a.jpg', + 'https://firebasestorage.googleapis.com.attacker.example/v0/b/compass-130ba.firebasestorage.app/o/a.jpg', + // Our site, but not a static image — could redirect off-site + 'https://www.compassmeet.com/api/whatever?url=https://attacker.example', + 'https://www.compassmeet.com/images/../redirect', + '//attacker.example/images/a.png', + '/\\attacker.example/images/a.png', + 'javascript:alert(1)', + 'data:image/png;base64,AAAA', + 'not a url', + '', + null, + undefined, + ])('refuses %s', (src) => { + expect(isOwnImageUrl(src)).toBe(false) + }) +}) + +describe('profileSchema photo URLs', () => { + const ours = 'https://www.compassmeet.com/images/default-avatar.png' + const theirs = 'https://attacker.example/pixel.png' + const parse = (props: object) => profileSchema.partial().safeParse(props).success + + it('accepts our own photos and an empty pinned_url', () => { + expect(parse({pinned_url: ours, photo_urls: [ours]})).toBe(true) + expect(parse({pinned_url: ''})).toBe(true) + expect(parse({pinned_url: null, photo_urls: null})).toBe(true) + }) + + it('refuses a photo hosted elsewhere', () => { + expect(parse({pinned_url: theirs})).toBe(false) + expect(parse({photo_urls: [ours, theirs]})).toBe(false) + }) +}) diff --git a/web/components/events/user-link.tsx b/web/components/events/user-link.tsx index de437e79..782f3592 100644 --- a/web/components/events/user-link.tsx +++ b/web/components/events/user-link.tsx @@ -1,3 +1,4 @@ +import {isOwnImageUrl} from 'common/util/image-url' import Link from 'next/link' import {useUserInStore} from 'web/hooks/use-user-supabase' @@ -22,8 +23,9 @@ export function UserLink({ href={`/${user.username}`} className={`hover:text-primary-700 flex items-center gap-1 ${className}`} > - {user.avatar_url && ( - {user.name} + {/* A plain loads straight from the viewer's browser, so only a photo we host. */} + {isOwnImageUrl(user.avatar_url) && ( + {user.name} )} {user.name} diff --git a/web/components/messaging/send-message-button.tsx b/web/components/messaging/send-message-button.tsx index 67abb2e2..58c2c782 100644 --- a/web/components/messaging/send-message-button.tsx +++ b/web/components/messaging/send-message-button.tsx @@ -11,6 +11,7 @@ import { } from 'common/moderation/first-message' import {Profile} from 'common/profiles/profile' import {User} from 'common/user' +import {isOwnImageUrl} from 'common/util/image-url' import {richTextToString} from 'common/util/parse' import {findKey} from 'lodash' import {useRouter} from 'next/router' @@ -386,9 +387,10 @@ export const SendMessageButton = (props: { the content it was introducing. */} - {profile.pinned_url && ( + {/* A plain loads straight from the viewer's browser, so only a photo we host. */} + {isOwnImageUrl(profile.pinned_url) && ( 0 - ? avatarUrl - : `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackInitial)}` + ownImageUrlOrUndefined(avatarUrl) ?? + `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackInitial)}` // console.log(username, fallbackInitial, url) // there can be no avatar URL or username in the feed, we show a "submit comment" From 40a519afc1f8e63a3d6238e8ba985d0d83fa8be6 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 12:38:06 +0000 Subject: [PATCH 04/13] Stop push subscriptions being reassigned to whoever sends them save-subscription and save-subscription-mobile reassigned an existing endpoint or token to the caller, so anyone holding another member's could redirect their notifications (or overwrite the web push keys). Rows owned by someone else are now left alone. Since a shared device no longer moves to the next member on its own, sign-out now removes this device's subscription through a new delete-subscription endpoint, before the session ends. Co-Authored-By: Claude Opus 5.5 --- backend/api/src/app.ts | 2 + backend/api/src/delete-subscription.ts | 37 +++++++++++++++ backend/api/src/save-subscription-mobile.ts | 8 +++- backend/api/src/save-subscription.ts | 32 +++++-------- .../unit/delete-subscription.unit.test.ts | 46 +++++++++++++++++++ .../save-subscription-mobile.unit.test.ts | 13 ++++++ .../tests/unit/save-subscription.unit.test.ts | 43 ++++++----------- common/src/api/schema.ts | 13 ++++++ web/lib/firebase/users.ts | 4 ++ web/lib/service/native-push.ts | 4 ++ web/lib/service/push-logout.ts | 42 +++++++++++++++++ 11 files changed, 193 insertions(+), 51 deletions(-) create mode 100644 backend/api/src/delete-subscription.ts create mode 100644 backend/api/tests/unit/delete-subscription.unit.test.ts create mode 100644 web/lib/service/push-logout.ts diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 82d9cc24..0f936991 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -9,6 +9,7 @@ import {createVoteComment} from 'api/create-vote-comment' import {deleteCompatibilityPrompt} from 'api/delete-compatibility-prompt' import {deleteMessage} from 'api/delete-message' import {deleteOption} from 'api/delete-option' +import {deleteSubscription} from 'api/delete-subscription' import {editMessage} from 'api/edit-message' import {editVoteComment} from 'api/edit-vote-comment' import {getChannelMemberships} from 'api/get-channel-memberships' @@ -765,6 +766,7 @@ const handlers: {[k in APIPath]: APIHandler} = { 'remove-pinned-photo': removePinnedPhoto, 'save-subscription': saveSubscription, 'save-subscription-mobile': saveSubscriptionMobile, + 'delete-subscription': deleteSubscription, 'search-location': searchLocationEndpoint, 'search-near-city': searchNearCity, 'search-users': searchUsers, diff --git a/backend/api/src/delete-subscription.ts b/backend/api/src/delete-subscription.ts new file mode 100644 index 00000000..ab7158d9 --- /dev/null +++ b/backend/api/src/delete-subscription.ts @@ -0,0 +1,37 @@ +import {createSupabaseDirectClient} from 'shared/supabase/init' + +import {APIErrors, APIHandler} from './helpers/endpoint' + +/** + * Called on sign-out, while the session is still valid, so the device stops receiving the + * signed-out member's notifications and is free for the next account to claim. + * + * `save-subscription*` no longer hands a token that belongs to someone else over to whoever sends + * it (that let anyone holding another member's token redirect their notifications), so this is how + * a shared device changes hands. Only the caller's own rows are removed; a token owned by anyone + * else is left alone, and the response does not say which happened. + */ +export const deleteSubscription: APIHandler<'delete-subscription'> = async ( + {endpoint, token}, + auth, +) => { + if (!endpoint && !token) { + throw APIErrors.badRequest('Missing endpoint or token') + } + + const pg = createSupabaseDirectClient() + if (endpoint) { + await pg.none('delete from push_subscriptions where endpoint = $1 and user_id = $2', [ + endpoint, + auth.uid, + ]) + } + if (token) { + await pg.none('delete from push_subscriptions_mobile where token = $1 and user_id = $2', [ + token, + auth.uid, + ]) + } + + return {success: true} +} diff --git a/backend/api/src/save-subscription-mobile.ts b/backend/api/src/save-subscription-mobile.ts index 5cdb64ec..4787e6ae 100644 --- a/backend/api/src/save-subscription-mobile.ts +++ b/backend/api/src/save-subscription-mobile.ts @@ -19,12 +19,16 @@ export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = as try { const pg = createSupabaseDirectClient() + // A token that already belongs to another member is left alone. It used to be reassigned to + // whoever sent it, so anyone holding someone else's token could redirect their notifications to + // an account of their own choosing. The response is the same either way, so it does not say whose + // token it was. await pg.none( ` insert into push_subscriptions_mobile(token, platform, user_id) values ($1, $2, $3) - on conflict(token) do update set platform = excluded.platform, - user_id = excluded.user_id + on conflict(token) do update set platform = excluded.platform + where push_subscriptions_mobile.user_id = excluded.user_id `, [token, platform, userId], ) diff --git a/backend/api/src/save-subscription.ts b/backend/api/src/save-subscription.ts index f583c2d8..16e1aa04 100644 --- a/backend/api/src/save-subscription.ts +++ b/backend/api/src/save-subscription.ts @@ -13,26 +13,18 @@ export const saveSubscription: APIHandler<'save-subscription'> = async (body, au try { const pg = createSupabaseDirectClient() - // Check if a subscription already exists - const exists = await pg.oneOrNone('select id from push_subscriptions where endpoint = $1', [ - subscription.endpoint, - ]) - - if (exists) { - // Already exists, optionally update keys and userId - await pg.none('update push_subscriptions set keys = $1, user_id = $2 where id = $3', [ - subscription.keys, - userId, - exists.id, - ]) - } else { - await pg.none( - `insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3) - on conflict(endpoint) do update set keys = excluded.keys - `, - [subscription.endpoint, subscription.keys, userId], - ) - } + // An endpoint that already belongs to another member is left alone, keys included. It used to be + // reassigned to whoever sent it, so anyone holding someone else's endpoint could redirect their + // notifications — or overwrite the keys so the browser could no longer decrypt them. A row with no + // owner yet can still be claimed. The response is the same either way, so it does not say whose + // endpoint it was. + await pg.none( + `insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3) + on conflict(endpoint) do update set keys = excluded.keys, user_id = excluded.user_id + where push_subscriptions.user_id is null or push_subscriptions.user_id = excluded.user_id + `, + [subscription.endpoint, subscription.keys, userId], + ) return {success: true} } catch (err) { diff --git a/backend/api/tests/unit/delete-subscription.unit.test.ts b/backend/api/tests/unit/delete-subscription.unit.test.ts new file mode 100644 index 00000000..3fb98884 --- /dev/null +++ b/backend/api/tests/unit/delete-subscription.unit.test.ts @@ -0,0 +1,46 @@ +jest.mock('shared/supabase/init') + +import {deleteSubscription} from 'api/delete-subscription' +import {AuthedUser} from 'api/helpers/endpoint' +import {sqlMatch} from 'common/test-utils' +import * as supabaseInit from 'shared/supabase/init' + +describe('deleteSubscription', () => { + let mockPg = {} as any + beforeEach(() => { + jest.resetAllMocks() + mockPg = {none: jest.fn().mockResolvedValue(null)} + ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) + }) + + const mockAuth = {uid: '321'} as AuthedUser + + // Scoped to the caller, so nobody can sign another member's device out of their notifications. + it('removes only the caller’s web subscription', async () => { + const result = await deleteSubscription({endpoint: 'mockEndpoint'}, mockAuth, {} as any) + + expect(result).toEqual({success: true}) + expect(mockPg.none).toBeCalledTimes(1) + expect(mockPg.none).toBeCalledWith( + sqlMatch('delete from push_subscriptions where endpoint = $1 and user_id = $2'), + ['mockEndpoint', mockAuth.uid], + ) + }) + + it('removes only the caller’s mobile subscription', async () => { + await deleteSubscription({token: 'mockToken'}, mockAuth, {} as any) + + expect(mockPg.none).toBeCalledTimes(1) + expect(mockPg.none).toBeCalledWith( + sqlMatch('delete from push_subscriptions_mobile where token = $1 and user_id = $2'), + ['mockToken', mockAuth.uid], + ) + }) + + it('rejects a request that names neither', async () => { + await expect(deleteSubscription({}, mockAuth, {} as any)).rejects.toThrow( + 'Missing endpoint or token', + ) + expect(mockPg.none).not.toBeCalled() + }) +}) diff --git a/backend/api/tests/unit/save-subscription-mobile.unit.test.ts b/backend/api/tests/unit/save-subscription-mobile.unit.test.ts index 3b8d5050..fc67ed45 100644 --- a/backend/api/tests/unit/save-subscription-mobile.unit.test.ts +++ b/backend/api/tests/unit/save-subscription-mobile.unit.test.ts @@ -47,6 +47,19 @@ describe('saveSubscriptionMobile', () => { expect(mockPg.none).toBeCalledWith(expect.anything(), ['iosToken', 'ios', mockAuth.uid]) }) + // The token used to be reassigned to whoever sent it, which let anyone holding another member's + // token redirect their notifications. + it('never reassigns a token owned by another member', async () => { + const mockAuth = {uid: '321'} as AuthedUser + ;(mockPg.none as jest.Mock).mockResolvedValue(null) + + await saveSubscriptionMobile({token: 'someToken'}, mockAuth, {} as any) + + const sql = (mockPg.none as jest.Mock).mock.calls[0][0] as string + expect(sql).not.toMatch(/set[^]*user_id\s*=[^]*where/) + expect(sql).toMatch(/where push_subscriptions_mobile\.user_id = excluded\.user_id/) + }) + // Android builds shipped before iOS existed send only a token. it('defaults to android when the client does not say, for builds that predate the field', async () => { const mockAuth = {uid: '321'} as AuthedUser diff --git a/backend/api/tests/unit/save-subscription.unit.test.ts b/backend/api/tests/unit/save-subscription.unit.test.ts index 6e1272ac..64ffe834 100644 --- a/backend/api/tests/unit/save-subscription.unit.test.ts +++ b/backend/api/tests/unit/save-subscription.unit.test.ts @@ -29,51 +29,36 @@ describe('saveSubscription', () => { } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any - const mockExists = {id: 'mockId'} - ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(mockExists) ;(mockPg.none as jest.Mock).mockResolvedValue(null) const result = await saveSubscription(mockBody, mockAuth, mockReq) expect(result.success).toBeTruthy() - expect(mockPg.oneOrNone).toBeCalledTimes(1) - expect(mockPg.oneOrNone).toBeCalledWith( - sqlMatch('select id from push_subscriptions where endpoint = $1'), - [mockBody.subscription.endpoint], - ) expect(mockPg.none).toBeCalledTimes(1) expect(mockPg.none).toBeCalledWith( - sqlMatch('update push_subscriptions set keys = $1, user_id = $2 where id = $3'), - [mockBody.subscription.keys, mockAuth.uid, mockExists.id], + sqlMatch('insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)'), + [mockBody.subscription.endpoint, mockBody.subscription.keys, mockAuth.uid], ) }) - it('should save user subscription even if this is their first one', async () => { - const mockBody = { - subscription: { - endpoint: 'mockEndpoint', - keys: 'mockKeys', - }, - } + // The endpoint used to be reassigned to whoever sent it, which let anyone holding another + // member's endpoint redirect their notifications. + it("only updates an existing endpoint that is unowned or already the caller's", async () => { const mockAuth = {uid: '321'} as AuthedUser - const mockReq = {} as any - - ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(false) ;(mockPg.none as jest.Mock).mockResolvedValue(null) - const result = await saveSubscription(mockBody, mockAuth, mockReq) - - expect(result.success).toBeTruthy() - expect(mockPg.oneOrNone).toBeCalledTimes(1) - expect(mockPg.oneOrNone).toBeCalledWith( - sqlMatch('select id from push_subscriptions where endpoint = $1'), - [mockBody.subscription.endpoint], + await saveSubscription( + {subscription: {endpoint: 'mockEndpoint', keys: 'mockKeys'}} as any, + mockAuth, + {} as any, ) - expect(mockPg.none).toBeCalledTimes(1) + expect(mockPg.none).toBeCalledWith( - sqlMatch('insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)'), - [mockBody.subscription.endpoint, mockBody.subscription.keys, mockAuth.uid], + sqlMatch( + 'where push_subscriptions.user_id is null or push_subscriptions.user_id = excluded.user_id', + ), + expect.anything(), ) }) }) diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index dcf91a7f..6f39fc54 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1580,6 +1580,19 @@ export const API = (_apiTypeCheck = { summary: 'Save a mobile push subscription for the user', tag: 'Notifications', }, + 'delete-subscription': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as {success: boolean}, + props: z.object({ + // The web push endpoint, the native push token, or both — whichever this device holds. + endpoint: z.string().optional(), + token: z.string().optional(), + }), + summary: "Remove this device's push subscription for the user, on sign-out", + tag: 'Notifications', + }, 'create-bookmarked-search': { method: 'POST', authed: true, diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 64ecf4a3..d464f56f 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -491,6 +491,10 @@ export async function revokeAppleToken(): Promise<'revoked' | 'not-applicable' | * stored token, and neither is a reason to leave someone half signed out. */ export async function firebaseLogout() { + // Before signing out: removing the device's push subscription needs the session that owns it. + // Imported lazily because web/lib/api imports this file. + const {removePushSubscription} = await import('web/lib/service/push-logout') + await removePushSubscription() if (isNativeApp()) { await Promise.all([ SocialLogin.logout({provider: 'google'}).catch((e) => debug('Google logout skipped', e)), diff --git a/web/lib/service/native-push.ts b/web/lib/service/native-push.ts index e7061b96..12504a4f 100644 --- a/web/lib/service/native-push.ts +++ b/web/lib/service/native-push.ts @@ -3,6 +3,8 @@ import {debug} from 'common/logger' import {useEffect} from 'react' import {useUser} from 'web/hooks/use-user' import {api} from 'web/lib/api' +import {NATIVE_PUSH_TOKEN_KEY} from 'web/lib/service/push-logout' +import {safeLocalStorage} from 'web/lib/util/local' import {isNativeApp, nativePlatform} from 'web/lib/util/webview' /** @@ -40,6 +42,8 @@ export default function NativePush() { platform: nativePlatform() === 'ios' ? 'ios' : 'android', }) debug('Mobile subscription saved:', data) + // Kept so sign-out can remove this device's row (see `removePushSubscription`). + safeLocalStorage?.setItem(NATIVE_PUSH_TOKEN_KEY, token.value) } catch (err) { console.error('Failed saving mobile push subscription', err) } diff --git a/web/lib/service/push-logout.ts b/web/lib/service/push-logout.ts new file mode 100644 index 00000000..53599179 --- /dev/null +++ b/web/lib/service/push-logout.ts @@ -0,0 +1,42 @@ +import {debug} from 'common/logger' +import {api} from 'web/lib/api' +import {safeLocalStorage} from 'web/lib/util/local' +import {isNativeApp} from 'web/lib/util/webview' + +/** Where `NativePush` keeps the device's push token, so sign-out can name the row to remove. */ +export const NATIVE_PUSH_TOKEN_KEY = 'native-push-token' + +/** + * Detaches this device from the member who is signing out. Must run before `auth.signOut()`: the + * server only removes the caller's own subscription, so it needs the session that owns it. + * + * The server no longer reassigns a token that already belongs to someone else (see + * `save-subscription*`), so without this a shared device would keep delivering the previous + * member's notifications, and the next member to sign in on it would get none. + * + * On the web the browser subscription itself is dropped too: `WebPush` does not save a subscription + * that already exists, so the next member would otherwise never register one. Best-effort + * throughout — nothing here is a reason to leave someone half signed out. + */ +export async function removePushSubscription() { + try { + if (isNativeApp()) { + const token = safeLocalStorage?.getItem(NATIVE_PUSH_TOKEN_KEY) + if (!token) return + await api('delete-subscription', {token}) + safeLocalStorage?.removeItem(NATIVE_PUSH_TOKEN_KEY) + return + } + + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return + const registration = await navigator.serviceWorker.getRegistration() + const subscription = await registration?.pushManager.getSubscription() + if (!subscription) return + await api('delete-subscription', {endpoint: subscription.endpoint}).catch((e) => + debug('Failed to remove push subscription on the server', e), + ) + await subscription.unsubscribe() + } catch (e) { + debug('Push subscription cleanup skipped', e) + } +} From 8de64720d83917f212384f57d5264dbd81364583 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 12:38:47 +0000 Subject: [PATCH 05/13] Redact disabled profiles in get_profile_by_user_id The anon-callable function already redacted members-only profiles, but a disabled profile with public visibility still came back in full. Redact those too, keeping the disabled flag so the page can still say so. Co-Authored-By: Claude Opus 5.5 --- backend/supabase/migration.sql | 1 + .../20261029_redact_disabled_profiles.sql | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 backend/supabase/migrations/20261029_redact_disabled_profiles.sql diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 29859353..9bbd305f 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -94,4 +94,5 @@ BEGIN; \i backend/supabase/migrations/20261028_drop_compatibility_answers_free.sql \i backend/supabase/migrations/20261028_lock_likes_ships.sql \i backend/supabase/migrations/20261028_lock_client_read_tables.sql +\i backend/supabase/migrations/20261029_redact_disabled_profiles.sql COMMIT; diff --git a/backend/supabase/migrations/20261029_redact_disabled_profiles.sql b/backend/supabase/migrations/20261029_redact_disabled_profiles.sql new file mode 100644 index 00000000..1d399fc2 --- /dev/null +++ b/backend/supabase/migrations/20261029_redact_disabled_profiles.sql @@ -0,0 +1,49 @@ +-- Disabled profiles must not be readable with the public anon key either. +-- Created: 2026-10-29 +-- +-- 20260901_redact_member_only_profiles.sql taught get_profile_by_user_id() to redact members-only +-- profiles, but a disabled profile with `visibility = 'public'` still came back in full. The API +-- (`getUserAndProfile`) already hides a disabled profile from everyone but its owner and admins, so +-- this function was the one road left to the whole row. +-- +-- The function runs on the anon key and cannot tell the owner or an admin from a stranger, so it +-- redacts for everyone, exactly as it does for members-only profiles. That costs nothing: the owner's +-- own profile is read through the authenticated `get-profile` endpoint (see web/hooks/use-profile.tsx), +-- and admins review profiles through the API too. The redacted row keeps `disabled`, which is how the +-- page still says "disabled" rather than pretending the account does not exist. + +create or replace function get_profile_by_user_id(uid text) + returns setof profiles + language plpgsql + stable + security definer + set search_path = public +as +$$ +declare + full_row profiles; + redacted_row profiles; +begin + select * into full_row from profiles where user_id = uid limit 1; + if not found then + return; + end if; + + if full_row.visibility <> 'member' and not full_row.disabled then + return next full_row; + return; + end if; + + -- Fields are copied across one by one rather than nulled out of a copy, so a column added to + -- `profiles` later is excluded by default instead of leaking until someone remembers this file. + redacted_row.user_id := full_row.user_id; + redacted_row.visibility := full_row.visibility; + redacted_row.disabled := full_row.disabled; + return next redacted_row; +end; +$$; + +grant execute on function get_profile_by_user_id(text) to anon, authenticated; + +-- Make PostgREST pick up the new definition immediately (Supabase also auto-reloads on DDL). +notify pgrst, 'reload schema'; From 7d15c990e847398094c4af303bec0ab4c36bfa27 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 12:39:00 +0000 Subject: [PATCH 06/13] Make ballots, anonymous proposal authors and stars private vote_results and votes were readable with the public anon key, exposing every member's ballot and the authors of anonymous proposals, and get_profile_stars told anyone who a member had starred. Drop the public-read policies and grants, and move the client's reads to: get_votes_with_results (security definer, creator hidden on anonymous proposals), get-my-vote-choices and get-my-stars endpoints for the caller's own data, and /stats for proposal and ballot counts. Co-Authored-By: Claude Opus 5.5 --- backend/api/src/app.ts | 4 + backend/api/src/get-my-stars.ts | 22 +++ backend/api/src/get-my-vote-choices.ts | 20 +++ backend/api/src/stats.ts | 9 ++ .../api/tests/unit/get-my-stars.unit.test.ts | 32 ++++ .../unit/get-my-vote-choices.unit.test.ts | 33 ++++ backend/supabase/migration.sql | 1 + .../20261029_private_votes_and_stars.sql | 141 ++++++++++++++++++ backend/supabase/vote_results.sql | 8 +- backend/supabase/votes.sql | 8 +- common/src/api/schema.ts | 18 +++ common/src/stats.ts | 5 + web/lib/supabase/stars.ts | 14 +- web/lib/supabase/votes.ts | 68 +++------ web/pages/stats.tsx | 19 ++- 15 files changed, 335 insertions(+), 67 deletions(-) create mode 100644 backend/api/src/get-my-stars.ts create mode 100644 backend/api/src/get-my-vote-choices.ts create mode 100644 backend/api/tests/unit/get-my-stars.unit.test.ts create mode 100644 backend/api/tests/unit/get-my-vote-choices.unit.test.ts create mode 100644 backend/supabase/migrations/20261029_private_votes_and_stars.sql diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 0f936991..9c84ad59 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -103,6 +103,8 @@ import {getEvents} from './get-events' import {getMe} from './get-me' import {getMyReferralCount} from './get-my-referral-count' import {getMyReferrals} from './get-my-referrals' +import {getMyStars} from './get-my-stars' +import {getMyVoteChoices} from './get-my-vote-choices' import {getNotifications} from './get-notifications' import {getOutreachQueue} from './get-outreach-queue' import {getOutreachStats} from './get-outreach-stats' @@ -690,6 +692,8 @@ const handlers: {[k in APIPath]: APIHandler} = { 'create-vote-comment': createVoteComment, 'delete-bookmarked-search': deleteBookmarkedSearch, 'get-bookmarked-searches': getBookmarkedSearches, + 'get-my-stars': getMyStars, + 'get-my-vote-choices': getMyVoteChoices, 'delete-compatibility-answer': deleteCompatibilityAnswer, 'delete-message': deleteMessage, 'edit-message': editMessage, diff --git a/backend/api/src/get-my-stars.ts b/backend/api/src/get-my-stars.ts new file mode 100644 index 00000000..e4aafca0 --- /dev/null +++ b/backend/api/src/get-my-stars.ts @@ -0,0 +1,22 @@ +import {type APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +/** + * The ids of the profiles the caller has starred, newest first. + * + * Takes no member id on purpose: who someone has saved is private to them. The web used to read this + * through `get_profile_stars(creator)`, which anyone could call with any member's id on the anon key. + */ +export const getMyStars: APIHandler<'get-my-stars'> = async (_props, auth) => { + const pg = createSupabaseDirectClient() + + const targetIds = await pg.map( + `select target_id from profile_stars + where creator_id = $1 + order by created_time desc`, + [auth.uid], + (r) => r.target_id, + ) + + return {targetIds} +} diff --git a/backend/api/src/get-my-vote-choices.ts b/backend/api/src/get-my-vote-choices.ts new file mode 100644 index 00000000..301e49a6 --- /dev/null +++ b/backend/api/src/get-my-vote-choices.ts @@ -0,0 +1,20 @@ +import {type APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +/** + * The caller's own ballot on every proposal, keyed by proposal id, so each card can highlight the + * button they picked. + * + * Takes no member id on purpose: ballots are private. The web used to read `vote_results` straight + * from PostgREST, where anyone with the public key could see how every member voted. + */ +export const getMyVoteChoices: APIHandler<'get-my-vote-choices'> = async (_props, auth) => { + const pg = createSupabaseDirectClient() + + const rows = await pg.manyOrNone<{vote_id: number; choice: number}>( + `select vote_id, choice from vote_results where user_id = $1`, + [auth.uid], + ) + + return {choices: Object.fromEntries(rows.map((r) => [r.vote_id, r.choice]))} +} diff --git a/backend/api/src/stats.ts b/backend/api/src/stats.ts index 86cf19c8..442fc130 100644 --- a/backend/api/src/stats.ts +++ b/backend/api/src/stats.ts @@ -149,6 +149,7 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => { countryStats, memberGrowthRows, activeMembersCount, + voteCounts, demographicResults, groupShareResults, ] = await Promise.all([ @@ -193,6 +194,12 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => { pg.one( `select count(*)::int as count from user_activity where last_online_time > now() - interval '30 days'`, ), + // Proposal and ballot counts for the democracy panel. Counted here because neither table is + // readable by the client roles any more: individual ballots and anonymous authors are private. + pg.one( + `select (select count(*)::int from votes) as proposals, + (select count(*)::int from vote_results) as ballots`, + ), Promise.all( demographicFields.map(([field, {multi}]) => field === 'age' ? ageDistribution(pg) : fieldDistribution(pg, field, multi), @@ -246,6 +253,8 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => { upcomingEvents: eventsCount.count, messages: messagesCount.count, conversations: conversationCount.count, + proposals: voteCounts.proposals, + ballots: voteCounts.ballots, genderRatio: genderPercentage, genderCounts: genderRatio, // The full ranked list, not a top-N: the /stats world map colours every country with members, and diff --git a/backend/api/tests/unit/get-my-stars.unit.test.ts b/backend/api/tests/unit/get-my-stars.unit.test.ts new file mode 100644 index 00000000..3705842c --- /dev/null +++ b/backend/api/tests/unit/get-my-stars.unit.test.ts @@ -0,0 +1,32 @@ +import {getMyStars} from 'api/get-my-stars' +import {AuthedUser} from 'api/helpers/endpoint' +import {sqlMatch} from 'common/test-utils' +import * as supabaseInit from 'shared/supabase/init' + +jest.mock('shared/supabase/init') + +describe('getMyStars', () => { + let mockPg = {} as any + + beforeEach(() => { + jest.resetAllMocks() + mockPg = {map: jest.fn()} + ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) + }) + afterEach(() => { + jest.restoreAllMocks() + }) + + it("returns the caller's own stars and nobody else's", async () => { + ;(mockPg.map as jest.Mock).mockResolvedValue(['b', 'a']) + + const result = await getMyStars({}, {uid: 'me'} as AuthedUser, {} as any) + + expect(mockPg.map).toBeCalledWith( + sqlMatch('from profile_stars where creator_id = $1 order by created_time desc'), + ['me'], + expect.any(Function), + ) + expect(result).toEqual({targetIds: ['b', 'a']}) + }) +}) diff --git a/backend/api/tests/unit/get-my-vote-choices.unit.test.ts b/backend/api/tests/unit/get-my-vote-choices.unit.test.ts new file mode 100644 index 00000000..abdbef56 --- /dev/null +++ b/backend/api/tests/unit/get-my-vote-choices.unit.test.ts @@ -0,0 +1,33 @@ +import {getMyVoteChoices} from 'api/get-my-vote-choices' +import {AuthedUser} from 'api/helpers/endpoint' +import {sqlMatch} from 'common/test-utils' +import * as supabaseInit from 'shared/supabase/init' + +jest.mock('shared/supabase/init') + +describe('getMyVoteChoices', () => { + let mockPg = {} as any + + beforeEach(() => { + jest.resetAllMocks() + mockPg = {manyOrNone: jest.fn()} + ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) + }) + afterEach(() => { + jest.restoreAllMocks() + }) + + it("returns the caller's own ballots keyed by proposal", async () => { + ;(mockPg.manyOrNone as jest.Mock).mockResolvedValue([ + {vote_id: 3, choice: 1}, + {vote_id: 7, choice: -1}, + ]) + + const result = await getMyVoteChoices({}, {uid: 'me'} as AuthedUser, {} as any) + + expect(mockPg.manyOrNone).toBeCalledWith(sqlMatch('from vote_results where user_id = $1'), [ + 'me', + ]) + expect(result).toEqual({choices: {3: 1, 7: -1}}) + }) +}) diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 9bbd305f..ae3a0c9b 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -95,4 +95,5 @@ BEGIN; \i backend/supabase/migrations/20261028_lock_likes_ships.sql \i backend/supabase/migrations/20261028_lock_client_read_tables.sql \i backend/supabase/migrations/20261029_redact_disabled_profiles.sql +\i backend/supabase/migrations/20261029_private_votes_and_stars.sql COMMIT; diff --git a/backend/supabase/migrations/20261029_private_votes_and_stars.sql b/backend/supabase/migrations/20261029_private_votes_and_stars.sql new file mode 100644 index 00000000..9c780866 --- /dev/null +++ b/backend/supabase/migrations/20261029_private_votes_and_stars.sql @@ -0,0 +1,141 @@ +-- Make ballots, anonymous proposal authors and stars private. +-- Created: 2026-10-29 +-- +-- Three reads were open to anyone holding the public anon key: +-- +-- vote_results "public read" policy: every member's ballot on every proposal. +-- votes "public read" policy: creator_id on every row, including the proposals +-- posted with is_anonymous = true. +-- get_profile_stars security definer, granted to anon, takes any member id: who anyone has saved. +-- +-- Same pattern as 20261028_lock_client_read_tables.sql: drop the public-read policies and revoke the +-- anon/authenticated SELECT grant. The client's remaining read shapes move to: +-- +-- proposal list + tallies get_votes_with_results(), now security definer and with creator_id +-- nulled on anonymous proposals. Tallies stay public; ballots do not. +-- viewer's own ballots the get-my-vote-choices API endpoint (needs the caller's identity, +-- which a function on the anon key does not have). +-- commenters' ballots get_vote_commenter_choices(), below. Shown next to a comment so readers +-- know which way its author voted; limited to members who commented on +-- that proposal, since they argued in public under their own name. +-- proposal/ballot counts the /stats endpoint. +-- viewer's own stars the get-my-stars API endpoint. get_profile_stars() is dropped. +-- +-- Writes to votes and vote_results already go through the service-role backend, which bypasses RLS and +-- keeps its grant. + +drop policy if exists "public read" on votes; +drop policy if exists "public read" on vote_results; + +revoke select on table votes, vote_results from anon, authenticated; + +-- Same columns and ranking as 20260808_vote_top_arguments.sql. Two changes: security definer, so the +-- client roles can still read the tallies without a grant on the tables, and creator_id is null when +-- the proposal is anonymous. +create or replace function get_votes_with_results( + order_by text default 'recent', + only_vote_id bigint default null +) + returns table + ( + id bigint, + title text, + description jsonb, + created_time timestamptz, + creator_id text, + is_anonymous boolean, + status text, + votes_for int, + votes_against int, + votes_abstain int, + priority int, + comment_count int, + top_for jsonb, + top_against jsonb + ) + language sql + stable + security definer + set search_path = public +as +$$ +with results as (select v.id, + v.title, + v.description, + v.created_time, + case when v.is_anonymous then null else v.creator_id end as creator_id, + v.is_anonymous, + v.status, + coalesce(sum(case when r.choice = 1 then 1 else 0 end), 0) as votes_for, + coalesce(sum(case when r.choice = -1 then 1 else 0 end), 0) as votes_against, + coalesce(sum(case when r.choice = 0 then 1 else 0 end), 0) as votes_abstain, + coalesce(sum(r.priority), 0)::float / + greatest(coalesce(sum(case when r.choice = 1 then 1 else 0 end), 1), 1) * 100 / + 3 as priority, + -- Counted in a subquery rather than another left join: joining both children + -- multiplies the rows and inflates every vote tally. + (select count(*) + from vote_comments c + where c.vote_id = v.id + and not c.hidden) as comment_count, + top_argument(v.id, 'for') as top_for, + top_argument(v.id, 'against') as top_against + from votes v + left join vote_results r on v.id = r.vote_id + where only_vote_id is null + or v.id = only_vote_id + group by v.id) +select id, + title, + description, + created_time, + creator_id, + is_anonymous, + status, + votes_for, + votes_against, + votes_abstain, + priority, + comment_count, + top_for, + top_against +from results +order by case when order_by = 'recent' then created_time end desc, + case when order_by = 'mostVoted' then (votes_for + votes_against + votes_abstain) end desc, + case when order_by = 'mostVoted' then created_time end desc, + case when order_by = 'mostDiscussed' then comment_count end desc, + case when order_by = 'mostDiscussed' then created_time end desc, + case when order_by = 'priority' then priority end desc, + case when order_by = 'priority' then created_time end desc; +$$; + +-- The ballot of each member with a visible comment on one proposal. Nobody else's. +create or replace function get_vote_commenter_choices(vid bigint) + returns table + ( + user_id text, + choice smallint + ) + language sql + stable + security definer + set search_path = public +as +$$ +select r.user_id, r.choice +from vote_results r +where r.vote_id = vid + and exists (select 1 + from vote_comments c + where c.vote_id = vid + and c.user_id = r.user_id + and not c.hidden); +$$; + +grant execute on function get_votes_with_results(text, bigint) to anon, authenticated; +grant execute on function get_vote_commenter_choices(bigint) to anon, authenticated; + +drop function if exists get_profile_stars(text); + +-- Make PostgREST pick up the changes immediately (Supabase also auto-reloads on DDL). +notify pgrst, 'reload schema'; diff --git a/backend/supabase/vote_results.sql b/backend/supabase/vote_results.sql index 4e566c45..6690c92b 100644 --- a/backend/supabase/vote_results.sql +++ b/backend/supabase/vote_results.sql @@ -18,10 +18,10 @@ add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes -- Row Level Security ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY; --- Policies -DROP POLICY IF EXISTS "public read" ON vote_results; -CREATE POLICY "public read" ON vote_results -FOR SELECT USING (true); +-- No SELECT policy: RLS is enabled with no read policy AND the anon/authenticated SELECT grant is +-- revoked (migration 20261029_private_votes_and_stars.sql dropped the former "public read" policy). +-- Ballots and anonymous proposal authors are private; the client reads tallies through the security +-- definer get_votes_with_results(). -- Indexes CREATE INDEX IF NOT EXISTS user_id_idx ON vote_results (user_id); diff --git a/backend/supabase/votes.sql b/backend/supabase/votes.sql index 0a2c0564..ee39ba88 100644 --- a/backend/supabase/votes.sql +++ b/backend/supabase/votes.sql @@ -15,10 +15,10 @@ add constraint votes_creator_id_fkey foreign key (creator_id) references users ( -- Row Level Security ALTER TABLE votes ENABLE ROW LEVEL SECURITY; --- Policies -DROP POLICY IF EXISTS "public read" ON votes; -CREATE POLICY "public read" ON votes -FOR SELECT USING (true); +-- No SELECT policy: RLS is enabled with no read policy AND the anon/authenticated SELECT grant is +-- revoked (migration 20261029_private_votes_and_stars.sql dropped the former "public read" policy). +-- Ballots and anonymous proposal authors are private; the client reads tallies through the security +-- definer get_votes_with_results(). -- Indexes CREATE INDEX IF NOT EXISTS creator_id_idx ON votes (creator_id); diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 6f39fc54..f0d5d5f1 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -843,6 +843,15 @@ export const API = (_apiTypeCheck = { summary: 'Star or unstar a profile', tag: 'Relations', }, + 'get-my-stars': { + method: 'GET', + authed: true, + rateLimited: true, + props: z.object({}).strict(), + returns: {} as {targetIds: string[]}, + summary: 'Ids of the profiles the caller has starred, newest first', + tag: 'Relations', + }, 'hide-profile': { method: 'POST', authed: true, @@ -1473,6 +1482,15 @@ export const API = (_apiTypeCheck = { summary: 'Mute or unmute notifications for a proposal discussion', tag: 'Votes', }, + 'get-my-vote-choices': { + method: 'GET', + authed: true, + rateLimited: true, + props: z.object({}).strict(), + returns: {} as {choices: Record}, + summary: "The caller's own ballot on every proposal: 1 for, 0 abstain, -1 against", + tag: 'Votes', + }, 'get-vote-mute': { method: 'GET', authed: true, diff --git a/common/src/stats.ts b/common/src/stats.ts index 67402194..815ac4e1 100644 --- a/common/src/stats.ts +++ b/common/src/stats.ts @@ -73,6 +73,11 @@ export type Stats = { messages: number /** Message channels that carry at least one message — empty channels aren't conversations. */ conversations: number + /** Proposals on /vote. Counted server-side: `votes` is not readable by the client roles. */ + proposals: number + /** Ballots cast across all proposals. Counted server-side: `vote_results` is not readable by the + * client roles. */ + ballots: number genderRatio: Record genderCounts: Record /** Every country with at least one member, by profile count descending. Profiles with no country set diff --git a/web/lib/supabase/stars.ts b/web/lib/supabase/stars.ts index c355366f..dc1cac80 100644 --- a/web/lib/supabase/stars.ts +++ b/web/lib/supabase/stars.ts @@ -1,16 +1,16 @@ import {DisplayUser} from 'common/api/user-types' import {convertPartialUser} from 'common/supabase/users' import {run} from 'common/supabase/utils' +import {api} from 'web/lib/api' import {db} from 'web/lib/supabase/db' -export const getStars = async (creatorId: string) => { - // Direct SELECT on profile_stars is revoked from the anon/authenticated roles; read this creator's - // stars through the capped get_profile_stars() SECURITY DEFINER function (ordered newest-first). - const {data} = await run(db.rpc('get_profile_stars' as any, {creator: creatorId})) +// `_userId` is only there to key the cache on the signed-in member, so switching accounts refetches. +// The stars are always the caller's own: who someone has saved is private, so the API takes no member +// id (the get_profile_stars() function this replaced took any member's id on the anon key). +export const getStars = async (_userId: string) => { + const {targetIds: ids} = await api('get-my-stars', {}) + if (!ids.length) return [] - if (!data) return [] - - const ids = (data as any[]).map((d) => d.target_id as string) // Direct SELECT on `users` is revoked from the anon/authenticated roles (bulk-read cap); go through // the capped get_display_users() function. const {data: users} = await run(db.rpc('get_display_users' as any, {ids})) diff --git a/web/lib/supabase/votes.ts b/web/lib/supabase/votes.ts index c4996855..3f308488 100644 --- a/web/lib/supabase/votes.ts +++ b/web/lib/supabase/votes.ts @@ -1,4 +1,5 @@ import {OrderBy} from 'common/votes/constants' +import {api} from 'web/lib/api' import {db} from 'web/lib/supabase/db' export const getVotes = async (params: {orderBy: OrderBy}) => { @@ -25,62 +26,41 @@ export const getVote = async (params: {voteId: number}) => { } // What the `` of a proposal page needs, read on the server before the page is sent — which -// means it is read as the anon role, since nobody is signed in at that point. -// -// `get_votes_with_results` can't be used here even though it returns a superset: it joins -// `vote_comments` to rank the highlighted arguments, and anon has no grant on that table, so the -// whole call errors rather than coming back without them. The proposal row and the ballots are both -// anon-readable, and the tallies are the same arithmetic the function does. +// means it is read as the anon role, since nobody is signed in at that point. Same function as the +// page itself; it is security definer, so the anon role can call it without reading the tables. export const getVoteMeta = async (params: {voteId: number}) => { - const {voteId} = params - const [vote, results] = await Promise.all([ - db.from('votes').select('id, title, description, status').eq('id', voteId).limit(1), - db.from('vote_results').select('choice').eq('vote_id', voteId), - ]) - if (vote.error) throw vote.error - if (results.error) throw results.error - - const row = vote.data?.[0] - if (!row) return null + const vote = await getVote(params) + if (!vote) return null - const choices = (results.data ?? []).map((r) => r.choice) - return { - ...row, - votes_for: choices.filter((c) => c === 1).length, - votes_against: choices.filter((c) => c === -1).length, - votes_abstain: choices.filter((c) => c === 0).length, + const {id, title, description, status, votes_for, votes_against, votes_abstain} = vote as any + return {id, title, description, status, votes_for, votes_against, votes_abstain} as { + id: number + title: string + description: any + status: string | null + votes_for: number + votes_against: number + votes_abstain: number } } export type VoteMeta = NonNullable>> // The viewer's own ballot on every proposal, so each card can highlight the button they picked. One -// query for the whole list rather than a per-card lookup — it's a single index scan on `user_id`, and -// the alternative is teaching `get_votes_with_results` about the caller, which it has no way to know -// (the list is fetched with the anon key, not as the signed-in Postgres role). -export const getMyVoteChoices = async (params: {userId: string}) => { - const {userId} = params - const {data, error} = await db - .from('vote_results') - .select('vote_id, choice') - .eq('user_id', userId) - if (error) throw error - - return Object.fromEntries((data ?? []).map((r) => [r.vote_id, r.choice])) as Record< - number, - number - > +// call for the whole list rather than a per-card lookup. Ballots are private, so this goes through +// the API as the caller; `userId` only keys the cache, so signing in as someone else refetches. +export const getMyVoteChoices = async (_params: {userId: string}) => { + const {choices} = await api('get-my-vote-choices', {}) + return choices } -// The author's own recorded vote, shown next to their comments so a reader can see whether an -// argument comes from someone who ended up voting for or against. +// The recorded vote of each member who commented on the proposal, shown next to their comments so a +// reader can see whether an argument comes from someone who ended up voting for or against. Only +// commenters: they argued in public under their name. Everyone else's ballot stays private. export const getVoteResultsByUser = async (params: {voteId: number}) => { const {voteId} = params - const {data, error} = await db - .from('vote_results') - .select('user_id, choice') - .eq('vote_id', voteId) + const {data, error} = await db.rpc('get_vote_commenter_choices' as any, {vid: voteId}) if (error) throw error - return Object.fromEntries((data ?? []).map((r) => [r.user_id, r.choice])) + return Object.fromEntries(((data ?? []) as any[]).map((r) => [r.user_id, r.choice])) } diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 334ee60d..aa9f9e6b 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -203,10 +203,14 @@ function ActivitySection({ data, discussions, messages, + proposals, + ballots, }: { data: Record discussions: number | null | undefined messages: number | null | undefined + proposals: number | null | undefined + ballots: number | null | undefined }) { const t = useT() const panels = [ @@ -233,8 +237,8 @@ function ActivitySection({ icon: BuildingLibraryIcon, title: t('stats.group.democracy', 'Democracy'), rows: [ - {label: t('stats.proposals', 'Proposals'), value: data.votes}, - {label: t('stats.votes', 'Votes cast'), value: data.vote_results}, + {label: t('stats.proposals', 'Proposals'), value: proposals}, + {label: t('stats.votes', 'Votes cast'), value: ballots}, ], }, ].filter((p) => p.rows.some((r) => !!r.value)) @@ -408,12 +412,9 @@ export default function Stats() { // (`conversations` = channels with at least one message), so the stats page and the // home/about stat bands report the same number. Counting the channels table instead also // counted channels created but never written in, which read higher than the about page. - const tables = [ - 'compatibility_prompts', - 'compatibility_answers', - 'votes', - 'vote_results', - ] as const + // No 'votes' or 'vote_results' either: neither is readable by the client roles (ballots and + // anonymous authors are private), so both counts come from /stats. + const tables = ['compatibility_prompts', 'compatibility_answers'] as const const [settled, statsResult] = await Promise.allSettled([ Promise.allSettled(tables.map((tbl) => getCount(tbl))), @@ -503,6 +504,8 @@ export default function Stats() { data={data} discussions={statsData?.conversations} messages={statsData?.messages} + proposals={statsData?.proposals} + ballots={statsData?.ballots} /> )} From e132a719c5f8f1160299af7e1e97d450ea859e89 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 12:39:37 +0000 Subject: [PATCH 07/13] Freeze message edits and deletes once a member left or is blocked Authorship alone let a member rewrite or delete their messages after leaving the chat or being blocked, e.g. to alter the messages the other person was about to report. edit-message and delete-message now share getChangeableOwnMessage, which applies the same membership and block checks as posting. Co-Authored-By: Claude Opus 5.5 --- backend/api/src/delete-message.ts | 17 ++---- backend/api/src/edit-message.ts | 19 ++----- backend/api/src/helpers/own-message.ts | 52 +++++++++++++++++ .../tests/unit/delete-message.unit.test.ts | 8 ++- .../api/tests/unit/edit-message.unit.test.ts | 12 ++-- .../api/tests/unit/own-message.unit.test.ts | 56 +++++++++++++++++++ 6 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 backend/api/src/helpers/own-message.ts create mode 100644 backend/api/tests/unit/own-message.unit.test.ts diff --git a/backend/api/src/delete-message.ts b/backend/api/src/delete-message.ts index 785ea371..426b386f 100644 --- a/backend/api/src/delete-message.ts +++ b/backend/api/src/delete-message.ts @@ -1,7 +1,8 @@ +import {getChangeableOwnMessage} from 'api/helpers/own-message' import {broadcastPrivateMessages} from 'api/helpers/private-messages' import {createSupabaseDirectClient} from 'shared/supabase/init' -import {APIErrors, APIHandler} from './helpers/endpoint' +import {APIHandler} from './helpers/endpoint' // const DELETED_MESSAGE_CONTENT: JSONContent = { // type: 'doc', @@ -21,18 +22,8 @@ import {APIErrors, APIHandler} from './helpers/endpoint' export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, auth) => { const pg = createSupabaseDirectClient() - // Verify user is the message author and message is not too old - const message = await pg.oneOrNone( - `SELECT * - FROM private_user_messages - WHERE id = $1 - AND user_id = $2`, - [messageId, auth.uid], - ) - - if (!message) { - throw APIErrors.notFound('Message not found') - } + // Verify user is the message author, is still in the chat and is not blocked by anyone in it + const message = await getChangeableOwnMessage(pg, messageId, auth.uid) // Soft delete the message // await pg.none( diff --git a/backend/api/src/edit-message.ts b/backend/api/src/edit-message.ts index 571242ad..5a8fa5c2 100644 --- a/backend/api/src/edit-message.ts +++ b/backend/api/src/edit-message.ts @@ -1,26 +1,15 @@ +import {getChangeableOwnMessage} from 'api/helpers/own-message' import {broadcastPrivateMessages} from 'api/helpers/private-messages' import {encryptMessage} from 'shared/encryption' import {createSupabaseDirectClient} from 'shared/supabase/init' -import {APIErrors, APIHandler} from './helpers/endpoint' +import {APIHandler} from './helpers/endpoint' export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => { const pg = createSupabaseDirectClient() - // Verify user is the message author and message is not too old - const message = await pg.oneOrNone( - `SELECT * - FROM private_user_messages - WHERE id = $1 - AND user_id = $2 --- AND created_time > NOW() - INTERVAL '1 day' - AND deleted = FALSE`, - [messageId, auth.uid], - ) - - if (!message) { - throw APIErrors.notFound('Message not found or cannot be edited') - } + // Verify user is the message author, is still in the chat and is not blocked by anyone in it + const message = await getChangeableOwnMessage(pg, messageId, auth.uid, {includeDeleted: false}) const plaintext = JSON.stringify(content) const {ciphertext, iv, tag} = encryptMessage(plaintext) diff --git a/backend/api/src/helpers/own-message.ts b/backend/api/src/helpers/own-message.ts new file mode 100644 index 00000000..e662baf6 --- /dev/null +++ b/backend/api/src/helpers/own-message.ts @@ -0,0 +1,52 @@ +import {assertNotBlocked} from 'api/helpers/blocks' +import {APIErrors} from 'api/helpers/endpoint' +import {SupabaseDirectClient} from 'shared/supabase/init' + +/** + * The message `userId` wants to edit or delete, provided they still may. + * + * Authorship alone is not enough. Once a member has left the chat, or a block stands between them + * and anyone else in it, the thread is frozen for them: they cannot post (see + * `createPrivateUserMessageMain` and `create-private-user-message.ts`), and they cannot rewrite or + * remove what they already said either. Otherwise a harasser who had just been blocked could go back + * and edit or delete the very messages the other person is about to report. + * + * The other members are taken whether or not they have left, the same set `create-private-user-message` + * checks: a block against someone who already walked away still counts. + */ +export const getChangeableOwnMessage = async ( + pg: SupabaseDirectClient, + messageId: number, + userId: string, + {includeDeleted = true}: {includeDeleted?: boolean} = {}, +) => { + const message = await pg.oneOrNone<{ + channel_id: number + member_status: string | null + other_member_ids: string[] + }>( + `select msg.*, + m.status as member_status, + coalesce((select array_agg(o.user_id) + from private_user_message_channel_members o + where o.channel_id = msg.channel_id + and o.user_id != $2), '{}') as other_member_ids + from private_user_messages msg + left join private_user_message_channel_members m + on m.channel_id = msg.channel_id and m.user_id = $2 + where msg.id = $1 + and msg.user_id = $2 + and ($3 or msg.deleted = false)`, + [messageId, userId, includeDeleted], + ) + + if (!message) throw APIErrors.notFound('Message not found') + + if (!message.member_status || message.member_status === 'left') { + throw APIErrors.forbidden('You are no longer in this chat') + } + + await assertNotBlocked(userId, message.other_member_ids) + + return message +} diff --git a/backend/api/tests/unit/delete-message.unit.test.ts b/backend/api/tests/unit/delete-message.unit.test.ts index fe11ae3f..835d5a0f 100644 --- a/backend/api/tests/unit/delete-message.unit.test.ts +++ b/backend/api/tests/unit/delete-message.unit.test.ts @@ -1,5 +1,6 @@ jest.mock('shared/supabase/init') jest.mock('api/helpers/private-messages') +jest.mock('api/helpers/blocks') import {deleteMessage} from 'api/delete-message' import {AuthedUser} from 'api/helpers/endpoint' @@ -28,6 +29,8 @@ describe('deleteMessage', () => { } const mockMessage = { channel_id: 'mockChannelId', + member_status: 'member', + other_member_ids: ['456'], } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any @@ -40,9 +43,10 @@ describe('deleteMessage', () => { expect(results.success).toBeTruthy() expect(mockPg.oneOrNone).toBeCalledTimes(1) - expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('SELECT *'), [ + expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('from private_user_messages msg'), [ mockMessageId.messageId, mockAuth.uid, + true, ]) expect(mockPg.none).toBeCalledTimes(1) expect(mockPg.none).toBeCalledWith(sqlMatch('DELETE'), [ @@ -76,6 +80,8 @@ describe('deleteMessage', () => { } const mockMessage = { channel_id: 'mockChannelId', + member_status: 'member', + other_member_ids: ['456'], } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any diff --git a/backend/api/tests/unit/edit-message.unit.test.ts b/backend/api/tests/unit/edit-message.unit.test.ts index 387c46c7..505db330 100644 --- a/backend/api/tests/unit/edit-message.unit.test.ts +++ b/backend/api/tests/unit/edit-message.unit.test.ts @@ -1,6 +1,7 @@ jest.mock('shared/supabase/init') jest.mock('shared/encryption') jest.mock('api/helpers/private-messages') +jest.mock('api/helpers/blocks') import {editMessage} from 'api/edit-message' import {AuthedUser} from 'api/helpers/endpoint' @@ -32,6 +33,8 @@ describe('editMessage', () => { const mockPlainTextContent = JSON.stringify(mockProps.content) const mockMessage = { channel_id: 'mockChannelId', + member_status: 'member', + other_member_ids: ['456'], } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any @@ -53,9 +56,10 @@ describe('editMessage', () => { expect(result.success).toBeTruthy() expect(mockPg.oneOrNone).toBeCalledTimes(1) - expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('SELECT *'), [ + expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('from private_user_messages msg'), [ mockProps.messageId, mockAuth.uid, + false, ]) expect(encryptionModules.encryptMessage).toBeCalledTimes(1) expect(encryptionModules.encryptMessage).toBeCalledWith(mockPlainTextContent) @@ -86,9 +90,7 @@ describe('editMessage', () => { ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(null) - expect(editMessage(mockProps, mockAuth, mockReq)).rejects.toThrow( - 'Message not found or cannot be edited', - ) + expect(editMessage(mockProps, mockAuth, mockReq)).rejects.toThrow('Message not found') }) it('should throw if the message broadcast failed', async () => { @@ -98,6 +100,8 @@ describe('editMessage', () => { } const mockMessage = { channel_id: 'mockChannelId', + member_status: 'member', + other_member_ids: ['456'], } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any diff --git a/backend/api/tests/unit/own-message.unit.test.ts b/backend/api/tests/unit/own-message.unit.test.ts new file mode 100644 index 00000000..1a402adc --- /dev/null +++ b/backend/api/tests/unit/own-message.unit.test.ts @@ -0,0 +1,56 @@ +jest.mock('api/helpers/blocks') + +import * as blocks from 'api/helpers/blocks' +import {getChangeableOwnMessage} from 'api/helpers/own-message' + +describe('getChangeableOwnMessage', () => { + let mockPg = {} as any + beforeEach(() => { + jest.resetAllMocks() + mockPg = {oneOrNone: jest.fn()} + }) + + it('returns the message for an author still in the chat and not blocked', async () => { + const message = {id: 1, channel_id: 7, member_status: 'member', other_member_ids: ['456']} + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(message) + + await expect(getChangeableOwnMessage(mockPg, 1, '321')).resolves.toBe(message) + expect(blocks.assertNotBlocked).toBeCalledWith('321', ['456']) + }) + + it('throws when the message is not the caller’s', async () => { + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(null) + + await expect(getChangeableOwnMessage(mockPg, 1, '321')).rejects.toThrow('Message not found') + }) + + // A member who left could otherwise go back and rewrite what they said before leaving. + it('throws once the author has left the chat', async () => { + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({ + channel_id: 7, + member_status: 'left', + other_member_ids: ['456'], + }) + + await expect(getChangeableOwnMessage(mockPg, 1, '321')).rejects.toThrow( + 'You are no longer in this chat', + ) + expect(blocks.assertNotBlocked).not.toBeCalled() + }) + + // A blocked harasser could otherwise edit or delete the messages about to be reported. + it('throws when a block stands between the author and another member', async () => { + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({ + channel_id: 7, + member_status: 'member', + other_member_ids: ['456'], + }) + ;(blocks.assertNotBlocked as jest.Mock).mockRejectedValue( + new Error('You can no longer interact with this person'), + ) + + await expect(getChangeableOwnMessage(mockPg, 1, '321')).rejects.toThrow( + 'You can no longer interact with this person', + ) + }) +}) From e89c6aaf6be9f609b27a9ead9c76d9242dcd7cb1 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 12:40:01 +0000 Subject: [PATCH 08/13] Restrict compatibility ordering for other members to admins get-profiles with compatibleWithUserId set to someone else paged through that member's compatibility scores, bypassing the block and visibility checks of compatible-profiles. It now answers 403 unless the caller is that member or an admin. Co-Authored-By: Claude Opus 5.5 --- backend/api/src/get-profiles.ts | 13 +++++++- .../api/tests/unit/get-profiles.unit.test.ts | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index af0b428a..2390df2e 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/node' import {type JSONContent} from '@tiptap/core' -import {type APIHandler} from 'api/helpers/endpoint' +import {APIErrors, type APIHandler} from 'api/helpers/endpoint' import { DIET_CHOICES, EDUCATION_CHOICES, @@ -18,6 +18,7 @@ import { ROMANTIC_CHOICES, } from 'common/choices' import {MIN_BIO_LENGTH} from 'common/constants' +import {isAdminId} from 'common/envs/constants' import {OptionTableKey} from 'common/profiles/constants' import {parseJsonContentToText} from 'common/util/parse' import {getWantsKidsRange} from 'common/wants-kids' @@ -1173,6 +1174,16 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC } export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => { + // Ordering by someone else's compatibility scores is an admin tool. For anyone else it would page + // through another member's scores, skipping the block and visibility checks that + // `compatible-profiles` applies. Checked outside the try so it answers 403 rather than an empty page. + if ( + props.compatibleWithUserId && + props.compatibleWithUserId !== auth.uid && + !isAdminId(auth.uid) + ) { + throw APIErrors.forbidden('Only admins can view compatibility scores for another member') + } try { if (!props.skipId) props.skipId = auth.uid const {profiles, count} = await loadProfiles({...props, userId: auth.uid}) diff --git a/backend/api/tests/unit/get-profiles.unit.test.ts b/backend/api/tests/unit/get-profiles.unit.test.ts index 3347ce99..86e1778a 100644 --- a/backend/api/tests/unit/get-profiles.unit.test.ts +++ b/backend/api/tests/unit/get-profiles.unit.test.ts @@ -69,6 +69,38 @@ describe('getProfiles', () => { expect(profilesModule.loadProfiles).toHaveBeenCalledTimes(1) }) }) + + describe('when ordering by compatibility score', () => { + const props = { + limit: 20, + orderBy: 'compatibility_score' as const, + compatibleWithUserId: 'someone-else', + projection: 'card' as const, + } + + it("refuses a non-admin asking for another member's scores", async () => { + jest.spyOn(profilesModule, 'loadProfiles').mockResolvedValue({profiles: [], count: 0}) + + await expect( + profilesModule.getProfiles(props as any, {uid: 'me'} as any, {} as any), + ).rejects.toMatchObject({code: 403}) + expect(profilesModule.loadProfiles).not.toHaveBeenCalled() + }) + + it('lets a member order by their own scores', async () => { + jest.spyOn(profilesModule, 'loadProfiles').mockResolvedValue({profiles: [], count: 0}) + + await profilesModule.getProfiles( + {...props, compatibleWithUserId: 'me'} as any, + {uid: 'me'} as any, + {} as any, + ) + + expect(profilesModule.loadProfiles).toHaveBeenCalledWith( + expect.objectContaining({compatibleWithUserId: 'me'}), + ) + }) + }) }) describe('loadProfiles', () => { From 5b4a4eea415732e05d282a1a5f665766d45512aa Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 12:40:28 +0000 Subject: [PATCH 09/13] Regenerate Supabase types Picks up banned_networks and donations, and drops compatibility_answers_free. Co-Authored-By: Claude Opus 5.5 --- common/src/supabase/schema.ts | 130 ++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 46 deletions(-) diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index c0f325aa..be4348f3 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -4,10 +4,48 @@ export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: '14.5' + PostgrestVersion: '13.0.4' } public: { Tables: { + banned_networks: { + Row: { + created_at: string + device_token: string | null + expires_at: string | null + id: number + network: unknown + note: string | null + source_user_id: string | null + } + Insert: { + created_at?: string + device_token?: string | null + expires_at?: string | null + id?: never + network?: unknown + note?: string | null + source_user_id?: string | null + } + Update: { + created_at?: string + device_token?: string | null + expires_at?: string | null + id?: never + network?: unknown + note?: string | null + source_user_id?: string | null + } + Relationships: [ + { + foreignKeyName: 'banned_networks_source_user_id_fkey' + columns: ['source_user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } blog_posts: { Row: { author_id: string | null @@ -234,51 +272,6 @@ export type Database = { }, ] } - compatibility_answers_free: { - Row: { - created_time: string - creator_id: string - free_response: string | null - id: number - integer: number | null - multiple_choice: number | null - question_id: number - } - Insert: { - created_time?: string - creator_id: string - free_response?: string | null - id?: number - integer?: number | null - multiple_choice?: number | null - question_id: number - } - Update: { - created_time?: string - creator_id?: string - free_response?: string | null - id?: number - integer?: number | null - multiple_choice?: number | null - question_id?: number - } - Relationships: [ - { - foreignKeyName: 'compatibility_answers_free_creator_id_fkey' - columns: ['creator_id'] - isOneToOne: false - referencedRelation: 'users' - referencedColumns: ['id'] - }, - { - foreignKeyName: 'compatibility_answers_free_question_id_fkey' - columns: ['question_id'] - isOneToOne: false - referencedRelation: 'compatibility_prompts' - referencedColumns: ['id'] - }, - ] - } compatibility_prompts: { Row: { answer_count: number | null @@ -537,6 +530,51 @@ export type Database = { } Relationships: [] } + donations: { + Row: { + admin_id: string | null + amount_cents: number + created_time: string + donated_at: string + id: number + note: string | null + user_id: string + } + Insert: { + admin_id?: string | null + amount_cents: number + created_time?: string + donated_at?: string + id?: never + note?: string | null + user_id: string + } + Update: { + admin_id?: string | null + amount_cents?: number + created_time?: string + donated_at?: string + id?: never + note?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: 'donations_admin_id_fkey' + columns: ['admin_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'donations_user_id_fkey' + columns: ['user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } email_unsubscribe_tokens: { Row: { created_at: string From e0e59f5e6101d9cf774c3289445f7211df0f678e Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 13:01:24 +0000 Subject: [PATCH 10/13] Update favicon path in production config --- common/src/envs/prod.ts | 2 +- web/public/favicon.svg | 104 ++++++++-------------------------------- 2 files changed, 20 insertions(+), 86 deletions(-) diff --git a/common/src/envs/prod.ts b/common/src/envs/prod.ts index 0e9d79a3..eacff3f6 100644 --- a/common/src/envs/prod.ts +++ b/common/src/envs/prod.ts @@ -57,7 +57,7 @@ export const PROD_CONFIG: EnvConfig = { adminIds: [ '0vaZsIJk9zLVOWY4gb61gTrRIU73', // Martin ], - faviconPath: '/favicon-tab.svg', + faviconPath: '/favicon.svg', } export const refreshConfig = () => { diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 7e378604..83c2d3a0 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,86 +1,20 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + From 804231694f63b1eeaf7d4bfa95577e74038265dc Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 13:11:19 +0000 Subject: [PATCH 11/13] Add native app-specific navigation items to sidebar --- web/components/page-base.tsx | 58 +++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/web/components/page-base.tsx b/web/components/page-base.tsx index 7cc684e3..1717f208 100644 --- a/web/components/page-base.tsx +++ b/web/components/page-base.tsx @@ -1,7 +1,10 @@ import { + GlobeAltIcon, HandThumbUpIcon, HomeIcon, + InformationCircleIcon, LifebuoyIcon, + LinkIcon, NewspaperIcon, PencilSquareIcon, QuestionMarkCircleIcon, @@ -36,6 +39,7 @@ import {useProfile} from 'web/hooks/use-profile' import {useTracking} from 'web/hooks/use-tracking' import {useUser} from 'web/hooks/use-user' import {GoogleOneTapLogin} from 'web/lib/firebase/google-onetap-login' +import {isNativeApp} from 'web/lib/util/webview' import Sidebar from './nav/sidebar' import {NotificationsIcon, SolidNotificationsIcon} from './notifications-icon' @@ -154,15 +158,15 @@ const Home = { href: '/', icon: HomeIcon, } -// Out of the rail: it is in the footer and linked from About, and a thirteen-row rail hid the four -// rows that matter. Re-add to the "what Compass is" cluster below (with the SolidQuestionIcon -// import) if it earns its place back. -// const faq = { -// key: 'nav.faq', -// name: 'FAQ', -// href: '/faq', -// icon: SolidQuestionIcon, -// } +// Out of the rail: it is in the footer and linked from Help, and a thirteen-row rail hid the four +// rows that matter. Still defined for the native drawer's `appOnly` cluster below, since the apps +// have no footer. +const Faq = { + key: 'nav.faq', + name: 'FAQ', + href: '/faq', + icon: InformationCircleIcon, +} const About = { key: 'nav.about', name: 'About', @@ -187,19 +191,20 @@ const NotifsSolid = { href: `/notifications`, icon: SolidNotificationsIcon, } -// const Social = { -// key: 'nav.social', -// name: 'Socials', -// href: '/social', -// icon: LinkIcon, -// } -// Out of the rail for the same reason as FAQ; the footer's "Organization" column keeps the path. -// const Organization = { -// key: 'nav.organization', -// name: 'Organization', -// href: '/organization', -// icon: GlobeAltIcon, -// } +const Social = { + key: 'nav.social', + name: 'Socials', + href: '/social', + icon: LinkIcon, +} +// Out of the rail for the same reason as FAQ; the footer's "Organization" column keeps the path on +// the web, the native drawer's `appOnly` cluster in the apps. +const Organization = { + key: 'nav.organization', + name: 'Organization', + href: '/organization', + icon: GlobeAltIcon, +} const Vote = { key: 'nav.vote', name: 'Vote', @@ -315,10 +320,15 @@ const getDesktopNavigation = (user: User | null | undefined): NavGroups => ({ utility: user ? buildArray(Settings, Help, isAdminId(user.id) && Admin) : [Help], }) +// The rows the native apps would otherwise lose: on the web they live in the footer, which the apps +// don't render, and nothing else links to /organization or /social. +const appOnly = [Faq, Organization, Social] + // The drawer behind the bottom bar's menu button. People, Messages, Notifs and Profile are already -// on the bar itself, so only Invite is left of the daily-use band. +// on the bar itself, so only Invite is left of the daily-use band. Reading `isNativeApp()` here is +// hydration-safe: the drawer only mounts once opened, long after the first client render. const getMobileSidebar = (user: User | null | undefined, _toggleModal: () => void): NavGroups => ({ primary: user ? [Invite] : [], - secondary, + secondary: isNativeApp() ? [...secondary, appOnly] : secondary, utility: user ? buildArray(Settings, Help, isAdminId(user.id) && Admin) : [Help], }) From da6ce39dae2769f987d2d4e6fa8b8fc00acef560 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 13:46:08 +0000 Subject: [PATCH 12/13] Refine seeking display order, group filters by priority, and expand translations. --- common/messages/de.json | 1 + common/messages/fr.json | 1 + common/src/choices.ts | 6 +- docs/filters.md | 7 ++ web/components/filters/filters.tsx | 156 +++++++++++++++-------------- web/lib/profile/seeking.ts | 20 ++-- 6 files changed, 106 insertions(+), 85 deletions(-) diff --git a/common/messages/de.json b/common/messages/de.json index e532ea24..7100f312 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -505,6 +505,7 @@ "filter.group.background": "Hintergrund", "filter.group.display": "Darstellung", "filter.group.lifestyle": "Lebensstil", + "filter.group.more": "Weitere Filter", "filter.group.personality": "Persönlichkeit", "filter.group.relationship": "Beziehung", "filter.group.values": "Werte & Überzeugungen", diff --git a/common/messages/fr.json b/common/messages/fr.json index ac88cf61..fcd088ba 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -505,6 +505,7 @@ "filter.group.background": "Milieu", "filter.group.display": "Affichage", "filter.group.lifestyle": "Mode de vie", + "filter.group.more": "Plus de filtres", "filter.group.personality": "Personnalité", "filter.group.relationship": "Amour & Famille", "filter.group.values": "Valeurs & Croyances", diff --git a/common/src/choices.ts b/common/src/choices.ts index 8ad6fa0b..bb73740e 100644 --- a/common/src/choices.ts +++ b/common/src/choices.ts @@ -1,10 +1,12 @@ import {invert} from 'lodash' +// Key order is display order everywhere connection types are listed (checkboxes, connect buttons, +// the "Looking for" text). export const RELATIONSHIP_CHOICES = { // Other: 'other', - Collaboration: 'collaboration', - Friendship: 'friendship', Relationship: 'relationship', + Friendship: 'friendship', + Collaboration: 'collaboration', } as const // Values match the `profile_visibility` enum on `profiles`. Labels describe the *audience*, not the diff --git a/docs/filters.md b/docs/filters.md index 17a56438..0dd33b3e 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -26,6 +26,13 @@ - Big Five — ranges of the Big Five personality traits. - Last Active — how recently they were active on the platform. +The panel splits them in two. The likely dealbreakers are always visible: Seeking, Age, Gender, Living, +plus Status, Style and Wants Kids for members looking for a relationship. These match +`CORE_FILTER_KEYS` in `profile-grid.tsx`, which the empty state keeps when it suggests what to relax; +Languages and Orientation are the exceptions and count as refinements in the panel. Everything else is a +nice-to-have and sits under "More filters", in collapsed groups (Background, Lifestyle, Values & +Beliefs, Personality, Advanced). + Two toggles sit above the list, both of which write a whole search rather than one field: - **Who I'm looking for** — copies the "Who I'm looking for" section of your own profile into the diff --git a/web/components/filters/filters.tsx b/web/components/filters/filters.tsx index 5be442c8..91b0a533 100644 --- a/web/components/filters/filters.tsx +++ b/web/components/filters/filters.tsx @@ -312,32 +312,6 @@ function Filters(props: { - {/* LOCATION - Always visible */} - - } - > - updateFilter({country}), - }} - /> - - {/* AGE RANGE - Always visible */} - {/* ACCORDION GROUPS */} + {/* LOCATION - Always visible */} + + } + > + updateFilter({country}), + }} + /> + - {/* Relationship Group */} + {/* Relationship criteria - shown whenever the member is looking for a relationship. Orientation + and Has kids sit under Background instead: refinements, rarely dealbreakers on their own. */} {includeRelationshipFilters && ( - } - > + <> {/* Relationship Status */} - - } - > - - - {/* Romantic Style */} - {/* Has Kids */} - - } - > - - - {/* Wants Kids */} - + )} + {/* SECONDARY FILTERS - nice to have, so each group stays collapsed until opened. Primary = the + same dealbreakers the empty state keeps when it suggests what to relax (CORE_FILTER_KEYS in + profile-grid.tsx), except languages and orientation, which stay refinements here. */} +

+ {t('filter.group.more', 'More filters')} +

+ + {/* ACCORDION GROUPS */} + {/* Background Group */} + + {/* Orientation - gender already narrows who shows up, so this is a refinement on top. */} + {includeRelationshipFilters && ( + + } + > + + + )} + + {/* Has Kids */} + {includeRelationshipFilters && ( + + } + > + + + )} {/* Lifestyle Group */} diff --git a/web/lib/profile/seeking.ts b/web/lib/profile/seeking.ts index 513738c2..b5ce75ae 100644 --- a/web/lib/profile/seeking.ts +++ b/web/lib/profile/seeking.ts @@ -1,5 +1,6 @@ +import {RELATIONSHIP_CHOICES} from 'common/choices' import {Profile} from 'common/profiles/profile' -import {capitalize} from 'lodash' +import {capitalize, sortBy} from 'lodash' import {convertRelationshipType, RelationshipType} from 'web/lib/util/convert-types' import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text' @@ -7,14 +8,15 @@ export function getSeekingConnectionText(profile: Profile, t: any, _short?: bool const relationshipTypes = profile.pref_relation_styles const seekingGenderText = stringOrStringArrayToText({ text: relationshipTypes?.length - ? relationshipTypes - .map((rel) => - t( - `profile.relationship.${rel}`, - convertRelationshipType(rel as RelationshipType), - ).toLowerCase(), - ) - .sort() + ? // Same order as RELATIONSHIP_CHOICES, not alphabetical, so it reads the same in every language. + sortBy(relationshipTypes, (rel) => + Object.values(RELATIONSHIP_CHOICES).indexOf(rel as any), + ).map((rel) => + t( + `profile.relationship.${rel}`, + convertRelationshipType(rel as RelationshipType), + ).toLowerCase(), + ) : [t('profile.connection.default', 'connection')], // preText: !short ? t('profile.seeking', 'Seeking') : undefined, asSentence: true, From 270d7fe0d7248d27f0535ee0a63399fd46fb10f8 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 29 Oct 2026 13:46:40 +0000 Subject: [PATCH 13/13] Bump API and Android app versions to 1.88.0 and 188 respectively --- android/app/build.gradle | 2 +- backend/api/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index f1f606d1..090ea984 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -11,7 +11,7 @@ android { applicationId "com.compassconnections.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 187 + versionCode 188 versionName "1.47.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { diff --git a/backend/api/package.json b/backend/api/package.json index 6d2eed52..3401a59e 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -1,6 +1,6 @@ { "name": "@compass/api", - "version": "1.87.0", + "version": "1.88.0", "private": true, "description": "Backend API endpoints", "main": "src/serve.ts",