Repository navigation
Close the security and privacy gaps from the app review (batch 2) - #25
Merged
Merged
Conversation
The design language reference, the Hub prototype and the house game concept were in apps/web/public, so every deploy served them, with notes that carried local file paths and a home network address. They now live in apps/web/reference, which the dev server still serves at /reference/... for design review and builds leave out. A test keeps public/ to the app's own files. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The hosted app answered every missing path with the app page, so an old script requested after a deploy came back as HTML with status 200. The fallback now skips /assets/, and the fingerprinted files there are cached for a year. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Everyone who joined a commune later could still read the email address of each person who had left, and a new address if they changed it. Migration 0031 keeps a former member's name in the member list and leaves their email out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Reporting and withdrawing a payment 2,000 times left the bill payer 2,000 notices, all sent with every load; repeated corrections or reversed receipts did the same in the other direction. Migration 0032 brings an unread notice up to date when the same person repeats the same kind of change to the same expense, sends the latest 100 notices (unread first) instead of all of them, and allows ten payment reports per share a day. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Findings 22, 43 and 44 from the app review. Each tab now remembers which account it is showing and names it on every request; the hosted client and the local API refuse to act for anyone else, and asking who is signed in still answers. A tab that signs in or out tells the others. A tab showing an account that is no longer signed in clears itself and goes to sign-in, saying why and whether its last change was saved; a tab already at sign-in picks up a sign-in made elsewhere. Signing out clears the tab, its stored settings and the other tabs first, whatever the server says. If the server does not confirm it, the sign-in page says the device may still be signed in and offers to try again. A session the server refuses while the app loads is treated as signed out, and the connection problem screen has a Sign out button. Browser storage calls no longer throw when the store is full or blocked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Since 0032 a repeat correction updates the member's unread notice rather than adding another, so the money lifecycle HTTP test now expects one notice carrying the latest share. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The auth SDK tells other tabs about each sign-in and token refresh by writing the session, with its token, email and IP address, to localStorage, where it outlived the browser session. Other tabs have heard it by the time a request settles and the SDK never reads it back, so the hosted client removes it then. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
- Nobody but Commune's own functions can run pgcrypto or pg_trgm, which the Data API offered as RPCs. - Another admin confirms contributions to an admin's own pledge when the commune has one. - The snapshot's activity keeps the last 90 days of voids, corrections and reversals, however busy the commune is. - The email worker answers a reminder dispatch the same way whether or not an email went, and each scheduled run trims email bodies and invitation email rows older than 30 days. - grants.sql checks every function: definer functions fix their search_path and are opt-in, extension functions are closed, and no function sets the request identity or runs dynamic SQL. - The request identity rules and the Neon Auth console settings Commune expects are written down. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
When the commune has another admin, the Funds screen no longer offers an admin Record contribution on their own pledge, which 0034 refuses, and says another admin records it. A commune with one admin is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Finding 24 from the app review.
An invitation email led with the commune's name, which anyone can
choose, and never said who sent it: a commune called "Bank: card locked,
call 020 7946 0000" produced exactly that subject from Commune's
address. The subject is now "{Inviter} invited you to join “{Commune}”
on Commune", the body and heading quote the commune too, and the footer
offers a report link that names the invitation by its id, never its
token.
Migration 0033 refuses a new commune name, or a rename, that includes a
link, an email address or a phone number; existing names are not
changed. The worker applies the same rule to the commune and the
inviter's account name and will not email such an invitation (422).
Invitation emails are also limited to 500 a day across Commune and to 5
a day from an account whose earliest membership is under a day old. The
Commune-wide count uses the created_at index that 0034 adds.
A signed Resend webhook, /webhooks/resend, records bounced and
complaining addresses in public.email_suppressions. Invitations to them
are refused (409) and reminder emails to them are skipped exactly like
an opt-out. The worker hashes the invitation token itself, so it no
longer needs pgcrypto.
Deploy the worker after 0033 and set RESEND_WEBHOOK_SECRET, as
infra/neon/README.md describes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The local API tests named communes with the first eight hex characters of a random UUID. About one run in twenty those held seven or more digits in a row, which 0033 now refuses as a phone number, so the test failed at random. The suffix now maps digits to letters; it stays unique and never forms a digit run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The phone number rule refused any run of 7 digits joined by spaces, dots, dashes or brackets, so a trip called "Ski trip 2025-2026" could not be created. It now needs 9 digits: UK numbers have 10 or 11, and a year range has 8. The database and the email sender keep the same rule. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The local list now runs to 0034, the Neon release order covers 0013 to 0034 and says to deploy the worker after 0034, and the handoff summarises what the second set of review fixes changed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
POST /api/auth/change-password checks the current password the same constant-time way as login, counts a wrong one towards the sign-in limit for that address, sets the new password (12 to 128 characters) and ends the account's other sessions in one transaction. POST /api/auth/logout-others ends the account's other sessions. Both keep the session that asked, so this device stays signed in, and every request looks its session up, so another device is refused from its next request. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The hosted client gains /auth/change-password and /auth/logout-others. Both check the verified account this tab is showing first. A change asks the auth server to end every session and start a new one for this device (revokeOtherSessions); this tab keeps its cached token, still valid until it expires, and renews it from the new cookie as the same account. The SDK reports a wrong current password and an ended session as the same 401, so a wrong password becomes a 400, as on the local API, and only an ended session reads as being signed out. A failure after the change is an unknown outcome. A used or expired reset link now says so instead of "Invalid or expired session token". A real-SDK contract test pins the endpoints, bodies and error shapes; the lifecycle contract test covers the new paths' sign-in checks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
A new "Password and sign-in" group sits between Preferences and Support & legal, built from the existing rows, Dialog, Field and Feedback. Change password asks for the current password and a new one of 12 to 128 characters; Sign out everywhere else asks for a confirmation. Both keep this device signed in, end the account's other sessions and say so, including that a device with Commune already open can keep working for up to 15 minutes, until its current access token expires. A wrong current password, too many attempts and an unconfirmed outcome each get their own message in the dialog, which clears the page banner so it is not repeated. A refused sign-in still signs this tab out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The reset page reads the token from the link, then removes it from the address bar and this tab's history entry at once, keeping any other query such as native=1. The tab keeps the token in session storage until the reset works, so a reload or another try after a failure still resets, and forgets it on success or when the link comes back expired. An expired or used link now says so, and a lost reply explains how to tell whether the reset already worked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
After the change the auth SDK keeps its cached session and token, so the tab carries on as the same account without asking the server again and the account checks in session.ts see no change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
…Auth The sign-in settings page now lists the token lifetime and cookie cache the Settings wording relies on, the limits that should cover a password change, how the reset page handles its token, and a two-browser check for a preview branch. The handoff mentions the new Settings actions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The hosted client checked which account the tab shows only for reads and two auth routes, so once the shared session belonged to someone else, a stale tab's writes ran as that person. route() now checks it for every path outside /auth/, and the viewer test covers writes, the invitation email and accepting an invitation. A sign-in is now announced by the refresh that first gives a tab an account, so the return from Google reaches the other tabs as a password sign-in already did. A refresh started by hearing another tab never announces, so one message cannot lead to another; a two-tab test shows a Google return sends one message, and that a session the database refuses does not bounce between tabs (without the rule it does, endlessly). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Only a function's owner can revoke EXECUTE from it, and a trusted extension installed by a role that is not a superuser belongs to the bootstrap superuser, so the revoke loop can leave every pgcrypto and pg_trgm function open with nothing but per-function warnings. After the loop, 0034 now raises one WARNING naming the extension functions in public that PUBLIC, authenticated or anonymous can still run. tests/grants.sql already fails in that case, on its extension check. The release notes say what to do when the warning appears and which of the functions a PostgREST-style Data API could call: eight, those taking no parameter or one unnamed text or bytea value (pgcrypto 1.3, pg_trgm 1.6). crypt and the rest take parameters such an API cannot pass, so the header no longer says the Data API offered bcrypt at any cost. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
0031 hid former members' addresses from the snapshot, but list_commune_invites still returned the address of every accepted invitation, so admins (including ones who joined later) could read it. The list now returns an accepted invitation's address only while the person who accepted it is still an active member, and a new accepted_by column says who that was. As the result columns change, 0031 replaces the function from its own definition (with the usual assertions) and restates its grants. Members names such an invitation from the snapshot, which keeps former members' names, or says "Former member", and offers a new link only for invitations that still carry an address. The local API and the hosted client pass accepted_by through. A SQL case covers a member who leaves, an admin who joins later and an open invitation; a web case covers the three ways an accepted invitation is named, and the hosted contract test now includes an invitation with no address. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
0034's reserve kept the money corrections of the last 90 days, at most 50, but counted receipt reversals with voids and corrections. Anyone owed money can record and reverse a receipt as often as they like, so 50 reversals pushed a void out of the activity again. commune_private.is_money_correction becomes money_correction_reserve, which puts voids and corrections in one reserve and receipt reversals in another; the snapshot carries up to 50 of each from the last 90 days alongside the latest 100 events, and the partial index keys on the commune, then the reserve, so it serves both. database_hardening.sql records and reverses a receipt 60 times after a void and checks the void is still there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The phone rule in 0033 and the worker joined digits only by spaces, dots, dashes and brackets, so "020/7946/0000" or "020_7946_0000" passed. Digits may now be joined by any run of spaces, punctuation or symbols except commas, semicolons and ampersands, which still part two numbers, so "Flat 12, 2024-2025", "Summers 2024, 2025, 2026" and "Rooms 10 & 11, 2025/26" stay names, as do "Ski trip 2025/2026" and "Flat 4/12 Smith Street". Tried first: any non-letter, non-digit character between digits caught every number but refused "Flat 12, 2024-2025", "Summers 2024, 2025, 2026", "Flat 1; 2024-2025" and "Rooms 10 & 11, 2025/26"; limiting the run to three characters still refused them and missed "020 -- 7946 -- 0000", and limiting it to two also missed "020 / 7946 / 0000". Leaving out commas, semicolons and ampersands, with no limit, caught every number and refused none of the names. The joining characters are listed by code point (ASCII and Latin-1 punctuation and symbols, general punctuation to miscellaneous symbols, CJK punctuation and emoji) rather than as a locale's non-letters, and the worker uses the same ranges, so the two agree whatever the database's locale. Both tests gain the new names and numbers, and the worker's still compares its verdict with the database's for every one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
0032 stopped repeated payment reports filling someone's notices, but an admin could still move a member's pledge date (or lower it) over and over, adding a notice each time. notify_pledge now brings an unread notice with the same recipient, commune, pledge, kind and actor up to date (amount, detail and time) instead of adding another, as notify_ledger does for expenses, and leaves the notices of someone who is no longer a member alone. Both callers hold the pledge's row lock, so two changes cannot both add one. notice_limits.sql moves a pledge date 200 times and expects one notice naming the latest move, a new one once that is read, and no change once the member has left. invites_pledges_requests.sql (0028) expected a separate notice for a lowering and a later move; Blake now has one, describing the latest change with the lowered amount. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Owner
Author
|
The Vercel preview for Generated by Claude Code |
EmotiveImpact
changed the base branch from
claude/project-thread-evovgx
to
main
October 12, 2026 02:00
EmotiveImpact
marked this pull request as ready for review
October 12, 2026 02:00
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Before: signing out in one tab left other tabs acting as the old account, and a sign-out the server refused cleared nothing. A session rejected at start-up looked like an outage with no way to sign out, and there was no way to change a password or sign out other devices. Anyone who joined a commune later could read former members' email addresses. One member could fill another's notices by reporting and withdrawing a payment thousands of times. Invitation emails led with a commune name anyone could choose and never said who sent them, so a commune called "Bank: card locked, call 020 7946 0000" produced exactly that subject from Commune's address. The design prototypes, with local file paths and a home network address, were served on the app's domain, and missing files came back as the app page.
After: each of those is closed, with a test that fails on the old code. Sign-out clears every tab whatever the server says, and each tab refuses to act for an account other than the one it shows. Settings has Change password and Sign out everywhere else. Invitation emails name the inviter, quote the commune, refuse names carrying contact details, and stop at addresses that bounced or complained.
This is batch 2 of the app review fixes and is built on #24 (batch 1), so review that first; the diff here shows only this batch.
What changed, by finding
accepted_byor says "Former member".apps/web/reference, which the dev server still serves and builds leave out. A test keepspublic/to the app's own files./assets/return not found, and fingerprinted files are cached for a year.grants.sqlchecks every function's search path and grants. The auth SDK's stored copy of the session is removed after each request, and the Neon Auth console settings Commune expects are indocs/AUTH-SETTINGS.md.Releasing
Nothing hosted was touched. Migrations 0031 to 0034 are local only and follow 0030. When releasing, apply them in order, refresh the Data API schema cache, deploy the client, then deploy the reminder worker with
RESEND_WEBHOOK_SECRETset and the webhook added in Resend, asinfra/neon/README.mddescribes. An older open tab still offers Record contribution on an admin's own pledge and shows the refusal until it reloads.If applying 0034 prints "Clients can still run these extension functions in public", the role applying it does not own them, which happens when a role that is not a superuser installed the extensions. The rest of 0034 still applies, but only the functions' owner can close them, so the release notes ask Neon support to run the revokes and then
tests/grants.sqlagain. Until then a PostgREST-style Data API can call eight of them, none of which reads Commune's data.Needs the owner
docs/AUTH-SETTINGS.mdon production, including ending other sessions after a password reset (finding 23), the 15-minute token lifetime and any session cookie cache, and run its two-browser check on a preview branch.How it was checked
On the final branch:
In a browser: an admin's own pledge with and without a second admin, a rename carrying a phone number refused and a year range accepted, and signing out in one tab clearing another.
How
Each database change is a SECURITY DEFINER function or a patch made with
pg_get_functiondefandreplace(), which stops if the patch no longer matches. Every migration starts withBEGIN;and has oneCOMMIT;. The webhook verifies Resend's Svix signature with a five-minute tolerance and a 256 KB limit.🤖 Generated with Claude Code
https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Generated by Claude Code