Skip to content

Feat: permission feature additions for projectcampus#873

Merged
flipvh merged 15 commits into
mainfrom
feat/projectcampus-upstreams
Jul 11, 2026
Merged

Feat: permission feature additions for projectcampus#873
flipvh merged 15 commits into
mainfrom
feat/projectcampus-upstreams

Conversation

@flipvh

@flipvh flipvh commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

No description provided.

flipvh and others added 14 commits July 10, 2026 11:23
camelCase entity types (e.g. a fork's courseSection) derived a wrong physical
table name (courseSections instead of course_sections), breaking the yjs
relay's derived SQL. Snake_case first via toColumnName — identical output for
every existing single-word entity type. Locked by an explicit convention test.

From projectcampus (CELLA-CHANGES #9).

Co-Authored-By: Claude Fable 5 <[email protected]>
The two ImmutableEntityCase[] assignments infer string[][] from their flatMap
callbacks — an error under plain tsc while tsgo (repo default) accepts it, so
CI stays green while 'npx tsc' fails. Explicit return annotations on the map
callbacks fix the inference.

From projectcampus (CELLA-CHANGES #8).

Co-Authored-By: Claude Fable 5 <[email protected]>
The attachments organization_id/created_at index was removed from the drizzle
schema (via #862/#863) without regenerating migrations; any fresh 'pnpm
generate' emits this diff as a side effect. Landing it standalone so it stops
photobombing unrelated migration work.

Co-Authored-By: Claude Fable 5 <[email protected]>
Two failure modes from projectcampus (CELLA-CHANGES #14):
- noRlsProductEntityNames hardcoded 'pages'; a fork without that module emits
  'GRANT ... ON pages', which errors. Now filtered by the tables that actually
  exist.
- That error was swallowed by 'EXCEPTION WHEN OTHERS THEN RAISE NOTICE',
  rolling back ownership, FORCE RLS and EVERY grant in one silent notice — a
  fresh DB then boots with zero table grants (every request 403s, pg 42501)
  or without enforced RLS. The handler now re-raises; the legitimate skip
  (roles not yet created) is already handled before the block.

Regenerated rls_setup migration included.

Co-Authored-By: Claude Fable 5 <[email protected]>
The engine is strict at runtime: the first membership whose (context, role)
has no policy row for the checked subject throws — a request-time 500 that
only surfaces when a real user with that role hits that subject. Projectcampus
shipped several such gaps that survived until member-session e2e tests ran
(CELLA-CHANGES #17).

configurePermissions now asserts at config time that every subject with
declared rows covers every role of every context in its ancestor chain;
all-zero rows (contexts.x.role({})) express 'no access' explicitly. Subjects
with no case are skipped (they fail fast with a clear engine error).
configureAccessPolicies and configureWidePermissions (deliberately partial
test fixtures) opt out.

Note for forks: configs with gaps now fail on startup after upgrading —
intended; add the missing all-zero rows.

Co-Authored-By: Claude Fable 5 <[email protected]>
Contexts gain a draft state that defers member invites until publish, dormant
for forks that never use it (publishedAt defaults to creation time — the same
graceful-degradation pattern as nullableAncestors). Distinct from publicAt
(public readability). From projectcampus (CELLA-CHANGES #1–04 / PR-1).

- contextEntityColumns: nullable publishedAt timestamp, defaultNow().
- create-memberships: on a draft context, invites for non-top roles are
  recorded (inactive memberships + tokens) but emails are held and existing
  users are not direct-added; the most-privileged role (first in the
  vocabulary) stays live so staff can collaborate in drafts.
- inactive_memberships.remindedAt: last email dispatch; reminder emails are
  throttled to once per 7 days via remindedAt ?? createdAt.
- helpers/deferred-invites.ts: dispatchDeferredInvites({contextIds}) — called
  by a fork's publish operation; rotates invitation tokens (raw secrets are
  unrecoverable and may have expired), sends held emails, stamps remindedAt,
  honors the throttle.
- me-queries findPendingInvitations: invites whose context is unpublished stay
  hidden from the invitee (staff-side pending lists unchanged).

Known pre-existing behavior made visible by the throttle work: re-inviting a
truly-new token invitee (no emails row) re-enters the new-user path where
onConflictDoNothing suppresses the duplicate — no email is re-sent, but an
orphan token row is minted per attempt.

Co-Authored-By: Claude Fable 5 <[email protected]>
Cella lets each context pick a role subset, but several code paths still
assumed one flat vocabulary. All changes are no-ops for single-vocabulary
forks. From projectcampus (CELLA-CHANGES #5/06/10/12 / PR-2).

- MenuStructureItem.carryRole + resolveParentMembershipRole: auto-created
  parent/associated memberships get the least-privileged fitting role
  ('member' when the vocabulary has it — identical to the previous hardcode),
  or carry the invited role when carryRole is set and valid in the parent
  vocabulary (e.g. courseSection student → course student).
- create-memberships + update-membership: reject roles outside the target
  context's vocabulary with 400 invalid_role.
- SelectRoleRadio: optional entityType prop restricts options to the
  context's vocabulary; invite-email-form passes it.
- mockContextMembership picks its random role from the context's vocabulary
  (roles.all yields invalid combos for multi-vocabulary forks).

Co-Authored-By: Claude Fable 5 <[email protected]>
'Invite > Email (Bulk)': paste any text, email addresses are regex-extracted,
deduped and lowercased, one role per batch, dispatched through the existing
invite mutation. Linked from the email tab. The invite form's default role now
derives from the target context's vocabulary ('member' when present, else the
least-privileged role) — required by multi-vocabulary forks, identical for
cella. From projectcampus (CELLA-CHANGES #11 / PR-4).

Co-Authored-By: Claude Fable 5 <[email protected]>
Product rows must be self-describing for read visibility: relationships
between product entities are data (id columns, embedded id-arrays), never
permission indirection. First-consumer experience (projectcampus comments)
showed delegation taxes every sync surface with a host-aware variant — list
SQL predicates, single-row checks, delta sync, SSE dispatch, revocation,
unseen counts — and fails closed into silence. The embedding write-side
invariant is enough lifecycle machinery; projectcampus replaced its only
usage with copied-and-cascaded columns (CELLA-CHANGES #19/#22).

Removes: the hierarchy host: option, delegateToHost/hostDelegation/hostRow
from the engine and subjects, cascadeSoftDeleteHosted, CDC hosted-cascade
suppression and e:<hosted> host counters. Entity EMBEDDINGS (id-array
columns + CDC ref-counting) are unrelated and unchanged.

BREAKING CHANGE: forks using host:/delegateToHost (raak: attachment hosted
by task) must migrate before syncing — give the hosted entity its own
membership read grants (raak has no row restrictions, so grants alone
reproduce the previous behavior) and add an explicit same-transaction
cascade in the host's delete operation.

Co-Authored-By: Claude Fable 5 <[email protected]>
The channel axiom (projectcampus, CELLA-CHANGES #22): product-row read
visibility is a function of the home context chain, memberships x the static
policy matrix, and the row's own createdBy — never per-row audience data.
Visibility variance belongs at the TYPE level (a product type gets its own
policy matrix); the per-row exceptions ('own', fork-side draft flags) are
createdBy-based and only ever widen, so revocation does not exist. Per-row
restrictions proved to be the superlinear complexity term across every sync
surface, and restrict() has zero consumers.

Removes: restrict()/rowRestrictions, visibilityDepth/audienceRoles
qualification in the engine check, the restricted-grant machinery in the
collection-scope compiler and SQL row predicates, and restriction scenarios
in the parity property test. Row conditions ('own') and publicRead stay.

BREAKING CHANGE: forks calling restrict() must move visibility variance to
type-level policy matrices before syncing (no known consumers).

Co-Authored-By: Claude Fable 5 <[email protected]>
Multi-depth product scoping, from projectcampus (CELLA-CHANGES #15/#24/#16)
— the static machinery that replaces the removed per-row restrict():

- Collection scope: membership grants at intermediate ancestor levels now
  contribute list scope, each compiled against its own level's id column.
  Two-level configs produce zero intermediate entries (behavior-identical);
  without this, forks with nested contexts list zero rows for mid-level
  members. `requested` narrowing keeps home-level semantics exactly.
- subtreeRoles (optional, default undefined = current behavior): on product
  subjects, grants of non-listed roles speak only for rows HOMED at their
  own context level; listed roles (staff/admin elevation) keep subtree
  scope. Applies to every action including create; context subjects exempt.
  This is the type-level answer to per-row visibility: one static role set
  instead of per-row audience data, evaluable identically by the engine
  check, the SQL compiler and SSE dispatch.
- Single-row subjects carry `row`, so 'own' conditions and publicSelf
  evaluate from real row data on get/update/delete paths.
- The scope compiler is now topology-injectable like the engine, enabling
  deep-chain parity tests on synthetic hierarchies; new parity blocks cover
  intermediate grants and home/subtree scoping, engine ≍ SQL row-for-row.

Co-Authored-By: Claude Fable 5 <[email protected]>
From projectcampus (CELLA-CHANGES #18/#20). The dispatch decision doubles as
cacheToken issuance — appCache serves cached bytes to any valid token holder
without re-running predicates — so dispatch must answer exactly the API's
read question. It already ran the engine per subscriber but starved it of
inputs.

- The dispatch subject now carries the event's row (REPLICA IDENTITY FULL
  puts it on every event), evaluated by the same checkPermission as API
  reads; exported as a testable predicate.
- Batch events are evaluated per row: a subscriber is pinged iff they can
  read at least one row the event speaks for (wire fields land with the CDC
  contract change).
- Subscriber membership snapshots refresh live from membership events
  instead of staying frozen at connect time.
- Unseen counts compose the same collection read predicate as list
  endpoints — shared counters are change signals, never per-user numbers.
- Three-way parity: collection SQL, engine check and dispatch agree
  row-for-row; dispatcher behavior tests with fake subscribers.

Co-Authored-By: Claude Fable 5 <[email protected]>
From projectcampus (CELLA-CHANGES #21/#23), completing the dispatch-mirror
work:

- Batch messages carry slim per-row permission fields (id, createdBy,
  context id columns, publicAt) so SSE dispatch decides per subscriber per
  row instead of on the first row alone — batches can span visibility
  boundaries.
- Batch messages are now split per seq-context: seqs are per-context
  counters, so a transaction spanning contexts (one bulk create with mixed
  placements) previously produced a single message with a non-contiguous
  seq range — logged as 'sync integrity at risk' and semantically wrong for
  the client's range fetch. Each context's message now has its own
  contiguous range, token and reservations.

Wire-contract change; the CDC worker and API deploy together (existing
deployment model), no compat shims.

Co-Authored-By: Claude Fable 5 <[email protected]>
Companion rule to the dispatch-mirror work (projectcampus CELLA-CHANGES
#20/#22): shared sequences and counters are cursors and change signals; any
number a user sees must come from a predicate-filtered read.

- Batch-create pings no longer bump unseen counts by the seq-range width —
  a batch's width is not 'new for you' (it can span contexts or contain
  rows the subscriber cannot read). The client invalidates the unseen-counts
  query instead; the server computes the predicate-exact answer.
- The catchup integrity check compared server-reported counts against the
  client's cached list totals — but caches are predicate-filtered per user,
  so any member who can't see every row mismatched forever, invalidating on
  every reconnect. It now compares server-count to the PREVIOUS server-count
  (an honest change signal) and invalidates only when something actually
  changed while the client wasn't watching.

Co-Authored-By: Claude Fable 5 <[email protected]>
The name should carry the concept's precise meaning: roles listed here are
ELEVATED — their product-subject grants cover the whole subtree below their
context (staff/admin oversight), while everyone else's grants speak only for
rows homed at their own level. 'elevated' also matches the existing elevation
vocabulary in the policy docs (elevation rows vs self rows). Unreleased
rename — the option shipped on this branch.

Co-Authored-By: Claude Fable 5 <[email protected]>
@flipvh flipvh deployed to production July 11, 2026 10:58 — with GitHub Actions Active
@flipvh flipvh marked this pull request as ready for review July 11, 2026 11:12
@flipvh flipvh merged commit 2bd01e7 into main Jul 11, 2026
9 of 10 checks passed
@flipvh flipvh deleted the feat/projectcampus-upstreams branch July 11, 2026 11:12
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