[Orgs] Multi-tenant organizations with a project role ladder (admin/member/viewer)
From the PR description
Rewritten 2026-08-26. This description was replaced wholesale after @willchen96's review and its invitation addendum. The previous body described the model that review rejected - four project tiers (
owner/manager/editor/viewer), hidden personal organizations, Teams, and nine migrations. None of that is on this branch any more, so its replication matrix no longer replicates. Everything below describes the branch atd6058c22.
Updated 2026-08-26 (evening). A post-review fix round landed on top of the revision, and the branch was rebased onto
mainat1b58c7aa, which now carries #383 (model-selection policy). Two consequences run through this description: the migrations are renumbered20260831_01..03(see Migrations), and the org-visibility RPCs carry #383'smodelcolumn. See Post-review fix round.
Followed by #268 (organization management UI) and #363 (chat permission parity). Backend-only: 39 files, +9768 / -416, 51 commits over main at 1b58c7aa. Zero changes under frontend/, e2e/ or word-addin/.
Summary
A firm is not one user. This PR adds multi-tenant organizations and one permission system the product can explain in a sentence: a project is personal or belongs to an organization; organization admins administer its projects, organization members collaborate on them, and anyone else can be invited to a single project as admin, member or viewer without joining the organization.
Concretely: two organization roles (admin, member), three project roles (admin, member, viewer), org-role inheritance onto org projects, per-recipient access grants that carry a role, membership that requires the recipient's consent, an organization that durably owns its projects even after the creator's account is deleted, and one capability matrix that every gated route declares against.
Live demo (recorded 2026-08-26, revised model)
Ten flows recorded with a real browser driving the running product on this stack's current tip, against an upgrade-path database - main's schema.sql with migrations 20260831_01..05 applied on top, i.e. the path an existing deployment actually takes rather than a fresh install. The three below are the ones this backend PR is responsible for.

The sole admin cannot strand the organization: their own row shows a role badge instead of a picker, the leave attempt comes back 409 with the refusal surfaced in the UI, and the role is still admin afterwards - the guard is the Postgres trigger, not the client.

A non-member finds the project in none of the tabs - Mine, Shared with me, All - and a direct URL is refused without leaking the project's name or contents.

