diff --git a/docs/explorations/0192_[_]_SCHEMA_AUTHORIZATION_COVERAGE_AND_ENFORCEMENT_AUDIT.md b/docs/explorations/0192_[_]_SCHEMA_AUTHORIZATION_COVERAGE_AND_ENFORCEMENT_AUDIT.md new file mode 100644 index 000000000..23faaeafe --- /dev/null +++ b/docs/explorations/0192_[_]_SCHEMA_AUTHORIZATION_COVERAGE_AND_ENFORCEMENT_AUDIT.md @@ -0,0 +1,692 @@ +# Schema Authorization, Permissions, and Roles: Coverage and Enforcement Audit + +## Problem Statement + +xNet ships a sophisticated, declarative, schema-native authorization model +(roles, action grants, cascading membership, an evaluator, an E2E +recipient-computation path, and a "who-can-do-what" permission matrix UI). The +question this exploration answers is narrow and concrete: + +> **Do the existing schemas fully utilize authorization, permissions, and roles +> properly?** + +Short answer: **No — on two independent axes.** + +1. **Coverage gap.** Only **12 of ~64** registered schemas declare an + `authorization` block. The highest-traffic content types — `Page`, + `Database` (+ rows/fields/views), `Canvas`, `Folder`, `Channel`, + `Dashboard`, `Map`, `Comment`, `ChatMessage`, `Tag`, `MediaAsset`, + `Profile`, `Experiment`, `Metric`, `Observation` — carry `space` and + `visibility` *properties* but **no authorization block**. Per + `getAuthMode()` they are "legacy = owner-only." + +2. **Wiring gap.** The schema-native authorization engine + (`DefaultPolicyEvaluator`) and the authorized sync provider + (`AuthorizedYjsSyncProvider` / `YjsAuthGate`) are **built, tested, and + exported, but never instantiated in any production client**. The React, + Electron, and Expo `NodeStore`s are constructed with no `authEvaluator`, no + `auth`, and no `nodeContentCipher`. Production sync uses the plain + `NodeStoreSyncProvider`. The only thing actually enforcing access today is + **the hub** (server-side grants, room/query auth, `visibility`), which does + **not** read the schema `authorization` blocks at all. + +The net effect: the schema `authorization` blocks are, in practice, **design +intent + a UI reflection (the Permissions tab)** — not the live policy. The two +systems (schema-declared roles vs. hub grants) have drifted, and there are +latent correctness landmines that would break collaboration the day someone +"turns the evaluator on." + +## Executive Summary + +```mermaid +flowchart TB + subgraph Declared["Schema-declared model (built + tested)"] + A["authorization block on Schema
roles + actions + cascade"] + B["DefaultPolicyEvaluator.can()"] + C["computeRecipients() — E2E key sharing"] + D["buildPermissionMatrix() → Permissions tab"] + end + subgraph Enforced["What actually enforces in prod"] + H["Hub: grant index + ShareAccess
room/query auth + visibility"] + end + + A --> B + A --> C + A --> D + A -. "12 / 64 schemas only" .-> A + + B -. "❌ never wired into client NodeStore" .-> X1[(no-op)] + C -. "❌ nodeContentCipher never set" .-> X2[(no-op)] + D --> UI["✅ ShareDialog Permissions tab
(read-only reflection)"] + + H --> Live["✅ real enforcement
(server-side, grant-based)"] + + A -. "hub ignores schema.authorization
→ two sources of truth diverge" .-> H + + style X1 fill:#fee,stroke:#c00 + style X2 fill:#fee,stroke:#c00 + style Live fill:#efe,stroke:#0a0 + style UI fill:#efe,stroke:#0a0 +``` + +The authorization *machinery* is mature and well-designed (a hybrid of +policy-as-data, ReBAC-style relationship cascade, and capability grants — see +[External Research](#external-research)). The *adoption* is incomplete: most +schemas don't declare policy, and the client never runs the engine. Today's +real, working access control is entirely the hub's grant model. This is a +defensible MVP posture, but it is not "schemas fully utilizing authorization," +and the gap is currently undocumented and unguarded, so it silently widens with +every new schema. + +This document maps exactly what exists, where the gaps and landmines are, and +recommends a staged path to make the schema layer the single declarative source +of truth while keeping the hub authoritative for untrusted peers. + +## Current State In The Repository + +### The authorization DSL (rich, complete) + +The schema type carries an optional policy: + +- `packages/data/src/schema/types.ts` — `Schema.authorization?: SerializedAuthorization`. +- `packages/data/src/auth/builders.ts` — the DSL: + `role.creator()`, `role.property(name)`, `role.relation(name, targetRole)`, + `role.members({ edgeSchema, containerProp, memberProp, roleProp, minRole, roleOrder, parentProp })`; + combinators `allow/deny/and/or/not`; constants `PUBLIC`, `AUTHENTICATED`. +- `packages/data/src/auth/presets.ts` — reusable shapes: + `private()`, `publicRead()`, `open()`, `collaborative(parentRelation)`, + `team(editorsProperty)`. +- `packages/data/src/schema/schemas/space-authorization.ts` — the two cascade + builders: + - `spaceOwnAuthorization()` — for `Space` nodes; roles resolved via + `SpaceMembership` edges and the `parent` chain. + - `spaceCascadeAuthorization(relationName = 'space')` — for content; roles are + `role.relation('space', 'spaceOwner' | 'spaceAdmin' | …)`, i.e. inherited + from the linked Space. No `space` set ⇒ owner-only (private by default). + +### Roles and membership model + +```mermaid +erDiagram + SPACE ||--o{ SPACE_MEMBERSHIP : "has members" + SPACE ||--o{ SPACE : "parent (nesting)" + SPACE_MEMBERSHIP }o--|| PERSON : "member (DID)" + SPACE ||--o{ CONTENT : "space relation (cascade)" + + SPACE { + text name + select kind "personal|workspace|org|team|community|family" + relation parent + select visibility "private|unlisted|public" + person owners + } + SPACE_MEMBERSHIP { + relation space + person member "DID" + select role "viewer|commenter|member|admin|owner" + } + CONTENT { + relation space "→ inherits roles" + select visibility "inherit|private|unlisted|public" + } +``` + +- `packages/data/src/schema/schemas/space.ts` — + `SPACE_ROLES = ['viewer','commenter','member','admin','owner']`, + `spaceRoleGrantActions(role)` maps roles → hub actions + (`viewer→[read]`, `member→[read,comment,write]`, `admin/owner→+share,admin`). +- `packages/data/src/schema/schemas/space-membership.ts` — + `SpaceMembership { space, member, role }` with deterministic id + `spaceMembershipId(spaceId, memberDid)`. **No authorization block of its + own** (it is an edge node). + +### The evaluator (built, tested — not wired) + +`packages/data/src/auth/evaluator.ts`: + +- `DefaultPolicyEvaluator.can(input)` (lines ~443–543): stale-check → cache → + load node + schema → **auth mode** → role resolution → field rules → action + expression → deny-precedence → allow → **grant-index fallback** → deny. +- `DefaultRoleResolver.resolveRoles()` (lines ~142–295): resolves `creator` / + `property` / `relation` / `membership` roles, walking the container/`parent` + chain up to `MAX_CONTAINER_DEPTH = 32`. +- `createPolicyEvaluator()` factory (line ~865). + +**The auth-mode switch is the crux.** `packages/data/src/auth/mode.ts`: + +```ts +export function getAuthMode(schema: Schema): AuthMode { + if (!schema.authorization) return 'legacy' + return 'enforce' +} +``` + +And in `can()`: + +```ts +const mode = getAuthMode(schema.schema) +if (mode === 'legacy') { + const allowed = node.createdBy === input.subject // owner-only + return this.decision(input, allowed, allowed ? ['owner'] : [], start) // RETURNS EARLY +} +if (!schema.schema.authorization) { // ⚠️ DEAD CODE (unreachable) + return this.deny(input, ['DENY_NO_ROLE_MATCH'], start) +} +``` + +So a schema without an `authorization` block is **owner-only, and the function +returns *before* the grant-index fallback**. The `!authorization` deny branch is +**unreachable** dead code. + +### The store integration (no-op without an evaluator) + +`packages/data/src/store/store.ts`: + +- `canReadNode(node)` / `filterReadableNodes(nodes)` — **return the node(s) + unfiltered when `this.authEvaluator` is undefined** (≈ lines 2445–2471). +- `assertAuthorized()` / `assertAuthorizedBatch()` — **no-op when neither + `this.auth` nor `this.authEvaluator` is set** (≈ lines 2420–2509). +- Auth-pushdown read path (≈ lines 734–765) is gated on + `this.authEvaluator && !this.nodeContentCipher`. + +### Where stores are actually constructed (the wiring gap) + +- `packages/react/src/context.ts:660` — the **web/electron renderer** path: + ```ts + const ns = new NodeStore({ storage: nodeStorageAdapter, authorDID, signingKey }) + ``` + No `authEvaluator`, no `auth`, no `nodeContentCipher`. ⇒ all reads/writes are + allow-all client-side. +- `packages/runtime/src/client.ts:248` — the SDK/CLI path passes + `authEvaluator: options.authEvaluator` and `auth: options.auth` **through**, + but these are optional seams that default to `undefined`. +- `apps/expo/.../XNetProvider.tsx:195`, `apps/electron/.../data-service.ts:1657` + — likewise no evaluator. +- `grep` for `createPolicyEvaluator` and `new DefaultPolicyEvaluator` across + `packages/**` and `apps/**` (excluding `/dist/` and `*.test.*`) returns + **zero** production call sites. + +### The sync path uses the *unauthorized* provider + +- `packages/runtime/src/sync/sync-manager.ts:387` — production sync uses + `new NodeStoreSyncProvider(...)`. +- `AuthorizedYjsSyncProvider` and `YjsAuthGate` + (`packages/sync/src/yjs-authorized-sync.ts`, `.../yjs-authorization.ts`) are + **exported from `packages/sync/src/index.ts` but never used** outside their + own tests. + +### E2E recipients also short-circuit, and ignore `visibility` + +`packages/data/src/auth/recipients.ts` — `computeRecipients()`: + +```ts +recipients.add(node.createdBy) +if (!schema.authorization) { + return [...recipients] // owner-only for the 52 auth-less schemas +} +``` + +It also **never reads `node.properties.visibility`** — "public" is determined +solely by `hasPublicAccess(readExpr)` on the schema's `read` action. None of the +content cascade schemas put `PUBLIC` in their `read` action, so +`visibility: 'public'` on a node is invisible to the schema model. (And +`nodeContentCipher` is never set in any client, so this path is dead in +production anyway.) + +### What *does* enforce: the hub + +`packages/hub/src/...` (this is the real, working access control): + +- `server.ts` — `authorizeRoomAction()` (≈ 318–425): token expiry → revocation → + UCAN capability → **doc-level grant** (`listGrantedDocIds`) → **space + membership** (`shareAccess.canAccessNode`). Query auth (≈ 1196–1243) checks + `query/read` / `index/write` capabilities. `visibility` is read at + `server.ts:263`. +- `services/share-access.ts` — share-link roles → action allowlists + (`read` / `comment` / `write`), grant status with revocation + expiry. +- `routes/public.ts` — `resolveEffectiveVisibility()`; only `visibility === + 'public'` bypasses the grant model. +- The hub **does not deserialize `schema.authorization`** anywhere — it runs an + independent grant + visibility model. + +### The one live consumer of `schema.authorization` on the client + +- `packages/data/src/auth/permission-matrix.ts` — `buildPermissionMatrix()`. +- `apps/web/src/components/PermissionMatrixPanel.tsx` — the **Permissions tab** + in `apps/web/src/components/ShareDialog.tsx`. This reflects the schema's + declared roles/actions as "who can do what." For the ~52 auth-less schemas it + has nothing to show (or shows owner-only), so the panel silently + under-reports for most content. + +### Coverage table + +| Declares `authorization` (12 files) | Auth-less — "legacy/owner-only" (representative) | +|---|---| +| `space.ts` (`spaceOwnAuthorization`) | `page.ts`, `database.ts`, `database-field.ts`, `database-row.ts`, `database-view.ts`, `database-select-option.ts` | +| `task.ts`, `project.ts`, `milestone.ts` | `canvas.ts`, `folder.ts`, `channel.ts`, `dashboard.ts`, `map.ts` | +| `crm.ts` (10 entity types) | `comment.ts`, `commentAnchors.ts`, `commentOrphans.ts`, `commentReferences.ts`, `mentions.ts`, `reaction.ts` | +| `account.ts`, `transaction.ts`, `posting.ts`, `budget.ts` | `chat-message.ts`, `media-asset.ts`, `external-reference.ts`, `tag.ts` | +| `schema-extension.ts`, `import-batch.ts` | `profile.ts`, `user-widget.ts`, `inbox-state.ts`, `saved-view.ts`, `task-view.ts` | +| `moderation.ts` (custom roles) | `experiment.ts`, `metric.ts`, `observation.ts`, `grant.ts`, `system.ts`, `space-membership.ts` | + +> `warnLegacySchema()` exists in `mode.ts` to warn devs about missing +> authorization — but it is **never called** in production (test-only). New +> schemas ship auth-less with zero friction. + +## External Research + +xNet's design is a credible hybrid of three well-known authorization families. +Naming them clarifies which guarantees xNet already targets and which it leaves +on the table. + +- **Policy-as-data / policy-as-code (OPA, AWS Cedar).** Rules are evaluated + against inputs. xNet's `actions: { read: allow('owner', 'spaceMember', …) }` + is exactly this: a serialized policy attached to each schema. Cedar/OPA + guidance is that policy engines are easy for point checks but need extra work + for *list filtering at scale* — which is precisely the `filterReadableNodes` + post-filter + auth-pushdown work in `store.ts`. + ([Oso: OPA vs Cedar vs Zanzibar](https://www.osohq.com/learn/opa-vs-cedar-vs-zanzibar)) +- **Relationship-based access control / Zanzibar (SpiceDB, OpenFGA).** Access + derives from a graph of relationships and inherits along it. xNet's + `role.relation('space', …)` cascade and `role.members({ parentProp })` + ancestor walk are a hand-rolled ReBAC: "you can read this Task because you are + a member of its Space (or an ancestor Space)." + ([AuthZed: PBAC vs ReBAC](https://authzed.com/blog/policy-based-access-control), + [WorkOS: ReBAC vs PBAC](https://workos.com/blog/relationship-based-vs-policy-based-authorization)) +- **Capabilities / UCAN.** Instead of a central ACL, holders present + cryptographically-provable certificates. xNet's hub grants + share-links + + UCAN capability checks (`authorizeRoomAction`) are this model. UCAN is the + canonical local-first/P2P authorization scheme. + ([UCAN spec](https://github.com/ucan-wg/spec/blob/main/README.md), + [localfirst.fm #19 — UCAN/Beehive/Beelay](https://www.localfirst.fm/19/transcript)) +- **Local-first access control (Ink & Switch *Keyhive*).** The frontier work on + this exact problem: a Group-Management CRDT with **coordination-free + revocation**, plus E2EE with causal keys and post-compromise security. xNet's + `SpaceMembership` edges (CRDT nodes) + `computeRecipients` + content-key + rotation on revocation are an early, partial version of the same idea — but + Keyhive's lesson is that *the access-control state and the encryption keys + must be the same CRDT*, which xNet has not yet unified. + ([Ink & Switch: Keyhive](https://www.inkandswitch.com/keyhive/notebook/)) + +**Takeaway for xNet:** the model is on the right track and matches the state of +the art. The deficiency is not the *design* — it is that (a) most schemas don't +declare policy, (b) the declarative model isn't the live enforcer, and (c) the +two enforcement systems (schema vs. hub) are not derived from one source, so +they can disagree. Mature systems (SpiceDB, Cedar) treat "every resource type +has an explicit policy" and "one policy, many enforcement points" as +non-negotiable invariants. xNet has the parts but not the invariants. + +## Key Findings + +1. **Coverage: ~19% of schemas declare authorization.** 12 of 64. The most-used + content types are auth-less. (`packages/data/src/schema/schemas/*`) + +2. **The declarative engine is unwired in every client.** No production + `NodeStore` is constructed with `authEvaluator`/`auth`; + `createPolicyEvaluator` has zero non-test call sites. Client-side reads and + writes are allow-all. (`packages/react/src/context.ts:660`, + `packages/runtime/src/client.ts:248`) + +3. **The authorized sync provider is dead code in prod.** + `AuthorizedYjsSyncProvider`/`YjsAuthGate` are exported but unused; sync uses + `NodeStoreSyncProvider`. (`packages/runtime/src/sync/sync-manager.ts:387`) + +4. **Two divergent sources of truth.** The hub enforces via grants + visibility + and never reads `schema.authorization`. The schema blocks drive only the + (unwired) evaluator, the (unwired) recipients path, and the Permissions UI. + Nothing keeps them in lockstep — exactly the "Phase 4: unify enforcement" + that exploration 0181 explicitly deferred. + +5. **Landmine #1 — legacy mode skips grants.** `can()` returns owner-only for + auth-less schemas *before* the grant-index fallback + (`evaluator.ts` ~482–488). If the evaluator were wired today, every shared + `Page`/`Database`/`Canvas` would become invisible to collaborators despite + valid hub grants. + +6. **Landmine #2 — dead deny branch.** `if (!schema.authorization) deny(...)` + at `evaluator.ts:490` is unreachable (getAuthMode returns `'legacy'` first). + It signals confused intent: was the design owner-only-fallback, or + deny-closed? They contradict. + +7. **Landmine #3 — recipients lock out collaborators.** `computeRecipients` + returns owner-only for auth-less schemas and never consults the grant index + for them. If `nodeContentCipher` were enabled, E2E content would be + undecryptable by anyone but the author for 52 schema types. + +8. **`visibility` is decorative in the schema model.** `computeRecipients` and + the cascade builders never read it; only the hub honors it. A node marked + `visibility: 'public'` is *not* public per the schema model — a real + disagreement between the property users edit and the policy that (would) + run. + +9. **No guard rails.** `warnLegacySchema` is never called; there is no + conformance test asserting "every content schema declares authorization or + is explicitly auth-exempt." Coverage silently erodes. + +10. **The good news.** The hub enforcement is real and reasonably complete + (room/query auth, grant revocation, expiry, space-membership cascade, + public reads). The declarative model is well-tested in isolation + (`auth/space-cascade.test.ts` proves cascade, most-permissive-wins, sibling + isolation, deny precedence). The pieces are sound; the assembly is missing. + +## Options And Tradeoffs + +```mermaid +flowchart LR + Start{"Make schemas
'fully utilize' authz?"} + Start --> A["A. Document hub as the
source of truth;
retire/flag the engine"] + Start --> B["B. Wire the schema engine
as the client enforcer
(full local-first)"] + Start --> C["C. Hybrid: one declarative
source → generate hub behavior +
wire engine as defense-in-depth"] + + A --> A1["+ lowest effort, honest
− abandons local-first/offline authz
− Permissions UI stays misleading"] + B --> B1["+ true offline + P2P enforcement, E2E
− big lift; must fix 3 landmines
− perf risk on every read"] + C --> C1["+ single source, no drift
+ staged, flag-gated
− most design work up front"] + + style C fill:#efe,stroke:#0a0 +``` + +### Option A — Accept the hub as the source of truth; demote the engine + +Treat `schema.authorization` as documentation. Mark the evaluator + recipients + +authorized-sync as "experimental / server-parity (not wired)." Fix the +Permissions UI to reflect *hub* behavior (grants + visibility), not the schema +blocks. + +- **Pros:** least effort; removes the misleading "we have schema authz" + impression; honest about what's enforced. +- **Cons:** abandons the local-first promise (offline/P2P access control, E2E + recipients); leaves a lot of good code as a museum; the schema blocks that + *do* exist (CRM, finance) still don't enforce client-side. + +### Option B — Wire the schema engine as the client enforcer + +Add authorization to all content schemas, construct the client `NodeStore` with +a `createPolicyEvaluator`, swap in `AuthorizedYjsSyncProvider`, and fix the three +landmines. + +- **Pros:** real offline + peer-to-peer enforcement; E2E recipients become + viable; one engine, many call sites. +- **Cons:** large blast radius; every read now pays an auth post-filter (the + perf work in 0182 exists for exactly this, but it's still cost); + client-enforced authz on an untrusted client is advisory anyway — the hub must + *still* enforce, so you now maintain two enforcers unless you also do C. + +### Option C — Hybrid: one declarative source → generated hub behavior + wired engine (recommended) + +Make `schema.authorization` the **single declarative source**. Derive the hub's +grant/visibility expectations from it (so they cannot diverge), and wire the +evaluator into the client as **defense-in-depth + an offline gate**, with the +hub remaining authoritative for untrusted peers. + +- **Pros:** kills the drift (Finding #4) structurally; preserves local-first; + honors the existing investment; can be staged behind a flag with a + conformance test as the ratchet. +- **Cons:** the most design work to define "derive hub behavior from schema"; + must still fix the three landmines first. + +## Recommendation + +**Adopt Option C, staged.** Concretely, in priority order: + +1. **Stop the bleeding (guard rails first).** Add a conformance test over the + schema registry: *every registered schema must either declare an + `authorization` block or appear in an explicit `AUTH_EXEMPT` allowlist with a + one-line justification.* Wire `warnLegacySchema` (or a build-time lint) so + new auth-less schemas are loud, not silent. This freezes coverage where it is + and forces a decision per schema henceforth. *(Low effort, high leverage — + do this regardless of the rest.)* + +2. **Fix the three landmines** so the engine is safe to enable: + - In `can()`, when `mode === 'legacy'`, **fall through to the grant-index + fallback** instead of returning owner-only early (or better: eliminate + legacy mode entirely once coverage is complete). Delete the dead + `!authorization` deny branch. + - In `computeRecipients`, expand grants for legacy schemas too (move the + grant loop before the `!authorization` return, or remove that return once + coverage is complete). + - Make `visibility` authoritative in the model: have the cascade builder emit + a `read` action that includes `PUBLIC` when the node's effective visibility + is `public`, so the schema model and the hub agree. + +3. **Backfill coverage** with a shared default. Almost every auth-less content + schema already has `space` + `visibility`, so the mechanical fix is to add + `spaceCascadeAuthorization()` to each (`page`, `database*`, `canvas`, + `folder`, `channel`, `dashboard`, `map`, `comment*`, `chat-message`, + `reaction`, `tag`, `media-asset`, `experiment`, `metric`, `observation`, …). + Edge/system nodes (`space-membership`, `grant`, `system`, `inbox-state`, + `profile`) get an explicit decision (owner-only preset or auth-exempt). + +4. **Single source → derive hub behavior.** Add (or formalize) a + `schemaToHubPolicy(schema)` that produces the grant-action / visibility + expectations the hub enforces, so `spaceRoleGrantActions` and the hub's + `canAccessNode` are *generated from* the same `authorization` block the + evaluator reads. Add a parity test (declared read-roles ⇔ hub grant actions). + +5. **Wire the engine behind a flag.** Construct the client `NodeStore` with + `createPolicyEvaluator` and swap `AuthorizedYjsSyncProvider` into sync, both + gated by a feature flag (mirrors the worker-runtime and labs ladders). Keep + the hub authoritative; the client engine is defense-in-depth + the offline + answer. Default off until the parity test and perf budgets are green. + +This sequence is safe: steps 1–2 are pure correctness/guard-rail work with no +behavior change; step 3 is additive; steps 4–5 are flag-gated. + +## Example Code + +### 1. Conformance test (guard rail — ship first) + +```ts +// packages/data/src/schema/schemas/authorization-coverage.test.ts +import { describe, it, expect } from 'vitest' +import { allSchemas } from './index' // registry of registered schemas +import { getAuthMode } from '../../auth/mode' + +// Edge/system nodes that intentionally carry no policy of their own. +const AUTH_EXEMPT = new Set([ + 'xnet://xnet.fyi/SpaceMembership@1.0.0', // edge node; secured by its Space + 'xnet://xnet.fyi/Grant@1.0.0', // the grant record itself + 'xnet://xnet.fyi/System@1.0.0', // singleton system config + // …each entry needs a one-line justification in review +]) + +describe('authorization coverage', () => { + it('every registered schema declares authorization or is explicitly exempt', async () => { + const offenders: string[] = [] + for (const schema of await allSchemas()) { + if (AUTH_EXEMPT.has(schema['@id'])) continue + if (getAuthMode(schema) === 'legacy') offenders.push(schema.name) + } + expect(offenders, `schemas missing an authorization block: ${offenders.join(', ')}`) + .toEqual([]) + }) +}) +``` + +### 2. Backfill a content schema (mechanical) + +```ts +// packages/data/src/schema/schemas/page.ts (illustrative diff) +import { spaceCascadeAuthorization } from './space-authorization' + +export const PageSchema = defineSchema({ + name: 'Page', + namespace: 'xnet://xnet.fyi/', + properties: { /* …unchanged… space + visibility already present… */ }, + document: 'page', ++ authorization: spaceCascadeAuthorization() // inherit roles from the linked Space +}) +``` + +### 3. Fix Landmine #1 — legacy must consult grants (transitional) + +```ts +// packages/data/src/auth/evaluator.ts (inside can()) + const mode = getAuthMode(schema.schema) + if (mode === 'legacy') { + if (node.createdBy === input.subject) { + return this.decision(input, true, ['owner'], start) + } +- return this.decision(input, false, [], start) // ❌ ignores valid grants ++ // Fall through to the grant-index fallback so hub grants still apply ++ const grant = this.findMatchingGrant( ++ input, ++ this.grantIndex?.findGrants(input.nodeId, input.subject) ?? [] ++ ) ++ return this.decision(input, Boolean(grant), [], start, grant ? [grant.id] : undefined) + } +- if (!schema.schema.authorization) { // ❌ dead code — delete +- return this.deny(input, ['DENY_NO_ROLE_MATCH'], start) +- } +``` + +### 4. Wire the evaluator into the client (flag-gated) + +```ts +// packages/react/src/context.ts (illustrative) +const ns = new NodeStore({ storage: nodeStorageAdapter, authorDID, signingKey }) + +if (flags.clientSideAuthEnforcement) { + ns.attachAuthEvaluator( + await createPolicyEvaluator({ store: ns, schemaRegistry, grantIndex }) + ) +} +``` + +## Risks And Open Questions + +- **Turning on client enforcement could lock users out of their own shared + data** if landmines #1/#3 aren't fixed first, or if a content schema's `space` + is null when it shouldn't be. Mitigation: fix landmines, ship the conformance + test, default the flag off, and dry-run against real data. +- **Performance.** Every list read gains an auth post-filter. The 0182 auth + pushdown helps, but it's disabled when `nodeContentCipher` is set — so E2E and + cheap auth currently trade off. Need a perf budget before flipping the flag. +- **Client enforcement is advisory.** An untrusted client can be patched to + ignore the evaluator, so the hub must remain authoritative. This is *why* + Option C (one source → both enforcers) matters more than Option B alone. +- **What is the intended default for an unshared node?** Owner-only (current + cascade behavior with empty `space`) seems right, but it should be explicit + and tested, not an emergent property of `role.relation` returning nothing. +- **Edge/system nodes:** which truly need no policy (`SpaceMembership`, + `Grant`, `System`, `Profile`, `InboxState`)? Each `AUTH_EXEMPT` entry needs a + justification — e.g. is a `Profile` world-readable by design? Today it's + owner-only-in-the-model but hub-published in practice. +- **`visibility` semantics:** is it a per-node override of the Space's + visibility, or advisory metadata? The schema model ignores it; the hub honors + it. Pick one and make both agree. +- **Migration:** adding `authorization` to existing schemas changes the + effective schema. Does `useEffectiveSchema` / the schema-version machinery + need a bump, or is `authorization` non-versioned metadata? (0188 made + effective schema read-time-composed — confirm authz rides along.) + +## Implementation Status + +Steps 1–9 (the safe, high-value core: guard rail, correctness fixes, full +coverage backfill, and schema↔hub parity) shipped together — they have **zero +production behavior change** because the evaluator is still unwired, so the +schema layer is now complete and correct and *ready* to be wired. Steps 10–11 +(wiring the evaluator + authorized sync into clients) and the `visibility: +'public'` engine extension remain deliberately deferred: they are a flag-gated +runtime rollout the recommendation explicitly stages behind perf/real-data +validation, and wiring needs a client-side `GrantIndex` lifecycle that does not +exist yet. `PermissionMatrixPanel` needed no change — it reflects +`schema.authorization`, which now exists for every content type, so it +automatically shows the cascade roles post-backfill. + +## Implementation Checklist + +- [x] Add `authorization-coverage.test.ts` over the schema registry with an + explicit, justified `AUTH_EXEMPT` allowlist (guard rail). +- [x] Call `warnLegacySchema` from `defineSchema` (gated to the dev server so it + doesn't spam test suites), so new auth-less schemas are loud. +- [x] Fix Landmine #1: legacy branch in `can()` falls through to the + grant-index fallback. +- [x] Delete the unreachable `!authorization` deny branch in `evaluator.ts`. +- [x] Fix Landmine #3: `computeRecipients` folds grant recipients into + auth-less schemas on every path. +- [ ] *(deferred — flag-gated)* Make `visibility: 'public'` emit `PUBLIC` in the + read path so the schema model and hub agree. Needs a per-node conditional + (effective-visibility) resolver; the hub already enforces public today. +- [x] Backfill authorization on all 24 content schemas: 9 with a `space` + relation via `spaceCascadeAuthorization()`; 9 child types inherit from + their parent — `database-row/field/select-option/view` + `saved-view` + (`database`), `chat-message` (`channel`), `comment`/`reaction` (`target`), + `task-view` (`project`); 6 standalone/personal — `folder`, `tag`, + `external-reference`, `media-asset`, `inbox-state`, `user-widget` — + owner-only via `presets.private()`. (`mentions`/comment-anchors are not + registered schemas, so no-op.) +- [x] Decide + apply policy for edge/system schemas via `AUTH_EXEMPT`: + `SpaceMembership`, `Grant`, `Profile`, `SchemaDefinition`, + `SchemaCompatibility`, `SyncPolicy`, `PresenceSummary`. (`InboxState` + became owner-only `presets.private()` rather than exempt.) +- [x] Add `schemaToHubPolicy()`/`hubActionsForSpaceRole()` deriving hub grant + actions from `schema.authorization`, plus a schema↔hub parity test. +- [ ] *(deferred — flag-gated rollout)* Wire `createPolicyEvaluator` into the + client `NodeStore` (`packages/react/src/context.ts`, runtime, electron, + expo) behind a flag; needs a client-side `GrantIndex` lifecycle. +- [ ] *(deferred — flag-gated rollout)* Swap `AuthorizedYjsSyncProvider`/ + `YjsAuthGate` into the sync path behind the same flag. +- [x] `PermissionMatrixPanel` — no change needed; it now reflects the + backfilled policy automatically via `schema.authorization`. + +## Validation Checklist + +- [x] `authorization-coverage.test.ts` is green (no un-exempted legacy schema). +- [x] Cascade proven by `auth/space-cascade.test.ts` (read/write/delete, most- + permissive, sibling isolation, deny precedence). *(Deferred: add real + `Page`/`Database`/`Canvas` fixtures alongside the generic ones.)* +- [x] Regression test: a non-owner with a **hub grant** on a legacy schema is + allowed through the evaluator; without a grant, denied (Landmine #1). +- [x] Test: `computeRecipients` includes grantees for a legacy schema + (Landmine #3). +- [ ] *(deferred — with the visibility feature)* `visibility: 'public'` yields + `PUBLIC` from both `computeRecipients` and the hub. +- [x] schema↔hub parity test: cascade hub actions ⇔ `spaceRoleGrantActions` for + every Space role. +- [ ] *(deferred — flag-gated)* Flag-on E2E: two identities in a shared Space + both see a Page; a non-member does not; revocation rotates keys. +- [ ] *(deferred — flag-gated)* Perf: list-read latency with the evaluator + wired stays within budget at 0184 scale. +- [x] With nothing wired, behavior is unchanged: the full `@xnetjs/data` auth + + schema suite (492 tests) and typecheck/build are green; the hub remains + the sole enforcer in production. + +## References + +### Internal + +- `packages/data/src/schema/types.ts` — `Schema.authorization`. +- `packages/data/src/auth/builders.ts`, `presets.ts` — the DSL + presets. +- `packages/data/src/auth/mode.ts` — `getAuthMode`, `warnLegacySchema` (uncalled). +- `packages/data/src/auth/evaluator.ts` — `DefaultPolicyEvaluator.can()`, + `resolveRoles`, `createPolicyEvaluator` (lines ~443–543, ~142–295, ~865). +- `packages/data/src/auth/recipients.ts` — `computeRecipients`. +- `packages/data/src/auth/permission-matrix.ts` — `buildPermissionMatrix`. +- `packages/data/src/schema/schemas/space-authorization.ts` — cascade builders. +- `packages/data/src/schema/schemas/space.ts`, `space-membership.ts` — roles + edges. +- `packages/data/src/store/store.ts` — `canReadNode`, `filterReadableNodes`, + `assertAuthorized`, auth-pushdown (lines ~734–765, ~2420–2509). +- `packages/react/src/context.ts:660`, `packages/runtime/src/client.ts:248` — + client store construction (no evaluator). +- `packages/runtime/src/sync/sync-manager.ts:387` — plain `NodeStoreSyncProvider`. +- `packages/sync/src/yjs-authorized-sync.ts`, `yjs-authorization.ts` — unused + authorized provider. +- `packages/hub/src/server.ts` (`authorizeRoomAction`, query auth, visibility), + `services/share-access.ts`, `routes/public.ts` — the real enforcement. +- `apps/web/src/components/ShareDialog.tsx`, `PermissionMatrixPanel.tsx` — UI. +- `docs/explorations/0181_[_]_SPACES_AS_NESTED_GROUPINGS_AND_SCHEMA_AUTHORIZATION.md` + (deferred "Phase 4: unify enforcement"), + `0188_[_]_EXTENSIBLE_SCHEMAS_AND_UNIVERSAL_DATABASE_VIEW.md` + (`buildPermissionMatrix`), + `0179_[_]_SPACES_GROUPS_AND_UNIFIED_SHARING.md`, + `0182_[_]_USEQUERY_USEMUTATE_PERFORMANCE_FRONTIER.md` (auth pushdown). + +### External + +- [Ink & Switch — Keyhive: Local-first access control](https://www.inkandswitch.com/keyhive/notebook/) +- [UCAN specification](https://github.com/ucan-wg/spec/blob/main/README.md) +- [localfirst.fm #19 — Brooklyn Zelenka: UCAN, Beehive, Beelay](https://www.localfirst.fm/19/transcript) +- [AuthZed — Policy-Based vs Relationship-Based Access Control](https://authzed.com/blog/policy-based-access-control) +- [WorkOS — ReBAC vs PBAC](https://workos.com/blog/relationship-based-vs-policy-based-authorization) +- [Oso — OPA vs Cedar vs Zanzibar (2025 guide)](https://www.osohq.com/learn/opa-vs-cedar-vs-zanzibar) +- [Permit.io — Zanzibar vs OPA](https://www.permit.io/blog/zanzibar-vs-opa) diff --git a/packages/data/src/auth/evaluator.test.ts b/packages/data/src/auth/evaluator.test.ts index 88f34bd2a..6e9d6c82e 100644 --- a/packages/data/src/auth/evaluator.test.ts +++ b/packages/data/src/auth/evaluator.test.ts @@ -160,6 +160,55 @@ describe('GrantIndex', () => { }) describe('DefaultPolicyEvaluator', () => { + it('honors hub grants for non-owners on legacy schemas (0192 Landmine #1)', async () => { + const alice = createIdentity() + const bob = createIdentity() + const carol = createIdentity() + const store = await createStore(alice) + + const LegacyDoc = defineSchema({ + name: 'AuthLegacyDoc', + namespace: 'xnet://tests/', + properties: { title: text({ required: true }) } + }) + const schemaRegistry = new SchemaRegistry() + schemaRegistry.register(LegacyDoc) + + const node = await store.create({ + schemaId: LegacyDoc.schema['@id'], + properties: { title: 'shared' } + }) + await store.create({ + schemaId: GRANT_SCHEMA_ID, + properties: { + grantee: bob.did, + resource: node.id, + actions: JSON.stringify(['read']), + revokedAt: 0, + expiresAt: Date.now() + 10_000 + } + }) + + const grantIndex = new GrantIndex(store) + await grantIndex.initialize() + const evaluator = new DefaultPolicyEvaluator({ store, schemaRegistry, grantIndex }) + + // Owner is always allowed. + expect( + (await evaluator.can({ subject: alice.did, action: 'read', nodeId: node.id })).allowed + ).toBe(true) + // Non-owner WITH a grant is allowed — the fix. Previously a flat deny here + // hid grant-shared legacy-schema nodes from their collaborators. + expect( + (await evaluator.can({ subject: bob.did, action: 'read', nodeId: node.id })).allowed + ).toBe(true) + // Non-owner WITHOUT a grant stays denied. + expect( + (await evaluator.can({ subject: carol.did, action: 'read', nodeId: node.id })).allowed + ).toBe(false) + grantIndex.dispose() + }) + it('allows relation-derived roles and caches decisions', async () => { const alice = createIdentity() const bob = createIdentity() diff --git a/packages/data/src/auth/evaluator.ts b/packages/data/src/auth/evaluator.ts index 45db01626..bd0a2d127 100644 --- a/packages/data/src/auth/evaluator.ts +++ b/packages/data/src/auth/evaluator.ts @@ -478,17 +478,33 @@ export class DefaultPolicyEvaluator implements PolicyEvaluator { return decision } + // `getAuthMode` returns 'legacy' exactly when the schema has no + // authorization block; the `!authorization` disjunct is redundant at + // runtime but lets the compiler narrow `authorization` to defined below. const mode = getAuthMode(schema.schema) - if (mode === 'legacy') { - const allowed = node.createdBy === input.subject - const decision = this.decision(input, allowed, allowed ? ['owner'] : [], start) - this.cache.set(input.subject, input.action, input.nodeId, decision) - this.emitDecision(decision) - return decision - } - - if (!schema.schema.authorization) { - const decision = this.deny(input, ['DENY_NO_ROLE_MATCH'], start) + if (mode === 'legacy' || !schema.schema.authorization) { + // Owner is always allowed. + if (node.createdBy === input.subject) { + const decision = this.decision(input, true, ['owner'], start) + this.cache.set(input.subject, input.action, input.nodeId, decision) + this.emitDecision(decision) + return decision + } + // Non-owner: fall through to the grant index so hub/share grants still + // apply. Returning a flat deny here (the old behavior) silently ignored + // valid grants, making any shared legacy-schema node invisible to its + // collaborators (exploration 0192, Landmine #1). + const grant = this.findMatchingGrant( + input, + this.grantIndex?.findGrants(input.nodeId, input.subject) ?? [] + ) + if (grant) { + const decision = this.decision(input, true, [], start, [grant.id]) + this.cache.set(input.subject, input.action, input.nodeId, decision) + this.emitDecision(decision) + return decision + } + const decision = this.deny(input, ['DENY_NO_ROLE_MATCH', 'DENY_NO_GRANT'], start) this.emitDecision(decision) return decision } diff --git a/packages/data/src/auth/hub-policy.test.ts b/packages/data/src/auth/hub-policy.test.ts new file mode 100644 index 000000000..374c4bda5 --- /dev/null +++ b/packages/data/src/auth/hub-policy.test.ts @@ -0,0 +1,65 @@ +/** + * Schema ↔ hub grant parity (exploration 0192). + * + * The Space cascade (`spaceCascadeAuthorization`, declared on content schemas) + * and the hub's `spaceRoleGrantActions` mapping must describe the same access + * ladder. This test derives the hub actions implied by the schema and asserts + * they match `spaceRoleGrantActions` for every Space role — so changing one + * without the other turns CI red. + */ +import { describe, expect, it } from 'vitest' +import { defineSchema } from '../schema/define' +import { TaskSchema, SPACE_ROLES, spaceRoleGrantActions } from '../schema/schemas' +import { schemaToHubPolicy, hubActionsForSpaceRole } from './hub-policy' +import { allow, role } from '.' + +describe('schemaToHubPolicy', () => { + it('projects schema actions onto the hub grant vocabulary (delete → admin)', () => { + const policy = schemaToHubPolicy(TaskSchema.schema) + // The cascade grants admins read+write+delete+share → read/write/admin/share. + expect(policy.roleActions.spaceAdmin).toEqual(['admin', 'read', 'share', 'write']) + // Viewers only read. + expect(policy.roleActions.spaceViewer).toEqual(['read']) + expect(policy.public).toBe(false) + }) + + it('reports public read when the schema grants PUBLIC', () => { + const PublicDoc = defineSchema({ + name: 'PublicDoc', + namespace: 'xnet://test/', + properties: {}, + authorization: { + roles: { owner: role.creator() }, + actions: { + read: { _tag: 'public' }, + write: allow('owner'), + delete: allow('owner'), + share: allow('owner') + } + } + }) + expect(schemaToHubPolicy(PublicDoc.schema).public).toBe(true) + }) + + it('returns an empty policy for legacy (authorization-less) schemas', () => { + const Legacy = defineSchema({ + name: 'Legacy', + namespace: 'xnet://test/', + properties: {} + }) + expect(schemaToHubPolicy(Legacy.schema)).toEqual({ roleActions: {}, public: false }) + }) +}) + +describe('schema ↔ hub grant parity', () => { + it('cascade hub actions match spaceRoleGrantActions for every Space role', () => { + for (const spaceRole of SPACE_ROLES) { + const fromSchema = new Set(hubActionsForSpaceRole(TaskSchema.schema, spaceRole)) + // `comment` is a hub-only refinement of `read` not modeled by the cascade. + const expected = new Set( + spaceRoleGrantActions(spaceRole).filter((action) => action !== 'comment') + ) + expect(fromSchema, `space role: ${spaceRole}`).toEqual(expected) + } + }) +}) diff --git a/packages/data/src/auth/hub-policy.ts b/packages/data/src/auth/hub-policy.ts new file mode 100644 index 000000000..c632c32fc --- /dev/null +++ b/packages/data/src/auth/hub-policy.ts @@ -0,0 +1,88 @@ +/** + * Derive the hub's grant-action expectations from a schema's authorization + * block (exploration 0192). + * + * The hub enforces access with a grant model whose vocabulary is + * `read | comment | write | share | admin` (see `spaceRoleGrantActions`). The + * schema authorization DSL speaks `read | write | delete | share`. These two + * have historically been maintained by hand in separate places and were free + * to drift. `schemaToHubPolicy` projects a schema's declared per-action role + * sets onto the hub vocabulary so the relationship can be asserted in CI + * (`hub-policy.test.ts`) — the schema authorization block becomes the single + * declarative source the hub mapping is checked against. + * + * Correspondence: + * read → read write → write share → share delete → admin + * + * `comment` is a hub-only refinement of `read` (a commenter may annotate but + * not edit). The Space cascade folds commenters into `read` and does not model + * a distinct `comment` action, so it is intentionally absent here. + */ +import type { Schema } from '../schema/types' +import { deserializeAuthorization } from './serialize' +import { extractRoleRefs, hasPublicAccess } from './validate' + +/** Maps a schema authorization action onto the hub grant-action vocabulary. */ +const SCHEMA_ACTION_TO_HUB: Readonly> = { + read: 'read', + write: 'write', + share: 'share', + delete: 'admin' +} + +export interface HubPolicy { + /** For each role declared by the schema, the hub grant actions it implies. */ + roleActions: Record + /** True when the schema's `read` action grants PUBLIC. */ + public: boolean +} + +/** Project a schema's authorization block onto the hub grant-action model. */ +export function schemaToHubPolicy(schema: Schema): HubPolicy { + if (!schema.authorization) { + return { roleActions: {}, public: false } + } + + const auth = deserializeAuthorization(schema.authorization) + const roleActions: Record> = {} + let isPublic = false + + for (const [action, expr] of Object.entries(auth.actions)) { + if (!expr) continue + if (action === 'read' && hasPublicAccess(expr)) { + isPublic = true + } + const hubAction = SCHEMA_ACTION_TO_HUB[action] + if (!hubAction) continue + for (const roleName of extractRoleRefs(expr)) { + const actions = (roleActions[roleName] ??= new Set()) + actions.add(hubAction) + } + } + + return { + roleActions: Object.fromEntries( + Object.entries(roleActions).map(([role, actions]) => [role, [...actions].sort()]) + ), + public: isPublic + } +} + +/** Capitalize the first letter — `viewer` → `Viewer`. */ +function capitalize(value: string): string { + return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}` +} + +/** + * Hub grant actions a Space *member* with the given Space role inherits on a + * node governed by the Space cascade, derived purely from the schema. A member + * with role `R` resolves to the cascade role `spaceR` (e.g. `viewer` → + * `spaceViewer`), so this looks up that role's projected hub actions. + * + * Used by the parity test to prove the cascade and `spaceRoleGrantActions` + * agree, so neither can change without the other. + */ +export function hubActionsForSpaceRole(schema: Schema, spaceRole: string): string[] { + const cascadeRole = `space${capitalize(spaceRole)}` + return schemaToHubPolicy(schema).roleActions[cascadeRole] ?? [] +} diff --git a/packages/data/src/auth/index.ts b/packages/data/src/auth/index.ts index ec1527ec1..4438e11d7 100644 --- a/packages/data/src/auth/index.ts +++ b/packages/data/src/auth/index.ts @@ -74,6 +74,9 @@ export { export type { AuthMode } from './mode' export { getAuthMode, warnLegacySchema } from './mode' +// Schema → hub grant-action projection + parity (exploration 0192) +export { schemaToHubPolicy, hubActionsForSpaceRole, type HubPolicy } from './hub-policy' + // Recipient computation + migration helpers export { PUBLIC_CONTENT_KEY, diff --git a/packages/data/src/auth/recipients.test.ts b/packages/data/src/auth/recipients.test.ts index 0da5a327f..b954b7123 100644 --- a/packages/data/src/auth/recipients.test.ts +++ b/packages/data/src/auth/recipients.test.ts @@ -50,6 +50,22 @@ describe('computeRecipients', () => { expect(recipients).toEqual([OWNER_DID]) }) + it('includes grant recipients even for legacy schemas (0192 Landmine #3)', async () => { + const recipients = await computeRecipients(legacySchema(), createNodeState(), { + getNode: async () => null, + grantIndex: { + findGrantsForResource: () => [ + { + id: 'grant-1', + properties: { actions: JSON.stringify(['read']), grantee: GRANTEE_DID } + } + ] + } + }) + + expect(new Set(recipients)).toEqual(new Set([OWNER_DID, GRANTEE_DID])) + }) + it('returns PUBLIC sentinel when read expression is public', async () => { const TaskSchema = defineSchema({ name: 'Task', diff --git a/packages/data/src/auth/recipients.ts b/packages/data/src/auth/recipients.ts index 99a682e01..95ef5b5f4 100644 --- a/packages/data/src/auth/recipients.ts +++ b/packages/data/src/auth/recipients.ts @@ -38,7 +38,25 @@ export async function computeRecipients( const recipients = new Set() recipients.add(node.createdBy) + // Grant-index recipients apply regardless of whether the schema declares + // roles: a node shared purely via a hub/share grant must still reach its + // grantees, including legacy (authorization-less) schemas. Folding this in + // for every path fixes the owner-only lockout (exploration 0192, Landmine #3). + const addGrantRecipients = () => { + const grants = dependencies.grantIndex?.findGrantsForResource(node.id) ?? [] + for (const grant of grants) { + const actions = parseGrantActions(grant.properties.actions) + if (actions.includes('read') || actions.includes('write')) { + const grantee = grant.properties.grantee + if (typeof grantee === 'string' && grantee.startsWith('did:key:')) { + recipients.add(grantee as DID) + } + } + } + } + if (!schema.authorization) { + addGrantRecipients() return [...recipients] } @@ -46,6 +64,7 @@ export async function computeRecipients( const readExpr = auth.actions.read if (!readExpr) { + addGrantRecipients() return [...recipients] } @@ -64,16 +83,7 @@ export async function computeRecipients( } } - const grants = dependencies.grantIndex?.findGrantsForResource(node.id) ?? [] - for (const grant of grants) { - const actions = parseGrantActions(grant.properties.actions) - if (actions.includes('read') || actions.includes('write')) { - const grantee = grant.properties.grantee - if (typeof grantee === 'string' && grantee.startsWith('did:key:')) { - recipients.add(grantee as DID) - } - } - } + addGrantRecipients() return [...recipients] } diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index d50b62eee..a85ce4c08 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -734,7 +734,10 @@ export { describeRoleResolver, type PermissionMatrix, type ActionPermission, - type RoleSummary + type RoleSummary, + schemaToHubPolicy, + hubActionsForSpaceRole, + type HubPolicy } from './auth' // Blob service diff --git a/packages/data/src/schema/define.ts b/packages/data/src/schema/define.ts index 49c7b6e7a..c6a222f12 100644 --- a/packages/data/src/schema/define.ts +++ b/packages/data/src/schema/define.ts @@ -16,8 +16,9 @@ import type { InferNode } from './types' import type { AuthorizationDefinition } from '@xnetjs/core' -import { validateAuthorization, serializeAuthorization } from '../auth' +import { validateAuthorization, serializeAuthorization, warnLegacySchema } from '../auth' import { createNodeId } from './node' +import { isAuthExemptSchema } from './schemas/auth-exempt' /** * Default schema version when not specified. @@ -152,6 +153,16 @@ export function defineSchema

