Skip to content

B7c: no inline drive access gates outside packages/lib/src/permissions (org-aware primitives + per-query seam guard) - #2689

Merged
2witstudios merged 18 commits into
pu/org-walletsfrom
pu/ow-b7c
Sep 21, 2026
Merged

2witstudios merged 18 commits into
pu/org-walletsfrom
pu/ow-b7c

Conversation

@2witstudios

@2witstudios 2witstudios commented Sep 21, 2026

Copy link
Copy Markdown
Owner

B7c: no inline drive access gates outside the permissions layer

Leaf kxgdqsjqyrlyvcytnhlbdllz (Phase 1 task jcz0vxkkjwvsmnqqhqi6sb5a). Base pu/org-wallets at d1f0c0d43 (#2673, B7b); merge-base equals that tip. Wave F waits on this PR.

What changed

1. Four permission primitives (packages/lib/src/permissions). Every former inline gate now calls one of them.

  • drive-relationship.ts holds the pure decisions: isDriveLead, driveRoleOf, canAdministerDrive and isDriveMemberRelationship.
    • RelationshipDrive requires id, ownerId, orgId and orgVisibility. A caller that selects only { id, ownerId } would otherwise be read as a personal drive (review 5237996587, P3-4).
  • drive-relationship-loader.ts does the IO: loadDriveRelationship(s) answer "lead, plus the effective membership from B7b's shared loader".
    • The batch version runs one accepted-rows query plus the resolver's org queries, however many drives there are.
  • member-drives.ts holds listMemberDrives, getMemberDriveIds, sharesMemberDrive and memberOfAnyDriveCondition. They answer "which drives is this person a member of":
    • While dark: exactly the owned-drives + accepted-rows union the callers used to run inline.
    • While enabled: decideListedDriveRole without page shares. That means owned drives, valid rows and OPEN drives of the person's org. It never includes a stale org row, a pending invitation, a page share, or an unjoined RESTRICTED/PRIVATE drive (DRV-6).
    • memberOfAnyDriveCondition is the same rule as a SQL condition, so the profile search can filter inside the database. The integration suite checks it against the decision.
  • drive-audience.ts (with pure decideDriveAudience in org-drive-resolution.ts) holds listDriveAudience(s), the one member enumeration.
    • It returns the lead plus everyone resolveEffectiveDriveMembership admits, each with an effective role and custom role.
    • It writes no ORG-4 audit: an audience is computed, and nobody accessed anything.

2. Routed gates. Each one kept its own role test on the answer, so a personal drive answers as before. The exceptions are listed under "Dark-visible differences".

Where Before Now
drives/[driveId]/trash, permissions-tree, drives/[driveId]/pages (admin branch) ownerId === + ADMIN row canAdministerDrive(loadDriveRelationship)
drives/[driveId]/pages (non-admin branch) own member check + own page query getUserAccessiblePagesInDrive
pages/tree owner or accepted row isDriveMemberRelationship
pages/bulk-copy, pages/bulk-move owner, or OWNER/ADMIN row driveRoleOf(...) ∈ {OWNER, ADMIN}
page-reorder-service, permission-management-service ADMIN row, no acceptedAt master's #2672 lines verbatim (isDriveOwnerOrAdmin) + org cases
drives/[driveId]/members/invite (findAdminMembership removed) owner + ADMIN row canAdministerDrive
revoke pending invite (revoke-adapters, lib invites port) raw row, the validator checks acceptedAt findActorDriveRole port → driveRoleOf
account/handle-drive ownerId !== + ADMIN row of target isDriveLead, target membership.role === 'ADMIN'
commands + command-tools getMemberDriveIds, activity/summary, activity-tools, pulse ×3, memory discovery-service owned + rows getMemberDriveIds
messages/threads, inbox ×2, sidebar/badges (channel candidates) JOIN drive_members + d."ownerId" = p."driveId" = ANY(getMemberDriveIds); threads also gains the getBatchPagePermissions filter
lib/users/visibility (callerCanViewUser, profile search) owned + rows, EXISTS subqueries sharesMemberDrive, memberOfAnyDriveCondition
lib app-shell-service owned + rows, role from raw row listMemberDrives (in the shell's transaction)
ai/page-agents/multi-drive (sandbox edit rule) raw rows loadDriveRelationships + membershipRowsOf
lib getDriveRecipientUserIds, getDriveMemberUserIds(ByStandardRole|ByCustomRole), isMemberOfDrive, calendar getAllMemberUserIdsForEvent owner + rows listDriveAudience(s)
channel fan-out, channel-tools inbox notifications, page-privacy kick own row reads getDriveRecipientUserIds, then the existing canonical per-page check
listAllUserBackups, driveRepository.findByIdAndOwner owned + ADMIN rows / eq(ownerId) listMemberDrives (lead or ADMIN) / isDriveLead
lead-only actions: drive restore, permanent delete, rename_drive, root page-agent create, "cannot remove the owner", /api/drives ownership flag, storage/info shortcut inline ownerId isDriveLead (lead-only stays lead-only)
lib checkDriveAccess, checkDriveAccessForRoles, getDriveAccess(WithDrive), resolveGranterAccess, resolveDriveMembership, self-heal, org sync, decideMoveDriveIntoOrg drive.ownerId === userId isDriveLead
isUserMemberOfAnyEventDrive dark branch read rows always isUserDriveMember (dark answer unchanged; pinned by B7b's frozen-legacy equivalence test)

3. Seam guard, validated per query (packages/lib/src/__tests__/seams/drive-access-gates.seam.test.ts + access-gate-scan.ts).

  • It scans every apps/ and packages/ source file outside packages/lib/src/permissions (and packages/db, apps/e2e). It finds every drive_members query and every comparison with an ownerId:
    • query shapes: .from, joins, query.driveMembers.find*, raw SQL;
    • comparison shapes: drizzle eq/ne on drives.ownerId, JS ===/!==, and SQL "ownerId" =.
  • It attributes each site to the innermost enclosing function. A sibling closure above the site, destructured parameters and object return types are handled.
  • It requires an exact per-function count per kind, with a reason. That answers CodeRabbit's MAJOR on fix(permissions): pending drive invites grant nothing; a custom role's drive-wide grant never opens a private page #2672:
    • a second, ungated query beside an allowlisted one changes that function's count;
    • a query in any other function has no entry.
  • The allowlist: 68 functions in 51 files. Each reason says why the sites decide no access. The categories are:
    • writers and invite management;
    • payer lookups (joining the lead's tier);
    • inventories of the caller's own led drives;
    • display lists behind an access check;
    • DM eligibility (documented as ungated);
    • the accessible-drives resolver itself (listAccessibleDrives(WithOrgs), decided by decideListedDriveRole);
    • ownerIds that are not a drive's (agent sessions, conversations, env enrollments, webhook secrets).
  • Self-tests. Planted shapes in fake files cover every query and comparison shape, and the writes, selects and comments it must ignore. Then:
    • anchor attribution;
    • a planted ungated query in the same function as an allowlisted one;
    • a planted gate in another function of an allowlisted file;
    • a planted gate in an unlisted file;
    • stale and unexplained entries.
  • Real red: a line planted in drives/[driveId]/trash/route.ts GET (if (drive.ownerId === auth.userId)) gave trash/route.ts › GET: found {"reads":0,"ownerCompares":1}, allowed {…0}. The file was then restored and git status was clean.
  • The per-file drive-members-enumeration allowlist loses 24 files, and the web drive-member-gate-coverage allowlist loses 2. Both now read drive_members through the seam.

Point-guard ruling: lead actions on org-owned drives (50a26fd86)

On an org-owned drive, the drive lead and an org Owner or Admin may rename, trash and restore it (ORG-4 full access; DRV-1: the human lead keeps the Owner role). Each org-power use writes an audit event. Personal drives stay owner-only, and an org MEMBER or a non-member is refused. Transfer-lead is not in this PR: no change-drive-lead action exists anywhere, and it belongs to the B8 leaf (dl27rujimf5clm6nwji6tw1u, section "Change a drive's lead").

  • The decision. Pure decideDriveLeadAuthority (drive-relationship.ts) returns lead / org-owner / org-admin / refused. isDriveLead stays the plain drives.ownerId fact: it also drives the ownership flags, the self-heal row, the audience's lead and the org sync, where widening it would be wrong.

  • The IO. loadDriveLeadAuthority (drive-relationship-loader.ts) reads the org role only for an org drive the user does not lead, and only while enabled. When org power is what allows the action, it writes one authz.access.granted event with { via: org_owner|org_admin, action, orgId, orgVisibility }, on every visibility.

  • Surfaces:

    Action Surface Gate
    rename rename_drive tool loadDriveLeadAuthority
    rename drive settings PATCH owner-or-admin gate (an org Admin is already ADMIN) + recordOrgPowerDriveAction
    trash drive DELETE, trash_drive tool owner-or-admin gate + recordOrgPowerDriveAction
    restore web route, restore_drive tool loadDriveLeadAuthority; the tool goes through driveRepository.findByIdForLeadAction (replaces findByIdAndOwner)
    permanent delete (the trash surface) trash/drives/[driveId] loadDriveLeadAuthority
  • Tests.

    • Pure unit: 5 cases.
    • pg integration: lead, org Owner, org Admin, org MEMBER, non-member and personal-drive cases for each of rename, trash, restore and permanent delete, with exactly one audit row per org-power action read back through securityAudit.queryEvents, and none for the lead or a refusal. A dark case too.
    • Each web surface runs the same 7-case table through a fake that uses the real pure decision and records the audit: restore, permanent delete, rename_drive, settings rename, settings trash, trash_drive/restore_drive.
  • Mutation:

    # Line Result
    L1 drive-relationship.ts:76: lead authority ignores org power RED unit 1/9, integration 1/10, web 10/130: exactly the org-Owner and org-Admin cases across the five surfaces
    L2 drive-relationship-loader.ts:110: no audit write RED 1/10
    L3 drives/[driveId]/route.ts:361: trash audit dropped RED 2/68
    L4 restore/route.ts:53: back to ownerId === RED 2/19

Review round 1 (50a26fd86, e4f102c67)

  • P1-A (CI Unit Tests: too many clients already). The new integration file now ends its pool in afterAll (50a26fd86). E2E's only failure was 18-sidebar-directory-live.spec.ts:451, the known Retro 0 flake.

  • P1-B (ruling). See above.

  • Codex P2 (backups).

    • New getAdministeredDriveIds: lead + effective ADMIN, so an org Owner/Admin administers every drive of the org, joined or not. It uses the ORG-4 private-drive audit, and a dark case gives owned + accepted ADMIN rows.
    • pg matrix against getDriveAccess for every person and drive, plus a constant-query check.
    • listAllUserBackups uses it.
  • Codex P2 / review P3-3 (activity summary). Counts are over accessiblePageIds (the page set pulse scopes to), not every page of the member drives. A private page, or a page a custom role hides, is no longer counted (a dark-visible difference, in the right direction).

  • Review P2-1 (guard shapes).

    • access-gate-scan now matches any *ownerId under ===/!==/==/!= (a destructured ownerId, driveOwnerId), and ${drives.ownerId} / ${driveMembers} in sql templates, with a self-test for each.
    • All four of the reviewer's planted shapes now go RED (1 failed | 13 passed each; file restored).
    • The 15 newly found functions are non-drive owners, the client usePermissions flag, and app-hosting/.../dedicated (payer gate). All are allowlisted with reasons, which brings the allowlist to 68 functions in 51 files.
  • Review P3-2. The route-level "stale source=org row" cases prove each route asks the relationship and reads no row itself. The stale-row refusal itself is proven by the primitives matrix on Postgres.

  • Review P3-1. listAccessibleDrives(WithOrgs) is an access resolver that lives in services/, allowlisted as "the resolver itself". Moving it into permissions/ is a follow-up, not this leaf.

  • Found while fixing, not fixed here (needs a migration; this lane owns none). The DB function accessible_page_ids_for_user (migration 0133), behind accessiblePageIds (pulse, activity summary), is not org-aware:

    • it counts any accepted row, a stale source='org' row included;
    • it knows neither org Admin power nor implicit Open members.

    It is exact while dark, and it is flagged in #org-wallets.

Requirement IDs and tests

ID Tests (titles carry the ID) Claim
ORG-4 ORG-4 (partial) DRV-5 (partial) X-6 (partial) loadDriveRelationship(s) answer every person and drive exactly as getDriveAccess… · ORG-4 (partial) DRV-5 (partial) X-6 (partial) listDriveAudiences names exactly the people getDriveAccess makes members of each drive… (drive-gate-primitives.integration) · route/service cases: trash, permissions-tree, drive pages, bulk-copy, bulk-move, invite, reorder, permission-management, revoke adapter, backups list, multi-drive membershipRowsOf, getDriveRecipientUserIds, calendar attendees partial (see below)
DRV-5 primitives matrix ×4 (relationship, member drives, SQL condition, audience) · drive-audience-decision · app-shell integration · every "implicit Open member" route case (drive pages, page tree, commands ×2, activity ×2, pulse, discovery, threads, inbox, badges, visibility, channel-tools, page-privacy kick) partial: sidebar/picker UI is Wave F
DRV-6 DRV-5 (partial) DRV-6 (partial) X-6 (partial) listMemberDrives lists exactly the drives a person is listed on and a member of… partial: the join request/approval flow is not here
DRV-7, DRV-9 not claimed not claimed
X-6 every "stale source=org row opens nothing" case (15 routes/services) · the primitives matrix (Dana, removed from Northwind with a stale Product row; Marcus's stale Research/Finance rows) · drive-audience-decision partial: the policy, wallet and automation clauses belong to other lanes

ORG-4 still cannot leave (partial), even with the ruling in. With this PR every apps/web gate agrees with the resolvers: org Owner/Admin power opens the drive, and a PRIVATE-drive access is audited through loadEffectiveDriveMembership. The two clauses still missing are:

  • Now proven: rename, trash, restore and permanent delete by an org Owner/Admin on every org visibility, each audited (pg, one row per action).
  • Still unproven:
    • root page-agent create stays lead-only (not in the ruling);
    • changing a drive's lead is B8;
    • accessible_page_ids_for_user is row-based (above);
    • DM eligibility is row-based.
  • DM eligibility (users/messageable, usersShareDrive) is still row-based and deliberately ungated, so it is allowlisted.

Reviewer: please confirm ORG-4 stays (partial). scripts/spec-coverage-allowlist.txt is unchanged.

Dark-visible differences (ORGS_ENABLED false)

Personal drives answer exactly as before except for these. Each one brings a gate into agreement with the canonical resolver.

  1. messages/threads lists only channels getBatchPagePermissions lets the caller view. Before, a pending invitee, a private channel or a channel hidden by a custom role listed its title and last message. Master already has this (fix(permissions): pending drive invites grant nothing; a custom role's drive-wide grant never opens a private page #2672).
  2. page-reorder-service / permission-management-service: a pending ADMIN invitation is no longer admin. These are master's fix(permissions): pending drive invites grant nothing; a custom role's drive-wide grant never opens a private page #2672 lines verbatim.
  3. drives/[driveId]/pages, non-admin branch: a custom role's limits and an expired page share now apply. They match getUserAccessLevel, and the ancestor walk starts from non-trashed pages only, as before.
  4. Revoking a pending invite: the drive's lead can revoke without an OWNER row (before: FORBIDDEN). An org drive's lead never gets that row (B7b).
  5. pulse ×3 and memory discovery now include owned drives that lack an OWNER self-heal row (again, org leads never get one).
  6. Page-privacy kick: the decision is now getUsersWhoCanViewPage over the drive's members. A non-lead holding a personal drive's OWNER row is kicked if they can no longer view the page. Everyone else is kicked exactly as before.
  7. getDriveMemberUserIds and isMemberOfDrive now include the lead. Neither has a production caller.
  8. Inbox and badge candidates no longer include pending rows. The final getBatchPagePermissions filter is unchanged, so the output is identical.

Traps checked

  • acceptedAt: every new read gates on it:
    • listMemberDrives rows;
    • loadAcceptedRowsInDrives;
    • listDriveAudiences, pinned in acceptedAt-gate.test.ts;
    • memberOfAnyDriveCondition.
  • Pending invitations (Tomás) answer "no membership" in the primitives matrix, dark and enabled.
  • A view-denying custom role: the drive pages route now uses getUserAccessiblePagesInDrive. Implicit members carry the default role everywhere (Handbook's view-denying default in the matrix).
  • Expired shares: the drive pages route drops its unfiltered explicit-grant query. listMemberDrives ignores shares entirely (Lu's expired share lists nothing).
  • accessiblePageIds: cross-drive aggregates keep their per-page filters: pulse, the threads/inbox/badges getBatchPagePermissions, and the channel fan-out getUsersWhoCanViewPage. The candidate drive sets only feed them.
  • N+1: batch and listing paths run a constant number of queries, asserted by spying on pool.query:
    • loadDriveRelationships: 1 drive = all drives = 3;
    • listMemberDrives: unchanged after adding 12 OPEN drives;
    • listDriveAudiences: 1 drive = all.
  • Pre-existing, not changed here: ai/page-agents/multi-drive loops getPrincipalDriveAccess over every drive in the system.

Mutation checks

Method: by line index at 66a57454f, with an anchor asserted on the line. Each distinct command ran once unmutated as a control (16 controls, all GREEN). git status was clean after.

# Line mutated Result
M1 member-drives.ts:63 if (!ORGS_ENABLED)if (true) RED 4/8 primitives integration
M2 member-drives.ts:164 drop ne(driveMembers.role, 'OWNER') in the SQL condition RED 1/8 (SQL vs decision)
M3a/b drive-relationship.ts:47 canAdministerDrive admits any membership RED unit 1/4, integration 2/8
M4 drive-relationship-loader.ts:35 batch ignores rows RED 2/8
M5a/b org-drive-resolution.ts:250 audience ignores org members RED integration 1/8, unit 2/4
M6 drive-audience.ts:42 acceptedAt gate removed RED 2/8
M7 trash/route.ts:55 owner-only RED 2/14
M8 pages/tree/route.ts:66 any relationship passes RED 3/18
M9 messages/threads/route.ts:207 permission filter dropped RED 1/5
M10 inbox/route.ts:50 empty member-drive set RED 2/16
M11 revoke-adapters.ts:39 always ADMIN RED 4/6
M12 visibility.ts:46 always shares RED 3/8
M13 calendar-event-drive-service.ts:314 home drive only RED 1/46
M14 drive-backup-service.ts:84 no lead/ADMIN filter RED 2/2
M15 pages/[pageId]/route.ts:164 nobody kicked RED 2/50
M16 app-shell-service.ts:166 everything owned RED 3/10
M17 access-gate-scan.ts:186 presence-only (per-file-style) check RED 1/13 (same-function plant)
M18 handle-drive/route.ts:82 any membership may receive ownership RED 1/29
M19 drive-member-service.ts:126 recipients = lead only RED 1/25

Commands and results

Local only; the build slot was respected and CI is the gate.

  • Test Postgres: a throwaway homebrew postgresql@17 on 127.0.0.1:5577 with session TZ America/Chicago, migrated with bun run --filter @pagespace/db db:migrate.

  • Harness: the worktree source harness (vitest.wt*.config.ts, tsconfig.wt.json) is git-excluded.

  • Lib integration: DATABASE_URL=postgresql://user@127.0.0.1:5577/pagespace_test bunx vitest run --config vitest.wt-int.config.ts <file>, 10 suites, 116 passed:

    Suite Passed
    drive-gate-primitives.integration.test.ts 8/8
    org-drive-sibling-resolvers (B7b matrix + dark equivalence) 7/7
    org-drive-resolvers 8/8
    page-viewers 9/9
    app-shell-service 10/10
    org-membership-sync 10/10
    leave 12/12
    org-services 21/21
    org-member-revocation 3/3
    org-drive-service 28/28
  • Lib unit: bunx vitest run --config vitest.wt.config.ts src/permissions src/services src/organizations src/__tests__ src/agent-workspaces src/repositories src/auth --exclude '**/*.integration.test.ts'306 files, 7650 passed | 1 todo.

  • Web, every directory holding a touched file: bunx vitest run --config vitest.wt.config.ts <32 dirs> → 583 files, 9459 passed, 120 failed in 57 files.

    • The same 57 files fail with the same 120 failures on a clean origin/pu/org-wallets checkout run the same way. They are source-harness partial-mock artifacts, and none is new.
    • bulk-copy, bulk-move and manage-keys-scope are among the files that cannot collect under the harness. Their copies with the db mocks spread over the real modules pass: 40/40, 47/47, 1/1.
  • Types:

    • bunx tsc --noEmit -p tsconfig.json (packages/lib): 0 errors.
    • bunx tsc --noEmit -p tsconfig.wt.json (apps/web): the one error is a harness-missing prosemirror-model in untouched packages/editor. An injected probe error in visibility.ts was reported, so the check is live.
  • Lint: bunx eslint on all 71 touched web files: 0 problems. The touched lib sources give 0 errors; lib test files are outside the lint config.

  • Left to CI: monorepo typecheck, knip, the full web/realtime suites, E2E, spec coverage.

  • drive-gate-primitives.integration.test.ts is added to lib vitest.config.ts's excludes (unit run and coverage), so it runs in the test:integration step.

  • New lib subpaths permissions/drive-relationship, drive-relationship-loader and member-drives have exports + typesVersions entries.

Not in this PR / for the reviewer

  • DM eligibility (users/messageable, lib usersShareDrive) is deliberately ungated and row-based. It is unchanged and allowlisted. An implicit Open member cannot DM co-members through it, and a stale org row still can. This needs its own ruling.
  • The assignees picker and the pulse team roster enumerate rows for display (allowlisted). Once the B4 sync materializes rows, implicit members appear there.
  • Search is scoped to the caller's led drives, both before and after (allowlisted as an ownership inventory).
  • Merge with master: drive-member-gate-coverage.test.ts, messages/threads/route.ts and inbox/route.ts differ between master and pu/org-wallets, so the integration merge must keep this branch's candidate SQL and master's per-query scanner.
  • Changelog: no entry. Nothing org-related is user-visible while ORGS_ENABLED=false. The dark-visible fixes above are the fix(permissions): pending drive invites grant nothing; a custom role's drive-wide grant never opens a private page #2672 master parity items plus the lead-without-OWNER-row items.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF

2witstudios and others added 14 commits September 20, 2026 23:23
… the former inline gates (B7c)

loadDriveRelationship(s) answer lead + org-aware effective membership (batch: constant
queries); listMemberDrives / getMemberDriveIds / memberOfAnyDriveCondition answer 'which
drives is this person a member of' with the listing rule, dark = owned + accepted rows.
Integration suite checks every Northwind person x drive against getDriveAccess and
listAccessibleDrives, and dark against the frozen inline union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…py/move gate on the org-aware drive relationship (B7c)

Each route asked drives.ownerId and its own drive_members read; they now ask
loadDriveRelationship (lead + effective membership) and keep their own role test,
so a personal drive answers as before while an org Admin, an implicit Open member
and a stale source='org' row resolve like every lib resolver. The drive pages
route's non-admin branch takes getUserAccessiblePagesInDrive's page set.
Pure decisions (drive-relationship.ts) split from the IO loader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…and ownership transfer ask the org-aware relationship (B7c)

page-reorder-service and permission-management-service take master's isDriveOwnerOrAdmin
fix (#2672 line) plus org cases; the drive invite route gates on canAdministerDrive
(findAdminMembership removed); the revoke port answers the actor's effective drive role
(findActorDriveRole) so the lead of an org drive, who has no OWNER row, can revoke and a
stale org row cannot; handle-drive's transfer target must be an effective ADMIN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
… scope to the org-aware member-drive set (B7c)

Each ran its own owned-drives + accepted-rows union; they now call getMemberDriveIds,
which is that exact union while ORGS_ENABLED is off and the listing rule
(decideListedDriveRole without page shares) while on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…the org-aware member-drive set (B7c)

They read accepted drive_members rows only, so an org drive's lead (who never gets an
OWNER self-heal row) and an implicit Open member had no drive context, while a stale
org row still counted. getMemberDriveIds answers owned + joined, org-aware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…te drives from the org-aware member-drive set (B7c)

The candidate SQL joined drive_members and compared drives.ownerId; it now matches
p.driveId = ANY(getMemberDriveIds) (owned or joined, org-aware) or a live page grant.
messages/threads gains the getBatchPagePermissions filter the inbox and badges already
had (master #2672 line), so a pending invitee, a stale org row or a private channel
no longer lists a channel's title and last message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…permissions layer (B7c)

callerCanViewUser asks sharesMemberDrive and searchRelatedProfilesByName filters with
memberOfAnyDriveCondition (both org-aware; the SQL condition is checked against the
decision for every Northwind person and drive), instead of their own owned-drives and
drive_members reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
… listed role (B7c)

fetchShellDriveIds ran owned + accepted rows and took the role from the raw row; the
shell now lists listMemberDrives (same union while dark, the listing rule while on)
inside its transaction, and the ownership flag and role come from that listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…emberships in one batch (B7c)

The sandbox-eligibility edit rule read raw drive_members rows; it now reads
loadDriveRelationships (an org Admin is ADMIN, an implicit Open member carries the
drive's default role, a stale org row gives nothing), and the lead check is isDriveLead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
… and event attendees; lib owner checks ask isDriveLead (B7c)

listDriveAudience(s) (pure decideDriveAudience over the lead, accepted rows and, while
enabled, the org's members) replaces the drive_members reads in getDriveRecipientUserIds,
getDriveMemberUserIds(ByStandardRole|ByCustomRole), isMemberOfDrive and the calendar
attendee union: a stale org row stops receiving drive broadcasts and mentions, an org
Admin and an implicit Open member start. isUserMemberOfAnyEventDrive drops its dark
drive_members branch for the same isUserDriveMember path (dark answer unchanged, pinned
by B7b's frozen-legacy equivalence). checkDriveAccess, checkDriveAccessForRoles,
getDriveAccess(WithDrive), resolveGranterAccess, resolveDriveMembership, the self-heal,
the org sync and decideMoveDriveIntoOrg compare drives.ownerId only through isDriveLead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
Drive restore, permanent deletion, rename_drive, root-level page-agent creation, the
'cannot remove the drive owner' guard, the /api/drives ownership flag and storage/info's
owner shortcut compared drives.ownerId inline; they ask the permissions layer's
isDriveLead. Lead-only stays lead-only (an org Owner or Admin is not the lead).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…rivacy kick, the all-backups list and restore_drive take members from the permissions layer (B7c)

The candidate sets now come from getDriveRecipientUserIds (the org-aware audience) and
are still decided per page by the canonical viewer checks; the page-privacy kick is
'members the viewer check no longer admits'. listAllUserBackups reads listMemberDrives
(lead or ADMIN), and driveRepository.findByIdAndOwner asks isDriveLead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…s, checked per query (B7c)

drive-access-gates.seam.test.ts scans apps and packages for every drive_members query
and ownerId comparison outside the permissions layer, attributes each to its enclosing
function, and requires an exact per-function count with a reason (writer, invite
management, payer lookup, own-drive inventory, display behind an access check, or an
ownerId that is not a drive's). Self-tests plant each shape in fake files, including an
ungated query beside an allowlisted one in the same function. The per-file enumeration
and acceptedAt allowlists lose the 24 + 2 files that no longer read drive_members.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 827aedeb-7280-42f9-a015-2cedcb7c82b4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

… (B7c)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66a57454fb

ℹ️ About Codex in GitHub

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

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

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +83 to +85
const driveIds = (await listMemberDrives(userId, { includeTrashed: false }))
.filter((d) => d.isOwner || d.role === 'ADMIN')
.map((d) => d.driveId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use effective admin access when listing backups

When organizations are enabled, an org Owner/Admin without a direct membership row on a RESTRICTED or PRIVATE drive is deliberately omitted by listMemberDrives, even though isDriveOwnerOrAdmin grants them full administrative access and listDriveBackups allows the same user to view that drive's backups directly. Consequently, /api/backups silently omits backups the user administers; derive this list using the effective drive-admin permission rather than the picker/sidebar-oriented member-drive listing.

AGENTS.md reference: AGENTS.md:L119-L124

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e4f102c. New getAdministeredDriveIds (permissions/member-drives.ts) returns the drives the user leads plus every drive whose effective membership is ADMIN. So an org Owner/Admin administers every drive of the org, joined or not, and org power over a PRIVATE drive writes the ORG-4 audit. Dark, it is owned + accepted ADMIN rows. It is checked on pg against getDriveAccess for every Northwind person and drive (getAdministeredDriveIds lists exactly the drives getDriveAccess makes the person lead or ADMIN of…, plus a dark case and a constant-query check). listAllUserBackups uses it (list-all-user-backups.test.ts 2/2). Leaving this open for verification.

Comment on lines +128 to +130
// Get pages updated count (pages in drives user has access to): the drives the user is a
// member of, owned or joined (org-aware; page-level access does not count)
const userDrives = (await getMemberDriveIds(userId, { includeTrashed: true })).map(driveId => ({ driveId }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter activity counts through page permissions

For an ordinary org member implicitly included in an OPEN drive, getMemberDriveIds now adds that drive, but the following queries count every non-trashed page in it, including private pages and pages denied by a custom role. This both inflates the activity summary and reveals whether inaccessible pages changed; scope the counts through the canonical accessible-page permission set rather than drive membership alone.

AGENTS.md reference: AGENTS.md:L119-L124

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e4f102c. The page counts are now over accessiblePageIds(userId), the canonical page set pulse already scopes to, so a private page or a page a custom role hides is never counted. Test: X-6 (partial) counts only pages the user can view (accessiblePageIds)… (summary route 5/5). Caveat, reported separately: the DB function behind accessiblePageIds (accessible_page_ids_for_user, migration 0133) is not org-aware yet. It is exact while ORGS_ENABLED is false, and it needs a migration lane. Leaving open.

@2witstudios 2witstudios left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[independent-review] agent:ow-irv-2689 — PR #2689 (B7c) at 66a5745

Verdict: 2 P1, 1 P2, 3 P3. Not mergeable on this head. P1-A is CI red, and the likely cause is in this PR. P1-B is the point-guard ruling (rename, trash and restore of an org-owned drive), which this head does not contain. I reviewed against the narrowed scope: transfer-lead is out and lives on the B8 leaf. Everything else I checked holds.

Per-ID verdicts

ID Claim Verdict Evidence
ORG-4 ORG-4 (partial) ×15 partial, correctly marked; must stay partial drive-gate-primitives.integration.test.tsORG-4 (partial) DRV-5 (partial) X-6 (partial) loadDriveRelationship(s) answer every person and drive exactly as getDriveAccess… and …listDriveAudiences names exactly the people getDriveAccess makes members…. Both pass locally on pg17, session TZ America/Chicago. Two clauses are unmet at this head: the ruling's lead-level actions for an org Owner/Admin (P1-B), and DM eligibility (still row-based). CI: ORG-4 allowlisted partial.
DRV-5 DRV-5 (partial) ×22 partial, correct The primitives matrix (implicit Open member with no row) and drive-audience-decision.test.ts. The sidebar and picker are Wave F. CI: DRV-5 allowlisted partial.
DRV-6 DRV-6 (partial) ×1 partial, correct …listMemberDrives lists exactly the drives a person is listed on and a member of…: an unjoined RESTRICTED/PRIVATE drive is never listed. The join request/approve flow is not here. CI: DRV-6 allowlisted partial.
DRV-7, DRV-9 not claimed n/a The leaf lists them. Nothing in this PR proves them, and claiming nothing is correct.
X-6 X-6 (partial) ×34 partial, correct Stale source='org' rows, pending invitations and guests are refused in the primitives matrix (real pg). The policy, wallet and automation clauses belong to other lanes. See P3-2 on what the route-level cases actually prove.
  • Every ID in a test title is followed by exactly (partial): one ASCII space, lowercase. The diff has no plain ID claim.
  • scripts/spec-coverage-allowlist.txt is unchanged, and no ID is promoted.
  • CI Spec ID coverage (run 35563809109, job 106225236214):
    • line 230: DRV-5 allowlisted partial
    • line 231: DRV-6 allowlisted partial
    • line 246: ORG-4 allowlisted partial
    • line 309: X-6 allowlisted partial
    • lines 311–312: covered 10/87, allowlisted-missing 77, MISSING 0 / spec-coverage: OK

(f) Can ORG-4 leave (partial)? No. At this head an org Owner/Admin is still refused restore, permanent delete and rename_drive on an org-owned drive (P1-B). DM eligibility is also still row-based. Even after P1-B lands, ORG-4 should leave (partial) only if a test proves every clause, including the audit event, on a PRIVATE drive for each action.

P1

P1-A — CI Unit Tests are red on this head, and the likely cause is the new integration suite.

  • The failing step is lib test:integration: Test Files 2 failed | 44 passed | 2 skipped (48).
    • session-repository-resource and replay-across-replicas fail at beforeAll: sorry, too many clients already (code 53300, 6 occurrences).
  • The integration config runs every file in one fork (singleFork: true). @pagespace/db/db's pool holds idle clients for idleTimeoutMillis: 600000, with max: 10.
  • This PR adds drive-gate-primitives.integration.test.ts to that run. It never calls pool.end(), while its sibling org-drive-sibling-resolvers.integration.test.ts:261 does.
  • Base d1f0c0d43 was green (run 35559851834), with one integration file fewer.
  • I could not reproduce the exhaustion locally (my cluster has max_connections=300), so the causal link is inferred, not proven.
  • Fix: afterAll(() => pool.end()) in the new suite, then re-run. If it stays red, bisect the file set.
  • E2E's single failure is 18-sidebar-directory-live.spec.ts:451, the known flake from Retro 0, so re-run it.

P1-B — the point-guard ruling (org Owner/Admin counts as lead for rename, trash and restore on an org-owned drive, each audited) is not in this head.

  • isDriveLead (drive-relationship.ts:36) is a pure ownerId === userId.
  • These refuse an org Owner/Admin:
    • restore (restore/route.ts:52)
    • permanent delete (trash/drives/[driveId]/route.ts:35)
    • rename_drive (drive-tools.ts:287)
  • isDriveLead cannot answer the ruling as it stands: it needs the org role (IO) and must write an audit event, so it cannot stay a pure predicate on ownerId.
  • I will re-review when the lane pushes. I will check:
    • personal drives are byte-identical;
    • each action writes exactly one audit event (on a PRIVATE drive, and whatever the ruling requires on OPEN/RESTRICTED);
    • an org MEMBER is refused;
    • a stale source='org' row grants nothing;
    • the lane's mutation of the new branch goes red.
  • Transfer-lead is out of scope per the ruling update (B8 leaf). account/handle-drive refuses org drives at route.ts:62, which I confirmed.

P2

P2-1 — the seam guard misses real comparison shapes, and one gate in the repo slips through today.
DRIVE_OWNER_COMPARE only matches .ownerId member access, eq/ne(drives.ownerId…) and quoted SQL. I planted each shape below in drives/[driveId]/trash/route.ts GET and ran drive-access-gates.seam.test.ts, then restored the file (git status clean):

Planted shape Guard
const { ownerId } = drive; if (ownerId !== auth.userId) … GREEN 13/13
if (drive.ownerId == auth.userId) GREEN
sql`${drives.ownerId} = ${auth.userId}` GREEN
sql`select 1 from ${driveMembers} …` GREEN
control: ungated db.select().from(driveMembers) beside the allowlisted one in drive-invite-repository.ts › findExistingMember RED, found {"reads":2…}, allowed {"reads":1…}
  • So CodeRabbit's per-query requirement holds for the shapes the scanner knows. The "finds every comparison with an ownerId" claim does not hold.
  • Real miss: apps/web/src/app/api/app-hosting/apps/[appId]/dedicated/route.ts:80-81. const ownerId = await lookupDriveOwnerId(app.driveId); if (!ownerId || ownerId !== auth.userId) is an inline drive-lead gate that is neither routed nor allowlisted. It is a payer gate, and under the ruling it is the kind of lead question that should go through the seam.
  • Fix:
    • match a bare ownerId identifier compared with ===/!==/==/!=;
    • match ${drives.ownerId} and ${driveMembers…} interpolated in SQL templates;
    • add self-tests for each shape;
    • route dedicated through isDriveLead, or allowlist it with the PAYER reason.

P3 (body only, non-blocking)

  • P3-1 — listAccessibleDrives / listAccessibleDrivesWithOrgs (drive-service.ts) are allowlisted, but they are access decisions. The reason given is "the resolver itself". The leaf's rule allows only entries that are not access decisions. The behaviour is correct; this is a structural exception worth naming in the leaf, or a follow-up move into permissions/.
  • P3-2 — the route-level "stale source=org row opens nothing" cases mock loadDriveRelationship to NONE. Example: trash route.test.ts:245. They prove each route no longer reads the row itself (db.select not called), which is real wiring evidence. The stale-row refusal itself is proven only by the primitives integration matrix. The PR body's "15 routes/services" X-6 count should say this.
  • P3-3 — activity/summary counts pages updated across getMemberDriveIds with no per-page filter. It returns numbers only, and it was the same before. While enabled, the set now includes OPEN org drives, so private-page update counts there are visible as a number. Not new in kind.

What I verified

Throwaway homebrew postgresql@17 on 127.0.0.1:5591, session TZ America/Chicago, migrated from source. Source-alias harness, no dist builds.

  • (a) Sweep. I grepped every ownerId and drive_members mention outside permissions/. Apart from P2-1 and the allowlist, I found no unlisted gate. The allowlist reasons read correctly, except P3-1.

  • (b) Old vs new, personal drives, ORGS_ENABLED false AND true.

    • I wrote a reviewer probe, a git-excluded pg integration file.

    • Fixture: randomized personal drives with OWNER/ADMIN/MEMBER rows, pending and accepted rows, source='org'/invite, custom roles, trashed drives. 63 (user, drive) cells per mode; non-vacuous counts: member 25/21, admin 8/7, pending-ADMIN 3/3.

    • I compared each primitive against the frozen inline code from d1f0c0d43:

      Primitive Old inline gate Sites
      isDriveMemberRelationship owner or accepted row page tree
      canAdministerDrive owner or accepted ADMIN trash, permissions-tree, drive pages admin branch, invite
      driveRoleOf ∈ {OWNER, ADMIN} owner or accepted OWNER/ADMIN bulk-copy, bulk-move
      batch loadDriveRelationships the single-drive answer
      getMemberDriveIds (both trash flags) and listMemberDrives roles owned ∪ accepted rows commands ×2, activity ×2, pulse ×3, memory discovery, threads, inbox, badges
      listDriveAudiences / getDriveRecipientUserIds old getDriveRecipientUserIds channel fan-out, calendar
      memberOfAnyDriveCondition old searchRelatedProfilesByName EXISTS profile search
      sharesMemberDrive old callerCanViewUser drive clause visibility
    • 4/4 passed: every answer identical in both modes.

    • page-reorder-service and permission-management-service use master's isDriveOwnerOrAdmin verbatim. The only change is a pending ADMIN losing admin, which is the listed dark difference.

  • (c) Traps.

    • A pending ADMIN grants nothing: my probe (3 cells per mode) and Tomás in the matrix.
    • A view-denying custom role grants no edit: canAdministerDrive needs role ADMIN, and page-level checks stay with the canonical resolvers.
    • An expired page share and a stale org row grant nothing: matrix.
    • Cross-drive aggregates keep their per-page filters: commands (canPrincipalViewPage), pulse ×3 (accessiblePageIds), threads, inbox and badges (getBatchPagePermissions). Discovery reads only the caller's own messages and activity.
  • (d) Constant queries (pool.query spy):

    Primitive Dark Enabled
    loadDriveRelationships, 1 drive vs all 1 / 1 1 / 1
    listDriveAudiences, 1 drive vs all 2 / 2 2 / 2
    listMemberDrives, before vs after 15 extra memberships 2 / 2 3 / 3
    • The lane's own test (org drives: 3 = 3) also passes.
  • Tests run:

    • lib integration drive-gate-primitives 8/8 and app-shell-service 10/10;
    • lib unit, 11 files 216/216: relationship, audience decision, acceptedAt gate, drive-member-service, calendar, invites, drive-repository, and the seams;
    • web, 30 files 525 passed / 23 skipped: trash, pages/tree, invite, permissions-tree, drive pages, handle-drive, commands, activity summary, pulse, threads, inbox, badges, users/visibility, revoke-adapters, discovery, reorder, permission-management, backups, trash/drives, channel/command/activity tools, pages/[pageId];
    • bulk-move 47/47.
  • Mutations by line index, anchor asserted, control first, restored, git status clean:

    Line Mutation Result
    member-drives.ts:58 drop isNotNull(driveMembers.acceptedAt) RED, 3 failed (control 8/8 GREEN)
    member-drives.ts:165 stale-org-row clause → sql`true` RED, 1 failed (control 8/8 GREEN)
    bulk-move/route.ts:88 role === 'OWNER' || role === 'ADMIN'role !== null RED, 1 failed (control 47/47 GREEN)
  • Rules.

    • No any, and no env flag (ORGS_ENABLED is the code constant).
    • No new now() and no bulk inserts. The IN lists in drive-audience.ts are chunked at 500.
    • The new lib subpaths drive-relationship, drive-relationship-loader and member-drives have exports + typesVersions entries. Every @pagespace/lib/permissions/* import in apps resolves.
    • No real Spec ID appears in any seam self-test title.
  • Scope. No migration. Nothing outside the leaf, except that the dedicated route is untouched (P2-1).

}
}

describe('B7c: the gate primitives agree with the canonical resolvers (integration)', () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1-A. This suite joins lib test:integration, which runs every file in one fork (singleFork: true). It never ends @pagespace/db/db's pool, which has max 10 and idleTimeoutMillis 600000. Its sibling org-drive-sibling-resolvers.integration.test.ts:261 does call pool.end().

CI run 35563809109 Unit Tests shows later files failing at beforeAll with sorry, too many clients already (53300): session-repository-resource and replay-across-replicas. Base d1f0c0d was green. The causal link is inferred, not reproduced locally. Suggest afterAll(() => pool.end()) and a re-run.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 50a26fd: afterAll(async () => { await pool.end(); }) in drive-gate-primitives.integration.test.ts (still 12/12 locally on pg17). CI on e4f102c will confirm the lib test:integration step. Leaving open.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up in e405664. CI on e4f102c still ran out of connections, now in one file (replay-across-replicas). The real leak was mine: CI sets ADMIN_DATABASE_URL, and this file cleared the audit env only inside its lead-action describe. So the resolver matrix before it wrote ORG-4 audits through a dedicated admin pool that nothing ended. The env reset and pool.end now wrap the whole file, and app-shell-service.integration (which now runs Northwind) ends its pool too. Local, one fork, max_connections 100, ADMIN_DATABASE_URL set: drive-gate-primitives + app-shell + replay-across-replicas give 3 files, 25/25. Leaving open.

const found = await db.query.drives.findFirst({
where: eq(drives.id, driveId),
});
const drive = found && (!requireUserOwnership || isDriveLead(auth.userId, found)) ? found : undefined;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1-B (point-guard ruling). On an org-owned drive, an org Owner or Admin counts as lead for rename, trash and restore, and each use is audited. At this head, restore, permanent delete (trash/drives/[driveId]/route.ts:35) and rename_drive (drive-tools.ts:287) all still ask the pure isDriveLead, so an org Owner/Admin is refused.

In scope: an org-aware lead check with one audit event per use, and personal drives unchanged. Out of scope: transfer-lead (B8 leaf). I will re-review on the next push.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 50a26fd. Pure decideDriveLeadAuthority + IO loadDriveLeadAuthority: on an org-owned drive the lead and an org Owner/Admin may rename, restore and permanently delete, and each org-power use writes one authz.access.granted event {via, action, orgId, orgVisibility}. The settings rename and trash (owner-or-admin gate, where an org Admin is already ADMIN) and trash_drive record the same event via recordOrgPowerDriveAction. Personal drives stay owner-only; an org MEMBER or non-member is refused. pg: one audit row per action per drive across OPEN/RESTRICTED/PRIVATE. Mutation L1 (lead authority ignores org power) turns exactly the 10 org-Owner/Admin web cases RED, plus unit and integration. Transfer-lead is B8. Leaving open.

* column (`ownerId: drives.ownerId`) and writing it (`.set({ ownerId })`) are not comparisons.
* Non-drive owners (agent workspaces, sessions, organizations) match too and are allowlisted as such.
*/
const DRIVE_OWNER_COMPARE = [

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2-1. These patterns miss real comparison shapes. Planted in drives/[driveId]/trash/route.ts GET, each of these left the guard GREEN (13/13):

  • const { ownerId } = drive; if (ownerId !== auth.userId)
  • drive.ownerId == auth.userId
  • sql`${drives.ownerId} = ${auth.userId}`
  • sql`select 1 from ${driveMembers} …`

A real instance ships today: apps/web/src/app/api/app-hosting/apps/[appId]/dedicated/route.ts:81, if (!ownerId || ownerId !== auth.userId) on lookupDriveOwnerId. It is neither routed nor allowlisted.

Suggest matching bare *ownerId identifiers under ===/!==/==/!= and ${drives.ownerId} / ${driveMembers…} interpolations, with a self-test for each shape. Then route or allowlist dedicated with the PAYER reason.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e4f102c. access-gate-scan now matches any *ownerId under ===/!==/==/!= (destructured ownerId, driveOwnerId), ${drives.ownerId}, and ${driveMembers} in sql templates, with a self-test for each. Your four planted shapes, re-planted in trash/route.ts GET, each go RED (1 failed | 13 passed; file restored). The 15 newly found functions are allowlisted with reasons: dedicated as the payer gate, plus non-drive owners (agent sessions and streams, auth sessions, orgs, workspaces, dev preview, env binding) and the client usePermissions flag. Leaving open.

2witstudios and others added 3 commits September 21, 2026 04:54
…lead actions, each audited (B7c, point-guard ruling)

decideDriveLeadAuthority / loadDriveLeadAuthority: rename (rename_drive), restore (web
route, restore_drive via driveRepository.findByIdForLeadAction) and permanent delete are
open to the drive's lead and, on an org-owned drive, to an org Owner or Admin; each
org-power action writes one authz.access.granted event (action, org power, visibility).
Rename and trash through the drive settings route and trash_drive, which already admit an
org Admin as ADMIN, record the same event (recordOrgPowerDriveAction). Personal drives stay
owner-only; an org MEMBER and a non-member are refused; dark, org power is nothing.
The drive-gate-primitives integration file now ends its pool (CI ran out of connections).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…nts only viewable pages; the seam guard sees every ownerId comparison shape (B7c review)

- getAdministeredDriveIds (lead + effective ADMIN, org-aware: an org Owner/Admin
  administers every drive of the org, joined or not, with the ORG-4 private-drive audit);
  listAllUserBackups uses it (Codex P2).
- activity/summary counts pages updated over accessiblePageIds, the page set pulse
  scopes to, instead of every page of the member drives (Codex P2, review P3-3).
- access-gate-scan matches any *ownerId under ===, !==, ==, != (a destructured ownerId,
  driveOwnerId), ${drives.ownerId} and ${driveMembers} in sql templates, with a self-test
  each; the 15 functions it newly finds are non-drive owners, a client display flag and
  the app-hosting payer gate, allowlisted with reasons (review P2-1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…pen (B7c review P1-A)

drive-gate-primitives cleared the audit env only inside its lead-action describe, so its
resolver matrix wrote ORG-4 audits through a dedicated admin pool (CI sets
ADMIN_DATABASE_URL) that nothing ended; the env reset and pool.end now wrap the file.
app-shell-service, which now runs the Northwind fixture, ends its pool too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
@2witstudios
2witstudios merged commit 006bf6b into pu/org-wallets Sep 21, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant