[Orgs] 3 Chat permission parity: chats join the project role ladder (stacked on #268)
From the PR description
Rewritten 2026-08-26 for the revised model. This PR was re-stacked onto #267's revised branch, which replaced the four-tier
owner/manager/editor/viewerladder withadmin/member/viewer, dropped personal organizations, and moved project sharing ontoproject_access_grants. Everything below describes the branch as it stands at71da5a13; the previous description (and the live rounds it linked) described the superseded model and no longer replicates.
Updated 2026-08-26 (evening). A post-review fix round landed, and the whole stack was rebased onto
mainat1b58c7aa, which now carries #383 (model-selection policy). Two consequences run through this description: the chat migrations are renumbered20260831_04/_05(and #267's are20260831_01..03), andget_chats_overviewcarries #383'smodelcolumn in both signatures. See Post-review fix round.
Stacked on #268 (which is stacked on #267). The base of this PR is olp-pr/organizations-ui (#268's origin mirror, at tip 2b5d8280). The diff shown here is only this PR's commits - read git log on the branch: the original 5, plus the fix round and the #383 reconciliation, 16 commits, 25 files, +3281 / -246 (git diff 2b5d8280..71da5a13). Read #267 first: this PR invents no policy, it applies #267's model to the one resource family that was left out.
Summary
#267 gives projects and tabular reviews one permission system: a role ladder (admin / member / viewer), routes that declare a capability instead of comparing ownership flags, and list RPCs whose visibility predicate matches the detail routes branch for branch.
Chats had none of it. No org_id, no shared_with, an access helper private to routes/chat.ts, rename and delete implemented as raw .eq("user_id", userId) filters on the write itself, and a list RPC that disagreed with the detail route about which chats exist. This PR makes chats the third resource on the same schema - and, because a filter is not an authorization check, fixes the class of bug that shape produces: writes that answer 200/204 after matching zero rows.
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 resources this PR brings onto the ladder.

An admin streams a chat in a shared project; the viewer sees that same chat listed and can open and read it, with the composer read-only - the list RPC and the detail route agree about which chats exist.

The admin deletes a member's chat and the row is really gone; the same delete as a viewer is refused and the refusal is surfaced - not a 204 over zero matched rows.

The unified member-tier details gate from the fix round, re-recorded after that fix: a member creates a review and edits its details from both the list row and the review page, while a viewer is refused at the same gate.
The remaining seven flows from the same round:
- flow-01 - org creation and invitations
- flow-02 - invitation accepted, roster updated
- flow-03 - last-admin protection
- flow-04 - per-recipient project sharing roles
- flow-05 - member inheritance and member-tier writes
- flow-06 - viewer is read-only
- flow-07 - outsider denied, no leak
Source mp4s and the labeled per-checkpoint stills are on the media branch: assets/orgs-live-2026-08-26.
What changed
Schema (20260831_04). chats gains shared_with jsonb not null default '[]' and org_id uuid references organizations(id) on delete set null, plus idx_chats_org. org_id is backfilled with creation-time semantics: a chat inside a project inherits that project's org, everything else stays NULL. There is no personal organization to park a standalone chat in - org_id is null is the personal case.
One shared role derivation (lib/access.ts). ensureReviewAccess's body becomes ensureSharedRowAccess; ensureReviewAccess and the new ensureChatAccess both delegate to it. Reviews are unchanged byte-for-byte - the merge is the same code, the parameter is just named row rather than review. Branches, in order:
| Branch | Yields |
|---|---|
row.user_id === caller |
admin, early return, is_owner: true |
caller's email in row.shared_with (trim + lowercase) |
member |
row.project_id → checkProjectAccess |
the project verdict (creator/grant/org, already merged strongest-wins) |
row.org_id → getOrgRole (skipped when it equals the project's org) |
org admin → admin, org member → member |
Every branch is a floor, never a ceiling. Being added to a chat's share list must not demote a project admin to member, and a project viewer named in a chat's share list must actually be able to write in it. Both directions are pinned by tests.
Every chat route now declares a capability.
| Route | Capability | Deny |
|---|---|---|
GET /chat/:id, GET /chat/:id/people |
project.view (viewer+) |
404 Chat not found |
POST /chat, POST /chat/:id/generate-title, PATCH (title, and - since the #383 merge - model / reasoningLevel) |
content.edit (member+) |
403 You do not have permission to modify this chat |
PATCH (shared_with) |
access.manage (admin) |
403 Only a project admin can change who has access. |
DELETE /chat/:id |
container.delete (admin) |
403 You do not have permission to delete this chat |
The two admin rungs are deliberate and match projects exactly: re-sharing decides who can reach the thread, deleting destroys the container. Everything else is collaboration.
Chats become shareable. PATCH /chat/:chatId accepts shared_with alongside title, normalized the way project sharing normalizes (trim, lowercase, dedupe, drop empties) so the SQL @> containment stays an exact match. It refuses self-sharing (400 You cannot share a chat with yourself.) and emails with no Mike account (400 <email> does not belong to a Mike user.). Its write is now scoped by chat id alone, because authorization already happened above it.
GET /chat/:chatId/people is new - creator plus every share recipient as {email, display_name, role}, the same roster shape GET /projects/:projectId/people returns, including its nullable owner, since a chat in an organization project outlives the account that wrote it.
GET /chat/:chatId returns access_role and is_owner, mirroring the project and review detail responses. is_owner stays provenance ("I started this thread"), not a fourth role - the creator branch derives admin on its own.
get_chats_overview gains the caller's email (20260831_05) and an is_owner column. Its predicate is ensureChatAccess, in SQL: a single call to review_access_role(c.user_id, c.project_id, c.shared_with, c.org_id, p_user_id, p_user_email), the same function tabular reviews use - chats carry no column it does not take. Verified row-identical to the previous hand-written four-branch predicate on a scratch Postgres (8 chats × 11 caller/email combinations, empty symmetric difference). Both signatures also keep #383's model column - see the fix round.
Review-chat writes are bound to their review (tabular.ts). DELETE and PATCH /tabular-review/:reviewId/chats/:chatId took the review id from the URL and never used it; the write was .eq("id", chatId).eq("user_id", userId), so the review path segment was decoration. ensureReviewChatWriteAccess now gates both: the review must exist and be reachable (404), the chat must belong to that review (404), and the caller must be the chat's creator (403 Only the chat's creator can modify it). Both writes additionally carry .eq("review_id", reviewId) so the statement expresses the binding the gate just proved. Review chats stay creator-write on purpose - collaborators read each other's threads but do not rename or delete them.
Two review reads (GET /tabular-review/:id/chats and .../chats/:chatId/messages) now select shared_with. Without that column ensureReviewAccess's direct-share branch could never fire, so a review-level collaborator 404'd on routes they were entitled to reach.
Account deletion scrubs the departing email from chats.shared_with as well as from projects and reviews. removeEmailFromSharedWith's type said "projects" | "tabular_reviews"; chats got a share list in this PR and were never added, so a chat the leaver created went with everything else they owned, while a colleague's chat they had been invited to survived with the departed person's address still in its share list - rendered by /people to everyone who can see the thread, forever.
PATCH /chat/:chatId validates the body's shape instead of coercing it. String(req.body.title) stored any JSON value; a non-array shared_with was silently skipped and fell through to a catch-all 400 "title or shared_with is required" naming the exact field the caller did send; and a non-string entry inside a well-formed array was dropped by continue, so the caller got 200 and a share list quietly missing a recipient they asked for - a partial success reported as a total one. All three are now explicit 400s. Separately, a failed write answers 500 rather than a false 404: by the time control reaches that line the chat has already been resolved and the caller's role checked, so error cannot mean "no such chat" - reporting it as 404 tells the user their colleague deleted the thread.
Post-review verification round (45bfe43c)
(Commit ids in this section are the pre-rebase ones; the branch has since been rebased onto main 1b58c7aa. This round is now 45bfe43c, and the state it was measured against - fd993cb5 below - is 8132c78a.)
An adversarial pass over this branch found one thing, and it is the same failure shape the rest of the PR exists to remove - a permission disagreement that loses data silently.
POST /projects/:projectId/chat was still judging writes on the caller's project role, plus a special case for the chat's own creator. That was a complete account of who could reach a chat right up until this PR gave chats a shared_with and an org_id of their own. After that it was not: a project viewer named on a chat's share list derives member from ensureChatAccess - the direct-share branch - so GET /chat serves them the thread, and the client, which gates on the role the server served, offers a composer. This route still saw a viewer on the project and returned 403. The message rendered locally and was gone on reload, because nothing had been persisted, and no error was shown to anybody.
Continuing an existing chat is now judged by ensureChatAccess - the same call GET /chat makes - so the two routes cannot drift again. It folds in every branch the project role alone cannot see (the chat's creator, its share list, its org, merged strongest-wins), and the creator special case disappears rather than moving, because that derivation already returns admin for a row's creator. Starting a new chat is still judged against the project: nothing exists yet to carry standing of its own, and creating a thread is an edit to the project. Fail-closed is preserved and now explicit - a chat that yields no verdict sets the role to null, and can(null, ...) is false, so an unreadable chat cannot be written through this door either.
Replication (fails on this branch as it stood at fd993cb5):
| # | Do this | Expect |
|---|---|---|
| 30 | As A: create a chat in P, then PATCH /chat/<id> {"shared_with":["<V's email>"]} where V holds only a viewer grant on P |
200 |
| 31 | As V: GET /chat/<id> |
200, access_role: "member" - the share promotes above the project verdict |
| 32 | As V: POST /projects/<P>/chat with that chat_id - i.e. send a message in the thread |
200, and the message persists across a reload. At fd993cb5 this was a 403 while GET /chat still listed the thread and served it as writable - the client rendered the message and lost it |
| 33 | As V: POST /projects/<P>/chat with no chat_id (start a new thread) |
403 - starting a thread is still an edit to the project, and a viewer cannot make one |
Post-review fix round (2026-08-26)
A second review round found eight further defects; three more commits carry the rebase reconciliation with #383. Each is its own commit with its own regression test.
| Commit | What, and why it mattered |
|---|---|
c32df551 |
Judge document-writing tools by the project role, not the chat's. Behaviour change (c) above - one gate on writeRole decided access to the entire tool set, so a project viewer named on one chat's share list could rewrite any document in the project through the assistant |
4f87e9a0 |
Stop reporting a database failure as "chat not found". PATCH ended with if (error || !data) 404. By that line the chat has already been resolved and the role checked, so error means the database refused or the connection dropped - reported to the user as "your colleague deleted this thread". DELETE, a few lines below, already got this right |
7b34546c |
Validate the PATCH body's shape instead of coercing it. Three soft spots - String(req.body.title) storing any JSON value, a non-array shared_with silently skipped into a catch-all 400 that named the field the caller did send, and a non-string entry dropped by continue so a 200 came back with a recipient quietly missing. The last is the same failure shape the rest of the PR removes: a partial success reported as a total one |
f2e20eb0 |
Erase a deleted account's email from chat share lists too. removeEmailFromSharedWith was typed "projects" | "tabular_reviews". A colleague's chat the leaver had been invited to kept their address in its share list forever, rendered by /people to everyone who can see the thread. This is the ordinary cost of adding a column to a resource family - every place that enumerates "tables with share lists" is a place that has to learn |
54f1bcde |
Don't lock a review chat forever when its creator leaves. chat.user_id !== userId is true for every caller alive once user_id is NULL, so the thread was refused to everybody, permanently. Now creatorScopedAllowed - behaviour change (d) above |
562ce666 |
Tell PostgREST to reload its schema cache after these migrations. Prophylaxis for the other side of the deploy window: the 3-arg wrapper covers old code against the new database, but new code against a PostgREST that has not noticed the migration yet gets PGRST204/PGRST202, and no wrapper can help because the missing thing is the function itself. Both migrations now end with notify pgrst, 'reload schema'; |
3a421404 |
Gate the chat list's actions on the served role, not on ownership. The project chat list still asked chat.user_id !== user.id, wrong in both directions under this model: a project admin may delete a colleague's chat and was refused, while someone on a share list was offered a delete button the server answers 403 to. This is the commit that makes access_role/is_owner on GET /projects/:projectId/chats consumed rather than merely served |
333f32cf |
Derive the chat list's visibility from one shared predicate. get_chats_overview restated the four branches by hand even though #267 had already shipped review_access_role(), which takes exactly those four inputs. Half the duplication was already avoided (the project arm called project_access_role); the other half was still copied out. Verified row-identical on a scratch Postgres before the swap - see Testing |
One reported issue was investigated and deliberately not changed. The claim was that chats.shared_with needed normalizing on write and backfilling for existing rows. Neither holds: routes/chat.ts already normalizes (trim, lowercase, dedupe, drop empties) on every write, which is what keeps the SQL @> containment an exact match - and there is nothing to backfill, because 20260831_04 is the migration that introduces the column. Recorded here so the same reading does not have to be re-derived.
The #383 reconciliation (bfecd088, 71c5b62d, 71da5a13):
| Commit | What, and why it mattered |
|---|---|
bfecd088 |
Renumber the chat migrations and teach the overview RPC #383's model column. Two independent collisions, neither of which a textually-clean rebase surfaces. Ordering: main has shipped 20260826_01...20260827_03, and deployments apply only files sorting after their recorded version - filed as 20260825_10/_11 these would have been silently skipped by every upgraded database, so chats would gain shared_with and org_id on fresh installs only. They move to 20260831_04/_05, directly after the org set. Shape: #383 gave chats a model column and put it in get_chats_overview's row, which the chat rail reads. This PR replaces that function, and migrations run in filename order - so a replacement written before #383 would run after #383's and strip the column the UI consumes, while fresh installs kept it. create or replace succeeds when the return type matches, so nothing errors at apply time; the column simply vanishes. The drift check is the only automated tripwire, and it fires on the fresh-versus-upgraded comparison, not in unit tests |
71c5b62d |
Finish the semantic merge with #383 in the tests the rebase left behind. Four groups still assumed whichever side of the merge they were written on - each failing for a reason unrelated to what it pins, which is the worst kind of red because fixing it wrongly can quietly delete a guarantee. The streaming-tool-gating harness now passes a resolvable model (#383 validates inside the stream); #383's "persists the chat model and reasoning" test predated the review-chat write gate and 404'd before reaching the code under test, so it seeds the review row and an ok verdict; this branch's rename tests expected the old bare 204 and now expect #383's 200-with-row; and PATCH /chat persists a reasoning change from the parsed, typed value so the type system can see it is a ReasoningLevel |
71da5a13 |
The deploy-window wrapper's pinned column set includes #383's model. Verified against a database built the way deployments actually get there - main's schema.sql, then this stack's five migrations, applied twice |
Why it changed
Three of these are not "a missing feature" but concrete defects, all replicable below:
- List and detail disagreed about what exists. The 3-arg RPC's predicate was "chat creator OR project creator OR project-org member".
GET /chat/:idresolved throughcheckProjectAccess, which also honours direct access grants. A colleague holding a grant on a shared project could open a chat by URL and get200, while that same chat never appeared in their list. List/detail lockstep is exactly what the overview-RPC convention exists to enforce. - A filter was doing an authorization check's job. When
.eq("user_id", userId)matched zero rows, Supabase reported no error and the route answered204/200. A project admin renaming a colleague's chat was told it worked and watched the change vanish on reload; a total stranger'sDELETEreturned204. A silent success is worse than a refusal - the client has no way to know its optimistic update is about to disappear. - A URL that names two resources verified one. Any chat id could be addressed through any review the caller had never seen, or through a review id that does not exist at all.
⚠️ Behaviour changes worth a reviewer's explicit attention
These follow from applying #267's revised model, and from the semantic merge with #383, rather than from local decisions - and each is wider than the original revision of this PR. Flagging them rather than letting them be discovered.
(a) Chats in grant-accessible projects now appear in the global GET /chat list. They were always openable by URL; they were simply unlisted, because the RPC had no email to match a grant with. Making the list agree with the detail route is the point of the change, but it is a visible difference in the sidebar. Disclosed in 20260831_05's header, and the now-stale comment on GET /projects/:projectId/chats is corrected in the same commit: that endpoint is a convenience scoping, not the only way to find a collaborator's project chat.
(b) An org admin can now delete a colleague's chat in an organization project. This is a widening versus the original #363, where container.delete was owner-only and org admins (then manager) were refused. It follows from the revised ladder with no special-casing: org admin ⇒ project admin (orgRoleToProjectRole), and container.delete requires admin (ROLE_RANK.admin = 2, REQUIRED_RANK["container.delete"] = 2). The justification is the same one #267 uses for projects - someone who can already delete the entire project, taking every chat in it, is not meaningfully restrained from deleting one chat inside it. If you want chats narrower than their container, that is a policy change in permissions.ts, not here. Pinned in both directions by lets an org admin delete a colleague's chat in the org's project (204) and 403s an org member deleting a colleague's chat.
(c) Document-writing tools are gated on the caller's PROJECT role, not on their standing in the chat. POST /projects/:projectId/chat gated the whole LLM stream on one flag derived from ensureChatAccess, and past that gate handed the model the full tool set - edit_document, replicate_document, generate_docx|excel|ppt - over a context built by buildProjectDocContext, which loads every document in the project with no per-caller filter. A project viewer invited to one chat derives member from the share branch, so they could rewrite any document in that project by asking the assistant to. A project viewer invited to a thread may still talk in it and read documents; they can no longer rewrite the project's documents through it.
(d) An admin may now rename or delete a review chat whose creator's account has been deleted. ensureReviewChatWriteAccess compared identities directly, and with chat.user_id NULL that comparison is true for every caller alive - so the thread was stranded permanently and nobody could touch it. It now goes through creatorScopedAllowed, the same helper documents and reviews use. A live creator's chat is still creator-only.
(e) From the #383 merge. PATCH /chat/:chatId accepts model and reasoningLevel alongside title and shared_with. Model and reasoning writes are gated on content.edit on the chat - a deliberate tightening: on main they follow mere reachability, so anyone who could open a thread could change the model it runs on. Flagged explicitly as a behaviour change against main. The review-chat rename answers 200 with the updated row (#383's contract, which the client uses to reconcile its cache), not the bare 204 this branch originally returned; the DELETE beside it still answers 204.
Base-case replication - the gap this closes
Run the stack from docs/local-development.md (docker compose up --build; frontend :3000, backend :3001, Supabase gateway :54321) on this PR's base branch, olp-pr/organizations-rbac - i.e. with #267 in place but not this PR. Apply 20260831_01..03. Bearer tokens come from POST http://localhost:54321/auth/v1/token?grant_type=password with the well-known local anon key from docker-compose.yml; send them to :3001 as Authorization: Bearer <token>.
Accounts: A (project creator ⇒ project admin), G (holds a member access grant on A's project P, no org membership), X (no access to anything).
- As A: create project P, then
POST /projects/<P>/access {"email":"<G>","role":"member"}. Open P → Chat, send a message, note the chat id. - As G:
GET /chat→ A's chat is absent. ThenGET /chat/<chatId>→ 200 with the full transcript. The list and the detail route disagree - the RPC has no email, so it cannot see the grant. - As A:
PATCH /chat/<chatId> {"shared_with":["<G>"]}→ the field is ignored; the handler reads onlyreq.body.title, so this is 400title is required.GET /chat/<chatId>/people→ 404 (route does not exist). There is no sharing mechanism for chats at all. - As X (no access to anything at all):
DELETE /chat/<chatId>→ 204 No Content. Nothing was deleted; the response is indistinguishable from success. Reload as A - the chat is still there. - As an org admin on a colleague's chat in an org project:
PATCH /chat/<chatId> {"title":"renamed"}→ 404Chat not found, even thoughGET /chat/<chatId>on the same id returns 200. The write filter, not the access rule, produced that answer. - As A: create a tabular review R inside P, open its chat, note
reviewChatId. ThenDELETE /tabular-review/00000000-0000-0000-0000-000000000000/chats/<reviewChatId>- a made-up review id - → 204, and the chat is gone. The handler never read:reviewId. - As a review collaborator who is not the chat's creator:
PATCH /tabular-review/<R>/chats/<A's reviewChatId> {"title":"x"}→ 204, and the title is unchanged. Success-shaped no-op. - As A: share review R directly by putting G's email in
tabular_reviews.shared_with. As G:GET /tabular-review/<R>→ 200, butGET /tabular-review/<R>/chats→ 404. The route selects the review withoutshared_with, so the direct-share branch cannot fire.
PR replication - the same stack on this branch
Check out olp-pr/chat-permissions (tip 71da5a13), apply 20260831_04 and _05 (idempotent; verified applying twice over a database already carrying #267's 20260831_01..03), restart the backend.
Accounts: A (project creator ⇒ project admin), D (org admin ⇒ project admin), M (plain org member ⇒ project member), V (holds a viewer access grant on P), G (holds a member grant on P, no org membership), X (outsider). P is an organization project.
List / detail agreement
| # | Do this | Expect |
|---|---|---|
| 1 | As G: GET /chat |
A's project chat is listed, with is_owner: false - the change flagged as (a) above |
| 2 | As G: GET /chat/<chatId> |
200, access_role: "member", is_owner: false |
| 3 | As M: GET /chat then GET /chat/<chatId> |
Listed, 200, access_role: "member" - org membership inherits |
| 4 | As V (viewer grant): GET /chat/<chatId> |
200, access_role: "viewer" |
| 5 | As X: GET /chat and GET /chat/<chatId> |
Chat absent from the list; 404 on detail - no existence leak |
The write-gate matrix
| # | As | Attempt | Expect |
|---|---|---|---|
| 6 | V (viewer) | PATCH /chat/<chatId> {"title":...} |
403 You do not have permission to modify this chat; the title is unchanged |
| 7 | V | POST /chat {"chat_id":..., ...} (send a message) |
403, same message |
| 8 | M (member) | PATCH {"title":...}, POST /chat/<chatId>/generate-title |
200 each - content collaboration is member-tier |
| 9 | M | PATCH {"shared_with":["<X>"]} |
403 Only a project admin can change who has access. |
| 10 | M | DELETE /chat/<chatId> (a colleague's chat) |
403 You do not have permission to delete this chat; the chat survives |
| 11 | D (org admin) | PATCH {"title":"renamed"} on A's chat |
200, and the rename persists on reload - this is the silent-no-op fix |
| 12 | D (org admin) | DELETE /chat/<A's chatId> |
204, and the chat is really gone - the widening flagged as (b) above |
| 13 | X | DELETE /chat/<chatId> |
404, not a success-shaped 204 |
Chat sharing
| # | Do this | Expect |
|---|---|---|
| 14 | As A on a standalone chat: PATCH /chat/<id> {"shared_with":["<M's email>"]} |
200, returns the normalized {id, title, shared_with} |
| 15 | As M: GET /chat and GET /chat/<standaloneId> |
Listed; 200; access_role: "member"; a message streams end to end; PATCH {"title":...} → 200 |
| 16 | As M: DELETE /chat/<standaloneId> |
403 - a share grants member, never admin |
| 17 | As D (org admin, chat not shared with them): GET /chat/<standaloneId> |
404 - a standalone chat is org_id null, so org membership reaches nothing. Sharing a standalone chat is explicit and per-email |
| 18 | As A: PATCH /chat/<id> {"shared_with":["<A's own email>"]} |
400 You cannot share a chat with yourself. |
| 19 | As A: PATCH /chat/<id> {"shared_with":["nobody@example.com"]} |
400 nobody@example.com does not belong to a Mike user. |
| 20 | As A: GET /chat/<id>/people |
{owner: {email, display_name, role:"admin"}, members: [{email, display_name, role:"member"}]} - names, never bare UUIDs |
| 21 | Delete the account that created an org project's chat, then GET /chat/<id>/people as D |
owner: null, roster still renders; the chat survives (user_id is ON DELETE SET NULL per #267) |
No-demotion invariant (strongest-wins in both directions)
| # | Do this | Expect |
|---|---|---|
| 22 | Add D (org admin) to a chat's shared_with, then GET /chat/<id> as D |
access_role: "admin" - a share cannot demote |
| 23 | Add V (project viewer) to a chat's shared_with, then as V PATCH {"title":...} |
200 - an explicit share is not shadowed by a weaker branch |
| 24 | As V (viewer): start your own chat in P, then PATCH {"title":...} on it |
200 - the creator branch derives admin for that row, which is what keeps the streaming route and the CRUD routes from disagreeing |
Review-chat binding
| # | Do this | Expect |
|---|---|---|
| 25 | PATCH/DELETE /tabular-review/<bogus-uuid>/chats/<reviewChatId> |
404 Review not found; the chat survives |
| 26 | Same with a real but different review id | 404 Chat not found |
| 27 | As a review collaborator who is not the chat's creator: PATCH/DELETE /tabular-review/<R>/chats/<cid> |
403 Only the chat's creator can modify it |
| 28 | As the chat's creator: the same calls | 204, and the change persists |
| 29 | Share review R directly with G, then as G: GET /tabular-review/<R>/chats and .../chats/<cid>/messages |
200 each (both were 404) |
The failure ordering matters and is asserted: review-404 and chat-404 fire before the 403, so a bogus review id never leaks whether a chat exists.
Tradeoffs and design decisions
- Chats keep
shared_with; projects moved toproject_access_grants. #267 moved projects onto role-carrying grant rows because a project is the container people administer - sharing one has to say what the recipient may do. A chat is a single thread, and every recipient of one is a content collaborator; there is no second thing a chat share could mean. A one-role grant table would be a whole second sharing mechanism with nothing to express. So chats use the same arraytabular_reviews.shared_withalready uses, andlib/access.tsmaps a direct chat share tomember. This is the one place the two resource families deliberately differ, and it is worth a second opinion. - The old 3-arg
get_chats_overviewsurvives as a delegating wrapper. Migrations land before the code that needs them, so for the length of a deploy there are API instances running old code against the new database. Dropping the 3-arg signature outright would takeGET /chatdown withPGRST202on every not-yet-replaced instance from the moment the migration applied - a self-inflicted outage inside the deploy window. The wrapper is a deploy-window artefact with an explicit expiry recorded in the migration header; a follow-up migration drops it (from the migration andschema.sql) once no pre-#363 instance can still be serving. p_user_emailis REQUIRED, not defaulted - this is load-bearing. Withp_user_email text default nullthe old three-key call matches both candidates and neither layer can choose: Postgres42725 function ... is not unique, PostgRESTPGRST203 Could not choose the best candidate function. Making it required means a three-key call can only be the wrapper (the new function has a required parameter it does not supply) and a four-key call can only be the new function (the wrapper has no such parameter). Every caller in the tree passes it explicitly, so requiring it costs nothing.- Caveat on that disambiguation: it holds for named arguments only. PostgREST always calls by name, so the deploy window is safe. A positional SQL call -
select * from get_chats_overview('uid', null, 0)in psql - leaves argument 2's type open and resolves to the 4-arg function, not the wrapper. Recorded in the migration header; anyone hand-testing the wrapper must cast (null::text) or use named notation. - The chat's own
org_idbranch is inert in practice, and kept anyway.chats.org_idis only ever written at insert, always as the project's org ornull, and no route re-parents a chat or a project. So the branch can never add a row the project branch does not already cover - theis_owner-parity argument for the wrapper depends on exactly that. It is kept for structural symmetry with reviews, so a future feature that stamps an org onto a chat directly does not have to remember to add a branch here. - The RPC predicate no longer duplicates
ensureChatAccess. It delegates toreview_access_role()- the SQL twin ofensureSharedRowAccess, which bothensureReviewAccessandensureChatAccessdelegate to - so the four branches exist once per language rather than once per resource. A single source of truth across languages would mean a SQL round trip on every access check, or fetching ids in TS and threading them into every list query; neither is worth it on the hottest path. chats.shared_withgets a GIN index (chats_shared_with_idx).shared_with @> jsonb_build_array(...)cannot use a btree, andtabular_reviews_shared_with_idxexists for precisely this predicate. Be honest about what it buys, though: the list query cannot use it - the predicate now reaches the column through a function, and the hand-written version's project arm was an unindexable function call inside the sameORanyway. It serves the direct containment probes: account-deletion share scrubbing and the audit lookups.GET /chat/:chatIdstill doesselect("*"), soshared_withis now visible to every viewer of a chat, and/peopleis gated only atproject.view. Deliberate, and it matches projects: knowing who else is in the room is part of viewing it.- Review chats are not made shareable. They inherit review access for reads and stay creator-write. Only the path-binding hole and the silent 204 are fixed here. Giving review chats their own
shared_withwould mean a second sharing surface with no UI and no demand; the review's own roster is the intended boundary. PATCH /chat/:chatId's two body-shape 400s fire before the access check.title is requiredandYou cannot share a chat with yourself.are reachable by a caller who cannot see the chat at all and would otherwise get a 404. They leak nothing about the chat (both are decided from the request body alone), but the asymmetry is real and you need it to write assertions.- Out of scope: workflows (a third, unrelated access scheme - its own issue), chat message authorship, and the sharing UI.
GET /projects/:projectId/chatsnow servesaccess_role/is_ownerper row and the project chat list consumes them for its rename/delete affordances; the sharing UI and/peopleremain unconsumed.
Migrations
| File | Does |
|---|---|
20260831_04_chat_permissions.sql |
chats.shared_with jsonb not null default '[]', chats.org_id uuid references organizations(id) on delete set null, idx_chats_org. Backfills org_id from the chat's project; standalone chats stay NULL. Idempotent - if not exists adds, backfill guarded by c.org_id is null. Ends with notify pgrst, 'reload schema'; so a new column is not PGRST204 until PostgREST restarts. |
20260831_05_chats_overview_rpc.sql |
get_chats_overview(p_user_id, p_user_email, p_limit, p_offset) with an is_owner output column; re-creates the 3-arg signature as the delegating deploy-window wrapper; adds chats_shared_with_idx (GIN). Delegates the predicate to review_access_role; keeps #383's model column in both signatures; ends with notify pgrst, 'reload schema';. |
Two nullability details #267's user_id relaxation forces: is_owner is coalesce()d, because a chat whose author's account was deleted has user_id null where = yields NULL; and the "not me" guards use is distinct from rather than <>, so a chat shared directly with the caller stays visible after its author is gone.
backend/schema.sql is updated in the same change so fresh installs and upgraded deployments converge.
Testing performed
Automated, on tip 71da5a13 (base 2b5d8280, stack rebased onto main 1b58c7aa):
cd backend && npx tsc --noEmit→ clean.npx vitest run→ 1067 passed | 31 skipped, exit 0, against a base (d6058c22) measured in the same working copy at 1009 passed | 28 skipped. So this PR adds 58 passing cases and 3 stack-gated ones. (The unhandledERR_STREAM_WRITE_AFTER_ENDpreviously noted here as harmless is fixed in #267's fix round; it no longer appears.)cd frontend && npx vitest run→ 773 passed across 111 files - this PR's chat-list role gating adds a suite to #268's 766.
| File | New cases | Pins |
|---|---|---|
lib/__tests__/access.test.ts |
8 | The full ensureChatAccess branch/overlap matrix: creator ⇒ admin; case-insensitive direct share ⇒ member; project inheritance; the chat's own org with no project; no downgrade of a project viewer who is also in shared_with; no demotion of a project admin added to a chat's share list; cross-tenant and unshared-standalone denial; fail-closed when the caller has no email |
integration/chat.routes.test.ts |
22 (4 replacing rewritten cases; 18 net new) | A write-recording Supabase harness captures every update/delete with its filters, so the tests assert filters === [{column:"id", value:"chat-1"}] - they prove the user_id scoping is really gone rather than testing a happy path. Plus the admin/member/viewer walk, the three new 400s, the /people roster, the exact 4-key argument object handed to get_chats_overview, and a nested standalone-share group (reads as member, may generate a title, may not delete) |
integration/tabular.routes.test.ts |
6 | The review-chat binding gates: review-404, no-access-404, wrong-review-404, missing-chat-404, non-creator-403, creator-204 |
integration/projectChat.routes.test.ts |
4 | The verification round's write-path parity: a project viewer named on a chat's share list may continue that thread; a viewer with no standing on the chat is still refused; a chat that yields no verdict fails closed; and a new chat is judged against the project, not against any chat row |
integration/chatsOverview.supabase.test.ts (new, stack-gated) |
3 | The deploy-window overload pair against a real Supabase stack - see below |
Database, verified against a real Supabase stack built the UPGRADE way - main's schema.sql at 1b58c7aa (i.e. #383 already in place), then 20260831_01..._05 applied twice (idempotent, 0 errors on both passes). This is how deployments actually get there, and it is the only way the 42P13 return-type collision with #383 was visible at all:
- The stack-gated suites run 22/22 green against that database.
- Fresh-vs-upgraded schema fingerprints are identical, modulo one stub-environment
pgcrypto/varcharartefact that is a property of the scratch container rather than of either schema. - A scratch Postgres was also used for a differential check on the RPC predicate: the new single
review_access_role(...)call versus the previous hand-written four-branch predicate, over 8 chats × 11 caller/email combinations - empty symmetric difference in both directions. - Both
get_chats_overviewsignatures are present afterwards, andp_user_emailon the 4-arg carries no default:get_chats_overview(p_user_id text, p_limit integer DEFAULT NULL::integer, p_offset integer DEFAULT 0) get_chats_overview(p_user_id text, p_user_email text, p_limit integer DEFAULT NULL::integer, p_offset integer DEFAULT 0) chats_shared_with_idxandidx_chats_orgexist;chats.user_idis nullable withconfdeltype = 'n'(SET NULL).- 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→ identical apart from the stub-environment artefact noted above.
Stack-gated (SUPABASE_TEST_URL + SUPABASE_TEST_SERVICE_ROLE_KEY; run by npm run test:stack --prefix backend). chatsOverview.supabase.test.ts seeds two auth users, two orgs, four projects, one project_access_grants row and six chats - one per access branch (mine, inMyProject, inSharedOrgProject, inGrantedProject, sharedDirectly, strangers) - and pins:
| Payload | Resolves to | Returns |
|---|---|---|
{p_user_id, p_limit, p_offset} (pre-#363 instance) |
3-arg wrapper | the old six columns, exactly the pre-migration row set (inMyProject, inSharedOrgProject, mine) |
{p_user_id, p_user_email, p_limit, p_offset} (this PR) |
4-arg function | the new columns incl. is_owner, plus inGrantedProject and sharedDirectly |
The wrapper's pinned column set includes #383's model (71da5a13): the pin predated #383 and would otherwise have rejected the very column the deploy-window contract is required to keep serving.
strangers is never visible under either signature, and the wrapper's paging matches the new function's. Old-signature parity is therefore measured, not asserted: p_user_email => null switches off the chat-share arm and project_access_role's grant arm (both email-gated), collapsing the predicate to the old function exactly. Mutation-checked - re-introducing p_user_email text default null turns all three cases red with PGRST203, which is the regression they exist to catch.
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 2026-08-21 round is retained for history, but it exercised the superseded owner/manager/editor/viewer ladder and personal orgs - its matrix no longer replicates and should not be read as evidence for this branch.
Note for CI: checks cannot run while the base is the olp-pr/organizations-rbac staging branch - the workflows trigger on PRs to main only. Retargeting to main after #267/#268 merge gives this PR its first CI run.
🤖 Generated with Claude Code
Second fix round - 2026-08-26 (architecture re-review)
Two corrections to this description first: the tip this body previously described (71da5a13) gained one further commit before this round - 02d48724 fix(chat): the sidebar speaks the ladder (role-gates SidebarChatItem on the served role via roleFrom, surfaces failed deletes, +4 tests). A re-review of that commit found it incomplete in two ways, both fixed here. The branch was also rebased onto #268's fix-round tip; the one conflict (6aac618b vs #267's gate-ordering fix) resolved to this branch's unified writeRole gate, which already sits before the model write - the ordering comment was kept, and the now-dead chatCreatorId local was removed. New tip: d835a516 (after one further test-only clock-pinning commit at the #267 layer).
| Fix | Commit | Base case (at 02d48724) |
After |
|---|---|---|---|
The sidebar gated on a column the RPC never returned. roleFrom prefers access_role; get_chats_overview served only is_owner, so every non-owned row fell back to member - a project viewer was offered a Rename the server 403s, and a project admin was told "Only an admin can delete this chat" about a delete the server accepts (making behaviour change (b) unreachable from the sidebar). All four sidebar tests passed access_role explicitly, so none exercised the real payload. |
9d5907d2 |
In the global sidebar as a project viewer: hover a colleague's project chat → Rename offered → 403. As an org admin: Delete refused client-side. | The RPC computes its visibility verdict once (lateral) and serves it as access_role in the 4-arg signature; the deploy-window wrapper keeps its pre-#363 seven columns. Safe to reshape in place: the 4-arg signature is introduced by this unmerged migration, so no deployed database holds a prior return type. schema.sql updated byte-identically. Stack test now pins the served role for all five access branches; the sidebar suite gains the two rows that were wrong (admin non-owner → delete allowed; row with no role fields → everything refused). |
A freshly created chat was unusable by its own author. saveChat's optimistic row carried neither is_owner nor access_role; roleFrom fails closed to viewer, so the creator was refused rename/delete on their own new thread until a reload. |
96a0ccd7 |
Create a chat from the sidebar → immediately try Rename → "Only a member can rename this chat." | The optimistic row is stamped is_owner: true, access_role: "admin" - the same stamp ProjectWorkspace gives its optimistic project row, stating what the server will serve for this row on every future load. New provider-level test derives the role the gates consume from the row actually in state. |
CI never ran the chats-overview stack suite. chatsOverview.supabase.test.ts (the overload-pair + access_role pins) was invoked by neither the stack workflow nor scripts/test-stack.sh. |
5ff098f5 |
The suite runs only when someone invokes the file by hand. | Both invocation lists gain the file, keeping script and workflow in lockstep. |
Testing performed (this round): backend npx tsc --noEmit clean, npx vitest run → 1076 passed | 31 skipped; frontend npx tsc --noEmit clean, npx vitest run → 783 passed; lint 0 errors. Against a real local Supabase stack built the way an existing deployment upgrades (main 1b58c7aa schema.sql + 20260831_01..05, applied twice for idempotency): all four stack suites 22/22, including the new per-branch access_role assertions; fresh-vs-upgraded drift fingerprints identical (3,812 lines, no drift). Each fix verified red-before/green-after by stashing its code change.
Evidence caveat: the Live demo GIFs for flows 08/09 were filmed at 71da5a13, before 02d48724 and this round - the flows' server-side behaviour is unchanged, but the sidebar affordances they show predate the role-gating fixes.
Third fix round - 2026-08-26 (high-tier findings)
Rebased onto #268's high-tier tip plus two commits. New tip: d5f86663.
6af8c258- chat creation adopts the tenant lookup's new honesty.resolveContentOrgIdis result-shaped since #267's round; this branch's three chat-creation sites now refuse the create rather than filing a project chat as personal when the project read fails.d5f86663- every refusal reaches the person who was refused. Two halves: (1) sidebar rename gets the same contract delete already had - the context restores AND rethrows, and the row surfaces "Chat not renamed" instead of the title silently snapping back; (2) the global chat page consumes the standingGET /chat/:idalready serves -getChatfoldsis_owner/access_roleinto the row, and the page derivescanSendfrom it, so a project viewer landing on a grant-reachable chat gets the disabled "Viewing only" composer instead of a live one whose sends 403. Base cases atd835a516: rename a colleague's chat as a viewer from the sidebar → title changes and silently reverts; open a colleague's project chat from the global sidebar as a viewer → writable composer, send → 403.
Regression tests: rename-rethrow (provider-level) and rename-surfacing (row-level) cases; a new page.roles.test.tsx pinning the composer read-only for access_role: "viewer" and live for member; all verified red at the pre-fix tip. Full stack tip: backend 1,084 passed, frontend 790 passed, both tsc clean.
Migration renumber - 2026-08-27 (decided deploy order)
Rebased onto the renumbered stack, plus commit 8ac1bbcf: this PR's two migrations are now 20260831_04_chat_permissions.sql and 20260831_05_chats_overview_rpc.sql, following the org files to slots that sort past #294's 20260829_01 - the maintainer's decided order is queues first, this stack after. All cross-references (migration headers, schema.sql pointers, code comments naming _04 as the origin of chats.shared_with, stack tests) moved in the same commit. Verified: main schema → #294's migration → 20260831_01..05 ×2 applies clean on a real Postgres; stack suites 22/22 on the combined database; drift fingerprints identical. Earlier 20260828_* mentions in this body refer to the renamed files. New tip: 8ac1bbcf.
Rebase onto September main - 2026-09-01
Rebased with the stack onto main at fdd4ed19. New tip: 59a93268 (25 commits over #268).
- Chat list meets main's bulk-selection rework (
ae81170f): main gave the project chat table the shared selection machinery (select-all, bounded-concurrency bulk delete with per-id outcomes) while this branch was re-gating the same surface on the served role. The merge keeps both: rename and delete gate onroleFrom/content.edit/container.delete, the bulk path role-filters to deletable rows before main's helper runs, failures land in the action notice with failed rows kept selected, and a single-row delete awaits the server before touching the list - a refused delete never drops the row. - Migrations move to
20260902_04/_05(f353f898), following the org files past main's20260901_01..03(the deployment-watermark rule; fourth slot move for the stack, references and compose db-init mounts swept in the same commit). Function bodies remain byte-identical withschema.sql, checked mechanically. - The departing-email scrub keeps both halves of the account-deletion Promise.all it merged into: chat share lists are scrubbed AND main's export artifacts still die loudly.
Testing performed (this round): backend npx tsc --noEmit clean, npx vitest run → 1,361 passed | 40 skipped; frontend tsc clean, lint 0 errors, 931 passed. Stack-gated suites and the fresh-vs-upgraded drift check re-run against a real Postgres on the decided upgrade path - see the stack-verification note in #267's rebase section.
Our analysis
Extend role-based access control to chats — 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-363.md from
inside the repo you want the changes in.