>( authorization: options.authorization ? serializeAuthorization(options.authorization) : undefined } + // ─── Dev-time guard rail: every schema should declare an authorization block + // (or be on the intentional auth-exempt allowlist). Without one the engine + // treats nodes as owner-only "legacy" mode (exploration 0192). The coverage + // test enforces this in CI; this warning surfaces it while authoring in the + // dev server. Gated to `development` (not merely non-production) so the many + // ad-hoc minimal schemas defined inside test suites don't spam warnings. + if (process.env.NODE_ENV === 'development' && !isAuthExemptSchema(schemaId)) { + warnLegacySchema(schema) + } + // Validation function function validate(node: unknown): ValidationResult { const errors: ValidationError[] = [] diff --git a/packages/data/src/schema/schemas/auth-exempt.ts b/packages/data/src/schema/schemas/auth-exempt.ts new file mode 100644 index 000000000..7c04b96f7 --- /dev/null +++ b/packages/data/src/schema/schemas/auth-exempt.ts @@ -0,0 +1,49 @@ +/** + * Schemas that intentionally carry no `authorization` block (exploration 0192). + * + * Every other registered schema must declare authorization (enforced by + * `authorization-coverage.test.ts`). The schemas listed here are edge/system/ + * identity nodes whose access semantics deliberately differ from the Space + * cascade and are governed elsewhere (the hub grant model, signed-content + * federation, or per-user privacy): + * + * - `SpaceMembership` — an edge node; who may read/write a membership is the + * membership *admin* question, secured by the hub against the parent Space, + * not a cascade of the membership row itself. + * - `Grant` — the capability record that the grant model is *made of*; gating + * it with the grant model would be circular. Hub-managed. + * - `Profile` — public identity. Profiles must be readable by collaborators to + * render names/handles on shared content, so they are hub-published rather + * than Space-gated. + * - `SchemaDefinition` / `SchemaCompatibility` / `SyncPolicy` — system / + * federation nodes; signed and content-addressed, governed by the schema + * authority resolution path, not per-node roles. + * - `PresenceSummary` — ephemeral presence aggregate; visibility is handled by + * the presence pipeline. + * + * This is the single source of truth: `defineSchema` reads it to suppress the + * dev-time legacy warning, and the coverage test reads it to allow these IRIs. + * Both the versioned (canonical) and unversioned (legacy alias) IRIs are listed + * so the check matches however a schema id is resolved. + */ +export const AUTH_EXEMPT_SCHEMA_IRIS: ReadonlySet = new Set([ + 'xnet://xnet.fyi/SpaceMembership@1.0.0', + 'xnet://xnet.fyi/SpaceMembership', + 'xnet://xnet.fyi/Grant@1.0.0', + 'xnet://xnet.fyi/Grant', + 'xnet://xnet.fyi/Profile@1.0.0', + 'xnet://xnet.fyi/Profile', + 'xnet://xnet.fyi/SchemaDefinition@1.0.0', + 'xnet://xnet.fyi/SchemaDefinition', + 'xnet://xnet.fyi/SchemaCompatibility@1.0.0', + 'xnet://xnet.fyi/SchemaCompatibility', + 'xnet://xnet.fyi/SyncPolicy@1.0.0', + 'xnet://xnet.fyi/SyncPolicy', + 'xnet://xnet.fyi/PresenceSummary@1.0.0', + 'xnet://xnet.fyi/PresenceSummary' +]) + +/** Whether a schema id is on the intentional authorization-exempt allowlist. */ +export function isAuthExemptSchema(schemaId: string): boolean { + return AUTH_EXEMPT_SCHEMA_IRIS.has(schemaId) +} diff --git a/packages/data/src/schema/schemas/authorization-coverage.test.ts b/packages/data/src/schema/schemas/authorization-coverage.test.ts new file mode 100644 index 000000000..d50c0c860 --- /dev/null +++ b/packages/data/src/schema/schemas/authorization-coverage.test.ts @@ -0,0 +1,65 @@ +/** + * Authorization coverage guard rail (exploration 0192). + * + * Every built-in schema must either declare an `authorization` block or appear + * on the intentional `AUTH_EXEMPT_SCHEMA_IRIS` allowlist. A schema with no + * authorization is "legacy" (owner-only) in the policy engine's eyes — so a new + * content type shipping without a policy silently becomes un-shareable the day + * the evaluator is wired in. This test freezes coverage and forces a per-schema + * decision for every future schema. + */ +import { describe, expect, it } from 'vitest' +import { getAuthMode } from '../../auth' +import { AUTH_EXEMPT_SCHEMA_IRIS, isAuthExemptSchema } from './auth-exempt' +import { builtInSchemas } from './index' + +describe('authorization coverage', () => { + it('every registered schema declares authorization or is explicitly exempt', async () => { + const offenders: string[] = [] + const seen = new Set() + + for (const load of Object.values(builtInSchemas)) { + const defined = await load() + const schema = defined.schema + const id = schema['@id'] + if (seen.has(id)) continue + seen.add(id) + + if (getAuthMode(schema) === 'legacy' && !isAuthExemptSchema(id)) { + offenders.push(`${schema.name} (${id})`) + } + } + + expect( + offenders, + `Schemas missing an authorization block (add spaceCascadeAuthorization() ` + + `or add the IRI to AUTH_EXEMPT_SCHEMA_IRIS with a justification): ` + + offenders.join(', ') + ).toEqual([]) + }) + + it('every auth-exempt schema is actually registered and actually legacy', async () => { + // Guards against the allowlist rotting: an exempt entry that no longer + // exists, or one that has since gained an authorization block (and should + // therefore be removed from the exemption). + const byId = new Map< + string, + Awaited>['schema'] + >() + for (const load of Object.values(builtInSchemas)) { + const defined = await load() + byId.set(defined.schema['@id'], defined.schema) + } + + const stale: string[] = [] + for (const iri of AUTH_EXEMPT_SCHEMA_IRIS) { + const schema = byId.get(iri) + if (!schema) continue // unversioned aliases resolve to the same @id; skip + if (getAuthMode(schema) !== 'legacy') { + stale.push(`${schema.name} (${iri}) now declares authorization — remove the exemption`) + } + } + + expect(stale, stale.join(', ')).toEqual([]) + }) +}) diff --git a/packages/data/src/schema/schemas/canvas.ts b/packages/data/src/schema/schemas/canvas.ts index 706c8008d..e3ed14a7d 100644 --- a/packages/data/src/schema/schemas/canvas.ts +++ b/packages/data/src/schema/schemas/canvas.ts @@ -10,6 +10,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, relation, select } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const CanvasSchema = defineSchema({ name: 'Canvas', @@ -44,7 +45,9 @@ export const CanvasSchema = defineSchema({ default: 'inherit' }) }, - document: 'yjs' // Collaborative Y.Doc for canvas data (nodes, edges) + document: 'yjs', // Collaborative Y.Doc for canvas data (nodes, edges) + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) /** diff --git a/packages/data/src/schema/schemas/channel.ts b/packages/data/src/schema/schemas/channel.ts index 9a2d013dc..cddc4bd10 100644 --- a/packages/data/src/schema/schemas/channel.ts +++ b/packages/data/src/schema/schemas/channel.ts @@ -16,6 +16,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { checkbox, created, createdBy, person, relation, select, text } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const CHANNEL_KINDS = ['channel', 'dm', 'voice'] as const export type ChannelKind = (typeof CHANNEL_KINDS)[number] @@ -76,7 +77,9 @@ export const ChannelSchema = defineSchema({ createdAt: created(), createdBy: createdBy() }, - document: undefined + document: undefined, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) export type Channel = InferNode<(typeof ChannelSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/chat-message.ts b/packages/data/src/schema/schemas/chat-message.ts index b3bebb6ed..13f491c07 100644 --- a/packages/data/src/schema/schemas/chat-message.ts +++ b/packages/data/src/schema/schemas/chat-message.ts @@ -16,6 +16,7 @@ import type { InferNode } from '../types' import type { MessageMentions } from './mentions' import { defineSchema } from '../define' import { checkbox, created, createdBy, date, file, json, relation, text } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const ChatMessageSchema = defineSchema({ name: 'ChatMessage', @@ -54,7 +55,9 @@ export const ChatMessageSchema = defineSchema({ createdAt: created(), createdBy: createdBy() }, - document: undefined + document: undefined, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('channel') }) export type ChatMessage = InferNode<(typeof ChatMessageSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/comment.ts b/packages/data/src/schema/schemas/comment.ts index d536dfa3d..cf45eea8d 100644 --- a/packages/data/src/schema/schemas/comment.ts +++ b/packages/data/src/schema/schemas/comment.ts @@ -22,6 +22,7 @@ import { createdBy, json } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const CommentSchema = defineSchema({ name: 'Comment', @@ -112,7 +113,9 @@ export const CommentSchema = defineSchema({ }, // Comments are plain text + markdown, no collaborative Y.Doc needed - document: undefined + document: undefined, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('target') }) /** diff --git a/packages/data/src/schema/schemas/dashboard.ts b/packages/data/src/schema/schemas/dashboard.ts index 098775270..ff72d0217 100644 --- a/packages/data/src/schema/schemas/dashboard.ts +++ b/packages/data/src/schema/schemas/dashboard.ts @@ -17,6 +17,7 @@ import type { SavedViewDescriptor } from '../../store/query-ast' import type { InferNode } from '../types' import { defineSchema } from '../define' import { json, relation, select, text } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' /** Refresh policy for a widget's query subscription. */ export type DashboardWidgetRefresh = 'live' | 'on-open' | { intervalMs: number } @@ -120,7 +121,9 @@ export const DashboardSchema = defineSchema({ ] as const, default: 'inherit' }) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) /** diff --git a/packages/data/src/schema/schemas/database-field.ts b/packages/data/src/schema/schemas/database-field.ts index e35a7da73..db35abb69 100644 --- a/packages/data/src/schema/schemas/database-field.ts +++ b/packages/data/src/schema/schemas/database-field.ts @@ -19,6 +19,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, relation, number, checkbox, json } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const DatabaseFieldSchema = defineSchema({ name: 'DatabaseField', @@ -55,7 +56,9 @@ export const DatabaseFieldSchema = defineSchema({ /** Hidden by default (views can override) */ hidden: checkbox({}) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('database') }) /** diff --git a/packages/data/src/schema/schemas/database-row.ts b/packages/data/src/schema/schemas/database-row.ts index 5739f1e58..4e057ced6 100644 --- a/packages/data/src/schema/schemas/database-row.ts +++ b/packages/data/src/schema/schemas/database-row.ts @@ -15,6 +15,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, relation } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const DatabaseRowSchema = defineSchema({ name: 'DatabaseRow', @@ -46,7 +47,9 @@ export const DatabaseRowSchema = defineSchema({ * Only created when the row has rich text columns. * Each rich text column gets its own Y.XmlFragment in the doc. */ - document: 'yjs' + document: 'yjs', + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('database') }) /** diff --git a/packages/data/src/schema/schemas/database-select-option.ts b/packages/data/src/schema/schemas/database-select-option.ts index 8e0dd7e32..a614b5f15 100644 --- a/packages/data/src/schema/schemas/database-select-option.ts +++ b/packages/data/src/schema/schemas/database-select-option.ts @@ -13,6 +13,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, relation } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const DatabaseSelectOptionSchema = defineSchema({ name: 'DatabaseSelectOption', @@ -41,7 +42,9 @@ export const DatabaseSelectOptionSchema = defineSchema({ /** Fractional index for option ordering in pickers */ sortKey: text({ required: true }) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('database') }) /** diff --git a/packages/data/src/schema/schemas/database-view.ts b/packages/data/src/schema/schemas/database-view.ts index 3e8397da7..a1cf9ae9a 100644 --- a/packages/data/src/schema/schemas/database-view.ts +++ b/packages/data/src/schema/schemas/database-view.ts @@ -17,6 +17,7 @@ import type { FilterGroup, SortConfig } from '../../database/view-types' import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, relation, select, json } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const DatabaseViewSchema = defineSchema({ name: 'DatabaseView', @@ -84,7 +85,9 @@ export const DatabaseViewSchema = defineSchema({ /** End date field ID */ endDateField: text({ maxLength: 100 }) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('database') }) /** diff --git a/packages/data/src/schema/schemas/database.ts b/packages/data/src/schema/schemas/database.ts index 4916bc012..df841491e 100644 --- a/packages/data/src/schema/schemas/database.ts +++ b/packages/data/src/schema/schemas/database.ts @@ -18,6 +18,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, file, select, number, relation } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const DatabaseSchema = defineSchema({ name: 'Database', @@ -84,7 +85,9 @@ export const DatabaseSchema = defineSchema({ }) }, // Y.Doc used ONLY as the awareness/presence channel — no persistent state - document: 'yjs' + document: 'yjs', + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) /** diff --git a/packages/data/src/schema/schemas/experiment.ts b/packages/data/src/schema/schemas/experiment.ts index 873b7a6d5..2d824cb4d 100644 --- a/packages/data/src/schema/schemas/experiment.ts +++ b/packages/data/src/schema/schemas/experiment.ts @@ -12,6 +12,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, select, json, date, relation } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const ExperimentSchema = defineSchema({ name: 'Experiment', @@ -108,7 +109,9 @@ export const ExperimentSchema = defineSchema({ default: 'private' }) }, - document: 'yjs' // Collaborative protocol / journal / observations narrative + document: 'yjs', // Collaborative protocol / journal / observations narrative + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) /** An Experiment node type (inferred from schema). */ diff --git a/packages/data/src/schema/schemas/external-reference.ts b/packages/data/src/schema/schemas/external-reference.ts index 982b3a9d4..229baff4c 100644 --- a/packages/data/src/schema/schemas/external-reference.ts +++ b/packages/data/src/schema/schemas/external-reference.ts @@ -8,6 +8,7 @@ */ import type { InferNode } from '../types' +import { presets } from '../../auth' import { defineSchema } from '../define' import { select, text, url } from '../properties' @@ -69,7 +70,9 @@ export const ExternalReferenceSchema = defineSchema({ /** Provider-specific metadata stored as JSON */ metadata: text({ maxLength: 10000 }) }, - document: undefined + document: undefined, + // Standalone/personal content: owner-only by default (exploration 0192). + authorization: presets.private() }) /** diff --git a/packages/data/src/schema/schemas/folder.ts b/packages/data/src/schema/schemas/folder.ts index 84f00c9f8..36a1d1005 100644 --- a/packages/data/src/schema/schemas/folder.ts +++ b/packages/data/src/schema/schemas/folder.ts @@ -12,6 +12,7 @@ */ import type { InferNode } from '../types' +import { presets } from '../../auth' import { compareSortKeys } from '../../database/fractional-index' import { defineSchema } from '../define' import { created, createdBy, relation, text } from '../properties' @@ -37,7 +38,9 @@ export const FolderSchema = defineSchema({ createdAt: created(), createdBy: createdBy() }, - document: undefined + document: undefined, + // Standalone/personal content: owner-only by default (exploration 0192). + authorization: presets.private() }) export type Folder = InferNode<(typeof FolderSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/inbox-state.ts b/packages/data/src/schema/schemas/inbox-state.ts index 951163d74..4eaee17ad 100644 --- a/packages/data/src/schema/schemas/inbox-state.ts +++ b/packages/data/src/schema/schemas/inbox-state.ts @@ -15,6 +15,7 @@ */ import type { InferNode } from '../types' +import { presets } from '../../auth' import { defineSchema } from '../define' import { created, createdBy, json, person } from '../properties' @@ -71,7 +72,9 @@ export const InboxStateSchema = defineSchema({ createdAt: created(), createdBy: createdBy() }, - document: undefined + document: undefined, + // Standalone/personal content: owner-only by default (exploration 0192). + authorization: presets.private() }) export type InboxState = InferNode<(typeof InboxStateSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/map.ts b/packages/data/src/schema/schemas/map.ts index a132808b3..a324f2cad 100644 --- a/packages/data/src/schema/schemas/map.ts +++ b/packages/data/src/schema/schemas/map.ts @@ -17,6 +17,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { json, relation, select, text } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' // ─── Minimal structural GeoJSON (dependency-free) ──────────────────────────── // Just enough of the GeoJSON shape to persist imported features and feed them @@ -155,7 +156,9 @@ export const MapSchema = defineSchema({ ] as const, default: 'inherit' }) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) /** diff --git a/packages/data/src/schema/schemas/media-asset.ts b/packages/data/src/schema/schemas/media-asset.ts index 7285927d6..3f8c1e654 100644 --- a/packages/data/src/schema/schemas/media-asset.ts +++ b/packages/data/src/schema/schemas/media-asset.ts @@ -3,6 +3,7 @@ */ import type { InferNode } from '../types' +import { presets } from '../../auth' import { defineSchema } from '../define' import { file, number, select, text } from '../properties' @@ -37,7 +38,9 @@ export const MediaAssetSchema = defineSchema({ /** Natural height when known */ height: number({ integer: true, min: 0 }) }, - document: undefined + document: undefined, + // Standalone/personal content: owner-only by default (exploration 0192). + authorization: presets.private() }) export type MediaAsset = InferNode<(typeof MediaAssetSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/metric.ts b/packages/data/src/schema/schemas/metric.ts index af78c4e2a..9c7d709a9 100644 --- a/packages/data/src/schema/schemas/metric.ts +++ b/packages/data/src/schema/schemas/metric.ts @@ -12,6 +12,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, number, select, json, relation } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const MetricSchema = defineSchema({ name: 'Metric', @@ -118,7 +119,9 @@ export const MetricSchema = defineSchema({ ] as const, default: 'private' }) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) /** A Metric node type (inferred from schema). */ diff --git a/packages/data/src/schema/schemas/observation.ts b/packages/data/src/schema/schemas/observation.ts index 498ef8708..46a65e7a5 100644 --- a/packages/data/src/schema/schemas/observation.ts +++ b/packages/data/src/schema/schemas/observation.ts @@ -13,6 +13,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, number, select, json, date, relation } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const ObservationSchema = defineSchema({ name: 'Observation', @@ -74,7 +75,9 @@ export const ObservationSchema = defineSchema({ ] as const, default: 'private' }) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) /** An Observation node type (inferred from schema). */ diff --git a/packages/data/src/schema/schemas/page.ts b/packages/data/src/schema/schemas/page.ts index dfdee06e7..df1d3463e 100644 --- a/packages/data/src/schema/schemas/page.ts +++ b/packages/data/src/schema/schemas/page.ts @@ -10,6 +10,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, file, relation, select } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const PageSchema = defineSchema({ name: 'Page', @@ -47,7 +48,9 @@ export const PageSchema = defineSchema({ default: 'inherit' }) }, - document: 'yjs' // Collaborative Y.Doc for rich text + document: 'yjs', // Collaborative Y.Doc for rich text + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization() }) /** diff --git a/packages/data/src/schema/schemas/reaction.ts b/packages/data/src/schema/schemas/reaction.ts index 0701e53d4..550330190 100644 --- a/packages/data/src/schema/schemas/reaction.ts +++ b/packages/data/src/schema/schemas/reaction.ts @@ -5,6 +5,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { created, createdBy, person, relation, select, text } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' const reactionTypes = [ { id: 'like', name: 'Like', color: 'green' }, @@ -30,7 +31,9 @@ export const ReactionSchema = defineSchema({ createdAt: created(), createdBy: createdBy() }, - document: undefined + document: undefined, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('target') }) export type Reaction = InferNode<(typeof ReactionSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/saved-view.ts b/packages/data/src/schema/schemas/saved-view.ts index 206bb3732..4a372ed71 100644 --- a/packages/data/src/schema/schemas/saved-view.ts +++ b/packages/data/src/schema/schemas/saved-view.ts @@ -8,6 +8,7 @@ import type { InferNode } from '../types' import { defineSchema } from '../define' import { relation, select, text } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const SavedViewSchema = defineSchema({ name: 'SavedView', @@ -34,7 +35,9 @@ export const SavedViewSchema = defineSchema({ /** Optional database that owns database-scoped views */ database: relation({ target: 'xnet://xnet.fyi/Database@2.0.0' as const }) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('database') }) export type SavedView = InferNode<(typeof SavedViewSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/tag.ts b/packages/data/src/schema/schemas/tag.ts index 11f081136..b0fa1a4db 100644 --- a/packages/data/src/schema/schemas/tag.ts +++ b/packages/data/src/schema/schemas/tag.ts @@ -14,6 +14,7 @@ */ import type { InferNode } from '../types' +import { presets } from '../../auth' import { defineSchema } from '../define' import { checkbox, created, createdBy, text } from '../properties' @@ -40,7 +41,9 @@ export const TagSchema = defineSchema({ createdAt: created(), createdBy: createdBy() }, - document: undefined + document: undefined, + // Standalone/personal content: owner-only by default (exploration 0192). + authorization: presets.private() }) export type Tag = InferNode<(typeof TagSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/task-view.ts b/packages/data/src/schema/schemas/task-view.ts index 920248a47..43e7d87b7 100644 --- a/packages/data/src/schema/schemas/task-view.ts +++ b/packages/data/src/schema/schemas/task-view.ts @@ -11,6 +11,7 @@ import type { FilterGroup, SortConfig } from '../../database/view-types' import type { InferNode } from '../types' import { defineSchema } from '../define' import { text, relation, select, json } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' export const TaskViewSchema = defineSchema({ name: 'TaskView', @@ -48,7 +49,9 @@ export const TaskViewSchema = defineSchema({ /** Fractional index for view tab ordering */ sortKey: text({ required: true }) - } + }, + // Inherits access from its home Space (exploration 0181/0192). + authorization: spaceCascadeAuthorization('project') }) /** diff --git a/packages/data/src/schema/schemas/user-widget.ts b/packages/data/src/schema/schemas/user-widget.ts index a06535353..06d789987 100644 --- a/packages/data/src/schema/schemas/user-widget.ts +++ b/packages/data/src/schema/schemas/user-widget.ts @@ -8,6 +8,7 @@ */ import type { InferNode } from '../types' +import { presets } from '../../auth' import { defineSchema } from '../define' import { json, text } from '../properties' @@ -44,7 +45,9 @@ export const UserWidgetSchema = defineSchema({ /** Default tile size in 12-column grid units — whole-value LWW */ defaultSize: json({}) - } + }, + // Standalone/personal content: owner-only by default (exploration 0192). + authorization: presets.private() }) export type UserWidget = InferNode<(typeof UserWidgetSchema)['_properties']>