An organization member with no explicit grant reaches the org project through inheritance alone, and the member tier is a real write tier: the upload is accepted and the folder is created.
The remaining seven flows from the same round:
- flow-01 - org creation and invitations
- flow-02 - invitation accepted, roster updated
- flow-04 - per-recipient project sharing roles
- flow-06 - viewer is read-only
- flow-08 - chat list/detail parity
- flow-09 - admin deletes a chat they did not author
- flow-10 - tabular review gating
Source mp4s and the labeled per-checkpoint stills are on the media branch: assets/orgs-live-2026-08-26.
What the review asked for, and what this branch does
| # | Requested | Where it landed |
|---|---|---|
| 1 | Organizations expose only admin/member; creator auto-admin; the database guarantees ≥1 admin. Projects use admin/member/viewer; creator starts as admin with no separate elevated tier; org admin ⇒ project admin, org member ⇒ project member; strongest role wins on overlap |
permissions.ts is now a three-role ladder with five capabilities (0686b56e). org_members.role is check (role in ('admin','member')); the creator is inserted as the first admin by POST /orgs; trigger org_members_last_admin_guard enforces the invariant in Postgres. isOwner became isCreator - provenance only, deriving admin like any other admin (1655c7a0) |
| 2 | Role-aware individual sharing that works on organization projects, so outside counsel can be a viewer without joining the org. Replace or supersede the roleless shared_with array |
project_access_grants - one row per recipient, keyed by normalized email, carrying admin/member/viewer. Merged additively with org inheritance, so a grant can promote but never demote. Three endpoints under /projects/:projectId/access (95b8d362) |
| 3 | Drop hidden personal organizations; personal content is org_id IS NULL; for org projects the organization is the durable owner, and deleting the creator's account must not delete firm content |
Personal orgs, the signup-trigger provisioning and the lazy re-provision are gone; handle_new_user reverts to main's version. user_id on eight content tables became nullable ON DELETE SET NULL, and account deletion now detaches an organization's content - including the leaver's work inside colleagues' org projects - instead of destroying it (1655c7a0, fa16d988, f29118f4) |
| 4 | Move Teams to a separate PR | Tables, endpoints, cleanup and export code removed (7486ae70). The work is preserved unmodified at 1e0f1478, this branch's pre-revision tip, and will return as its own PR once a team-scoped grant model is defined |
| 5 | Consolidate the nine migrations into roughly: tables/constraints/indexes/triggers · backfill · final RPCs | Exactly that, as 20260831_01/_02/_03 (bfbb2659, renumbered in e16a8693). The date moved forward because main has since shipped #383's 20260826_01...20260827_03, and deployments apply only files sorting after their recorded version |
| addendum | Membership requires acceptance: pending invitations grant nothing, recipient accepts/declines, admins cancel/resend, expiry, email-normalized, no duplicates while active, audited, and creatable for users who have not registered yet | Full lifecycle on org_invitations + seven endpoints. POST /orgs/:orgId/members no longer exists; org_members rows are written by exactly two things - creating an org, and accepting an invitation (7486ae70) |
The model
backend/src/lib/permissions.ts - one rank table, exhaustively unit-tested, fails closed on a null or unknown role (so the retired names owner/manager/editor are inert rather than ranking as something).
Roles rank viewer(0) < member(1) < admin(2). A capability names its minimum rank.
| Capability | viewer | member | admin | Covers |
|---|---|---|---|---|
project.view |
✓ | ✓ | ✓ | read docs/chats/reviews, download, watch generation streams |
content.edit |
✓ | ✓ | upload documents, push versions, chat, accept/reject edits, run extractions, reshape a review's columns and document set | |
docs.organize |
✓ | ✓ | rename/move documents and create/rename/move/delete folders | |
access.manage |
✓ | project settings, sharing, access grants | ||
container.delete |
✓ | delete the project/review itself |
There is deliberately no tier between member and admin. The earlier draft split collaboration into editor (content) and manager (structure), borrowed from Drive's writer/fileOrganizer pair where it protects a shared drive's taxonomy. A collaborator who may delete every document in a folder one at a time is not meaningfully restrained by being unable to rename the folder; the split only produced a refusal popup on the operation people reach for when tidying up after themselves. The two capability names survive at the call sites even though they now share a rank, because they say different things about why a route is gated and a future tier can move one without hunting for which role === "member" checks meant which.
A caller's role for a project is the strongest of: the creator branch (admin), a direct grant's role, and the org-role inheritance (admin→admin, member→member). strongerRole is unchanged; only the rank table underneath it moved. The same merge exists in SQL as project_access_role() / review_access_role(), so list RPCs and detail routes cannot disagree about who sees what.
What changed
lib/permissions.ts - three roles, five capabilities, as above. Its unit test is now an explicit role × capability table, so a policy change must change a written-out verdict.
lib/access.ts + the new lib/projectAccess.ts - project_access_grants replaces the roleless array as the authorization input. Grants are keyed by normalized email (so a project can be shared with someone who has no account yet, and - the case the review called out - with someone who is not a member of the project's organization at all) and are additive, merged through strongerRole. resolveContentOrgId no longer needs to know who is asking: it inherits a project's org or returns null. isOwner → isCreator, and user_id is string | null throughout with comparisons guarded so a detached row never matches a caller by accident.
projects.shared_with survives as a derived mirror, rewritten from the grant table after every mutation (syncSharedWithMirror). No authorization decision consults it. replaceGrantsFromEmails handles the legacy roleless array by treating it as the set of direct grantees: emails already granted keep their role, only newcomers default to member - a client that cannot express roles must not be able to silently demote an admin collaborator by re-sending a list it did not change.
lib/orgs.ts + routes/orgs.ts + routes/user.ts - two org roles, peer admins, and the full invitation lifecycle:
POST /orgs/:orgId/invitations create (admin)
GET /orgs/:orgId/invitations roster (admin)
DELETE /orgs/:orgId/invitations/:id cancel (admin)
POST /orgs/:orgId/invitations/:id/resend refresh expiry (admin)
GET /user/invitations mine (recipient)
POST /user/invitations/:id/accept join
POST /user/invitations/:id/decline refuse
Three details worth naming. Invitations are addressed to a normalized email, not a user id, which is what makes claim-after-signup work. Expiry is evaluated lazily on read - a pending row past expires_at reports as expired and cannot be accepted, and the stored status stays pending until somebody acts on it; a sweeper job would have raced the accept path for no benefit. And an invitation addressed to somebody else answers 404, not 403, because a 403 would confirm that a given id exists for some other address. Expired invitations are re-openable in place (a re-invite refreshes the row and returns 201) rather than accumulating dead rows, and 410 Gone distinguishes "this existed and is past" from 404's "never heard of it". Every transition is audited (org.invite.created/cancelled/resent/accepted/declined).
routes/projects.ts - sharing becomes per-recipient:
GET /projects/:projectId/access list grants (anyone who can see the project)
POST /projects/:projectId/access grant / re-role one email (admin)
DELETE /projects/:projectId/access/:email revoke one email (admin)
POST is an upsert on purpose: re-sharing with a different role is how a person changes someone's access, and making that a 409 would force the client to guess which verb to send. Reading the list is open to anyone who can see the project, because "who else is on this" is not privileged.
GET /projects/:projectId now returns access_role, org_role, owner_email, owner_display_name and admin_contacts ([{user_id, email, display_name, source: "creator"|"grant"|"organization"}], creator first, then admin grants, then the org's admins). The review found the symptom in the UI: the refusal popup renders an "ask ..." line guarded on an owner email, and the endpoint had never returned one, so the line could not appear under any circumstances. A refusal that cannot say who to ask is a dead end. /people gains each collaborator's role, and its owner may now be null - an organization project outlives the account that created it.
PATCH /projects/:projectId still accepts a roleless shared_with array for the un-revised web client, but no longer writes the column directly, and it now answers 403 rather than 404 when the caller can see the project but may not change its settings: those are different facts and the UI has to tell them apart to say anything useful.
A chat's creator can always continue their own chat. POST /projects/:projectId/chat gated purely on the project role, so a project viewer who had started a chat got a 404 on their next message. The client gates on canEditContent || chatOwnerId === user.id, so it rendered that message locally and then lost it - the server had persisted nothing and said nothing useful. POST /chat already had the creator branch; both ladders now agree, and the refusal is a 403 that says why.
The route sweep. Gates that said structure.manage now say docs.organize; ones that said members.manage say access.manage. Folder create/rename/move/delete all sit at member. Review columns, title, document set and clear-cells sit at content.edit; review sharing is access.manage. Project delete is admin-only and deletes by id rather than by owner - an org project may have no creator left to scope by. Error copy loses Owner/Manager/Editor throughout.
lib/userDataCleanup.ts - a firm's matters survive the account that opened them. partitionOwnedProjects splits the departing user's projects by org_id. Personal ones are destroyed exactly as before; organization ones are detached (user_id → NULL on the project and on the content inside it that the departing user created), and the detach runs before the by-user deletions so those deletions no longer match the rows being kept. On departure the membership goes; if they were the sole admin, the earliest remaining member is promoted first. Where there is no heir at all and the organization still owns projects, the membership row is deliberately not deleted here: neither of the trigger's escape hatches is open yet, so the delete would be refused. The auth.users cascade removes it a moment later, which is exactly the hatch the trigger reserves for account deletion. Pending invitations addressed to the departing account are cancelled, and their grants are deleted with each affected project's mirror rebuilt.
8. Post-review verification round (6104a8f8 ... 4b10a113)
(The commit ids cited in this section and the tables above are the pre-rebase ones from the original revision; the branch has since been rebased onto main at 1b58c7aa. The round itself is unchanged - it now runs 6104a8f8 ... 4b10a113.)
An adversarial pass over the revised model found six things, five of them user-visible. They are separate commits at the head of the branch so they can be read against the revision they audit.
- A creator-less project vanished from every list.
on delete set nullis what lets a firm's matter outlive the associate who opened it - and then the list RPCs dropped those rows on the floor. Every visibility predicate is a three-branch OR, and the grant and org branches carriedp.user_id::text <> p_user_idto avoid re-matching the creator branch. In SQL's three-valued logic a comparison against NULL is not false, it is NULL: withuser_idblanked, branch one yields NULL and branches two and three yieldTRUE and NULL, which is also NULL, andwhere NULLkeeps nothing. The project still existed and was still governed correctly when addressed by id -project_access_rolewas written the safe way - but no admin could find it in a list, a picker or a select-all. 22 predicates across both overloads of the projects, project-ids, project-summaries, filter-options and tabular-review functions are brought to theis null or <>spelling the workflows RPCs already used. The guard never earned its keep as logic anyway:A or (not A and B)is the same set asA or B; it only ever narrowed the result under NULL (2044a99b). - A viewer could build folders by "resolving" a path.
POST /projects/:projectId/folder-paths/resolvechecked only that the caller could reach the project, while its four siblings - create, rename, delete, move - all requiredocs.organize. The name is what hid the hole: "resolve" sounds like a lookup, butresolve_project_folder_pathinserts aproject_subfoldersrow for every path segment that does not exist, which is the whole reason the upload flow calls it. A viewer could POST an arbitrary nested folder tree into someone else's project and have it persist. It now declares the same capability its siblings do, and the test asserts the refusal happens before the RPC is reached (e97e50d1). - A retired role name was quietly downgraded instead of refused. Inviting someone as
ownerreturned 201 and created amemberinvitation. The coercion failed safe, but it answered a request the product no longer supports by silently substituting a different one and reporting success - an integrator migrating off the old vocabulary would have shipped a fleet of accidental members with no signal. It is now a 400, matching its two neighbours (updateMemberandPOST /projects/:id/accessalready refuse). Omitting the role entirely still means member: the distinction is between not choosing and choosing something that does not exist (d7882cb3). - Creator-scoped operations outlived their creator. Replacing or deleting a document version, folding a document in as a version, and moving a review between projects are scoped to authorship rather than to a tier - rules that predate the ladder and are not overturned here. But once account deletion blanks
user_id, "only the creator may act" becomes "nobody may act", forever: a document left by a departed associate could never have a version replaced, an orphaned review could never be moved. The row was detached precisely so the firm would keep it, and then the firm could not touch it.creatorScopedAllowedkeeps the authorship rule while there is an author to name and falls through to the container's admins once there is not - deliberately requiringcontainer.delete(admin rank) rather than the weaker capability the operation might imply, because inheriting somebody else's authorship is an administrative act. The two refusals that still said "owner" now say "creator" (46220bc9). - The firm keeps its content, not just its containers. The durability work retained "organization projects this user created" and destroyed everything else of theirs - which in a firm is close to backwards. Associates do not open matters; partners do. So a leaver's uploads, chats, folders and reviews almost all lived inside somebody else's org project, and every one was swept by the
.eq("user_id", ...)deletions: the firm was left holding an empty matter and no record of what had been in it. Retention now keys on what the organization owns - the project, and therefore its contents, whoever put them there.orgProjectIdsHoldingUserContentfinds every org project the leaver touched, including colleagues', and keeps and detaches those contents while the projects themselves stay with the colleague who still owns them (theprojectsdetach gained its own.eq("user_id", ...)guard so a broader id set cannot blank a living colleague's authorship). Org-tagged rows outside any project are kept too -org_idis the whole claim - and org workflows are kept, because a firm's shared workflow is not the personal property of whoever first drafted it. Personal content is untouched:org_id IS NULLis still destroyed, files and all (f29118f4). - Three more FKs still cascaded from
auth.usersand are converted to SET NULL alongside the original five, because keeping a parent while cascading its children away is its own kind of loss:workflows(org workflows carryorg_idexactly like projects, and cascaded away with their author regardless),tabular_review_chats(the thread hangs off a review that now survives; losing it leaves a review nobody appears to have worked on), andworkflow_reference_documents- which the audit turned up and was not on the original list: it hangs off a workflow whoseworkflow_idalready cascades, so a surviving org workflow stripped of its references is still there and no longer works. The migration's conversion loop is table-driven, so this is three names in an array and stays re-runnable, and the comment above it now records which tables are deliberately excluded (user_api_keys,quick_actions,audit_eventsand friends, where the row is the account's own state and should die with it) (f29118f4).
One non-user-visible fix in the same round: the only test exercising org visibility against a real Postgres was still seeding the schema the review asked us to delete - an organization with personal: false (a column that no longer exists) and an org_members row with role: 'owner' (a value the check constraint refuses). Because it is gated behind SUPABASE_TEST_URL it skips in npm test, so npm run test:stack would have failed in beforeAll with a Postgres error before a single assertion (205d363d).
Post-review fix round (2026-08-26)
A second review round over the revision found twelve further defects, and the rebase onto main at 1b58c7aa (#383, model-selection policy) forced four reconciliation commits. Each fix is its own commit with its own regression test, so the fix can be read against the thing it repairs.
The fixes (2e2cd8a2 ... db7ad0ef):
| Commit | What, and why it mattered |
|---|---|
2e2cd8a2 |
A display mirror must not be able to grant access. The stack-gated document-visibility test seeded sharing the pre-split way - an email in projects.shared_with - so it failed against correct code. It now seeds a real project_access_grants row, and deliberately plants the reviewer's email in the private control project's mirror with no matching grant. If any path ever authorizes off the mirror again, the private document appears and the test goes red. An assertion that something is absent is only as strong as the temptation put in front of it |
9cad668d |
Delete only the bytes whose rows are actually being destroyed. Storage keys are namespaced by uploader (documents/{uploaderId}/...), and account deletion wiped that prefix wholesale. That was safe only while "the uploader's account is gone" and "the uploaded rows are gone" meant the same thing - the durability work broke that equivalence on purpose. The firm was left holding a matter full of document rows whose every version pointed at an object that no longer existed. The rows looked fine; the files were gone. Paths are now read out of the rows being destroyed, not guessed from a key's shape |
eb4de973 |
A failed read is not an answer, least of all a destructive one. Three consecutive reads in deleteUserOrganizations destructured data only. supabase-js never throws, so a timeout arrives dressed as "nothing here" - and the three questions were "are there other admins?", "is anyone else left?", "does the org still own projects?", with deleteByIds(db, "organizations", ...) on the far side. An organization deleted because a query timed out, and projects.org_id is SET NULL, so every matter silently becomes ownerless personal content |
f006ff25 |
Let the FK remove the last admin the trigger will not. The trigger's two escape hatches are facts, not intentions: the org row already gone, or the member's auth.users row already gone. Account deletion had a branch making neither true (keep the org because it owns projects, then delete the membership anyway) and the trigger refused with 23514 - 500ing a request that had already swept storage. The row is now left for the auth.users cascade, which is precisely the hatch reserved for account deletion |
6269d945 |
Stop authorizing the audit feed off a display mirror. accessibleProjectIds was the one access path never moved onto project_access_grants. In the happy path the two agree, which is what made it easy to defend - but shared_with is still writable, _02 backfills it from legacy rows, and this PR's own description says it can be stale. Any divergence handed a stranger somebody else's audit trail: emails, document titles, chat titles, prompt excerpts. An audit log is the wrong place to be relaxed about this |
a4d0f11f |
Make a stale shared_with mirror audible. syncSharedWithMirror correctly must not fail its caller - the grants already landed, and a retry would replay a mutation that succeeded. But "must not fail the caller" and "may be ignored" are different decisions, and supabase-js gives the second one for free. Every failure mode produced behaviour identical to success. The failure is now logged with the project id; the function still returns normally |
f5e31103 |
Report internal failures as internal, not as Postgres. sendOrgFailure's db_error arm returned error.message straight off supabase-js - raw Postgres text, which names schemas, constraints and, in DETAIL:, somebody's email address. Honest scope: nothing leaked, because middleware/internalErrorResponse.ts rewrites every ≥500 body. That is a reason to fix it calmly, not to leave it - a load-bearing backstop has stopped being a backstop, and depth is only depth when each layer is independently right |
2e8665fd |
Accepting an invitation applies the role it reports. For someone already in the org the endpoint skipped the insert (correct) and still answered role: invite.role (a claim it had not applied). A member accepting an admin invitation was told admin and stayed a member - which is how you promote somebody by invitation. Roles that arrive from more than one direction must combine as floors: effectiveRole is now computed, written, returned and audited, with invited_role kept alongside because "offered" and "took effect" are separate facts |
a3e67215 |
lower() the caller's email in the last two review predicates. Eleven arms spelled it lower(p_user_email); the direct-share arms of get_tabular_reviews_overview and get_tabular_review_ids_overview did not. A case mismatch does not raise - it returns zero rows, which renders as "you have nothing here". This is the exact symptom replication row 56 claims to fix |
b5e26b77 |
A NULL owner is not a filter option, it is a filter bypass. Making projects.user_id nullable made a previously-impossible CTE row possible, so the owner dropdown offered {"value": null, "label": "Shared"}. Choosing it sent p_owner_user_id = null - which the codebase's optional-filter idiom reads as "no filter requested". The user asked to narrow and got the complete unfiltered list, with no error and no empty state. A relaxed constraint breaks no query; it invalidates every assumption downstream code was allowed to make while it held |
f6838245 |
Restamp a review's org_id when it moves between projects. tabular_reviews.org_id is denormalized and an authorization input, so a stale copy is a permission that should have been revoked. Moving a review out of an org project into a personal one kept the firm's whole membership able to see it; moving one in left it invisible to the firm. The recompute reuses resolveContentOrgId and sits inside if (projectIdUpdateProvided), so an unrelated edit never turns into a permission change |
db7ad0ef |
Make organization workflows creatable and editable. workflows.org_id was write-dead in both directions: POST /workflows hardcoded const orgId = null, and both the SQL org arm and resolveWorkflowAccess reported allow_edit: false. The only org workflows the system could ever hold were the orphans account deletion produced, which nobody could edit - and replication row 63 asked a reader to create one, which no sequence of API calls could perform. See the tradeoffs bullet: two calls here want maintainer sign-off |
The #383 reconciliation (ec99e7e6 ... d6058c22) - a rebase can merge text cleanly and still merge meaning wrongly, so the semantic half is kept in its own commits:
| Commit | What, and why it mattered |
|---|---|
ec99e7e6 |
A late SSE write must be dropped, not thrown after the fact. Described under Testing above. The code was byte-identical to main's; this branch's extra tests merely shifted full-suite timing enough for a latent race to lose reliably |
64864d72 |
Reconcile #383's model selection with the ladder's access verdicts. Three collisions: #383's PATCH /chat/:chatId reads the row straight out of getAccessibleChat, which the ladder rewrote to return a verdict ({ok, chat, projectRole}); #383 resolves an effective model before any chat write, so RBAC tests written earlier died 400 model_required before reaching the permission check they exist to pin; and #383's two new describes pushed the file past the chat limiter's 30-per-15-minutes budget, so the RBAC block began answering 429 before any handler ran. The handler unwraps the verdict and keeps both policies - rename stays creator-only, model/reasoning follow main's rule at this layer - and a vi.hoisted() env raise gives the file a limiter budget it fits inside (hoisted because app.ts builds its limiters at import time) |
e16a8693 |
Renumber the org migrations past the model-selection set. 20260825_02..04 → 20260831_01..03. See Migrations for the full reasoning; the short version is that a rebase keeps a diff clean and does not keep the filename-ordering invariant, and every upgraded database would have skipped these files in silence |
d6058c22 |
The org visibility RPC must keep the model column #383 gave it. Replaying the upgrade for real - main's schema.sql, then this branch's migrations - died at 20260831_03 with Postgres 42P13, "cannot change return type of existing function", after _01 and _02 had already applied. #383 added model to get_chats_overview's row; this migration's replacement, written before #383 existed, declared the old row. Both copies now carry it, byte-identical between migration and schema.sql (checked mechanically - the drift gate fingerprints function definitions). Unit tests cannot see this class of failure; only actually replaying the migrations over a main-shaped database can |
Why it changed
Three of the requested changes fix things that were actively wrong, not merely complicated:
ownerwas not a role. It was a row'suser_id, permanently attached to whoever happened to create the project, making the creator structurally different from every other administrator forever. For firm content that is exactly backwards: the person who opened the matter is often not the person still running it a year later.- A roleless
shared_witharray could say who and nothing else. "Let outside counsel read this matter" and "let a colleague restructure it" were literally the same operation, and every collaborator landed on one hard-coded tier. - An admin's typo enrolled a stranger into a workspace of confidential matters, and the person it happened to was never asked.
POST /orgs/:orgId/memberstook an email, resolved it to an account, and inserted a membership row.
And personal organizations cost more than they bought. user_id already anchored personal content, so the hidden org bought nothing - but getPersonalOrgId had to self-heal a signup trigger that deliberately swallows its own errors, resolveContentOrgId needed a user id purely to find that fallback, the org tables carried a row per account that existed only to be ignored, and "is this org real?" became a question every membership path had to remember to ask.
Base-case replication - the gap on main
Run the stack from docs/local-development.md (docker compose up --build; frontend :3000, backend :3001, Supabase gateway :54321) on main at 6a62d01a. Bearer tokens come from POST http://localhost:54321/auth/v1/token?grant_type=password using the well-known local anon key in docker-compose.yml; send them to :3001 as Authorization: Bearer <token>.
Accounts: A (project creator), B (a colleague), C (outside counsel - should be read-only).
- There is no tenant.
GET /orgs→ 404 (the route does not exist).select * from organizations→ relation does not exist. The only unit of access is a single user. - Sharing is per-row and roleless. The only way to give B access to A's project P is to put B's email in
P.shared_with. Do it for a second project and you do it again; there is no group and no directory. - There is no way to express "read-only". Add C to
P.shared_with. As C: upload a document, rename it, create a folder, rename a folder, move a folder, clear a review's extracted cells → 200 each. C was meant to read one matter; C can reshape it. - One boolean is the entire client contract.
GET /projects/<P>returnsis_ownerand nothing else - noaccess_role, noowner_email, noadmin_contacts. There is no vocabulary for "may edit content but not manage access", which is why the UI in #268 could only ever be right for one of two states. - A refusal cannot name anyone. Nothing in
GET /projects/<P>orGET /projects/<P>/peoplegives the client an administrator's address, so the "only an admin can do that" popup has no one to point at. - A delete that deletes nothing reports success. As B (in
shared_with, not the creator):DELETE /tabular-review/<R>where R is a review A created inside P → 204 No Content, and the review is still there on reload. The write was.eq("user_id", userId); a filter, not an authorization check. - Deleting an account destroys the matters. Delete A's account. Every project A created - and every document, chat and review inside - is cascaded away, including work B and C were collaborating on. There is no durable owner other than the person who happened to click New Project.
- Directory search 500s for every caller.
GET /projects?view=directory-search&q=test→ 500..contains("shared_with", [email])serialises a JS array as the PgArray literal{a@b.com}, which Postgres rejects for ajsonbcolumn (projects.ts:437). - The audit feed under-reports. As B, generate an event in A's shared project, then
GET /audit. The event is absent - the identical containment misuse ataudit.ts:34, except the error is swallowed rather than surfaced. The fix is no longer "correct the containment literal":accessibleProjectIdsnow readsproject_access_grantsby normalized email, like every other access path, so the audit feed stops authorizing off a display mirror at the same time as it stops under-reporting. - A review shared with a mixed-case address is a ghost. Put
"B@Example.com"in a review'sshared_with. As B (b@example.com):GET /tabular-review/<id>→ 200, but the review never appears inGET /tabular-review. List and detail disagree.
PR replication - the same stack on this branch
Check out olp-pr/organizations-rbac (tip d6058c22), apply 20260831_01, _02, _03 in order (idempotent - verified applying twice over a main-shaped database at 1b58c7aa), restart the backend.
Accounts: A (creates the org and project P ⇒ project admin), D (org admin ⇒ project admin), M (plain org member ⇒ project member), V (holds a viewer grant on P, not in the org), G (holds a member grant on P, not in the org), N (outside@counsel.test - a valid address with no Mike account), X (outsider).
1. Organization lifecycle and invitations (18 rows)
| # | Do this | Expect |
|---|---|---|
| 1 | As A: POST /orgs {"name":"Firm"} |
201, role: "admin", member_count: 1 - the creator is the first admin, no separate owner tier |
| 2 | POST /orgs/<org>/members {"email":"<D>"} |
404 - the route does not exist any more. Nobody is added by an admin typing an address |
| 3 | As A: POST /orgs/<org>/invitations {"email":"<D>","role":"admin"} |
201, status pending |
| 4 | As D, before accepting: GET /orgs/<org>, GET /projects |
404 on the org; P is absent. A pending invitation grants nothing at all |
| 5 | As A: GET /orgs/<org>/members |
D is not in the roster. Only GET /orgs/<org>/invitations shows them |
| 6 | As D: GET /user/invitations |
The invitation, with org_name, role, invited_by_email, expires_at |
| 7 | As D: POST /user/invitations/<id>/accept |
200 {org_id, role:"admin"}. Now GET /orgs/<org>/members includes D |
| 8 | Repeat 3-7 for M at role:"member", and have a third invitee call POST /user/invitations/<id>/decline |
204; the decliner never appears in the roster |
| 9 | As A: POST /orgs/<org>/invitations {"email":"<N>"} - an address with no account |
201. Invitations are addressed to an email, not a user id; N claims it after signing up |
| 10 | As A: invite the same address again while the first is live | 409 That email already has a pending invitation |
| 11 | As A: invite somebody who is already a member | 409 That person is already a member of this organization |
| 12 | As A: invite your own address | 400 You are already a member of this organization. Invite "notanemail" → 400 A valid email address is required |
| 13 | As M (plain member): POST /orgs/<org>/invitations, GET /orgs/<org>/invitations, PATCH /orgs/<org> |
403 Only an organization admin can do that. each |
| 14 | As X (not a member): any /orgs/<org>/... route |
404 Organization not found - the role lookup fails before the admin check, so membership is never confirmed to an outsider |
| 15 | As X: POST /user/invitations/<D's invitation id>/accept |
404 Organization not found, not 403 - a 403 would confirm the id exists for some other address |
| 16 | Set an invitation's expires_at into the past, then as its recipient POST .../accept |
410 That invitation has expired. The stored status is still pending (expiry is lazy). GET /user/invitations filters it out |
| 17 | As A: POST /orgs/<org>/invitations/<id>/resend on that expired row, then have the recipient accept |
200, expires_at pushed 14 days out, accept now succeeds. Re-inviting the same address instead refreshes the row in place and returns 201 - no dead rows accumulate |
| 18 | As A (sole admin): PATCH /orgs/<org>/members/<A> {"role":"member"}, or DELETE /orgs/<org>/members/<A> |
409 An organization must keep at least one admin. Enforced twice: the service's read-then-act check, and trigger org_members_last_admin_guard, which locks the organizations row for update so two concurrent admin departures cannot both win. Kill the service check and the DB still refuses with SQLSTATE 23514 |
Also worth asserting: POST /orgs/<org>/invitations/<already-accepted id>/resend → 409 That invitation has already been answered, and every one of the above writes an org.invite.* audit event.
2. Per-recipient sharing (10 rows)
| # | Do this | Expect |
|---|---|---|
| 19 | As A: POST /projects {"name":"P","org_id":"<org>"} |
201. Passing an org_id you do not belong to → 400 You are not a member of that organization. |
| 20 | As A: POST /projects/<P>/access {"email":"<V>","role":"viewer"} |
201 - the case the review asked for: an outside individual, read-only, on an organization project, without joining the organization |
| 21 | As A: POST /projects/<P>/access {"email":"<N>","role":"member"} - no Mike account |
201. Grants are keyed by normalized email, so outside counsel can be invited before they sign up |
| 22 | As A: re-post for V with {"role":"member"} |
201, the grant is re-roled in place. Deliberately an upsert, not a 409 - a client should not have to guess which verb changes a role |
| 23 | As A: GET /projects/<P>/access |
{org_id, access_role, grants:[{email, role, ...}]} |
| 24 | As M (org member, no grant): GET /projects/<P>/access |
200 - "who else is on this" is not privileged |
| 25 | As M: POST /projects/<P>/access {...} and DELETE /projects/<P>/access/<email> |
403 Only a project admin can change who has access. |
| 26 | As A: DELETE /projects/<P>/access/<uri-encoded email> |
204. Repeat → 404 Access grant not found |
| 27 | As A: POST /projects/<P>/access with your own address, then with {"role":"editor"} |
400 You cannot share a project with yourself. and 400 role must be admin, member or viewer |
| 28 | As A: PATCH /projects/<P> {"shared_with":["<V>","<X>"]} (the un-revised web client's roleless call) |
200. V keeps viewer - a roleless client cannot demote or promote anyone - X is added at member, anyone dropped from the array loses their grant, and projects.shared_with is rewritten from the grants afterwards |
3. Inheritance and strongest-wins (7 rows)
| # | As | Call | Expect |
|---|---|---|---|
| 29 | D (org admin) | GET /projects/<P> |
200, access_role: "admin", org_role: "admin", is_owner: false - an org admin administers the org's projects without owning any row |
| 30 | M (org member) | GET /projects/<P> |
200, access_role: "member", org_role: "member" |
| 31 | V (viewer grant, no org) | GET /projects/<P> |
200, access_role: "viewer", org_role: null |
| 32 | X | GET /projects/<P>, GET /projects, the paginated list, /projects/ids, project summaries, ?view=directory-search |
404 on detail, absent from all five lists, and directory search returns 200, not 500 (the containment fix) |
| 33 | M | after POST /projects/<P>/access {"email":"<M>","role":"admin"} |
access_role: "admin" - a grant promotes |
| 34 | D | after POST /projects/<P>/access {"email":"<D>","role":"viewer"} |
access_role: "admin" - a grant can never demote. Same in the review direction: sharing a review with its own project's admin must not drop them |
| 35 | M | GET /projects and GET /tabular-review |
Every row carries access_role, computed by project_access_role() / review_access_role() - the SQL twins of strongerRole. List and detail cannot disagree, and the client needs no N+1 of detail fetches to render affordances |
4. The write-gate matrix (12 rows)
| # | As | Attempt | Expect |
|---|---|---|---|
| 36 | V (viewer) | upload a document, create a folder, chat, generate a review, clear cells | Refused end to end - read-only |
| 37 | M (member) | upload, push a version, chat, accept an edit, rename/move a document | 200 each |
| 38 | M (member) | create / rename / move / delete a folder | 200 each. This is the loosening the review asked for: folder delete was creator-only, and docs.organize now sits at member |
| 39 | M (member) | PATCH /tabular-review/<R> with columns_config, title or document_ids; POST /<R>/clear-cells |
200 each - reshaping a review is content work |
| 40 | V (viewer) | the same four | 403 Only a review member can change columns / ... change review settings / Only a review member can clear cells |
| 41 | M (member) | PATCH /projects/<P> {"name":...} |
403 Only a project admin can change project settings. - note 403, not 404: M can plainly see the project, and the UI has to tell "denied" from "does not exist" |
| 42 | M (member) | PATCH /tabular-review/<R> {"shared_with":[...]} |
403 Only a project admin can change sharing |
| 43 | D (org admin) | all of 38-42, on a project they did not create | 200 each - no row ownership required anywhere |
| 44 | D (org admin) | DELETE /projects/<P> and DELETE /tabular-review/<R> (a review M created) |
204 each, and the rows are really gone. On main the review delete answered 204 and deleted nothing; the project delete now runs by id, because an org project may have no creator to scope by |
| 45 | M (member) | the same two deletes | 403 Only a project admin can delete this project. / You do not have permission to delete this review, with no destructive statement issued |
| 46 | X | the same two deletes | 404 - the capability check never becomes an existence oracle |
| 47 | V (viewer) | start a chat in P, then send a second message to that chat | The first send is 403 You do not have permission to write in this project.; a chat V already created continues to accept messages. The client's canEditContent || chatOwnerId === user.id and the server's ladder now agree, so a message is never rendered locally and then silently dropped |
5. Durability and account deletion (6 rows)
| # | Do this | Expect |
|---|---|---|
| 48 | Delete A's account (A created org project P, plus a personal project Q) | Q and everything in it is destroyed as before. P survives, with projects.user_id = NULL; the documents, chats, reviews and folders A created inside P survive with their user_id nulled |
| 49 | As D: GET /projects/<P> and GET /projects/<P>/people |
200; owner: null, owner_email: null, and admin_contacts still names D and the org's other admins |
| 50 | As D: delete P, rename a folder in it, add a grant | All still work - the organization is the durable owner and its admins lost nothing when the creator left |
| 51 | Delete the account of an org's sole admin while other members remain | The earliest remaining member is promoted to admin first, then the departing membership row is deleted. Never a window with no admin, and the trigger never has to reject the delete |
| 52 | Delete the sole member of an org that still owns projects | The organization is kept, and the membership row is left for the auth.users cascade to remove moments later. Deleting the org would SET NULL the org_id on those projects; deleting the membership here would trip org_members_protect_last_admin (SQLSTATE 23514) - both the org row and the member's auth.users row still exist at that point - and 500 the request with storage already swept. An org with no members and no projects is still deleted outright |
| 53 | Delete an account with pending invitations addressed to it, then export another user's data | Those invitations are cancelled. The GDPR export drops teams (gone) and gains the user's invitations |
6. Pre-existing bugs closed on the way (3 rows)
| # | Do this | Expect |
|---|---|---|
| 54 | GET /projects?view=directory-search&q=test as anyone |
200 (was 500 for every caller on main) |
| 55 | As a shared collaborator: generate an event, then GET /audit |
The event appears - the same jsonb containment misuse, swallowed rather than thrown |
| 56 | Open a review whose shared_with holds a mixed-case address, then list reviews |
Visible in both (migration _03 normalizes the legacy rows) |
7. Post-review verification round (7 rows)
Each of these fails on the branch as it stood at bfbb2659 - that is, against the revision itself, not against main.
| # | Do this | Expect |
|---|---|---|
| 57 | Delete A's account, then as D (org admin) call GET /projects, the paginated list, /projects/ids, project summaries and /projects/filter-options |
P appears in all five. At bfbb2659 it appeared in none of them while GET /projects/<P> still returned 200 - governed correctly by id, unfindable in the UI |
| 58 | As V (viewer): POST /projects/<P>/folder-paths/resolve {"path":"Evidence/2024/Q3"} |
404, and no project_subfolders rows are written - assert the refusal precedes the RPC. At bfbb2659 a read-only viewer could persist an arbitrary nested folder tree into someone else's project |
| 59 | As A: POST /orgs/<org>/invitations {"email":"<X>","role":"owner"} |
400. At bfbb2659 this returned 201 and silently created a member invitation |
| 60 | Same call with the role key omitted |
201 at member - the documented default is untouched; only choosing a role that does not exist is refused |
| 61 | Delete the account of a document's uploader inside an org project, then as D: replace that document's version, delete a version, and move an orphaned review to another project | All succeed. creatorScopedAllowed falls through to container.delete once there is no author to name. At bfbb2659 these were permanently impossible for everyone, and the refusals said "owner" rather than "creator" |
| 62 | As M (org member): upload documents, start chats and create folders inside A's org project P. Then delete M's account | P keeps every one of them, with their user_id blanked. At bfbb2659 all of M's content was swept, leaving the firm an empty matter - while P itself correctly stays with A |
| 63 | As M: POST /workflows {"metadata":{...},"org_id":"<org>"}, attach reference documents, have D edit it (200 - org workflows are editable by the org), then delete M's account |
The workflow and its reference documents survive. workflows, tabular_review_chats and workflow_reference_documents joined the SET NULL set - verify with select conrelid::regclass, confdeltype from pg_constraint where contype='f' and conname like '%user_id%': eight tables now report n |
Tradeoffs and design decisions
projects.shared_withis kept as a derived legacy mirror, not deleted. Nothing reads it for authorization - it is rewritten fromproject_access_grantsafter every mutation purely so the un-revised web client keeps rendering. It goes away with #268; keeping it here is what makes this PR backend-only and independently mergeable. The cost is a column that can be stale if someone writes it directly, which is why every write path goes throughsyncSharedWithMirror- and a failed refresh is now logged loudly rather than swallowed, since a stale mirror is invisible from every other angle.- There is no outbound email for invitations. Invitations are in-app only: an admin creates one, the recipient sees it at
GET /user/invitations(and, in #268, at the top of Settings → Organizations). "Resend" therefore refreshesexpires_at- it does not send anything, because there is no mailer in this repo to send with. That is a deliberate scope line, not an oversight: wiring a transactional email provider is its own PR (template, deliverability, an unsubscribe story, a secret). Named here because "resend" is a verb that promises something the button does not currently do. - An organization with no members is kept if it still owns projects. The obvious cleanup - delete an org once its last member leaves - would
SET NULLtheorg_idon its projects and strand exactly the content the durability work exists to protect. So a memberless-but-project-owning org survives as an orphan container, reachable only by re-granting membership out of band. The alternative (cascade-deleting the projects) is the bug; the alternative-alternative (refusing the last member's departure) traps people in an org. Flagged as the least-bad of three. user_idbecame nullableON DELETE SET NULLon eight tables, not justprojects-projects,project_subfolders,documents,chats,tabular_reviews, and (added by the verification round)workflows,tabular_review_chats,workflow_reference_documents. Detaching only the project row would not have helped: theauth.userscascade that follows account deletion would have taken every document, chat and review inside it anyway. The conversion is a table-drivenDOblock that looks up each FK by name and skips constraints already converted (confdeltype <> 'n'), so the migration re-runs cleanly and adding a table is one array entry. The exclusions are deliberate and now recorded in the migration:user_api_keys,quick_actions,audit_eventsand friends keep their CASCADE, because there the row is the account's own state and should die with it. The consequence is thatuser_idisstring | nullthroughout the TypeScript, every comparison had to be guarded so a detached row cannot match a caller by accident, and every SQL predicate had to be made NULL-safe - which is a real class of bug, not a theoretical one (see rows 57 and 61).- Detached content is inherited by the container's admins, not frozen. Once
user_idis NULL, an operation scoped to authorship has no author, socreatorScopedAllowedfalls through tocontainer.delete- admin rank, deliberately stronger than the capability the operation would otherwise imply, because taking over somebody else's authorship is an administrative act rather than a content one. The alternative was leaving orphaned versions and reviews permanently untouchable, which defeats the point of keeping them. The cost is that "only the creator" is now "only the creator, or an admin once the creator is gone" - a rule with a second clause, which is worth stating in the UI when #268 surfaces it. - An expired invitation answers 410, not 404 or 403. 404 would make an expired invitation indistinguishable from one that never existed, so the recipient could not tell "ask for a new one" from "you have the wrong link". 403 would be wrong twice over - nothing about the caller is at fault.
410 Goneis the one status that says "this existed and is past", and #268 maps it to "That invitation has expired. Ask an admin to send a new one." - The last-admin guard is enforced in the database, not only in the API.
org_members_protect_last_admin()is asecurity definertrigger withset search_path = public, firingbefore delete or update of role, takingselect ... for updateon the organizations row so two concurrent admin departures serialise, and raising SQLSTATE23514. The service's own read-then-act check stays because it produces a better message on the common path; the trigger is what makes the invariant true under concurrency, and the service maps23514to the samelast_adminverdict so both paths answer identically. Two escape hatches are deliberate: the org row already gone in-transaction (org-delete cascade), and the member'sauth.usersrow already gone (account-delete cascade). POST /projects/:projectId/accessdoes no account-existence check;PATCH /projects/:projectId'sshared_withstill does. The grant endpoint is the outside-counsel path - granting to an address before it has an account is the point, and refusing it would defeat the review's request. The legacy PATCH path keeps itsdoes not belong to a Mike user400 because the un-revised client shows no roles and no pending state, so a silent grant to a typo'd address would be invisible there. The asymmetry is intentional and disappears with #268.- Enforcement lives in application code; RLS is defence in depth. The API runs as
service_rolefor every request, so splitting enforcement across two layers means two places to get it wrong and no single test surface. The four new tables ship RLS-enabled with no policies andanon/authenticatedrevoked, so any future direct-client path starts from deny rather than allow. - The access predicate is duplicated in SQL, but only twice.
lib/access.tsdecides detail access; the list RPCs decide list visibility, and they must agree branch for branch or a user sees rows they cannot open. In the previous revision ten RPCs each restated the predicate by hand. They now all callproject_access_role()/review_access_role(), which is both the single definition and what makes returningaccess_roleon every list row cheap enough to do at all. - Refusals are 403 where the caller can already see the container and 404 where they cannot.
PATCH /projects/:idnow answers 403 rather than the old 404 (a real fix - the previous revision's live round recorded that asymmetry as a defect). Folder routes still answer 404 on refusal; they never adopted the split, and doing it in this PR would have widened an already large route sweep. Named because you need it to write assertions. workflows.tskeeps its own{allowEdit, isOwner}shape and never callscan(). Workflows have a third access scheme, including system workflows with a nulluser_id; folding them into the ladder is a separate change, named rather than hidden. What this PR does add is a coherent org arm on both sides:POST /workflowstakes an optionalorg_id, validated exactly asPOST /projectsvalidates its own, and an org member may edit an org workflow (both org roles sit atmember+ where editing content lives). Anything less was write-dead - no API call could create an org workflow and no one could edit one. Share and delete stay creator-scoped, so a detached org workflow can be edited by the firm but not deleted or shared; extendingcreatorScopedAllowedto cover them is a follow-up. ⚠️ Two calls here await maintainer sign-off: member-tier (not admin-tier) editing, and the creator-scoped share/delete gap.- Version replace and delete stay creator-gated, not capability-gated. The matrix has no vocabulary for "the person who uploaded this file", and inventing one to model two routes would have put a lie in the shared table.
- The audit feed reads the grant table, and gets no org branch.
accessibleProjectIdsauthorized offprojects.shared_with- the derived mirror - which broke the model's one load-bearing rule, that no authorization decision consults it. It now readsproject_access_grantsby normalized email like every other access path. Visibility is unchanged (own ∪ direct-grant): whether an org admin should see a colleague's document downloads is its own design question and deserves its own PR. - Teams are removed, not deleted. The tables, endpoints, cleanup and export code are preserved unmodified at
1e0f1478. They come back as their own PR with a team-scoped grant model, database-enforced membership, cascading cleanup and auditing - the conditions the review set - rather than riding along here as structure with no meaning.
Migrations
Three files, 20260831_01..03 - renumbered from 20260825_02..04 in e16a8693. While this branch was in review, main merged #383, whose migrations occupy 20260826_01 through 20260827_03, so every existing deployment is already recorded at 20260827_03. Deployments apply only files sorting after their recorded version, so shipping as 20260825_02..04 would have had every upgraded database judge them already-applied and silently skip them: fresh installs (schema.sql) would have organizations and every upgraded database would not - the exact split-brain the numbering convention exists to prevent. A rebase keeps a branch's diff clean; it does not keep this invariant, because sort position is decided at merge time against whatever main has shipped meanwhile. 20260828 is the first free date after #383's future-dated 20260827 set, and every in-tree reference (tests read migrations by path) moved with the files.
| File | Does |
|---|---|
_01_organizations.sql |
organizations, org_members (check role in ('admin','member')), org_invitations (normalized-email CHECK, status CHECK, 14-day expires_at, partial unique (org_id, email) where status='pending'), project_access_grants (check role in ('admin','member','viewer'), unique (project_id, email)); the org_id columns and indexes on the four content tables; the org_members_last_admin_guard trigger; RLS enable, anon/authenticated revokes and explicit service_role grants; and the user_id relaxation on the eight tables listed above, via a table-driven loop that also documents which tables deliberately keep CASCADE |
_02_org_data_migration.sql |
Data only. Backfills project_access_grants from every existing shared_with entry at role member, then rewrites shared_with from the grants. Also normalizes tabular_reviews.shared_with (lowercase, trim, dedupe, preserve first position) - the mixed-case rows that detail endpoints admitted but the containment-based list RPCs could never find |
_03_org_rpcs.sql |
SQL functions only. The two role helpers, plus the 12 org-aware overview/filter RPCs - with all 22 "not my own row" predicates written NULL-safe (user_id is null or user_id::text <> p_user_id), so a project whose creator's account is gone still lists. get_project_filter_options' owner CTE carries where vp.user_id is not null, because a NULL owner is not a filter option - offering it sent p_owner_user_id = null, which the optional-filter idiom (p_owner_user_id is null or ...) reads as "no filter requested", so the control silently returned the complete unfiltered list. get_chats_overview keeps the model column #383 gave it in both signatures, byte-identical between this file and schema.sql; without that, create or replace either raises 42P13 mid-upgrade or silently strips a column the chat rail reads. Four drop function if exists (both overloads each of get_projects_overview and get_tabular_reviews_overview) because they gain an access_role output column and PostgreSQL will not let create or replace change a result type - which is also the change that finally populates owner_email, declared on the project overview's result shape and selected as null::text since the day it was written |
handle_new_user reverts to main's version: with personal orgs gone the signup trigger has no org work to do, so the migration simply stops touching it, and schema.sql's copy reverts to match - a fresh install must not create what an upgrade does not.
backend/schema.sql is updated in the same change. _02 is DML and correctly has no schema.sql counterpart.
Testing performed
Automated, on tip d6058c22 (rebased onto main 1b58c7aa):
cd backend && npx tsc --noEmit→ clean.npx vitest run→ 1009 passed | 28 skipped, exit 0.- The unhandled
ERR_STREAM_WRITE_AFTER_ENDpreviously noted here as harmless is fixed on this branch (ec99e7e6). It was never harmless: an unhandled'error'on a response stream can take the whole process down, so one disconnected client becomes an outage for every connected one.routeStreaming.ts'swrite()now refuses once the response is over - our ownfinish()ran, orres.writableEnded- andfinish()is idempotent, because Node raises that error asynchronously on the stream's'error'event where notry/catcharoundres.write()can observe it.
New test files - 112 declared cases - plus +70 added to existing suites, +26 of them from the post-review fix round (see below). Every fix commit carries its own regression test.
| File | Cases | Pins |
|---|---|---|
lib/__tests__/permissions.test.ts (new) |
19 | The full 3 × 5 matrix, loop-generated from an explicit table, plus fail-closed on null/unknown and on the retired names owner/manager/editor |
lib/__tests__/orgs.test.ts (new) |
29 | The service-layer model: creator-becomes-admin, last-admin from both the check and the trigger's 23514, the whole invitation lifecycle (create/duplicate/already-member/self, cancel, resend, lazy expiry, accept, decline, wrong recipient, already answered), and claim-after-signup by email |
__tests__/integration/orgs.routes.test.ts (new) |
29 | The seven invitation endpoints and the org routes end to end, including every status code in matrix 1 above |
lib/__tests__/projectAccess.test.ts (new) |
10 | Grant CRUD, the upsert re-role, replaceGrantsFromEmails preserving existing roles, the mirror rebuild |
lib/__tests__/userDataCleanup.orgs.test.ts (new) |
7 | Detach-not-destroy, sole-admin promotion before removal, memberless-org retention when projects remain, invitation cancellation |
lib/__tests__/orgSqlPredicates.test.ts (new) |
7 | Static assertions over the shipped SQL, read from both the migration and schema.sql and asserted to agree: no stored value compared against a bare p_user_email (every arm is lower(...)), the owner-filter CTE excludes NULL owners, and the org arm of get_workflows_overview reports allow_edit. Executing these predicates needs a live Postgres and the stack suite does not run under npm test; grepping the SQL is a poor substitute for running the query and a very good substitute for nothing - and it catches the exact way each was introduced, one arm of a repeated predicate written by hand |
__tests__/integration/tabularPagination.supabase.test.ts (new, stack-gated) |
11 | Org visibility in the paginated and bulk-ids RPCs against a real Supabase stack - skipped without SUPABASE_TEST_URL + SUPABASE_TEST_SERVICE_ROLE_KEY |
lib/__tests__/access.test.ts |
+13 | The inheritance table (org admin → project admin, org member → project member), strongest-wins in both directions, an outsider admitted by grant alone with no org membership, cross-tenant denial, and a personal project staying outside every org's reach |
integration/projects.routes.test.ts |
+5 | The /access endpoints, the 403-vs-404 split on PATCH, container.delete on project delete |
integration/tabular.routes.test.ts |
+5 | content.edit on columns/document-set/clear-cells, access.manage on review sharing, and container.delete closing the silent-204 |
integration/chat.routes.test.ts |
+6 | Chat writes at content.edit, and the project-chat creator carve-out |
routes/__tests__/audit.test.ts |
+1 | The jsonb containment fix |
Database, verified independently for this description - scratch Postgres, main-shaped baseline (schema.sql at 1b58c7aa, i.e. with #383 in place), then 20260831_01, _02, _03:
- All three apply cleanly, and again on a second pass - 0 errors on both passes.
- Post-apply catalog inspection confirms: tables
organizations,org_members,org_invitations,project_access_grantsand noteams/team_members; functionsproject_access_role,review_access_role,org_members_protect_last_admin; triggerorg_members_last_admin_guardonorg_members; and exactly eightuser_idforeign keys atconfdeltype = 'n'(SET NULL) -projects,project_subfolders,documents,chats,tabular_reviews,workflows,tabular_review_chats,workflow_reference_documents- with the columns nullable to match. - Schema-drift check replicated the way CI runs it - the upgraded database above versus a fresh install of this branch's
schema.sql, each reduced bybackend/scripts/schema-fingerprint.sql→ 4164 fingerprint lines, identical,NO DRIFT. Fresh installs and upgraded deployments converge exactly.
Live. A ten-flow browser round against the revised model was recorded on 2026-08-26 - see Live demo above; the replication tables in this description are the script it follows. The three earlier rounds - 2026-08-21, 2026-08-18, 2026-08-16 - are retained for history, but every one of them exercised owner/manager/editor, personal orgs and admin-added members. Their matrices no longer replicate and should not be read as evidence for this branch.
Provenance
The original schema, org module and tenant stamping began as a port of amal66/mike@b3166dd. Almost none of that survives this revision: the role ladder, project_access_grants, lib/projectAccess.ts, the invitation lifecycle, the SQL role helpers and the account-deletion detach are all new code written for this PR in response to the review. The fork will adopt the same model.
Credits & prior art
- @Chris-o-O (Chris-o-O/mike-explor) - independently parallels this work: their fork built organizations and team management on top of Mike. Different implementation, same conviction that a firm is not one user.
- The three-role ladder follows matter-workspace conventions from legal practice tools - private by default, container-rooted inheritance, administrative oversight that does not require owning the row - rather than Drive's writer/fileOrganizer split, which the review correctly identified as a distinction legal teams do not need.
🤖 Generated with Claude Code
Second fix round - 2026-08-26 (architecture re-review)
A second code-level review pass at d6058c22 surfaced four blockers, fixed here with one regression test each. New tip: 17ed10b7 (this branch's diff only; the stack above was rebased on top).
| Fix | Commit | Base case (at d6058c22) |
After |
|---|---|---|---|
POST /projects/:id/chat persisted the model before the write gate. A project viewer naming a colleague's chat_id with a different model permanently changed that thread's model, then got the 403. |
320b87cb |
As a viewer: POST /projects/P/chat {chat_id: <colleague's>, model: <other>} → 403 and chats.model changed. |
403 and nothing written; the regression test records every UPDATE the route issues and asserts a refused request writes none. |
Workflows never got the heir clause. All four creator-scoped routes (delete, list/create/revoke shares) filtered .eq("user_id", userId), which nothing satisfies once account deletion detaches user_id → NULL - a detached org workflow was editable by the whole org but deletable/shareable by nobody, forever. Delete was also a silent 204 for any non-creator. |
f91298b7 |
Delete the creator of an org workflow → as org admin DELETE /workflows/:id → 204, row still there on re-read. |
One resolver applies creatorScopedAllowed's rule: creator, or org admin of a detached org workflow. Non-creators now get an honest 404; reference-file cleanup keys on the workflow, not the caller. |
Org deletion judged "owns nothing" on projects alone. Sole member leaves; org owns zero projects but org workflows → org deleted → ON DELETE SET NULL blanks org_id on the workflows the same cleanup just detached → rows with no creator and no org, reachable by nothing. |
9bf7bede |
Seed: one-member org, no projects, one org workflow → delete the account → org gone, workflow stranded (user_id NULL, org_id NULL). |
The emptiness probe walks ORG_CONTENT_TABLES (projects, documents, workflows, tabular_reviews); the org survives while any of them holds a row. |
Storage bytes died before their rows, non-transactionally. Account deletion deleted version files in step 4 and the rows in steps 6-7; any failure between left a live account whose every document 404s (the same corruption 9cad668d fixed for org content). Same shape on the project-deletion path. |
964de87e |
Not safely replicable live; the regression tests inject a failure into a mid-sequence row deletion on both paths and assert storage was never touched. | Paths are collected first (version rows cascade away with their documents), rows deleted, bytes deleted last and best-effort - a late failure leaves reclaimable orphans for the claim-filtered sweep, not a corrupted account. |
Tradeoff flagged: deleteStorageFiles is deliberately best-effort (.catch) - once rows are gone, aborting on a failed file removal could only strand the caller in a worse state; the claim-filtered orphan sweep in the same request is the backstop.
One further test-only commit, 17ed10b7, pins the clock in history/page.test.tsx (Date-only fake timers, mid-month date): the suite derived calendar expectations from "today" and went red on CI just past midnight UTC on 2026-08-27 - a latent flake on main, untouched by this branch, surfaced by re-running CI. Verified green under TZ=UTC, UTC+14 and local time.
Testing performed (this round): npx tsc --noEmit clean; npx vitest run → 1018 passed | 28 skipped, exit 0 (at this branch's tip). Each fix verified red-before/green-after by stashing the code change and re-running its test. Schema untouched by this round; the fresh-vs-upgraded drift check was replicated locally at the stack tip (baseline 9a1277ba + all migrations added since vs current schema.sql): 3,812 fingerprint lines, identical, no drift.
Third fix round - 2026-08-26 (high-tier findings)
Two commits close the review's remaining high-severity findings at this layer. New tip: b9c2cdf3.
b9c2cdf3 - sharing plumbing fails closed, rosters answer their tier. Four fixes in one coherent sweep:
listProjectGrantsis result-shaped: a failed read no longer presents as "no grants", which used to (a) wipe theshared_withmirror to[]and (b) make legacy-path revocation a success-shaped no-op - the caller was told access was removed while nobody lost it. Base case at17ed10b7: fail the grants read (kill the DB mid-request) duringPATCH /projects/:id {"shared_with":[...]}→ 200, no grants revoked. Now: 500, retryable, grants intact.removeGrantsForEmailthrows on failure so account deletion can't report success over live grants.resolveContentOrgIdis result-shaped: a failed project lookup no longer returnsnull, which IS the encoding of personal content - a blip while uploading into an org project filed the firm's document as the uploader's private property (destroyed with their account later). All six call sites refuse the request instead of guessing the tenant.GET /projects/:id/accessis admin-only andGET /people's roster is member+; a viewer keeps the creator +admin_contacts(what refusal popups need) and nothing more. Base case: as a viewer-tier grantee,GET /access→ full grant list with emails, roles, grantors. Now: 403;/peoplemembers list empty at viewer.- The three grant endpoints route DB failures through
sendInternalErrorinstead of echoing raw driver messages.
Regression tests: fail-closed suite in projectAccess.test.ts (mirror untouched on failed read, replace refuses, deletion throws), tenant-refusal cases in access.test.ts and tabular.routes.test.ts, roster-tiering cases in orgs.routes.test.ts. Backend: 1,026 passed, tsc clean at this layer.
Tradeoff flagged: POST /projects's legacy share path logs-and-continues on a failed grant write (project 201s unshared) rather than 500ing after the insert - a 500 would invite a duplicate-creating retry, and the revised modal grants per-recipient with its own retry.
Migration renumber - 2026-08-27 (decided deploy order)
The maintainer decided the landing order: the durable-queues PRs (#294, and #295 stacked on it) merge and deploy first; this stack lands after. Deployments apply only migration files sorting past their recorded watermark, so this stack's 20260828_01..03 - older than #294's 20260829_01 - would have been silently skipped on any deployment that took #294 first. Commit 746d9df4 renumbers them to 20260831_01..03 (past #294's slot and past the chat-agents branch's reserved 20260830_01), moving every internal cross-reference, the static SQL-predicate test's literal paths, and the stale date headers in the same commit. New tip: 746d9df4.
Verified on a real local Postgres: the decided production sequence - main's schema → 20260829_01_db_jobs.sql (from #294 at e18c30ec) → 20260831_01..05 applied twice - runs clean end to end, all four stack suites pass 22/22 against that combined database, and the fresh-vs-upgraded drift fingerprints are identical (3,812 lines). Earlier mentions of 20260828_* in this body refer to the same files under their previous names.
Rebase onto September main - 2026-09-01
The stack now sits on main at fdd4ed19, which brought three reworks this branch had to meet semantically, not just textually. New tip: 66ff80d5 (65 commits). Each re-port is its own commit:
| Commit | What, and why it mattered |
|---|---|
156766f4 |
The upload-session surfaces speak the ladder. Main replaced the multipart upload endpoints with the upload-session protocol (410 on the old routes), which left this branch's gates and stamps homeless: a viewer could open an upload session into a project they cannot edit, version replacement fell back to isOwner (dead once account deletion detaches user_id), and the finalize worker upserted document rows with no org_id - filing a firm's upload as personal content destined to die with its uploader. Project uploads now gate at content.edit, version work at content.edit + creatorScopedAllowed (workflow assets stay editable at the share's edit tier), and finalize stamps org_id via the result-shaped resolveContentOrgId, failing the durable job rather than misfiling the tenant |
f960fefa |
Org retention learns the workflow-assets-as-documents model. Main's 20260901_03 folded workflow_reference_documents into documents.workflow_id - and three cleanup paths still queried the dropped table, which would have aborted every account deletion on a database past that migration. Beyond un-breaking: the doom list now keeps (and the detach pass re-anchors) documents whose workflow survives as an org workflow, new workflow-asset uploads are stamped with the workflow's org, and storage claims flow through document_versions (the migration gave every legacy reference file a version row carrying its original path) |
d80cfd80 |
The org migrations meet main's September schema. Two silent hazards: _01's SET NULL loop would error on the dropped assets table (now skipped via to_regclass), and _03's get_projects_overview/get_project_ids_overview copies predated 20260901_02's collaborative/private scopes - applying them on an upgrade would have silently stripped the two new filters while fresh installs kept them, the exact split the drift gate exists to catch. Both bodies re-synced byte-identical with schema.sql, scope arms NULL-safe |
44675fef |
Renumber to 20260902_01..03. Main has shipped 20260901_01..03, so the 20260831_* slots would sort before an already-recorded deployment watermark and be skipped on upgrade - the third occurrence of this race, same mechanical fix, every in-tree reference moved in the same commit. docker-compose's db-init mounts the files alongside main's set |
| audit | accessibleProjectIds moved into main's new lib/auditExport.ts; the jsonb-containment fix and the grants-based authorization were re-ported into that module in the commits that originally carried them, so the audit history still reads true |
Three verification loose ends from the 2026-09-01 independent review round are also closed here:
| Commit | Fix |
|---|---|
dacc4b54 |
Org member role changes and removals join the audit trail (org.member.role_changed / org.member.removed / org.member.left, titled by the target's email like the org.invite.* events, carrying previous and new role). The invitation lifecycle was fully audited; the two mutations that move more standing wrote nothing |
48d21e47 |
The account export reads grants, not the shared_with mirror - the last non-authorizing mirror read. The regression test plants both baits: a live grant absent from the mirror (must appear in the export) and a mirror entry with no grant (must not) |
e9d25547 |
A cancelled invitation reads as gone, not as answered. Accepting one answered 409 "already answered" - untrue on both ends (an admin withdrew it; the recipient never answered). It now reports 404, landing on the client copy written for exactly this: "That invitation is no longer available. It may have been cancelled." |
Testing performed (this round): backend npx tsc --noEmit clean; npx vitest run → 1,361 passed | 40 skipped at the stack tip (this branch's layer green standalone); fresh-worktree replay of the full battery; each new fix verified red-before/green-after by stashing its code change. The commit history is marker-clean and each commit's diff reads against the thing it changes. One commit dropped out of the branch as already-merged: the history/page.test.tsx clock pin landed on main via #390.
Post-push amendments (same day): CI's drift gate caught a syntax error the RPC re-sync introduced in _03 (a comment line swallowed the create or replace for get_project_ids_overview); fixed in place by fixup into d80cfd80, and the full drift check was replicated locally on a scratch Postgres both at this branch's tip and at the stack tip - baseline schema (9a1277ba) + all 51 migrations added since, org files applied twice - 0 fingerprint lines of drift (4,889 / 4,926 lines respectively). And 66ff80d5 absorbs today's browserslist advisories (word-addin lockfile) so the new dependency-audit gate stays green; raised standalone as #422 for main, whichever lands second dedupes.
Our analysis
Add organization RBAC for firm workspaces — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-267.md from
inside the repo you want the changes in.