Repository navigation
Fix the serious findings from the full app review (batch 1) - #24
Conversation
.migration-backup/ held 556 files of the replaced Supabase and Mantine app, including docs/COMMUNE-BUILD-BIBLE.md with a Gemini API key that had been scrubbed in June and came back with the backup on 22 September. Nothing in the current app, build or tooling reads the folder; the old files stay in git history. Removing them does not make the key safe: it still has to be revoked in Google Cloud. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Members showed only one direction of money between you and each person:
"Owes you" hid anything you owed them, and your own row showed what you owe
and hid what you are owed. In a trip where Jordan owed €42.17 on one bill
and you owed Jordan €150 on another, Members said "Owes you €42.17" while
the breakdown said "You owe Jordan Reid €107.83".
netBalances in packages/core now offsets open shares in both directions into
one figure per person. Members and the breakdown's statement headline both
use it, and your own row shows the net across everyone ("Owe €75.67
overall"). A test checks the figure equals the statement's closing balance.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
vercel.json only had the page rewrite, so the app sent no Content Security Policy and any site could frame it, which allowed clickjacking of money and membership actions. The headers block was dropped in May. The response now carries a CSP (scripts only from the app, styles and fonts from the app and Google Fonts, images from the app, data URLs and Unsplash, frame-ancestors 'none', no plugins, no base or form redirection), X-Frame-Options DENY, nosniff, a referrer policy and a permissions policy. The header allows connections to any HTTPS host so a preview built for a new Neon branch cannot break on its host name; a build that knows its Neon endpoints adds a meta policy naming exactly those origins, and browsers enforce both. Checked against a production build with a mocked Neon: eleven signed-in screens, sign-in, sign-up and password reset raise no violations, a fetch to another host is refused and the app will not render in a frame. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The app linked to ourcommune.io/privacy and /terms and the website linked back to app.ourcommune.io/privacy and /terms, but neither page existed, so the links ended on sign-in or Home, and sign-up gave no privacy information. /privacy and /terms are now app routes that work signed in or out. Until the fuller notice in docs/legal/privacy-draft.md is approved, they give a short, factual summary of what the app does today: what it keeps, what stays in the browser, the services that handle data (Neon, Vercel, Resend, Google, Unsplash), that it does not move money, the emails it sends, and support@ourcommune.io for requests, with the ICO for complaints. Sign-up links to the privacy page, and the sign-in pages and Settings link to both. Nothing here names the operator or sets retention: those wait for the owner's decisions listed in the draft. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Every reminder email said reminder emails could be changed in Commune settings, but Settings only had the on-device Notices switch, and the emails had no one-click unsubscribe. Settings now has an Email reminders switch saved to the account. It reads the existing preference row and saves through set_email_reminders (migration 0029), because the table's column grants cannot express an upsert of your own row through the Data API. The local API uses the same function. When UNSUBSCRIBE_SECRET and WORKER_PUBLIC_URL are set, the email worker adds a signed /unsubscribe link to each reminder email and List-Unsubscribe and List-Unsubscribe-Post headers (RFC 8058). Opening the link only asks, since mail apps open links to check them; the POST turns reminder emails off. A link altered to name someone else is refused. Without the settings, emails point to Settings as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
A Pause or Edit sent from a screen opened before someone else recorded a period used to write the whole bill back, putting the old amount and due date in place. Every change to a bill now makes a new version; an edit must name the version it was based on and a stale one is refused. Pause, resume, archive and restore are separate commands that change nothing else, are safe to repeat, and write their own history line. - Resuming names anyone in the payer or split who has left. - An edit cannot move the next due date onto, or before, a period that was already recorded or skipped, and a new date must fall within a year back and two years ahead. - Archived bills are always paused, so they never block a member leaving, and record, skip, pause and edit refuse them until they are restored. - Adding a bill carries a request id, so a retry returns the same bill. - A skip reaches at most one period ahead, and one bill can have at most 12 periods skipped in a day. - Index recurring_bill_occurrences(expense_id) for the snapshot's per-expense period check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Migration 0027 moves every note and task write into SECURITY DEFINER functions, as announcements already are, and drops the client write grants and policies on both tables. - create_note / create_operation carry a request id; a retry with the same content returns the same row, a reused id with other content is refused. Content is checked with plain messages. - update_note / update_operation name the version the edit started from. Every task change, occurrence commands included, moves the task's version, so an edit opened before a completion is refused. An edit cannot set status, completion or timestamps. - remove_note / remove_operation are soft deletes for the creator or an admin, with one activity line; history rows stay. Removed rows leave the snapshot, search, the read policy and departures. - A commune holds up to 500 notes and 1,000 tasks and events. shared_content.sql passes again; grants, people and handover tests use the commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
…make creates retry-safe Database part of review findings 7, 10 and 11 (migration 0028). - Invitations: when an admin leaves, is removed or becomes a member, the invitations they sent that are still open are revoked. Accepting or previewing an invitation needs its sender to still be an active admin, and the migration revokes open invitations that already fail that rule. list_commune_invites also returns the sender's id and name. - Retries: expenses, funds, pledges and contributions are created through commands that take a request id. A repeat returns the original record while it still stands as requested; the same id for a different change is refused. The older RPCs keep their signatures and run the same rules. - Pledges: someone other than the pledger may only lower a pledge (never below what is recorded), move it later or cancel it, and the pledger gets a notice naming who changed what. Cancelling twice changes nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The save route now takes the version an edit was based on (a stale one is a 409) and a request id for an add, and no longer pauses or resumes. POST /api/recurring/:id/pause and /archive call the new commands and return the bill's state with duplicate: true for a repeat. The recurring HTTP tests now pick their month-end dates relative to today, since a new bill's first due date must fall within the last year. A new recurring-bills test covers a stale edit, the pause command, archive and restore, resuming after a member left, and the skip limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The local API now calls create_note, update_note and remove_note, and create_operation, update_operation and remove_operation, instead of writing the tables. Adds need a requestId and edits an expectedVersion; a stale edit is a 409. Task edits no longer carry status or completion. Adds note-and-task-commands.mjs and updates the other HTTP tests and the lifecycle fixture, which now also seeds a late task for someone else and a late task with nobody on it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Commune keeps account ids as text while Managed Neon Auth keys accounts by uuid. Every name and email lookup compared u.id::text with an id, so each one read the whole account table, and the task list made one per name. With 20,000 accounts a household with 300 rotating tasks took 37 seconds to load. Migration 0030 adds commune_private.accounts, which compares uuid with uuid behind a validity check (a malformed id still matches no one), and moves every lookup onto it. The task list now resolves its names once per snapshot. The same household loads in about 0.1 seconds, and the task list output is unchanged. The invitation email sender uses the same lookup, so the worker deploys after this migration. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Both backends now create expenses, funds, pledges and contributions through
create_expense_command, create_fund_command, pledge_fund_command and
record_fund_contribution_command, passing the form's request id and
answering {id, duplicate}. The local API answers 201 for a new record and
200 for a repeat, and gives older callers without a request id a fresh one.
The invitation list carries invitedBy and invitedByName.
The Neon contract test pins the new function names, arguments and results,
the reused-request-id rejection (400), the pledge-limit refusal (403) and
the invite sender mapping.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Add expense and the fund, pledge and contribution dialogs keep one request id while they are open. After an unconfirmed attempt the fields lock and the form says that trying again will not record it twice; a refusal says nothing was saved, and a reused id says an earlier attempt may already have been saved. On someone else's pledge the editor offers only what the database accepts: a lower amount (not below what is recorded) or a later date, with copy that names who can raise it or bring it forward and says they will be told. The cancel dialog says the same. Pledge change and cancellation notices name who did it and open the fund. Members shows who sent each invitation, and a demotion, removal or leave says which open invitations will be withdrawn. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The local API test runs four accounts through 0028: invitations list who sent them, a removed or demoted admin's invitations stop working, two simultaneous attempts at one expense record it once, repeats of a fund, pledge or contribution return the first record, a reused request id with different details is refused, an admin cannot raise or bring forward someone else's pledge, and the pledger is told about a change or a cancellation once. It refuses port 3002 and any non-loopback address. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The production client no longer writes commune_notes or
commune_operations through the Data API. Adds, edits and removals call
create_note, update_note, remove_note, create_operation,
update_operation and remove_operation, and return {id, version,
duplicate}. A missing version or request id is passed as null so the
database refuses it plainly. Task edits never send status or completion.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Each open add form keeps one request id, so a retry after an unconfirmed save is recognised instead of adding a second copy. Task edits send the version the form opened with; a completion, skip or handover since makes the edit stale. The form then says the task changed, keeps Save off and offers to load the latest details. The note editor detects a conflict by status rather than by message text. Failures use commandFailure: a refusal shows the database's reason, an unknown outcome says a retry is safe. Remove is offered only to the person who added the task or an admin, and waits while a change saves. Status and completion are no longer part of an add or an edit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The week showed only the oldest late task; any other late task appeared nowhere. Now each late task has its card, yours first and then the oldest. The Yours card includes your late tasks, so it no longer says you are clear while one is waiting. Copy reads "1 day late" or "3 days late" instead of "Late · 1 days", and an unassigned task says "Nobody has this yet." The late cards' Skip and Done buttons wait while a change is saving. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The recurring bills screen now uses the new commands. An edit sends the version its editor loaded, an add sends one request id per open form, and Pause, Resume, Archive and Restore call their own commands instead of saving the whole bill. Each action is shown only to the people its command allows. When a bill changed while it was being edited, the editor says so, lists what changed and saves nothing until the person keeps their edits on top of the newer version or takes the latest one. A save whose reply was lost is recognised as the person's own. People who have left are taken out of the split when the editor opens, and the editor says who; a payer who has left must be replaced. Resuming such a bill opens the editor, and someone who cannot edit it is told why it cannot resume yet. A period that was already recorded or skipped says so plainly, and conflicts refresh the bill before the message appears. Archived bills leave the active list and appear under an Archived filter, where they can be restored, still paused. Skip appears only once the next period can be skipped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The membership dialog chose between "Your" and "Their" by action, so an admin demoting themselves read "Their open invitation will be withdrawn". It now follows whose membership changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
The database refuses to skip a one-off task, so the button always failed. The task window already hides it; the late card now does too. With every late task listed, one-off late tasks are more often on show. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS # Conflicts: # apps/web/tests/neon-lifecycle-contract.test.tsx
…and 11) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS # Conflicts: # apps/local-api/README.md
The merged recurring bill and invitation changes each added a lookup that compared account ids as text. Both now go through commune_private.accounts, and the lookup test covers them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
A repeated add now gets the existing bill back only when that bill is still at version 1 and its name, amount, payer, split, frequency, next due date and paused state all match the request. A different request, or a bill that has changed since, is refused as already used for a different bill instead of silently keeping the old one. The add takes a transaction lock on the person and request id before it looks the request up, so the same request sent at once to two communes waits for the first and is refused, instead of reaching the unique index. An edit that names no version is refused as a mistake in the request, not reported as a conflict. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
When the editor's conflict view follows a save that got no reply, and the newer bill is not recognisably that save, it now says the earlier save may have gone through and asks the person to check the bill below, instead of saying nothing of theirs was saved. An outright refusal of a later attempt settles it, and taking the latest version starts afresh. An add refused because its request was already used now tells the person an earlier attempt may already have been saved, and to check Recurring before adding it again, as the expense form does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
A pledge can be cancelled and made again, so its request id no longer lives on the pledge row. fund_pledge_requests keeps one row per (user, request) with the pledge it made and what it asked for; clients have no grants on it. A repeat replays only while the pledge still stands as that request left it (not cancelled, unchanged, and not made again by a later request); otherwise it is refused as a reused request. A late retry can no longer bring back a cancelled pledge, or read as "you already have a pledge" when it did save. fund_commitments.request_id is dropped (0028 is unreleased). update_fund_pledge gains p_expected_amount_minor and p_expected_due_on (DEFAULT NULL, same grants). When given and the pledge no longer matches, any editor is refused with P0001 and nothing is saved, so an edit made from an older screen cannot undo someone else's change. SQL test blocks 4 and 5 fail without these changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
PATCH /api/commitments/:id accepts expectedAmountMinor and expectedDueOn and passes them to update_fund_pledge; a pledge changed since is a 409. The HTTP test covers both review points: a retry of an earlier pledge request after cancel and re-pledge is a 400 reused request (it was a 409 "already have a pledge", or revived the cancelled pledge), and stale edits or cancellations are a 409 with nothing saved and no notice sent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Pledge edits and cancellations send the amount and date the dialog opened with, through the local API and the hosted client. On a 409 the fund is reloaded before the message shows, and the dialog starts again from the pledge as it is now. Once an expense, fund, pledge or contribution form has had an attempt with an unknown outcome, a later refusal (a 403 after a role change, a 401) no longer says "Nothing was saved."; it says an earlier attempt may already have been saved and where to check before adding it again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Removing a note left its earlier versions readable by any member, someone who joined later included, through the revision route and the Data API, so an old door code stayed one request away. The revision and occurrence read policies now also require the note or task to be there. The rows are kept and the owner still sees them. The local route and the hosted client answer 404 "Revision not found", as for an id that never existed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
A retried add found its earlier row by request id and returned it as a duplicate even after it had been removed, handing back a record nobody can see. create_note now raises P0001 "This note has been removed" and create_operation "This task has been removed" (or event), which the local API and the hosted client report as 409. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
A member leaving locks the commune row and then that member's tasks, while update_operation and remove_operation locked the task first and then needed the commune row for their activity line. Run together, each waited on the other until PostgreSQL cancelled one as a deadlock. Both now read the task's commune without a lock, lock the commune, then lock the task. An unknown id is still refused with 42501. task_lock_order.mjs holds a departure between its two locks, starts an edit or a removal, then lets both run: before this change each deadlocked (40P01) every time; now the edit is refused as stale and the removal goes through. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
When an edit saved but its reply was lost, the next refresh brought in the next version from that same save, and the form called it someone else's change: "nothing here has been saved", with Save disabled. The form now remembers what it last sent. It is stale only when the latest record differs from that; when it matches, it says "Your last save went through." and the next save names that version. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
|
The Vercel preview for Generated by Claude Code |
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. |
Before: the full app review on 9 October found 14 serious problems. An old app backup in the repository still carried a Google API key. The hosted app had stopped sending its security headers, the privacy and terms pages did not exist, and reminder emails promised a setting nobody could find. Members saw conflicting balances. Every name lookup read every account on the platform, so a busy task list took more than 30 seconds to load. Notes and tasks could be written straight to the database, so anyone could mark a task done or delete anything. A removed admin's invitations still worked, a retried save could create a second expense, fund, pledge or contribution, and anyone could raise someone else's pledge. Recurring bills could not be paused or archived safely, an edit could save over a newer one, and the week view showed only the first late task.
After: each of those is fixed, and each has a test that fails on the old code. The database refuses the unsafe changes itself, so the hosted client and the local API behave the same way.
What changed, by finding
packages/core.Second review
A separate review of each part found 11 more points, all fixed here with tests that fail without them:
The older task commands (complete, skip, hand over, undo) still lock the task before the commune. A later batch brings them into the same order.
Releasing
Nothing hosted was touched. Migrations 0026 to 0030 are local only, like 0012 to 0025. When releasing, apply them in order with the earlier ones, refresh the Data API schema cache, then deploy the client. Three need care, recorded in
infra/neon/README.md:UNSUBSCRIBE_SECRETandWORKER_PUBLIC_URLset.Needs the owner
docs/legal/privacy-draft.mdand add the operator's details. The live pages give a short factual summary until then.How it was checked
On the final branch, after the second review fixes:
A browser walk as an admin and a member at desktop and phone widths covered the first round; the second review's screen changes are covered by the web tests.
How
Each fix lives in a SECURITY DEFINER function with
REVOKE ALL ... FROM PUBLICand a fixedsearch_path. Existing functions are patched withpg_get_functiondefandreplace(), and the migration stops if a patch no longer matches. Every migration starts withBEGIN;and has oneCOMMIT;, ready for the automatic migration step in #21. The recurring bill, note and task, and invitation work was built in parallel and merged here. The remaining findings follow in later batches.🤖 Generated with Claude Code
https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS