diff --git a/CHANGELOG.md b/CHANGELOG.md index f736d7ae..164edc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Runtime route, database adapter/migration, and UI implementation remain follow-up work under issue #413; the required calendar-management interaction contract is captured in Figma and traced in the doctoring record. +- **Active PR #524; not yet protected-`develop` truth:** added normalized SQLite + persistence for calendar-subscription credentials with current-hash-only + storage, atomic live-membership checks, durable rotation/usage evidence, + idempotent revocation, a secret-free audit outbox, restart-survival tests, and + c8 registration. Protected route and customer UI migration remain follow-up + work under issue #413. ### Security diff --git a/docs/doctoring/calendar-subscription-sqlite.md b/docs/doctoring/calendar-subscription-sqlite.md new file mode 100644 index 00000000..8675fb8a --- /dev/null +++ b/docs/doctoring/calendar-subscription-sqlite.md @@ -0,0 +1,151 @@ +# Calendar subscription SQLite persistence — active PR doctoring record + +> **Status:** Active stacked PR work only. Nothing in this document is protected-`develop` shipped truth until the complete stack is independently reviewed, satisfies the live rulesets on the unchanged integrated head, and reaches protected `develop`. +> +> **Stack:** issue #413 → access-grant domain (#506) → calendar-subscription domain (#514) → SQLite persistence (#524). This slice deliberately does **not** change the protected calendar HTTP route, browser UI, deployment topology, or release version. + +## Problem and bounded outcome + +The protected calendar feed still depends on a broad session credential transported in a URL. The parent calendar-subscription domain (#514) defines a separately revocable, project-bound reusable credential lifecycle, but intentionally contains no production persistence. PR #524 supplies the durable SQLite adapter needed to make that lifecycle survivable across process restarts and enforce tenant/session revocation at the same atomic state transition that records a successful credential use. + +The bounded buyer-visible value is operationally durable calendar access without storing a plaintext reusable subscription secret, while preserving immediate revocation/rotation semantics, cross-tenant nondisclosure, and immutable lifecycle evidence. Route migration and customer-facing management UI remain later slices and must not be represented as shipped by this PR. + +## Current exact implementation boundary + +`server/calendar_subscription_sqlite.mjs` owns four stable adapter surfaces: + +- `installCalendarSubscriptionSchema(database)` installs normalized persistence relations and indexes at database bootstrap; +- `createSqliteCalendarSubscriptionRepository(database)` implements the parent domain repository port; +- `createSqliteCalendarSubscriptionAuthorizationPort(database)` verifies current project-organization membership for management actions without disclosing cross-tenant resource existence; +- `createSqliteCalendarSubscriptionMembershipPort(database)` returns an opaque live `membership_id:token_version` version used by the domain and repository to reject stale credentials. + +No request handler invokes schema installation. SQLite foreign-key enforcement remains a connection/bootstrap responsibility because SQLite foreign-key enforcement is disabled by default unless enabled by the application, and changing `PRAGMA foreign_keys` within an active multi-statement transaction is ineffective. The existing server bootstrap therefore remains the correct ownership boundary for connection policy rather than this feature adapter. + +## Data model and 3NF rationale + +```mermaid +erDiagram + USERS ||--o{ CALENDAR_SUBSCRIPTIONS : subject + PROJECTS ||--o{ CALENDAR_SUBSCRIPTIONS : resource + CALENDAR_SUBSCRIPTIONS ||--o{ SUBSCRIPTION_ROTATIONS : history + CALENDAR_SUBSCRIPTIONS ||--o{ SUBSCRIPTION_USAGE_EVENTS : history + + CALENDAR_SUBSCRIPTIONS { + text subscription_id PK + text secret_hash UK + integer subject_id FK + integer project_id FK + text name + text audience + text membership_version + integer created_at_ms + integer expires_at_ms + integer last_used_at_ms + integer rotated_at_ms + integer revoked_at_ms + } + + SUBSCRIPTION_ROTATIONS { + integer rotation_event_id PK + text subscription_id FK + integer rotated_at_ms + integer expires_at_ms + } + + SUBSCRIPTION_USAGE_EVENTS { + integer usage_event_id PK + text subscription_id FK + integer used_at_ms + } + + CALENDAR_SUBSCRIPTION_AUDIT_OUTBOX { + integer audit_event_id PK + text subscription_id + text event_type + integer subject_id + integer project_id + integer occurred_at_ms + integer delivered_at_ms + } +``` + +`calendar_subscriptions` is the current authorization relation. It contains one current hash and current lifecycle state only. `subscription_rotations` and `subscription_usage_events` contain independent repeating event facts, preventing repeating groups or history arrays in the authorization row. The audit outbox is an immutable security-event ledger/delivery relation rather than a copy of current authorization state: `subject_id` and `project_id` are intentionally event attributes captured at occurrence time so retained evidence does not depend on a subsequently deleted authorization row. Its lack of a foreign key to `calendar_subscriptions` is deliberate for security-event retention and retryability after resource deletion. + +All owned table/index names contain multiple lexical words and use snake_case. The focused schema test also executes `PRAGMA foreign_key_check` and locks exact owned-object names to prevent silent naming/normalization drift. + +## Credential and tenant-security invariants + +1. The one-time plaintext credential exists only at the parent domain `create()`/`rotate()` return boundary. The SQLite adapter receives and stores only SHA-256 hashes. +2. Only the current hash remains in `calendar_subscriptions`; historical rotation, usage, and audit relations contain no secret or hash fields. Rotation therefore cannot create a credential-hash archive. +3. Calendar audience is fixed to `scopeweave:calendar`; authorization additionally binds the credential to exactly one project. +4. Create rechecks the domain-captured membership version inside the SQLite savepoint before inserting state. +5. Use performs a conditional update that simultaneously verifies current hash, project, audience, non-revocation, pre-expiry time, stored membership-version snapshot, and independently resolved live membership/session version before it records `last_used_at_ms` and usage evidence. +6. Removing and re-adding an organization membership changes the membership-row identity. Session-wide invalidation changes `users.token_version`. Either change makes an already issued credential unusable until an authenticated operator explicitly rotates it. +7. Rotation rechecks current management authorization and live membership, replaces the sole current hash, and snapshots the fresh membership version in one savepoint. The previous secret is immediately invalid and no prior hash is retained. +8. Revocation is operator-idempotent: repeated authenticated revoke requests return the already-revoked state without duplicating the durable revocation event. +9. Cross-tenant management is nondisclosing: unknown and inaccessible project management requests fail through the same parent-domain not-found boundary. + +This credential is ScopeWeave-specific and must **not** be represented as an OAuth access token. RFC 9700 is used as current threat/least-privilege evidence—particularly its guidance to reduce bearer-token exposure and applicability—not as a claim of protocol conformance. + +## Transaction and evidence design + +Repository transitions use named SQLite `SAVEPOINT` / `ROLLBACK TO` / `RELEASE` boundaries rather than unconditional `BEGIN`/`COMMIT`. SQLite documents that savepoints may be nested within an existing transaction and that `ROLLBACK TO` rewinds state without cancelling the outer transaction. This lets the adapter compose safely with a future wider request/outbox transaction instead of failing because nested `BEGIN` transactions are unsupported. + +For create, successful use, rotate, and first revoke, lifecycle state and `calendar_subscription_audit_outbox` evidence are written inside the same savepoint. A test-installed trigger forces an outbox write failure during use and verifies that `last_used_at_ms` and `subscription_usage_events` both roll back. This is the executable failure-mode evidence for the “state and durable evidence together” contract rather than an assertion-only transaction test. + +Outbox delivery itself is intentionally outside this slice. A later worker may mark `delivered_at_ms`; deterministic authorization never depends on model judgement or outbox-delivery availability. + +## TDD and acceptance evidence + +The initial test-only head imported the absent `server/calendar_subscription_sqlite.mjs`. The hosted Server Tests run failed with `ERR_MODULE_NOT_FOUND`, demonstrating that production implementation was required before the persistence contract could pass. After the adapter was added, all ten focused SQLite behavior scenarios passed together with the repository unit/API suite and cloud browser E2E in the observed hosted run. + +However, those Server Tests currently check out GitHub's synthetic `refs/pull/524/merge` SHA rather than the contributor head. The observed GREEN checkout was synthetic merge `086f0e972e858264eae0dbd88091b476d9547cda`, produced from contributor head `7f667689b237e0910d99f47bfce63e6a267d2a85` over parent `cf12559739cc3161000e6e6dedfe9370033acb7a`. Under ScopeWeave's quality contract, synthetic/predecessor evidence is explicitly non-passing. PR #523/#522 addresses that workflow defect; #524 cannot promote this run to exact-head merge evidence. + +Focused acceptance coverage includes: + +- hash-only durable create and safe list metadata; +- repeat authorization for the correct project before expiry and exact-expiry rejection; +- token-version revocation and membership remove/re-add invalidation; +- authenticated rotation after session-version change while the old secret remains invalid; +- absence of secrets/hashes from rotation, usage, and audit history relations; +- idempotent revocation with one durable revoke event; +- cross-tenant management nondisclosure; +- file-backed reopen/process-survival behavior; +- transactional rollback when durable audit evidence cannot be written; +- schema naming, normalized history relations, and foreign-key integrity. + +The canonical c8 command includes `server/calendar_subscription_sqlite.mjs` and the focused persistence test. Exact 100% statement/branch/function/line evidence remains mandatory before this PR can be considered integration-ready; a normal unit-test GREEN run does not substitute for that measurement. + +## Traceability + +| Requirement / risk | Executable evidence | Implementation boundary | Status | +| --- | --- | --- | --- | +| Reusable calendar secret is never plaintext at rest | hash-only persistence/list assertions | `calendar_subscriptions.secret_hash` | Active PR | +| Old secret is unusable after rotation | old/new authorization regression | `rotateSubscriptionAtomically` | Active PR | +| Logout-all/session revocation invalidates subscription | `token_version` mutation regression | membership version + atomic use SQL | Active PR | +| Membership removal/re-add does not revive old credential | membership-row replacement regression | opaque `membership_id:token_version` | Active PR | +| Cross-tenant project existence is not disclosed | other-tenant list/rotate regression | authorization port + parent domain mapping | Active PR | +| State and audit evidence cannot diverge on write failure | forced-outbox-failure rollback regression | savepoint transaction | Active PR | +| Durable credential survives process restart | file-backed SQLite reopen regression | SQLite repository | Active PR | +| History contains no credential material | schema introspection assertions | rotation/usage/audit relations | Active PR | +| DB object naming and referential integrity | exact object list + `foreign_key_check` | schema installer | Active PR | +| Exact-head CI evidence | must execute contributor SHA, not PR merge ref | repository workflow ownership | Blocked on #523/#522 integration | +| Independent current-head approval | qualifying independent reviewer after latest push | protected branch/ruleset governance | External governance prerequisite | +| Calendar route no longer consumes broad session JWT | future API migration | `server/app.mjs` | Planned / out of this slice | +| Customer can create/copy/rotate/revoke subscription in UI | future implementation matching Figma contract from #514 | browser client | Planned / out of this slice | + +## Rollback and recovery + +Before route integration, rollback of this slice consists of removing the SQLite adapter, focused tests, coverage registrations, doctoring entry, and changelog line together. Because no protected route or protected schema migration consumes these relations yet, that rollback creates no production credential downtime. + +After a future route migration, rollback must never restore the broad session-JWT query credential as a “safe” steady state. Durable revocation/rotation/audit history must be retained according to the future retention policy, and credential material must not be reconstructed from logs or history. + +## References + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (RFC 9700; BCP 240). RFC Editor. https://doi.org/10.17487/RFC9700 + +SQLite. (n.d.). *Savepoints*. Retrieved August 16, 2026, from https://sqlite.org/lang_savepoint.html + +SQLite. (2026, February 18). *Transaction*. https://sqlite.org/lang_transaction.html + +SQLite. (n.d.). *SQLite foreign key support*. Retrieved August 16, 2026, from https://www.sqlite.org/foreignkeys.html diff --git a/package.json b/package.json index 0b0d2dbf..5f8a6202 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/coverage-script-contract.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-race.test.mjs && node tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs && node tests/unit/coverage-script-contract.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.mjs --include=server/calendar_subscription_sqlite.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-race.test.mjs && node tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs new file mode 100644 index 00000000..d89498d8 --- /dev/null +++ b/server/calendar_subscription_sqlite.mjs @@ -0,0 +1,519 @@ +const INSERT_SAVEPOINT = 'calendar_subscription_insert_state'; +const USAGE_SAVEPOINT = 'calendar_subscription_usage_state'; +const ROTATE_SAVEPOINT = 'calendar_subscription_rotate_state'; +const REVOKE_SAVEPOINT = 'calendar_subscription_revoke_state'; +const CALENDAR_AUDIENCE = 'scopeweave:calendar'; +const CALENDAR_PURPOSE = 'calendar_read'; + +function requireDatabase(database) { + if (!database || typeof database.exec !== 'function' || typeof database.prepare !== 'function') { + throw new TypeError('calendar-subscription SQLite adapter requires a database with exec() and prepare()'); + } + return database; +} + +function normalizeSubscriptionRow(row) { + if (!row) return null; + return { + ...row, + subject_id: String(row.subject_id), + project_id: String(row.project_id), + }; +} + +function withSavepoint(database, savepointName, operation) { + database.exec(`SAVEPOINT ${savepointName}`); + try { + const result = operation(); + database.exec(`RELEASE ${savepointName}`); + return result; + } catch (error) { + let rollbackSucceeded = false; + try { + database.exec(`ROLLBACK TO ${savepointName}`); + rollbackSucceeded = true; + } catch { + // An unconfirmed rollback must leave the savepoint open rather than risk committing failed state. + } + if (rollbackSucceeded) { + try { + database.exec(`RELEASE ${savepointName}`); + } catch { + // Cleanup failure must never replace the causal operation error after state is rolled back. + } + } + throw error; + } +} + +function membershipVersionStatement(database) { + return database.prepare(` + SELECT CAST(m.id AS TEXT) || ':' || CAST(u.token_version AS TEXT) AS membership_version + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE p.id = ? AND m.user_id = ? + `); +} + +function matchLiveMembershipVersion(statement, projectId, subjectId, expectedVersion) { + const live = statement.get(projectId, subjectId); + if (!live?.membership_version || String(live.membership_version) !== String(expectedVersion)) { + return null; + } + return String(live.membership_version); +} + +function assertLiveMembershipVersion(statement, projectId, subjectId, expectedVersion) { + const membershipVersion = matchLiveMembershipVersion( + statement, + projectId, + subjectId, + expectedVersion, + ); + if (!membershipVersion) { + throw new Error('calendar_subscription_membership_inactive'); + } + return membershipVersion; +} + +/** + * Install normalized durable storage for reusable calendar subscriptions. + * + * The authorization relation stores only the currently active SHA-256 secret + * hash plus the fixed `calendar_read` purpose and the membership/session epoch + * captured when the credential was issued or rotated. Rotation and usage + * relations contain lifecycle facts only and never retain plaintext credentials + * or historical hashes. The audit outbox intentionally has no foreign key to + * the live subscription so security-event evidence survives resource deletion. + * + * Call this during database bootstrap with foreign-key enforcement enabled. + * Request handlers must never perform schema installation. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {void} + */ +export function installCalendarSubscriptionSchema(database) { + const db = requireDatabase(database); + db.exec(` + CREATE TABLE IF NOT EXISTS calendar_subscriptions ( + subscription_id TEXT PRIMARY KEY, + secret_hash TEXT NOT NULL UNIQUE CHECK(length(secret_hash) = 64), + subject_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 120), + purpose TEXT NOT NULL CHECK(purpose = '${CALENDAR_PURPOSE}'), + audience TEXT NOT NULL CHECK(audience = '${CALENDAR_AUDIENCE}'), + membership_version TEXT NOT NULL CHECK(length(membership_version) BETWEEN 1 AND 128), + created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0), + expires_at_ms INTEGER NOT NULL CHECK(expires_at_ms > created_at_ms), + last_used_at_ms INTEGER, + rotated_at_ms INTEGER, + revoked_at_ms INTEGER, + CHECK(last_used_at_ms IS NULL OR last_used_at_ms >= created_at_ms), + CHECK(rotated_at_ms IS NULL OR rotated_at_ms >= created_at_ms), + CHECK(revoked_at_ms IS NULL OR revoked_at_ms >= created_at_ms) + ); + CREATE INDEX IF NOT EXISTS calendar_subscription_secret_hash_index + ON calendar_subscriptions(secret_hash); + CREATE INDEX IF NOT EXISTS calendar_subscription_subject_project_index + ON calendar_subscriptions(subject_id, project_id, revoked_at_ms, expires_at_ms); + + CREATE TABLE IF NOT EXISTS subscription_rotations ( + rotation_event_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES calendar_subscriptions(subscription_id) ON DELETE CASCADE, + rotated_at_ms INTEGER NOT NULL CHECK(rotated_at_ms >= 0), + expires_at_ms INTEGER NOT NULL CHECK(expires_at_ms > rotated_at_ms) + ); + CREATE INDEX IF NOT EXISTS subscription_rotation_history_index + ON subscription_rotations(subscription_id, rotated_at_ms); + + CREATE TABLE IF NOT EXISTS subscription_usage_events ( + usage_event_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES calendar_subscriptions(subscription_id) ON DELETE CASCADE, + used_at_ms INTEGER NOT NULL CHECK(used_at_ms >= 0) + ); + CREATE INDEX IF NOT EXISTS subscription_usage_history_index + ON subscription_usage_events(subscription_id, used_at_ms); + + CREATE TABLE IF NOT EXISTS calendar_subscription_audit_outbox ( + audit_event_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL, + event_type TEXT NOT NULL CHECK(event_type IN ('created', 'used', 'rotated', 'revoked')), + subject_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + occurred_at_ms INTEGER NOT NULL CHECK(occurred_at_ms >= 0), + delivered_at_ms INTEGER + ); + CREATE INDEX IF NOT EXISTS calendar_subscription_audit_delivery_index + ON calendar_subscription_audit_outbox(delivered_at_ms, audit_event_id); + `); +} + +/** + * Create the SQLite implementation of the CalendarSubscriptionRepository port. + * + * Every security transition uses a savepoint so it composes with a wider caller + * transaction while still rolling back lifecycle state and durable audit-outbox + * evidence together. Create and rotate compare the membership version supplied + * by the domain with live database membership inside that transaction. Usage + * binds the supplied issuance epoch to both the stored row and independently + * resolved live membership in the same conditional UPDATE, so remove-then-rejoin + * cannot revive a durable calendar credential. Purpose and audience are checked + * at the same persistence boundary. Rotation is the only path that can bind the + * credential to a newly authorized membership epoch. + * + * Management list and revoke queries independently require live project + * membership, so authorization loss between domain preflight and persistence + * cannot disclose metadata or mutate state. Rotation replaces the sole current + * hash; no historical credential hash is copied into history relations. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {{insertSubscription: Function, listSubscriptions: Function, findSubscriptionByHash: Function, recordUsageAtomically: Function, rotateSubscriptionAtomically: Function, revokeSubscriptionAtomically: Function}} Repository adapter. + */ +export function createSqliteCalendarSubscriptionRepository(database) { + const db = requireDatabase(database); + const liveMembershipVersion = membershipVersionStatement(db); + const insertSubscription = db.prepare(` + INSERT INTO calendar_subscriptions( + subscription_id, secret_hash, subject_id, project_id, name, purpose, + audience, membership_version, created_at_ms, expires_at_ms, last_used_at_ms, + rotated_at_ms, revoked_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) + `); + const listSubscriptions = db.prepare(` + SELECT * + FROM calendar_subscriptions + WHERE subject_id = ? + AND project_id = ? + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + ) + ORDER BY created_at_ms DESC, subscription_id ASC + `); + const findByHash = db.prepare('SELECT * FROM calendar_subscriptions WHERE secret_hash = ?'); + const findScopedById = db.prepare(` + SELECT * + FROM calendar_subscriptions + WHERE subscription_id = ? AND subject_id = ? AND project_id = ? + `); + const findManageableScopedById = db.prepare(` + SELECT * + FROM calendar_subscriptions + WHERE subscription_id = ? + AND subject_id = ? + AND project_id = ? + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + ) + `); + const recordUsage = db.prepare(` + UPDATE calendar_subscriptions + SET last_used_at_ms = CASE + WHEN last_used_at_ms IS NULL OR last_used_at_ms < ? THEN ? + ELSE last_used_at_ms + END + WHERE secret_hash = ? + AND project_id = ? + AND purpose = ? + AND audience = ? + AND revoked_at_ms IS NULL + AND ? >= created_at_ms + AND ? < expires_at_ms + AND membership_version = ? + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + AND (CAST(m.id AS TEXT) || ':' || CAST(u.token_version AS TEXT)) = ? + ) + `); + const replaceSecret = db.prepare(` + UPDATE calendar_subscriptions + SET secret_hash = ?, + membership_version = ?, + expires_at_ms = ?, + rotated_at_ms = ? + WHERE subscription_id = ? + AND subject_id = ? + AND project_id = ? + AND purpose = ? + AND revoked_at_ms IS NULL + AND ? >= created_at_ms + AND ? > ? + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + AND (CAST(m.id AS TEXT) || ':' || CAST(u.token_version AS TEXT)) = ? + ) + `); + const revokeSubscription = db.prepare(` + UPDATE calendar_subscriptions + SET revoked_at_ms = ? + WHERE subscription_id = ? + AND subject_id = ? + AND project_id = ? + AND revoked_at_ms IS NULL + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + ) + `); + const insertRotation = db.prepare(` + INSERT INTO subscription_rotations(subscription_id, rotated_at_ms, expires_at_ms) + VALUES(?,?,?) + `); + const insertUsage = db.prepare(` + INSERT INTO subscription_usage_events(subscription_id, used_at_ms) + VALUES(?,?) + `); + const insertAudit = db.prepare(` + INSERT INTO calendar_subscription_audit_outbox( + subscription_id, event_type, subject_id, project_id, occurred_at_ms, delivered_at_ms + ) VALUES(?,?,?,?,?,NULL) + `); + + return Object.freeze({ + /** + * Persist one hash-only subscription after atomically rechecking the live + * membership version captured by the domain. + */ + async insertSubscription(record) { + withSavepoint(db, INSERT_SAVEPOINT, () => { + const membershipVersion = assertLiveMembershipVersion( + liveMembershipVersion, + record.project_id, + record.subject_id, + record.membership_version, + ); + insertSubscription.run( + record.subscription_id, + record.secret_hash, + record.subject_id, + record.project_id, + record.name, + record.purpose, + record.audience, + membershipVersion, + record.created_at_ms, + record.expires_at_ms, + record.last_used_at_ms, + record.rotated_at_ms, + record.revoked_at_ms, + ); + insertAudit.run( + record.subscription_id, + 'created', + record.subject_id, + record.project_id, + record.created_at_ms, + ); + }); + }, + + /** + * List scoped records only while the subject remains a live member of the + * project's organization at the persistence boundary. + */ + async listSubscriptions({ subject_id: subjectId, project_id: projectId }) { + return listSubscriptions.all(subjectId, projectId).map(normalizeSubscriptionRow); + }, + + /** Resolve the current reusable credential by its one-way SHA-256 hash. */ + async findSubscriptionByHash(secretHash) { + return normalizeSubscriptionRow(findByHash.get(secretHash)); + }, + + /** + * Record one successful use while comparing the stored issuance epoch with + * the caller-supplied issuance epoch and live membership in one transition. + */ + async recordUsageAtomically(secretHash, binding) { + return withSavepoint(db, USAGE_SAVEPOINT, () => { + const membershipVersion = String(binding.membership_version); + const result = recordUsage.run( + binding.now_ms, + binding.now_ms, + secretHash, + binding.project_id, + binding.purpose, + binding.audience, + binding.now_ms, + binding.now_ms, + membershipVersion, + membershipVersion, + ); + if (Number(result.changes) !== 1) return null; + const current = findByHash.get(secretHash); + insertUsage.run(current.subscription_id, binding.now_ms); + insertAudit.run( + current.subscription_id, + 'used', + current.subject_id, + current.project_id, + binding.now_ms, + ); + return normalizeSubscriptionRow(current); + }); + }, + + /** + * Atomically replace the sole active secret hash and bind it to the current + * live membership version. The prior hash is not retained anywhere. A + * membership change after the domain preflight returns `null` so the domain + * preserves its stable tenant-nondisclosing not-found boundary. + */ + async rotateSubscriptionAtomically(subscriptionId, binding) { + return withSavepoint(db, ROTATE_SAVEPOINT, () => { + const membershipVersion = matchLiveMembershipVersion( + liveMembershipVersion, + binding.project_id, + binding.subject_id, + binding.membership_version, + ); + if (!membershipVersion) return null; + const result = replaceSecret.run( + binding.new_secret_hash, + membershipVersion, + binding.expires_at_ms, + binding.now_ms, + subscriptionId, + binding.subject_id, + binding.project_id, + binding.purpose, + binding.now_ms, + binding.expires_at_ms, + binding.now_ms, + membershipVersion, + ); + if (Number(result.changes) !== 1) return null; + const current = findScopedById.get(subscriptionId, binding.subject_id, binding.project_id); + insertRotation.run(subscriptionId, binding.now_ms, binding.expires_at_ms); + insertAudit.run( + subscriptionId, + 'rotated', + current.subject_id, + current.project_id, + binding.now_ms, + ); + return normalizeSubscriptionRow(current); + }); + }, + + /** + * Revoke a subscription idempotently while independently requiring current + * project membership. `revocation_applied` is true only for the first state + * transition so callers cannot emit duplicate external audit events on a + * same-millisecond or later retry. + */ + async revokeSubscriptionAtomically(subscriptionId, binding) { + return withSavepoint(db, REVOKE_SAVEPOINT, () => { + const existing = findManageableScopedById.get( + subscriptionId, + binding.subject_id, + binding.project_id, + ); + if (!existing) return null; + if (existing.revoked_at_ms !== null && existing.revoked_at_ms !== undefined) { + return { + ...normalizeSubscriptionRow(existing), + revocation_applied: false, + }; + } + const result = revokeSubscription.run( + binding.now_ms, + subscriptionId, + binding.subject_id, + binding.project_id, + ); + if (Number(result.changes) !== 1) return null; + const current = findScopedById.get(subscriptionId, binding.subject_id, binding.project_id); + insertAudit.run( + subscriptionId, + 'revoked', + current.subject_id, + current.project_id, + binding.now_ms, + ); + return { + ...normalizeSubscriptionRow(current), + revocation_applied: true, + }; + }); + }, + }); +} + +/** + * Create project-management authorization for calendar subscription lifecycle. + * + * The same nondisclosing absence error is used for an unknown project and for a + * project outside the subject's organization. HTTP adapters can therefore map + * the domain's management failure without revealing tenant existence. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {{assertCanManage: Function}} Authorization port. + */ +export function createSqliteCalendarSubscriptionAuthorizationPort(database) { + const db = requireDatabase(database); + const projectAccess = db.prepare(` + SELECT p.id + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ? + `); + + return Object.freeze({ + /** Verify that the subject currently belongs to the project's organization. */ + async assertCanManage({ subjectId, projectId }) { + if (!projectAccess.get(projectId, subjectId)) { + throw new Error('calendar_subscription_resource_unavailable'); + } + }, + }); +} + +/** + * Create the live membership-version port used before calendar credential use. + * + * The opaque version combines membership-row identity with the user's session + * token version. Removing/re-adding membership changes the first component; + * logout-all or password/session invalidation changes the second. Repository + * transitions compare this live value in their own savepoint before committing. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {{assertActive: Function}} Membership revocation/version port. + */ +export function createSqliteCalendarSubscriptionMembershipPort(database) { + const db = requireDatabase(database); + const activeMembership = membershipVersionStatement(db); + + return Object.freeze({ + /** Return the current opaque membership/session version for one project. */ + async assertActive({ subjectId, projectId }) { + const row = activeMembership.get(projectId, subjectId); + if (!row?.membership_version) { + throw new Error('calendar_subscription_membership_inactive'); + } + return String(row.membership_version); + }, + }); +} diff --git a/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs b/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs new file mode 100644 index 00000000..8536f088 --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs @@ -0,0 +1,147 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from '../../server/calendar_subscription_sqlite.mjs'; + +function installFixture(database) { + database.exec('PRAGMA foreign_keys = ON'); + database.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + token_version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY + ); + CREATE TABLE memberships ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(org_id, user_id) + ); + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE + ); + INSERT INTO users(id, token_version) VALUES (1, 0); + INSERT INTO orgs(id) VALUES (10); + INSERT INTO memberships(id, org_id, user_id) VALUES (100, 10, 1); + INSERT INTO projects(id, org_id) VALUES (1000, 10); + `); + installCalendarSubscriptionSchema(database); +} + +const baseRecord = (overrides = {}) => ({ + subscription_id: 'csub_00112233445566778899aabbccddeeff', + secret_hash: 'a'.repeat(64), + subject_id: '1', + project_id: '1000', + name: 'Primary calendar', + purpose: 'calendar_read', + audience: 'scopeweave:calendar', + membership_version: '100:0', + created_at_ms: 1_000_000, + expires_at_ms: 2_000_000, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, + ...overrides, +}); + +test('SQLite subscription storage persists the calendar_read purpose as authorization state', async () => { + const database = new DatabaseSync(':memory:'); + installFixture(database); + const repository = createSqliteCalendarSubscriptionRepository(database); + + await repository.insertSubscription(baseRecord()); + const stored = await repository.findSubscriptionByHash('a'.repeat(64)); + + assert.equal(stored.purpose, 'calendar_read'); + assert.equal( + database.prepare('SELECT purpose FROM calendar_subscriptions WHERE subscription_id = ?') + .get(baseRecord().subscription_id).purpose, + 'calendar_read', + ); + database.close(); +}); + +test('usage is bound to stored purpose and issuance membership epoch across remove-then-rejoin', async () => { + const database = new DatabaseSync(':memory:'); + installFixture(database); + const repository = createSqliteCalendarSubscriptionRepository(database); + await repository.insertSubscription(baseRecord()); + + const wrongPurpose = await repository.recordUsageAtomically('a'.repeat(64), { + now_ms: 1_100_000, + project_id: '1000', + purpose: 'session', + audience: 'scopeweave:calendar', + membership_version: '100:0', + }); + assert.equal(wrongPurpose, null, 'calendar credentials must not authorize a broader purpose'); + + database.prepare('DELETE FROM memberships WHERE id = 100').run(); + database.prepare('INSERT INTO memberships(id, org_id, user_id) VALUES (101, 10, 1)').run(); + const afterRejoin = await repository.recordUsageAtomically('a'.repeat(64), { + now_ms: 1_200_000, + project_id: '1000', + purpose: 'calendar_read', + audience: 'scopeweave:calendar', + membership_version: '100:0', + }); + assert.equal(afterRejoin, null, 'remove-then-rejoin must not revive the issuance epoch'); + + const rotated = await repository.rotateSubscriptionAtomically(baseRecord().subscription_id, { + subject_id: '1', + project_id: '1000', + new_secret_hash: 'b'.repeat(64), + now_ms: 1_300_000, + expires_at_ms: 2_300_000, + purpose: 'calendar_read', + membership_version: '101:0', + }); + assert.equal(rotated.membership_version, '101:0'); + assert.equal(rotated.purpose, 'calendar_read'); + + const rebound = await repository.recordUsageAtomically('b'.repeat(64), { + now_ms: 1_400_000, + project_id: '1000', + purpose: 'calendar_read', + audience: 'scopeweave:calendar', + membership_version: '101:0', + }); + assert.equal(rebound.subscription_id, baseRecord().subscription_id); + database.close(); +}); + +test('revocation reports only the first state transition while preserving the original timestamp', async () => { + const database = new DatabaseSync(':memory:'); + installFixture(database); + const repository = createSqliteCalendarSubscriptionRepository(database); + await repository.insertSubscription(baseRecord()); + + const first = await repository.revokeSubscriptionAtomically(baseRecord().subscription_id, { + subject_id: '1', + project_id: '1000', + now_ms: 1_500_000, + }); + const second = await repository.revokeSubscriptionAtomically(baseRecord().subscription_id, { + subject_id: '1', + project_id: '1000', + now_ms: 1_600_000, + }); + + assert.equal(first.revocation_applied, true); + assert.equal(second.revocation_applied, false); + assert.equal(second.revoked_at_ms, 1_500_000); + assert.equal( + database.prepare("SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox WHERE event_type = 'revoked'") + .get().count, + 1, + ); + database.close(); +}); diff --git a/tests/unit/calendar-subscription-sqlite-race.test.mjs b/tests/unit/calendar-subscription-sqlite-race.test.mjs new file mode 100644 index 00000000..0367e876 --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite-race.test.mjs @@ -0,0 +1,343 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { createCalendarSubscriptionService } from '../../server/calendar_subscription_domain.mjs'; +import { + createSqliteCalendarSubscriptionAuthorizationPort, + createSqliteCalendarSubscriptionMembershipPort, + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from '../../server/calendar_subscription_sqlite.mjs'; + +function installCoreSchema(database) { + database.exec('PRAGMA foreign_keys = ON'); + database.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + token_version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY + ); + CREATE TABLE memberships ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(org_id, user_id) + ); + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE + ); + INSERT INTO users(id, token_version) VALUES (1, 0); + INSERT INTO orgs(id) VALUES (10); + INSERT INTO memberships(id, org_id, user_id) VALUES (100, 10, 1); + INSERT INTO projects(id, org_id) VALUES (1000, 10); + `); +} + +function deterministicRandomSource() { + let call = 0; + return { + randomBytes(length) { + call += 1; + return new Uint8Array(length).fill(call); + }, + }; +} + +function createService( + database, + projectAuthorization, + membershipRevocation = createSqliteCalendarSubscriptionMembershipPort(database), +) { + return createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(database), + clock: { nowMs: () => 1_000_000 }, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization, + membershipRevocation, + }); +} + +function revokingAuthorization(database) { + const authorize = createSqliteCalendarSubscriptionAuthorizationPort(database); + return { + async assertCanManage(binding) { + await authorize.assertCanManage(binding); + database.prepare('DELETE FROM memberships WHERE id = 100').run(); + }, + }; +} + +function changingMembershipVersionAfterRead(database) { + const membership = createSqliteCalendarSubscriptionMembershipPort(database); + return { + async assertActive(binding) { + const version = await membership.assertActive(binding); + database.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = 1').run(); + return version; + }, + }; +} + +async function createSubscription(database) { + const service = createService( + database, + createSqliteCalendarSubscriptionAuthorizationPort(database), + ); + return service.create({ + subjectId: '1', + projectId: '1000', + name: 'Race-safe calendar', + expiresAtMs: 2_000_000, + }); +} + +test('list rechecks live membership after the management authorization boundary', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + await createSubscription(database); + + const service = createService(database, revokingAuthorization(database)); + const subscriptions = await service.list({ subjectId: '1', projectId: '1000' }); + + assert.deepEqual( + subscriptions, + [], + 'membership removed after the preflight authorization must not disclose durable subscription metadata', + ); + database.close(); +}); + +test('rotate maps a membership-version race through the nondisclosing management boundary', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + const created = await createSubscription(database); + const before = database.prepare(` + SELECT secret_hash, membership_version, expires_at_ms, rotated_at_ms + FROM calendar_subscriptions + WHERE subscription_id = ? + `).get(created.subscriptionId); + + const service = createService( + database, + createSqliteCalendarSubscriptionAuthorizationPort(database), + changingMembershipVersionAfterRead(database), + ); + await assert.rejects( + service.rotate({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_500_000, + }), + (error) => error?.code === 'calendar_subscription_not_found' && error?.status === 404, + 'a version change after domain preflight must fail through the same tenant-nondisclosing boundary', + ); + + const after = database.prepare(` + SELECT secret_hash, membership_version, expires_at_ms, rotated_at_ms + FROM calendar_subscriptions + WHERE subscription_id = ? + `).get(created.subscriptionId); + assert.deepEqual(after, before, 'failed rotation races must leave authorization state unchanged'); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM subscription_rotations').get().count, + 0, + 'failed rotation races must not create lifecycle history', + ); + assert.equal( + database.prepare( + "SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox WHERE event_type = 'rotated'", + ).get().count, + 0, + 'failed rotation races must not manufacture durable rotation evidence', + ); + database.close(); +}); + +test('revoke rechecks live membership before mutating subscription state or audit evidence', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + const created = await createSubscription(database); + + const service = createService(database, revokingAuthorization(database)); + await assert.rejects( + service.revoke({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + }), + (error) => error?.code === 'calendar_subscription_not_found' && error?.status === 404, + 'membership removed after the preflight authorization must fail through the nondisclosing management boundary', + ); + + const stored = database.prepare( + 'SELECT revoked_at_ms FROM calendar_subscriptions WHERE subscription_id = ?', + ).get(created.subscriptionId); + assert.equal(stored.revoked_at_ms, null); + assert.equal( + database.prepare( + "SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox WHERE event_type = 'revoked'", + ).get().count, + 0, + 'failed authorization races must not manufacture durable revocation evidence', + ); + database.close(); +}); + +test('foreign-key enforcement rejects invalid durable subscriptions and missing hashes normalize to null', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + + assert.equal( + database.prepare('PRAGMA foreign_keys').get().foreign_keys, + 1, + 'bootstrap test fixture must exercise SQLite foreign-key enforcement rather than integrity inspection alone', + ); + assert.throws( + () => database.prepare(` + INSERT INTO calendar_subscriptions( + subscription_id, secret_hash, subject_id, project_id, name, audience, + membership_version, created_at_ms, expires_at_ms, last_used_at_ms, + rotated_at_ms, revoked_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?,?,NULL,NULL) + `).run( + 'csub_missing_project', + 'c'.repeat(64), + 1, + 999999, + 'Missing project', + 'scopeweave:calendar', + '100:0', + 1_000_000, + 2_000_000, + null, + ), + /FOREIGN KEY constraint failed/, + ); + + const repository = createSqliteCalendarSubscriptionRepository(database); + assert.equal( + await repository.findSubscriptionByHash('f'.repeat(64)), + null, + 'an unknown hash must retain the repository missing-row contract', + ); + database.close(); +}); + +test('savepoint release cleanup failure never masks the causal operation error', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + const created = await createSubscription(database); + database.exec(` + CREATE TRIGGER calendar_subscription_test_abort_usage_cleanup + BEFORE INSERT ON calendar_subscription_audit_outbox + WHEN NEW.event_type = 'used' + BEGIN + SELECT RAISE(ABORT, 'forced audit outbox failure'); + END; + `); + + let rolledBack = false; + const cleanupFailingDatabase = { + prepare(sql) { + return database.prepare(sql); + }, + exec(sql) { + if (sql === 'ROLLBACK TO calendar_subscription_usage_state') { + rolledBack = true; + } else if (rolledBack && sql === 'RELEASE calendar_subscription_usage_state') { + throw new Error('forced release cleanup failure'); + } + return database.exec(sql); + }, + }; + const service = createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(cleanupFailingDatabase), + clock: { nowMs: () => 1_000_000 }, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization: createSqliteCalendarSubscriptionAuthorizationPort(database), + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(database), + }); + + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + /forced audit outbox failure/, + 'release cleanup failure must not replace the operation error that caused rollback', + ); + assert.equal(rolledBack, true, 'the adapter must attempt rollback before release cleanup'); + assert.equal( + database.prepare( + 'SELECT last_used_at_ms FROM calendar_subscriptions WHERE subscription_id = ?', + ).get(created.subscriptionId).last_used_at_ms, + null, + 'the causal failure must leave authorization state rolled back', + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, + 0, + 'rollback must remove usage evidence written before the audit failure', + ); + database.close(); +}); + +test('savepoint rollback cleanup failure never masks the causal operation error', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + const created = await createSubscription(database); + database.exec(` + CREATE TRIGGER calendar_subscription_test_abort_usage_rollback + BEFORE INSERT ON calendar_subscription_audit_outbox + WHEN NEW.event_type = 'used' + BEGIN + SELECT RAISE(ABORT, 'forced audit outbox failure'); + END; + `); + + let releaseAttempted = false; + const rollbackFailingDatabase = { + prepare(sql) { + return database.prepare(sql); + }, + exec(sql) { + if (sql === 'ROLLBACK TO calendar_subscription_usage_state') { + throw new Error('forced rollback cleanup failure'); + } + if (sql === 'RELEASE calendar_subscription_usage_state') { + releaseAttempted = true; + } + return database.exec(sql); + }, + }; + const service = createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(rollbackFailingDatabase), + clock: { nowMs: () => 1_000_000 }, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization: createSqliteCalendarSubscriptionAuthorizationPort(database), + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(database), + }); + + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + /forced audit outbox failure/, + 'rollback cleanup failure must not replace the operation error that caused rollback', + ); + assert.equal( + releaseAttempted, + false, + 'an unconfirmed rollback must not release and accidentally commit the failed savepoint', + ); + database.close(); +}); diff --git a/tests/unit/calendar-subscription-sqlite.test.mjs b/tests/unit/calendar-subscription-sqlite.test.mjs new file mode 100644 index 00000000..96378c6c --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite.test.mjs @@ -0,0 +1,436 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; +import { + createSqliteCalendarSubscriptionAuthorizationPort, + createSqliteCalendarSubscriptionMembershipPort, + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from '../../server/calendar_subscription_sqlite.mjs'; + +function installCoreSchema(db) { + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + token_version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY + ); + CREATE TABLE memberships ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(org_id, user_id) + ); + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE + ); + `); +} + +function seed(db) { + db.exec(` + INSERT INTO users(id, token_version) VALUES (1, 0), (2, 0); + INSERT INTO orgs(id) VALUES (10), (20); + INSERT INTO memberships(id, org_id, user_id) VALUES (100, 10, 1), (200, 20, 2); + INSERT INTO projects(id, org_id) VALUES (1000, 10), (2000, 20); + `); +} + +function deterministicRandomSource() { + let call = 0; + return { + randomBytes(length) { + call += 1; + return new Uint8Array(length).fill(call); + }, + }; +} + +function mutableClock(initial = 1_000_000) { + return { + value: initial, + nowMs() { + return this.value; + }, + }; +} + +function serviceFor(db, clock = mutableClock()) { + installCalendarSubscriptionSchema(db); + const auditEvents = []; + const service = createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(db), + clock, + randomSource: deterministicRandomSource(), + auditSink: { record: async (event) => auditEvents.push(event) }, + projectAuthorization: createSqliteCalendarSubscriptionAuthorizationPort(db), + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(db), + }); + return { service, auditEvents, clock }; +} + +const createRequest = (overrides = {}) => ({ + subjectId: '1', + projectId: '1000', + name: 'Primary calendar', + expiresAtMs: 2_000_000, + ...overrides, +}); + +function secretHash(secret) { + return createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +test('SQLite calendar adapter persists hash-only reusable state and safe list metadata', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const { service } = serviceFor(db); + + const created = await service.create(createRequest()); + assert.equal(created.secret.length, 43); + const stored = db.prepare('SELECT * FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId); + assert.ok(stored); + assert.equal(stored.subject_id, 1); + assert.equal(stored.project_id, 1000); + assert.equal(stored.membership_version, '100:0'); + assert.equal(stored.secret_hash, secretHash(created.secret)); + assert.notEqual(stored.secret_hash, created.secret); + assert.equal(Object.hasOwn(stored, 'secret'), false); + + const listed = await service.list({ subjectId: '1', projectId: '1000' }); + assert.equal(listed.length, 1); + assert.equal(listed[0].subscriptionId, created.subscriptionId); + assert.equal(listed[0].status, 'active'); + assert.equal(Object.hasOwn(listed[0], 'secret'), false); + assert.equal(Object.hasOwn(listed[0], 'secret_hash'), false); + assert.equal(Object.hasOwn(listed[0], 'membership_version'), false); + + const outbox = db.prepare('SELECT * FROM calendar_subscription_audit_outbox ORDER BY audit_event_id').all(); + assert.deepEqual(outbox.map(({ event_type }) => event_type), ['created']); + assert.ok(outbox.every((row) => !JSON.stringify(row).includes(created.secret))); + db.close(); +}); + +test('calendar secret is reusable only for its project, live membership, and pre-expiry window', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const clock = mutableClock(); + const { service } = serviceFor(db, clock); + const created = await service.create(createRequest()); + + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '2000' }), + (error) => error?.code === 'calendar_subscription_unauthorized' && error?.status === 401, + ); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, 0); + + const first = await service.authorize({ secret: created.secret, projectId: '1000' }); + assert.equal(first.subscriptionId, created.subscriptionId); + clock.value += 1_000; + await service.authorize({ secret: created.secret, projectId: '1000' }); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, 2); + assert.equal( + db.prepare('SELECT last_used_at_ms FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId).last_used_at_ms, + clock.value, + ); + + clock.value = 2_000_000; + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_unauthorized', + 'exact expiry is not usable', + ); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, 2); + db.close(); +}); + +test('membership revocation invalidates use while authenticated rotation snapshots the new version', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const clock = mutableClock(); + const { service } = serviceFor(db, clock); + const created = await service.create(createRequest()); + + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = 1').run(); + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_unauthorized', + 'session-version change makes the stored subscription unusable', + ); + + clock.value += 100; + const rotated = await service.rotate({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_100_000, + }); + assert.notEqual(rotated.secret, created.secret); + assert.equal( + db.prepare('SELECT membership_version FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId).membership_version, + '100:1', + 'rotation snapshots the independently rechecked live membership version', + ); + await assert.rejects(service.authorize({ secret: created.secret, projectId: '1000' })); + await service.authorize({ secret: rotated.secret, projectId: '1000' }); + + db.prepare('DELETE FROM memberships WHERE id = 100').run(); + db.prepare('INSERT INTO memberships(id, org_id, user_id) VALUES (101, 10, 1)').run(); + await assert.rejects( + service.authorize({ secret: rotated.secret, projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_unauthorized', + 'membership remove/re-add changes the durable authorization version', + ); + db.close(); +}); + +test('rotation invalidates the old hash without retaining credential material in history relations', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const clock = mutableClock(); + const { service } = serviceFor(db, clock); + const created = await service.create(createRequest()); + const oldHash = secretHash(created.secret); + + clock.value += 500; + const rotated = await service.rotate({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_500_000, + }); + const current = db.prepare('SELECT secret_hash, rotated_at_ms FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId); + assert.equal(current.secret_hash, secretHash(rotated.secret)); + assert.notEqual(current.secret_hash, oldHash); + assert.equal(current.rotated_at_ms, clock.value); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscriptions WHERE secret_hash = ?').get(oldHash).count, 0); + + const rotationColumns = db.prepare('PRAGMA table_info(subscription_rotations)').all().map(({ name }) => name); + const usageColumns = db.prepare('PRAGMA table_info(subscription_usage_events)').all().map(({ name }) => name); + const outboxColumns = db.prepare('PRAGMA table_info(calendar_subscription_audit_outbox)').all().map(({ name }) => name); + for (const columns of [rotationColumns, usageColumns, outboxColumns]) { + assert.equal(columns.some((name) => /secret|hash/i.test(name)), false, 'history/audit relations retain no credential material'); + } + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_rotations').get().count, 1); + db.close(); +}); + +test('revocation is idempotent, auditable once, and blocks subsequent use', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const clock = mutableClock(); + const { service } = serviceFor(db, clock); + const created = await service.create(createRequest()); + + clock.value += 700; + const first = await service.revoke({ subjectId: '1', projectId: '1000', subscriptionId: created.subscriptionId }); + const second = await service.revoke({ subjectId: '1', projectId: '1000', subscriptionId: created.subscriptionId }); + assert.equal(first.status, 'revoked'); + assert.equal(second.revokedAtMs, first.revokedAtMs); + assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox WHERE event_type = 'revoked'").get().count, + 1, + ); + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_unauthorized', + ); + db.close(); +}); + +test('tenant management is nondisclosing and cannot list or mutate another organization subscription', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const { service } = serviceFor(db); + const created = await service.create(createRequest()); + + await assert.rejects( + service.list({ subjectId: '2', projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_not_found' && error?.status === 404, + ); + await assert.rejects( + service.rotate({ + subjectId: '2', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_500_000, + }), + (error) => error?.code === 'calendar_subscription_not_found', + ); + db.close(); +}); + +test('state survives process-style reopen and remains usable until explicit revocation', async () => { + const dir = mkdtempSync(join(tmpdir(), 'scopeweave-calendar-subscription-')); + const file = join(dir, 'calendar.sqlite'); + try { + let db = new DatabaseSync(file); + installCoreSchema(db); + seed(db); + const first = serviceFor(db); + const created = await first.service.create(createRequest()); + db.close(); + + db = new DatabaseSync(file); + db.exec('PRAGMA foreign_keys = ON'); + const second = serviceFor(db); + const authorized = await second.service.authorize({ secret: created.secret, projectId: '1000' }); + assert.equal(authorized.subscriptionId, created.subscriptionId); + db.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('repository rolls back usage state and immutable evidence together on audit-outbox failure', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const { service } = serviceFor(db); + const created = await service.create(createRequest()); + db.exec(` + CREATE TRIGGER calendar_subscription_test_abort_usage + BEFORE INSERT ON calendar_subscription_audit_outbox + WHEN NEW.event_type = 'used' + BEGIN + SELECT RAISE(ABORT, 'forced audit outbox failure'); + END; + `); + + await assert.rejects(service.authorize({ secret: created.secret, projectId: '1000' }), /forced audit outbox failure/); + const row = db.prepare('SELECT last_used_at_ms FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId); + assert.equal(row.last_used_at_ms, null); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, 0); + db.close(); +}); + +test('owned schema is normalized, uses descriptive multiword names, and passes foreign-key integrity', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + installCalendarSubscriptionSchema(db); + + assert.equal(db.prepare('PRAGMA foreign_keys').get().foreign_keys, 1); + assert.throws(() => db.prepare(` + INSERT INTO calendar_subscriptions( + subscription_id, secret_hash, subject_id, project_id, name, audience, + membership_version, created_at_ms, expires_at_ms, last_used_at_ms, + rotated_at_ms, revoked_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) + `).run( + 'csub_missing_project', + 'c'.repeat(64), + 1, + 999999, + 'Missing project', + 'scopeweave:calendar', + '100:0', + 1_000_000, + 2_000_000, + null, + )); + + const owned = db.prepare(` + SELECT name, type + FROM sqlite_master + WHERE name LIKE 'calendar_subscription%' + OR name LIKE 'subscription_rotation%' + OR name LIKE 'subscription_usage%' + ORDER BY name + `).all(); + assert.deepEqual( + owned.map(({ name }) => name), + [ + 'calendar_subscription_audit_delivery_index', + 'calendar_subscription_audit_outbox', + 'calendar_subscription_secret_hash_index', + 'calendar_subscription_subject_project_index', + 'calendar_subscriptions', + 'subscription_rotation_history_index', + 'subscription_rotations', + 'subscription_usage_events', + 'subscription_usage_history_index', + ], + ); + assert.ok(owned.every(({ name }) => name.includes('_')), 'every owned SQLite object uses multiple lexical words'); + assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []); + + const rotations = db.prepare('PRAGMA table_info(subscription_rotations)').all().map(({ name }) => name); + assert.deepEqual(rotations, ['rotation_event_id', 'subscription_id', 'rotated_at_ms', 'expires_at_ms']); + const usage = db.prepare('PRAGMA table_info(subscription_usage_events)').all().map(({ name }) => name); + assert.deepEqual(usage, ['usage_event_id', 'subscription_id', 'used_at_ms']); + db.close(); +}); + +test('adapter dependencies and stale or missing atomic transitions fail closed', async () => { + assert.throws( + () => installCalendarSubscriptionSchema(null), + /requires a database with exec\(\) and prepare\(\)/, + ); + + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + installCalendarSubscriptionSchema(db); + const repository = createSqliteCalendarSubscriptionRepository(db); + + db.prepare('UPDATE users SET token_version = 1 WHERE id = 1').run(); + await assert.rejects( + repository.insertSubscription({ + subscription_id: 'csub_stale_membership_snapshot', + secret_hash: 'a'.repeat(64), + subject_id: '1', + project_id: '1000', + name: 'Stale membership attempt', + audience: 'scopeweave:calendar', + membership_version: '100:0', + created_at_ms: 1_000_000, + expires_at_ms: 2_000_000, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, + }), + /calendar_subscription_membership_inactive/, + 'create must recheck the live membership version inside the persistence boundary', + ); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscriptions').get().count, 0); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox').get().count, 0); + + const missingRotation = await repository.rotateSubscriptionAtomically('csub_missing', { + subject_id: '1', + project_id: '1000', + new_secret_hash: 'b'.repeat(64), + now_ms: 1_000_100, + expires_at_ms: 2_000_000, + membership_version: '100:1', + }); + assert.equal(missingRotation, null); + + const missingRevocation = await repository.revokeSubscriptionAtomically('csub_missing', { + subject_id: '1', + project_id: '1000', + now_ms: 1_000_100, + }); + assert.equal(missingRevocation, null); + assert.equal(await repository.findSubscriptionByHash('f'.repeat(64)), null); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox').get().count, 0); + db.close(); +}); \ No newline at end of file diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 9ebfdb83..867ed8e5 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -44,6 +44,11 @@ assert.match( /--include=server\/calendar_subscription_domain\.mjs/, 'the durable calendar-subscription domain is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/calendar_subscription_sqlite\.mjs/, + 'the durable calendar-subscription SQLite adapter is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/access-grant-domain\.test\.mjs/, @@ -64,6 +69,11 @@ assert.match( /tests\/unit\/calendar-subscription-domain-edge\.test\.mjs/, 'the calendar-subscription failure boundaries execute under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-sqlite\.test\.mjs/, + 'the calendar-subscription SQLite persistence contract executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/,