Skip to content

Close the security and privacy gaps from the app review (batch 2) - #25

Merged
EmotiveImpact merged 25 commits into
mainfrom
claude/app-review-batch-2
Oct 12, 2026
Merged

EmotiveImpact merged 25 commits into
mainfrom
claude/app-review-batch-2

Conversation

@EmotiveImpact

Copy link
Copy Markdown
Owner

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

Finding Change
22, 43, 44 Each tab remembers the account it shows and names it on every request, and the hosted client and the local API refuse to act for anyone else, for reads and writes alike. A tab whose account signed out or changed elsewhere clears itself and says whether its last change was saved, and a sign-in, including the return from Google, reaches the other tabs once. Sign-out clears the tab, its stored settings and the other tabs first; if the server does not confirm it, the sign-in page says so and offers a retry. A session refused at start-up counts as signed out, and the connection screen has Sign out. Storage calls no longer throw when the store is full.
23 Settings gains Change password and Sign out everywhere else. Both keep this device signed in and end the account's other sessions; a device that already has Commune open keeps working until its access token expires (up to 15 minutes on Neon), and the wording says so. A wrong current password meets the sign-in limit. The reset page takes its token out of the address bar and keeps it for this tab until the reset works. The Neon console part of this finding is yours (below).
24 Subject "{Inviter} invited you to join “{Commune}” on Commune", a report link that names the invitation by id, 500 invitation emails a day overall and 5 a day from accounts under a day old. Migration 0033 refuses new names and renames with links, email addresses or phone numbers: 9 or more digits joined by any spaces, punctuation or symbols, so "020/7946/0000" is caught, while commas, semicolons and ampersands still part two numbers, so "Ski trip 2025/2026" and "Rooms 10 & 11, 2025/26" are fine. The worker applies the same rule before emailing and refuses an invitation whose commune or inviter name breaks it, which covers names from before 0033. A signed Resend webhook records bounces and complaints; invitations to those addresses are refused and reminders skip them like an opt-out.
25 Migration 0031 keeps a former member's name and leaves out their email, in the snapshot and in the invitation list. An accepted invitation shows its address only while the person who accepted it is still a member; Members names it from the new accepted_by or says "Former member".
26 Migration 0032 updates an unread notice in place when the same person repeats the same change, for expenses, payment reports and pledges alike, sends the latest 100 notices (unread first), and allows ten payment reports per share a day.
36 The prototypes moved to apps/web/reference, which the dev server still serves and builds leave out. A test keeps public/ to the app's own files.
57 Missing files under /assets/ return not found, and fingerprinted files are cached for a year.
66 Migration 0034: pgcrypto and pg_trgm functions are closed to clients, and a warning says so if the role applying it does not own them (see Releasing); another admin confirms contributions to an admin's own pledge (Funds says so); activity keeps up to 50 voids and corrections from the last 90 days however busy the commune is, with receipt reversals in a reserve of their own so they cannot push a void out; the worker trims email bodies and invitation email rows after 30 days and answers a dispatch the same way whether or not an email went. grants.sql checks 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 in docs/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_SECRET set and the webhook added in Resend, as infra/neon/README.md describes. 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.sql again. Until then a PostgREST-style Data API can call eight of them, none of which reads Commune's data.

Needs the owner

  • Check the Neon Auth settings in docs/AUTH-SETTINGS.md on 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.
  • Decide how long notices, activity and old invitations are kept (finding 42). Only email bodies and delivery rows are trimmed so far.
  • If 0034 prints the extension warning when you release it, ask Neon support for the revokes the release notes list.
  • A reminder dispatch still takes slightly longer when an email is sent than when it is skipped, so timing could hint at an opt-out. Closing that fully means sending from a queue; say if you want it.

How it was checked

On the final branch:

  • 20 SQL test files on fresh databases, plus the lock order test.
  • 15 local API suites on a disposable database and port, including a new one for changing a password and signing out other sessions.
  • 382 web tests in 36 files, including the hosted client's mapping of the new auth calls against the real SDK and two tabs signing in and out.
  • The reminder worker's unit tests and 4 database suites (integration, invitations, webhooks, monthly limits), with no real email sent.
  • Typechecks for the web app, local API and worker, and a production build.

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_functiondef and replace(), which stops if the patch no longer matches. Every migration starts with BEGIN; and has one COMMIT;. 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

claude added 25 commits October 9, 2026 11:55
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

Copy link
Copy Markdown
Owner Author

The Vercel preview for commune-web shows Error here, as it has on every branch since 6 October. It is the project or account issue explained on #24 (app review finding 45), not this diff: the production build of this branch passes locally (vite build on 999154f). It needs someone with the Vercel login to read a failed deployment's log.


Generated by Claude Code

@EmotiveImpact
EmotiveImpact changed the base branch from claude/project-thread-evovgx to main October 12, 2026 02:00
@EmotiveImpact
EmotiveImpact marked this pull request as ready for review October 12, 2026 02:00
@EmotiveImpact
EmotiveImpact merged commit 0b0191c into main Oct 12, 2026
3 of 4 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T02:01:22.361679Z 999154f Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

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

1 failed deployment
Preview — 999154f9 Deployed Oct 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants