diff --git a/.ops/tasks/Application-declared collection configuration provisioning.md b/.ops/tasks/Application-declared collection configuration provisioning.md deleted file mode 100644 index 3693f7ec..00000000 --- a/.ops/tasks/Application-declared collection configuration provisioning.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -title: Application-declared collection configuration provisioning -status: done -priority: high -owner: codex -parent: SDK and authority beta hardening -tags: - - sdk - - applications - - manifests - - configuration - - mdbase-yaml - - provisioning - - tasknotes - - hosted - - relay - - developer-experience -created_at: 2026-08-05T12:01:29+10:00 -updated_at: 2026-08-05T22:23:00+10:00 -type: task ---- - -# Application-declared collection configuration provisioning - -## Context - -TaskNotes stores portable views as Obsidian Base sources. Creating those sources -requires the selected collection's `mdbase.yaml` to admit their paths through -`x-obsidian.bases.include`. - -PR #181 temporarily added that policy to the generic hosted collection template. -PR #182 reverted it before deployment because an application-specific Obsidian -policy does not belong in every mdbase collection. The application declaration -can currently provision type packs containing contracts, types, and schemas, but -cannot declare configuration requirements or propose narrowly scoped -configuration changes. - -## Desired outcome - -Allow an application to declare the collection configuration it requires and a -safe, deterministic provision that can satisfy that requirement. The SDK should -assess the selected collection, present any required setup for explicit review, -and apply the approved setup atomically. The same declaration and semantics must -work for hosted collections and relay-backed filesystem collections. - -TaskNotes is the first consumer. Existing compatible collections should work -without a prompt; existing incompatible collections should be repairable through -the reviewed setup flow without recreation or a service-wide template change. - -## Design constraints - -- Keep the generic `mdbase` collection template application-agnostic. -- Do not give applications an unrestricted `mdbase.yaml` write operation. -- Keep canonical assessment, merge, conflict, and apply semantics in - `mdbase-rs`; Connect, hosted providers, relays, and SDK consumers must not - maintain independent merge implementations. -- Prefer typed, idempotent semantic operations such as set membership over raw - JSON Patch or whole-file replacement. -- Initially restrict application provisions to approved `x-*` extension - namespaces. Core settings such as `spec_version`, type storage, validation, - security, and service limits remain collection-owner policy. -- Bind every apply to the registered application declaration, provision digest, - reviewed assessment digest, and current collection revision. -- Apply configuration and type-pack setup atomically so an application cannot be - left half-configured. -- Preserve user-authored configuration, return structured conflicts for wrong - types or incompatible scalar values, and make retries idempotent. -- Record durable provision receipts and contribution ownership. Do not silently - remove configuration when an application disconnects. -- Support multiple applications contributing the same set-like value without - duplicate entries or unsafe cleanup. - -## Proposed contract direction - -Keep requirements and provisions distinct in the application declaration: - -```json -{ - "requirements": { - "configuration": [ - { - "id": "tasknotes-base-sources", - "path": "/x-obsidian/bases/include", - "predicate": "contains", - "value": "views/tasknotes/**/*.base" - } - ] - }, - "provisions": { - "configuration": [ - { - "requirement": "tasknotes-base-sources", - "operation": "set_add", - "path": "/x-obsidian/bases/include", - "value": "views/tasknotes/**/*.base" - } - ] - } -} -``` - -This shape is provisional. Confirm the smallest reusable contract before -freezing the schema. TaskNotes should create explicit namespaced paths such as -`views/tasknotes/today.base`; it should not change global defaults such as -`create_folder` or `default_for_new_views`. - -## Workstreams - -1. Define canonical configuration requirement, assessment, provision, receipt, - and conflict models in `mdbase-rs`. -2. Add atomic assess/apply operations with revision and digest preconditions, - idempotent retries, extension-namespace policy, and multi-contributor tests. -3. Extend the Connect protocol application-manifest schema, runtime types, - validator, semantic capability contract, and developer tooling with precise - JSON-pointer diagnostics. -4. Route the canonical operations through the local connector, relay, hosted - provider, authorization policy, and signed request machinery without - duplicating configuration semantics. -5. Generalize the SDK application session's definition review into a collection - setup review that combines type-pack and configuration changes and applies - them atomically. -6. Update TaskNotes' generated application declaration, setup UX, and view-source - creation to request the namespaced include and use explicit `.base` paths. -7. Add TaskNotes unit and end-to-end coverage for already-compatible, - provisionable, declined, conflicting, retried, and upgraded setup flows. -8. Add cross-runtime hosted and relay conformance tests and include the exact - TaskNotes declaration in release validation and live acceptance. - -## Acceptance criteria - -- A new generic hosted collection contains no TaskNotes- or Obsidian-specific - configuration. -- Connecting TaskNotes to an already compatible collection requires no setup - mutation. -- Connecting TaskNotes to a collection missing the include produces a clear, - human-readable setup review rather than `Invalid input` or a raw engine error. -- Approval adds exactly the declared include, preserves unrelated YAML, records - a receipt, and enables TaskNotes to create, list, execute, update, and delete - its `.base` views. -- Declining the required provision leaves the collection unchanged and explains - why the affected TaskNotes feature is unavailable. -- Wrong-type and incompatible-value cases return structured, actionable - conflicts and never overwrite user policy. -- Applying or retrying setup is atomic and idempotent across process restart and - ambiguous transport outcomes. -- Hosted and relay-backed collections pass the same behavioral fixtures using - the same `mdbase-rs` semantics. -- Existing collections can adopt the provision through application setup; no - collection recreation or blanket hosted-template migration is required. -- TaskNotes' generated declaration validates through local tooling, Connect's - registration boundary, consumer CI, and release acceptance. - -## History - -- mdbase Connect PR #181 demonstrated the required `x-obsidian` configuration - and complete hosted Base-view lifecycle, but placed the policy in the generic - template. -- mdbase Connect PR #182 reverted that change before production deployment. - -## Handoff - -Start by specifying the canonical `mdbase-rs` assessment and apply contract, -including merge algebra, receipts, conflicts, and transaction boundaries. Do -not begin with TaskNotes UI or a hosted-only endpoint; those should consume the -shared semantics once the contract is settled. - -## Progress — 2026-08-05 - -- The canonical atomic assessment/apply contract and receipts merged in - mdbase-rs PR #40 at `179cf4a`; Connect's declaration binding, hosted setup, - relay/local semantics, and authorization coverage are implemented on the - beta-hardening train. -- TaskNotes has a preliminary collection-setup migration checkpoint, but it is - intentionally not the final pin. It will consume the same immutable successor - artifact set as the other three applications after release readiness is - frozen, avoiding a second consumer/deployment cycle. -- The task remains open until that exact TaskNotes artifact migration and its - local, hosted, consumer-CI, and release-acceptance proof are complete. - -## Completion — 2026-08-05 - -- Canonical assessment, merge, conflict, receipt, contribution ownership, and - atomic apply semantics live in mdbase-rs (`179cf4a`) and are consumed by both - filesystem and hosted authorities. Connect does not carry a second merge - implementation, and the generic hosted template remains free of TaskNotes or - Obsidian policy. -- Connect commit `55b536aafa9a1ae1031171fa7e39ae99fa4530f0` freezes the - beta.33 manifest schema, `collection.setup.apply` capability, signed - assessment/apply operations, relay and hosted routing, reviewed setup session - API, diagnostics, idempotency, conflict, receipt, restart, and cross-authority - fixtures. -- TaskNotes commit `6febc15` pins only that immutable artifact set. Its - declaration requests `views/tasknotes/**/*.base`, its setup UI distinguishes - review from access, and its five ordinary editable sources use explicit - `views/tasknotes/*.base` paths. -- Fresh relay-backed live acceptance applied the reviewed setup to collection - `84ad01aa-268f-4f21-88ea-8e9e22600c74`, preserved generic collection - ownership, wrote the one namespaced include, created all five sources, and - executed the Today view. The run also dropped a successful create response, - reloaded, recovered the original durable request, and proved exactly one - task. It exposed and fixed a date-string comparison in the application-owned - Today source before release. -- TaskNotes' 356-test verification, manifest registration/validation, - desktop/mobile 8/8 matrix, seven production checks, Android notification and - process-restart smoke, and repeated real-authority dogfood are green. The - unit/system suites cover already-compatible, missing, declined, conflicting, - retried, upgraded, hosted, and relay-backed setup paths. - -All acceptance criteria are satisfied. No intermediate deployment occurred. diff --git a/.ops/tasks/Beta hardening 01 - contracts and baseline.md b/.ops/tasks/Beta hardening 01 - contracts and baseline.md deleted file mode 100644 index c8b106d9..00000000 --- a/.ops/tasks/Beta hardening 01 - contracts and baseline.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Beta hardening 01 - contracts and baseline -status: done -priority: critical -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 1 -phase: 0 -depends_on: [] -tags: [beta, sdk, protocol, contracts, baseline] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-04T18:33:40+10:00 -type: task ---- - -# Beta hardening 01 - contracts and baseline - -## Outcome - -Freeze the mutation state machine, generated mutator catalogue, independent -version axes, consumer-tested public SDK, canonical fingerprint encoding, -deployment switch, artifact inventory, and complete green baseline. - -## Exit gate - -Every Phase 0 artifact is reviewable and the disposable compile fixtures for -Editor, Workouts, Pickle, and TaskNotes prove the frozen API before storage or -protocol implementation begins. - -## Notes - -Beta.31 already provides a canonical collection-operation tuple. Audit and -extend that source rather than creating another handwritten catalogue. - -## Baseline evidence — 2026-08-04 - -Verified from `/home/calluma/projects/mdbase-connect` at beta.31 commit -`eafb7eb`: - -- `cargo fmt --all -- --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `MDBASE_CONNECT_ENV=test MDBASE_CONNECT_SECRET_BACKEND=insecure-test-file cargo test --workspace` -- `fnm exec --using=24 pnpm build` -- `fnm exec --using=24 pnpm typecheck` -- `fnm exec --using=24 pnpm test` -- `pnpm check:operations` -- `pnpm check:consumer-artifacts` -- `fnm exec --using=24 pnpm test:integration` -- `fnm exec --using=24 pnpm test:system` - -The complete system matrix passed: local connector restart/grant lifecycle, -multi-instance PostgreSQL/NATS relay recovery, hosted sync, PostgreSQL hosted -provider including writer races/backups/restarts, S3-compatible hosted files, -the adversarial file lifecycle suite, packaged container restart, and the -Docker-backed Electron desktop path. - -Canonical consumer checkouts passed their committed beta.28 verification -commands without worktree changes: - -- Editor: typecheck, 227 unit tests, production build, bundle budget, CSP. -- Workouts: typecheck, 22 unit tests, manifest verification, production build. -- Pickle: format, typecheck, lint, 14 unit tests, production build. -- TaskNotes: format, typecheck, lint, 456-test coverage run, both layer coverage - gates, both conformance suites, and production build. - -Workouts and TaskNotes initially exposed stale beta.23 packages in -`node_modules` while their lockfiles and audited artifacts specified beta.28. -After exact frozen-lock reinstalls (`npm ci` and pnpm 10.7.0 -`--frozen-lockfile`), both authoritative runs passed. No consumer worktree was -modified. - -The unqualified Rust workspace test was also exercised and its only failure was -the daemon secret-store test timing out while attempting to reach the desktop -DBus keyring. The supported explicit test backend above makes the full workspace -green and is the authoritative headless result. - -The optional consumer Playwright/browser-shell and Android smoke commands are -tracked for the real candidate-artifact migration slice because they exercise -installed artifacts and native packaging rather than the beta.28 compile/unit -baseline. They are not waived from the later consumer/rollout gates. - -## Exit gate — green - -ADR 0005 is accepted; the mutator and problem catalogues are generated into -both runtimes; the version matrix and deployment switch are frozen; the four -consumer compile spikes freeze the public shape; fingerprint bytes are shared -and adversarially tested; artifact provenance is enforced; and the full current -baseline above is green. Phase 1 may proceed independently. Phase 2 remains -gated until Phase 1 is also green. diff --git a/.ops/tasks/Beta hardening 02 - SQLite migrations and recovery.md b/.ops/tasks/Beta hardening 02 - SQLite migrations and recovery.md deleted file mode 100644 index 16e8a811..00000000 --- a/.ops/tasks/Beta hardening 02 - SQLite migrations and recovery.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Beta hardening 02 - SQLite migrations and recovery -status: done -priority: critical -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 2 -phase: 1 -depends_on: [Beta hardening 01 - contracts and baseline] -tags: [beta, sqlite, migrations, backup, recovery] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-04T18:50:05+10:00 -type: task ---- - -# Beta hardening 02 - SQLite migrations and recovery - -## Outcome - -Replace ad hoc local registry schema setup with numbered, crash-resumable -migrations, consistent restricted backups, integrity classification, explicit -restore/rebuild UX, and historical beta.28 fixtures. - -## Exit gate - -New and upgraded beta.28 registries converge; every injected interruption -resumes idempotently; clean and WAL-active backups restore; corruption is -preserved and diagnosed without touching canonical Markdown. - -## Delivery - -- Replaced ad hoc schema setup with a numbered, checksummed migration ledger - and `PRAGMA user_version` boundary. -- Added exact beta.28 shape recognition, transactional migration, fail-closed - handling for unsupported/future/tampered schemas, and distinct busy/corrupt/ - incompatible/migration problem codes. -- Added online WAL-safe backups with restricted permissions, SHA-256 integrity, - HMAC-authenticated metadata, durable writes, diagnostics, verified restore to - a new path, and a conservative index-only rebuild operation. -- Documented the supported schema matrix and recovery contract in - `docs/registry-schema-support.md`. - -## Evidence - -- `cargo test -p mdbase-connect-core`: 95 passed, including beta.28 data - preservation, new/upgraded schema convergence, three reopens through the - public registry boundary, WAL-active restore, all injected migration fault - points, corrupt/future/tampered/busy classification, backup tamper rejection, - permission checks, and authorization/receipt-preserving index rebuild. -- `MDBASE_CONNECT_ENV=test MDBASE_CONNECT_SECRET_BACKEND=insecure-test-file cargo test --workspace`: green. -- `cargo clippy --workspace --all-targets -- -D warnings`: green. -- `cargo fmt --all -- --check` and `git diff --check`: green. -- Node 24 workspace build, typecheck, and test suites: green; client compile - spikes green. -- `pnpm e2e` local daemon/system path: green after the migration boundary. - -## Exit-gate decision - -Green. The supported beta.28 registry converges without data loss, recovery is -idempotent at every injected interruption, both clean/new and active-WAL backup -paths restore, and corrupt inputs remain preserved and explicitly diagnosed. diff --git a/.ops/tasks/Beta hardening 03 - local durable mutation journal.md b/.ops/tasks/Beta hardening 03 - local durable mutation journal.md deleted file mode 100644 index dcd86f3e..00000000 --- a/.ops/tasks/Beta hardening 03 - local durable mutation journal.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Beta hardening 03 - local durable mutation journal -status: done -priority: critical -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 3 -phase: 2 -depends_on: [Beta hardening 01 - contracts and baseline, Beta hardening 02 - SQLite migrations and recovery] -tags: [beta, idempotency, sqlite, filesystem, recovery] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-05T08:49:15+10:00 -progress_summary: Complete. The fenced SQLite mutation journal, recovery, compaction, tombstones, revocation replay, and privacy-safe diagnostics are green. The generated 19-mutator by 6-termination-boundary matrix passes all 114 cases, and filesystem durability is green on Ubuntu, macOS, and Windows in Server CI run 30954941302. -type: task ---- - -# Beta hardening 03 - local durable mutation journal - -## Outcome - -Implement the fenced, lease-based local mutation journal and prove restart, -replay, compaction, revocation, and real Linux/macOS/Windows filesystem -durability for every canonical mutator. - -## Gate - -Do not begin implementation until delivery slices 1 and 2 are independently -green. - -Gate opened 2026-08-04: delivery slices 1 and 2 are independently green with -their exit evidence recorded in their task sidecars. - -## Exit evidence - -- One canonical catalogue drives the 19 public mutators exercised by the - recovery harness. Every mutator passes all six termination boundaries: - before claim, after claim, after prepare, after effect, after applied - evidence, and after terminal receipt (114 cases total). -- Identical retries return or resume the durable outcome; live owners remain - bounded; expired or prior-process leases are fenced; conflicting request-ID - reuse is permanent and typed; genuinely indeterminate effects retain the - stable request ID and report `operation_outcome_unknown`. -- Linux, macOS, and Windows durability jobs are green in Server CI run - `30954941302`, including create, replace, delete, rename-parent flushes, - response loss, restart, takeover, and registry-backup behavior. -- Journal compaction preserves fingerprint tombstones for the replay horizon, - retired-grant replay remains authorization-safe, and diagnostics disclose no - record plaintext. - -Exit gate closed green on 2026-08-05. diff --git a/.ops/tasks/Beta hardening 04 - hosted durable mutation journal.md b/.ops/tasks/Beta hardening 04 - hosted durable mutation journal.md deleted file mode 100644 index 80e786cf..00000000 --- a/.ops/tasks/Beta hardening 04 - hosted durable mutation journal.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Beta hardening 04 - hosted durable mutation journal -status: done -priority: critical -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 4 -phase: 2 -depends_on: [Beta hardening 03 - local durable mutation journal] -tags: [beta, idempotency, postgresql, r2, recovery] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-05T08:49:15+10:00 -progress_summary: Complete. The provider-neutral PostgreSQL journal covers the full canonical mutator catalogue, the shared 114-case termination matrix is green across local and hosted authorities, the hosted and adversarial R2 provider suites pass, beta.28 provider upgrade/replay passes, and previous-provider notification recovery is green in Server CI run 30954941302. -type: task ---- - -# Beta hardening 04 - hosted durable mutation journal - -## Outcome - -Generalize hosted receipts into the same provider-neutral durable journal, -coordinate PostgreSQL and external side effects safely, enforce constraints and -capabilities, and pass the cross-authority mutator conformance suite. - -## Exit evidence - -- The same generated 19-mutator catalogue and six termination boundaries used - for the local authority pass against hosted PostgreSQL dispatch; there is no - handwritten or reduced hosted operation list. -- PostgreSQL mutation effects and journal transitions are atomic where one - transaction can own them. External R2 effects use durable prepared/applied - evidence, fenced takeover, and resumable cleanup; the 12-scenario adversarial - suite is green. -- Immutable beta.28 upgrade fixtures migrate legacy record/sync receipts, - preserve encrypted completed outcomes, remove legacy runtime paths, and - replay the exact result after upgrade. -- Hosted provider, server-container, previous-release upgrade/OAuth, and - previous-provider notification-recovery jobs are green in Server CI run - `30954941302`. Readiness and relay negotiation fail closed when a required - protocol, authorization, semantic-capability, or durable-mutation contract is - absent. - -Exit gate closed green on 2026-08-05. diff --git a/.ops/tasks/Beta hardening 05 - bounded IO and typed outcomes.md b/.ops/tasks/Beta hardening 05 - bounded IO and typed outcomes.md deleted file mode 100644 index 2662f354..00000000 --- a/.ops/tasks/Beta hardening 05 - bounded IO and typed outcomes.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Beta hardening 05 - bounded IO and typed outcomes -status: done -priority: critical -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 5 -phase: 3 -depends_on: [Beta hardening 04 - hosted durable mutation journal] -tags: [beta, sdk, timeout, cancellation, postgresql] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-04T21:58:03+10:00 -progress_summary: Uniform signal/timeout options and workload defaults now bound SDK, sync, files, notifications, watch startup, management, HTTP decoders, and both PostgreSQL services. Live PostgreSQL 18 probes exhaust production pools and force statement and row-lock timeouts with typed outcomes. Client, server, management, package, and Editor gates are green. -type: task ---- - -# Beta hardening 05 - bounded IO and typed outcomes - -## Outcome - -Apply the frozen request-options contract across public operations, make retry -budgets deadline-aware, decode every boundary into typed outcomes, bound all -PostgreSQL waits, and eliminate indefinitely pending promises and UI state. - -## Exit evidence - -- Commits `05e9207`, `31f046f`, and `075fd1b` implement boundary decoders, - request budgets/defaults, abortable watch startup, management bounds, and - live database saturation probes. -- Client: 155 tests, typecheck, build, public API fixture, browser CSP and - bundle budget green. -- Server: 270 tests green. Management: 7 tests and typecheck green. -- Disposable PostgreSQL 18: control-plane and hosted-provider pool exhaustion, - statement cancellation (`57014`), and row-lock cancellation (`55P03`) green; - hosted failures map to typed `pool`, `statement`, and `lock` classes. -- `pnpm package:audit` builds all workspace packages/apps and validates every - published package boundary. diff --git a/.ops/tasks/Beta hardening 06 - management correctness.md b/.ops/tasks/Beta hardening 06 - management correctness.md deleted file mode 100644 index 76ace998..00000000 --- a/.ops/tasks/Beta hardening 06 - management correctness.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Beta hardening 06 - management correctness -status: done -priority: high -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 6 -phase: 5 -depends_on: [Beta hardening 05 - bounded IO and typed outcomes] -tags: [beta, management, concurrency, user-experience] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-04T22:13:17+10:00 -progress_summary: Complete. Management mutations have explicit not-sent versus outcome-unknown semantics, lifecycle cancellation, duplicate suppression, generation-aware refresh, exact partial completion, and persisted connector upgrade-required presentation. The focused management, Editor, and live server matrices are green. -type: task ---- - -# Beta hardening 06 - management correctness - -## Outcome - -Make refresh invalidation generation-aware, make revocation atomic or exactly -report partial completion, and prove timeout, cancellation, ordering, unmount, -offline, and upgrade-required presentation. - -## Evidence - -- Commit `05e9207` contains the generation counter, batch revocation endpoint, - management outcome handling, and focused Editor/server tests. -- Commit `c78c22a` adds not-sent versus outcome-unknown mutation semantics, - component-lifecycle cancellation, rapid duplicate suppression, exact partial - revocation refresh behavior, and durable connector compatibility state. -- Management 9/9, Editor 257/257, and the 18 focused live authorization and - migration tests are green. Management, Editor, and server typechecks pass. -- The broader server suite remains green at 270/270 from the immediately - preceding Phase 4/5 verification run. - -## Next - -Use Connect commit `c78c22a` as the candidate artifact source unless packaging -or consumer verification exposes a defect that requires a new source commit. diff --git a/.ops/tasks/Beta hardening 07 - public SDK surface.md b/.ops/tasks/Beta hardening 07 - public SDK surface.md deleted file mode 100644 index 7a452adf..00000000 --- a/.ops/tasks/Beta hardening 07 - public SDK surface.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Beta hardening 07 - public SDK surface -status: done -priority: critical -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 7 -phase: 4 -depends_on: [Beta hardening 05 - bounded IO and typed outcomes] -tags: [beta, sdk, developer-experience, packages, bundle] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-04T21:58:03+10:00 -progress_summary: The real root SDK now implements the frozen Connect → application session → connection path, uniform request defaults, external-store adapter, advanced/crypto subpaths, URL config, bounded watch subscription, and independently recoverable multi-request mutation handles. Obsolete beta.28 factories and root low-level exports are absent. Real-surface consumer/negative fixtures, docs, CSP, package audit, and enforced 182 KB raw/46 KB gzip bundle gates are green. -type: task ---- - -# Beta hardening 07 - public SDK surface - -## Outcome - -Land the frozen golden path, external-store adapter, explicit advanced and -crypto subpaths, removal of obsolete beta.28 exports, API fixtures, negative -fixtures, documentation, and bundle/CSP/tree-shaking budgets. - -## Exit evidence - -- Commits `05e9207`, `31f046f`, and `0e6f95b` implement the public façade, - subpaths, request defaults, durable recovery, docs, and package gates. -- The unexported candidate path now aliases the actual root entry point; Editor, - Workouts, Pickle, and TaskNotes compile spikes therefore test shipped types. -- Negative fixtures reject `createApplicationSession`, - `resumePendingMutation(input)`, and removed root low-level imports. -- Durable handles retain exact plaintext/encrypted requests and crypto context, - survive authorization loss, migrate the previous single slot, support - multiple request IDs, and clear independently (155 client tests). -- `pnpm package:audit` is green. Browser output is 179,638 raw / 45,406 gzip - under enforced 182,000 / 46,000 limits, with no eval/CSP violation. diff --git a/.ops/tasks/Beta hardening 08 - internal module extraction.md b/.ops/tasks/Beta hardening 08 - internal module extraction.md deleted file mode 100644 index 4eb5975e..00000000 --- a/.ops/tasks/Beta hardening 08 - internal module extraction.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Beta hardening 08 - internal module extraction -status: done -priority: medium -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 8 -phase: 4-follow-up -depends_on: [Beta hardening 07 - public SDK surface] -blocking_beta_invitation: true -tags: [beta, architecture, refactor, testing] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-05T08:49:15+10:00 -progress_summary: Complete. The five hardened production modules that exceeded the 1,000-line review budget were split behind their frozen façades without raising an exception. The clean-checkout architecture gate now passes across 481 production files, 1,031 relative imports, and 14 workspace packages; Editor CI and Desktop Release run 30954941644 are green. -type: task ---- - -# Beta hardening 08 - internal module extraction - -## Outcome - -Where justified after behavioral hardening, extract oversized internals behind -the frozen façades with focused tests and no behavior or bundle regression. - -## Constraint - -Keep this non-blocking unless module size directly prevents a correctness -change or safe review. Never mix large movement into a behavioral slice. - -## Activation evidence - -PR #183 made this slice blocking on 2026-08-05: the clean-checkout -`check:architecture` gate reports five hardened production modules above the -1,000-line review budget. Preserve the frozen public façades and extract -cohesive internals without raising or adding legacy line-budget exceptions. - -## Exit evidence - -- Cohesive implementation modules were extracted only after their behavioral - hardening landed; public façades and package exports remain frozen. -- No architecture budget or legacy exception was raised. `pnpm - check:architecture` passes across 481 production files, 1,031 relative - imports, and 14 workspace packages. -- Editor CI run `30954941837` and every built Desktop Release target in run - `30954941644` are green, covering Linux, Windows, macOS Intel and Apple - Silicon, Windows Store packaging, and release-regression tests. - -Exit gate closed green on 2026-08-05. diff --git a/.ops/tasks/Beta hardening 09 - candidate and consumer migrations.md b/.ops/tasks/Beta hardening 09 - candidate and consumer migrations.md deleted file mode 100644 index b0e711c4..00000000 --- a/.ops/tasks/Beta hardening 09 - candidate and consumer migrations.md +++ /dev/null @@ -1,274 +0,0 @@ ---- -title: Beta hardening 09 - candidate and consumer migrations -status: done -priority: critical -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 9 -phase: 6 -depends_on: [Beta hardening 06 - management correctness, Beta hardening 07 - public SDK surface] -tags: [beta, packaging, consumers, editor, workouts, pickle, tasknotes] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-05T22:23:00+10:00 -progress_summary: Delivery slice 9 is complete on immutable beta.33 source 55b536aafa9a1ae1031171fa7e39ae99fa4530f0. Exact SHA-512-verified packages are pinned in Editor eb48e42, Workouts fa5684c, Pickle 5e3cbe0, and TaskNotes 6febc15; every product-specific compile, unit, browser/native, manifest/build, and real-authority recovery gate is green. Application configuration provisioning and Final SDK polish are also complete in this same train. No staging or production deployment has occurred; Phase 7 is the only remaining program slice. -type: task ---- - -# Beta hardening 09 - candidate and consumer migrations - -## Outcome - -Build one candidate artifact set from one Connect commit, then migrate and -prove Editor, Workouts, Pickle, and TaskNotes in implementation order with -durable response-loss recovery and every product-specific gate. - -## Current state - -- The actual SDK root, rather than a parallel candidate declaration, compiles - all four consumer spikes and their removed-API assertions. -- Workspace package audit and the in-repo Editor build/tests are green. -- The final artifact source is Connect commit - `4680eadb3b06d70d83edfdfeb5940e00c5e06aee`. Its six beta.32 packages and - SHA-512 hashes are recorded in the generated candidate manifest. -- Editor integration found that `operation_outcome_unknown` did not always - include the durable request ID. Commits `51bc556` and `79c6e43` bind that - problem to `details.request_id`, update generated protocol contracts, and - keep all fixtures type-valid. -- mdbase Editor commit `8ef38b9` completed the API migration, and follow-up - commit `502bc26` repins it to the final `8edc7b327c2a` beta.32 artifacts. Its - 229 unit tests, 42 Playwright tests, typecheck, build, bundle/CSP checks, and - manifest validation pass. Rename, delete, and type-pack response-loss paths - resume the exact stored request ID. -- Workouts integration found two authority defects instead of papering over - them in the consumer. Commit `79176cc` makes full-collection access satisfy - semantic contract capabilities, while `8edc7b3` translates explicit portable - contract selectors under full access in both local and hosted dispatch. -- mdbase Workouts commit `f61217f` consumes the exact `8edc7b327c2a` Connect and - protocol artifacts. It uses bounded/cancellable reads and writes, generation- - aware shared scans, durable unknown-write recovery, explicit definition - review, and an isolated HTTPS Connect dogfood path. Offline install reported - zero vulnerabilities; typecheck, 24 unit tests, manifest verification, - production build, 10 browser tests, and the real authorize/read/create/pause - dogfood test are green. -- Pickle integration exposed a missing domain boundary: `@mdbase-dev/pickle` - did not forward request budgets or own the recorded-versus-pending response - outcome. Connect commits `161dd7a` and `4680ead` add those APIs and exact - request-ID recovery, keeping transport problem parsing out of the app UI. -- Pickle commit `3dd5612` consumes the exact `4680eadb3b06` Connect, protocol, - and Pickle artifacts. Startup, authorization callbacks, definition updates, - list/respond, watch startup/lifetime, and notification binding are bounded - and cancellable. Native backgrounding suspends foreground work, browser close - and deep-link replay are typed/tested, and an unknown response remains visible - and resumes by its original durable request ID after reopen. `pnpm verify` - passes with 18 tests, desktop/mobile Playwright is 8/8, Capacitor sync is - green, and the debug APK builds with the installed JDK 21. No Android device - is attached, so the physical-device smoke has not run. -- Pickle follow-up commit `f1c7c6e` adds an opt-in, isolated HTTPS dogfood - harness against a real paired authority. The test allows response creation to - complete, drops the HTTP response, observes the durable pending state, reloads - the application, recovers the original request ID, and proves exactly one - response Markdown file. It passes in 8.1 seconds; the ordinary desktop/mobile - Playwright matrix remains 8/8, and `pnpm verify` passes with 19 tests. The - notification criterion now reads the canonical CloudEvent `data` field and - reaches the expected local push-not-configured boundary without a criterion - evaluation error. -- Workouts commit `2b8a953` and Editor commit `c701dca` repin their already-green - migrations to the exact `4680eadb3b06` artifact set. Focused verification is - green at 24 Workouts tests and 236 Editor tests. -- TaskNotes commits `0214a59`, `e6beba3`, and `bbc14b4` atomically repin the - exact `4680eadb3b06` Connect, devkit, protocol, and testing artifacts; map - application intent to durable authority request IDs; recover the exact - pending request before later canonical reads or writes; and apply explicit - request budgets and lifecycle cancellation across authorization, repository, - files, notifications, and collection switching. -- TaskNotes commit `5c55752` adds the isolated HTTPS real-authority harness. It - completes a create at the authority, drops the successful response, observes - the retained draft and typed failure state, reloads, recovers the original - request, and proves exactly one new Markdown task. The first run exposed that - React Strict Mode cleanup aborted initial repository opening while the - repository retained its rejected initialization promise. The same commit - resets interrupted initialization on lifecycle resume and adds a regression - test; the real dogfood proof then passed in 10.1 seconds. -- TaskNotes `pnpm verify` passes with 352 tests, application/domain coverage - thresholds, 4,983 TaskNotes conformance cases (4,982 pass, one documented - skip), the real mdbase collection oracle, manifest validation, and production - build. Desktop/mobile Playwright is 8/8 and production smoke is 7/7. - Capacitor sync and the Android debug build with JDK 21 are green. No Android - device is attached, so the physical-device smoke has not run. -- TaskNotes' current product architecture has no application-owned offline task - replica or sync queue. The mdbase collection is the sole durable collection - boundary; bounded in-session caches and the separate application mutation - journal do not form a second authority. Replica/sync/transfer criteria in the - original canary wording are therefore not applicable and have been replaced - in the parent plan with direct-authority lifecycle and recovery gates. -- The earlier beta.31, `48af56d`, `8edc7b3`, and `161dd7a` candidate directories are retained - only as immutable rejected evidence. They are superseded and must not be - copied into another consumer. - -## Superseded candidate evidence - -- Artifact source: Connect commit `e1c1f49cca00bbae51e7f1d9ffb5e05c576bb753`. - The Connect, Devkit, Protocol, Sync, Pickle, and Testing tarballs all report - `0.1.0-beta.32-e1c1f49cca00`; the generated manifest records their SHA-512 - hashes, and `pnpm check:consumer-artifacts` verifies every consumer pin. -- Editor: branch `agent/beta32-connect-hardening`, draft PR - `mdbase-dev/mdbase-editor#73`, head `5b26518`. Typecheck, build, bundle/CSP, - 240 unit tests, and all 45 desktop/mobile/remote-authority Playwright tests - pass, including 10k-note performance and durable response-loss recovery. -- Workouts: branch `agent/beta32-connect-hardening`, draft PR - `callumalpass/mdbase-workouts#21`, head `7829ad2`. Typecheck, manifest, build, - 24 tests, 10 browser tests, and live beta.32 authorization/read/create/pause - dogfood pass. Legacy seed types are mapped in place through transactional - authorization and existing records remain visible. -- Pickle: branch `agent/beta32-connect-hardening`, draft PR - `callumalpass/pickle-android#18`, head `63bab98`. Full verify, 8 desktop/mobile - Playwright tests, live response-loss/reload recovery, Capacitor sync, Gradle - test/lint/debug build, and the Android 36 emulator smoke pass. The emulator - proof covers response flow, hardware Back, notification channel, FCM - registration, live opaque push, and process restart. -- TaskNotes: branch `agent/beta32-connect-hardening`, draft PR - `callumalpass/tasknotes-app#91`, head `3101679`. Full verify passes 352 tests, - coverage, 4,983 conformance cases (4,982 pass, one documented skip), the - mdbase oracle, manifest, and build. Eight desktop/mobile Playwright tests, - live response-loss/reload recovery, Capacitor sync, Gradle test/lint/debug - build, and the Android 36 notification/FCM/live-opaque-push/process-restart - smoke pass. -- The consumer branches and PRs are intentionally draft until the coordinated - release train completes Phase 7. No consumer mixes artifacts or sources from - another Connect commit. - -The gate was initially closed on 2026-08-05, then reopened when the Phase 7 -audit proved that package versions were described as diagnostic while the live -relay still used them as the effective compatibility boundary. The `e1c1f49` -artifacts remain useful migration evidence but are not release candidates. - -## Compatibility correction evidence - -- Operation transport is now v2 while grant encryption remains its independent - v1 key-agreement/AEAD profile. Authorization binding v3 signs the exact - operation transport, authorization, semantic capability, and conditional - durable-mutation requirements. -- Relay hello/welcome and hosted readiness advertise structured support sets. - Compatibility uses version-set intersection, accepts a lower package version - when every required contract intersects, and returns the axis-specific typed - problem before authorization, read, replay-ledger, journal, or collection - state. A live WebSocket regression proves control responses remain v1 while - operation requests and responses independently round-trip on transport v2. -- A mixed-version encrypted integration fixture authenticates an unsupported - durable mutation and proves `operation_outcome: not_sent`, zero replay-ledger - rows, and no collection file. Authorization and hosted-readiness tests cover - each axis independently. -- The beta.32 control-plane migration removes incompatible pending requests, - revokes credentials for v2 local grants, and retains each grant and its audit - history with an explicit reauthorization marker. It preserves collection - data and never mechanically re-signs authorization intent. -- The obsolete `ENCRYPTED_RELAY_PROTOCOL_VERSION` façade is removed. Loopback - readiness, CLI diagnostics, and pending-authorization storage name operation - transport directly; migration 0015 copies the historical `relay_protocol` - column and drops it. The MCP gateway no longer emits the removed beta.28 - `/oauth/authorize` query: it persists a distinct installation identity, - signs authorization binding v3 with the exact four-axis requirements, posts - `/oauth/authorization_request`, and follows only its opaque request URI. -- Local verification on 2026-08-05: `pnpm run build`, the complete `pnpm run - test`, `cargo test --workspace`, `cargo check --workspace`, protocol/client/ - server focused suites, daemon tests, and `pnpm run check:architecture` pass. - The browser SDK remains within its fixed 182,000-byte raw and 46,000-byte gzip - budgets at 181,097 and 45,997 bytes. -- PR Editor CI first exposed one remaining v1 operation-envelope assertion in - the remote hosted-authority Playwright harness. The harness now consumes the - canonical operation transport constant for requests and responses; the full - local Editor Playwright matrix passes 47/47. -- Replacement Server CI exposed three release-fixture lifecycle gaps rather - than product downgrades. The local system harness now signs binding v3 with - its exact compatibility requirements and passes the complete MVP E2E. The - Windows filesystem recovery tests explicitly drop every watcher-owning - registry before bounded fixture removal; both restart/fencing tests pass. -- Hosted-provider migration `0026_notification_connect_contracts.sql` upgrades - persisted beta.28 notification-grant projections before strict Rust - deserialization. It derives the durable-mutation ceiling from the exact - stored operation and file permissions, and the previous-provider upgrade - program asserts `2|3|1|1` for its mutation-capable fixture. Hosted-provider - unit tests, shell syntax, workspace formatting, and the embedded migration - build pass locally. -- Server CI run `30964080966` proves migration `0026` through the complete - beta.28 previous-provider notification-recovery path and proves the watcher - lifecycle correction on Windows 2025 as well as Linux and macOS. Its hosted - provider job then exposed the remaining stale multi-instance relay fixture. -- The relay fixture now signs exact binding-v3 contract requirements, - advertises `CONNECT_CONTRACT_SUPPORT` in its control-v1 hello, uses operation - transport v2 for plain requests and responses, and independently retains - grant encryption v1. The exact multi-instance NATS relay system suite passes - locally; replacement CI is required before closing the slice. -- Replacement Server CI run `30964684133` passes the corrected relay suite, - cross-platform durability, and previous-provider migration. Its next provider - stage exposed two more stale hosted fixtures: the generic operation request - wrapper still emitted control protocol v1, and the runtime notification grant - omitted its explicit compatibility ceiling. -- Hosted operation requests now use the canonical operation transport v2 while - file, sync, import, and grant-encryption v1 messages remain independently - unchanged. The mutation-capable notification grant derives exact binding-v3 - requirements. The complete hosted-provider E2E passes locally through - notification recovery, quotas, authority transfer, browser/SDK, files, - restart, backup/restore, token rotation, revocation, and body limits. - -The slice closes again only after one immutable post-correction Connect commit -produces all six packages, all four consumer PRs pin exactly those artifacts, -and their required product gates are rerun. - -## Final beta.33 candidate — 2026-08-05 - -- Artifact source: `55b536aafa9a1ae1031171fa7e39ae99fa4530f0`. - Connect, Devkit, Protocol, Sync, Pickle, and Testing are all beta.33 packages - from that exact revision. `pnpm check:consumer-artifacts` verifies the - declared byte lengths, SHA-512 digests, package files, and lockfile references - in every consumer. -- Final consumer heads are Editor `eb48e42`, Workouts `fa5684c`, Pickle - `5e3cbe0`, and TaskNotes `6febc15`. All worktrees are clean and pushed. -- TaskNotes, the final and strongest integration gate, found and fixed an - application-owned Today-view date comparison during fresh-collection setup. - Repeated live relay dogfood then proved reviewed configuration plus type-pack - setup, namespaced view creation/execution, lost-response recovery, and one - logical write. -- The final candidate passed `cargo check --workspace`, `cargo test - --workspace`, the full JavaScript test/typecheck train, architecture and - release-readiness checks, and package audit. Earlier rejected beta.32 - artifacts remain evidence only and must not be deployed. - -Slice 9 is closed. Phase 7 may use only this release train (or a newly frozen -replacement if deployment discovers a correctness defect). - -## Rejected packed candidate `62513b927384` - -- Connect merge commit `62513b927384959600c66fb76b50d3bc90134e08` - passed Server CI `30965358301`, Desktop release CI `30965358308`, post-merge - Server CI `30966039505`, post-merge Editor CI `30966039504`, and signed image - publication `30966577643`. -- All six packages were then built and packed from a clean detached worktree at - that exact commit. Artifact-manifest verification proved all four consumers - were temporarily pinned to one `0.1.0-beta.32-62513b927384` set. -- Workouts passed 24 tests, typecheck, build, and 10 browser tests. Pickle passed - its full verify and 8 browser tests. TaskNotes passed its full 352-test verify; - its browser rerun was deferred after a concurrent local server occupied its - Playwright port. -- Editor passed 240 unit tests, typecheck, and build. Its packed-artifact browser - run passed 42 tests but correctly failed authorization recovery because - Playwright serialized `writeSeed` without the imported - `GRANT_ENCRYPTION_PROTOCOL_VERSION` binding. Two remote-authority assertions - also still expected operation transport v1. -- The release train stopped before a tag, consumer commit, staging deployment, - or production change. `62513b927384` is rejected evidence, not a release - candidate. -- The Connect correction copies grant encryption v1 into the browser fixture - seed before crossing the Playwright evaluation boundary. Its regression - serializes the function into an isolated realm and executes the connector - path through IndexedDB and WebCrypto; package test, typecheck, and build pass - under Node 24.13.0. -- Draft Connect PR `mdbase-dev/mdbase-connect#185` packages committed source - `7f689ed697f2`. The packed Testing output resolves the imported constant while - constructing the seed and reads only `seed.grantEncryptionProtocolVersion` - inside the serialized callback. -- Editor commit `ae6aad7` replaces only operation request/response envelope - literals with `OPERATION_TRANSPORT_PROTOCOL_VERSION`; independent file and - encryption v1 fixtures remain unchanged. Exact `7f689ed697f2` artifacts pass - the focused authorization/remote-authority run 3/3 and the complete browser - matrix 45/45, including 10,000-note performance and accessibility. diff --git a/.ops/tasks/Beta hardening 10 - rollout and beta gate.md b/.ops/tasks/Beta hardening 10 - rollout and beta gate.md deleted file mode 100644 index cd9fe683..00000000 --- a/.ops/tasks/Beta hardening 10 - rollout and beta gate.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Beta hardening 10 - rollout and beta gate -status: done -priority: critical -owner: codex -parent: SDK and authority beta hardening -delivery_slice: 10 -phase: 7 -depends_on: [Beta hardening 09 - candidate and consumer migrations] -tags: [beta, deployment, canary, observability, rollback] -created_at: 2026-08-04T17:48:28+10:00 -updated_at: 2026-08-06T03:28:00+10:00 -progress_summary: Complete. Staging and production run exact signed beta.34 images after restore-tested checkpoints and verified automatic whole-train rollback. Workouts, Editor, Pickle, and TaskNotes passed in order with clean observations; broker recovery, a 60-minute zero-failure soak, production acceptance, retained rollback evidence, and two clean post-promotion snapshots are green. The external-beta invitation gate is satisfied; no invitations were sent by this task. -type: task ---- - -# Beta hardening 10 - rollout and beta gate - -## Outcome - -Stage the coordinated breaking release train, prove contract-aware activation -and whole-train rollback, canary Workouts, Editor, Pickle, then TaskNotes with -privacy-safe observation, and satisfy every external-beta invitation gate. - -## Gate state - -Initially opened 2026-08-05, then returned to `open` when the compatibility -audit reopened delivery slice 9. Production and staging remain untouched. -Staging must activate the complete release train behind explicit contract-aware -readiness, prove whole-train rollback, and retain the documented canary order -before any external-beta invitation. - -## Ready-to-activate evidence — 2026-08-05 - -- Source and every consumer worktree are clean and pushed; exact artifact - verification is green across all four applications. -- Connect's complete Rust/JavaScript, architecture, release-readiness, package, - performance, and system gates are green at the frozen source commit. -- Consumer gates are green through TaskNotes' fresh relay-backed setup and - durable response-loss/reload proof, including both Android emulator smokes. -- The beta.33 server/connector pair has been exercised locally with an isolated - database, broker, account, connector, and collection. This is test evidence, - not a staging or production activation. - -The next action is the single production-shaped deployment workflow requested -by the user. Preserve the old stack and verified database checkpoint as the -rollback target before activating any schema that it cannot reopen. - -## Staging rollout evidence — 2026-08-06 - -- Staging activation uses beta.34 source - `ea56354739626c55f05a485cd707164740b2c391` and exact signed image digests. - beta.34 is a runtime-only correction that exposes the already-shipped - privacy-safe metrics; all consumers retain the exact beta.33 SDK artifacts. -- Two earlier activations detected the missing snapshot evidence and restored - the complete beta.31 train automatically. The corrected activation passed - signed image identity, migrated readiness, OAuth v4, candidate manifests, - and browser CORS. -- Workouts, Editor, Pickle, and TaskNotes were merged and deployed one at a - time. Each hold produced a clean journal/pool/boundary observation before the - next canary advanced; final deployed-manifest verification passes for all - four applications. -- The broker drill observed the required HTTP 503 while Core NATS was - suspended, recovered stable readiness after resume, and reran the full signed - acceptance suite successfully. -- Workflow `31024686507` is performing the mandatory 60-minute continuous - soak. Production promotion is committed in cloud-ops main - `2cd2987fadf09e81ab30a9354c0648f91aa4625d`, but deployment is held until the - soak and production checkpoint workflow `31024842803` both succeed. - -## Production rollout and gate closure — 2026-08-06 - -- Soak workflow `31024686507` passed its complete 3,600-second window with 110 - samples, 660 endpoint checks, zero transient failures, and full signed - acceptance at both boundaries. -- Production checkpoint workflow `31024842803` encrypted and restore-tested all - three databases and retained artifact - `encrypted-production-backups-31024842803` for the destructive prerelease - migration rollback boundary. -- Promotion workflow `31029549943` deployed the exact staged beta.34 image set - from cloud-ops main `2cd2987fadf09e81ab30a9354c0648f91aa4625d`. - The three exact production reset waivers passed, prior live digests were - recorded, all four services reached live in dependency order, and rollback - artifact `production-deployment-state-31029549943` is retained. -- Workflow and independent production acceptance passed exact image identity, - health/readiness, OAuth device and semantic web writes, all four public - declarations, R2 browser CORS, and entitlement reconciliation. -- Two consecutive privacy-safe production journal snapshots show completed - state only, no unfinished age or failure events, and available pool capacity. - -All rollout, rollback, observation, documentation, and external-beta invitation -criteria are satisfied. Invitations were deliberately left to the normal -human-owned product process rather than being sent by this engineering task. diff --git a/.ops/tasks/Coordinated local collection runtime simplification.md b/.ops/tasks/Coordinated local collection runtime simplification.md deleted file mode 100644 index 54c2a1f3..00000000 --- a/.ops/tasks/Coordinated local collection runtime simplification.md +++ /dev/null @@ -1,1074 +0,0 @@ ---- -title: Coordinated local collection runtime simplification -status: in_progress -priority: high -owner: codex -tags: - - architecture - - mdbase-rs - - connector - - runtime - - reliability - - performance - - scalability - - cancellation - - caching - - observability -created_at: 2026-08-11T18:24:43+10:00 -updated_at: 2026-08-12T17:44:36+10:00 -type: task ---- - -# Coordinated local collection runtime simplification - -## Outcome - -Make one long-lived mdbase runtime the canonical local execution boundary for -each registered collection, and make Connect a thin authority and scheduling -layer around that runtime. - -The work should improve reliability, latency, bounded memory use, and scaling -to larger and more numerous collections by removing duplicated discovery, -watching, invalidation, and execution machinery. Performance is a consequence -of clearer ownership and less repeated work; it is not a reason to weaken -durability, authorization, or Markdown-as-source-of-truth guarantees. - -The target is not a rewrite. Each phase should introduce one stronger primitive, -move its current callers onto it, and delete the mechanism it replaces. Avoid a -permanent compatibility layer between two local runtime models. - -## Scope and repository map - -| Repository | Responsibility in the target architecture | -| --- | --- | -| `mdbase-rs` | Canonical collection semantics, filesystem transactions and recovery, collection generations, exact change sets, watching external edits, query/cache/index semantics, snapshots, and cooperative operation cancellation. | -| `mdbase-connect` | Application authorization, grant enforcement, admission and fairness, durable application request identity/outcomes, relay and loopback routing, application-facing retry metadata, and lifecycle management of local collection runtimes. | - -Connect must not duplicate mdbase mutation semantics, infer changes by decoding -operation-specific JSON, or ask a second watcher to rediscover a mutation that -mdbase just performed. mdbase must not acquire Connect concepts such as grants, -applications, relay policy, or request retry behavior. - -The existing NATS-based transport remains in place. Transport replacement is -not part of this program. - -## Architectural principles - -1. **One owner per concern.** mdbase owns collection state transitions; - Connect owns authority and application request state transitions. -2. **One runtime per active local collection.** The runtime owns the provider, - external-change watcher, generation, and rebuildable indexes for its - lifetime. -3. **Known changes are applied, not rediscovered.** Successful mdbase mutations - return their exact change set. Watchers exist for external changes and - recovery reconciliation. -4. **Canonical data and derived state remain distinct.** Markdown and durable - mdbase transaction evidence are authoritative. Query caches, link indexes, - and compiled plans are bounded, disposable, and rebuildable. -5. **Prepare can be cancelled; commit must finish.** Expensive read work and - mutation preparation cooperate with deadlines. Once a durable filesystem - commit begins, it completes or recovers independently of whether the caller - is still waiting. -6. **Bounded work is explicit.** Queueing, concurrency, cache residency, and - snapshot lifetime have deliberate limits. Increasing timeouts or queue sizes - is not a substitute for removing redundant work. -7. **Simple failure states beat clever concurrency.** Prefer ordered mutation - execution, immutable read generations, and idempotent recovery over broad - shared-state synchronization. -8. **Every new abstraction pays for itself by deletion.** A collection runtime, - execution outcome, or mutation plan is complete only when its duplicated - watcher, inference, shadow-copy, or task-spawning path is removed. - -## Target architecture - -```text -application - | - v -Connect SDK request coordinator - | - v -Connect transport + grant enforcement - | - v -per-collection executor - - ordered mutation lane - - bounded foreground read lane - - bounded background lane - | - v -mdbase FilesystemRuntime - - provider and operation gate - - collection generation - - transaction/recovery engine - - external-change watcher - - rebuildable query/link indexes - | - v -canonical Markdown collection - -mdbase ExecutionOutcome - | - +--> Connect durable request receipt - +--> Connect change journal / notifications - +--> mdbase incremental cache update -``` - -There should be no second Connect-owned collection watcher in the completed -architecture. There should also be no unbounded fan-out from an incoming -request to the general Tokio blocking pool. - -## Existing foundations - -Preserve and build on the work already shipped: - -- mdbase query memory is bounded for metadata pages and long-running queries - accept cooperative cancellation (`mdbase-rs` tree pinned by Connect at - `ca71aeb`). -- Connect has bounded admission, per-grant fairness, absolute deadlines, - cooperative query cancellation, and durable mutation outcomes across client - deadlines (`f5b0ff7b` and `b1ba9e67`). -- Connect has payload-free daemon profiling, admission/RSS observations, and a - long-lived read/query soak (`29eb42d` and `8ff69e8`). -- mdbase has crash-recoverable collection transactions and a caller-owned - staged mutation API. - -This program should consolidate those foundations rather than introduce a -parallel execution framework. - -## Phase 0 — freeze boundaries and measure the current path - -### Contract work - -1. Write a short cross-repository ADR defining: - - `CollectionGeneration`; - - `ExecutionOutcome` and `ChangeSet`; - - mdbase filesystem transaction identity versus Connect application request - identity; - - mutation prepare/commit/cancel semantics; - - known mutation changes versus external watcher changes; - - generation-pinned reads and expiration; and - - which state is authoritative, durable support state, or rebuildable cache. -2. Draft the Rust API before changing Connect. The API should be useful to any - mdbase host and contain no Connect-specific types. -3. Decide whether a commit identifier must survive process restart. If so, it - is an opaque mdbase transaction identifier and must not contain paths or - record content. -4. Define one normalized external-change shape that can represent create, - update, delete, rename, and collection-control changes without losing the - revisions mdbase needs for cache invalidation. - -### Baseline observations - -Record the current release-mode behavior on at least 2,000 and 10,000 synthetic -records: - -- create, update, delete, rename, and reference-updating rename; -- 200-row library query and sequential pagination; -- concurrent and mixed daemon workloads; -- cache refresh and rebuild phase timings; -- mutation execution versus watcher synchronization time; -- RSS through a 1,600-request soak; and -- two active collections under concurrent load. - -Performance results remain informational. Correctness and boundedness are -release requirements; fixed latency or RSS thresholds are not. - -### Exit evidence - -- The ADR is reviewed in both repositories. -- The proposed mdbase API can express every current canonical mutation and - watcher event without Connect decoding operation-specific result fields. -- Baseline reports are retained through the existing performance observation - machinery. -- No implementation phase begins with an unresolved ownership question. - -## Phase 1 — return canonical mdbase execution outcomes - -Implement provider-level types along these lines, with final names chosen in -the mdbase-rs API review: - -```rust -struct ExecutionOutcome { - result: OperationResult, - generation: CollectionGeneration, - changes: ChangeSet, - commit_id: Option, -} - -enum ChangeSet { - None, - Records(Vec), - Resources(Vec), - CollectionWide, -} -``` - -### mdbase-rs - -1. Make operation execution return the generation observed or produced by the - operation and the exact canonical affected resources. -2. Keep `OperationResult` as the portable semantic result. Keep generation, - commit, and local invalidation metadata in the provider/runtime outcome - rather than automatically expanding the public wire schema. -3. Replace host-side calls to `OperationRequest::affected_paths()` with the - engine-produced change set. Retain the current helper only as an internal - construction aid until every operation emits exact changes. -4. Cover single mutations, atomic batches, reference-updating rename, type and - view-source changes, dry runs, invalid operations, and no-op results. -5. Prove that change sets never report paths outside the collection and that - invalid operations cannot advance generation. - -### mdbase-connect preparation - -1. Add an adapter that consumes `ExecutionOutcome` while leaving the existing - runtime active. -2. Record the outcome's generation and opaque commit identity in privacy-safe - diagnostics where useful, but do not persist paths or content in the - application request journal. -3. Add conformance fixtures proving direct mdbase execution and Connect's - adapter agree on result and invalidation semantics. - -### Deletion requirement - -When Connect cuts over in Phase 4, delete its operation-specific invalidation -inference. Do not retain both outcome consumption and JSON-result inference. - -## Phase 2 — make ordinary mutations sparse - -The canonical single-record mutation path currently creates a collection-wide -shadow working copy before committing a small write. Replace this gradually -with explicit mutation plans. - -### mdbase-rs - -1. Introduce an internal `MutationPlan` containing exact revision - preconditions, planned writes/deletes/renames, and the resulting `ChangeSet`. -2. Refactor create, update, and delete first. They should validate and render - only their affected record, then use the existing crash-recoverable - transaction layer to commit the exact write set. -3. Refactor rename after the reverse-link candidate work in Phase 3 is - available. A rename plan includes the source, destination, and only records - whose references actually change. -4. Keep complete atomic batch semantics. Replace the full copied collection - with a sparse copy-on-write overlay only after single-operation planning is - stable; do not combine the two behavioral changes in one patch. -5. Make mutation preparation cooperatively cancellable. Once the transaction - journal enters commit, finish or recover the commit even if the host's - cancellation token is set. -6. Keep mdbase transaction recovery and Connect request recovery separate: - mdbase proves the filesystem write set; Connect proves what happened to one - authorized application request. - -### Transaction simplification investigation - -The transaction journal currently checkpoints progress after each applied -entry. Evaluate an idempotent recovery model based on each entry's exact before -and after revision: - -- current equals before: apply the planned entry; -- current equals after: the entry is already applied; -- current equals neither: stop with a typed interference/manual-recovery - outcome. - -Adopt the simpler prepared/committing/committed journal only if crash injection -proves it at every entry boundary on Linux, macOS, and Windows. Otherwise keep -the current journal; sparse write sets still deliver most of the architectural -and performance benefit. - -### Exit evidence - -- Single-record create/update/delete cost no longer grows with the total number - of collection records. -- Existing revision, validation, crash recovery, and atomicity semantics remain - unchanged. -- A cancellation during preparation performs no write; cancellation after - commit begins cannot strand an incomplete unowned transaction. -- The old full-shadow path is removed for migrated operations. - -## Phase 3 — make cache and link maintenance incremental - -### mdbase-rs runtime and cache - -1. Add `apply_changes(ChangeSet)` for known successful mutations and - `apply_external_changes(...)` for watcher-originated changes. -2. Re-index only affected records/resources and advance the cache generation in - the same rebuildable cache transaction. -3. Stop walking every collection record to prove cache freshness before every - query. Full scans remain startup/recovery/reconciliation tools. -4. Replace per-operation control-folder stamp walks with runtime generation - invalidation. Retain an explicit full control-resource reconciliation path. -5. Treat cache corruption or a generation mismatch as a typed reason to - rebuild, never as permission to change or delete canonical Markdown. - -### Reverse-link candidates - -1. Extend the reconstructible link index with canonical resolved target path or - record identity, source revision, location, field, and raw target. -2. Use that index to select candidate sources for backlinks and - reference-updating rename. -3. Re-parse and semantically verify each candidate before changing it. The - index is an accelerator, not the final authority. -4. Fall back to a full scan when the index is absent, stale, or cannot represent - an ambiguous link safely. - -### Exit evidence - -- A warm unchanged query does not perform a collection-wide filesystem scan. -- A one-record external edit re-indexes that record and produces the same query - result as a clean full rebuild. -- A known mutation updates the cache from its `ChangeSet` without waiting for a - watcher round trip. -- Reference-updating rename work scales primarily with actual candidate - references rather than total records. -- Rebuilding the cache from Markdown produces the same generation-visible - results as incremental maintenance. - -## Phase 4 — cut Connect over to one per-collection executor - -### mdbase-connect - -1. Replace registry-owned provider handles plus the separate - `CollectionWatchService` with one runtime handle per active local collection. -2. Put a small `CollectionExecutor` around that runtime. It owns: - - one ordered mutation lane; - - a bounded foreground read lane; - - a bounded background/sync lane; - - deadline-aware queued work; and - - lifecycle state for open, idle, unavailable, rebuilding, and closing. -3. Keep authorization and admission before execution. Admission protects the - daemon globally; the executor provides collection-local ordering, fairness, - and lifecycle ownership. -4. Stop spawning an independent general blocking-pool job for every admitted - request. Use stable bounded workers owned by the executor and preserve - responsiveness across collections. -5. On a successful mutation, consume `ExecutionOutcome` directly: - - advance the Connect mutation journal state; - - persist the application-facing final receipt; - - append the local change journal entry; - - deliver runtime notifications; and - - return the response. -6. Feed normalized external changes from the mdbase runtime through the same - change-journal and notification path, with generation/revision deduplication. -7. Preserve the existing distinction between reserved mutation capacity, - foreground reads, background work, and grant fairness. Do not replace known - admission guarantees with a generic actor mailbox. - -### Deletion requirement - -Delete: - -- Connect's separate per-collection watcher supervisor and workers; -- synchronous post-mutation watcher rescans; -- Connect's operation-result invalidation inference; and -- redundant provider lifecycle paths in `CollectionRegistry`. - -Do not declare Phase 4 complete while both old and new runtime paths can serve -normal authority requests. - -### Exit evidence - -- Known mutations have no watcher-synchronization phase in their request - latency. -- External editor changes still produce durable ordered Connect change events. -- One slow collection cannot occupy every local execution worker. -- Active and queued counts return to zero after cancellation, timeout, runtime - close, and daemon shutdown. -- Restart tests recover both the mdbase filesystem transaction and the Connect - application request outcome without duplicating the logical mutation. - -## Phase 5 — coordinate application request pressure - -The daemon should remain bounded, but applications should avoid creating work -that is already obsolete before it reaches the collection executor. - -### mdbase-connect SDK - -1. Add one request coordinator per selected connection, not one per UI - component. -2. Bound foreground concurrency to the authority's advertised/recommended - capacity. -3. Coalesce identical in-flight reads and queries where request identity and - result semantics permit it. -4. Support latest-wins cancellation for explicitly replaceable query families, - such as library search/filter refreshes. Do not apply latest-wins semantics - implicitly to mutations. -5. Keep mutations in an ordered request lane and preserve their durable pending - recovery handles. -6. Return structured retry timing for genuine overload and make retries consume - the caller's original overall request budget. -7. Distinguish privacy-safe metrics for queue-full rejection, queue deadline, - execution deadline, cancellation, and policy synchronization delay. - -### Exit evidence - -- Rapidly changing a query does not leave stale requests occupying all - per-grant capacity. -- The coordinator has one state owner and does not introduce a second session - or cache model. -- Existing applications remain free to issue independent queries when they are - genuinely concurrent. -- Durable mutations are never coalesced, silently cancelled, or retried under a - new request identity. - -## Phase 6 — generation-pinned pagination and bounded runtime residency - -Do this after the simpler runtime and incremental cache are established. - -### mdbase-rs - -1. Add optional generation-pinned query pagination with an opaque cursor, - deterministic tie-breaker, and typed expiration when the requested - generation is no longer available. -2. Keep offset/limit compatibility only where still required; migrate - controlled consumers to cursor paging and then decide whether the old path - should remain public. -3. Cache compiled query or saved-view plans only when profiling demonstrates a - meaningful repeated compilation cost. Key plans by canonical query/view - digest and every semantic generation that can invalidate them. - -### mdbase-connect - -1. Add explicit per-runtime accounting for parsed/indexed state, query cache, - link index, and active snapshots using mdbase-provided measurements where - possible. -2. Bound the number of resident collection runtimes and idle-evict clean, - inactive runtimes through the same lifecycle owner. -3. Reopen an evicted runtime from canonical Markdown and rebuildable support - state without changing collection identity or application grants. -4. Exercise concurrent workloads across more collections than the residency - limit so eviction, reopening, and fairness are observable. - -### Exit evidence - -- Pagination does not duplicate or skip records within a retained generation. -- Expired generations fail explicitly instead of silently mixing snapshots. -- Long-lived daemon memory remains bounded as collections are opened, idled, - evicted, and reopened. -- Plan caching is omitted if measurements do not justify its complexity. - -## Verification strategy - -### mdbase-rs correctness - -- Change-set conformance for every canonical mutator and dry-run/error path. -- Sparse mutation equivalence against the existing shadow implementation while - the migration is under test. -- Crash injection before prepare, after durable prepare, at each commit entry, - after commit, and during cleanup. -- External modification at every revision/precondition boundary. -- Incremental cache versus clean-rebuild differential tests. -- Reverse-link candidate versus full-scan differential tests, including - ambiguous names, relative links, IDs, embeds, and frontmatter links. -- Cancellation at bounded intervals through query, view, snapshot, link-graph, - rename discovery, and batch preparation. - -### mdbase-connect correctness - -- Direct loopback and relay execution against the same runtime semantics. -- Process termination at every mdbase-commit/Connect-receipt boundary followed - by exact request replay. -- Watcher failure/restart and external edit ordering. -- Disk-full and cache-corruption recovery without canonical content loss. -- Queue deadline and execution deadline while other grants and collections - continue making progress. -- Runtime eviction/reopen with stable collection identity and grant behavior. -- Daemon shutdown with no active or queued operation left unowned. - -### Performance visibility - -Extend the existing non-gating observations rather than adding pass/fail -budgets: - -- mutation preparation, commit, cache application, journal/receipt completion, - and total latency; -- executor queue time by work class and collection; -- cache full scans, incremental updates, rebuilds, and hit rate; -- rename candidate count versus total records; -- active/idle runtime count and estimated resident bytes; -- single- and multi-collection daemon soak checkpoints; and -- library page, rapid replacement, pagination, mutation, and mixed workloads. - -Record source commits from both repositories in each observation so results -remain attributable across a coordinated pin change. - -## Delivery and review order - -1. Land the ADR and API types in mdbase-rs without changing behavior. -2. Land exact `ExecutionOutcome` production and conformance tests in mdbase-rs. -3. Land sparse create/update/delete as reviewable mdbase-rs changes. -4. Land incremental cache application and reverse-link candidates separately. -5. Pin that reviewed mdbase-rs revision in a Connect branch. -6. Add the Connect executor and outcome adapter, then cut normal local authority - traffic over in one reviewed change series. -7. Delete the old Connect watcher/inference/provider lifecycle in the same - release train. -8. Add SDK request coordination only after the executor's observable capacity - and cancellation behavior are stable. -9. Add generation-pinned pagination and runtime residency bounds as follow-up - slices, retaining only complexity justified by the recorded workloads. -10. Promote one coordinated candidate through staging, crash/recovery suites, - consumer acceptance, and the non-gating performance observations. - -Correctness changes, performance changes, and large module moves should remain -separate commits where possible. Do not combine this architecture program with -unrelated view-model, import, or transport redesign. - -## Acceptance criteria - -1. Each active local collection has one mdbase runtime and one Connect - executor; Connect has no second collection watcher. -2. Connect consumes an exact mdbase `ExecutionOutcome` and no longer infers - mutation invalidation from operation-specific JSON. -3. Create, update, and delete use sparse mutation plans rather than a - collection-wide shadow copy. -4. Known mutations update derived indexes directly and do not wait for watcher - rediscovery. -5. External edits flow through the same generation-aware change path and remain - recoverable after watcher or daemon restart. -6. Warm queries avoid a collection-wide freshness scan; full reconciliation is - explicit and recoverable. -7. Reference-updating rename uses a verified reverse-link candidate set with a - safe full-scan fallback. -8. Cancellation can stop expensive preparation and reads, while a begun commit - always finishes or recovers to a typed durable state. -9. Per-collection execution and SDK coordination prevent stale read/query - bursts from starving mutations or unrelated collections. -10. Cursor pagination is stable within a retained generation and fails - explicitly when that generation expires. -11. Runtime and cache residency remain bounded as multiple collections cycle - through active and idle states. -12. NATS remains the transport architecture for this program. -13. Performance history shows the effects without introducing latency or memory - release gates. -14. The completed implementation deletes the duplicate watcher, invalidation, - shadow-copy, and general task-fan-out paths it replaces. - -## Risks and controls - -| Risk | Control | -| --- | --- | -| Cross-repository contract drift | Pin exact mdbase revisions; run shared outcome/change-set fixtures in both repositories; record both commits in profiling output. | -| Cache treated as authority | Keep caches reconstructible; differential-test incremental state against clean rebuilds; fall back safely on generation mismatch. | -| Mutation optimization weakens durability | Preserve exact revision preconditions and crash recovery; migrate one operation class at a time; require crash injection before deleting the shadow path. | -| A collection actor becomes a new monolith | Keep the executor limited to scheduling and lifecycle; collection semantics stay in mdbase; retain separate bounded lanes rather than one opaque mailbox. | -| Dual runtime paths linger | Put deletion requirements and exit evidence in every phase; do not ship a permanent feature flag or compatibility facade. | -| Generation semantics become a distributed consensus mechanism | Keep generations local and opaque; they order one authority's snapshots and changes, not multiple authorities globally. | -| Performance work expands without evidence | Use existing phase timings and soak history; omit compiled plans or transaction-journal changes when measurements do not justify them. | - -## Implementation evidence — 2026-08-11 - -The user approved this program for implementation while staging acceptance -continues. Production remains on beta55. Beta64 is the frozen compatibility and -performance baseline; NATS replacement and a production rollout remain out of -scope for this phase. - -Phase 0 is complete and published for review: - -- provider contract, executable fixtures, and reproducible baseline: - `callumalpass/mdbase-rs#48` at `2454cb2`; -- Connect execution, persistence, and cutover ADR: - `mdbase-dev/mdbase-connect#250` at `719ab9c` before this registry update; -- the contract passed six independent red-team passes with no remaining P0/P1 - contradiction. - -The accepted contract fixes the implementation boundaries before behavior changes: - -- host claim, application request, commit, event, and collection generation - identities are separate and crash-resolvable; -- all blocking provider/feed work receives one deadline-bearing operation context; -- preparation is cancellable, a pre-commit rejection is a durable final outcome, - and only the fsynced commit transition transfers settlement ownership to mdbase; -- exact normalized changes are paged and digest-bound, including explicit body - change metadata; -- mdbase owns a durable, singleton, fenced pull/ack feed and the only application - change writer after an explicit per-collection cutover; -- transfer intent/receipt/ack handling is crash-idempotent, and baseline feed - initialization sets every cursor and watermark explicitly; -- Connect does not retain a dual writer or permanent compatibility runtime after - a collection cuts over. - -The current-main release baseline used mdbase source `a363419` and Connect -`d4138fb9`. Both 2,000- and 10,000-record workloads passed correctness, 160 mixed -requests per concurrency scenario, and 1,600 soak requests with zero errors. The -10,000-record provider mutation mean was 4,125.60 ms and same-collection mixed -throughput was 2.53 requests/s, while between-request RSS/PSS stayed flat at roughly -76,652/74,012 KiB. This points at repeated coordination/rebuild work as the primary -provider-runtime cost in the harness rather than an unbounded provider-memory leak. - -Beta64 staging heavy testing also found a separate hosted-mirror liveness problem: -an HTTP sync transport that stopped making progress could hold the mirror RAII guard -indefinitely, leaving `syncing=true` and manual recovery at `mirror_busy` until daemon -restart. Draft `mdbase-dev/mdbase-connect#249` at `5dec13c` adds bounded connect/read -and whole-sync deadlines, typed timeout outcomes, resumable journal settlement, and -abort/timeout cleanup tests. It passed the complete Rust workspace, formatting, -clippy, TypeScript typecheck/tests, and local daemon/relay E2E path. It still requires -a new prerelease and deployed hosted/binary/restart staging acceptance before merge -to any production rollout. - -## Implementation evidence — 2026-08-12 - -The coordinated provider runtime is merged in `callumalpass/mdbase-rs#49` at -`8c12700ca395f9ca1516ec9ff9cb19a062efed3e`. Its complete CI matrix is green on -Linux, macOS, and Windows, including the portable black-box runtime scenarios, -package publication, dependency policy, formatting/clippy/docs/features, and -live PostgreSQL. Local verification also passed the full workspace/conformance -suite and ten consecutive parallel 117-test runtime-library runs after making -the crash/deadline controls transaction-scoped. - -The provider implementation now supplies: - -- generation-bound exact execution outcomes for canonical records and control - resources; -- durable prepared/committing/committed/rejected/cancelled/manual states with - opaque host claims and crash recovery at every commit boundary; -- a durable fenced pull/ack change feed for known and external changes; -- sparse create/update/delete paths, indexed uniqueness and reverse links, - incremental generation-bound cache updates, and demand-loaded bodies; -- generation-pinned opaque query cursors with explicit release/expiry; and -- privacy-safe retained-runtime measurements. - -The Connect beta65 staging candidate is `mdbase-dev/mdbase-connect#251`. It pins -the merged provider revision and cuts normal v0.3 local authority traffic over -to one `FilesystemRuntime` and `CollectionExecutor` per resident collection. -The executor retains separate bounded mutation, foreground, and background -lanes. Connect consumes provider outcomes and feed events directly, persists a -change receipt before feed acknowledgement, and no longer owns a second -collection watcher or operation-result invalidation inference path. Runtime -residency is bounded to eight and tested across eleven collections, including -eviction, external Markdown edits, and identity-preserving reopen. - -The SDK candidate adds one request coordinator per selected connection, -independent ordered mutation capacity, bounded foreground pressure, safe read -coalescing, explicit latest-wins families, cursor pagination, early-release -cleanup, and legacy snapshot/offset fallback. The architecture gate remains at -1,000 lines with the new runtime, residency, mutation, and pagination concerns -split into focused modules. - -Local candidate evidence is green: 136 core tests, 68 daemon tests, the complete -Rust workspace, formatting and clippy with warnings denied, every JavaScript -workspace suite (including 197 SDK and 270 editor tests), typechecking, packed -public API boundaries, generated problem/operation catalogs, release version -and readiness checks, and the architecture gate. The beta65 PR cross-platform -and release matrix is still running. Production remains untouched on beta55. - -## Staging evidence — beta66 and beta67 - -The coordinated runtime implementation is now merged. The provider landed in -`callumalpass/mdbase-rs#50` at merge `818866705dcc4b6dcfd3bbc1ba63f83fdaec406f`. -Connect landed in `mdbase-dev/mdbase-connect#253` at merge -`52ce558305d8280c3ec96555c0a64a17ffbfd46e`. Beta66 was published and promoted -only to the staging Connect server and hosted provider. The staging server, -provider, MCP, synthetic operation, OAuth write, manifest, R2 CORS, and 120-second -soak checks passed with 66 checks and zero failures. Production remains on -beta55. - -Live deployed testing found that the beta66 SDK's automatic first-page cursor -probe exposed one compatibility gap with hosted providers that still reject the -`pagination` field. The shared SDK fix landed in -`mdbase-dev/mdbase-connect#256` at merge -`425275f269a7b3a8d8c5041c089077cdb0465163` and was published as beta67. An -automatic first-page cursor probe now retries exactly once without pagination -when the provider returns `operation_invalid`; explicit cursor requests never -downgrade. The npm publication and canonical Editor deployment workflows passed. -The staging server and provider deliberately remain on beta66 so the deployed -consumer test proves the compatibility boundary rather than hiding it with a -coordinated backend upgrade. - -Controlled consumer artifacts were packed from that exact beta67 merge and -passed a coordinated revision, declaration, and SHA-512 audit. TaskNotes commit -`d510da8` is deployed at `https://staging.tasknotes-app.pages.dev/`; Pickle commit -`02a1994` is deployed at `https://staging.pickle-9zb.pages.dev/`; Reader commit -`044e5f2` is deployed at `https://mdbase-reader.pages.dev/`; canonical Editor was -deployed by the beta67 release workflow. TaskNotes, Pickle, Reader, Workouts, and -standalone Editor passed their applicable formatting, type, unit, contract, -build, desktop/mobile browser, Android, notification, push, restart, and package -boundary suites. Workouts has no `deploy:dev` script, Reader has no Git remote, -and the standalone Editor repository is archived; those constraints are recorded -without weakening the canonical Editor deployment. - -The beta67 desktop release completed successfully across Linux, Windows, macOS -Apple Silicon, and macOS Intel. The TaskNotes, Pickle, and Workouts beta67 draft -PRs also completed every configured build, smoke, and test check successfully; -release and deploy jobs that are intentionally disabled for draft PRs were -skipped. - -An independent Luna browser pass used `staging-test@mdbase.dev` and a temporary -hosted collection. Pickle and Editor each issued one cursor request rejected by -the beta66 provider and exactly one successful offset retry, with no loop or UI -pagination error. TaskNotes completed real create, update, and delete operations; -Reader completed OAuth setup and a valid hosted query. All four layouts passed at -390 by 844 pixels. The temporary collection and its grants were permanently -removed after the test. - -The desktop migration reused the same persistent staging profile across beta65, -beta66, and beta67. Before beta67 startup, the stopped beta66 profile was copied -and verified byte-for-byte: 982 files, 1,196,000,004 bytes, and 31 SQLite -databases with successful `integrity_check`. Beta67 reopened both original -collection identities, including `~/testvault/mdbase-reader`. Six full Reader -queries returned 1,507 of 1,507 records with stable metadata and body digests; -metadata took 832–860 ms and bodies 926–935 ms. Daemon RSS stopped growing and -declined from about 283.5 MiB at 60–70 seconds idle to 282.0 MiB at 120 seconds. -This remains consistent with a bounded allocator/runtime high-water mark, not an -ongoing per-query leak. - -An independent Luna pass reproduced the 1,507-record beta66 metadata and body -digests through both direct and daemon-routed paths. One-, ten-, and -100-millisecond process deadlines stopped direct and daemon reads within 3, 14, -and 104 milliseconds respectively, with no query child left behind. In an -isolated beta67 profile, ten synthetic collections all registered and queried; -runtime diagnostics reported the intended capacity of eight with eight idle -residents. An external Markdown edit became visible on the next query, and a -daemon restart preserved the exact sorted-ID digest for all ten registrations. -The agent removed every synthetic registration and the isolated profile, leaving -the shared registry at its original two collections. That isolated pass did not -drive cursor lifecycle, so cursor behavior was verified separately rather than -inferred from it. - -That cursor lifecycle was then exercised through the daemon operation surface -on the migrated Reader collection. A generation-pinned first page opened -successfully; explicit release returned `released: true`; reuse failed with -`generation_expired`; and a fresh cursor left idle for 31 seconds also failed -with `generation_expired`. No vault content was changed. - -The exact beta67 tag also passed the isolated multi-instance PostgreSQL/NATS relay -suite: oversized framed responses, opaque file frames above NATS `max_payload`, -200 concurrent cross-instance requests with retryable admission, fencing, broker -outage and recovery, and post-dispatch durable mutation timeout followed by -same-identity result recovery. This is strong protocol evidence but does not -replace the remaining authenticated staging relay matrix. - -The dedicated hosted-file PostgreSQL/S3 suite and adversarial file lifecycle -suite also passed on the exact beta67 tree, covering upload and download -integrity, commit-versus-abort races, late-copy compensation, transfer expiry, -bounded recovery, and cleanup. - -## Staging evidence — beta68 - -Beta68 is the coordinated staging candidate. The Connect implementation and -SDK fixes landed in `mdbase-dev/mdbase-connect#259` at merge -`86085d2335a8cd46fe21ba178815aeaea7479e90`; the annotated -`v0.1.0-beta.68` tag points at that exact merge. Npm publication, server CI, and -immutable signed image publication all passed. The candidate keeps the -beta66 coordinated runtime and beta67 provider-paging negotiation, and adds -single-flight desktop snapshot refreshes in both the renderer and main -process. Only `credential_store_unavailable` becomes the typed offline -snapshot with a bounded retry window; unrelated failures propagate and every -settled request releases capacity. The same merge also fixes the canonical -Editor staging OAuth callback contract and tests both accepted redirect forms. - -Published staging images are pinned by digest: - -- relay (unchanged): - `sha256:21b5cce2a4692748358e8b0ab85a91f0d27ddd8c863760968928fe9c0a778ea0`; -- hosted provider: - `sha256:7f63d3ff16a1ad09e626a0daf1ded022a9aa29104eb1429c0a765bdecae58ece`; -- Connect server: - `sha256:f46752c2db2aa0a70dd639fb5c31b5924418651f754505cc660de6d76a956968`; - and -- MCP: - `sha256:73f1b45b65cfb2cfe509cd4a00a7780889fbf89e2e68e1ff360a1c0dceac2c06`. - -The Linux, Windows, macOS Apple Silicon, and macOS Intel desktop release jobs -all passed, as did the release publication job. The public update manifest is -`0.1.0-beta.68` with rollout percentage `0`, so production users are not -offered the staged desktop candidate. Production itself remains exactly on the -beta55 tag commit `673e9e1ddab2a2a50a7cdb1506fd2a15ac4b61ef`. - -Private operations PR `mdbase-dev/mdbase-cloud-ops#141` landed at -`9089d2718cc3715f98bccb05873315948c94dc11`. Its candidate manifest pins the -exact TaskNotes, Editor, Workouts, and Pickle commits. Deployment verified image -signatures and attestations, captured rollback state, confirmed there was no -database migration delta, skipped the unchanged relay, and promoted the hosted -provider, Connect server, and MCP sequentially. Exact digest checks, -health/readiness, entitlement reconciliation, synthetic operations, OAuth -device and semantic writes, all four candidate manifests, and R2 CORS passed. -The separate 15-minute post-deploy soak also passed on that exact ops commit. -Staging reports Connect and MCP revision `86085d2335a8cd46fe21ba178815aeaea7479e90`; -the hosted provider reports `0.1.0-beta.68` with notification recovery healthy -and zero consecutive failures. - -The first deployment invocation was interrupted while the GitHub CLI stalled -during preflight, before any Render service changed. Its report incorrectly -labelled the interrupted exit-130 preflight as successful. The guarded rerun -used the same assertions and completed normally. Fixing that report -classification is an operations follow-up; it did not weaken or bypass a -deployment check. - -The clean compatibility report shows only operation transport v3 in sampled -hosted and relay traffic, but it still found one recent active pre-beta57 -connector plus legacy or unknown grant bindings and unbound hosted replicas. -The observation window is incomplete because telemetry began on August 10. -Compatibility removal is therefore explicitly not ready: keep the narrow -beta55-era bridge and its telemetry until the remaining client has upgraded or -aged out. This is a versioned boundary, not a second local runtime model. - -### Coordinated consumers - -All controlled SDK artifacts were packed from exact beta68 merge -`86085d2335a8cd46fe21ba178815aeaea7479e90` and audited for declared revision, -size, and SHA-512 consistency. - -- TaskNotes `b0667ce12cf19d6b1f089e8014153e4797eca7f3` passed 397 tests, - coverage/layer gates, 4,983 conformance cases, build, 20 browser tests, and - Android build/push/restart smoke. `pnpm deploy:dev` published - `https://staging.tasknotes-app.pages.dev/`. -- Pickle `2a3271b53573025e078a1b1bca83462c462d5261` passed 21 tests, - format/lint/type/build, eight browser tests, and Android - build/push/back/restart smoke. `pnpm deploy:dev` published - `https://staging.pickle-9zb.pages.dev/`. -- Workouts `9ffc67c33c70cc9f66222de8d79ea88c475514f6` passed typecheck, - 24 tests, build, and ten mobile browser tests. The repository has no - `deploy:dev` command; its PR deploy job is intentionally skipped. -- Reader `dbba0234856e8b13eeb63f4dd33122217d467e1c` passed its complete - format/lint/architecture/spec/type, workspace-test, and build suites. - `pnpm deploy:dev` published - `https://mdbase-reader.pages.dev/` with that revision. Reader has no Git - remote, so the exact local commit is the source record. -- Canonical Editor deployment passed from the beta68 merge. Its staging - manifest contains both the plain staging callback and the query-bearing - callback for `connect-staging.mdbase.dev`. - -The TaskNotes, Pickle, and Workouts draft PR checks are green on those exact -heads. The PRs remain draft and unmerged; no production consumer was changed. - -An independent Luna/Playwright pass used `staging-test@mdbase.dev` and a -disposable hosted collection against the deployed beta68 services. Editor -completed create/query/read/update/delete through the corrected OAuth callback. -TaskNotes completed real create/read/list/update/delete. Pickle completed its -available assess/change/describe/query paths; it exposes no request-creation UI. -Reader uploaded, created, queried, downloaded, and deleted a temporary source. -All four applications rendered and operated at 390 by 844 pixels. TaskNotes, -Pickle, and Reader had no console errors; Editor emitted only the known -nonfunctional Cloudflare Insights CSP block. Deleting the collection returned -all four applications to their expected reconnect or authorization-lost state. -The collection and grants were permanently removed. - -### Migration, large collection, cancellation, and memory - -The persistent desktop staging profile was migrated without replacement. Its -pre-beta67 backup remains byte-identical at 982 files and 1,196,000,004 bytes; -all 31 SQLite integrity checks passed. The same beta68 daemon preserved the -Reader and relay-fixture collection identities and remained connected through -both server cutovers, reconnecting in two to three seconds. - -The Reader vault changed externally during acceptance from 1,507 to 1,508 -records. At the final observation it contained 1,512 Markdown files, 2,851 -files overall, and 10,225,385,614 bytes. No acceptance test mutated it. Direct -filesystem and daemon-routed queries agreed exactly at the current generation: -metadata SHA-256 -`5ad5824892fbb93e1fe82f6c5b99beee89999679925766d1de0e82d924f0a58d` -and body SHA-256 -`16a496851aca7b344911a9dd238bd48607b8b4b3dbfde75aa25a9b1063a4d136`. -Direct metadata/body queries took 762/801 ms; daemon-routed queries took -851/943 ms. - -Generation-pinned cursor opening, advance, deterministic same-page reuse, -final-page advance, explicit release, and typed `generation_expired` after -release all passed. Ten 50-millisecond body-query cancellations returned in -52–54 milliseconds. Eight overlapping full-body reads and ten foreground -metadata reads all returned the exact current digests. The first foreground -read under maximum overlap took 5.427 seconds and the remaining nine took -833–869 milliseconds, with no stderr failure. - -The shared daemon reached an allocator/runtime high-water mark near 873.6 MiB -after the accumulated migration, heavy binary, and Reader workloads. Across a -controlled three-minute post-load sample it stayed within 873,328–873,576 KiB -RSS and 860,554–860,833 KiB PSS, with 17 threads, 94–95 file descriptors, and -no child processes. This remains evidence of a bounded retained high-water -mark, not monotonic per-request growth; it is intentionally an observation and -not a release threshold. - -The workstation login keyring later became locked while the already-running -daemon retained its relay credential in memory. Relay reconnect and ordinary -queries continued, but new mirror secret access returned the typed -`credential_store_unavailable` state. Restarting the exact beta68 desktop on -the same profile completed within the bounded bootstrap deadline and entered -offline local-control mode instead of hanging. An isolated, paired staging-test -profile using the repository's explicit test-only secret backend was then used -for the remaining relay acceptance. No credential was printed or copied into -the persistent profile. Recovery after an operator unlock remains an -operational ergonomics follow-up, not a reason to weaken system credential -storage. - -### Deployed relay and binary acceptance - -The final independent Luna pass paired a disposable beta68 connector and local -collection to `staging-test@mdbase.dev`, then authorized the deployed staging -Editor. Direct access was disabled and the SDK reported `relay` for every -operation, so this exercised the real staging Connect/NATS/daemon route rather -than loopback. Deterministic non-Markdown objects round-tripped with exact -SHA-256 values: - -| Size | SHA-256 | Upload | Download and hash | -| --- | --- | ---: | ---: | -| 1 MiB | `06b7bbfb7824aa03382051691630eb26de85102d1b08a81e907ec0744cd8a286` | 2.742 s | exact match | -| 8 MiB | `0ff4d6c068be24637e84ea9f481c3c29f7afcdef1e06e1f40a68e5de85dcbb5b` | 4.762 s | exact match | -| 128 MiB | `e6c6c52c24cfd829d5e3c78668fcd4fa00c4232f9edf162279308f936b06b148` | 51.025 s | 45.188 s, exact match | - -During the 128 MiB upload, 20 foreground relay queries all succeeded in -245–520 milliseconds. Aborting a 128 MiB relay download returned the typed -`operation_cancelled` result after 3.200 seconds, and an immediate relay query -succeeded in 315 milliseconds. This complements the ten 50-millisecond local -read cancellations and the exact post-dispatch durable mutation timeout/replay -suite; no mutation was treated as `not_sent` after durable dispatch. - -The isolated daemon rose from 84,396 KiB RSS/58,657 KiB PSS to a transfer peak -of 100,928/72,017 KiB. After cleanup it stayed at 100,324 KiB RSS and -74,584–74,585 KiB PSS across the final three-minute window, with 9–10 threads, -25 file descriptors, three sockets, and no child process. The grant was -revoked, the collection was unregistered, all binary and collection files were -removed, and final collection, grant, and pending-authorization counts were -zero. Hosted collection and mirror behavior was already covered separately by -the beta68 deployed application lifecycle and the exact hosted-file and -adversarial suites; this pass intentionally concentrated on the real relay -binary path. - -## Production promotion — beta68 - -The reviewed beta68 candidate was promoted to production on August 12. Before -promotion, operations PR `mdbase-dev/mdbase-cloud-ops#142` fixed interrupted -deployment reporting and landed at -`15bd83c09e7f398a69119c42626aeee0130c6156`; both hermetic repository checks -passed. Production pin PR `mdbase-dev/mdbase-cloud-ops#143` then changed only -`render/release.env` and the three beta68 image pins in `render.yaml`, landing -at `fb3cb35b8ea5d360bdd5bd18a30371ffd247fe64`. - -Production workflow run `31555494420` completed successfully in 361 seconds. -Its retained artifact `production-deployment-state-31555494420` records release -commit `86085d2335a8cd46fe21ba178815aeaea7479e90`, operations commit -`fb3cb35b8ea5d360bdd5bd18a30371ffd247fe64`, and the exact beta55 rollback -images for provider, Connect, and MCP. Release preflight, exact staging -verification, platform preflight, rollback snapshot and migration safety, -provider/Connect/MCP deployment, entitlement reconciliation, and production -verification all passed. The unchanged relay image was correctly skipped. - -Production now reports Connect and MCP revision -`86085d2335a8cd46fe21ba178815aeaea7479e90`. Hosted provider readiness reports -`0.1.0-beta.68`, recovery `ok`, and zero consecutive notification failures. -Independent production monitor run `31556193214` passed every service health -and readiness boundary and all application-manifest checks. The release -workflow's production verification also passed its OAuth device and semantic -write probes. - -The controlled consumer commits were promoted immediately after backend health -gates passed: - -- TaskNotes PR `callumalpass/tasknotes-app#116` merged at - `7d9345308e5eec07f86243438d4f6b5888c2a4c8`; production deployment and smoke - run `31555874437` passed. -- Pickle PR `callumalpass/pickle-android#25` merged at - `86bdd048e430e12ef5ee9f79ba272c4a8120fb4f`; production run `31555878664` - passed. -- Workouts PR `callumalpass/mdbase-workouts#24` merged at - `c2b1d73c6b4e938b17ac41ace26f64f449284d52`; production run `31555881489` - passed. -- Canonical Editor production run `31555891150` passed from exact beta68 merge - `86085d2335a8cd46fe21ba178815aeaea7479e90`. -- Reader was deployed from local beta68 commit `588d2e6e3f50`; its deployment - now has an explicit production target and serves build marker `588d2e6e3f50` - at `https://mdbase-reader.pages.dev/`. The same changes were consolidated - onto the local Reader `main` branch at `c02fbbf`. - -Every live application manifest declares its production homepage and validates -against `https://connect.mdbase.dev`. The beta68 desktop public update rollout -remains at zero; server and web-application promotion did not opt desktop users -into an automatic update. - -## Beta69 release, deployed acceptance, and production promotion - -Beta69 packages and desktop artifacts were cut from exact Connect commit -`90334b9c4f6de306bdee5b6992a849362d508789`. Annotated tag -`v0.1.0-beta.69` resolves to that commit. NPM publication run `31569806332` -published the public Connect package set with the `next` dist-tag, and desktop -release run `31569806325` produced both macOS architectures, Linux, the -unsigned Windows preview, and the GitHub prerelease. The signed desktop update -manifest verifies with Sigstore, identifies beta69, and is at 100 percent -rollout. - -The immutable beta69 production candidate was: - -- unchanged relay broker - `sha256:21b5cce2a4692748358e8b0ab85a91f0d27ddd8c863760968928fe9c0a778ea0`; -- hosted provider - `sha256:c233855520ab7b4fa0e2a6576bebb29cdfa029eef57ad327cd303aebb3516888`; -- Connect server - `sha256:1f157b98560fe4572b36425cc574e474e3c59469dbd5ec721783e29db0a310b2`; -- MCP - `sha256:bc49fbd42a134601d508186ccab98fcc6c244c7de203aae793c2eb1ca0e8d7d5`. - -Staging preparation run `31569820053` deployed those exact images and passed -health, readiness, synthetic, OAuth semantic-write, CORS, and application -manifest gates. A desktop daemon built from the exact beta69 commit remained -connected on protocol 3 while deployed acceptance ran under -`staging-test@mdbase.dev`. - -Two disposable real Obsidian vaults used the exact beta69 plugin bundle and a -hosted collection. Zero-byte, one-byte, 1 MiB plus one byte, 64 MiB plus 13 -bytes, and 96 MiB plus seven bytes all converged with exact byte counts and -SHA-256 values. Cancelling during the 96 MiB transfer produced the typed abort -and recovery-required states; resume then converged cleanly. Conflict creation, -stale-decision rejection, resolution, plugin reload, and a final zero-action -cursor all passed. Direct access was separately disabled for a forced-relay -collection: record CRUD, rename, delete, watch, all file operations, and a -deterministic 32 MiB plus 13 byte binary round trip passed while the reported -route remained `relay`. This complements the beta68 128 MiB relay, 9.6 GB -Reader collection, migration, restart, cancellation, and retained-RSS evidence. - -Obsidian main `3dac58f4f4002a6b421d25bc099f3be93746900a` passed all 60 tests and its -mobile budget after moving to beta69; no Obsidian release was cut. Production -consumer deployments passed from TaskNotes -`4c1e63934741d8707d3fa8f0ce475db483cada3f` (run `31568975593`), Workouts -`5ef1333f31deca164219790e1cdcb1fd2727f8f0` (run `31568774272`), Pickle -`7f99435ffc15cc7db83fd60b09eaef04ca3f42b0` (run `31568778199`), and the -canonical Editor (run `31571762187`). Reader did not require a production -deployment; it was nevertheless deployed during the coordinated update, then -immediately replaced with a clean build from local main commit -`3556f55f8e0a298031f3416c990596bf5d90d47f` after unrelated working-tree UI -changes were detected. Those unrelated Reader changes remain untouched, and -Reader should not be changed further as part of this release. - -Production pin PR `mdbase-dev/mdbase-cloud-ops#146` landed at -`d0dc1d52a35d7bb50f2513ce5a24db5a6d4d6dc`. The first promotion attempt, -run `31571916863`, correctly aborted because blanket reconciliation encountered -an active account without a hosted-storage entitlement, and it automatically -restored the exact beta68 images. Operations PR #148 then added a guarded, -paginated selector that reconciles only active accounts with hosted -collections, confirms production identity, performs one audited account at a -time, and emits a privacy-safe summary. Its first production use in run -`31573071508` successfully reconciled the selected account but rejected the -valid nested result because the wrapper asserted the old response shape; that -run also automatically restored the exact beta68 images. - -Operations PR #149 corrected and tested the nested result validation, landing -at `3cd57259054b338ec11b93420d2bbb1018a1f088`. Final production promotion run -`31573899487` then completed successfully in 17 minutes 51 seconds. It deployed -the exact beta69 candidate, reconciled all 43 active hosted accounts, and passed -the workflow's production verification without rollback. A subsequent local -`bin/verify-production` independently confirmed all four live image digests, -service health and readiness, OAuth device and semantic web authorization -writes, application manifests, and browser-file CORS. Independent monitor run -`31575201645` passed the same production synthetic and application conformance -boundaries. - -The two guarded failures are useful release evidence: deployment remained -transactional, rollback restored beta68 each time, and the final repair is a -narrow operations boundary rather than a compatibility branch in Connect. -Future account-wide maintenance should keep the same pattern: select only the -accounts for which the invariant applies, mutate serially with an auditable -per-account result, validate the API's exact result shape, and retain automatic -rollback around the entire promotion. - -## Handoff - -Beta69 production and its controlled web consumers are green. Keep the narrow -beta55-era protocol bridge and its telemetry until the remaining old client has -upgraded or aged out. The bounded-but-high retained Reader RSS and operator -recovery after a locked credential store remain operational follow-ups, not -evidence of an active leak or a reason to reintroduce duplicate runtimes. - -Do not start a second Connect scheduler, restore a dual watcher, move binary -payloads off NATS as part of this program, or remove the narrow protocol bridge -while the compatibility report remains not-ready. Continue to treat exact -provider outcomes, bounded per-collection execution, request coordination, and -typed durable mutation settlement as the canonical design. diff --git a/.ops/tasks/Cross-runtime Obsidian Bases view conformance.md b/.ops/tasks/Cross-runtime Obsidian Bases view conformance.md deleted file mode 100644 index b11a07a2..00000000 --- a/.ops/tasks/Cross-runtime Obsidian Bases view conformance.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -title: Cross-runtime Obsidian Bases view conformance -status: planned -priority: high -owner: unassigned -tags: - - testing - - obsidian-bases - - conformance - - mdbase-rs - - tasknotes - - datetime - - hosted - - developer-experience -created_at: 2026-08-06T23:01:31+10:00 -updated_at: 2026-08-06T23:01:31+10:00 -type: task ---- - -# Cross-runtime Obsidian Bases view conformance - -## Outcome - -Make editable Obsidian `.base` execution a fixture-driven compatibility -boundary from raw YAML through selected records. A change to YAML decoding, -structured-filter semantics, expression evaluation, formulas, sorting, date -handling, timezone propagation, or authority routing should fail a focused test -before it can reach TaskNotes. - -Keep the canonical mdbase v0.3 CEL query path and the Obsidian Bases -compatibility path visibly separate. Both need conformance tests, but a Bases -fixture must exercise the Bases path rather than being translated into CEL and -thereby testing different semantics. - -## Incident and current coverage - -Beta.38 fixed a regression in which the Rust `.base` compatibility decoder -interpreted a YAML filter sequence as positional fields of one logical-filter -struct. A nested `or: [A, B]` in TaskNotes' Today view therefore behaved like -`A && B`; the expression parser and timezone-aware date evaluator never saw the -intended filter tree. - -The existing expression coverage is strong: - -- `obsidian-bases-expression` has 447 tests, including 281 expression/context/ - expected-value cases generated from a live Obsidian instance and 13 native - parser/diagnostic cases. -- `mdbase-rs` vendors the compact 281-case oracle byte-for-byte and runs the - Rust Bases evaluator against every non-divergent case. -- The beta.38 fix adds a model regression for logical sequences and a complete - TaskNotes Today execution regression using a dynamically computed Melbourne - date. - -The missing layer is a shared corpus whose input begins with raw `.base` YAML. -The expression oracle starts after YAML decoding, while TypeScript structured- -filter tests start with already-formed JavaScript objects. Neither could detect -the corrupt YAML-to-filter-AST binding. - -## Design - -### 1. Define a portable raw-view fixture format - -Add a versioned fixture corpus with these inputs and outputs: - -- raw `.base` source, including global filters, per-view filters, formulas, - properties, ordering, sorting, grouping, limits, and renderer metadata; -- candidate files with frontmatter, file metadata, links, embeds, backlinks, - property types, and optional invocation context; -- an explicit IANA timezone and frozen instant; -- requested view name, pagination inputs, and rendering mode; and -- expected selected paths, projected values, order/groups, and structured - diagnostics. - -The fixture format must preserve raw YAML text. Do not normalize it into JSON -before handing it to the implementation under test, because sequence-versus- -mapping decoding is part of the contract. - -### 2. Capture an Obsidian view oracle - -Extend the existing live-Obsidian oracle tooling in -`obsidian-bases-expression` to create fixture notes and `.base` files, execute -named views in Obsidian, and record observable membership, ordering, formula -values, and diagnostics. Record Obsidian build metadata and explicit known -divergences just as the expression oracle does. - -Keep generated fixtures reviewable and deterministic. Dynamic functions such -as `today()` and `now()` need either a captured instant with normalization or -assertion forms that remain valid when the oracle cannot freeze Obsidian's -clock. - -### 3. Consume one corpus in TypeScript and Rust - -- `obsidian-bases-expression` should test raw-source adaptation plus structured - filter/expression evaluation against the oracle outputs. -- `mdbase-rs` should consume the same compact fixtures and execute the complete - `.base` view path, not call its expression evaluator directly. -- Add a checked fixture-sync command or content hash so copied compact fixtures - cannot drift silently from their generated source. -- Treat documented divergences as explicit data with a reason; never skip a - mismatch merely by case name. - -### 4. Make filter-tree decoding explicit - -Replace implicit untagged-Serde variant-order behavior with shape-based -decoding that distinguishes scalar expressions, mappings, and sequences before -constructing the filter AST. Validate logical objects and return path-aware -diagnostics for unsupported keys or ambiguous operator shapes. - -Cover scalar and list operands, empty lists, deep nesting, multiple logical -keys, unknown keys, non-string expressions, YAML anchors/aliases if supported, -and adversarial sequences that Serde can otherwise coerce into structs. - -### 5. Add TaskNotes-owned golden views - -Run the exact committed TaskNotes default `.base` sources as compatibility -fixtures. The Today matrix must include at least: - -- undated open, overdue date-only, today date-only, tomorrow date-only, and - completed tasks; -- scheduled instants immediately before and after local midnight; -- positive and negative UTC offsets; -- DST transition days in `Australia/Melbourne`, `America/New_York`, and a - non-DST zone; and -- nested `and`/`or`/`not` combinations where each individual branch can be - independently true or false. - -Also cover the other TaskNotes default saved views so changing shared global -filters or formulas cannot regress a view that is not the immediate focus of a -patch. - -### 6. Prove authority and SDK parity - -Exercise the same raw-view fixtures through: - -1. direct `mdbase-rs` execution; -2. the local Connect authority; -3. relay-backed filesystem authority; and -4. hosted-provider authority. - -Assert that the SDK sends the caller's IANA timezone unchanged and that all -authorities return the same paths, ordering, values, pagination, and diagnostic -shape. Include an application-level TaskNotes acceptance test that provisions -its real Today source, writes dated tasks, reloads the session, and queries the -view through the public SDK. - -### 7. Put the right tests on the release path - -- Fast raw-YAML model and fixture tests run in every `mdbase-rs` PR. -- The compact cross-runtime corpus runs in `mdbase-rs` and Connect hosted- - provider CI whenever the Rust pin, view adapter, SDK view operation, or - timezone plumbing changes. -- TaskNotes runs its exact default-view fixture against candidate Connect - artifacts before its generated application manifest is promoted. -- Release checks verify the tested `mdbase-rs` revision is the revision pinned - into the published server image. - -## Acceptance criteria - -1. One reviewed fixture demonstrates raw `or: [A, B]` YAML becoming an OR node - in both TypeScript and Rust and fails against the pre-beta.38 Rust decoder. -2. The exact TaskNotes Today source selects undated, overdue, and today tasks - and excludes tomorrow/completed tasks across the timezone/DST matrix. -3. The shared live-Obsidian view oracle records source, context, expected - output, Obsidian build metadata, and explicit known divergences. -4. Rust and TypeScript consume the same fixture semantics with an automated - drift check. -5. Malformed filter trees return stable, path-aware diagnostics rather than - silently changing boolean semantics. -6. Direct Rust, local, relay, hosted, and TaskNotes SDK tests agree on results - and timezone behavior. -7. A deliberate mutation of sequence decoding, `today()` timezone handling, - or nested OR evaluation causes the appropriate PR/release gate to fail. -8. Contributor documentation explains which suite to extend for expression - grammar, filter-tree/YAML shape, datetime behavior, authority routing, and - application-owned saved views. - -## References - -- mdbase-rs PR #44: saved-view logical-array fix -- mdbase-connect PR #194: beta.38 release pin -- mdbase-cloud-ops PR #102: beta.38 production promotion -- `obsidian-bases-expression/test/fixtures/oracle.compact.json` -- `mdbase-rs/tests/fixtures/obsidian-bases-oracle.json` - -## Handoff - -Start by specifying three to five raw `.base` fixtures around structured -logical filters and making both runtimes consume them. Then add the live -Obsidian generator and TaskNotes default-view matrix. Do not begin by adding -more evaluator-only cases: the evaluator oracle already has broad coverage, -and the highest-value missing seam is raw YAML through complete view execution. diff --git a/.ops/tasks/Final SDK polish.md b/.ops/tasks/Final SDK polish.md deleted file mode 100644 index 8330b21a..00000000 --- a/.ops/tasks/Final SDK polish.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -title: Final SDK polish -status: done -priority: high -owner: codex -parent: SDK and authority beta hardening -tags: - - sdk - - beta - - developer-experience - - public-api - - typescript - - request-budgets - - lifecycle - - consumers -created_at: 2026-08-05T12:12:48+10:00 -updated_at: 2026-08-05T22:23:00+10:00 -type: task ---- - -# Final SDK polish - -## Outcome - -Finish the pre-release SDK design with one deliberate breaking-change pass so -the stable surface is small, idiomatic, correctly typed, lifecycle-safe, and -pleasant for application developers. Preserve the strong authorization, -authority-routing, typed-outcome, and durable-mutation architecture; remove -accidental public machinery and wire-format leakage rather than adding another -abstraction layer. - -The user expanded the beta-hardening goal on 2026-08-05 to place this task -before the final staging activation. It follows application-declared collection -configuration provisioning, and both land in one successor release train so the -production-shaped deployment, rollback, canaries, and soak run only once. - -## Why this remains - -Consumer upgrades validated the main architecture but exposed a final set of -issues worth resolving before the API becomes expensive to break: - -- composite operations do not all uphold the documented single request-budget - contract; -- application-session startup is not explicitly coalesced or idempotent under - concurrent starts, cancellation, destruction, and framework remounts; -- the root package exports internal outcome/error construction machinery and a - raw operation escape hatch that are not part of the golden path; -- application-facing TypeScript mixes camelCase options with snake_case wire - fields; and -- query inputs retain `unknown` fields and an open index signature, allowing - malformed or misspelled queries to compile. - -## Scope - -### 1. Lifecycle and request-budget correctness - -1. Inventory every public asynchronous method and every composite operation. - Enforce the documented `ConnectRequestOptions` shape wherever a bounded - request can occur. -2. Make `MdbaseApplicationSession.start()` concurrency-safe and idempotent. - Define and test repeated start, concurrent start, cancellation, failure, - `destroy()`, and restart behavior. A failed or cancelled start must not leave - a rejected promise, orphaned base session, listener, or stale state owner. -3. Add request options to `ensureCapabilities()` and carry them through the - lower-level authorization path. -4. Fix `renameWithProgress()` and `deleteWithProgress()` so `timeoutMs` is not - dropped. Preflight and apply must consume one monotonic remaining budget, - while cancellation retains the correct not-sent versus unknown mutation - semantics. -5. Make `applyDefinitionUpdates()` consume one total budget across all assessed - packs and final verification rather than restarting the caller's timeout for - each sub-operation. -6. Specify pagination semantics explicitly. `queryAll()` must use one total - budget. If `queryPages()` intentionally uses a per-page budget for a - caller-driven async iterator, name and document that distinction rather than - overloading an apparently total `timeoutMs`. -7. Add black-hole, cancellation, concurrent-start, Strict Mode-style remount, - and listener-cleanup tests for these paths. - -### 2. Freeze a smaller public surface - -1. Replace broad root `export *` statements with an explicit reviewed export - list and a checked API inventory. -2. Keep the root package focused on the golden path: `MdbaseConnect`, the - application session, selected connection operations, selection adapters, - typed public outcomes/problems, notification payload helpers, files, and - application-facing types. -3. Remove internal construction and adaptation machinery from the root, - including raw transport/error conversion helpers, outcome-capture helpers, - implementation error classes, and runtime problem-code arrays unless a - demonstrated application use case requires them. -4. Remove the untyped `MdbaseConnection.operation()` escape hatch from the - ordinary connection. Provide any genuinely required protocol-author testing - seam through `/advanced` instead. -5. Review low-level registry methods on `MdbaseConnect` and - `MdbaseConnection`. Keep only application-facing lifecycle operations at the - root; move protocol and construction seams to `/advanced`. -6. Put supported problem/outcome builders and fault fixtures in - `@mdbase-dev/connect-testing` so application tests do not need production - access to SDK internals. -7. Add positive and negative compile fixtures against the packed package, not - merely source aliases, for every root, `/advanced`, `/crypto`, and testing - boundary. - -### 3. Make the TypeScript API idiomatic and precise - -1. Adopt one naming policy for application-facing TypeScript. Prefer camelCase - for SDK options, inputs, results, and progress events, translating to and - from canonical snake_case protocol payloads at the boundary. Do not rename - user-owned frontmatter keys. -2. Cover operational fields such as `if_revision`, `include_document`, - `frontmatter_mode`, `order_by`, `criterion_id`, `fire_at`, - `installed_by`, `expected_assessment_digest`, and `allow_downgrade`. -3. Replace `QueryInput.where?: unknown`, `order_by?: unknown`, and the open - string index signature with canonical query/filter/order types. Generate or - share these definitions from the mdbase operation contract rather than - inventing a second semantic model in Connect. -4. Ensure compile-time typos fail while valid forward-compatible record and - frontmatter data remain representable. -5. Review the connection-level frontmatter generic. Provide a simple per-call - result generic or another small solution if the current single generic makes - heterogeneous collections unnecessarily untyped; do not introduce a large - schema-code-generation system solely for this task. - -### 4. Clarify package ownership and developer workflow - -1. Audit direct `@mdbase-dev/connect-protocol` imports in Editor, Workouts, - Pickle, and TaskNotes. Re-export application-facing descriptors and constants - deliberately from `@mdbase-dev/connect`; leave authority/wire implementation - details in the protocol package. -2. Keep sync, Pickle, development tooling, and testing independently adoptable. - Do not merge packages merely to reduce the package count. -3. Make the normal dependency story explicit: ordinary applications install - `@mdbase-dev/connect`; add `connect-dev`, `connect-testing`, `connect-sync`, - or Pickle only when their feature set requires them. -4. Correct package-name drift and documentation defects, compile every public - example, and ensure the quickstart demonstrates the final golden path with - cancellation, typed failure handling, and uncertain-mutation recovery. - -### 5. Consumer migration and performance proof - -1. Pack every affected SDK artifact from one immutable Connect commit and pin - all four consumers to that exact artifact set. -2. Migrate Editor first for breadth, then Workouts, Pickle Android, and - TaskNotes. Remove compatibility aliases after all controlled consumers have - moved. -3. Run each consumer's compile, unit, browser/native, and real-authority suites, - including lifecycle cancellation and unknown-mutation recovery. -4. Retain explicit browser bundle governance: checked baseline, a 2 KiB gzip - per-change review signal, a 52 KiB standing warning threshold, a 64 KiB gzip - hard ceiling, and a 256 KiB raw hard ceiling. Accepted baseline increases - must be visible in review. -5. Re-run large collection, pagination, file streaming, burst, watch, and - platform matrices. The polish pass must not trade correctness or ergonomics - for avoidable startup, memory, or throughput regressions. - -## Non-goals - -- Do not redesign authorization, semantic capabilities, authority routing, - encrypted relay, durable mutation identity, or typed outcome taxonomy without - a concrete correctness defect. -- Do not add React, Vue, or other framework state owners. Make the core session - lifecycle safe and keep framework adapters thin unless consumer evidence - demonstrates that a maintained subpath materially reduces errors. -- Do not preserve beta compatibility aliases after controlled consumers move. -- Do not broaden the protocol or add speculative convenience layers. - -## Exit gate - -This task is complete when: - -1. every public async/composite path has tested deadline and cancellation - semantics, with no restarted total budgets, dropped options, leaked - listeners, or duplicate session owners; -2. a checked API report proves the reviewed root and subpath export inventory, - and negative fixtures reject removed internals and the raw operation escape - hatch; -3. application-facing naming is consistent, query inputs are precisely typed, - and all boundary mapping has round-trip and wire-conformance tests; -4. ordinary consumer code no longer imports protocol internals for SDK-level - concepts, while authority integration tests retain an explicit supported - path; -5. Editor, Workouts, Pickle Android, and TaskNotes pass on one immutable packed - artifact set with no compatibility façade; and -6. package audit, public API tests, documentation examples, browser CSP/bundle - checks, performance suites, and the relevant hosted/local system suites are - green. - -## Handoff - -Start with a read-only public API and request-budget audit. Turn each proposed -removal or rename into a compile-negative fixture before implementation, then -migrate all four consumers in the same release train. Keep correctness fixes -separate from mechanical naming and export movement so review can distinguish -behavioral changes from surface cleanup. - -## Progress — 2026-08-05 - -- The request-budget, concurrent application-session lifecycle, reviewed root - and subpath exports, camelCase SDK boundary, precise query types, testing - package ownership, compiled documentation, CSP, and browser bundle work is - implemented on `agent/beta33-hardening` through Connect commit `2dd725b`. -- The complete local/relay/sync/provider/files/container/desktop system train, - stress regression suites, architecture budgets, strict Rust linting, and the - 10k mirror profiles were green before the final hosted large-collection - optimization. -- The exact 10,003-record hosted gate now passes without relaxing a budget: - mutation p95 84.01 ms, snapshot 1.334 s, change-page p95 27.38 ms, - warm-read p95 46.72 ms, and warm-query p95 27.5 ms. The fix replaced - collection-wide record-index scans with paired indexes and moved hosted - writes onto mdbase-rs's explicit caller-owned staged-mutation boundary. - Ordinary filesystem mutations retain their atomic shadow transaction. -- mdbase-rs PR #41 merged as `b09f5d6`; its Linux, macOS, Windows, PostgreSQL, - packaging, dependency-policy, docs/features, full conformance, and strict - clippy gates are green. -- The task remains open for the immutable artifact freeze, exact one-time pin - in Editor, Workouts, Pickle Android, and TaskNotes, consumer-specific proof, - and the single successor rollout/canary/soak cycle. - -## Completion — 2026-08-05 - -- Final source commit `55b536aafa9a1ae1031171fa7e39ae99fa4530f0` - passes the complete JavaScript and Rust workspaces, release-readiness and - architecture checks, public/negative packed API fixtures, package audit, - compiled documentation, CSP and governed browser bundle checks, and the - local/relay/hosted/files/sync/stress/platform suites recorded above. -- One monotonic budget now spans composite operations; application-session - startup/destroy/restart is coalesced and lifecycle-safe; pagination semantics - are explicit; and black-hole, cancellation, Strict Mode remount, progress, - and listener-cleanup fixtures are green. -- The root export inventory is explicit. Removed builders, adapters, raw - operation escape hatches, and wire construction seams fail packed negative - fixtures; supported testing builders live in `@mdbase-dev/connect-testing`, - while `/advanced` and `/crypto` retain deliberate low-level seams. -- Application inputs/results are camelCase at the SDK boundary, query/filter/ - order types reject misspellings, heterogeneous record generics remain - representable, and round-trip wire fixtures preserve the canonical protocol. -- The immutable beta.33 packages are pinned in Editor `eb48e42`, Workouts - `fa5684c`, Pickle `5e3cbe0`, and TaskNotes `6febc15`. Exact byte-length and - SHA-512 verification passes across all four consumers. Ordinary application - code no longer imports protocol internals for SDK concepts; TaskNotes retains - one explicit low-level authority fixture. -- Editor passes typecheck, 240 tests, build, bundle/CSP and its 45-case browser - matrix; Workouts passes typecheck, 24 tests, manifest/build, 10 browser cases, - and real authority dogfood; Pickle passes full verify, 8/8 browser, Android - smoke, and response-loss recovery; TaskNotes passes full 356-test verify, - 8/8 browser, production and Android smokes, conformance, setup, and exact - response-loss recovery. - -All exit gates are satisfied. Rollout remains in the parent Phase 7 task so the -deployment, rollback proof, ordered canaries, and soak still occur exactly once. diff --git a/.ops/tasks/SDK and authority beta hardening.md b/.ops/tasks/SDK and authority beta hardening.md deleted file mode 100644 index 4bcf20ca..00000000 --- a/.ops/tasks/SDK and authority beta hardening.md +++ /dev/null @@ -1,632 +0,0 @@ ---- -title: SDK and authority beta hardening -status: done -priority: critical -owner: codex -tags: - - beta - - sdk - - idempotency - - sqlite - - postgresql - - recovery - - developer-experience - - user-experience - - consumers -created_at: 2026-08-04T10:51:42+10:00 -updated_at: 2026-08-06T03:28:00+10:00 -progress_summary: Complete. Phases 0-7 plus application-declared configuration provisioning and final SDK polish are shipped. All four consumers use exact beta.33 SDK artifacts; staging and production run exact signed beta.34 runtime images from ea56354739626c55f05a485cd707164740b2c391. Restore-tested checkpoints, automatic whole-train rollback, ordered canaries, broker recovery, 60-minute zero-failure soak, production promotion, independent acceptance, and post-promotion privacy-safe observations are green. Every external-beta invitation criterion is satisfied; invitations were not sent as part of this engineering program. -type: task ---- - -# SDK and authority beta hardening - -## Outcome - -Make mutation recovery, request completion, and database upgrade behavior safe -enough for an external beta; make the SDK a surface we are comfortable asking -other developers to learn; and prove the changes in the four current consumer -applications before inviting beta users. - -The target is not a claim that distributed writes are magically exactly once. -The target is a precise and testable contract: every mutation has one durable -request identity; a retry either returns its recorded result, completes a -recoverable operation, or reports that its outcome is genuinely unknown. A -stale in-progress receipt must never be reported as a definitive rejection. - -## Scope and repository map - -The implementation repository is `/home/calluma/projects/mdbase-connect`. -Consumer migrations cover these canonical checkouts: - -| Product name | Checkout | Current use | -| --- | --- | --- | -| mdbase Editor | `/home/calluma/projects/mdbase-editor` | Broadest SDK surface: application sessions, query/read/CRUD, guarded rename/delete, types/type packs, direct access, and watch. | -| TaskNotes | `/home/calluma/projects/tasknotes-app` | Direct collection authority, bounded in-session caches, files, native/browser authorization, notifications, and a separate application mutation journal. It deliberately has no application-owned offline task replica. | -| mdbase Workouts | `/home/calluma/projects/workout_tracker` | Online record CRUD, application sessions, cache invalidation, and Connect dogfood E2E. This is the current checkout for the `mdbase-workouts` repository. | -| Pickle | `/home/calluma/projects/pickle-android` | Record reads/responses, watch, native deep-link authorization, app lifecycle, and notifications. The sibling `/home/calluma/projects/pickle` repository is the CLI/inbox and is not an SDK consumer. | - -All four currently consume vendored `0.1.0-beta.28` packages built from commit -suffix `58665cbf4e9a`. Consumer updates must continue to use one exact set of -artifacts produced from one mdbase-connect commit. Do not copy package sources -or mix artifacts from different commits. - -## Program shape and current next goal - -This task is the beta-hardening epic, not one implementation pull request. -Phases 0–6 and their original implementation ordering are complete and remain -independently green. The beta.32 artifacts are immutable evidence, but were -deliberately stopped before staging activation. - -On 2026-08-05 the user expanded the completion gate with the two newest child -tasks: **Application-declared collection configuration provisioning** and -**Final SDK polish**. Complete them in that order, then build one successor -candidate and resume Phase 7. Do not activate beta.32 and do not deploy an -intermediate provisioning-only or polish-only train. - -The implementation and rollout orders remain deliberately different. Use -mdbase Editor first to review and integrate the broadest SDK surface. Use -mdbase Workouts first when canarying the final packaged release because it has -the smallest online mutation surface. - -## Non-negotiable contracts - -1. This is the pre-beta breaking-change window. Prefer the smallest coherent - SDK and protocol over preserving beta.28 names, call shapes, exports, wire - messages, or internal storage layout. Remove obsolete compatibility façades - in the same release after migrating all controlled consumers. -2. Ship the protocol, SDK, connector, hosted provider, control plane, management - UI, and four consumers as one versioned release train. Keep package release, - transport/wire protocol, signed authorization binding, semantic capability - contract, and durable-mutation feature versions distinct. A mismatch names - the incompatible contract and fails explicitly before the affected authority - authorization, read, or mutation; peers do not silently downgrade the - guarantee or provide a reduced authority compatibility mode. A package - version difference alone is not an incompatibility when every contract used - by the operation is supported. -3. Backward data migration is required even though API and wire compatibility - are not. Existing beta.28 SQLite/PostgreSQL data, grants, and completed - receipts must upgrade without loss. Take a recoverable pre-migration backup - before any step that prevents the old version from reopening the database. - Preserve grant records and audit history, but preserve a grant's validity only - when its signed meaning can be represented exactly under the new authorization - contract. Otherwise retain the record, mark it as requiring reauthorization, - and never mechanically re-sign or broaden it. -4. Every public asynchronous operation terminates through success, a typed - Connect outcome, cancellation, or a documented timeout. Raw `SyntaxError`, - indefinitely pending `fetch`, and indefinitely waiting database acquisition - are outside the public contract. -5. Markdown remains the source of truth for local collections. SQLite indexes, - caches, and journals are recoverable support state. Recovery must not delete - canonical collection files or silently discard grants and operation receipts. - A Connect version mismatch may pause authority operations and sync, but must - not prevent an application from reading its independent local replica or - canonical Markdown where no incompatible authority call is involved. -6. Database rollback is an operational requirement, not a reason to carry two - runtime models. Prefer reversible migrations; otherwise restore the verified - pre-migration backup. Do not retain dual readers, legacy receipt paths, or - dead columns merely so old binaries can continue against the new schema. -7. Correctness changes and large code movement land separately. Preserve a - reviewable behavioral diff before splitting oversized modules. - -## Phase 0 — freeze the contract and baseline - -1. Write a short ADR for the mutation state machine. Define request identity, - canonical request fingerprint, ownership/lease, fencing generation, - prepared state, applied state, completed receipt, acknowledgement, - abandonment, retry, conflicting reuse of a request ID, cancellation, and the - only conditions that justify `operation_outcome: unknown`. Define journal - retention, compaction, tombstones, and the supported recovery horizon so - pruning can never turn an old retry into a duplicate effect. Define replay - authorization after grant revocation and require each state transition to - compare its current fencing generation before committing. -2. Generate one canonical set of mutating operation identifiers from protocol - definitions. It must include record create/update/delete/rename, type - create/update, type-pack apply, view-source create/update/delete, timer - mutations, sync mutation batches, and file mutations if they use the same - operation channel. SDK retry classification and authority dispatch must - consume this definition instead of maintaining separate handwritten lists. -3. Write a compatibility matrix for the independent version axes: package - release, transport/wire protocol, signed authorization binding, semantic - capability contract, and durable-mutation feature set. Define the next value - for each axis that actually changes, the typed mismatch for each boundary, - and the coordinated deployment switch. Do not bump unrelated axes or - negotiate the durable guarantee down for an old peer. -4. Draft the target public SDK before threading new behavior through it. Record - the golden-path object hierarchy; the final `ConnectRequestOptions` shape; - the durable pending/unknown mutation recovery handle; the complete typed - outcome taxonomy; supported root and subpath exports; and browser/native - callback shapes. -5. Test that draft against compile-only consumer integration spikes before - freezing it. Cover Editor's full CRUD/type/type-pack/watch surface, Workouts' - small CRUD repository, Pickle's native session/respond/watch lifecycle, and - TaskNotes' sync/files/journal seams. Use temporary type fixtures or disposable - candidate artifacts; do not commit half-migrated consumers. Incorporate the - feedback, then freeze the public surface that Phase 3, Phase 4, and the real - consumer migrations will implement. -6. Specify canonical fingerprint encoding across TypeScript and Rust. Include - operation and schema version in the fingerprint, and explicitly exclude - credentials, deadlines, retry counters, and other transport-only metadata. - Do not use runtime-dependent JSON object serialization as the contract. -7. Record a green baseline for TypeScript build/typecheck/unit/E2E, Rust fmt, - clippy, unit/integration tests, PostgreSQL/R2 suites, restart/race tests, - browser storage/CSP/bundle checks, desktop tests, and the four consumers' - existing verification commands. -8. Add a script or CI fixture that inventories Connect package versions and - artifact commit suffixes in the four consumers. Fail if packages in one - consumer come from different builds. - -Exit gate: the state machine including fencing and retention, operation -catalogue, version matrix, consumer-tested and frozen public API, fingerprint -encoding, deployment switch, artifact inventory, and current green baseline are -reviewable before storage or protocol changes land. - -## Phase 1 — introduce safe local SQLite migrations - -Do this before adding the new operation journal so existing beta databases are -upgraded by the mechanism we intend to keep. - -1. Replace ad hoc `CREATE IF NOT EXISTS` plus error-string-driven `ALTER TABLE` - handling with numbered migrations and a durable migration ledger. Use - `PRAGMA user_version` or an explicit schema table with migration checksums; - never infer completion solely from a duplicate-column error string. -2. Establish the current beta schema as an idempotent baseline without - recreating data. New profiles and upgraded beta.28 profiles must converge on - the same schema. -3. Run each transactional migration atomically. For SQLite operations that - cannot be fully transactional, record prepare/complete state and make every - step safe to resume after process death. -4. Create a timestamped, permission-restricted backup of the registry before - schema migration using SQLite's online backup mechanism or another method - proved to capture a consistent database plus WAL state. Define retention, - authenticated integrity metadata, and an explicit tested restore path. Never - back up or log plaintext collection data unnecessarily. -5. On open and after migration, run appropriate integrity checks (`quick_check` - by default, deeper checks in diagnostics). Classify corruption separately - from an incompatible schema or a busy database. -6. Provide fail-closed recovery UX: preserve the damaged file, explain its - location, offer diagnostic export, and offer a deliberate rebuild of - rebuildable indexes. Preserve or restore grants and completed receipts when - possible; never silently rebuild away authorization or recovery history. -7. Publish the exact historical-schema support matrix. Add fixtures for every - supported schema and fault-inject process termination after every migration - step. Reopen twice to prove both recovery and idempotence; verify backup - restoration with both clean and WAL-active source databases. - -Exit gate: beta.28 databases upgrade in place, new databases match them, every -injected migration interruption resumes safely, and corruption produces an -actionable error without touching Markdown. - -## Phase 2 — one durable mutation journal across authorities - -### Local connector - -1. Replace the permanent `claimed but response is NULL` state with a durable - journal keyed by application/grant/request ID and canonical request - fingerprint. Persist the operation kind, input digest, state, lease owner and - expiry, fencing generation, prepared execution data, recoverable result - metadata, and final encrypted receipt. -2. Claim work transactionally. A repeated identical request returns the final - receipt, observes a live lease, or takes over an expired lease and resumes - from durable state with a new fencing generation. Every later transition - must prove it still owns that generation. Reusing a request ID for different - input is a permanent typed conflict. A process restart invalidates leases - owned by the prior process epoch without relying only on wall-clock expiry. -3. For filesystem mutations, prepare enough information before applying the - write to determine on restart whether it was not applied, applied exactly as - planned, or interfered with externally. Define restricted temporary-file, - expected before/after digest, atomic replacement, file and parent-directory - flush, and cleanup semantics for the connector's supported platforms: Linux, - macOS, and Windows. Mobile sync and filesystem adapters need their own - durability conformance in the sync package and consumer suites; Android and - iOS are not part of the Rust connector journal's platform gate. - Persist post-apply evidence before publishing the final receipt without - duplicating plaintext record content in the journal unnecessarily. -4. Reconcile the registry and filesystem as part of the recovery state machine. - Do not convert the current 25-second wait into a longer wait; bounded waiting - remains useful only while another live owner holds the lease. -5. If recovery cannot distinguish outcomes, return `operation_outcome: unknown` - with a stable request ID and recovery action. Never return `rejected` merely - because the original process disappeared. -6. Compact only under the ADR's retention contract. Preserve a request-ID and - fingerprint tombstone for the full replay horizon, and expose privacy-safe - diagnostics for receipts that cannot yet be pruned. - -### Hosted provider and generic dispatch - -1. Generalize the existing hosted record-operation receipt machinery into a - provider-neutral mutation journal used by every mutating public operation. -2. Put operation execution and receipt commit in one PostgreSQL transaction - wherever data lives in PostgreSQL. Preserve the existing row locks, - constraints, working-set invalidation, and R2 staging/deletion journal - behavior. -3. For R2 or other external side effects, use durable prepare/apply/finalize - state and deterministic object keys so restart and replay are safe. Keep the - database as the coordinator and preserve orphan reconciliation. -4. Add database uniqueness/foreign-key/check constraints for request identity, - fingerprint, legal state transitions, and final receipt cardinality. -5. Add connector/provider capability reporting and control-plane enforcement. - The control plane should expose an actionable minimum connector version but - must not reinterpret authority outcomes. - -### Proof - -For every operation in the canonical mutator catalogue, inject termination at -claim, prepare, side-effect apply, registry/database reconcile, receipt commit, -and response send. Restart and resend the exact request. Assert one logical -effect and the same final receipt. Also cover concurrent duplicates, -request-ID reuse with different input, lease expiry/takeover, a stale fenced -owner attempting to commit after takeover, grant revocation before replay, -receipt compaction boundaries, clock movement/process epoch changes, and -explicit fail-before-write behavior for every mismatched contract version. -Run real filesystem durability conformance on every supported desktop platform, -not only in mocked process-kill tests. - -Exit gate: no mutator can remain permanently `in_progress`; replay never -mislabels an ambiguous write as rejected; all advertised mutators pass the same -cross-authority conformance suite. - -## Phase 3 — bound all I/O and normalize outcomes - -1. Implement the Phase 0 `ConnectRequestOptions` contract consistently. Prefer - one public relative deadline shape—`signal` plus `timeoutMs`, with an - explicit `null` value to disable the SDK default for intentional long-lived - work—and convert it to an absolute remaining budget internally. Use the - breaking release to make method - signatures uniform across every public networked method: - session start/authorization/callback, describe/query/read, all record/type/ - type-pack/view/timer operations, sync/refresh, notifications, files, direct - access, token refresh, and management client operations. -2. Add client-level defaults for ordinary requests, long polls/watch, uploads, - and sync. Allow explicit override, including a documented way to disable a - default deadline for intentional long-lived streams. Compose caller abort - and deadline signals without leaking listeners. -3. Make retry policy deadline-aware. Never start a retry that cannot complete - within the remaining budget. Cancellation before authority acceptance is - `cancelled`; loss of contact after a mutation may have begun is `unknown` - until the durable receipt is recovered. -4. Route every HTTP response through a boundary decoder. Invalid JSON, HTML - proxy errors, empty bodies, and schema-invalid payloads become typed - `invalid_*_response`/upstream outcomes carrying safe status and diagnostic - context. No raw `response.json()` exception crosses the public SDK boundary. -5. Add PostgreSQL connect, pool-acquire, statement, idle-transaction, and lock - timeouts appropriate to each workload in both the control plane and hosted - provider. Map timeout classes to bounded service responses; do not reuse an - HTTP request deadline as an unexamined universal database timeout. -6. Ensure UI busy state always clears on success, typed failure, timeout, or - cancellation. Treat a watch as a bounded startup operation that returns an - explicitly abortable subscription; do not model its intentional stream - lifetime as an indefinitely pending ordinary request promise. - -Exit gate: a test can black-hole HTTP and PostgreSQL calls without leaving a -public promise, pool checkout, transaction, or busy indicator unbounded; all -failure paths remain typed Connect outcomes. - -## Phase 4 — SDK surface and internal code quality - -### Beta-blocking public surface - -1. Document one golden path: create `MdbaseConnect`, create/start an application - session, subscribe to its snapshot, obtain the selected connection, perform - an operation, and recover a pending mutation. Include browser and native - callback examples. -2. Provide a small framework-neutral external-store adapter and a documented - `useSyncExternalStore` helper. It must wrap the existing session rather than - introduce a second state owner. A separate React package is optional - follow-up work unless controlled-consumer integration proves it materially - improves the frozen public surface. -3. Add explicit subpath exports such as `/advanced` and `/crypto` for low-level - key stores, signing, PKCE, transport, and crypto helpers. Remove them from the - root after updating the controlled consumers; do not retain deprecated root - aliases solely for beta.28 compatibility. -4. Implement the Phase 0 decision for the current `Connect -> application - session -> connection -> collection client` ladder. Keep distinct layers - only where each owns a real lifecycle or capability boundary; collapse or - rename transitional concepts while all consumers can be migrated together. -5. Add API-extractor/type fixture tests for the new supported imports and call - shapes. Add negative fixtures for removed beta.28 entry points so accidental - compatibility shims do not creep back in, plus package export, browser - bundle, CSP, and tree-shaking checks. Set a - bundle budget for the golden path and keep sync/notifications independently - adoptable as described by the existing composable-SDK task. - -Exit gate: the documented golden path is small, obsolete beta.28 entry points -are gone, the compile-only fixtures representing every controlled consumer still -compile against the new surface, advanced APIs have discoverable homes, and the -public API and bundle budgets are enforced. Full consumer migrations remain in -Phase 6. - -### Follow-up internal extraction - -1. Extract the large client, transport, file, portal authorization, and editor - modules by behavior behind the new public façades. Suggested boundaries are - session/auth, request/response transport, mutation recovery, query/watch, - files, types/type packs, sync bridge, and notification lifecycle. -2. Keep focused tests adjacent to each extracted behavior. Retain end-to-end - public API tests, but split the multi-thousand-line suites into conformance, - auth, records, definitions, recovery, and transport groups. - -This extraction is not a beta invitation blocker when the supported public -surface, correctness boundaries, tests, and bundle budgets are already clear. -Track and land it after behavioral hardening unless an oversized module directly -prevents safe implementation or review. Any extraction that does land must -produce no behavior or bundle regression. - -## Phase 5 — management UX correctness - -1. Replace refresh promise deduplication with generation-aware invalidation. A - refresh requested after a mutation must observe a generation at least as new - as that mutation; it must not join an older in-flight read and leave stale UI. -2. Make application revocation one server-side atomic/batch operation when the - domain permits it. If grant-by-grant revocation must remain, report exact - partial completion and always refresh in `finally` so the UI reflects truth. -3. Add timeout/cancel/retry presentation for management operations. Copy must - distinguish rejected, timed out before acceptance, and outcome unknown. -4. Test rapid refresh/mutate races, double clicks, navigation/unmount, partial - revoke failures, stale responses arriving out of order, and connector - offline/upgrade-required states. - -Exit gate: management actions cannot display pre-mutation state as current and -cannot leave an indefinite spinner or hide partial completion. - -## Phase 6 — update and prove each consumer - -First generate the next-beta artifact set with `pnpm package:consumer`, record -its source commit, and replace each consumer's vendored packages and lockfile -as one atomic change. Apply the common migration below, then the product-specific -work. Do not use consumer applications to paper over an authority defect. - -### Common migration - -1. Move low-level imports to the new subpaths where used; retain ordinary - session/connection/outcome imports from `@mdbase-dev/connect`. -2. Use the shared session external-store adapter where it removes local glue. - Keep product-specific repository and UI state outside the SDK. -3. Thread `AbortSignal` from component/repository lifecycles and set deliberate - budgets for first load, foreground refresh, writes, background sync, and - watches. Clear controllers on collection switch, unmount, app background, - or superseding action. -4. Render typed recovery states for timeout, cancellation, incompatible Connect - authority contract, and unknown mutation outcome. If an operation is unknown, - retain its durable request ID and invoke SDK recovery; never issue a fresh - mutation ID from a generic Retry button. -5. Add a consumer contract test that simulates response loss after authority - commit, restarts the authority/client as relevant, resumes the exact pending - mutation, and verifies one effect. -6. Run typecheck, lint/format, unit, build, E2E, manifest validation, package - artifact consistency, and product-specific native/cloud tests. - -### mdbase Editor — first implementation integration/reference application - -1. Extend `MutationOperationOptions` and the gateway façade so read/create/ - update/type/type-pack/preflight operations receive the same signal/deadline - support already used by query, watch, and progress mutations. -2. Replace the special-case retention of rename/delete preflights on `unknown` - with the SDK's durable pending-mutation recovery object. Preserve progress - and conflict presentation. -3. Exercise all mutator classes, especially type creation/update and type-pack - application, because these expose gaps not covered by CRUD-only consumers. -4. Use the editor as the public golden-path and API ergonomics review. If its - gateway still needs to translate many inconsistent SDK shapes, fix the SDK - before documenting workarounds. - -Required gates: `pnpm typecheck`, `pnpm test`, `pnpm build`, bundle/CSP checks, -Playwright, authorization recovery, watch reconnect, guarded rename/delete, -and type-pack response-loss recovery. - -### mdbase Workouts — first packaged rollout canary - -1. Thread request options through the small `connect-api` repository boundary; - abort stale loads and writes when sheets close or collections change. -2. Make cache invalidation generation-aware so a timed-out or recovered write - cannot be hidden by a stale in-flight read. -3. Add a compact recovery banner/action for an unknown write and connector - upgrade, using the original request ID. - -Required gates: typecheck/lint, unit, build, manifest test, ordinary Playwright, -and `test:e2e:connect`. This is the first real canary because it has a small -online CRUD surface and a focused Connect dogfood suite. - -### Pickle — native lifecycle canary - -1. Add abort/deadline handling to startup, authorization callback, definition - updates, list/respond, watch, and notification binding. Abort or suspend - foreground work when Capacitor backgrounds the app without losing pending - mutation identity. -2. Ensure a response write that loses its HTTP response remains visible as - pending and resumes when the app reopens; never submit a second response. -3. Handle native browser close, deep-link replay, offline transition, and watch - restart through typed outcomes. Replace generic errors where recovery is - actionable. - -Required gates: `pnpm verify`, Playwright, Android smoke, deep-link callback, -background/foreground, notification binding, and response-loss/restart E2E. - -### TaskNotes — final and hardest canary - -1. Keep the application command journal and SDK/authority mutation journal as - separate layers with explicit responsibilities: the application journal - stores user intent and scheduling; the SDK journal stores transport request - identity and authority outcome. Persist the mapping between their operation - IDs so recovery cannot generate a duplicate write. -2. Thread deadlines/cancellation through session lifecycle, authorization and - definition updates, direct repository operations, files, notification - operations, and collection switching. Backgrounding suspends foreground - authority work; foreground resume receives a fresh lifecycle signal. -3. Reconcile application-journal intent with recovered authority receipts - before issuing a later write or canonical read. Test kill/restart during - multi-record commands, rolling-occurrence maintenance, attachment operations, - notification reconciliation, and collection lifecycle changes. -4. Preserve the direct-authority product boundary: a network timeout marks the - collection unavailable and retains actionable user intent or drafts without - reporting a false rejection. Bounded in-session caches must never become an - application-owned offline replica or second synchronization authority. - -Required gates: full `pnpm verify`, cloud E2E, browser E2E, production and Android -smokes, TaskNotes/mdbase conformance, lifecycle restart, provider conflicts, -files, notifications, collection switching, and authority response-loss -recovery. - -Exit gate: all four consumers use one exact candidate artifact set, expose -correct recovery UX, and pass their complete relevant suites. TaskNotes is last -because it is the strongest integration test, not the first place to discover -basic SDK or authority defects. - -## Phase 7 — rollout, observation, and rollback - -1. Exercise the whole breaking release train in staging. Use blue/green or - versioned deployment isolation so the old stack remains a rollback target, - not a compatibility path inside the new stack. -2. Activate the new connector/desktop and hosted authority together with the new - SDK consumers. Peers advertise their independent contract versions. A pairing - returns a typed mismatch naming the incompatible contract before the affected - authority authorization, read, or mutation only when a contract required by - that operation is unsupported; package version difference alone does not - fail. Do not offer reduced semantics for an incompatible authority contract. - Applications with independent local replicas or canonical Markdown may - remain locally usable while authority access and sync are paused behind an - upgrade-required state. -3. Canary in this order: mdbase Workouts, mdbase Editor, Pickle, TaskNotes. Hold - after each until logs/metrics and recovery tests show no unexplained pending - journals, duplicate-ID conflicts, timeout spikes, pool exhaustion, migration - failures, or user-visible stuck operations. -4. Add privacy-safe metrics for journal states/age, lease takeover, duplicate - replay, unknown outcomes, timeout class, invalid response class, migration - version/failure, SQLite integrity failures, and PostgreSQL pool/statement - timeout. Never include collection paths, record contents, keys, or tokens. -5. Roll back the release train as a unit: restore the previous consumer artifact - set and old service deployment, then restore the verified pre-migration - database snapshot where the old binary cannot open the new schema. Do not - implement dual receipt readers merely to make mixed-version rollback work. -6. Publish beta notes that describe guarantees and limitations precisely, - including what users should do when a connector upgrade is required or an - outcome remains unknown. - -## Beta invitation gate - -Do not invite external beta users until all of these are true: - -- Every canonical mutator is covered by restart/replay conformance on local and - hosted authorities, with no duplicate logical effect, false rejection, stale - fenced-owner commit, or unsafe replay after journal compaction. -- beta.28 SQLite fixtures migrate safely under termination injection, corruption - is preserved and diagnosed, and Markdown data is untouched. -- All SDK methods have bounded/cancellable behavior and all boundary failures - are typed Connect outcomes. -- PostgreSQL pool acquisition, statements, locks, and transactions are bounded - and pass saturation/failure tests without process crashes or leaked work. -- The management UI passes refresh-generation and revoke partial-failure tests. -- The four consumers use the same candidate artifact commit and pass the gates - listed above, including response-loss/restart tests. -- Upgrade and whole-train rollback have been exercised in packaged, - production-shaped environments; the supported contract matrix has been proved - to interoperate, and every incompatible contract combination fails clearly - before the affected authority operation. -- Public SDK docs, recovery guidance, changelog, package exports, and examples - match the shipped behavior; the editor example is understandable without - reading SDK internals. - -## Delivery slices - -Keep pull requests and commits reviewable in this order: - -1. Contract ADR, version matrix, generated mutator catalogue, consumer API - spikes, frozen public API, fingerprint encoding, artifact inventory, mismatch - tests, and green baseline. -2. Numbered SQLite migration runner, backup/restore path, historical fixtures, - interruption recovery, and corruption UX. -3. Local durable journal, fencing/retention implementation, filesystem - durability, and cross-platform fault-injection suite. -4. Hosted generic journal, external-side-effect recovery, retention, and - provider conformance. -5. Unified request options, timeout policy, boundary decoder, and database - bounds. -6. Management refresh/revoke correctness. -7. SDK subpaths, external-store adapter, public docs, API fixtures, and bundle - budgets. -8. Optional internal module extraction as a separately tracked, non-blocking - slice unless it is required to implement or review a correctness boundary. -9. Candidate packaging and consumer migrations, one repository/PR at a time: - Editor first for API integration, then Workouts, Pickle, and TaskNotes. -10. Application-declared collection configuration provisioning, with canonical - assess/apply semantics in mdbase-rs, one atomic setup review, hosted and - relay conformance, and TaskNotes as the first consumer. -11. Final SDK polish, including lifecycle/request-budget correctness, reviewed - root and subpath exports, idiomatic typed inputs, one successor artifact set, - and complete four-consumer migration and performance proof. -12. Dark/parallel authority deployment, coordinated activation, and packaged - canaries in the order Workouts, Editor, Pickle, TaskNotes; then whole-train - rollback proof, observation, release notes, soak, and beta gate. The - existing `Beta hardening 10 - rollout and beta gate` child tracks this held - final rollout despite the two appended prerequisite slices. - -Each slice must leave the repository green. Do not combine a schema change, a -public API redesign, a large file split, and a consumer migration in one review. - -## Current execution evidence — 2026-08-05 - -- The active train is committed and pushed through Connect `2dd725b`, with the - required mdbase-rs staged-mutation API merged at `b09f5d6`. -- The final hosted 10,003-record performance proof is green: mutation p95 - 84.01 ms against the unchanged 200 ms hard limit; snapshot 1.334 s; - change-page p95 27.38 ms; warm-read p95 46.72 ms; warm-query p95 27.5 ms. - Restart, logical backup restore, credential rotation, revocation, and body - limit checks passed in the same system run. -- Application-declared provisioning and final SDK polish remain part of this - release unit. No successor artifact has been frozen or deployed yet. Next is - release-readiness/package verification, followed by one immutable artifact - set across Editor, Workouts, Pickle Android, and TaskNotes, then one staged - activation/canary/rollback/soak program. - -## Phase 7 staging evidence — 2026-08-06 - -- Immutable beta.33 SDK packages and beta.34 runtime images are published from - exact source commits `9335459694ec0d1ad550f30bad9c8cd500666e41` and - `ea56354739626c55f05a485cd707164740b2c391`. beta.34 changes only the hosted - provider's privacy-safe metric filter; its protocol and minimum connector - version remain beta.33. -- The staging database-only checkpoint is encrypted and restore-tested. Two - failed beta.33 activations automatically restored the complete beta.31 image - train, proving the rollback path before the corrected beta.34 activation. -- Staging now runs the exact signed beta.34 relay, provider, Connect, and MCP - digests. Health/readiness, OAuth v4 writes, R2 CORS, and all four live - declarations pass exact release acceptance. -- Canaries advanced strictly in order: Workouts, Editor, Pickle, then TaskNotes. - Every post-canary observation has zero unknown outcomes, request-ID conflicts, - metric failures, boundary failures, database timeouts, migration failures, - unfinished journal age, and persistent pool exhaustion. -- The broker recovery drill suspended the sole staging Core NATS broker, - observed Connect readiness fail closed with HTTP 503, resumed the broker, - required stable recovery, and passed the complete signed staging suite. -- The 60-minute revision-bound soak is running as workflow `31024686507`. - Production remains on beta.31 while workflow `31024842803` creates the fresh - encrypted, restore-tested pre-migration checkpoint. The external-beta gate is - deliberately still closed. - -## Program completion — 2026-08-06 - -- Soak workflow `31024686507` ran for 3,600 seconds with 110 samples, 660 - endpoint checks, zero transient failures, and two complete signed acceptance - runs. -- Production checkpoint workflow `31024842803` retained encrypted artifact - `encrypted-production-backups-31024842803`; every PostgreSQL 17/18 export was - restored into a clean matching-major database before encryption. -- Cloud-ops main `2cd2987fadf09e81ab30a9354c0648f91aa4625d` - records the exact production beta.34 images and three reviewed prerelease - reset waivers. Promotion workflow `31029549943` used the exact confirmation - token, recorded the previous live image set, applied broker/provider/Connect/ - MCP in dependency order, reconciled entitlements, passed production OAuth, - live declarations, R2 CORS, and synthetic acceptance, and retained rollback - artifact `production-deployment-state-31029549943`. -- Independent post-promotion `bin/verify-production` passed. Two consecutive - privacy-safe production journal snapshots contain completed state only, no - unfinished operations or failure events, and available PostgreSQL pool - capacity. -- The checked changelog, SDK/outcome/recovery documentation, contract matrix, - examples, package exports, platform preview warnings, and beta release notes - describe the shipped guarantees and limitations. Every beta invitation item - is backed by the phase exit gates and rollout evidence above. - -All Phase 0-7 gates and both added successor tasks are complete. External beta -invitations may now begin through the product's normal human-owned invitation -process; this program did not send invitations or contact users. diff --git a/apps/editor/src/AccountManagement.tsx b/apps/editor/src/AccountManagement.tsx index ab60a6ea..247068e7 100644 --- a/apps/editor/src/AccountManagement.tsx +++ b/apps/editor/src/AccountManagement.tsx @@ -34,6 +34,10 @@ export function AccountManagement({ client, overview, sessions, onOverviewRefres const [deletionPassword, setDeletionPassword] = useState(""); const [reauthenticationToken] = useState(tokenFromFragment); const [googleAction, setGoogleAction] = useState<"link" | "delete" | null>(null); + const [sharingCode, setSharingCode] = useState<{ + code: string; + expiresAt: string; + }>(); const refresh = useCallback(async (signal?: AbortSignal) => { try { @@ -93,6 +97,23 @@ export function AccountManagement({ client, overview, sessions, onOverviewRefres setBusy(new Set(busyRef.current)); } + async function createSharingCode() { + if (!beginOperation("sharing-code")) return; + setError(""); + try { + const created = await client.createCollectionInvitationCode(); + setSharingCode({ + code: created.invitation_code, + expiresAt: created.expires_at + }); + setNotice("A new one-use sharing code is ready."); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + finishOperation("sharing-code"); + } + } + async function changePassword(event: FormEvent) { event.preventDefault(); if (newPassword !== passwordConfirmation) { @@ -199,6 +220,14 @@ export function AccountManagement({ client, overview, sessions, onOverviewRefres } +
+ +
+
{sharingCode?.code ?? "No active code shown"}{sharingCode ? `One use · expires ${relativeTime(sharingCode.expiresAt)}` : "Generate a private code, then send it to the collection owner."}
+ +
+
+ {account.authentication.managed &&
0 && } /> {!sessions &&

Checking active sessions…

} diff --git a/apps/editor/src/ConnectApp.test.tsx b/apps/editor/src/ConnectApp.test.tsx index 1ccf6dc0..b327d184 100644 --- a/apps/editor/src/ConnectApp.test.tsx +++ b/apps/editor/src/ConnectApp.test.tsx @@ -229,6 +229,120 @@ describe("ConnectApp", () => { expect(screen.queryByRole("button", { name: "Delete permanently" })).not.toBeInTheDocument(); }); + it("invites and manages collection members from the collection overview", async () => { + overview.collections = []; + overview.hosted_collections = [hostedCollection()]; + history.replaceState(null, "", "/connect?server=http%3A%2F%2F127.0.0.1%3A8787&collection=hosted"); + const originalFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation(async (input, init) => { + const path = new URL(String(input)).pathname; + if (path.endsWith("/members") && (!init?.method || init.method === "GET")) { + return Response.json({ members: [ + { kind: "owner", name: "Example Person", role: "owner", state: "active", accepted_at: new Date().toISOString() }, + { kind: "member", id: "member", name: "Shared Person", role: "viewer", state: "active", accepted_at: new Date().toISOString(), revoked_at: null } + ] }); + } + if (path.endsWith("/invitations") && init?.method === "POST") { + return Response.json({ invitation: { + id: "invitation", + collection_id: "hosted", + target_mode: "email", + submitted_email: "new@example.com", + role: "editor", + state: "pending", + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + created_at: new Date().toISOString(), + token: "cinv_private-token" + } }, { status: 202 }); + } + if (path.endsWith("/invitations") && (!init?.method || init.method === "GET")) { + return Response.json({ invitations: [] }); + } + return originalFetch(input, init); + }); + const user = userEvent.setup(); + render(); + + expect(await screen.findByRole("heading", { name: "People & sharing" })).toBeInTheDocument(); + expect(await screen.findByText("Shared Person")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Invite person" })); + await user.type(screen.getByLabelText("Email address"), "new@example.com"); + await user.selectOptions(screen.getByLabelText("Role"), "editor"); + await user.click(screen.getByRole("button", { name: "Create invitation" })); + + const link = await screen.findByLabelText("Collection invitation link"); + expect((link as HTMLInputElement).value).toContain( + "#collection-invitation=cinv_private-token" + ); + expect(vi.mocked(fetch).mock.calls.some(([input, init]) => + new URL(String(input)).pathname.endsWith("/invitations") + && init?.method === "POST" + && init.body === JSON.stringify({ email: "new@example.com", role: "editor" }) + )).toBe(true); + + await user.selectOptions(screen.getByLabelText("Role for Shared Person"), "editor"); + expect(vi.mocked(fetch).mock.calls.some(([input, init]) => + new URL(String(input)).pathname.endsWith("/members/member") + && init?.method === "PATCH" + && init.body === JSON.stringify({ role: "editor" }) + )).toBe(true); + }); + + it("hides owner-only and sharing controls from viewers", async () => { + const hosted = hostedCollection(); + hosted.access = { + relationship: "member", + role: "viewer", + can_authorize_applications: true, + can_manage_collection: false, + can_rename_collection: false, + can_delete_collection: false, + can_manage_members: false + }; + overview.collections = []; + overview.hosted_collections = [hosted]; + history.replaceState(null, "", "/connect/collections?server=http%3A%2F%2F127.0.0.1%3A8787&collection=hosted"); + render(); + + expect(await screen.findByRole("heading", { name: "Collections" })).toBeInTheDocument(); + expect(screen.getAllByText("Hosted research").length).toBeGreaterThan(0); + expect(screen.queryByRole("button", { name: "Rename" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "People & sharing" })).not.toBeInTheDocument(); + }); + + it("accepts a fragment-bound collection invitation without retaining the token", async () => { + history.replaceState(null, "", "/connect?server=http%3A%2F%2F127.0.0.1%3A8787&collection=collection#delete_token=keep&collection-invitation=cinv_private-token"); + const originalFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation(async (input, init) => { + const path = new URL(String(input)).pathname; + if (path === "/v1/hosted/collection-invitations/accept") { + const shared = hostedCollection(); + shared.id = "shared"; + shared.display_name = "Shared collection"; + overview.hosted_collections = [shared]; + return Response.json({ membership: { + id: "membership", + collection_id: "shared", + role: "viewer", + state: "active" + } }, { status: 201 }); + } + return originalFetch(input, init); + }); + const user = userEvent.setup(); + render(); + + expect(await screen.findByText("A collection was shared with you")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Accept" })); + await waitFor(() => expect(location.hash).toBe("#delete_token=keep")); + expect(vi.mocked(fetch).mock.calls.some(([input, init]) => + new URL(String(input)).pathname === "/v1/hosted/collection-invitations/accept" + && init?.method === "POST" + && init.body === JSON.stringify({ token: "cinv_private-token" }) + )).toBe(true); + }); + it("offers recovery actions when no computer is connected", async () => { overview.connectors = []; overview.collections = []; @@ -461,6 +575,27 @@ describe("ConnectApp", () => { )).toHaveLength(overviewCalls); }); + it("generates a one-use sharing code from account settings", async () => { + const originalFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation(async (input, init) => { + const path = new URL(String(input)).pathname; + if (path === "/v1/hosted/collection-invitation-codes" && init?.method === "POST") { + return Response.json({ + invitation_code: "ABCD-EFGH", + expires_at: new Date(Date.now() + 86_400_000).toISOString() + }, { status: 201 }); + } + return originalFetch(input, init); + }); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole("link", { name: /Account & sessions/ })); + await user.click(await screen.findByRole("button", { name: "Generate code" })); + + expect(await screen.findByText("ABCD-EFGH")).toBeInTheDocument(); + expect(screen.getByText("A new one-use sharing code is ready.")).toBeInTheDocument(); + }); + it("keeps hosted storage, sign-in methods, and account deletion in the editor", async () => { const user = userEvent.setup(); render(); @@ -501,7 +636,8 @@ function overviewFixture(): ManagementOverview { max_mirror_replicas_per_collection: 10, max_application_replicas_per_collection: 50, max_hosted_collections: 250, - max_files_per_collection: 10_000 + max_files_per_collection: 10_000, + max_collection_member_seats: 10 }, usage: { hosted_collections: 1, @@ -566,6 +702,15 @@ function hostedCollection(): ManagementOverview["hosted_collections"][number] { authority_epoch: 1, transferred_collection_id: null, created_at: new Date().toISOString(), + access: { + relationship: "owner", + role: "owner", + can_authorize_applications: true, + can_manage_collection: true, + can_rename_collection: true, + can_delete_collection: true, + can_manage_members: true + }, replicas: [] }; } diff --git a/apps/editor/src/ConnectApp.tsx b/apps/editor/src/ConnectApp.tsx index d3e5a7fa..5ef0dea0 100644 --- a/apps/editor/src/ConnectApp.tsx +++ b/apps/editor/src/ConnectApp.tsx @@ -2,6 +2,8 @@ import { ConnectManagementClient, ManagementApiError, type HostedCollection, + type HostedCollectionInvitation, + type HostedCollectionMember, type ManagementOverview, type ManagementRequestOptions } from "@mdbase/connect-management"; @@ -60,6 +62,7 @@ export function ConnectApp() { const [refreshError, setRefreshError] = useState(""); const [mutationError, setMutationError] = useState(""); const [navigationOpen, setNavigationOpen] = useState(false); + const [invitationToken, setInvitationToken] = useState(invitationTokenFromHash); const [busy, setBusy] = useState>(() => new Set()); const busyRef = useRef(new Set()); const refreshGenerationRef = useRef(0); @@ -149,6 +152,24 @@ export function ConnectApp() { return succeeded; } + async function acceptSharedCollection() { + if (!invitationToken) return; + let collectionId: string | undefined; + const accepted = await perform("accept-collection-invitation", async (options) => { + const result = await management.acceptCollectionInvitation(invitationToken, options); + collectionId = result.membership.collection_id; + }); + if (!accepted) return; + clearInvitationHash(); + setInvitationToken(null); + if (collectionId) navigate("overview", collectionId); + } + + function dismissSharedCollection() { + clearInvitationHash(); + setInvitationToken(null); + } + function navigate(next: ConnectView, collectionId?: string) { const path = next === "overview" ? "/connect" : `/connect/${next}`; const url = new URL(location.href); @@ -157,7 +178,7 @@ export function ConnectApp() { url.searchParams.set("collection", collectionId); rememberCollection(collectionId); } - history.pushState(null, "", `${url.pathname}${url.search}`); + history.pushState(null, "", `${url.pathname}${url.search}${url.hash}`); setView(next); setNavigationOpen(false); } @@ -242,9 +263,14 @@ export function ConnectApp() {
{(mutationError || refreshError) &&
} + {invitationToken && void acceptSharedCollection()} + onDismiss={dismissSharedCollection} + />} {pendingRequest && } {activeView === "overview" && (selectedCollection - ? + ? : )} {activeView === "storage" && selectedCollection && } {activeView === "access" && selectedCollection && } @@ -256,6 +282,18 @@ export function ConnectApp() { ; } +function CollectionInvitationBanner({ busy, onAccept, onDismiss }: { + busy: boolean; + onAccept(): void; + onDismiss(): void; +}) { + return
+
; +} + function PendingRequestBanner({ request, collectionName, count }: { request: ManagementOverview["pending_authorizations"][number]; collectionName?: string; @@ -272,9 +310,11 @@ function PendingRequestBanner({ request, collectionName, count }: { ; } -function CollectionOverview({ collection, applications, navigate }: { +function CollectionOverview({ collection, applications, busy, perform, navigate }: { collection: CollectionRow; applications: ApplicationAccessGroup[]; + busy: BusyOperations; + perform: PerformOperation; navigate(view: ConnectView, collectionId?: string): void; }) { return @@ -287,6 +327,7 @@ function CollectionOverview({ collection, applications, navigate }: { {applications.map((application) =>
{application.applicationName}{host(application.grants[0].homepage)}
{permissionSummary(application.grants)}Review
)} {applications.length === 0 && }
+ {collection.kind === "hosted" && collection.source.access.can_manage_members && }
{collection.status}{connectionDescription(collection)}
{collection.status}
@@ -294,6 +335,112 @@ function CollectionOverview({ collection, applications, navigate }: { ; } +function CollectionSharingPanel({ collection, busy, perform }: { + collection: HostedCollection; + busy: BusyOperations; + perform: PerformOperation; +}) { + const [members, setMembers] = useState(); + const [invitations, setInvitations] = useState(); + const [loadError, setLoadError] = useState(""); + const [inviting, setInviting] = useState(false); + const [targetMode, setTargetMode] = useState<"email" | "invitee_code">("email"); + const [target, setTarget] = useState(""); + const [role, setRole] = useState<"viewer" | "editor">("viewer"); + const [shareLink, setShareLink] = useState(""); + const [copied, setCopied] = useState(false); + + const load = useCallback(async (signal?: AbortSignal) => { + try { + const [memberResult, invitationResult] = await Promise.all([ + management.collectionMembers(collection.id, { signal }), + management.collectionInvitations(collection.id, { signal }) + ]); + if (signal?.aborted) return; + setMembers(memberResult.members); + setInvitations(invitationResult.invitations); + setLoadError(""); + } catch (reason) { + if (!signal?.aborted) setLoadError(errorMessage(reason)); + } + }, [collection.id]); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + async function invite(event: FormEvent) { + event.preventDefault(); + const invitationTarget = target.trim(); + if (!invitationTarget) return; + let token = ""; + const succeeded = await perform( + `sharing-invite-${collection.id}`, + async (options) => { + const created = await management.createCollectionInvitation( + collection.id, + targetMode === "email" + ? { email: invitationTarget, role } + : { invitee_code: invitationTarget, role }, + options + ); + token = created.invitation.token; + } + ); + if (!succeeded) return; + setTarget(""); + setInviting(false); + setCopied(false); + setShareLink(collectionInvitationUrl(token)); + await load(); + } + + async function mutate(id: string, action: (options: ManagementRequestOptions) => Promise) { + if (await perform(id, action)) await load(); + } + + async function copyLink() { + try { + await navigator.clipboard.writeText(shareLink); + setCopied(true); + } catch { + setCopied(false); + } + } + + const activeInvitations = invitations?.filter((invitation) => invitation.state === "pending") ?? []; + return
+ { setInviting(true); setShareLink(""); }}>
; +} + function Storage({ collection, busy, perform }: { collection: CollectionRow; busy: BusyOperations; @@ -403,8 +550,8 @@ function HostedCollectionRow({ collection, busy, perform, manage, showReplicas = {manage && Manage} {editorId && Open} {active && Sync folder} - {active && perform(`collection-${collection.id}`, (options) => management.renameHostedCollection(collection.id, name, options))} />} - void perform(`collection-${collection.id}`, (options) => management.deleteHostedCollection(collection.id, options))} /> + {active && collection.access.can_rename_collection && perform(`collection-${collection.id}`, (options) => management.renameHostedCollection(collection.id, name, options))} />} + {collection.access.can_delete_collection && void perform(`collection-${collection.id}`, (options) => management.deleteHostedCollection(collection.id, options))} />} {showReplicas && replicas.length > 0 &&
Synced folders{replicas.map((replica) =>
{replica.name}{replica.mode === "read_only" ? "Downloads only" : "Two-way sync"} @@ -606,6 +753,32 @@ function viewLabel(view: ConnectView): string { return "Account & sessions"; } +function invitationTokenFromHash(): string | null { + const parameters = new URLSearchParams(location.hash.replace(/^#/, "")); + const token = parameters.get("collection-invitation"); + return token?.startsWith("cinv_") ? token : null; +} + +function clearInvitationHash(): void { + if (!location.hash) return; + const url = new URL(location.href); + const parameters = new URLSearchParams(url.hash.replace(/^#/, "")); + parameters.delete("collection-invitation"); + url.hash = parameters.toString(); + history.replaceState( + null, + "", + `${url.pathname}${url.search}${url.hash}` + ); +} + +function collectionInvitationUrl(token: string): string { + const url = new URL("/connect", location.origin); + url.searchParams.set("server", new URL(management.baseUrl).origin); + url.hash = new URLSearchParams({ "collection-invitation": token }).toString(); + return url.href; +} + function collectionPreferenceKey(): string { const configuredServer = new URLSearchParams(location.search).get("server"); let origin = new URL(management.baseUrl).origin; diff --git a/apps/editor/src/connect.css b/apps/editor/src/connect.css index 811e520e..96d6968a 100644 --- a/apps/editor/src/connect.css +++ b/apps/editor/src/connect.css @@ -183,6 +183,8 @@ .connect-pending-banner strong { font-size: 0.76rem; } .connect-pending-banner small { color: var(--muted); font-size: 0.75rem; } .connect-pending-banner b { color: var(--accent-strong); font-size: 0.75rem; white-space: nowrap; } +.connect-pending-banner > .connect-banner-actions { display: flex; align-items: center; gap: 10px; } +.connect-banner-actions button { min-height: 36px; padding: 6px 10px; font-size: 0.75rem; } .connect-main { min-width: 0; @@ -426,14 +428,49 @@ button.danger:disabled { font-size: 0.78rem; } -.connect-inline-form input { +.connect-inline-form input, +.connect-inline-form select, +.connect-role-select select { min-height: 36px; padding: 7px 10px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--white); + color: var(--ink); + font: inherit; +} + +.connect-sharing-form { + display: grid; + grid-template-columns: minmax(130px, 0.45fr) minmax(220px, 1fr) minmax(110px, 0.35fr) auto; } +.connect-sharing-form label { width: auto; } +.connect-sharing-form > p { + grid-column: 1 / 4; + margin: 0; + color: var(--muted); + font-size: 0.75rem; + line-height: 1.45; +} +.connect-sharing-form > div { grid-column: 4; grid-row: 1 / 3; align-self: end; } + +.connect-sharing-link { + display: grid; + grid-template-columns: minmax(180px, 0.7fr) minmax(240px, 1fr) auto; + align-items: center; + gap: 14px; + padding: 14px 4px; + border-bottom: 1px solid var(--line); +} +.connect-sharing-link > div { display: grid; gap: 2px; } +.connect-sharing-link small { color: var(--muted); font-size: 0.75rem; line-height: 1.4; } +.connect-sharing-link input { min-width: 0; min-height: 36px; padding: 7px 10px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--soft); color: var(--muted); } +.connect-sharing-link button { min-height: 36px; color: var(--accent-strong); font-size: 0.75rem; } +.connect-role-select select { min-width: 104px; } +.connect-role-select select:disabled { opacity: 0.6; } +.connect-member-row { grid-template-columns: minmax(190px, 1fr) auto auto; } + .connect-inline-form > div { display: flex; gap: 12px; @@ -1111,7 +1148,8 @@ button.danger:disabled { margin: 20px auto -8px; } - .connect-pending-banner b { + .connect-pending-banner b, + .connect-pending-banner > .connect-banner-actions { grid-column: 2; } @@ -1135,6 +1173,13 @@ button.danger:disabled { width: 100%; } + .connect-sharing-form { grid-template-columns: 1fr; } + .connect-sharing-form > p, + .connect-sharing-form > div { grid-column: 1; grid-row: auto; } + .connect-sharing-link { grid-template-columns: 1fr; } + .connect-member-row { grid-template-columns: 1fr; gap: 8px; } + .connect-role-select select { min-height: 44px; } + .connect-account-details > div { grid-template-columns: 1fr; align-content: center; diff --git a/packages/management/index.test.ts b/packages/management/index.test.ts index 76f68daf..8c5150ac 100644 --- a/packages/management/index.test.ts +++ b/packages/management/index.test.ts @@ -134,6 +134,47 @@ describe("ConnectManagementClient", () => { }); }); + it("uses exact collection sharing routes and payloads", async () => { + const fetch = vi.fn(async () => Response.json({ + members: [], + invitations: [], + invitation: { token: "cinv_token" }, + membership: { id: "membership", collection_id: "collection" } + })); + vi.stubGlobal("fetch", fetch); + const client = new ConnectManagementClient("https://connect.example"); + + await client.collectionMembers("collection"); + await client.collectionInvitations("collection"); + await client.createCollectionInvitation("collection", { + email: "member@example.com", + role: "editor" + }); + await client.changeCollectionMemberRole("collection", "membership", "viewer"); + await client.revokeCollectionMember("collection", "membership"); + await client.cancelCollectionInvitation("collection", "invitation"); + await client.acceptCollectionInvitation("cinv_token"); + + expect(fetch).toHaveBeenNthCalledWith(3, + new URL("https://connect.example/v1/hosted/collections/collection/invitations"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ email: "member@example.com", role: "editor" }) + }) + ); + expect(fetch).toHaveBeenNthCalledWith(4, + new URL("https://connect.example/v1/hosted/collections/collection/members/membership"), + expect.objectContaining({ method: "PATCH", body: JSON.stringify({ role: "viewer" }) }) + ); + expect(fetch).toHaveBeenNthCalledWith(7, + new URL("https://connect.example/v1/hosted/collection-invitations/accept"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ token: "cinv_token" }) + }) + ); + }); + it("exposes account management without leaking editor URLs into OAuth state", async () => { const fetch = vi.fn(async () => Response.json({ client_id: "google-client", diff --git a/packages/management/index.ts b/packages/management/index.ts index a2004b86..d27609fc 100644 --- a/packages/management/index.ts +++ b/packages/management/index.ts @@ -94,6 +94,36 @@ export interface HostedReplica { } | null; } +export interface HostedCollectionAccess { + relationship: "owner" | "member"; + role: "owner" | "viewer" | "editor"; + can_authorize_applications: boolean; + can_manage_collection: boolean; + can_rename_collection: boolean; + can_delete_collection: boolean; + can_manage_members: boolean; +} + +export interface HostedCollectionMember { + kind: "owner" | "member"; + id?: string; + name: string; + role: "owner" | "viewer" | "editor"; + state: "active" | "changing" | "revoking"; + accepted_at: string; + revoked_at?: string | null; +} + +export interface HostedCollectionInvitation { + id: string; + target_mode: "email" | "invitee_code"; + submitted_email: string | null; + role: "viewer" | "editor"; + state: "pending" | "accepted" | "revoked" | "expired"; + expires_at: string; + created_at: string; +} + export interface HostedCollection { id: string; display_name: string; @@ -106,6 +136,7 @@ export interface HostedCollection { authority_epoch: number; transferred_collection_id: string | null; created_at: string; + access: HostedCollectionAccess; replicas: HostedReplica[]; } @@ -124,6 +155,7 @@ export interface ManagementOverview { max_application_replicas_per_collection: number; max_hosted_collections: number; max_files_per_collection: number; + max_collection_member_seats: number; }; usage: null | { hosted_collections: number; @@ -321,6 +353,99 @@ export class ConnectManagementClient { return this.request(`/v1/hosted/collections/${encodeURIComponent(id)}`, { method: "DELETE" }, options); } + collectionMembers( + collectionId: string, + options?: ManagementRequestOptions + ): Promise<{ members: HostedCollectionMember[] }> { + return this.request( + `/v1/hosted/collections/${encodeURIComponent(collectionId)}/members`, + {}, + options + ); + } + + collectionInvitations( + collectionId: string, + options?: ManagementRequestOptions + ): Promise<{ invitations: HostedCollectionInvitation[] }> { + return this.request( + `/v1/hosted/collections/${encodeURIComponent(collectionId)}/invitations`, + {}, + options + ); + } + + createCollectionInvitation( + collectionId: string, + input: + | { email: string; role: "viewer" | "editor" } + | { invitee_code: string; role: "viewer" | "editor" }, + options?: ManagementRequestOptions + ): Promise<{ + invitation: HostedCollectionInvitation & { collection_id: string; token: string }; + }> { + return this.request( + `/v1/hosted/collections/${encodeURIComponent(collectionId)}/invitations`, + { method: "POST", body: JSON.stringify(input) }, + options + ); + } + + cancelCollectionInvitation( + collectionId: string, + invitationId: string, + options?: ManagementRequestOptions + ): Promise { + return this.request( + `/v1/hosted/collections/${encodeURIComponent(collectionId)}/invitations/${encodeURIComponent(invitationId)}`, + { method: "DELETE" }, + options + ); + } + + changeCollectionMemberRole( + collectionId: string, + membershipId: string, + role: "viewer" | "editor", + options?: ManagementRequestOptions + ): Promise { + return this.request( + `/v1/hosted/collections/${encodeURIComponent(collectionId)}/members/${encodeURIComponent(membershipId)}`, + { method: "PATCH", body: JSON.stringify({ role }) }, + options + ); + } + + revokeCollectionMember( + collectionId: string, + membershipId: string, + options?: ManagementRequestOptions + ): Promise { + return this.request( + `/v1/hosted/collections/${encodeURIComponent(collectionId)}/members/${encodeURIComponent(membershipId)}`, + { method: "DELETE" }, + options + ); + } + + acceptCollectionInvitation( + token: string, + options?: ManagementRequestOptions + ): Promise<{ membership: { id: string; collection_id: string; role: "viewer" | "editor"; state: "active" } }> { + return this.request("/v1/hosted/collection-invitations/accept", { + method: "POST", + body: JSON.stringify({ token }) + }, options); + } + + createCollectionInvitationCode( + options?: ManagementRequestOptions + ): Promise<{ invitation_code: string; expires_at: string }> { + return this.request("/v1/hosted/collection-invitation-codes", { + method: "POST" + }, options); + } + revokeReplica(id: string, options?: ManagementRequestOptions): Promise { return this.request(`/v1/hosted/replicas/${encodeURIComponent(id)}`, { method: "DELETE" }, options); } diff --git a/services/server/migrations/0022_collection_membership_foundations.sql b/services/server/migrations/0022_collection_membership_foundations.sql new file mode 100644 index 00000000..d97aa0c7 --- /dev/null +++ b/services/server/migrations/0022_collection_membership_foundations.sql @@ -0,0 +1,104 @@ +-- Collection membership is policy attached to one stable logical collection ID, +-- not to an application grant, hosted replica, or current authority row. Local +-- authorities use collections.local_id and hosted authorities use +-- hosted_collections.id, which are preserved across authority transfer. +-- +-- Exact ceilings live in immutable policy revisions. Role names are presentation +-- presets only: changing a preset in application code cannot silently broaden an +-- existing membership. + +CREATE TABLE collection_identities ( + id uuid PRIMARY KEY, + owner_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + owner_principal_kind text NOT NULL DEFAULT 'user' + CHECK (owner_principal_kind = 'user'), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Backfill both authority representations. A logical ID collision with a +-- different owner is retained as the first identity and fails closed when +-- access is resolved; ordinary transfer rows have the same owner. +INSERT INTO collection_identities (id, owner_user_id) +SELECT id, user_id FROM hosted_collections +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE collection_memberships ( + id uuid PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES collection_identities(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + current_policy_id uuid, + current_policy_revision integer CHECK ( + current_policy_revision IS NULL OR current_policy_revision > 0 + ), + pending_policy_id uuid, + pending_policy_revision integer CHECK ( + pending_policy_revision IS NULL OR pending_policy_revision > 0 + ), + state text NOT NULL DEFAULT 'active' + CHECK (state IN ('active', 'changing', 'revoking', 'revoked')), + invited_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + accepted_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK ( + (current_policy_id IS NULL AND current_policy_revision IS NULL) + OR + (current_policy_id IS NOT NULL AND current_policy_revision IS NOT NULL) + ), + CHECK ( + ( + state = 'changing' + AND pending_policy_id IS NOT NULL + AND pending_policy_revision IS NOT NULL + ) + OR + ( + state <> 'changing' + AND pending_policy_id IS NULL + AND pending_policy_revision IS NULL + ) + ), + CHECK ( + (state = 'revoked' AND revoked_at IS NOT NULL) + OR (state <> 'revoked' AND revoked_at IS NULL) + ), + UNIQUE (id, user_id, collection_id) +); + +CREATE UNIQUE INDEX collection_memberships_active_user_idx + ON collection_memberships(collection_id, user_id) + WHERE revoked_at IS NULL; + +CREATE INDEX collection_memberships_user_idx + ON collection_memberships(user_id, collection_id) + WHERE revoked_at IS NULL; + +CREATE TABLE collection_membership_policies ( + id uuid PRIMARY KEY, + membership_id uuid NOT NULL REFERENCES collection_memberships(id) ON DELETE CASCADE, + revision integer NOT NULL CHECK (revision > 0), + role text NOT NULL CHECK (role IN ('viewer', 'editor')), + preset_version integer NOT NULL CHECK (preset_version > 0), + actions jsonb NOT NULL, + operations jsonb NOT NULL, + scope_ceiling jsonb NOT NULL, + file_ceiling jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (membership_id, revision), + UNIQUE (id, membership_id, revision) +); + +CREATE INDEX collection_membership_policies_membership_idx + ON collection_membership_policies(membership_id, revision); + +ALTER TABLE collection_memberships + ADD CONSTRAINT collection_memberships_current_policy_fk + FOREIGN KEY (current_policy_id, id, current_policy_revision) + REFERENCES collection_membership_policies(id, membership_id, revision); + +ALTER TABLE collection_memberships + ADD CONSTRAINT collection_memberships_pending_policy_fk + FOREIGN KEY (pending_policy_id, id, pending_policy_revision) + REFERENCES collection_membership_policies(id, membership_id, revision); diff --git a/services/server/migrations/0022a_local_collection_identity_backfill.sql b/services/server/migrations/0022a_local_collection_identity_backfill.sql new file mode 100644 index 00000000..60b483a3 --- /dev/null +++ b/services/server/migrations/0022a_local_collection_identity_backfill.sql @@ -0,0 +1,9 @@ +-- mdbase:skip-if-missing-table collections +-- Local-only collections use collections.local_id as the same stable logical +-- identity used by hosted collections. Historical authority candidates for one +-- logical collection normally share an owner; any mismatch fails closed when +-- policy is resolved against the current authority owner. + +INSERT INTO collection_identities (id, owner_user_id) +SELECT DISTINCT local_id, user_id FROM collections +ON CONFLICT (id) DO NOTHING; diff --git a/services/server/migrations/0023_grant_replica_membership_binding.sql b/services/server/migrations/0023_grant_replica_membership_binding.sql new file mode 100644 index 00000000..1b01a488 --- /dev/null +++ b/services/server/migrations/0023_grant_replica_membership_binding.sql @@ -0,0 +1,111 @@ +-- mdbase:skip-if-missing-table applications +-- Bind every hosted application grant to the same replica user and collection. +-- Membership columns are nullable for rollback compatibility and legacy owner +-- grants, but when present they form one complete immutable policy binding. + +ALTER TABLE grants + ADD COLUMN logical_collection_id uuid, + ADD COLUMN membership_id uuid, + ADD COLUMN membership_policy_id uuid, + ADD COLUMN membership_policy_revision integer; + +ALTER TABLE hosted_replicas + ADD COLUMN membership_id uuid, + ADD COLUMN membership_policy_id uuid, + ADD COLUMN membership_policy_revision integer; + +UPDATE grants +SET logical_collection_id = hosted_collection_id +WHERE hosted_collection_id IS NOT NULL + AND logical_collection_id IS NULL; + +ALTER TABLE grants + ADD CONSTRAINT grants_membership_binding_complete CHECK ( + ( + membership_id IS NULL + AND membership_policy_id IS NULL + AND membership_policy_revision IS NULL + ) + OR + ( + membership_id IS NOT NULL + AND membership_policy_id IS NOT NULL + AND membership_policy_revision IS NOT NULL + AND membership_policy_revision > 0 + AND logical_collection_id IS NOT NULL + ) + ); + +ALTER TABLE hosted_replicas + ADD CONSTRAINT hosted_replicas_membership_binding_complete CHECK ( + ( + membership_id IS NULL + AND membership_policy_id IS NULL + AND membership_policy_revision IS NULL + ) + OR + ( + membership_id IS NOT NULL + AND membership_policy_id IS NOT NULL + AND membership_policy_revision IS NOT NULL + AND membership_policy_revision > 0 + AND authorized_user_id IS NOT NULL + ) + ); + +CREATE UNIQUE INDEX hosted_replicas_grant_binding_idx + ON hosted_replicas(id, collection_id, authorized_user_id); + +CREATE UNIQUE INDEX hosted_replicas_policy_binding_idx + ON hosted_replicas( + id, + collection_id, + authorized_user_id, + membership_id, + membership_policy_id, + membership_policy_revision + ); + +ALTER TABLE grants + ADD CONSTRAINT grants_hosted_replica_binding_fkey + FOREIGN KEY (hosted_replica_id, hosted_collection_id, user_id) + REFERENCES hosted_replicas(id, collection_id, authorized_user_id); + +ALTER TABLE grants + ADD CONSTRAINT grants_membership_fkey + FOREIGN KEY (membership_id, user_id, logical_collection_id) + REFERENCES collection_memberships(id, user_id, collection_id); + +ALTER TABLE grants + ADD CONSTRAINT grants_membership_policy_fkey + FOREIGN KEY (membership_policy_id, membership_id, membership_policy_revision) + REFERENCES collection_membership_policies(id, membership_id, revision); + +ALTER TABLE hosted_replicas + ADD CONSTRAINT hosted_replicas_membership_fkey + FOREIGN KEY (membership_id, authorized_user_id, collection_id) + REFERENCES collection_memberships(id, user_id, collection_id); + +ALTER TABLE hosted_replicas + ADD CONSTRAINT hosted_replicas_membership_policy_fkey + FOREIGN KEY (membership_policy_id, membership_id, membership_policy_revision) + REFERENCES collection_membership_policies(id, membership_id, revision); + +ALTER TABLE grants + ADD CONSTRAINT grants_hosted_replica_policy_binding_fkey + FOREIGN KEY ( + hosted_replica_id, + hosted_collection_id, + user_id, + membership_id, + membership_policy_id, + membership_policy_revision + ) + REFERENCES hosted_replicas( + id, + collection_id, + authorized_user_id, + membership_id, + membership_policy_id, + membership_policy_revision + ); diff --git a/services/server/migrations/0024_hosted_collection_invitations_and_seats.sql b/services/server/migrations/0024_hosted_collection_invitations_and_seats.sql new file mode 100644 index 00000000..89db2516 --- /dev/null +++ b/services/server/migrations/0024_hosted_collection_invitations_and_seats.sql @@ -0,0 +1,119 @@ +-- Current/pending policy pointers are resolved with an exact membership/revision +-- join and fail closed. The original cyclic foreign keys prevent the intended +-- users -> memberships -> policies account-deletion cascade, so remove them in +-- a forward migration while retaining relational policy binding on every +-- derived grant and replica. +ALTER TABLE collection_memberships + DROP CONSTRAINT collection_memberships_current_policy_fk, + DROP CONSTRAINT collection_memberships_pending_policy_fk; + +ALTER TABLE entitlement_profiles + ADD COLUMN max_collection_member_seats bigint NOT NULL DEFAULT 10 + CHECK (max_collection_member_seats >= 0); + +UPDATE entitlement_profiles +SET max_collection_member_seats = 10 +WHERE code = 'beta_v1'; + +CREATE TABLE collection_invitation_codes ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (expires_at > created_at), + CHECK (consumed_at IS NULL OR revoked_at IS NULL) +); + +CREATE UNIQUE INDEX collection_invitation_codes_active_user_idx + ON collection_invitation_codes(user_id) + WHERE consumed_at IS NULL AND revoked_at IS NULL; + +CREATE TABLE collection_invitations ( + id uuid PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES collection_identities(id) ON DELETE CASCADE, + invited_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + target_mode text NOT NULL CHECK (target_mode IN ('email', 'invitee_code')), + submitted_email text, + target_user_id uuid REFERENCES users(id) ON DELETE CASCADE, + invitation_code_id uuid UNIQUE + REFERENCES collection_invitation_codes(id) ON DELETE SET NULL, + token_hash text NOT NULL UNIQUE, + role text NOT NULL CHECK (role IN ('viewer', 'editor')), + preset_version integer NOT NULL CHECK (preset_version > 0), + actions jsonb NOT NULL, + operations jsonb NOT NULL, + scope_ceiling jsonb NOT NULL, + file_ceiling jsonb NOT NULL, + state text NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'accepted', 'revoked')), + expires_at timestamptz NOT NULL, + accepted_at timestamptz, + revoked_at timestamptz, + accepted_membership_id uuid UNIQUE + REFERENCES collection_memberships(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (expires_at > created_at), + CHECK ( + (target_mode = 'email' AND submitted_email IS NOT NULL + AND invitation_code_id IS NULL) + OR + (target_mode = 'invitee_code' AND submitted_email IS NULL + AND target_user_id IS NOT NULL) + ), + CHECK ( + (state = 'pending' AND accepted_at IS NULL AND revoked_at IS NULL + AND accepted_membership_id IS NULL) + OR + (state = 'accepted' AND accepted_at IS NOT NULL AND revoked_at IS NULL + AND accepted_membership_id IS NOT NULL) + OR + (state = 'revoked' AND accepted_at IS NULL AND revoked_at IS NOT NULL + AND accepted_membership_id IS NULL) + ) +); + +CREATE UNIQUE INDEX collection_invitations_pending_target_idx + ON collection_invitations(collection_id, target_user_id) + WHERE state = 'pending' AND target_user_id IS NOT NULL; + +CREATE UNIQUE INDEX collection_invitations_pending_email_idx + ON collection_invitations(collection_id, submitted_email) + WHERE state = 'pending' AND target_mode = 'email'; + +CREATE INDEX collection_invitations_collection_idx + ON collection_invitations(collection_id, state, created_at); + +CREATE INDEX collection_invitations_target_idx + ON collection_invitations(target_user_id, state, expires_at); + +CREATE TABLE account_collection_member_seats ( + id uuid PRIMARY KEY, + owner_user_id uuid NOT NULL + REFERENCES account_storage_accounts(user_id) ON DELETE CASCADE, + membership_id uuid NOT NULL UNIQUE, + collection_id uuid NOT NULL, + member_user_id uuid NOT NULL, + consumed_at timestamptz NOT NULL DEFAULT now(), + released_at timestamptz, + FOREIGN KEY (membership_id, member_user_id, collection_id) + REFERENCES collection_memberships(id, user_id, collection_id) + ON DELETE CASCADE +); + +CREATE INDEX account_collection_member_seats_active_owner_idx + ON account_collection_member_seats(owner_user_id, consumed_at) + WHERE released_at IS NULL; + +INSERT INTO account_collection_member_seats + (id, owner_user_id, membership_id, collection_id, member_user_id) +SELECT membership.id, identity.owner_user_id, membership.id, + membership.collection_id, membership.user_id +FROM collection_memberships membership +JOIN collection_identities identity ON identity.id = membership.collection_id +JOIN account_storage_accounts account ON account.user_id = identity.owner_user_id +WHERE membership.revoked_at IS NULL +ON CONFLICT (membership_id) DO NOTHING; diff --git a/services/server/src/app.test.ts b/services/server/src/app.test.ts index 00474c09..b01d2c97 100644 --- a/services/server/src/app.test.ts +++ b/services/server/src/app.test.ts @@ -20,6 +20,10 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import { buildApp } from "./app.js"; import { createDatabase } from "./db.js"; +import { + createHostedCollectionMembership, + membershipPolicyPreset +} from "./collection-policy.js"; import { HostedProviderClient } from "./hosted-provider.js"; import { authorityProofMessage } from "./authority-proof.js"; import { pkceChallenge, tokenHash } from "./security.js"; @@ -1369,6 +1373,433 @@ describe("mdbase connect server", () => { })).json()).toMatchObject({ error: "expired_token" }); }); + it("authorizes a hosted member with an exact persisted policy binding", async () => { + const db = await createDatabase("memory"); + resources.push(() => db.end()); + const hostedProvider = { + url: "https://sync.example", + ready: vi.fn(), + upsertAccount: vi.fn().mockResolvedValue({}), + createCollection: vi.fn(), + renameCollection: vi.fn(), + deleteCollection: vi.fn(), + provisionTypePacks: vi.fn(), + provisionApplicationSetup: vi.fn(), + registerReplica: vi.fn(), + updateApplicationReplica: vi.fn(), + revokeReplica: vi.fn(), + upsertNotificationGrant: vi.fn(), + revokeNotificationGrant: vi.fn(), + rotateReplicaToken: vi.fn(), + compactThrough: vi.fn() + } as unknown as HostedProviderClient; + const { app } = await buildApp({ + db, + devAuth: true, + hostedCollections: true, + hostedProvider, + publicUrl: "http://connect.test" + }); + resources.push(() => app.close()); + + const ownerSession = await app.inject({ + method: "POST", + url: "/v1/dev/session", + payload: { name: "Sharing owner", email: "sharing-owner@example.com" } + }); + const ownerSetCookie = ownerSession.headers["set-cookie"]!; + const ownerCookie = (Array.isArray(ownerSetCookie) ? ownerSetCookie[0] : ownerSetCookie) + .split(";")[0]; + const memberSession = await app.inject({ + method: "POST", + url: "/v1/dev/session", + payload: { name: "Sharing member", email: "sharing-member@example.com" } + }); + const memberSetCookie = memberSession.headers["set-cookie"]!; + const memberCookie = (Array.isArray(memberSetCookie) ? memberSetCookie[0] : memberSetCookie) + .split(";")[0]; + const owner = await db.query<{ id: string }>( + "SELECT id FROM users WHERE email = 'sharing-owner@example.com'" + ); + const member = await db.query<{ id: string }>( + "SELECT id FROM users WHERE email = 'sharing-member@example.com'" + ); + const created = await app.inject({ + method: "POST", + url: "/v1/hosted/collections", + headers: { cookie: ownerCookie }, + payload: { + display_name: "Shared hosted notes", + template: "mdbase", + timezone: "Australia/Melbourne" + } + }); + expect(created.statusCode, JSON.stringify(created.json())).toBe(201); + const collectionId = created.json().collection.id as string; + const membership = await createHostedCollectionMembership(db, { + collectionId, + ownerUserId: owner.rows[0]!.id, + userId: member.rows[0]!.id, + role: "editor" + }); + const renamed = await app.inject({ + method: "PATCH", + url: `/v1/hosted/collections/${collectionId}`, + headers: { cookie: memberCookie }, + payload: { display_name: "Renamed by editor" } + }); + expect(renamed.statusCode, JSON.stringify(renamed.json())).toBe(200); + expect(hostedProvider.renameCollection).toHaveBeenCalledWith( + collectionId, + "Renamed by editor" + ); + await expect(db.query<{ display_name: string }>( + "SELECT display_name FROM hosted_collections WHERE id = $1", + [collectionId] + )).resolves.toMatchObject({ rows: [{ display_name: "Renamed by editor" }] }); + const deniedDelete = await app.inject({ + method: "DELETE", + url: `/v1/hosted/collections/${collectionId}`, + headers: { cookie: memberCookie } + }); + expect(deniedDelete.statusCode).toBe(404); + expect(hostedProvider.deleteCollection).not.toHaveBeenCalled(); + + const manifest: MdbaseAppManifest = { + manifest_version: 1, + distribution: "portable", + id: "dev.mdbase.shared-editor-test", + name: "Shared editor test", + project_url: "https://apps.example/shared-editor-test", + requirements: { + contracts: [], + access: "full_collection", + collection_kind: "hosted" + } + }; + const registration = await app.inject({ + method: "POST", + url: "/v1/apps/register", + payload: { manifest } + }); + const applicationId = registration.json().application.id as string; + const applicationManifestDigest = registration.json().application.manifest_digest as string; + const verifier = "shared-member-verifier-that-is-long-enough-0001"; + const proof = await testApplicationAuthorization({ + applicationId, + applicationDeclarationId: manifest.id, + applicationManifestDigest, + flow: "device_code", + codeChallenge: pkceChallenge(verifier), + requestedOperations: ["read", "update"], + collectionId, + grantAgreementPublicKey: p256PublicKey(), + grantSigningPublicKey: p256PublicKey() + }); + const device = await app.inject({ + method: "POST", + url: "/oauth/device_authorization", + headers: { "content-type": "application/x-www-form-urlencoded" }, + payload: new URLSearchParams({ + client_id: applicationId, + operations: "read,update", + collection_id: collectionId, + code_challenge: pkceChallenge(verifier), + code_challenge_method: "S256", + application_authorization: JSON.stringify(proof) + }).toString() + }); + expect(device.statusCode, JSON.stringify(device.json())).toBe(200); + const lookup = await app.inject({ + method: "POST", + url: "/v1/device-authorization-requests/lookup", + headers: { cookie: memberCookie }, + payload: { user_code: device.json().user_code } + }); + expect(lookup.statusCode, JSON.stringify(lookup.json())).toBe(200); + const requestId = lookup.json().request_id as string; + const available = await app.inject({ + method: "GET", + url: `/v1/authorization-requests/${requestId}`, + headers: { cookie: memberCookie } + }); + expect(available.json().collections).toContainEqual( + expect.objectContaining({ + id: collectionId, + kind: "hosted", + access: expect.objectContaining({ role: "editor", relationship: "member" }) + }) + ); + const viewerPolicyId = randomUUID(); + const viewerPolicy = membershipPolicyPreset("viewer"); + await db.query( + `INSERT INTO collection_membership_policies + (id, membership_id, revision, role, preset_version, actions, + operations, scope_ceiling, file_ceiling) + VALUES ($1, $2, 2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb)`, + [ + viewerPolicyId, + membership.membershipId, + viewerPolicy.role, + viewerPolicy.presetVersion, + JSON.stringify(viewerPolicy.actions), + JSON.stringify(viewerPolicy.operations), + JSON.stringify(viewerPolicy.scopeCeiling), + JSON.stringify(viewerPolicy.fileCeiling) + ] + ); + await db.query( + `UPDATE collection_memberships + SET current_policy_id = $2, current_policy_revision = 2 + WHERE id = $1`, + [membership.membershipId, viewerPolicyId] + ); + const staleApproval = await app.inject({ + method: "POST", + url: `/v1/authorization-requests/${requestId}/approve`, + headers: { cookie: memberCookie }, + payload: { + collection_id: collectionId, + operations: ["read", "update"] + } + }); + expect(staleApproval.statusCode).toBe(400); + expect(hostedProvider.registerReplica).not.toHaveBeenCalled(); + + await db.query( + `UPDATE collection_memberships + SET current_policy_id = $2, current_policy_revision = 1 + WHERE id = $1`, + [membership.membershipId, membership.id] + ); + const approved = await app.inject({ + method: "POST", + url: `/v1/authorization-requests/${requestId}/approve`, + headers: { cookie: memberCookie }, + payload: { + collection_id: collectionId, + operations: ["read", "update"] + } + }); + expect(approved.statusCode, JSON.stringify(approved.json())).toBe(200); + + const binding = await db.query<{ + user_id: string; + logical_collection_id: string; + membership_id: string; + membership_policy_id: string; + membership_policy_revision: number; + replica_membership_id: string; + replica_membership_policy_id: string; + replica_membership_policy_revision: number; + }>( + `SELECT grant_record.user_id, grant_record.logical_collection_id, + grant_record.membership_id, grant_record.membership_policy_id, + grant_record.membership_policy_revision, + replica.membership_id AS replica_membership_id, + replica.membership_policy_id AS replica_membership_policy_id, + replica.membership_policy_revision AS replica_membership_policy_revision + FROM grants grant_record + JOIN hosted_replicas replica ON replica.id = grant_record.hosted_replica_id + WHERE grant_record.user_id = $1 AND grant_record.hosted_collection_id = $2 + AND grant_record.revoked_at IS NULL`, + [member.rows[0]!.id, collectionId] + ); + expect(binding.rows).toEqual([{ + user_id: member.rows[0]!.id, + logical_collection_id: collectionId, + membership_id: membership.membershipId, + membership_policy_id: membership.id, + membership_policy_revision: membership.revision, + replica_membership_id: membership.membershipId, + replica_membership_policy_id: membership.id, + replica_membership_policy_revision: membership.revision + }]); + expect(hostedProvider.registerReplica).toHaveBeenCalledWith( + collectionId, + expect.objectContaining({ + mode: "read_write", + allowedOperations: ["read", "update"] + }) + ); + }); + + it("manages hosted invitations and memberships without leaking collection access", async () => { + const db = await createDatabase("memory"); + resources.push(() => db.end()); + const hostedProvider = { + url: "https://sync.example", + ready: vi.fn(), + upsertAccount: vi.fn().mockResolvedValue({}), + createCollection: vi.fn(), + renameCollection: vi.fn(), + deleteCollection: vi.fn(), + provisionTypePacks: vi.fn(), + provisionApplicationSetup: vi.fn(), + registerReplica: vi.fn(), + updateApplicationReplica: vi.fn(), + revokeReplica: vi.fn(), + upsertNotificationGrant: vi.fn(), + revokeNotificationGrant: vi.fn(), + rotateReplicaToken: vi.fn(), + compactThrough: vi.fn() + } as unknown as HostedProviderClient; + const { app } = await buildApp({ + db, + devAuth: true, + hostedCollections: true, + hostedProvider, + publicUrl: "http://connect.test" + }); + resources.push(() => app.close()); + const session = async (name: string, email: string) => { + const response = await app.inject({ + method: "POST", + url: "/v1/dev/session", + payload: { name, email } + }); + const setCookie = response.headers["set-cookie"]!; + return (Array.isArray(setCookie) ? setCookie[0] : setCookie).split(";")[0]; + }; + const ownerCookie = await session("Owner", "route-owner@example.com"); + const memberCookie = await session("Member", "route-member@example.com"); + const outsiderCookie = await session("Outsider", "route-outsider@example.com"); + await db.query( + `INSERT INTO email_identities + (id, user_id, email, normalized_email, verified_at, is_primary) + SELECT $1, id, email, email, now(), true FROM users + WHERE email = 'route-member@example.com'`, + [randomUUID()] + ); + await db.query( + `INSERT INTO email_identities + (id, user_id, email, normalized_email, verified_at, is_primary) + SELECT $1, id, email, email, now(), true FROM users + WHERE email = 'route-outsider@example.com'`, + [randomUUID()] + ); + const collection = await app.inject({ + method: "POST", + url: "/v1/hosted/collections", + headers: { cookie: ownerCookie }, + payload: { + display_name: "Route sharing", + template: "mdbase", + timezone: "Australia/Melbourne" + } + }); + const collectionId = collection.json().collection.id as string; + + const created = await app.inject({ + method: "POST", + url: `/v1/hosted/collections/${collectionId}/invitations`, + headers: { cookie: ownerCookie }, + payload: { email: "route-member@example.com", role: "viewer" } + }); + expect(created.statusCode, JSON.stringify(created.json())).toBe(202); + expect(created.headers["cache-control"]).toBe("no-store"); + const token = created.json().invitation.token as string; + expect(token).toMatch(/^cinv_/); + + const hidden = await app.inject({ + method: "GET", + url: `/v1/hosted/collections/${collectionId}/members`, + headers: { cookie: outsiderCookie } + }); + expect(hidden.statusCode).toBe(404); + expect(hidden.json()).toMatchObject({ + error: { code: "collection_sharing_not_found" } + }); + const wrongAccount = await app.inject({ + method: "POST", + url: "/v1/hosted/collection-invitations/accept", + headers: { cookie: outsiderCookie }, + payload: { token } + }); + expect(wrongAccount.statusCode).toBe(400); + expect(wrongAccount.json()).toMatchObject({ + error: { code: "invalid_collection_invitation" } + }); + const accepted = await app.inject({ + method: "POST", + url: "/v1/hosted/collection-invitations/accept", + headers: { cookie: memberCookie }, + payload: { token } + }); + expect(accepted.statusCode, JSON.stringify(accepted.json())).toBe(201); + const membershipId = accepted.json().membership.id as string; + + const members = await app.inject({ + method: "GET", + url: `/v1/hosted/collections/${collectionId}/members`, + headers: { cookie: ownerCookie } + }); + expect(members.statusCode).toBe(200); + expect(members.headers["cache-control"]).toBe("no-store"); + expect(members.json().members).toEqual([ + expect.objectContaining({ kind: "owner", role: "owner" }), + expect.objectContaining({ + kind: "member", + id: membershipId, + role: "viewer", + state: "active" + }) + ]); + expect(JSON.stringify(members.json())).not.toContain("route-member@example.com"); + + const promoted = await app.inject({ + method: "PATCH", + url: `/v1/hosted/collections/${collectionId}/members/${membershipId}`, + headers: { cookie: ownerCookie }, + payload: { role: "editor" } + }); + expect(promoted.statusCode, JSON.stringify(promoted.json())).toBe(200); + expect(promoted.json().membership).toMatchObject({ + id: membershipId, + role: "editor", + state: "active", + policy_revision: 2 + }); + const editorInvitation = await app.inject({ + method: "POST", + url: `/v1/hosted/collections/${collectionId}/invitations`, + headers: { cookie: memberCookie }, + payload: { email: "route-outsider@example.com", role: "viewer" } + }); + expect(editorInvitation.statusCode, JSON.stringify(editorInvitation.json())).toBe(202); + const invitationId = editorInvitation.json().invitation.id as string; + const cancelled = await app.inject({ + method: "DELETE", + url: `/v1/hosted/collections/${collectionId}/invitations/${invitationId}`, + headers: { cookie: memberCookie } + }); + expect(cancelled.statusCode).toBe(204); + + const revoked = await app.inject({ + method: "DELETE", + url: `/v1/hosted/collections/${collectionId}/members/${membershipId}`, + headers: { cookie: ownerCookie } + }); + expect(revoked.statusCode, JSON.stringify(revoked.json())).toBe(200); + expect(revoked.json().membership).toMatchObject({ state: "revoked" }); + const seat = await db.query<{ released_at: Date | string | null }>( + `SELECT released_at FROM account_collection_member_seats + WHERE membership_id = $1`, + [membershipId] + ); + expect(seat.rows[0]?.released_at).not.toBeNull(); + const deleted = await app.inject({ + method: "DELETE", + url: `/v1/hosted/collections/${collectionId}`, + headers: { cookie: ownerCookie } + }); + expect(deleted.statusCode, JSON.stringify(deleted.json())).toBe(200); + await expect(db.query( + "SELECT id FROM collection_identities WHERE id = $1", + [collectionId] + )).resolves.toMatchObject({ rows: [] }); + }); + it("adopts a legacy hosted grant for one portable v2 installation without another replica", async () => { const db = await createDatabase("memory"); resources.push(() => db.end()); diff --git a/services/server/src/app.ts b/services/server/src/app.ts index 1578bc0b..8caeef66 100644 --- a/services/server/src/app.ts +++ b/services/server/src/app.ts @@ -49,6 +49,7 @@ import { registerConnectorRelayRoute } from "./features/connectors/relay-route.j import { registerConnectorGrantRoutes } from "./features/grants/connector-routes.js"; import { registerConnectorHostedRoutes } from "./features/hosted/connector-routes.js"; import { registerHostedAccountRoutes } from "./features/hosted/account-routes.js"; +import { registerHostedSharingRoutes } from "./features/hosted/sharing-routes.js"; import { registerReferenceSyncRoutes } from "./features/hosted/reference-sync-routes.js"; import { registerMirrorPairingRoutes } from "./features/mirrors/pairing-routes.js"; import { registerNotificationRoutes } from "./features/notifications/routes.js"; @@ -379,6 +380,11 @@ export async function buildApp(options: BuildOptions) { hostedProvider: options.hostedProvider, hostedReference }); + registerHostedSharingRoutes(app, { + db: options.db, + hostedCollections: options.hostedCollections, + tailscaleAuth: options.tailscaleAuth + }); registerOnboardingRoutes(app, { db: options.db, publicUrl, diff --git a/services/server/src/collection-access.test.ts b/services/server/src/collection-access.test.ts index 87bcd6de..66541aa6 100644 --- a/services/server/src/collection-access.test.ts +++ b/services/server/src/collection-access.test.ts @@ -7,6 +7,8 @@ import { resolveHostedCollectionAccess, resolveLocalCollectionAccess } from "./collection-access.js"; +import { createHostedCollectionMembership } from "./collection-policy.js"; +import { listHostedCollectionsVisibleToUser } from "./collection-catalog.js"; let database: DatabasePool | undefined; afterEach(async () => database?.end()); @@ -41,6 +43,8 @@ describe("collection access policy", () => { role: "owner", can_authorize_applications: true, can_manage_collection: true, + can_rename_collection: true, + can_delete_collection: true, can_manage_members: true }); expect(await resolveHostedCollectionAccess( @@ -50,6 +54,121 @@ describe("collection access policy", () => { )).toBeNull(); }); + it("resolves exact viewer and editor policies without granting owner authority", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const viewerId = await insertUser(database, "viewer@example.com"); + const editorId = await insertUser(database, "editor@example.com"); + const outsiderId = await insertUser(database, "outsider@example.com"); + const collectionId = randomUUID(); + await database.query( + `INSERT INTO hosted_collections + (id, user_id, display_name, template, authority_epoch) + VALUES ($1, $2, 'Shared', 'mdbase', 2)`, + [collectionId, ownerId] + ); + await createHostedCollectionMembership(database, { + collectionId, ownerUserId: ownerId, userId: viewerId, role: "viewer" + }); + await createHostedCollectionMembership(database, { + collectionId, ownerUserId: ownerId, userId: editorId, role: "editor" + }); + + const viewer = await resolveHostedCollectionAccess(database, viewerId, collectionId); + expect(viewer).toMatchObject({ + relationship: "member", + role: "viewer", + userId: viewerId, + policyRevision: 1 + }); + expect(viewer?.operationCeiling.has("read")).toBe(true); + expect(viewer?.operationCeiling.has("update")).toBe(false); + expect(viewer?.fileCeiling.actions).toEqual(["list", "read"]); + expect(accessView(viewer!)).toMatchObject({ + can_authorize_applications: true, + can_manage_collection: false, + can_rename_collection: false, + can_delete_collection: false, + can_manage_members: false + }); + + const editor = await resolveHostedCollectionAccess(database, editorId, collectionId); + expect(editor?.actions.has("schema.manage")).toBe(true); + expect(editor?.actions.has("members.manage")).toBe(true); + expect(editor?.actions.has("collection.rename")).toBe(true); + expect(editor?.actions.has("collection.delete")).toBe(false); + expect(editor?.actions.has("authority.transfer")).toBe(false); + expect(accessView(editor!)).toMatchObject({ + can_manage_collection: false, + can_rename_collection: true, + can_delete_collection: false, + can_manage_members: true + }); + await expect(resolveHostedCollectionAccess(database, outsiderId, collectionId)) + .resolves.toBeNull(); + }); + + it("unions owned and shared hosted collections while filtering revoked policies", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const memberId = await insertUser(database, "member@example.com"); + const ownedId = randomUUID(); + const sharedId = randomUUID(); + await database.query( + `INSERT INTO hosted_collections + (id, user_id, display_name, template) + VALUES ($1, $2, 'Zulu owned', 'mdbase'), + ($3, $4, 'Alpha shared', 'mdbase')`, + [ownedId, ownerId, sharedId, memberId] + ); + await createHostedCollectionMembership(database, { + collectionId: sharedId, + ownerUserId: memberId, + userId: ownerId, + role: "viewer" + }); + + const visible = await listHostedCollectionsVisibleToUser(database, ownerId); + expect(visible.map(({ locator }) => locator.collectionId)).toEqual([ + sharedId, + ownedId + ]); + + await database.query( + `UPDATE collection_memberships + SET state = 'revoked', revoked_at = now() + WHERE collection_id = $1 AND user_id = $2`, + [sharedId, ownerId] + ); + const afterRevocation = await listHostedCollectionsVisibleToUser(database, ownerId); + expect(afterRevocation.map(({ locator }) => locator.collectionId)).toEqual([ownedId]); + }); + + it("does not resolve hosted member access while the authority is inactive", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const memberId = await insertUser(database, "member@example.com"); + const collectionId = randomUUID(); + await database.query( + `INSERT INTO hosted_collections + (id, user_id, display_name, template) + VALUES ($1, $2, 'Shared', 'mdbase')`, + [collectionId, ownerId] + ); + await createHostedCollectionMembership(database, { + collectionId, ownerUserId: ownerId, userId: memberId, role: "viewer" + }); + await database.query( + "UPDATE hosted_collections SET authority_state = 'transferred' WHERE id = $1", + [collectionId] + ); + + await expect(resolveHostedCollectionAccess(database, memberId, collectionId)) + .resolves.toBeNull(); + await expect(resolveHostedCollectionAccess(database, ownerId, collectionId)) + .resolves.toMatchObject({ relationship: "owner" }); + }); + it("uses local_id as the logical identity of a connector-backed collection", async () => { database = await createDatabase("memory"); const ownerId = await insertUser(database, "local@example.com"); diff --git a/services/server/src/collection-access.ts b/services/server/src/collection-access.ts index 9580a477..39707e30 100644 --- a/services/server/src/collection-access.ts +++ b/services/server/src/collection-access.ts @@ -1,6 +1,7 @@ import { COLLECTION_OPERATIONS, type CollectionOperation, + type FileCapability, type GrantScope } from "@mdbase-dev/connect-protocol"; import type { DatabaseQueryable } from "./db.js"; @@ -9,33 +10,29 @@ import { resolveLocalCollection, type CollectionLocator } from "./collection-catalog.js"; +import { + COLLECTION_ACTIONS, + resolveActiveMembershipPolicy, + type CollectionAction, + type CollectionMembershipPolicy, + type CollectionRole +} from "./collection-policy.js"; -export { COLLECTION_OPERATIONS }; - -export type CollectionAction = - | "collection.discover" - | "record.read" - | "record.write" - | "application.authorize" - | "mirror.enroll" - | "schema.manage" - | "collection.rename" - | "collection.delete" - | "authority.transfer" - | "members.manage"; - -export type CollectionRole = "owner" | "manager" | "editor" | "viewer"; +export { COLLECTION_ACTIONS, COLLECTION_OPERATIONS }; +export type { CollectionAction, CollectionRole }; export interface CollectionAccessContext { collection: CollectionLocator; userId: string; relationship: "owner" | "member"; role: CollectionRole; + membershipId: string | null; policyId: string | null; policyRevision: number; actions: ReadonlySet; operationCeiling: ReadonlySet; scopeCeiling: GrantScope; + fileCeiling: FileCapability; } export interface CollectionAccessView { @@ -43,25 +40,21 @@ export interface CollectionAccessView { role: CollectionRole; can_authorize_applications: boolean; can_manage_collection: boolean; + can_rename_collection: boolean; + can_delete_collection: boolean; can_manage_members: boolean; } -const OWNER_ACTIONS: ReadonlySet = new Set([ - "collection.discover", - "record.read", - "record.write", - "application.authorize", - "mirror.enroll", - "schema.manage", - "collection.rename", - "collection.delete", - "authority.transfer", - "members.manage" -]); - +const OWNER_ACTIONS: ReadonlySet = new Set(COLLECTION_ACTIONS); const OWNER_OPERATIONS: ReadonlySet = new Set( COLLECTION_OPERATIONS ); +const OWNER_FILE_CEILING: FileCapability = { + kind: "files", + protocol_version: 1, + actions: ["list", "read", "add", "replace", "move", "delete"], + scope: { kind: "collection" } +}; export async function resolveHostedCollectionAccess( db: DatabaseQueryable, @@ -69,8 +62,17 @@ export async function resolveHostedCollectionAccess( collectionId: string ): Promise { const collection = await resolveHostedCollection(db, collectionId); - if (!collection || collection.locator.ownerUserId !== userId) return null; - return ownerAccess(collection.locator, userId); + if (!collection) return null; + if (collection.locator.ownerUserId === userId) { + return ownerAccess(collection.locator, userId); + } + if (collection.locator.authorityState !== "active") return null; + const policy = await resolveActiveMembershipPolicy(db, { + collectionId: collection.locator.collectionId, + ownerUserId: collection.locator.ownerUserId, + userId + }); + return policy ? memberAccess(collection.locator, policy) : null; } export async function resolveLocalCollectionAccess( @@ -96,13 +98,15 @@ export function requireCollectionAction( export function accessView( access: CollectionAccessContext ): CollectionAccessView { + const canRename = access.actions.has("collection.rename"); + const canDelete = access.actions.has("collection.delete"); return { relationship: access.relationship, role: access.role, can_authorize_applications: access.actions.has("application.authorize"), - can_manage_collection: - access.actions.has("collection.rename") - && access.actions.has("collection.delete"), + can_manage_collection: canRename && canDelete, + can_rename_collection: canRename, + can_delete_collection: canDelete, can_manage_members: access.actions.has("members.manage") }; } @@ -116,11 +120,32 @@ export function ownerAccess( userId, relationship: "owner", role: "owner", + membershipId: null, policyId: null, policyRevision: collection.authorityEpoch, actions: OWNER_ACTIONS, operationCeiling: OWNER_OPERATIONS, - scopeCeiling: { access: "full_collection", contracts: [] } + scopeCeiling: { access: "full_collection", contracts: [] }, + fileCeiling: structuredClone(OWNER_FILE_CEILING) + }; +} + +export function memberAccess( + collection: CollectionLocator, + policy: CollectionMembershipPolicy +): CollectionAccessContext { + return { + collection, + userId: policy.userId, + relationship: "member", + role: policy.role, + membershipId: policy.membershipId, + policyId: policy.id, + policyRevision: policy.revision, + actions: new Set(policy.actions), + operationCeiling: new Set(policy.operations), + scopeCeiling: structuredClone(policy.scopeCeiling), + fileCeiling: structuredClone(policy.fileCeiling) }; } diff --git a/services/server/src/collection-catalog.ts b/services/server/src/collection-catalog.ts index ddb63849..11d828bb 100644 --- a/services/server/src/collection-catalog.ts +++ b/services/server/src/collection-catalog.ts @@ -1,6 +1,7 @@ import type { CollectionContractDescriptor } from "@mdbase-dev/connect-protocol"; import type { DatabaseQueryable } from "./db.js"; import type { HostedTemplate } from "./hosted.js"; +import { resolveActiveMembershipPolicy } from "./collection-policy.js"; export type CollectionAuthorityKind = "local" | "hosted"; @@ -68,15 +69,35 @@ export async function listHostedCollectionsVisibleToUser( transferred_collection_id: string | null; created_at: string | Date; }>( - `SELECT id, user_id, display_name, template, provider_url, contracts, - authority_state, authority_epoch, transferred_collection_id, - created_at - FROM hosted_collections - WHERE user_id = $1 - ORDER BY display_name`, + `SELECT hosted.id, hosted.user_id, hosted.display_name, hosted.template, + hosted.provider_url, hosted.contracts, hosted.authority_state, + hosted.authority_epoch, hosted.transferred_collection_id, + hosted.created_at + FROM hosted_collections hosted + LEFT JOIN collection_memberships membership + ON membership.collection_id = hosted.id + AND membership.user_id = $1 + AND membership.state = 'active' + AND membership.revoked_at IS NULL + LEFT JOIN collection_membership_policies policy + ON policy.id = membership.current_policy_id + AND policy.membership_id = membership.id + AND policy.revision = membership.current_policy_revision + WHERE hosted.user_id = $1 + OR (hosted.authority_state = 'active' AND policy.id IS NOT NULL) + ORDER BY hosted.display_name`, [userId] ); - return result.rows.map(hostedEntry); + const visible = await Promise.all(result.rows.map(async (row) => { + if (row.user_id === userId) return row; + const policy = await resolveActiveMembershipPolicy(db, { + collectionId: row.id, + ownerUserId: row.user_id, + userId + }); + return policy ? row : null; + })); + return visible.flatMap((row) => row ? [hostedEntry(row)] : []); } export async function resolveLocalCollection( diff --git a/services/server/src/collection-invitations.test.ts b/services/server/src/collection-invitations.test.ts new file mode 100644 index 00000000..ce1120ef --- /dev/null +++ b/services/server/src/collection-invitations.test.ts @@ -0,0 +1,419 @@ +import { randomUUID } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + acceptHostedCollectionInvitation, + CollectionInvitationError, + createCollectionInvitationCode, + createHostedCollectionInvitation, + listHostedCollectionInvitations, + listHostedCollectionMembers, + revokeHostedCollectionInvitation +} from "./collection-invitations.js"; +import { createHostedCollectionMembership } from "./collection-policy.js"; +import { revokeHostedCollectionMembership } from "./collection-membership-lifecycle.js"; +import { createDatabase, type DatabasePool } from "./db.js"; +import { effectiveEntitlement } from "./entitlements.js"; +import { tokenHash } from "./security.js"; +import { ProviderRevocationWorker } from "./hosted-capability-lifecycle.js"; +import type { HostedProviderClient } from "./hosted-provider.js"; + +let database: DatabasePool | undefined; +afterEach(async () => database?.end()); + +describe("hosted collection invitations", () => { + it("returns indistinguishable invitation results for known and unknown email targets", async () => { + database = await createDatabase("memory"); + const ownerId = await insertAccount(database, "owner@example.com", true); + const memberId = await insertAccount(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + + const known = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { email: " Member@Example.com " } + }); + const unknown = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { email: "unknown@example.com" } + }); + + expect(publicShape(known)).toEqual(publicShape(unknown)); + expect(known.submittedEmail).toBe("member@example.com"); + const stored = await database.query<{ + id: string; + target_user_id: string | null; + token_hash: string; + }>( + `SELECT id, target_user_id, token_hash FROM collection_invitations + ORDER BY created_at, id` + ); + expect(stored.rows.find((row) => row.id === known.id)?.target_user_id).toBe(memberId); + expect(stored.rows.find((row) => row.id === unknown.id)?.target_user_id).toBeNull(); + expect(stored.rows.map((row) => row.token_hash)).toContain(tokenHash(known.token)); + expect(JSON.stringify(stored.rows)).not.toContain(known.token); + }); + + it("accepts only as the bound verified user and copies the invitation policy snapshot", async () => { + database = await createDatabase("memory"); + const ownerId = await insertAccount(database, "owner@example.com", true); + const memberId = await insertAccount(database, "member@example.com"); + const outsiderId = await insertAccount(database, "outsider@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + const invitation = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "editor", + target: { email: "member@example.com" } + }); + await database.query( + `UPDATE collection_invitations + SET operations = '["read"]'::jsonb, + actions = '["collection.discover","record.read","application.authorize","mirror.enroll"]'::jsonb + WHERE id = $1`, + [invitation.id] + ); + + await expect(effectiveEntitlement(database, ownerId)).resolves.toMatchObject({ + maxCollectionMemberSeats: 10 + }); + await expect(acceptHostedCollectionInvitation(database, { + userId: outsiderId, + token: invitation.token + })).rejects.toMatchObject({ code: "invalid_collection_invitation" }); + const accepted = await acceptHostedCollectionInvitation(database, { + userId: memberId, + token: invitation.token + }); + expect(accepted).toMatchObject({ collectionId, role: "editor" }); + const materialized = await database.query<{ + operations: string[]; + actions: string[]; + }>( + `SELECT policy.operations, policy.actions + FROM collection_memberships membership + JOIN collection_membership_policies policy + ON policy.id = membership.current_policy_id + WHERE membership.id = $1`, + [accepted.membershipId] + ); + expect(materialized.rows[0]).toMatchObject({ + operations: ["read"], + actions: [ + "collection.discover", + "record.read", + "application.authorize", + "mirror.enroll" + ] + }); + const seats = await database.query<{ count: string | number }>( + `SELECT count(*) AS count FROM account_collection_member_seats + WHERE membership_id = $1 AND released_at IS NULL`, + [accepted.membershipId] + ); + expect(Number(seats.rows[0]?.count)).toBe(1); + await expect(acceptHostedCollectionInvitation(database, { + userId: memberId, + token: invitation.token + })).rejects.toMatchObject({ code: "invalid_collection_invitation" }); + }); + + it("allocates the final owner-funded seat transactionally", async () => { + database = await createDatabase("memory"); + const ownerId = await insertAccount(database, "owner@example.com", true); + const firstId = await insertAccount(database, "first@example.com"); + const secondId = await insertAccount(database, "second@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + await database.query( + "UPDATE entitlement_profiles SET max_collection_member_seats = 1 WHERE code = 'beta_v1'" + ); + const first = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { email: "first@example.com" } + }); + const second = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { email: "second@example.com" } + }); + await acceptHostedCollectionInvitation(database, { + userId: firstId, + token: first.token + }); + + await expect(acceptHostedCollectionInvitation(database, { + userId: secondId, + token: second.token + })).rejects.toMatchObject({ code: "collection_member_seat_unavailable" }); + const pending = await database.query<{ state: string }>( + "SELECT state FROM collection_invitations WHERE id = $1", + [second.id] + ); + expect(pending.rows[0]?.state).toBe("pending"); + }); + + it("retains a seat until provider-backed membership revocation completes", async () => { + database = await createDatabase("memory"); + const ownerId = await insertAccount(database, "owner@example.com", true); + const memberId = await insertAccount(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + const invitation = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { email: "member@example.com" } + }); + const accepted = await acceptHostedCollectionInvitation(database, { + userId: memberId, + token: invitation.token + }); + const policy = await database.query<{ current_policy_id: string }>( + `SELECT current_policy_id FROM collection_memberships WHERE id = $1`, + [accepted.membershipId] + ); + const replicaId = randomUUID(); + await database.query( + `INSERT INTO hosted_replicas + (id, collection_id, authorized_user_id, name, purpose, mode, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, 'Mirror', 'mirror', 'read_only', $4, $5, 1)`, + [replicaId, collectionId, memberId, accepted.membershipId, + policy.rows[0]!.current_policy_id] + ); + + await expect(revokeHostedCollectionMembership(database, { + collectionId, + actorUserId: ownerId, + membershipId: accepted.membershipId + })).resolves.toMatchObject({ state: "revoking" }); + const pending = await database.query<{ released_at: Date | string | null }>( + `SELECT released_at FROM account_collection_member_seats + WHERE membership_id = $1`, + [accepted.membershipId] + ); + expect(pending.rows[0]?.released_at).toBeNull(); + + const worker = new ProviderRevocationWorker(database, { + revokeReplica: vi.fn(), + revokeNotificationGrant: vi.fn() + } as unknown as HostedProviderClient); + await expect(worker.drain()).resolves.toBe(1); + const released = await database.query<{ released_at: Date | string | null }>( + `SELECT released_at FROM account_collection_member_seats + WHERE membership_id = $1`, + [accepted.membershipId] + ); + expect(released.rows[0]?.released_at).not.toBeNull(); + }); + + it("binds a one-use invitee-generated code without exposing account ids", async () => { + database = await createDatabase("memory"); + const ownerId = await insertAccount(database, "owner@example.com", true); + const memberId = await insertAccount(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + const generated = await createCollectionInvitationCode(database, memberId); + const invitation = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { inviteeCode: generated.code.toLowerCase() } + }); + expect(invitation.targetMode).toBe("invitee_code"); + expect(invitation.submittedEmail).toBeNull(); + expect(invitation).not.toHaveProperty("targetUserId"); + await expect(createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { inviteeCode: generated.code } + })).rejects.toMatchObject({ code: "invalid_collection_invitation_code" }); + await expect(acceptHostedCollectionInvitation(database, { + userId: memberId, + token: invitation.token + })).resolves.toMatchObject({ role: "viewer" }); + }); + + it("does not block invited-account deletion with sharing history", async () => { + database = await createDatabase("memory"); + const ownerId = await insertAccount(database, "owner@example.com", true); + const memberId = await insertAccount(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + const invitation = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { email: "member@example.com" } + }); + await acceptHostedCollectionInvitation(database, { + userId: memberId, + token: invitation.token + }); + + await expect(database.query("DELETE FROM users WHERE id = $1", [memberId])) + .resolves.toMatchObject({ rowCount: 1 }); + await expect(database.query( + "SELECT id FROM account_collection_member_seats WHERE member_user_id = $1", + [memberId] + )).resolves.toMatchObject({ rows: [] }); + await expect(database.query( + "SELECT id FROM collection_invitations WHERE id = $1", + [invitation.id] + )).resolves.toMatchObject({ rows: [] }); + }); + + it("preserves invitation history when an inviter leaves and permits code-owner deletion", async () => { + database = await createDatabase("memory"); + const ownerId = await insertAccount(database, "owner@example.com", true); + const editorId = await insertAccount(database, "editor@example.com"); + const targetId = await insertAccount(database, "target@example.com"); + const codeTargetId = await insertAccount(database, "code-target@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + await createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: editorId, + role: "editor" + }); + const emailInvitation = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: editorId, + role: "viewer", + target: { email: "target@example.com" } + }); + await database.query("DELETE FROM users WHERE id = $1", [editorId]); + await expect(database.query<{ + invited_by_user_id: string | null; + target_user_id: string | null; + }>( + "SELECT invited_by_user_id, target_user_id FROM collection_invitations WHERE id = $1", + [emailInvitation.id] + )).resolves.toMatchObject({ + rows: [{ invited_by_user_id: null, target_user_id: targetId }] + }); + + const generated = await createCollectionInvitationCode(database, codeTargetId); + const codeInvitation = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: ownerId, + role: "viewer", + target: { inviteeCode: generated.code } + }); + await expect(database.query("DELETE FROM users WHERE id = $1", [codeTargetId])) + .resolves.toMatchObject({ rowCount: 1 }); + await expect(database.query( + "SELECT id FROM collection_invitations WHERE id = $1", + [codeInvitation.id] + )).resolves.toMatchObject({ rows: [] }); + }); + + it("allows editors to manage invitations and returns privacy-safe lists", async () => { + database = await createDatabase("memory"); + const ownerId = await insertAccount(database, "owner@example.com", true); + const editorId = await insertAccount(database, "editor@example.com"); + const targetId = await insertAccount(database, "target@example.com"); + const outsiderId = await insertAccount(database, "outsider@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + await createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: editorId, + role: "editor" + }); + const invitation = await createHostedCollectionInvitation(database, { + collectionId, + actorUserId: editorId, + role: "viewer", + target: { email: "target@example.com" } + }); + + const invitations = await listHostedCollectionInvitations( + database, + editorId, + collectionId + ); + expect(invitations).toEqual([ + expect.objectContaining({ + id: invitation.id, + submitted_email: "target@example.com", + role: "viewer", + state: "pending" + }) + ]); + expect(JSON.stringify(invitations)).not.toContain(targetId); + await expect(listHostedCollectionMembers(database, outsiderId, collectionId)) + .rejects.toMatchObject({ code: "collection_sharing_not_found" }); + await expect(revokeHostedCollectionInvitation(database, { + collectionId, + actorUserId: editorId, + invitationId: invitation.id + })).resolves.toBe(true); + await expect(acceptHostedCollectionInvitation(database, { + userId: targetId, + token: invitation.token + })).rejects.toBeInstanceOf(CollectionInvitationError); + }); +}); + +function publicShape(invitation: { + targetMode: string; + role: string; + state: string; + expiresAt: Date; +}) { + return { + targetMode: invitation.targetMode, + role: invitation.role, + state: invitation.state, + expiresInDays: Math.round( + (invitation.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1_000) + ) + }; +} + +async function insertAccount( + db: DatabasePool, + email: string, + entitled = false +): Promise { + const id = randomUUID(); + await db.query( + "INSERT INTO users (id, email, name) VALUES ($1, $2, $3)", + [id, email, email.split("@")[0]] + ); + await db.query( + `INSERT INTO email_identities + (id, user_id, email, normalized_email, verified_at, is_primary) + VALUES ($1, $2, $3, $3, now(), true)`, + [randomUUID(), id, email] + ); + if (entitled) { + await db.query( + `INSERT INTO account_entitlement_grants + (id, user_id, profile_code, source, source_reference) + VALUES ($1, $2, 'beta_v1', 'operator', $3)`, + [randomUUID(), id, `test-${id}`] + ); + await db.query( + `INSERT INTO account_storage_accounts (user_id, provider_account_id) + VALUES ($1, $2)`, + [id, randomUUID()] + ); + } + return id; +} + +async function insertHostedCollection(db: DatabasePool, ownerId: string): Promise { + const id = randomUUID(); + await db.query( + `INSERT INTO hosted_collections + (id, user_id, display_name, template, provider_url, authority_state) + VALUES ($1, $2, 'Shared', 'mdbase', 'https://provider.example', 'active')`, + [id, ownerId] + ); + return id; +} diff --git a/services/server/src/collection-invitations.ts b/services/server/src/collection-invitations.ts new file mode 100644 index 00000000..34f5ca8e --- /dev/null +++ b/services/server/src/collection-invitations.ts @@ -0,0 +1,644 @@ +import { randomUUID } from "node:crypto"; +import { resolveHostedCollectionAccess } from "./collection-access.js"; +import { + CollectionMembershipPolicyError, + insertHostedCollectionMembershipPolicy, + membershipPolicyPreset, + type CollectionMembershipRole, + type MembershipPolicySnapshot +} from "./collection-policy.js"; +import type { DatabasePool, DatabaseQueryable } from "./db.js"; +import { normalizeEmailAddress } from "./email-identity.js"; +import { effectiveEntitlement } from "./entitlements.js"; +import { audit } from "./platform/audit-events.js"; +import { + canonicalUserCode, + randomToken, + randomUserCode, + tokenHash +} from "./security.js"; + +const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1_000; + +export class CollectionInvitationError extends Error { + constructor(readonly code: string, message: string) { + super(message); + this.name = "CollectionInvitationError"; + } +} + +export interface CreatedCollectionInvitation { + id: string; + collectionId: string; + targetMode: "email" | "invitee_code"; + submittedEmail: string | null; + role: CollectionMembershipRole; + state: "pending"; + expiresAt: Date; + token: string; +} + +export async function createCollectionInvitationCode( + db: DatabasePool, + userId: string +): Promise<{ code: string; expiresAt: Date }> { + const connection = await db.connect(); + try { + await connection.query("BEGIN"); + const activeUser = await connection.query<{ id: string }>( + "SELECT id FROM users WHERE id = $1 AND suspended_at IS NULL FOR UPDATE", + [userId] + ); + if (!activeUser.rows[0]) throw invalidInvitation(); + await connection.query( + `UPDATE collection_invitation_codes + SET revoked_at = now() + WHERE user_id = $1 AND consumed_at IS NULL AND revoked_at IS NULL`, + [userId] + ); + const code = randomUserCode(); + const expiresAt = new Date(Date.now() + INVITATION_TTL_MS); + await connection.query( + `INSERT INTO collection_invitation_codes + (id, user_id, code_hash, expires_at) + VALUES ($1, $2, $3, $4)`, + [randomUUID(), userId, tokenHash(canonicalUserCode(code)), expiresAt] + ); + await connection.query("COMMIT"); + return { code, expiresAt }; + } catch (error) { + await connection.query("ROLLBACK"); + throw error; + } finally { + connection.release(); + } +} + +export async function createHostedCollectionInvitation( + db: DatabasePool, + input: { + collectionId: string; + actorUserId: string; + role: CollectionMembershipRole; + target: { email: string } | { inviteeCode: string }; + } +): Promise { + const connection = await db.connect(); + try { + await connection.query("BEGIN"); + const collection = await lockActiveCollection(connection, input.collectionId); + await ensureCollectionIdentity( + connection, + input.collectionId, + collection.user_id + ); + await requireSharingManager( + connection, + input.actorUserId, + input.collectionId + ); + const token = randomToken("cinv"); + const expiresAt = new Date(Date.now() + INVITATION_TTL_MS); + const snapshot = membershipPolicyPreset(input.role); + let targetMode: "email" | "invitee_code"; + let submittedEmail: string | null = null; + let targetUserId: string | null = null; + let invitationCodeId: string | null = null; + + if ("email" in input.target) { + targetMode = "email"; + submittedEmail = normalizeEmailAddress(input.target.email); + targetUserId = await resolveEligibleTargetUser( + connection, + submittedEmail, + collection.user_id, + input.collectionId + ); + await connection.query( + `UPDATE collection_invitations + SET state = 'revoked', revoked_at = now(), updated_at = now() + WHERE collection_id = $1 AND state = 'pending' + AND (submitted_email = $2 OR ($3::uuid IS NOT NULL AND target_user_id = $3))`, + [input.collectionId, submittedEmail, targetUserId] + ); + } else { + targetMode = "invitee_code"; + const code = await connection.query<{ id: string; user_id: string }>( + `SELECT id, user_id FROM collection_invitation_codes + WHERE code_hash = $1 AND consumed_at IS NULL + AND revoked_at IS NULL AND expires_at > now() + FOR UPDATE`, + [tokenHash(canonicalUserCode(input.target.inviteeCode))] + ); + const resolved = code.rows[0]; + const codeUser = resolved + ? await connection.query<{ id: string }>( + "SELECT id FROM users WHERE id = $1 AND suspended_at IS NULL", + [resolved.user_id] + ) + : { rows: [] }; + if (!resolved || !codeUser.rows[0] || resolved.user_id === collection.user_id) { + throw invalidInvitationCode(); + } + const existing = await connection.query<{ id: string }>( + `SELECT id FROM collection_memberships + WHERE collection_id = $1 AND user_id = $2 AND revoked_at IS NULL`, + [input.collectionId, resolved.user_id] + ); + if (existing.rows[0]) throw invalidInvitationCode(); + invitationCodeId = resolved.id; + targetUserId = resolved.user_id; + await connection.query( + `UPDATE collection_invitations + SET state = 'revoked', revoked_at = now(), updated_at = now() + WHERE collection_id = $1 AND target_user_id = $2 AND state = 'pending'`, + [input.collectionId, targetUserId] + ); + await connection.query( + "UPDATE collection_invitation_codes SET consumed_at = now() WHERE id = $1", + [invitationCodeId] + ); + } + + const invitationId = randomUUID(); + await insertInvitation(connection, { + id: invitationId, + collectionId: input.collectionId, + actorUserId: input.actorUserId, + targetMode, + submittedEmail, + targetUserId, + invitationCodeId, + token, + expiresAt, + snapshot + }); + await audit( + connection, + input.actorUserId, + "collection_invitation.created", + invitationId, + { collection_id: input.collectionId, role: input.role, target_mode: targetMode } + ); + await connection.query("COMMIT"); + return { + id: invitationId, + collectionId: input.collectionId, + targetMode, + submittedEmail, + role: input.role, + state: "pending", + expiresAt, + token + }; + } catch (error) { + await connection.query("ROLLBACK"); + throw error; + } finally { + connection.release(); + } +} + +export async function acceptHostedCollectionInvitation( + db: DatabasePool, + input: { userId: string; token: string } +): Promise<{ collectionId: string; membershipId: string; role: CollectionMembershipRole }> { + const located = await db.query<{ collection_id: string }>( + "SELECT collection_id FROM collection_invitations WHERE token_hash = $1", + [tokenHash(input.token)] + ); + const collectionId = located.rows[0]?.collection_id; + if (!collectionId) throw invalidInvitation(); + + const connection = await db.connect(); + try { + await connection.query("BEGIN"); + const collection = await lockActiveCollection(connection, collectionId); + const invitation = await connection.query( + `SELECT * FROM collection_invitations + WHERE token_hash = $1 AND collection_id = $2 + FOR UPDATE`, + [tokenHash(input.token), collectionId] + ); + const row = invitation.rows[0]; + if ( + !row + || row.state !== "pending" + || new Date(row.expires_at).getTime() <= Date.now() + || row.target_user_id !== input.userId + || row.target_user_id === collection.user_id + ) { + throw invalidInvitation(); + } + const activeTarget = await connection.query<{ id: string }>( + `SELECT id FROM users + WHERE id = $1 AND suspended_at IS NULL + FOR UPDATE`, + [input.userId] + ); + if (!activeTarget.rows[0]) throw invalidInvitation(); + if ( + row.submitted_email + && !await hasVerifiedEmail( + connection, + input.userId, + row.submitted_email + ) + ) { + throw invalidInvitation(); + } + + const ownerAccount = await connection.query<{ user_id: string }>( + `SELECT user_id FROM account_storage_accounts + WHERE user_id = $1 FOR UPDATE`, + [collection.user_id] + ); + if (!ownerAccount.rows[0]) throw seatUnavailable(); + const entitlement = await effectiveEntitlement(connection, collection.user_id); + if (!entitlement) throw seatUnavailable(); + const seatUsage = await connection.query<{ count: string | number }>( + `SELECT count(*) AS count FROM account_collection_member_seats + WHERE owner_user_id = $1 AND released_at IS NULL`, + [collection.user_id] + ); + if (Number(seatUsage.rows[0]?.count ?? 0) >= entitlement.maxCollectionMemberSeats) { + throw seatUnavailable(); + } + + let policy; + try { + policy = await insertHostedCollectionMembershipPolicy(connection, { + collectionId, + ownerUserId: collection.user_id, + userId: input.userId, + invitedByUserId: row.invited_by_user_id, + snapshot: invitationSnapshot(row) + }); + } catch (error) { + if (error instanceof CollectionMembershipPolicyError) { + throw invalidInvitation(); + } + throw error; + } + await connection.query( + `INSERT INTO account_collection_member_seats + (id, owner_user_id, membership_id, collection_id, member_user_id) + VALUES ($1, $2, $3, $4, $5)`, + [randomUUID(), collection.user_id, policy.membershipId, + collectionId, input.userId] + ); + const accepted = await connection.query( + `UPDATE collection_invitations + SET state = 'accepted', accepted_at = now(), + accepted_membership_id = $2, updated_at = now() + WHERE id = $1 AND state = 'pending'`, + [row.id, policy.membershipId] + ); + if (accepted.rowCount !== 1) throw invalidInvitation(); + await audit( + connection, + input.userId, + "collection_invitation.accepted", + row.id, + { + collection_id: collectionId, + membership_id: policy.membershipId, + role: policy.role + } + ); + await connection.query("COMMIT"); + return { collectionId, membershipId: policy.membershipId, role: policy.role }; + } catch (error) { + await connection.query("ROLLBACK"); + throw error; + } finally { + connection.release(); + } +} + +export async function revokeHostedCollectionInvitation( + db: DatabasePool, + input: { collectionId: string; actorUserId: string; invitationId: string } +): Promise { + const connection = await db.connect(); + try { + await connection.query("BEGIN"); + await lockActiveCollection(connection, input.collectionId); + await requireSharingManager( + connection, + input.actorUserId, + input.collectionId + ); + const revoked = await connection.query( + `UPDATE collection_invitations + SET state = 'revoked', revoked_at = now(), updated_at = now() + WHERE id = $1 AND collection_id = $2 AND state = 'pending'`, + [input.invitationId, input.collectionId] + ); + if (revoked.rowCount === 1) { + await audit( + connection, + input.actorUserId, + "collection_invitation.revoked", + input.invitationId, + { collection_id: input.collectionId } + ); + } + await connection.query("COMMIT"); + return revoked.rowCount === 1; + } catch (error) { + await connection.query("ROLLBACK"); + throw error; + } finally { + connection.release(); + } +} + +export async function listHostedCollectionMembers( + db: DatabaseQueryable, + actorUserId: string, + collectionId: string +): Promise>> { + await requireSharingManager(db, actorUserId, collectionId); + const collection = await db.query<{ + user_id: string; + name: string; + created_at: Date | string; + }>( + `SELECT collection.user_id, account.name, collection.created_at + FROM hosted_collections collection + JOIN users account ON account.id = collection.user_id + WHERE collection.id = $1 AND collection.authority_state = 'active'`, + [collectionId] + ); + if (!collection.rows[0]) throw sharingNotFound(); + const memberships = await db.query<{ + id: string; + name: string; + role: CollectionMembershipRole; + state: string; + accepted_at: Date | string; + revoked_at: Date | string | null; + }>( + `SELECT membership.id, account.name, policy.role, membership.state, + membership.accepted_at, membership.revoked_at + FROM collection_memberships membership + JOIN users account ON account.id = membership.user_id + JOIN collection_membership_policies policy + ON policy.id = COALESCE(membership.pending_policy_id, + membership.current_policy_id) + WHERE membership.collection_id = $1 + AND membership.state <> 'revoked' + ORDER BY membership.accepted_at, membership.id`, + [collectionId] + ); + const owner = collection.rows[0]; + return [ + { + kind: "owner", + name: owner.name, + role: "owner", + state: "active", + accepted_at: owner.created_at + }, + ...memberships.rows.map((membership) => ({ + kind: "member", + id: membership.id, + name: membership.name, + role: membership.role, + state: membership.state, + accepted_at: membership.accepted_at, + revoked_at: membership.revoked_at + })) + ]; +} + +export async function listHostedCollectionInvitations( + db: DatabaseQueryable, + actorUserId: string, + collectionId: string +): Promise>> { + await requireSharingManager(db, actorUserId, collectionId); + const rows = await db.query<{ + id: string; + target_mode: string; + submitted_email: string | null; + role: string; + state: string; + expires_at: Date | string; + created_at: Date | string; + }>( + `SELECT id, target_mode, submitted_email, role, state, expires_at, created_at + FROM collection_invitations + WHERE collection_id = $1 + ORDER BY created_at DESC`, + [collectionId] + ); + return rows.rows.map((row) => ({ + id: row.id, + target_mode: row.target_mode, + submitted_email: row.submitted_email, + role: row.role, + state: row.state === "pending" + && new Date(row.expires_at).getTime() <= Date.now() + ? "expired" + : row.state, + expires_at: row.expires_at, + created_at: row.created_at + })); +} + +interface InvitationRow { + id: string; + invited_by_user_id: string | null; + submitted_email: string | null; + target_user_id: string | null; + role: CollectionMembershipRole; + preset_version: number; + actions: MembershipPolicySnapshot["actions"]; + operations: MembershipPolicySnapshot["operations"]; + scope_ceiling: MembershipPolicySnapshot["scopeCeiling"]; + file_ceiling: MembershipPolicySnapshot["fileCeiling"]; + state: string; + expires_at: Date | string; +} + +async function requireSharingManager( + db: DatabaseQueryable, + userId: string, + collectionId: string +): Promise { + const access = await resolveHostedCollectionAccess(db, userId, collectionId); + if (!access?.actions.has("members.manage")) throw sharingNotFound(); +} + +async function lockActiveCollection( + db: DatabaseQueryable, + collectionId: string +): Promise<{ user_id: string }> { + const collection = await db.query<{ user_id: string }>( + `SELECT user_id FROM hosted_collections + WHERE id = $1 AND authority_state = 'active' + FOR UPDATE`, + [collectionId] + ); + if (!collection.rows[0]) throw sharingNotFound(); + return collection.rows[0]; +} + +async function ensureCollectionIdentity( + db: DatabaseQueryable, + collectionId: string, + ownerUserId: string +): Promise { + await db.query( + `INSERT INTO collection_identities (id, owner_user_id) + VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, + [collectionId, ownerUserId] + ); + const identity = await db.query<{ owner_user_id: string }>( + "SELECT owner_user_id FROM collection_identities WHERE id = $1 FOR UPDATE", + [collectionId] + ); + if (identity.rows[0]?.owner_user_id !== ownerUserId) throw sharingNotFound(); +} + +async function resolveEligibleTargetUser( + db: DatabaseQueryable, + normalizedEmail: string, + ownerUserId: string, + collectionId: string +): Promise { + const emailIdentity = await db.query<{ user_id: string }>( + `SELECT identity.user_id + FROM email_identities identity + JOIN users account ON account.id = identity.user_id + WHERE identity.normalized_email = $1 + AND identity.verified_at IS NOT NULL + AND identity.retired_at IS NULL + AND account.suspended_at IS NULL`, + [normalizedEmail] + ); + const externalIdentity = emailIdentity.rows[0] + ? { rows: [] } + : await db.query<{ user_id: string }>( + `SELECT identity.user_id + FROM external_identities identity + JOIN users account ON account.id = identity.user_id + WHERE identity.normalized_email = $1 + AND identity.email_verified = true + AND account.suspended_at IS NULL`, + [normalizedEmail] + ); + const userId = emailIdentity.rows[0]?.user_id + ?? externalIdentity.rows[0]?.user_id; + if (!userId || userId === ownerUserId) return null; + const existing = await db.query<{ id: string }>( + `SELECT id FROM collection_memberships + WHERE collection_id = $1 AND user_id = $2 AND revoked_at IS NULL`, + [collectionId, userId] + ); + return existing.rows[0] ? null : userId; +} + +async function hasVerifiedEmail( + db: DatabaseQueryable, + userId: string, + normalizedEmail: string +): Promise { + const emailIdentity = await db.query<{ id: string }>( + `SELECT id FROM email_identities + WHERE user_id = $1 AND normalized_email = $2 + AND verified_at IS NOT NULL AND retired_at IS NULL`, + [userId, normalizedEmail] + ); + if (emailIdentity.rows[0]) return true; + const externalIdentity = await db.query<{ provider: string }>( + `SELECT provider FROM external_identities + WHERE user_id = $1 AND normalized_email = $2 + AND email_verified = true`, + [userId, normalizedEmail] + ); + return Boolean(externalIdentity.rows[0]); +} + +async function insertInvitation( + db: DatabaseQueryable, + input: { + id: string; + collectionId: string; + actorUserId: string; + targetMode: "email" | "invitee_code"; + submittedEmail: string | null; + targetUserId: string | null; + invitationCodeId: string | null; + token: string; + expiresAt: Date; + snapshot: MembershipPolicySnapshot; + } +): Promise { + await db.query( + `INSERT INTO collection_invitations + (id, collection_id, invited_by_user_id, target_mode, submitted_email, + target_user_id, invitation_code_id, token_hash, role, preset_version, + actions, operations, scope_ceiling, file_ceiling, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb, $15)`, + [ + input.id, + input.collectionId, + input.actorUserId, + input.targetMode, + input.submittedEmail, + input.targetUserId, + input.invitationCodeId, + tokenHash(input.token), + input.snapshot.role, + input.snapshot.presetVersion, + JSON.stringify(input.snapshot.actions), + JSON.stringify(input.snapshot.operations), + JSON.stringify(input.snapshot.scopeCeiling), + JSON.stringify(input.snapshot.fileCeiling), + input.expiresAt + ] + ); +} + +function invitationSnapshot(row: InvitationRow): MembershipPolicySnapshot { + return { + role: row.role, + presetVersion: Number(row.preset_version), + actions: row.actions, + operations: row.operations, + scopeCeiling: row.scope_ceiling, + fileCeiling: row.file_ceiling + }; +} + +function invalidInvitation(): CollectionInvitationError { + return new CollectionInvitationError( + "invalid_collection_invitation", + "The collection invitation is invalid or unavailable." + ); +} + +function invalidInvitationCode(): CollectionInvitationError { + return new CollectionInvitationError( + "invalid_collection_invitation_code", + "The collection invitation code is invalid or unavailable." + ); +} + +function seatUnavailable(): CollectionInvitationError { + return new CollectionInvitationError( + "collection_member_seat_unavailable", + "The collection owner does not have an available member seat." + ); +} + +function sharingNotFound(): CollectionInvitationError { + return new CollectionInvitationError( + "collection_sharing_not_found", + "Collection sharing is unavailable." + ); +} diff --git a/services/server/src/collection-membership-binding.ts b/services/server/src/collection-membership-binding.ts new file mode 100644 index 00000000..d8cc35cf --- /dev/null +++ b/services/server/src/collection-membership-binding.ts @@ -0,0 +1,38 @@ +import type { CollectionAccessContext } from "./collection-access.js"; + +export interface CollectionMembershipBinding { + membershipId: string; + policyId: string; + policyRevision: number; +} + +export interface StoredCollectionMembershipBinding { + membership_id: string | null; + membership_policy_id: string | null; + membership_policy_revision: number | null; +} + +export function membershipBindingForAccess( + access: CollectionAccessContext +): CollectionMembershipBinding | null { + return access.relationship === "member" + ? { + membershipId: access.membershipId!, + policyId: access.policyId!, + policyRevision: access.policyRevision + } + : null; +} + +export function matchesMembershipBinding( + stored: StoredCollectionMembershipBinding, + expected: CollectionMembershipBinding | null +): boolean { + return expected + ? stored.membership_id === expected.membershipId + && stored.membership_policy_id === expected.policyId + && Number(stored.membership_policy_revision) === expected.policyRevision + : stored.membership_id === null + && stored.membership_policy_id === null + && stored.membership_policy_revision === null; +} diff --git a/services/server/src/collection-membership-lifecycle.test.ts b/services/server/src/collection-membership-lifecycle.test.ts new file mode 100644 index 00000000..866de864 --- /dev/null +++ b/services/server/src/collection-membership-lifecycle.test.ts @@ -0,0 +1,281 @@ +import { randomUUID } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDatabase, type DatabasePool } from "./db.js"; +import type { HostedProviderClient } from "./hosted-provider.js"; +import { ProviderRevocationWorker } from "./hosted-capability-lifecycle.js"; +import { + changeHostedCollectionMembershipRole, + revokeHostedCollectionMembership +} from "./collection-membership-lifecycle.js"; +import { + createHostedCollectionMembership, + resolveActiveMembershipPolicy +} from "./collection-policy.js"; + +let database: DatabasePool | undefined; +afterEach(async () => database?.end()); + +describe("hosted collection membership lifecycle", () => { + it("changes an unused membership immediately with a new immutable policy", async () => { + database = await createDatabase("memory"); + const fixture = await membershipFixture(database, "editor"); + + const changed = await changeHostedCollectionMembershipRole(database, { + collectionId: fixture.collectionId, + actorUserId: fixture.ownerId, + membershipId: fixture.policy.membershipId, + role: "viewer" + }); + + expect(changed).toMatchObject({ + state: "active", + policyRevision: 2, + pendingProviderRevocations: 0 + }); + await expect(resolveActiveMembershipPolicy(database, { + collectionId: fixture.collectionId, + ownerUserId: fixture.ownerId, + userId: fixture.memberId + })).resolves.toMatchObject({ role: "viewer", revision: 2 }); + }); + + it("blocks access while changing and activates the new role after provider cleanup", async () => { + database = await createDatabase("memory"); + const fixture = await membershipFixture(database, "editor"); + const applicationId = randomUUID(); + const replicaId = randomUUID(); + const grantId = randomUUID(); + await database.query( + `INSERT INTO applications + (id, canonical_identity, name, homepage, redirect_uris) + VALUES ($1, $2, 'Editor', 'https://editor.example', '[]'::jsonb)`, + [applicationId, `https://editor.example/${applicationId}`] + ); + await database.query( + `INSERT INTO hosted_replicas + (id, collection_id, authorized_user_id, name, purpose, mode, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, 'Editor', 'application', 'read_write', $4, $5, 1)`, + [replicaId, fixture.collectionId, fixture.memberId, + fixture.policy.membershipId, fixture.policy.id] + ); + await database.query( + `INSERT INTO grants + (id, user_id, application_id, hosted_collection_id, + hosted_replica_id, logical_collection_id, operations, scope, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, $4, $5, $4, '["read","update"]'::jsonb, + '{"access":"full_collection","contracts":[]}'::jsonb, + $6, $7, 1)`, + [grantId, fixture.memberId, applicationId, fixture.collectionId, + replicaId, fixture.policy.membershipId, fixture.policy.id] + ); + await database.query( + `INSERT INTO access_tokens (id, token_hash, grant_id, expires_at) + VALUES ($1, $2, $3, now() + interval '1 hour')`, + [randomUUID(), `access-${randomUUID()}`, grantId] + ); + await database.query( + `INSERT INTO refresh_tokens (id, token_hash, grant_id, expires_at) + VALUES ($1, $2, $3, now() + interval '1 hour')`, + [randomUUID(), `refresh-${randomUUID()}`, grantId] + ); + + const changed = await changeHostedCollectionMembershipRole(database, { + collectionId: fixture.collectionId, + actorUserId: fixture.ownerId, + membershipId: fixture.policy.membershipId, + role: "viewer" + }); + expect(changed).toMatchObject({ state: "changing", pendingProviderRevocations: 1 }); + await expect(resolveActiveMembershipPolicy(database, { + collectionId: fixture.collectionId, + ownerUserId: fixture.ownerId, + userId: fixture.memberId + })).resolves.toBeNull(); + const revoked = await database.query<{ + grant_revoked: string | null; + replica_revoked: string | null; + access_revoked: string | null; + refresh_revoked: string | null; + }>( + `SELECT grant_record.revoked_at AS grant_revoked, + replica.revoked_at AS replica_revoked, + access_token.revoked_at AS access_revoked, + refresh_token.revoked_at AS refresh_revoked + FROM grants grant_record + JOIN hosted_replicas replica ON replica.id = grant_record.hosted_replica_id + JOIN access_tokens access_token ON access_token.grant_id = grant_record.id + JOIN refresh_tokens refresh_token ON refresh_token.grant_id = grant_record.id + WHERE grant_record.id = $1`, + [grantId] + ); + expect(revoked.rows[0]).toMatchObject({ + grant_revoked: expect.anything(), + replica_revoked: expect.anything(), + access_revoked: expect.anything(), + refresh_revoked: expect.anything() + }); + + const revokeReplica = vi.fn(); + const revokeNotificationGrant = vi.fn(); + const worker = new ProviderRevocationWorker(database, { + revokeReplica, + revokeNotificationGrant + } as unknown as HostedProviderClient); + await expect(worker.drain()).resolves.toBe(1); + expect(revokeReplica).toHaveBeenCalledWith(replicaId); + expect(revokeNotificationGrant).toHaveBeenCalledWith(fixture.collectionId, grantId); + await expect(resolveActiveMembershipPolicy(database, { + collectionId: fixture.collectionId, + ownerUserId: fixture.ownerId, + userId: fixture.memberId + })).resolves.toMatchObject({ role: "viewer", revision: 2 }); + }); + + it("keeps revocation pending until every member replica is removed", async () => { + database = await createDatabase("memory"); + const fixture = await membershipFixture(database, "viewer"); + const replicaIds = [randomUUID(), randomUUID()]; + for (const replicaId of replicaIds) { + await database.query( + `INSERT INTO hosted_replicas + (id, collection_id, authorized_user_id, name, purpose, mode, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, 'Mirror', 'mirror', 'read_only', $4, $5, 1)`, + [replicaId, fixture.collectionId, fixture.memberId, + fixture.policy.membershipId, fixture.policy.id] + ); + } + + const result = await revokeHostedCollectionMembership(database, { + collectionId: fixture.collectionId, + actorUserId: fixture.ownerId, + membershipId: fixture.policy.membershipId + }); + expect(result).toMatchObject({ state: "revoking", pendingProviderRevocations: 2 }); + await expect(resolveActiveMembershipPolicy(database, { + collectionId: fixture.collectionId, + ownerUserId: fixture.ownerId, + userId: fixture.memberId + })).resolves.toBeNull(); + + const revokeReplica = vi.fn(); + const worker = new ProviderRevocationWorker(database, { + revokeReplica, + revokeNotificationGrant: vi.fn() + } as unknown as HostedProviderClient); + await expect(worker.drain(1)).resolves.toBe(1); + const pending = await database.query<{ state: string; revoked_at: string | null }>( + "SELECT state, revoked_at FROM collection_memberships WHERE id = $1", + [fixture.policy.membershipId] + ); + expect(pending.rows[0]).toEqual({ state: "revoking", revoked_at: null }); + + await expect(worker.drain()).resolves.toBe(1); + const completed = await database.query<{ state: string; revoked_at: string | null }>( + "SELECT state, revoked_at FROM collection_memberships WHERE id = $1", + [fixture.policy.membershipId] + ); + expect(completed.rows[0]?.state).toBe("revoked"); + expect(completed.rows[0]?.revoked_at).not.toBeNull(); + expect(new Set(revokeReplica.mock.calls.map(([id]) => id))) + .toEqual(new Set(replicaIds)); + }); + + it("reconciles a transition after a crash following durable job completion", async () => { + database = await createDatabase("memory"); + const fixture = await membershipFixture(database, "viewer"); + await database.query( + `INSERT INTO hosted_replicas + (id, collection_id, authorized_user_id, name, purpose, mode, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, 'Mirror', 'mirror', 'read_only', $4, $5, 1)`, + [randomUUID(), fixture.collectionId, fixture.memberId, + fixture.policy.membershipId, fixture.policy.id] + ); + await revokeHostedCollectionMembership(database, { + collectionId: fixture.collectionId, + actorUserId: fixture.ownerId, + membershipId: fixture.policy.membershipId + }); + await database.query( + `UPDATE provider_revocation_jobs + SET state = 'completed', completed_at = now()` + ); + + const worker = new ProviderRevocationWorker(database, { + revokeReplica: vi.fn(), + revokeNotificationGrant: vi.fn() + } as unknown as HostedProviderClient); + await expect(worker.drain()).resolves.toBe(0); + await expect(database.query<{ state: string }>( + "SELECT state FROM collection_memberships WHERE id = $1", + [fixture.policy.membershipId] + )).resolves.toMatchObject({ rows: [{ state: "revoked" }] }); + }); + + it("allows an editor to manage another member but denies an outsider", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const editorId = await insertUser(database, "editor@example.com"); + const viewerId = await insertUser(database, "viewer@example.com"); + const outsiderId = await insertUser(database, "outsider@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + await createHostedCollectionMembership(database, { + collectionId, ownerUserId: ownerId, userId: editorId, role: "editor" + }); + const viewer = await createHostedCollectionMembership(database, { + collectionId, ownerUserId: ownerId, userId: viewerId, role: "viewer" + }); + + await expect(changeHostedCollectionMembershipRole(database, { + collectionId, + actorUserId: outsiderId, + membershipId: viewer.membershipId, + role: "editor" + })).rejects.toThrow("members.manage"); + await expect(changeHostedCollectionMembershipRole(database, { + collectionId, + actorUserId: editorId, + membershipId: viewer.membershipId, + role: "editor" + })).resolves.toMatchObject({ state: "active", policyRevision: 2 }); + }); +}); + +async function membershipFixture( + db: DatabasePool, + role: "viewer" | "editor" +) { + const ownerId = await insertUser(db, `${randomUUID()}@owner.example`); + const memberId = await insertUser(db, `${randomUUID()}@member.example`); + const collectionId = await insertHostedCollection(db, ownerId); + const policy = await createHostedCollectionMembership(db, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role + }); + return { ownerId, memberId, collectionId, policy }; +} + +async function insertUser(db: DatabasePool, email: string): Promise { + const id = randomUUID(); + await db.query( + "INSERT INTO users (id, email, name) VALUES ($1, $2, 'User')", + [id, email] + ); + return id; +} + +async function insertHostedCollection(db: DatabasePool, ownerId: string): Promise { + const id = randomUUID(); + await db.query( + `INSERT INTO hosted_collections + (id, user_id, display_name, template, provider_url, authority_state) + VALUES ($1, $2, 'Shared', 'mdbase', 'https://provider.example', 'active')`, + [id, ownerId] + ); + return id; +} diff --git a/services/server/src/collection-membership-lifecycle.ts b/services/server/src/collection-membership-lifecycle.ts new file mode 100644 index 00000000..65f53bdf --- /dev/null +++ b/services/server/src/collection-membership-lifecycle.ts @@ -0,0 +1,454 @@ +import { randomUUID } from "node:crypto"; +import { + requireCollectionAction, + resolveHostedCollectionAccess +} from "./collection-access.js"; +import { + CollectionMembershipPolicyError, + membershipPolicyPreset, + type CollectionMembershipRole +} from "./collection-policy.js"; +import type { + DatabaseConnection, + DatabasePool, + DatabaseQueryable +} from "./db.js"; +import { audit } from "./platform/audit-events.js"; + +export interface MembershipTransitionResult { + membershipId: string; + state: "active" | "changing" | "revoking" | "revoked"; + policyId?: string; + policyRevision?: number; + pendingProviderRevocations: number; +} + +export async function changeHostedCollectionMembershipRole( + db: DatabasePool, + input: { + collectionId: string; + actorUserId: string; + membershipId: string; + role: CollectionMembershipRole; + } +): Promise { + const connection = await db.connect(); + try { + await connection.query("BEGIN"); + await lockActiveHostedCollection(connection, input.collectionId); + requireCollectionAction( + await resolveHostedCollectionAccess(connection, input.actorUserId, input.collectionId), + "members.manage" + ); + const membership = await activeMembershipForUpdate( + connection, + input.collectionId, + input.membershipId + ); + const revision = Number(membership.current_policy_revision) + 1; + const policyId = randomUUID(); + const preset = membershipPolicyPreset(input.role); + await connection.query( + `INSERT INTO collection_membership_policies + (id, membership_id, revision, role, preset_version, actions, + operations, scope_ceiling, file_ceiling) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb)`, + [ + policyId, + membership.id, + revision, + preset.role, + preset.presetVersion, + JSON.stringify(preset.actions), + JSON.stringify(preset.operations), + JSON.stringify(preset.scopeCeiling), + JSON.stringify(preset.fileCeiling) + ] + ); + const pendingProviderRevocations = await revokeDerivedCapabilities( + connection, + membership.id, + membership.user_id, + input.collectionId, + "membership_role_change" + ); + if (pendingProviderRevocations === 0) { + await connection.query( + `UPDATE collection_memberships + SET current_policy_id = $2, current_policy_revision = $3, + state = 'active', updated_at = now() + WHERE id = $1`, + [membership.id, policyId, revision] + ); + } else { + await connection.query( + `UPDATE collection_memberships + SET pending_policy_id = $2, pending_policy_revision = $3, + state = 'changing', updated_at = now() + WHERE id = $1`, + [membership.id, policyId, revision] + ); + } + await audit( + connection, + input.actorUserId, + "collection_membership.role_change_requested", + membership.id, + { + collection_id: input.collectionId, + role: input.role, + policy_revision: revision, + state: pendingProviderRevocations === 0 ? "active" : "changing" + } + ); + if (pendingProviderRevocations === 0) { + await audit( + connection, + input.actorUserId, + "collection_membership.role_changed", + membership.id, + { collection_id: input.collectionId, policy_revision: revision } + ); + } + await connection.query("COMMIT"); + return { + membershipId: membership.id, + state: pendingProviderRevocations === 0 ? "active" : "changing", + policyId, + policyRevision: revision, + pendingProviderRevocations + }; + } catch (error) { + await connection.query("ROLLBACK"); + throw error; + } finally { + connection.release(); + } +} + +export async function revokeHostedCollectionMembership( + db: DatabasePool, + input: { + collectionId: string; + actorUserId: string; + membershipId: string; + } +): Promise { + const connection = await db.connect(); + try { + await connection.query("BEGIN"); + await lockActiveHostedCollection(connection, input.collectionId); + requireCollectionAction( + await resolveHostedCollectionAccess(connection, input.actorUserId, input.collectionId), + "members.manage" + ); + const membership = await activeMembershipForUpdate( + connection, + input.collectionId, + input.membershipId + ); + const pendingProviderRevocations = await revokeDerivedCapabilities( + connection, + membership.id, + membership.user_id, + input.collectionId, + "membership_revoked" + ); + if (pendingProviderRevocations === 0) { + await connection.query( + `UPDATE collection_memberships + SET state = 'revoked', revoked_at = now(), updated_at = now() + WHERE id = $1`, + [membership.id] + ); + await connection.query( + `UPDATE account_collection_member_seats + SET released_at = COALESCE(released_at, now()) + WHERE membership_id = $1`, + [membership.id] + ); + } else { + await connection.query( + `UPDATE collection_memberships + SET state = 'revoking', updated_at = now() + WHERE id = $1`, + [membership.id] + ); + } + await audit( + connection, + input.actorUserId, + "collection_membership.revocation_requested", + membership.id, + { + collection_id: input.collectionId, + state: pendingProviderRevocations === 0 ? "revoked" : "revoking" + } + ); + if (pendingProviderRevocations === 0) { + await audit( + connection, + input.actorUserId, + "collection_membership.revoked", + membership.id, + { collection_id: input.collectionId } + ); + } + await connection.query("COMMIT"); + return { + membershipId: membership.id, + state: pendingProviderRevocations === 0 ? "revoked" : "revoking", + pendingProviderRevocations + }; + } catch (error) { + await connection.query("ROLLBACK"); + throw error; + } finally { + connection.release(); + } +} + +export async function finalizeReadyMembershipTransitions( + db: DatabasePool +): Promise { + const candidates = await db.query<{ id: string; collection_id: string }>( + `SELECT id, collection_id FROM collection_memberships + WHERE state IN ('changing', 'revoking')` + ); + let finalized = 0; + for (const candidate of candidates.rows) { + const connection = await db.connect(); + try { + await connection.query("BEGIN"); + await connection.query( + "SELECT id FROM hosted_collections WHERE id = $1 FOR UPDATE", + [candidate.collection_id] + ); + const current = await connection.query<{ + state: "changing" | "revoking"; + pending_policy_id: string | null; + pending_policy_revision: number | null; + }>( + `SELECT state, pending_policy_id, pending_policy_revision + FROM collection_memberships + WHERE id = $1 AND collection_id = $2 + AND state IN ('changing', 'revoking') + FOR UPDATE`, + [candidate.id, candidate.collection_id] + ); + const membership = current.rows[0]; + if (!membership) { + await connection.query("COMMIT"); + continue; + } + const pending = await connection.query<{ count: string | number }>( + `SELECT count(*) AS count + FROM hosted_replicas replica + JOIN provider_revocation_jobs job ON job.replica_id = replica.id + WHERE replica.membership_id = $1 AND job.completed_at IS NULL`, + [candidate.id] + ); + if (Number(pending.rows[0]?.count ?? 0) !== 0) { + await connection.query("COMMIT"); + continue; + } + if (membership.state === "changing") { + if (!membership.pending_policy_id || !membership.pending_policy_revision) { + await connection.query("ROLLBACK"); + continue; + } + const updated = await connection.query( + `UPDATE collection_memberships + SET current_policy_id = pending_policy_id, + current_policy_revision = pending_policy_revision, + pending_policy_id = NULL, pending_policy_revision = NULL, + state = 'active', updated_at = now() + WHERE id = $1 AND state = 'changing' + AND pending_policy_id = $2 AND pending_policy_revision = $3`, + [candidate.id, membership.pending_policy_id, + membership.pending_policy_revision] + ); + if (updated.rowCount === 1) { + await audit( + connection, + null, + "collection_membership.role_changed", + candidate.id, + { + collection_id: candidate.collection_id, + policy_revision: membership.pending_policy_revision + } + ); + } + finalized += updated.rowCount ?? 0; + } else { + const updated = await connection.query( + `UPDATE collection_memberships + SET state = 'revoked', revoked_at = COALESCE(revoked_at, now()), + updated_at = now() + WHERE id = $1 AND state = 'revoking'`, + [candidate.id] + ); + if (updated.rowCount === 1) { + await connection.query( + `UPDATE account_collection_member_seats + SET released_at = COALESCE(released_at, now()) + WHERE membership_id = $1`, + [candidate.id] + ); + } + if (updated.rowCount === 1) { + await audit( + connection, + null, + "collection_membership.revoked", + candidate.id, + { collection_id: candidate.collection_id } + ); + } + finalized += updated.rowCount ?? 0; + } + await connection.query("COMMIT"); + } catch (error) { + await connection.query("ROLLBACK"); + throw error; + } finally { + connection.release(); + } + } + return finalized; +} + +async function lockActiveHostedCollection( + db: DatabaseQueryable, + collectionId: string +): Promise { + const result = await db.query<{ id: string }>( + `SELECT id FROM hosted_collections + WHERE id = $1 AND authority_state = 'active' + FOR UPDATE`, + [collectionId] + ); + if (!result.rows[0]) { + throw new CollectionMembershipPolicyError( + "collection_unavailable", + "The collection is not available for membership changes." + ); + } +} + +async function activeMembershipForUpdate( + db: DatabaseQueryable, + collectionId: string, + membershipId: string +): Promise<{ + id: string; + user_id: string; + current_policy_revision: number; +}> { + const result = await db.query<{ + id: string; + user_id: string; + current_policy_revision: number; + }>( + `SELECT id, user_id, current_policy_revision + FROM collection_memberships + WHERE id = $1 AND collection_id = $2 + AND state = 'active' AND revoked_at IS NULL + FOR UPDATE`, + [membershipId, collectionId] + ); + const membership = result.rows[0]; + if (!membership) { + throw new CollectionMembershipPolicyError( + "membership_unavailable", + "The membership is not available for changes." + ); + } + return membership; +} + +async function revokeDerivedCapabilities( + db: DatabaseConnection, + membershipId: string, + userId: string, + collectionId: string, + reason: string +): Promise { + const replicas = await db.query<{ id: string }>( + `SELECT id FROM hosted_replicas + WHERE membership_id = $1 AND collection_id = $2 + AND authorized_user_id = $3 AND revoked_at IS NULL + FOR UPDATE`, + [membershipId, collectionId, userId] + ); + const grants = await db.query<{ + id: string; + hosted_replica_id: string; + }>( + `SELECT id, hosted_replica_id FROM grants + WHERE membership_id = $1 + AND hosted_replica_id IN ( + SELECT id FROM hosted_replicas + WHERE membership_id = $1 AND collection_id = $2 + AND authorized_user_id = $3 + )`, + [membershipId, collectionId, userId] + ); + const grantByReplica = new Map( + grants.rows.map((grant) => [grant.hosted_replica_id, grant.id]) + ); + await db.query( + `UPDATE grants + SET revoked_at = COALESCE(revoked_at, now()) + WHERE membership_id = $1 AND user_id = $2 + AND logical_collection_id = $3`, + [membershipId, userId, collectionId] + ); + await db.query( + `UPDATE access_tokens SET revoked_at = COALESCE(revoked_at, now()) + WHERE grant_id IN ( + SELECT id FROM grants WHERE membership_id = $1 + )`, + [membershipId] + ); + await db.query( + `UPDATE refresh_tokens SET revoked_at = COALESCE(revoked_at, now()) + WHERE grant_id IN ( + SELECT id FROM grants WHERE membership_id = $1 + )`, + [membershipId] + ); + await db.query( + `UPDATE hosted_replicas + SET revoked_at = COALESCE(revoked_at, now()), token_hash = NULL + WHERE membership_id = $1 AND collection_id = $2 + AND authorized_user_id = $3`, + [membershipId, collectionId, userId] + ); + await db.query( + `DELETE FROM mirror_pairing_requests + WHERE replica_id IN ( + SELECT id FROM hosted_replicas WHERE membership_id = $1 + )`, + [membershipId] + ); + await db.query( + `UPDATE authorization_requests + SET denied_at = COALESCE(denied_at, now()) + WHERE user_id = $1 AND collection_id = $2 + AND completed_at IS NULL AND denied_at IS NULL`, + [userId, collectionId] + ); + for (const replica of replicas.rows) { + await db.query( + `INSERT INTO provider_revocation_jobs + (id, replica_id, grant_id, collection_id, reason) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING`, + [randomUUID(), replica.id, grantByReplica.get(replica.id) ?? null, + collectionId, reason] + ); + } + return replicas.rows.length; +} diff --git a/services/server/src/collection-policy.test.ts b/services/server/src/collection-policy.test.ts new file mode 100644 index 00000000..a3ec1e7f --- /dev/null +++ b/services/server/src/collection-policy.test.ts @@ -0,0 +1,422 @@ +import { randomUUID } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { COLLECTION_OPERATIONS } from "@mdbase-dev/connect-protocol"; +import { createDatabase, type DatabasePool } from "./db.js"; +import type { HostedProviderClient } from "./hosted-provider.js"; +import { issueApplicationTokens } from "./features/authorizations/token-service.js"; +import { + createHostedCollectionMembership, + membershipPolicyPreset, + resolveActiveMembershipPolicy +} from "./collection-policy.js"; + +let database: DatabasePool | undefined; +afterEach(async () => database?.end()); + +describe("collection membership policy", () => { + it("freezes exact viewer and editor ceilings without owner authority", () => { + const viewer = membershipPolicyPreset("viewer"); + expect(viewer.actions).toEqual([ + "collection.discover", + "record.read", + "application.authorize", + "mirror.enroll" + ]); + expect(viewer.operations).not.toContain("update"); + expect(viewer.operations).not.toContain("apply_collection_setup"); + expect(viewer.operations).toContain("read_type"); + expect(viewer.operations).toContain("assess_collection_setup"); + expect(viewer.fileCeiling.actions).toEqual(["list", "read"]); + + const editor = membershipPolicyPreset("editor"); + expect(editor.operations).toEqual(COLLECTION_OPERATIONS); + expect(editor.actions).toEqual([ + "collection.discover", + "record.read", + "application.authorize", + "mirror.enroll", + "record.write", + "schema.manage", + "collection.rename", + "members.manage" + ]); + expect(editor.actions).not.toContain("collection.delete"); + expect(editor.actions).not.toContain("authority.transfer"); + expect(editor.fileCeiling.actions).toEqual([ + "list", "read", "add", "replace", "move", "delete" + ]); + }); + + it("creates and resolves one immutable hosted membership policy", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const memberId = await insertUser(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + + const created = await createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role: "editor" + }); + const resolved = await resolveActiveMembershipPolicy(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId + }); + + expect(resolved).toEqual(created); + expect(resolved?.role).toBe("editor"); + expect(resolved?.operations).toEqual(COLLECTION_OPERATIONS); + const stored = await database.query<{ + current_policy_id: string; + current_policy_revision: number; + }>( + `SELECT current_policy_id, current_policy_revision + FROM collection_memberships WHERE id = $1`, + [created.membershipId] + ); + expect(stored.rows[0]).toEqual({ + current_policy_id: created.id, + current_policy_revision: 1 + }); + }); + + it("rejects owner memberships, duplicate active memberships, and inactive authorities", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const memberId = await insertUser(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + + await expect(createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: ownerId, + role: "editor" + })).rejects.toMatchObject({ code: "owner_is_not_member" }); + + await createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role: "viewer" + }); + await expect(createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role: "editor" + })).rejects.toMatchObject({ code: "membership_exists" }); + + await database.query( + "UPDATE hosted_collections SET authority_state = 'transferring' WHERE id = $1", + [collectionId] + ); + await expect(createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: await insertUser(database, "later@example.com"), + role: "viewer" + })).rejects.toMatchObject({ code: "collection_unavailable" }); + }); + + it("fails closed for revoked, stale, malformed, or owner-mismatched policies", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const otherOwnerId = await insertUser(database, "other-owner@example.com"); + const memberId = await insertUser(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + const policy = await createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role: "viewer" + }); + + await database.query( + "UPDATE collection_memberships SET current_policy_revision = 2 WHERE id = $1", + [policy.membershipId] + ); + await expect(resolveActiveMembershipPolicy(database, { + collectionId, ownerUserId: ownerId, userId: memberId + })).resolves.toBeNull(); + await database.query( + "UPDATE collection_memberships SET current_policy_revision = 1 WHERE id = $1", + [policy.membershipId] + ); + await database.query( + `UPDATE collection_membership_policies + SET operations = '["future_operation"]'::jsonb WHERE id = $1`, + [policy.id] + ); + await expect(resolveActiveMembershipPolicy(database, { + collectionId, ownerUserId: ownerId, userId: memberId + })).resolves.toBeNull(); + + await database.query( + "UPDATE collection_membership_policies SET operations = $2::jsonb WHERE id = $1", + [policy.id, JSON.stringify(membershipPolicyPreset("viewer").operations)] + ); + await database.query( + "UPDATE collection_identities SET owner_user_id = $2 WHERE id = $1", + [collectionId, otherOwnerId] + ); + await expect(resolveActiveMembershipPolicy(database, { + collectionId, ownerUserId: ownerId, userId: memberId + })).resolves.toBeNull(); + + await database.query( + "UPDATE collection_identities SET owner_user_id = $2 WHERE id = $1", + [collectionId, ownerId] + ); + await database.query( + `UPDATE collection_memberships + SET state = 'revoked', revoked_at = now() WHERE id = $1`, + [policy.membershipId] + ); + await expect(resolveActiveMembershipPolicy(database, { + collectionId, ownerUserId: ownerId, userId: memberId + })).resolves.toBeNull(); + }); + + it("relationally binds hosted grants and replicas to one user, collection, and policy", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const memberId = await insertUser(database, "member@example.com"); + const otherId = await insertUser(database, "other@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + const otherCollectionId = await insertHostedCollection(database, ownerId); + const applicationId = randomUUID(); + await database.query( + `INSERT INTO applications + (id, canonical_identity, name, homepage, redirect_uris) + VALUES ($1, $2, 'Editor', 'https://editor.example', '[]'::jsonb)`, + [applicationId, `https://editor.example/${applicationId}`] + ); + const policy = await createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role: "editor" + }); + const replicaId = randomUUID(); + await database.query( + `INSERT INTO hosted_replicas + (id, collection_id, authorized_user_id, name, purpose, mode, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, 'Editor', 'application', 'read_write', + $4, $5, $6)`, + [ + replicaId, + collectionId, + memberId, + policy.membershipId, + policy.id, + policy.revision + ] + ); + await expect(database.query( + `INSERT INTO grants + (id, user_id, application_id, hosted_collection_id, + hosted_replica_id, logical_collection_id, operations, scope, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, $4, $5, $4, '["read"]'::jsonb, + '{"access":"full_collection","contracts":[]}'::jsonb, + $6, $7, $8)`, + [ + randomUUID(), + memberId, + applicationId, + collectionId, + replicaId, + policy.membershipId, + policy.id, + policy.revision + ] + )).resolves.toBeDefined(); + + await expect(database.query( + `INSERT INTO grants + (id, user_id, application_id, hosted_collection_id, + hosted_replica_id, logical_collection_id, operations, scope, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, $4, $5, $4, '["read"]'::jsonb, + '{"access":"full_collection","contracts":[]}'::jsonb, + $6, $7, $8)`, + [ + randomUUID(), + otherId, + applicationId, + collectionId, + replicaId, + policy.membershipId, + policy.id, + policy.revision + ] + )).rejects.toThrow(); + + await expect(database.query( + `INSERT INTO grants + (id, user_id, application_id, hosted_collection_id, + hosted_replica_id, logical_collection_id, operations, scope, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, $4, $5, $4, '["read"]'::jsonb, + '{"access":"full_collection","contracts":[]}'::jsonb, + $6, $7, $8)`, + [ + randomUUID(), + memberId, + applicationId, + otherCollectionId, + replicaId, + policy.membershipId, + policy.id, + policy.revision + ] + )).rejects.toThrow(); + + await expect(database.query( + `INSERT INTO hosted_replicas + (id, collection_id, authorized_user_id, name, purpose, mode, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, 'Wrong user', 'application', 'read_write', + $4, $5, $6)`, + [ + randomUUID(), + collectionId, + otherId, + policy.membershipId, + policy.id, + policy.revision + ] + )).rejects.toThrow(); + }); + + it("refuses token issuance after the membership policy revision changes", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const memberId = await insertUser(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + const applicationId = randomUUID(); + await database.query( + `INSERT INTO applications + (id, canonical_identity, name, homepage, redirect_uris) + VALUES ($1, $2, 'Editor', 'https://editor.example', '[]'::jsonb)`, + [applicationId, `https://editor.example/${applicationId}`] + ); + const policy = await createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role: "editor" + }); + const replicaId = randomUUID(); + const grantId = randomUUID(); + await database.query( + `INSERT INTO hosted_replicas + (id, collection_id, authorized_user_id, name, purpose, mode, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, 'Editor', 'application', 'read_write', + $4, $5, $6)`, + [replicaId, collectionId, memberId, policy.membershipId, policy.id, policy.revision] + ); + await database.query( + `INSERT INTO grants + (id, user_id, application_id, hosted_collection_id, + hosted_replica_id, logical_collection_id, operations, scope, + membership_id, membership_policy_id, membership_policy_revision) + VALUES ($1, $2, $3, $4, $5, $4, '["read","update"]'::jsonb, + '{"access":"full_collection","contracts":[]}'::jsonb, + $6, $7, $8)`, + [grantId, memberId, applicationId, collectionId, replicaId, + policy.membershipId, policy.id, policy.revision] + ); + const rotateReplicaToken = vi.fn(); + const provider = { rotateReplicaToken } as unknown as HostedProviderClient; + + await expect(issueApplicationTokens(database, provider, grantId)) + .resolves.toMatchObject({ collection_id: collectionId, grant_id: grantId }); + expect(rotateReplicaToken).toHaveBeenCalledTimes(1); + + const replacementId = randomUUID(); + const viewer = membershipPolicyPreset("viewer"); + await database.query( + `INSERT INTO collection_membership_policies + (id, membership_id, revision, role, preset_version, actions, + operations, scope_ceiling, file_ceiling) + VALUES ($1, $2, 2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb)`, + [replacementId, policy.membershipId, viewer.role, viewer.presetVersion, + JSON.stringify(viewer.actions), JSON.stringify(viewer.operations), + JSON.stringify(viewer.scopeCeiling), JSON.stringify(viewer.fileCeiling)] + ); + await database.query( + `UPDATE collection_memberships + SET current_policy_id = $2, current_policy_revision = 2 + WHERE id = $1`, + [policy.membershipId, replacementId] + ); + + await expect(issueApplicationTokens(database, provider, grantId)) + .rejects.toThrow("no longer matches"); + expect(rotateReplicaToken).toHaveBeenCalledTimes(1); + }); + + it("enforces viewer/editor roles and one active membership in the database", async () => { + database = await createDatabase("memory"); + const ownerId = await insertUser(database, "owner@example.com"); + const memberId = await insertUser(database, "member@example.com"); + const collectionId = await insertHostedCollection(database, ownerId); + const policy = await createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role: "viewer" + }); + + await expect(database.query( + `INSERT INTO collection_membership_policies + (id, membership_id, revision, role, preset_version, actions, + operations, scope_ceiling, file_ceiling) + SELECT $1, membership_id, 2, 'owner', 1, actions, operations, + scope_ceiling, file_ceiling + FROM collection_membership_policies WHERE id = $2`, + [randomUUID(), policy.id] + )).rejects.toThrow(); + + await database.query( + `UPDATE collection_memberships + SET state = 'revoked', revoked_at = now() WHERE id = $1`, + [policy.membershipId] + ); + await expect(createHostedCollectionMembership(database, { + collectionId, + ownerUserId: ownerId, + userId: memberId, + role: "editor" + })).resolves.toMatchObject({ role: "editor", revision: 1 }); + }); +}); + +async function insertUser(db: DatabasePool, email: string): Promise { + const id = randomUUID(); + await db.query( + "INSERT INTO users (id, email, name) VALUES ($1, $2, 'User')", + [id, email] + ); + return id; +} + +async function insertHostedCollection( + db: DatabasePool, + ownerId: string +): Promise { + const id = randomUUID(); + await db.query( + `INSERT INTO hosted_collections + (id, user_id, display_name, template, provider_url, authority_state) + VALUES ($1, $2, 'Shared collection', 'mdbase', + 'https://provider.example', 'active')`, + [id, ownerId] + ); + return id; +} diff --git a/services/server/src/collection-policy.ts b/services/server/src/collection-policy.ts new file mode 100644 index 00000000..be278aee --- /dev/null +++ b/services/server/src/collection-policy.ts @@ -0,0 +1,365 @@ +import { randomUUID } from "node:crypto"; +import { + COLLECTION_OPERATIONS, + type CollectionOperation, + type FileAction, + type FileCapability, + type GrantScope +} from "@mdbase-dev/connect-protocol"; +import { z } from "zod"; +import { requiresWriteReplica } from "./collection-operation-policy.js"; +import { collectionContractDescriptorSchema } from "./protocol-schemas.js"; +import type { + DatabasePool, + DatabaseQueryable +} from "./db.js"; + +export const COLLECTION_ACTIONS = [ + "collection.discover", + "record.read", + "record.write", + "application.authorize", + "mirror.enroll", + "schema.manage", + "collection.rename", + "collection.delete", + "authority.transfer", + "members.manage" +] as const; + +export type CollectionAction = typeof COLLECTION_ACTIONS[number]; + +export const COLLECTION_MEMBERSHIP_ROLES = ["viewer", "editor"] as const; +export type CollectionMembershipRole = typeof COLLECTION_MEMBERSHIP_ROLES[number]; +export type CollectionRole = "owner" | CollectionMembershipRole; + +export const COLLECTION_MEMBERSHIP_PRESET_VERSION = 1; + +const FILE_ACTIONS = ["list", "read", "add", "replace", "move", "delete"] as const; + +const VIEWER_ACTIONS: readonly CollectionAction[] = [ + "collection.discover", + "record.read", + "application.authorize", + "mirror.enroll" +]; + +const EDITOR_ACTIONS: readonly CollectionAction[] = [ + ...VIEWER_ACTIONS, + "record.write", + "schema.manage", + "collection.rename", + "members.manage" +]; + +const VIEWER_OPERATIONS: readonly CollectionOperation[] = COLLECTION_OPERATIONS.filter( + (operation) => !requiresWriteReplica(operation) +); + +const VIEWER_FILE_ACTIONS: readonly FileAction[] = ["list", "read"]; +const EDITOR_FILE_ACTIONS: readonly FileAction[] = [...FILE_ACTIONS]; + +const uniqueArray = (item: T) => z.array(item).min(1).superRefine( + (values, context) => { + if (new Set(values).size !== values.length) { + context.addIssue({ code: "custom", message: "Values must be unique." }); + } + } +); + +const actionArraySchema = uniqueArray(z.enum(COLLECTION_ACTIONS)); +const operationArraySchema = uniqueArray(z.enum(COLLECTION_OPERATIONS)); +const fileActionArraySchema = uniqueArray(z.enum(FILE_ACTIONS)); +const grantScopeSchema = z.discriminatedUnion("access", [ + z.object({ + access: z.literal("full_collection"), + contracts: z.array(collectionContractDescriptorSchema).max(0) + }).strict(), + z.object({ + access: z.literal("contract"), + contracts: z.array(collectionContractDescriptorSchema) + }).strict() +]); +const fileScopeSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("collection") }).strict(), + z.object({ + kind: z.literal("selected_folders"), + folders: uniqueArray(z.string().trim().min(1).max(1024)) + }).strict() +]); +const fileCapabilitySchema = z.object({ + kind: z.literal("files"), + protocol_version: z.literal(1), + actions: fileActionArraySchema, + scope: fileScopeSchema +}).strict(); + +const storedPolicySchema = z.object({ + id: z.string().uuid(), + membership_id: z.string().uuid(), + collection_id: z.string().uuid(), + user_id: z.string().uuid(), + owner_user_id: z.string().uuid(), + revision: z.coerce.number().int().positive(), + role: z.enum(COLLECTION_MEMBERSHIP_ROLES), + preset_version: z.coerce.number().int().positive(), + actions: actionArraySchema, + operations: operationArraySchema, + scope_ceiling: grantScopeSchema, + file_ceiling: fileCapabilitySchema +}).strict(); + +export interface CollectionMembershipPolicy { + id: string; + membershipId: string; + collectionId: string; + userId: string; + ownerUserId: string; + revision: number; + role: CollectionMembershipRole; + presetVersion: number; + actions: readonly CollectionAction[]; + operations: readonly CollectionOperation[]; + scopeCeiling: GrantScope; + fileCeiling: FileCapability; +} + +export interface MembershipPolicySnapshot { + role: CollectionMembershipRole; + presetVersion: number; + actions: readonly CollectionAction[]; + operations: readonly CollectionOperation[]; + scopeCeiling: GrantScope; + fileCeiling: FileCapability; +} + +export function membershipPolicyPreset( + role: CollectionMembershipRole +): MembershipPolicySnapshot { + const editor = role === "editor"; + return { + role, + presetVersion: COLLECTION_MEMBERSHIP_PRESET_VERSION, + actions: [...(editor ? EDITOR_ACTIONS : VIEWER_ACTIONS)], + operations: [...(editor ? COLLECTION_OPERATIONS : VIEWER_OPERATIONS)], + scopeCeiling: { access: "full_collection", contracts: [] }, + fileCeiling: { + kind: "files", + protocol_version: 1, + actions: [...(editor ? EDITOR_FILE_ACTIONS : VIEWER_FILE_ACTIONS)], + scope: { kind: "collection" } + } + }; +} + +/** + * Internal foundation used by tests and, later, invitation acceptance. It is + * deliberately not exposed as an HTTP operation. The hosted collection row is + * locked so membership creation cannot race an authority transition. + */ +export async function createHostedCollectionMembership( + db: DatabasePool, + input: { + collectionId: string; + ownerUserId: string; + userId: string; + role: CollectionMembershipRole; + invitedByUserId?: string; + } +): Promise { + const connection = await db.connect(); + try { + await connection.query("BEGIN"); + const collection = await connection.query<{ user_id: string; authority_state: string }>( + `SELECT user_id, authority_state FROM hosted_collections + WHERE id = $1 FOR UPDATE`, + [input.collectionId] + ); + const authority = collection.rows[0]; + if ( + !authority + || authority.user_id !== input.ownerUserId + || authority.authority_state !== "active" + ) { + throw new CollectionMembershipPolicyError( + "collection_unavailable", + "The collection is not available for membership changes." + ); + } + const policy = await insertHostedCollectionMembershipPolicy(connection, { + collectionId: input.collectionId, + ownerUserId: input.ownerUserId, + userId: input.userId, + invitedByUserId: input.invitedByUserId ?? input.ownerUserId, + snapshot: membershipPolicyPreset(input.role) + }); + await connection.query("COMMIT"); + return policy; + } catch (error) { + await connection.query("ROLLBACK"); + throw error; + } finally { + connection.release(); + } +} + +export async function insertHostedCollectionMembershipPolicy( + db: DatabaseQueryable, + input: { + collectionId: string; + ownerUserId: string; + userId: string; + invitedByUserId: string | null; + snapshot: MembershipPolicySnapshot; + } +): Promise { + if (input.userId === input.ownerUserId) { + throw new CollectionMembershipPolicyError( + "owner_is_not_member", + "The collection owner cannot be represented as a membership." + ); + } + await db.query( + `INSERT INTO collection_identities (id, owner_user_id) + VALUES ($1, $2) + ON CONFLICT (id) DO NOTHING`, + [input.collectionId, input.ownerUserId] + ); + const identity = await db.query<{ owner_user_id: string }>( + "SELECT owner_user_id FROM collection_identities WHERE id = $1 FOR UPDATE", + [input.collectionId] + ); + if (identity.rows[0]?.owner_user_id !== input.ownerUserId) { + throw new CollectionMembershipPolicyError( + "collection_identity_mismatch", + "The collection identity does not match its current owner." + ); + } + const existing = await db.query<{ id: string }>( + `SELECT id FROM collection_memberships + WHERE collection_id = $1 AND user_id = $2 AND revoked_at IS NULL + FOR UPDATE`, + [input.collectionId, input.userId] + ); + if (existing.rows[0]) { + throw new CollectionMembershipPolicyError( + "membership_exists", + "The user already has an active membership for this collection." + ); + } + const parsedSnapshot = storedPolicySchema.pick({ + role: true, + preset_version: true, + actions: true, + operations: true, + scope_ceiling: true, + file_ceiling: true + }).safeParse({ + role: input.snapshot.role, + preset_version: input.snapshot.presetVersion, + actions: input.snapshot.actions, + operations: input.snapshot.operations, + scope_ceiling: input.snapshot.scopeCeiling, + file_ceiling: input.snapshot.fileCeiling + }); + if (!parsedSnapshot.success) { + throw new CollectionMembershipPolicyError( + "invalid_policy_snapshot", + "The membership policy snapshot is invalid." + ); + } + const membershipId = randomUUID(); + const policyId = randomUUID(); + const revision = 1; + const snapshot = parsedSnapshot.data; + await db.query( + `INSERT INTO collection_memberships + (id, collection_id, user_id, invited_by_user_id) + VALUES ($1, $2, $3, $4)`, + [membershipId, input.collectionId, input.userId, input.invitedByUserId] + ); + await db.query( + `INSERT INTO collection_membership_policies + (id, membership_id, revision, role, preset_version, actions, + operations, scope_ceiling, file_ceiling) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb)`, + [ + policyId, + membershipId, + revision, + snapshot.role, + snapshot.preset_version, + JSON.stringify(snapshot.actions), + JSON.stringify(snapshot.operations), + JSON.stringify(snapshot.scope_ceiling), + JSON.stringify(snapshot.file_ceiling) + ] + ); + await db.query( + `UPDATE collection_memberships + SET current_policy_id = $2, current_policy_revision = $3, updated_at = now() + WHERE id = $1`, + [membershipId, policyId, revision] + ); + return { + id: policyId, + membershipId, + collectionId: input.collectionId, + userId: input.userId, + ownerUserId: input.ownerUserId, + revision, + role: snapshot.role, + presetVersion: snapshot.preset_version, + actions: snapshot.actions, + operations: snapshot.operations, + scopeCeiling: snapshot.scope_ceiling, + fileCeiling: snapshot.file_ceiling + }; +} + +export async function resolveActiveMembershipPolicy( + db: DatabaseQueryable, + input: { collectionId: string; ownerUserId: string; userId: string } +): Promise { + const result = await db.query>( + `SELECT policy.id, policy.membership_id, membership.collection_id, + membership.user_id, identity.owner_user_id, policy.revision, + policy.role, policy.preset_version, policy.actions, + policy.operations, policy.scope_ceiling, policy.file_ceiling + FROM collection_memberships membership + JOIN collection_identities identity + ON identity.id = membership.collection_id + JOIN collection_membership_policies policy + ON policy.id = membership.current_policy_id + AND policy.membership_id = membership.id + AND policy.revision = membership.current_policy_revision + WHERE membership.collection_id = $1 + AND membership.user_id = $2 + AND membership.state = 'active' + AND membership.revoked_at IS NULL`, + [input.collectionId, input.userId] + ); + const decoded = storedPolicySchema.safeParse(result.rows[0]); + if (!decoded.success || decoded.data.owner_user_id !== input.ownerUserId) return null; + return { + id: decoded.data.id, + membershipId: decoded.data.membership_id, + collectionId: decoded.data.collection_id, + userId: decoded.data.user_id, + ownerUserId: decoded.data.owner_user_id, + revision: decoded.data.revision, + role: decoded.data.role, + presetVersion: decoded.data.preset_version, + actions: decoded.data.actions, + operations: decoded.data.operations, + scopeCeiling: decoded.data.scope_ceiling, + fileCeiling: decoded.data.file_ceiling + }; +} + +export class CollectionMembershipPolicyError extends Error { + constructor(readonly code: string, message: string) { + super(message); + this.name = "CollectionMembershipPolicyError"; + } +} diff --git a/services/server/src/db.test.ts b/services/server/src/db.test.ts index 8a570839..67a820b7 100644 --- a/services/server/src/db.test.ts +++ b/services/server/src/db.test.ts @@ -56,7 +56,11 @@ describe("database migrations", () => { "0018_account_onboarding", "0019_authorization_binding_v5_compatibility", "0020_protocol_usage_telemetry", - "0021_device_authorization_origin" + "0021_device_authorization_origin", + "0022_collection_membership_foundations", + "0022a_local_collection_identity_backfill", + "0023_grant_replica_membership_binding", + "0024_hosted_collection_invitations_and_seats" ]); const columns = await db.query<{ column_name: string }>( `SELECT column_name FROM information_schema.columns @@ -70,6 +74,27 @@ describe("database migrations", () => { AND column_name = 'file_capability'` ); expect(fileCapability.rows).toHaveLength(1); + const sharingTables = await db.query<{ table_name: string }>( + `SELECT table_name FROM information_schema.tables + WHERE table_name IN ( + 'collection_identities', + 'collection_memberships', + 'collection_membership_policies', + 'collection_invitation_codes', + 'collection_invitations', + 'account_collection_member_seats' + )` + ); + expect(new Set(sharingTables.rows.map(({ table_name }) => table_name))).toEqual( + new Set([ + "collection_identities", + "collection_memberships", + "collection_membership_policies", + "collection_invitation_codes", + "collection_invitations", + "account_collection_member_seats" + ]) + ); await expect(assertControlPlaneMigrationsCurrent(db)).resolves.toBeUndefined(); await runControlPlaneMigrations(db); @@ -540,7 +565,11 @@ describe("database migrations", () => { "0018_account_onboarding", "0019_authorization_binding_v5_compatibility", "0020_protocol_usage_telemetry", - "0021_device_authorization_origin" + "0021_device_authorization_origin", + "0022_collection_membership_foundations", + "0022a_local_collection_identity_backfill", + "0023_grant_replica_membership_binding", + "0024_hosted_collection_invitations_and_seats" ]); }); @@ -677,6 +706,62 @@ describe("database migrations", () => { expect(replica.rows[0].authorized_user_id).toBe(userId); }); + it("backfills one stable sharing identity across hosted and local authority rows", async () => { + const db = await openDatabase("memory"); + resources.push(() => db.end()); + await bootstrapLegacyBaseline(db); + const ownerId = randomUUID(); + const connectorId = randomUUID(); + const authorityRowId = randomUUID(); + const localOnlyAuthorityRowId = randomUUID(); + const logicalCollectionId = randomUUID(); + const localOnlyCollectionId = randomUUID(); + await db.query( + "INSERT INTO users (id, email, name) VALUES ($1, $2, 'Owner')", + [ownerId, `${ownerId}@example.com`] + ); + await db.query( + `INSERT INTO connectors (id, user_id, name, token_hash) + VALUES ($1, $2, 'Computer', $3)`, + [connectorId, ownerId, randomUUID()] + ); + await db.query( + `INSERT INTO collections + (id, user_id, connector_id, local_id, display_name, spec_version, + authority_state) + VALUES ($1, $2, $3, $4, 'Local authority', '0.3.0', 'active'), + ($5, $2, $3, $6, 'Local only', '0.3.0', 'active')`, + [ + authorityRowId, + ownerId, + connectorId, + logicalCollectionId, + localOnlyAuthorityRowId, + localOnlyCollectionId + ] + ); + await db.query( + `INSERT INTO hosted_collections + (id, user_id, display_name, template, authority_state, + transferred_collection_id) + VALUES ($1, $2, 'Hosted history', 'mdbase', 'transferred', $3)`, + [logicalCollectionId, ownerId, authorityRowId] + ); + + await runControlPlaneMigrations(db); + + const identities = await db.query<{ id: string; owner_user_id: string }>( + `SELECT id, owner_user_id FROM collection_identities + WHERE id IN ($1, $2) ORDER BY id`, + [logicalCollectionId, localOnlyCollectionId] + ); + expect(identities.rows).toEqual( + [logicalCollectionId, localOnlyCollectionId] + .sort() + .map((id) => ({ id, owner_user_id: ownerId })) + ); + }); + it("rejects an applied migration whose contents changed", async () => { const directory = await mkdtemp(join(tmpdir(), "connect-migrations-")); resources.push(() => rm(directory, { recursive: true, force: true })); diff --git a/services/server/src/entitlements.test.ts b/services/server/src/entitlements.test.ts index 675a6adb..0a53aab2 100644 --- a/services/server/src/entitlements.test.ts +++ b/services/server/src/entitlements.test.ts @@ -52,7 +52,8 @@ describe("account entitlements", () => { maxMirrorReplicasPerCollection: 10, maxApplicationReplicasPerCollection: 50, maxHostedCollections: 10, - maxFilesPerCollection: 10_000 + maxFilesPerCollection: 10_000, + maxCollectionMemberSeats: 10 }); }); diff --git a/services/server/src/entitlements.ts b/services/server/src/entitlements.ts index cd5eb831..6d92e85d 100644 --- a/services/server/src/entitlements.ts +++ b/services/server/src/entitlements.ts @@ -18,6 +18,7 @@ export interface EffectiveEntitlement { maxApplicationReplicasPerCollection: number; maxHostedCollections: number; maxFilesPerCollection: number; + maxCollectionMemberSeats: number; } export class HostedEntitlementRequiredError extends Error { @@ -44,6 +45,7 @@ interface EntitlementProfileRow { max_application_replicas_per_collection: string | number; max_hosted_collections: string | number; max_files_per_collection: string | number; + max_collection_member_seats: string | number; } export async function attachInvitationEntitlement( @@ -182,7 +184,8 @@ export async function effectiveEntitlement( profile.max_mirror_replicas_per_collection, profile.max_application_replicas_per_collection, profile.max_hosted_collections, - profile.max_files_per_collection + profile.max_files_per_collection, + profile.max_collection_member_seats FROM account_entitlement_grants entitlement_grant JOIN entitlement_profiles profile ON profile.code = entitlement_grant.profile_code @@ -210,7 +213,11 @@ export async function effectiveEntitlement( "max_application_replicas_per_collection" ), maxHostedCollections: maximum(profiles.rows, "max_hosted_collections"), - maxFilesPerCollection: maximum(profiles.rows, "max_files_per_collection") + maxFilesPerCollection: maximum(profiles.rows, "max_files_per_collection"), + maxCollectionMemberSeats: maximum( + profiles.rows, + "max_collection_member_seats" + ) }; } diff --git a/services/server/src/features/account/me-routes.ts b/services/server/src/features/account/me-routes.ts index 7c81f888..16a4fcd1 100644 --- a/services/server/src/features/account/me-routes.ts +++ b/services/server/src/features/account/me-routes.ts @@ -235,7 +235,8 @@ export function registerAccountOverviewRoute( max_application_replicas_per_collection: entitlement.maxApplicationReplicasPerCollection, max_hosted_collections: entitlement.maxHostedCollections, - max_files_per_collection: entitlement.maxFilesPerCollection + max_files_per_collection: entitlement.maxFilesPerCollection, + max_collection_member_seats: entitlement.maxCollectionMemberSeats }, usage: hostedUsage ? { hosted_collections: hostedUsage.collection_count, diff --git a/services/server/src/features/authorizations/approval-service.ts b/services/server/src/features/authorizations/approval-service.ts index 9147be38..161d3610 100644 --- a/services/server/src/features/authorizations/approval-service.ts +++ b/services/server/src/features/authorizations/approval-service.ts @@ -19,10 +19,15 @@ import { } from "@mdbase-dev/connect-protocol"; import { requireCollectionAction, + resolveHostedCollectionAccess, resolveLocalCollectionAccess, type CollectionAccessContext } from "../../collection-access.js"; import type { DatabasePool } from "../../db.js"; +import { + matchesMembershipBinding, + membershipBindingForAccess +} from "../../collection-membership-binding.js"; import { contractRequirements } from "../../hosted.js"; import { HostedProviderClient } from "../../hosted-provider.js"; import { hostedReplicaCollectionOperations } from "../../hosted-replica-policy.js"; @@ -629,6 +634,19 @@ export async function approveHostedAuthorization( "SELECT id FROM hosted_collections WHERE id = $1 FOR UPDATE", [input.collectionId] ); + const currentAccess = requireCollectionAction( + await resolveHostedCollectionAccess( + connection, + input.userId, + input.collectionId + ), + "application.authorize" + ); + if (currentAccess.collection.authorityState !== "active") { + throw new RequestValidationError( + "This hosted collection is not available for application authorization." + ); + } if (pending.collection_id && pending.collection_id !== input.collectionId) { throw new RequestValidationError( "This authorization request is restricted to a different collection." @@ -684,7 +702,7 @@ export async function approveHostedAuthorization( const hasApplicationSetup = provisions.length > 0 || (pending.provisions.configuration?.length ?? 0) > 0; if (hasApplicationSetup) { - requireCollectionAction(input.access, "schema.manage"); + requireCollectionAction(currentAccess, "schema.manage"); const setupResult = await provider.provisionApplicationSetup( input.collectionId, { @@ -730,7 +748,7 @@ export async function approveHostedAuthorization( pending.requested_operations as CollectionOperation[], requirements: pending.requirements, availableContracts: availableDescriptors, - access: input.access + access: currentAccess }); const scope = plan.scope; const allowedTypes = allowedTypesForRequirements( @@ -751,12 +769,18 @@ export async function approveHostedAuthorization( : undefined; const applicationInstallationId = pending.application_authorization.binding.application_installation_id; + const membershipBinding = membershipBindingForAccess(currentAccess); const existing = await connection.query<{ id: string; hosted_replica_id: string; application_installation_id: string | null; + membership_id: string | null; + membership_policy_id: string | null; + membership_policy_revision: number | null; }>( - `SELECT id, hosted_replica_id, application_installation_id + `SELECT id, hosted_replica_id, application_installation_id, + membership_id, membership_policy_id, + membership_policy_revision FROM grants WHERE user_id = $1 AND application_id = $2 AND hosted_collection_id = $3 AND revoked_at IS NULL @@ -771,11 +795,13 @@ export async function approveHostedAuthorization( applicationInstallationId ] ); - const retained = existing.rows[0]; + const retained = existing.rows.find((candidate) => + matchesMembershipBinding(candidate, membershipBinding) + ); const grantId = retained?.id ?? randomUUID(); replicaId = retained?.hosted_replica_id ?? randomUUID(); - for (const duplicate of existing.rows.slice(1)) { + for (const duplicate of existing.rows.filter((candidate) => candidate !== retained)) { await provider.revokeReplica(duplicate.hosted_replica_id); await connection.query( "UPDATE hosted_replicas SET revoked_at = now() WHERE id = $1", @@ -811,9 +837,18 @@ export async function approveHostedAuthorization( await provider.updateApplicationReplica(replicaId, replicaPolicy); await connection.query( `UPDATE hosted_replicas - SET mode = $2, allowed_types = $3::jsonb, revoked_at = NULL + SET mode = $2, allowed_types = $3::jsonb, revoked_at = NULL, + membership_id = $4, membership_policy_id = $5, + membership_policy_revision = $6 WHERE id = $1`, - [replicaId, plan.replicaMode, JSON.stringify(allowedTypes)] + [ + replicaId, + plan.replicaMode, + JSON.stringify(allowedTypes), + membershipBinding?.membershipId ?? null, + membershipBinding?.policyId ?? null, + membershipBinding?.policyRevision ?? null + ] ); await connection.query( `UPDATE grants SET @@ -822,6 +857,8 @@ export async function approveHostedAuthorization( file_capability = $6::jsonb, notification_criteria = $7::jsonb, application_authorization = $8::jsonb, application_installation_id = $9, + logical_collection_id = $10, membership_id = $11, + membership_policy_id = $12, membership_policy_revision = $13, activated_at = now(), revoked_at = NULL WHERE id = $1`, [ @@ -833,7 +870,11 @@ export async function approveHostedAuthorization( plan.fileCapability ? JSON.stringify(plan.fileCapability) : null, JSON.stringify(pending.notifications.criteria), JSON.stringify(pending.application_authorization), - applicationInstallationId + applicationInstallationId, + input.collectionId, + membershipBinding?.membershipId ?? null, + membershipBinding?.policyId ?? null, + membershipBinding?.policyRevision ?? null ] ); await connection.query( @@ -855,15 +896,20 @@ export async function approveHostedAuthorization( await connection.query( `INSERT INTO hosted_replicas (id, collection_id, authorized_user_id, name, purpose, mode, - allowed_types, token_hash) - VALUES ($1, $2, $3, $4, 'application', $5, $6::jsonb, NULL)`, + allowed_types, token_hash, membership_id, membership_policy_id, + membership_policy_revision) + VALUES ($1, $2, $3, $4, 'application', $5, $6::jsonb, NULL, + $7, $8, $9)`, [ replicaId, input.collectionId, input.userId, `${pending.application_name} application access`, plan.replicaMode, - JSON.stringify(allowedTypes) + JSON.stringify(allowedTypes), + membershipBinding?.membershipId ?? null, + membershipBinding?.policyId ?? null, + membershipBinding?.policyRevision ?? null ] ); await connection.query( @@ -871,9 +917,10 @@ export async function approveHostedAuthorization( (id, user_id, application_id, hosted_collection_id, hosted_replica_id, operations, scope, encryption, proof_public_key, application_origin, file_capability, notification_criteria, application_authorization, - application_installation_id) + application_installation_id, logical_collection_id, membership_id, + membership_policy_id, membership_policy_revision) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, NULL, $8, $9, - $10::jsonb, $11::jsonb, $12::jsonb, $13)`, + $10::jsonb, $11::jsonb, $12::jsonb, $13, $14, $15, $16, $17)`, [ grantId, input.userId, @@ -887,7 +934,11 @@ export async function approveHostedAuthorization( plan.fileCapability ? JSON.stringify(plan.fileCapability) : null, JSON.stringify(pending.notifications.criteria), JSON.stringify(pending.application_authorization), - applicationInstallationId + applicationInstallationId, + input.collectionId, + membershipBinding?.membershipId ?? null, + membershipBinding?.policyId ?? null, + membershipBinding?.policyRevision ?? null ] ); } diff --git a/services/server/src/features/authorizations/token-service.ts b/services/server/src/features/authorizations/token-service.ts index 170c310e..cba31edb 100644 --- a/services/server/src/features/authorizations/token-service.ts +++ b/services/server/src/features/authorizations/token-service.ts @@ -7,9 +7,14 @@ import type { import { requireCollectionAction, resolveHostedCollectionAccess, - resolveLocalCollectionAccess + resolveLocalCollectionAccess, + type CollectionAccessContext } from "../../collection-access.js"; import type { DatabaseQueryable } from "../../db.js"; +import { + matchesMembershipBinding, + membershipBindingForAccess +} from "../../collection-membership-binding.js"; import type { HostedProviderClient } from "../../hosted-provider.js"; import { randomToken, tokenHash } from "../../security.js"; import { authorityUrl } from "../../platform/authority-url.js"; @@ -57,6 +62,12 @@ export async function issueApplicationTokens( file_capability: FileCapability | null; proof_public_key: string | null; application_origin: string; + membership_id: string | null; + membership_policy_id: string | null; + membership_policy_revision: number | null; + replica_membership_id: string | null; + replica_membership_policy_id: string | null; + replica_membership_policy_revision: number | null; }>( `SELECT g.user_id, COALESCE(col.local_id, g.hosted_collection_id) AS collection_id, @@ -64,7 +75,11 @@ export async function issueApplicationTokens( COALESCE(col.display_name, hosted.display_name) AS collection_name, g.hosted_collection_id, g.hosted_replica_id, hosted.provider_url, g.operations, g.scope, g.encryption, g.file_capability, - g.proof_public_key, + g.proof_public_key, g.membership_id, g.membership_policy_id, + g.membership_policy_revision, + replica.membership_id AS replica_membership_id, + replica.membership_policy_id AS replica_membership_policy_id, + replica.membership_policy_revision AS replica_membership_policy_revision, CASE WHEN g.application_origin = '' THEN app.homepage ELSE g.application_origin END AS application_origin FROM grants g @@ -81,7 +96,7 @@ export async function issueApplicationTokens( ); if (!grant.rows[0]) throw new RequestValidationError("The application grant is no longer active."); if (grant.rows[0].hosted_collection_id) { - requireCollectionAction( + const access = requireCollectionAction( await resolveHostedCollectionAccess( db, grant.rows[0].user_id, @@ -89,6 +104,7 @@ export async function issueApplicationTokens( ), "application.authorize" ); + assertCurrentMembershipBinding(grant.rows[0], access); } else if (grant.rows[0].local_authority_row_id) { requireCollectionAction( await resolveLocalCollectionAccess( @@ -165,3 +181,29 @@ export async function issueApplicationTokens( ...(authority ? { authority } : {}) }; } + +function assertCurrentMembershipBinding( + grant: { + membership_id: string | null; + membership_policy_id: string | null; + membership_policy_revision: number | null; + replica_membership_id: string | null; + replica_membership_policy_id: string | null; + replica_membership_policy_revision: number | null; + }, + access: CollectionAccessContext +): void { + const expected = membershipBindingForAccess(access); + if ( + !matchesMembershipBinding(grant, expected) + || !matchesMembershipBinding({ + membership_id: grant.replica_membership_id, + membership_policy_id: grant.replica_membership_policy_id, + membership_policy_revision: grant.replica_membership_policy_revision + }, expected) + ) { + throw new RequestValidationError( + "The application grant no longer matches the current collection membership." + ); + } +} diff --git a/services/server/src/features/hosted/account-routes.ts b/services/server/src/features/hosted/account-routes.ts index fc1679e4..815dbb50 100644 --- a/services/server/src/features/hosted/account-routes.ts +++ b/services/server/src/features/hosted/account-routes.ts @@ -202,10 +202,10 @@ export function registerHostedAccountRoutes( id: string; display_name: string; }>( - `UPDATE hosted_collections SET display_name = $3 - WHERE id = $1 AND user_id = $2 + `UPDATE hosted_collections SET display_name = $2 + WHERE id = $1 AND authority_state = 'active' RETURNING id, display_name`, - [collectionId, user.id, input.display_name] + [collectionId, input.display_name] ); if (!renamed.rows[0]) { return hostedCollectionNotFound(reply); @@ -255,11 +255,20 @@ export function registerHostedAccountRoutes( WHERE hosted_collection_id = $1 AND user_id = $2`, [collectionId, user.id] ); - await connection.query( + const deleted = await connection.query<{ id: string }>( `DELETE FROM hosted_collections - WHERE id = $1 AND user_id = $2`, + WHERE id = $1 AND user_id = $2 + RETURNING id`, [collectionId, user.id] ); + if (!deleted.rows[0]) { + await connection.query("ROLLBACK"); + return hostedCollectionNotFound(reply); + } + await connection.query( + "DELETE FROM collection_identities WHERE id = $1", + [collectionId] + ); await audit( connection, user.id, diff --git a/services/server/src/features/hosted/service.ts b/services/server/src/features/hosted/service.ts index e9f0f520..3d423d64 100644 --- a/services/server/src/features/hosted/service.ts +++ b/services/server/src/features/hosted/service.ts @@ -426,10 +426,10 @@ export async function renameHostedCollectionForUser( id: string; display_name: string; }>( - `UPDATE hosted_collections SET display_name = $3 - WHERE id = $1 AND user_id = $2 + `UPDATE hosted_collections SET display_name = $2 + WHERE id = $1 AND authority_state = 'active' RETURNING id, display_name`, - [collectionId, userId, displayName] + [collectionId, displayName] ); await audit( options.db, @@ -468,11 +468,20 @@ export async function deleteHostedCollectionForUser( WHERE hosted_collection_id = $1 AND user_id = $2`, [collectionId, userId] ); - await connection.query( + const deleted = await connection.query<{ id: string }>( `DELETE FROM hosted_collections - WHERE id = $1 AND user_id = $2`, + WHERE id = $1 AND user_id = $2 + RETURNING id`, [collectionId, userId] ); + if (!deleted.rows[0]) { + await connection.query("ROLLBACK"); + return false; + } + await connection.query( + "DELETE FROM collection_identities WHERE id = $1", + [collectionId] + ); await audit( connection, userId, diff --git a/services/server/src/features/hosted/sharing-routes.ts b/services/server/src/features/hosted/sharing-routes.ts new file mode 100644 index 00000000..4b52e5d3 --- /dev/null +++ b/services/server/src/features/hosted/sharing-routes.ts @@ -0,0 +1,271 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { CollectionAccessDeniedError } from "../../collection-access.js"; +import { + acceptHostedCollectionInvitation, + CollectionInvitationError, + createCollectionInvitationCode, + createHostedCollectionInvitation, + listHostedCollectionInvitations, + listHostedCollectionMembers, + revokeHostedCollectionInvitation +} from "../../collection-invitations.js"; +import { + changeHostedCollectionMembershipRole, + revokeHostedCollectionMembership +} from "../../collection-membership-lifecycle.js"; +import { COLLECTION_MEMBERSHIP_ROLES } from "../../collection-policy.js"; +import type { DatabasePool } from "../../db.js"; +import { requireUser } from "../../platform/request-authentication.js"; + +interface HostedSharingRoutesOptions { + db: DatabasePool; + hostedCollections?: boolean; + tailscaleAuth?: boolean; +} + +const collectionParams = z.object({ collectionId: z.uuid() }); +const invitationParams = collectionParams.extend({ invitationId: z.uuid() }); +const membershipParams = collectionParams.extend({ membershipId: z.uuid() }); +const roleSchema = z.enum(COLLECTION_MEMBERSHIP_ROLES); + +export function registerHostedSharingRoutes( + app: FastifyInstance, + options: HostedSharingRoutesOptions +): void { + app.post( + "/v1/hosted/collection-invitation-codes", + { config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } }, + async (request, reply) => { + const user = await requireUser( + request, + reply, + options.db, + options.tailscaleAuth + ); + if (!user) return; + requireHostedSharing(options); + const created = await createCollectionInvitationCode(options.db, user.id); + return reply.header("cache-control", "no-store").code(201).send({ + invitation_code: created.code, + expires_at: created.expiresAt + }); + } + ); + + app.post( + "/v1/hosted/collections/:collectionId/invitations", + { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (request, reply) => { + const user = await requireUser( + request, + reply, + options.db, + options.tailscaleAuth + ); + if (!user) return; + requireHostedSharing(options); + const { collectionId } = collectionParams.parse(request.params); + const body = z.union([ + z.object({ email: z.string().trim().min(3).max(320), role: roleSchema }).strict(), + z.object({ invitee_code: z.string().trim().min(8).max(32), role: roleSchema }).strict() + ]).parse(request.body); + const invitation = await createHostedCollectionInvitation(options.db, { + collectionId, + actorUserId: user.id, + role: body.role, + target: "email" in body + ? { email: body.email } + : { inviteeCode: body.invitee_code } + }); + return reply.header("cache-control", "no-store").code(202).send({ + invitation: { + id: invitation.id, + collection_id: invitation.collectionId, + target_mode: invitation.targetMode, + submitted_email: invitation.submittedEmail, + role: invitation.role, + state: invitation.state, + expires_at: invitation.expiresAt, + token: invitation.token + } + }); + } + ); + + app.get( + "/v1/hosted/collections/:collectionId/invitations", + async (request, reply) => { + const user = await requireUser( + request, + reply, + options.db, + options.tailscaleAuth + ); + if (!user) return; + requireHostedSharing(options); + const { collectionId } = collectionParams.parse(request.params); + reply.header("cache-control", "no-store"); + return { + invitations: await listHostedCollectionInvitations( + options.db, + user.id, + collectionId + ) + }; + } + ); + + app.delete( + "/v1/hosted/collections/:collectionId/invitations/:invitationId", + async (request, reply) => { + const user = await requireUser( + request, + reply, + options.db, + options.tailscaleAuth + ); + if (!user) return; + requireHostedSharing(options); + const { collectionId, invitationId } = invitationParams.parse(request.params); + const revoked = await revokeHostedCollectionInvitation(options.db, { + collectionId, + actorUserId: user.id, + invitationId + }); + if (!revoked) throw sharingNotFound(); + return reply.code(204).send(); + } + ); + + app.post( + "/v1/hosted/collection-invitations/accept", + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + const user = await requireUser( + request, + reply, + options.db, + options.tailscaleAuth + ); + if (!user) return; + requireHostedSharing(options); + const { token } = z.object({ + token: z.string().trim().min(16).max(256) + }).strict().parse(request.body); + const accepted = await acceptHostedCollectionInvitation(options.db, { + userId: user.id, + token + }); + return reply.header("cache-control", "no-store").code(201).send({ + membership: { + id: accepted.membershipId, + collection_id: accepted.collectionId, + role: accepted.role, + state: "active" + } + }); + } + ); + + app.get( + "/v1/hosted/collections/:collectionId/members", + async (request, reply) => { + const user = await requireUser( + request, + reply, + options.db, + options.tailscaleAuth + ); + if (!user) return; + requireHostedSharing(options); + const { collectionId } = collectionParams.parse(request.params); + reply.header("cache-control", "no-store"); + return { + members: await listHostedCollectionMembers(options.db, user.id, collectionId) + }; + } + ); + + app.patch( + "/v1/hosted/collections/:collectionId/members/:membershipId", + async (request, reply) => { + const user = await requireUser( + request, + reply, + options.db, + options.tailscaleAuth + ); + if (!user) return; + requireHostedSharing(options); + const { collectionId, membershipId } = membershipParams.parse(request.params); + const { role } = z.object({ role: roleSchema }).strict().parse(request.body); + const changed = await hideMembershipAuthorization(() => + changeHostedCollectionMembershipRole(options.db, { + collectionId, + actorUserId: user.id, + membershipId, + role + }) + ); + return reply.code(changed.state === "changing" ? 202 : 200).send({ + membership: { + id: changed.membershipId, + state: changed.state, + role, + policy_revision: changed.policyRevision, + pending_provider_revocations: changed.pendingProviderRevocations + } + }); + } + ); + + app.delete( + "/v1/hosted/collections/:collectionId/members/:membershipId", + async (request, reply) => { + const user = await requireUser( + request, + reply, + options.db, + options.tailscaleAuth + ); + if (!user) return; + requireHostedSharing(options); + const { collectionId, membershipId } = membershipParams.parse(request.params); + const revoked = await hideMembershipAuthorization(() => + revokeHostedCollectionMembership(options.db, { + collectionId, + actorUserId: user.id, + membershipId + }) + ); + return reply.code(revoked.state === "revoking" ? 202 : 200).send({ + membership: { + id: revoked.membershipId, + state: revoked.state, + pending_provider_revocations: revoked.pendingProviderRevocations + } + }); + } + ); +} + +function requireHostedSharing(options: HostedSharingRoutesOptions): void { + if (!options.hostedCollections) throw sharingNotFound(); +} + +async function hideMembershipAuthorization(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof CollectionAccessDeniedError) throw sharingNotFound(); + throw error; + } +} + +function sharingNotFound(): CollectionInvitationError { + return new CollectionInvitationError( + "collection_sharing_not_found", + "Collection sharing is unavailable." + ); +} diff --git a/services/server/src/grant-planner.test.ts b/services/server/src/grant-planner.test.ts index 2a67c431..0f6c0bc2 100644 --- a/services/server/src/grant-planner.test.ts +++ b/services/server/src/grant-planner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { CollectionContractDescriptor, CollectionOperation, + FileCapability, GrantScope } from "@mdbase-dev/connect-protocol"; import { operationsForApplicationCapabilities } from "@mdbase-dev/connect-protocol"; @@ -101,6 +102,90 @@ describe("planCollectionGrant", () => { }); }); + it("rejects file actions and folders outside the approving user's ceiling", () => { + const viewerFiles: FileCapability = { + kind: "files", + protocol_version: 1, + actions: ["list", "read"], + scope: { kind: "selected_folders", folders: ["Shared"] } + }; + const member = { ...owner, relationship: "member" as const, fileCeiling: viewerFiles }; + + expect(() => planCollectionGrant({ + requestedOperations: [], + applicationOperationCeiling: [], + requirements: { + contracts: [], + files: { + actions: ["list", "read", "add"], + scope: { kind: "selected_folders", folders: ["Shared"] } + } + }, + availableContracts: [], + access: member + })).toThrow("file actions"); + + expect(() => planCollectionGrant({ + requestedOperations: [], + applicationOperationCeiling: [], + requirements: { + contracts: [], + files: { + actions: ["list", "read"], + scope: { kind: "collection" } + } + }, + availableContracts: [], + access: member + })).toThrow("collection-wide file access"); + + expect(() => planCollectionGrant({ + requestedOperations: [], + applicationOperationCeiling: [], + requirements: { + contracts: [], + files: { + actions: ["list", "read"], + scope: { kind: "selected_folders", folders: ["Private"] } + } + }, + availableContracts: [], + access: member + })).toThrow("file folders"); + }); + + it("accepts an exact bounded file requirement without widening it", () => { + const member = { + ...owner, + relationship: "member" as const, + fileCeiling: { + kind: "files" as const, + protocol_version: 1 as const, + actions: ["list", "read"] as const, + scope: { kind: "selected_folders" as const, folders: ["Shared"] } + } + }; + expect(planCollectionGrant({ + requestedOperations: [], + applicationOperationCeiling: [], + requirements: { + contracts: [], + files: { + actions: ["list", "read"], + scope: { kind: "selected_folders", folders: ["Shared"] } + } + }, + availableContracts: [], + access: member + })).toMatchObject({ + replicaMode: "read_only", + fileCapability: { + actions: ["list", "read"], + scope: { kind: "selected_folders", folders: ["Shared"] } + } + }); + }); + it("rejects an operation the application did not request", () => { expect(() => planCollectionGrant({ requestedOperations: ["delete"], diff --git a/services/server/src/grant-planner.ts b/services/server/src/grant-planner.ts index 3f8da63d..f74919ce 100644 --- a/services/server/src/grant-planner.ts +++ b/services/server/src/grant-planner.ts @@ -46,6 +46,7 @@ export function planCollectionGrant(input: { ) { throw new GrantPlanningError("File capabilities require at least one unique action."); } + assertFileRequirementWithinCeiling(fileRequirement, input.access.fileCeiling); } const applicationOperations = new Set(input.applicationOperationCeiling); if (operations.some((operation) => !applicationOperations.has(operation))) { @@ -93,6 +94,30 @@ export function planCollectionGrant(input: { }; } +function assertFileRequirementWithinCeiling( + requirement: NonNullable, + ceiling: FileCapability +): void { + const allowedActions = new Set(ceiling.actions); + if (requirement.actions.some((action) => !allowedActions.has(action))) { + throw new GrantPlanningError( + "The approving user may not grant one or more requested file actions." + ); + } + if (ceiling.scope.kind === "collection") return; + if (requirement.scope.kind === "collection") { + throw new GrantPlanningError( + "The approving user may not grant collection-wide file access." + ); + } + const allowedFolders = new Set(ceiling.scope.folders); + if (requirement.scope.folders.some((folder) => !allowedFolders.has(folder))) { + throw new GrantPlanningError( + "The approving user may not grant access to one or more requested file folders." + ); + } +} + export function fileCapabilityForRequirements( requirements: ApplicationRequirements ): FileCapability | undefined { diff --git a/services/server/src/hosted-capability-lifecycle.ts b/services/server/src/hosted-capability-lifecycle.ts index 0d2b8330..11b85d49 100644 --- a/services/server/src/hosted-capability-lifecycle.ts +++ b/services/server/src/hosted-capability-lifecycle.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { DatabasePool, DatabaseQueryable } from "./db.js"; import type { HostedProviderClient } from "./hosted-provider.js"; +import { finalizeReadyMembershipTransitions } from "./collection-membership-lifecycle.js"; interface RevocationJob { id: string; @@ -251,6 +252,9 @@ export class ProviderRevocationWorker { private async drainAvailable(limit: number): Promise { let completed = 0; + // Reconcile first so a crash after durable provider-job completion cannot + // strand a membership transition indefinitely. + await finalizeReadyMembershipTransitions(this.db); while (completed < limit) { const job = await claimRevocationJob(this.db); if (!job) break; @@ -268,6 +272,7 @@ export class ProviderRevocationWorker { WHERE id = $1`, [job.id] ); + await finalizeReadyMembershipTransitions(this.db); completed += 1; } catch (error) { await rescheduleRevocationJob(this.db, job, error); diff --git a/services/server/src/instance-admin.test.ts b/services/server/src/instance-admin.test.ts index fe8e1190..8e6b2231 100644 --- a/services/server/src/instance-admin.test.ts +++ b/services/server/src/instance-admin.test.ts @@ -89,9 +89,9 @@ describe("instance administration", () => { ); await db.query( `INSERT INTO hosted_replicas - (id, collection_id, name, purpose, mode, token_hash) - VALUES ($1, $2, 'Application', 'application', 'read_write', $3)`, - [replicaId, collectionId, tokenHash("provider")] + (id, collection_id, authorized_user_id, name, purpose, mode, token_hash) + VALUES ($1, $2, $3, 'Application', 'application', 'read_write', $4)`, + [replicaId, collectionId, userId, tokenHash("provider")] ); await db.query( `INSERT INTO pairing_requests @@ -301,9 +301,9 @@ describe("instance administration", () => { ); await db.query( `INSERT INTO hosted_replicas - (id, collection_id, name, purpose, mode, token_hash) - VALUES ($1, $2, 'Mirror', 'mirror', 'read_write', $3)`, - [randomUUID(), collectionId, tokenHash("provider")] + (id, collection_id, authorized_user_id, name, purpose, mode, token_hash) + VALUES ($1, $2, $3, 'Mirror', 'mirror', 'read_write', $4)`, + [randomUUID(), collectionId, userId, tokenHash("provider")] ); const service = new InstanceAdminService(db, { async revokeReplica() { diff --git a/services/server/src/platform/error-handler.ts b/services/server/src/platform/error-handler.ts index 546e6787..1c47d848 100644 --- a/services/server/src/platform/error-handler.ts +++ b/services/server/src/platform/error-handler.ts @@ -42,6 +42,8 @@ import { RelayUnavailableError } from "../relay.js"; import { CollectionAccessDeniedError } from "../collection-access.js"; +import { CollectionInvitationError } from "../collection-invitations.js"; +import { CollectionMembershipPolicyError } from "../collection-policy.js"; import { GrantPlanningError } from "../grant-planner.js"; import { apiError, @@ -80,6 +82,22 @@ export function registerErrorHandler(app: FastifyInstance): void { if (error instanceof GrantPlanningError) { return reply.code(400).send(apiError("invalid_grant", error.message)); } + if (error instanceof CollectionInvitationError) { + const status = error.code === "collection_sharing_not_found" + ? 404 + : error.code === "collection_member_seat_unavailable" + ? 409 + : 400; + return reply.code(status).send(apiError(error.code, error.message)); + } + if (error instanceof CollectionMembershipPolicyError) { + const notFound = error.code === "collection_unavailable" + || error.code === "membership_unavailable"; + return reply.code(notFound ? 404 : 409).send(apiError( + notFound ? "collection_sharing_not_found" : error.code, + notFound ? "Collection sharing is unavailable." : error.message + )); + } if (error instanceof CollectionAccessDeniedError) { return reply.code(403).send(apiError("collection_access_denied", error.message)); }