Repository navigation
Share a whole workspace, not one board at a time - #28
Conversation
Sharing hands out one board. An organization hands out everything in it, now and later: you are invited once, and every board the team makes afterwards is already yours to open. That is the whole difference, and it is why this is a second grant of access rather than a loop over the first. The vocabulary is deliberately the board's — an owner, and members who are editor or viewer — and your role in the organization *is* your role on its boards, so board_role() stays the one question everything asks. It just has two places to look now, and the stronger grant wins. Owning the organization resolves to editor on its boards, not owner: a board has exactly one owner and it is whoever made it. Creating one takes a real account. Every visitor is signed in anonymously, and an organization owned by a browser session is one nobody can get back into — which is also why sweep_anonymous_users had to learn about membership, or a guest invited on Monday was deleted the following week. Being invited needs no account, exactly as a board link does. freeze_board_org is the load-bearing piece. Being an org editor is what lets you write boards you do not own, and without a trigger that same grant would let you write the column saying whose board it is — carrying the team's work into an organization you control, past a policy that only ever sees the row being proposed. Then three things that follow from having teams at all: Scoped, paginated lists. list() had no where clause on purpose, and stayed cheap while "every board you can see" meant your own plus a handful. One invite to a team with five hundred boards makes every load of your personal list fetch five hundred rows to render three. Scoping has to come before paging — cutting the page in Postgres and filtering it in the browser gives pages of unpredictable size — so the scope is a security_invoker view, leaving the policies as the enforcement, and paging is keyset with an id tiebreak so a save mid-read cannot shift rows across a boundary. Handing an organization over. organizations_update still refuses the naive update, and there is a test that it keeps refusing it. Transfer is three changes that must land together, with rules a with check could not express, so it is a definer function where those rules can be read. An optional second owner. One owner is one person who can be ill, or gone. org_role() answers owner for both, so running the organization is shared; the four acts on its own row — rename, delete, hand over, appoint — stay with owner_id. There is always exactly one account that cannot be locked out by the other. Also fixes two things the scoping exposed: rls.test.js emptied public.boards before every test, which against the hosted project that DATABASE_URL is documented to accept would empty real workspaces; and one of its assertions counted every invite in the database rather than the board's, so it was green only because the previous statement had wiped the table. 676 tests, 141 policy tests, 98.1% line coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5PGDHtUp36t7S18WrykuA
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe change adds organization management, organization-scoped board access, invitation flows, co-owner and transfer operations, cursor-based board pagination, updated board tracking, and related database, UI, repository, documentation, and integration tests. ChangesOrganizations and access control
Paginated board listing
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (8)
supabase/migrations/20260808180000_organization_co_owner.sql (1)
48-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRestate the grants for the replaced
org_role().
create or replace functionkeeps the privileges of the previous definition, so this works. However,20260808120100_sweep_spares_org_members.sql(lines 53-58) states the convention that a replaced function restates its grant so the privilege is a property of the file.organization_people()follows that convention at lines 300-301 of this file;org_role()does not.♻️ Restate the grant next to the definition
end; $$; +revoke execute on function public.org_role(text) from public, anon; +grant execute on function public.org_role(text) to authenticated; +🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@supabase/migrations/20260808180000_organization_co_owner.sql` around lines 48 - 66, Add the explicit grant for public.org_role immediately after its CREATE OR REPLACE FUNCTION definition, matching the grant convention used by organization_people() and the prior org_role migration. Preserve the existing function implementation and grant the same role/privilege as the replaced definition.test/node/seen-boards.test.js (1)
66-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueState the failure in the assertion message.
The message prints only when the assertion fails, so it should name the fault. Here the set is expected to be empty, and
'and came back announced'reads as a description of the expected state. The other messages in this file name the fault.✏️ Clearer failure message
- assert.deepEqual([...seen.reconcile(['a', 'b', 'c'])], [], 'and came back announced'); + assert.deepEqual( + [...seen.reconcile(['a', 'b', 'c'])], + [], + 'a board absent from one page was announced when it was listed again', + );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/node/seen-boards.test.js` at line 66, Update the failure message on the reconcile assertion in the seen-board test to state that previously announced boards came back announced, matching the fault-focused wording used by the other assertions in the file.src/routes/JoinOrgPage.jsx (1)
27-33: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winHandle exceptions from
organizations.redeem.
redeemconverts returned RPC errors tonull, but it does not catch exceptions fromclient.rpc. If the promise rejects, setfailedwhen the component is still live.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/JoinOrgPage.jsx` around lines 27 - 33, Update the organizations.redeem promise chain in JoinOrgPage to catch rejected client.rpc exceptions and call setFailed(true) only when the component is still live, while preserving the existing orgId navigation and null-result handling.src/platform/organizations.js (1)
194-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueFreeze
NOBODYor return a fresh array.
people()returns the module-levelNOBODYarray on failure. Every failing call shares one array instance. A caller that sorts or pushes in place would then change what every later failure returns. The current consumer insrc/components/OrganizationDialog.jsxonly reads it, so this is defensive rather than a live defect.♻️ Proposed change
-const NOBODY = []; +const NOBODY = Object.freeze([]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/organizations.js` around lines 194 - 199, Update the failure path in people() to avoid exposing the mutable module-level NOBODY array: either freeze NOBODY at its definition or return a new empty array for each error or missing-data result. Preserve the existing mapped data behavior for successful organization_people responses.test/db/rls.test.js (2)
608-622: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUpdate the stale comment: transfer now exists.
The comment states "There is no transfer, and the
with checkis what makes that true". The same file addsdescribe('transferring it', …)at Line 1143, which exercisespublic.transfer_organization. The assertion is still correct — the naive update stays refused — but the prose contradicts the feature added in this PR. The equivalent test at Line 1151 already words this correctly.📝 Proposed comment fix
/** - * There is no transfer, and the `with check` is what makes that true - * rather than the absence of a button. An owner who could write another - * id into the column would be handing over every board in the - * organization and every outstanding invite along with it. + * Handing the organization on goes through `transfer_organization`, never + * through this column. The `with check` is what makes that true rather + * than the absence of a button: an owner who could write another id here + * would be handing over every board in the organization and every + * outstanding invite along with it, with none of the function's rules. */🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/db/rls.test.js` around lines 608 - 622, Update the comment above the “and cannot hand it to somebody else” test to remove the claim that no transfer exists, while preserving the explanation that the WITH CHECK policy rejects the direct owner_id update and prevents handing over the organization and invites. Match the wording used by the equivalent test near the transferring it coverage.
1274-1291: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
carolis not removed if an assertion fails.The cleanup at Line 1290 runs only when every assertion above it passes. If the transfer assertion at Line 1287 fails,
carolstays inauth.usersand her organization survives, becausebeforeEachscopes its deletes toalice,bobandgus. The next run then sees leftover fixture rows. Registercarolfor teardown instead, or wrap the cleanup intry/finally.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/db/rls.test.js` around lines 1274 - 1291, Ensure the temporary carol fixture is always cleaned up even when an assertion fails: update the test around transfer and coOwnerAfter to wrap the setup/assertions in try/finally, deleting carol in the finally block, or register carol with the test teardown mechanism. Preserve the existing assertions and cleanup behavior.src/styles/shell.css (1)
602-602: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDefine a shared destructive colour token.
src/styles/canvas.csshas no danger token. Add one to both theme blocks, then replace#d83a52in this rule and the other destructive states insrc/styles/shell.csswithvar(--danger).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/styles/shell.css` at line 602, Define a shared --danger colour token in both theme blocks of canvas.css, then update the .org-actions delete hover rule and all other destructive states in shell.css to use var(--danger) instead of hardcoded `#d83a52`.src/platform/supabase-repository.js (1)
189-196: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMerge the two adjacent doc blocks.
savenow has two consecutive JSDoc blocks. Only the second one is attached to the function, so the update-then-insert rationale above it is left as a detached comment. Fold theorgIdparagraph into the existing block.♻️ Proposed change
- */ - /** - * `orgId` says where a board being created belongs, and is used on the + * + * `orgId` says where a board being created belongs, and is used on the * insert path alone. It is deliberately not part of `patch`: an update🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/supabase-repository.js` around lines 189 - 196, Merge the standalone JSDoc block describing orgId into the existing JSDoc block immediately above save. Keep the rationale intact and ensure save has one contiguous documentation block containing both the update-then-insert behavior and orgId semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/OrganizationDialog.jsx`:
- Around line 320-330: Update the button label in the isPrimary/co-owner branch
of OrganizationDialog to say “Take the appointment back” instead of “Step down,”
matching the README terminology and the setCoOwner(null) action.
- Around line 353-363: Update the member-row controls in OrganizationDialog so
Remove is hidden only when the displayed person is the current caller, while
remaining available for the primary owner to remove a co-owner. Add or expose a
Leave this organization control for the second-owner view, ensuring it invokes
the membership-leave behavior rather than the appointment-clearing Step down
action.
In `@src/routes/BoardListPage.jsx`:
- Around line 611-629: Update the OrganizationDialog onChanged prop in
BoardListPage so it invokes the busy-aware reloadOrgs handler instead of passing
the bare refresh function. Preserve the existing onGone navigation and reload
behavior, ensuring all organization reads use the page’s busy state.
- Around line 216-232: Update loadMore to validate its response against the
currently active scope using a ref or liveness token rather than the captured
scope value. Ignore stale success updates, including board and cursor changes
and reconcile, and ignore stale error updates when navigation has changed scope;
retain normal loading cleanup for the active request.
In `@supabase/migrations/20260808120000_organizations.sql`:
- Around line 225-253: Update public.freeze_board_org to return new when
new.org_id is null and old.org_id no longer exists, allowing ON DELETE SET NULL
to detach boards during organization deletion while preserving the existing
authorization checks for other organization changes.
In `@test/browser/organizations.test.js`:
- Around line 106-117: Replace the fixed 60 ms sleep in the choose helper with
an observable readiness wait, while leaving each caller responsible for its
specific post-change condition. In inviteLink, add an explicit wait for the role
change to update the target link before reading [data-org-url], preserving the
existing URL assertion.
- Around line 354-372: Remove the first orphaned documentation block describing
handing an organization over from the consecutive blocks near the second-owner
test, and place it immediately above the handover test associated with the
outgoing owner’s screen. Keep the second-owner documentation block above its
corresponding test.
In `@test/node/organizations.test.js`:
- Around line 80-85: Update the teardown around the after hook and the test
board-creation paths: either track every board id created with newId('b') and
remove those boards, including boards owned by fixture accounts, or revise the
comment to accurately state that organization deletion leaves the boards behind
intentionally. Ensure the implementation and comment describe the same cleanup
behavior.
In `@test/node/supabase-repository.test.js`:
- Around line 206-215: Update the “boards saved in the same instant are still
partitioned” test to assert both that all six unique IDs were seen and that
mine.length equals 6, matching the duplicate-detection pattern used near line
197. Keep the existing Set assertion and failure message context.
---
Nitpick comments:
In `@src/platform/organizations.js`:
- Around line 194-199: Update the failure path in people() to avoid exposing the
mutable module-level NOBODY array: either freeze NOBODY at its definition or
return a new empty array for each error or missing-data result. Preserve the
existing mapped data behavior for successful organization_people responses.
In `@src/platform/supabase-repository.js`:
- Around line 189-196: Merge the standalone JSDoc block describing orgId into
the existing JSDoc block immediately above save. Keep the rationale intact and
ensure save has one contiguous documentation block containing both the
update-then-insert behavior and orgId semantics.
In `@src/routes/JoinOrgPage.jsx`:
- Around line 27-33: Update the organizations.redeem promise chain in
JoinOrgPage to catch rejected client.rpc exceptions and call setFailed(true)
only when the component is still live, while preserving the existing orgId
navigation and null-result handling.
In `@src/styles/shell.css`:
- Line 602: Define a shared --danger colour token in both theme blocks of
canvas.css, then update the .org-actions delete hover rule and all other
destructive states in shell.css to use var(--danger) instead of hardcoded
`#d83a52`.
In `@supabase/migrations/20260808180000_organization_co_owner.sql`:
- Around line 48-66: Add the explicit grant for public.org_role immediately
after its CREATE OR REPLACE FUNCTION definition, matching the grant convention
used by organization_people() and the prior org_role migration. Preserve the
existing function implementation and grant the same role/privilege as the
replaced definition.
In `@test/db/rls.test.js`:
- Around line 608-622: Update the comment above the “and cannot hand it to
somebody else” test to remove the claim that no transfer exists, while
preserving the explanation that the WITH CHECK policy rejects the direct
owner_id update and prevents handing over the organization and invites. Match
the wording used by the equivalent test near the transferring it coverage.
- Around line 1274-1291: Ensure the temporary carol fixture is always cleaned up
even when an assertion fails: update the test around transfer and coOwnerAfter
to wrap the setup/assertions in try/finally, deleting carol in the finally
block, or register carol with the test teardown mechanism. Preserve the existing
assertions and cleanup behavior.
In `@test/node/seen-boards.test.js`:
- Line 66: Update the failure message on the reconcile assertion in the
seen-board test to state that previously announced boards came back announced,
matching the fault-focused wording used by the other assertions in the file.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 3fc7e38b-5dad-4f35-a70b-f0139128ab88
📒 Files selected for processing (33)
README.mdsrc/App.jsxsrc/components/OrganizationDialog.jsxsrc/components/ShareDialog.jsxsrc/platform/adopt.jssrc/platform/organizations.jssrc/platform/seen-boards.jssrc/platform/storage.jssrc/platform/supabase-repository.jssrc/routes/BoardListPage.jsxsrc/routes/BoardPage.jsxsrc/routes/JoinOrgPage.jsxsrc/shell/organizations.jssrc/shell/sharing.jssrc/styles/shell.csssupabase/migrations/20260808120000_organizations.sqlsupabase/migrations/20260808120100_sweep_spares_org_members.sqlsupabase/migrations/20260808140000_board_summaries.sqlsupabase/migrations/20260808160000_transfer_organization.sqlsupabase/migrations/20260808180000_organization_co_owner.sqltest/browser/app.test.jstest/browser/boards.test.jstest/browser/organizations.test.jstest/browser/shell.test.jstest/coverage.jstest/db/rls.test.jstest/db/sweep.test.jstest/node/adopt.test.jstest/node/organizations.test.jstest/node/seen-boards.test.jstest/node/sharing.test.jstest/node/storage.test.jstest/node/supabase-repository.test.js
Eight findings from CodeRabbit. Seven were right; one was half right in a more interesting way than it looked. The Critical one said `on delete set null` would fire `freeze_board_org` for each detached board and be refused, because `org_role()` can no longer see the organization being deleted. The visibility half is exactly right — I proved it by hardening the test with a `coalesce` and watching both deletion paths start raising. The conclusion was wrong: it works today only because `null or null` makes the `if` not fire, which is not a rule anybody wrote down. So the cascade now returns early and says why, the identity test is `coalesce`d to deny by default, and two tests cover the paths that were uncovered — deleting an organization holding somebody else's board, and the owner's account going, which reaches those boards through two cascades with no session at all. The rest: - `loadMore` closed over `scope` from its own render, so its guard compared a value with itself. A late page could append a team's boards under the personal list and install that team's cursor. Guarded by a ref instead. - `onChanged` passed the bare `refresh`, so the re-read after a rename or an appointment raised no `busy` — the exact failure the comment three hundred lines above it describes, and one I had already fixed once for `onGone`. - "Step down" sat on somebody else's row and only the primary owner could see it, so it named an action its reader was not taking. - `Remove` showed on the second owner's own row: it worked, and took them out of the organization under a label about somebody else. Hidden on your own row, and the leave control now exists in the view they actually get. - A fixed 60ms sleep in `choose` stood in for a readiness condition. Replaced — with both halves, because moving a board out of the scope on screen unmounts the very select being waited on. - The tie-breaking pagination test asserted `new Set(mine).size === 6` under a message promising "exactly once", which cannot see a board served twice. - A teardown comment promised board cleanup that did not exist. - A doc block describing the transfer test had ended up above the second-owner one when I inserted it. 676 tests, 143 policy tests, 98.1% line coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5PGDHtUp36t7S18WrykuA
|
Thanks — seven of the eight landed. Notes on the one that did not, and on two where the fix differed from the suggestion.
The conclusion was wrong, though — nothing was broken. It works because Fixed as suggested, plus the consequence: the cascade returns early and says why, and the identity test is now The Board cleanup in 676 tests, 143 policy tests, 98.1% coverage. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/db/rls.test.js`:
- Around line 1342-1352: Update the comment above the account-deletion cascade
assertions to explain that freeze_board_org explicitly permits
organization-deletion cascades, while the identity check uses coalesce to deny
null identities otherwise. Remove the three-valued-logic rationale and keep all
assertions unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: a2a287e9-0125-4f05-af37-d2813a0164ea
📒 Files selected for processing (7)
src/components/OrganizationDialog.jsxsrc/routes/BoardListPage.jsxsupabase/migrations/20260808120000_organizations.sqltest/browser/organizations.test.jstest/db/rls.test.jstest/node/organizations.test.jstest/node/supabase-repository.test.js
🚧 Files skipped from review as they are similar to previous changes (4)
- test/node/supabase-repository.test.js
- test/node/organizations.test.js
- test/browser/organizations.test.js
- supabase/migrations/20260808120000_organizations.sql
The comment above the account-deletion cascade test described how that path worked *before* the fix it sits next to: three-valued logic letting a null identity through. The trigger now has an explicit branch for the cascade and a `coalesce` denying a null caller otherwise, so the comment named a mechanism the code no longer uses — which is the failure mode comments have. Only the comment changes; the assertions are the same and still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5PGDHtUp36t7S18WrykuA
Sharing hands out one board. An organization hands out everything in it, now and later: you are invited once, and every board the team makes afterwards is already yours to open. That is the whole difference, and it is why this is a second grant of access rather than a loop over the first.
The vocabulary is deliberately the board's — an owner, and members who are
editororviewer— and your role in the organization is your role on its boards, soboard_role()stays the one question everything asks. It just has two places to look now, and the stronger grant wins: being made an editor of one board is not undone by being a viewer of the team, and joining the team does not quietly downgrade a board someone handed you.Owning the organization resolves to
editoron its boards, notowner. A board has exactly one owner and it is whoever made it — two rows answeringownerwould putfreeze_board_ownerandboard_peoplein disagreement about who that is. What owning the organization additionally buys is written into the policies that grant it.The load-bearing pieces
freeze_board_org. Being an org editor is what lets you write boards you do not own — so without a trigger, that same grant lets you write the column that says whose board it is, carrying the team's work into an organization you control, past a policy that only ever sees the row being proposed. RLS cannot see the old row, so it is a trigger, likefreeze_board_owner. Inside it,coalesce(org_role(...), '') not in (...)rather than a barenot in:null not in (...)is null, so the one case the check exists for is the one it would have waved through.Creating one takes a real account. Every visitor is signed in anonymously, and an organization owned by a browser session is one nobody can get back into. The gate reads
auth.users.is_anonymous, not the JWT claim — a claim is a copy of that column from whenever the token was minted, so a guest who has just registered would be refused until it refreshed. Being invited needs no account, exactly as a board link does — which is whysweep_anonymous_usershad to learn about membership, or a guest invited on Monday was deleted the following week.Deleting an organization is
on delete set null. The boards in it are several people's work; they fall back to whoever created them and everyone else loses sight of them, which is the part the deletion was actually about. The confirm says so.Three things that follow from having teams at all
Scoped, paginated lists
list()had nowhereclause on purpose — RLS decides what you can see, and a filter in the client would be a second, weaker copy of the policies. That stayed cheap while "every board you can see" meant your own plus a handful shared with you. One invite to a team with five hundred boards makes every load of your personal list fetch five hundred rows to render three.Scoping has to come before paging: cutting the page in Postgres and filtering it in the browser gives pages of unpredictable size, and no way to tell "that is all of them" from "this page happened to be entirely somebody's team". So the scope is a
security_invokerview — the policies stay the enforcement, and the view adds only thepersonalcolumn and the absence ofdoc, which a list never wanted and now cannot ask for by mistake.Paging is keyset.
.range()is shorter and wrong: boards are ordered by last save, so anyone saving while you read shifts rows across the boundary.updated_atalone is not a total order either, soidbreaks ties — in the sort, in the cursor comparison, and in the index. The cursor carries the timestamp exactly as Postgres gave it;Date.parserounds microseconds away into a cursor that straddles its own boundary.The badge changed with it. The record used to be pruned to whatever was listed, which bounded it for free — against a page that would forget every board below the fold. It is capped instead. What it still does not do is add what it announces: being listed is not being looked at, and a reconcile that recorded the boards it badged would clear them on the next refresh, before anyone had read them.
Handing an organization over
organizations_updatestill testsowner_id = auth.uid()in both halves, and there is a test asserting the naive update keeps being refused — if it ever starts working, the rules below stop being rules. Transfer is three changes that must land together, with rules awith checkcould not express: the recipient must already be a member, and must be a real account. So it is aSECURITY DEFINERfunction, which is where those rules can be read.An optional second owner
One owner is one person who can be ill, or on a plane, or gone.
co_owner_idis a column rather than an'owner'role, because the shape is the rule — a role makes "how many owners" an open question.org_role()answersownerfor both, so running the organization is shared; the four acts on its own row — rename, delete, hand over, appoint — stay withowner_id. There is always exactly one account that cannot be locked out by the other. The appointment never touches their membership row, which is what makes appointing and removing one statement each — and why the two must end together, so a trigger clears the appointment when they stop being a member.Two fixes the scoping exposed
rls.test.jsemptiedpublic.boardsbefore every test.DATABASE_URLnaming the hosted project is a documented workflow, and that would empty real people's workspaces the first time someone followed it. Now scoped to the run's own fixture users.Verified
Against a real local Supabase stack, not a stand-in:
npm test— 676/676, line coverage 98.1% (gate 95%)npm run test:db— 141/141 policy testsnpm run build— cleanNot built yet
Written up in the README rather than left implied: an organization cannot outlive its first owner on its own (
owner_idison delete cascade,co_owner_idison delete set null— promoting somebody automatically is a decision the schema has no business making unasked); the personal scope has no index behind it, which is an argument rather than a measurement; and nothing re-reads a list while you are looking at it, which paging makes sharper.🤖 Generated with Claude Code
https://claude.ai/code/session_01T5PGDHtUp36t7S18WrykuA
Summary by CodeRabbit
New Features
Documentation