diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4688d27b..c6e39610 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,3 +45,8 @@ contain. - Kubernetes/IaC security coverage remains a follow-up design lane for any future `infra/` or container packaging surface. +- Short-lived `stream` / `attachment_view` access grants and reusable + `calendar_read` subscriptions are separate credential domains. Calendar + use binds the stored issuance membership epoch; rotation re-binds the + live epoch and invalidates the previous secret. Neither domain may + restore a general session JWT in a URL. diff --git a/CHANGELOG.md b/CHANGELOG.md index d79cba93..395ef325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 membership-revocation, random-source, clock, audit, and atomic repository ports. Route, database, calendar-subscription, and client migration remain follow-up work under issue #413. +- Added a separate framework-neutral calendar-subscription credential domain + with one-time secret display on create/rotate, hash-only persistence ports, + project-scoped management, reusable `calendar_read` authorization, issuance + membership-epoch binding so remove-then-rejoin cannot revive a secret, + a 366-day create/rotate lifetime cap, exact-expiry rejection, safe lifecycle + metadata, rotation, revocation, and usage evidence. 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. ### Security @@ -116,4 +125,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file diff --git a/docs/doctoring/calendar-subscription-domain.md b/docs/doctoring/calendar-subscription-domain.md new file mode 100644 index 00000000..4387f405 --- /dev/null +++ b/docs/doctoring/calendar-subscription-domain.md @@ -0,0 +1,79 @@ +# Calendar subscription credential domain + +Status: **active stacked PR work; not shipped on protected `develop`**. + +Issue: #413. + +## Why this slice exists + +Protected `develop` still accepts the general ScopeWeave session credential in the query string of `/api/projects/:id/calendar.ics`. Calendar clients commonly require a reusable URL rather than an `Authorization` header, but reusing a broad session JWT gives that URL the authority of a user session and exposes an unnecessarily powerful credential to URL-handling surfaces such as browser history, copied links, reverse proxies, observability systems, and downstream calendar clients. + +The IETF's OAuth bearer-token guidance is not a specification for ScopeWeave calendar subscriptions. It is relevant threat evidence: RFC 6750 warns against URI-query bearer-token transport because URLs are likely to be logged, while the current OAuth 2.0 Security BCP (RFC 9700, January 2025) further restricts token exposure and recommends limiting token privilege and audience. RFC 8725 provides current BCP guidance for JWT validation. ScopeWeave therefore treats the existing session-JWT calendar URL as a legacy authority boundary to retire, not as the target reusable-subscription design. + +This bounded slice introduces only a framework-neutral lifecycle domain. It does **not** change the protected calendar route, create database tables, migrate stored data, add HTTP endpoints, or implement the management UI. The product-interaction prerequisite from issue #413 is now captured in the Figma design described below; future UI implementation must conform to that reviewed interaction contract and the eventual API semantics rather than inventing an independent credential lifecycle. + +## Figma interaction design + +The editable product contract is `ScopeWeave Calendar Subscription Management — Issue 413`: . + +The design is active-PR evidence, not shipped-product evidence. It uses the current protected ScopeWeave visual tokens and reusable button/status components and covers seven explicit states: + +1. interaction/security contract; +2. subscription management list; +3. creation dialog; +4. one-time secret reveal; +5. rotation confirmation; +6. revocation confirmation; and +7. empty state. + +The interaction contract requires a recognizable subscription name and explicit expiry, read-only/current-project purpose copy, one-time secret display on create or rotate, an explicit copy/save acknowledgement, lifecycle metadata without stored secret material, immediate old-secret invalidation on rotation, named destructive consequences on revocation, and next-action-oriented failure/recovery copy. Active, expired, and revoked states use text in addition to color. + +Accessibility acceptance is part of the design rather than a later visual-polish step: the dialog title is the accessible name, helper/error content is associated with its control, focus enters the dialog and returns to its invoker, Escape never silently commits, primary actions retain visible focus, and copy/status feedback is announced without moving focus. UI implementation must add executable browser acceptance for these interactions before the Figma design can be represented as shipped behavior. + +## Domain contract + +`server/calendar_subscription_domain.mjs` defines one fixed resource audience, `scopeweave:calendar`, one fixed purpose, `calendar_read`, and an injected service with five operations: create, list, authorize, rotate, and revoke. The purpose is the buyer-visible authority boundary: a thin route may mint an ICS/calendar-feed principal from it and must not treat that principal as session-equivalent access to JSON APIs, SSE, attachments, or another project. RFC 5545 remains the interchange format for the eventual feed; this domain only issues the reusable credential that will later authorize that feed. + +A newly created or rotated subscription secret is generated from 32 random bytes and encoded as unpadded base64url. The plaintext secret is returned only by `create()` or `rotate()`. Only its SHA-256 hash crosses the repository port. Audit events contain the independently random correlation identifier (`csub_` plus 128 random bits), actor/resource identifiers, purpose, audience, and lifecycle metadata, but never the plaintext secret or its hash. Repeat revocation keeps the original `revoked_at_ms`. Adapters must set `revocation_applied: true` only on the first transition so a same-millisecond retry cannot emit a second audit event. + +The reusable credential is deliberately separate from the short-lived `stream` and `attachment_view` grant domain in PR #506. A calendar subscription needs an operator-visible lifecycle—name, creation time, last-use time, expiry, rotation, revocation, purpose/resource, and status—whereas the short-lived access-grant domain is one-time and capped at five minutes. Reusing either the general session JWT or the one-time grant lifecycle would collapse materially different authority and recovery semantics. + +Creation and lifecycle management require project-management authorization. Every secret use re-checks active membership and passes the *issuance* membership version stored on the row into `recordUsageAtomically(...)`. The service also fail-closes when live membership no longer equals that issuance epoch, so remove-then-rejoin cannot revive an unrevoked secret. `rotateSubscriptionAtomically(...)` receives the *current* live membership version so a still-authorized operator can re-bind after rejoin while invalidating the previous secret. Production adapters must, in the same transaction, compare the supplied version with live membership, compare usage against the row's stored epoch, and reject wrong project/audience/purpose, exact expiry (`expires_at_ms <= now_ms`), and a non-null `revoked_at_ms`. The service fail-closes unless the returned row is active. Membership-removal paths must atomically revoke affected calendar subscriptions; that revoke-on-change path is mandatory, not a substitute for the epoch comparison. + +Expiry is mandatory, exact expiry is unusable, and create/rotate reject a lifetime longer than 366 days from `nowMs` so a buggy caller cannot persist a decades-long feed secret. The product/API layer may choose a shorter policy and must communicate it to operators. Rotation invalidates the previous secret as part of one atomic repository transition; revocation is idempotent from the operator perspective. Listing returns safe lifecycle metadata only and never permits the stored secret, secret hash, or membership-version value to be redisplayed. + +Audit delivery occurs only after the durable repository transition and is best-effort at this framework-neutral boundary. An audit transport outage must not convert an already completed secret creation, rotation, revocation, or usage record into a client-visible failure that encourages unsafe retry. Production persistence that requires durable evidence should couple the state change to a transactional audit outbox. + +## Persistence boundary for the follow-up adapter + +Issue #413 owns the intended normalized durable objects `calendar_subscriptions`, `subscription_rotations`, and `subscription_usage_events`. This PR does not create them. The separate schema-migration work in PR #500 establishes migration identity/fail-closed generation handling for the broader database modernization and must remain the schema-transition authority rather than being duplicated here. + +A future adapter must preserve at least these invariants: + +- `calendar_subscriptions` contains the subscription identity, project/subject binding, name, fixed `calendar_read` purpose, fixed audience, current secret hash, issuance membership version, creation/expiry/last-use/rotation/revocation metadata; plaintext secrets are never persisted. +- `subscription_rotations` records non-secret correlation/evidence for each successful rotation without retaining previous plaintext secrets. +- `subscription_usage_events` records bounded operational usage evidence suitable for access/export investigation without turning request URLs or secrets into logs. +- create, rotate, revoke, and last-use updates are transactionally consistent with their lifecycle evidence, and membership invalidation cannot race a successful use or rotation into continued access. +- owned database object names remain descriptive multiword `snake_case` and the eventual relational design is normalized rather than embedding lifecycle history in an opaque JSON column. + +## Verification and traceability + +TDD began at commit `c86c878f6e9876fe9ca5c85d8b7e8b25fcb5ed77`, whose behavior contract imported an absent `server/calendar_subscription_domain.mjs`; the focused Node execution failed RED with `ERR_MODULE_NOT_FOUND`. Commit `1210d52225fa25e9f122e23fe80578334e64cff8` supplied the production domain. Commit `1bb891b9c34b997c0b618857093bba41a967cbe6` added failure-boundary coverage for port contracts, invalid identifiers/names/expiry, malformed membership versions, authorization denial, invalid clock/random sources, safe listing states, malformed/unknown/wrong-boundary credentials, and atomic lifecycle rejection. Both focused Node test files passed locally after implementation. + +The canonical repository coverage producer now instruments `server/calendar_subscription_domain.mjs` and executes both calendar-domain test files. Local `c8` evidence is unavailable in the current execution environment because the package is not locally cached; therefore hosted exact-head Istanbul evidence remains mandatory and no local statement/branch percentage is claimed as passing evidence. + +The protected `develop` truth remains unchanged by this active branch. Before integration, the exact child head must be reconciled after PR #506, all applicable repository/organization checks and security/dependency/supply-chain evidence must be terminal-success on the unchanged current head, owned production coverage must satisfy the repository contract, every valid review finding must be resolved, and the live rulesets' qualifying independent current-head/last-push approval must exist. Predecessor-head, skipped-required, neutral, synthetic, status-only, or model-only evidence does not transfer. + +## Rollback + +Until a persistence/route slice exists, rollback is code-only: remove this module, its focused tests, coverage registrations, doctoring entry, and changelog line together. No database migration or credential invalidation is required because this slice cannot yet issue a production calendar subscription. Once persistence is added, rollback must preserve the durable revocation/rotation history and must never restore the broad session-JWT query credential as the security-safe steady state. + +## References + +Desruisseaux, B. (Ed.). (2009). *Internet calendaring and scheduling core object specification (iCalendar)* (RFC 5545). Internet Engineering Task Force. https://doi.org/10.17487/RFC5545 + +Jones, M., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: Bearer token usage* (RFC 6750). Internet Engineering Task Force. https://doi.org/10.17487/RFC6750 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task Force. https://doi.org/10.17487/RFC9700 + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current practices* (BCP 225; RFC 8725). Internet Engineering Task Force. https://doi.org/10.17487/RFC8725 diff --git a/package.json b/package.json index c056826b..bd0967ff 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 && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.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 --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/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/orchestrator-attribution.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/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-return-boundary.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.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/calendar-subscription-return-boundary.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/orchestrator-attribution.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 tests/e2e/toast-accessibility.spec.js", @@ -31,4 +31,4 @@ "c8": "12.0.0", "fast-check": "4.9.0" } -} \ No newline at end of file +} diff --git a/server/calendar_subscription_domain.mjs b/server/calendar_subscription_domain.mjs new file mode 100644 index 00000000..0f94a5ee --- /dev/null +++ b/server/calendar_subscription_domain.mjs @@ -0,0 +1,487 @@ +import { createHash } from 'node:crypto'; + +const SECRET_BYTES = 32; +const SUBSCRIPTION_ID_BYTES = 16; +const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const NAME_MAX_LENGTH = 120; +const MEMBERSHIP_VERSION_MAX_LENGTH = 128; +const CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/u; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Fixed resource-server audience for reusable project calendar subscriptions. */ +export const CALENDAR_SUBSCRIPTION_AUDIENCE = 'scopeweave:calendar'; + +/** + * Fixed purpose bound to ICS/calendar-feed read only. + * Thin HTTP adapters must not treat this principal as session-equivalent + * access to JSON APIs, SSE, attachments, or other projects. + */ +export const CALENDAR_SUBSCRIPTION_PURPOSE = 'calendar_read'; + +/** + * Inclusive upper bound on create/rotate lifetime, measured from `nowMs`. + * 366 days covers a leap-year span without allowing a decades-long feed secret. + */ +export const CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS = 366 * MS_PER_DAY; + +/** Stable domain error safe for thin HTTP adapters to map without secret-state disclosure. */ +export class CalendarSubscriptionError extends Error { + /** + * @param {string} code Stable machine-readable error code. + * @param {number} status Suggested HTTP status for an adapter. + */ + constructor(code, status) { + super(code); + this.name = 'CalendarSubscriptionError'; + this.code = code; + this.status = status; + } +} + +function requireMethod(port, method) { + if (!port || typeof port[method] !== 'function') { + throw new TypeError(`calendar-subscription dependency must provide ${method}()`); + } +} + +function isBoundedString(value) { + return typeof value === 'string' + && value.length > 0 + && value === value.trim() + && !CONTROL_PATTERN.test(value); +} + +function normalizeName(value) { + if (!isBoundedString(value) || value.length > NAME_MAX_LENGTH) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } + return value; +} + +function validateIdentity(subjectId, projectId) { + if (!isBoundedString(subjectId) || !isBoundedString(projectId)) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } +} + +function normalizeSubscriptionId(value) { + if (!isBoundedString(value)) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } + return value; +} + +function readNow(clock) { + const nowMs = clock.nowMs(); + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError('calendar-subscription clock must return a non-negative safe integer'); + } + return nowMs; +} + +function normalizeExpiry(expiresAtMs, nowMs) { + if (!Number.isSafeInteger(expiresAtMs) || expiresAtMs <= nowMs) { + throw new CalendarSubscriptionError('calendar_subscription_expiry_invalid', 400); + } + const lifetimeMs = expiresAtMs - nowMs; + if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs > CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS) { + throw new CalendarSubscriptionError('calendar_subscription_expiry_invalid', 400); + } + return expiresAtMs; +} + +function normalizeMembershipVersion(value) { + if (Number.isSafeInteger(value) && value >= 0) return value; + if ( + typeof value === 'string' + && value.length > 0 + && value.length <= MEMBERSHIP_VERSION_MAX_LENGTH + && value === value.trim() + && !CONTROL_PATTERN.test(value) + ) return value; + throw unauthorizedSubscription(); +} + +function encodeSecret(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== SECRET_BYTES) { + throw new TypeError(`calendar-subscription random source must return ${SECRET_BYTES} bytes`); + } + return Buffer.from(bytes).toString('base64url'); +} + +function encodeSubscriptionId(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== SUBSCRIPTION_ID_BYTES) { + throw new TypeError(`calendar-subscription random source must return ${SUBSCRIPTION_ID_BYTES} bytes for subscription id`); + } + return `csub_${Buffer.from(bytes).toString('hex')}`; +} + +function hashSecret(secret) { + return createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +function unauthorizedSubscription() { + return new CalendarSubscriptionError('calendar_subscription_unauthorized', 401); +} + +function notFoundSubscription() { + return new CalendarSubscriptionError('calendar_subscription_not_found', 404); +} + +function statusOf(record, nowMs) { + if (record.revoked_at_ms !== null && record.revoked_at_ms !== undefined) return 'revoked'; + if (record.expires_at_ms <= nowMs) return 'expired'; + return 'active'; +} + +function viewOf(record, nowMs) { + return Object.freeze({ + subscriptionId: record.subscription_id, + subjectId: record.subject_id, + projectId: record.project_id, + name: record.name, + purpose: record.purpose ?? CALENDAR_SUBSCRIPTION_PURPOSE, + audience: record.audience, + createdAtMs: record.created_at_ms, + expiresAtMs: record.expires_at_ms, + lastUsedAtMs: record.last_used_at_ms ?? null, + rotatedAtMs: record.rotated_at_ms ?? null, + revokedAtMs: record.revoked_at_ms ?? null, + status: statusOf(record, nowMs), + }); +} + +function validateListedSubscription(record, { subjectId, projectId }) { + if ( + !record + || typeof record !== 'object' + || Array.isArray(record) + || record.subject_id !== subjectId + || record.project_id !== projectId + || (record.purpose ?? CALENDAR_SUBSCRIPTION_PURPOSE) !== CALENDAR_SUBSCRIPTION_PURPOSE + || record.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + ) { + throw notFoundSubscription(); + } + return record; +} + +function validateRevokedSubscription(record, { + subjectId, projectId, subscriptionId, nowMs, +}) { + if ( + !record + || typeof record !== 'object' + || Array.isArray(record) + || record.subscription_id !== subscriptionId + || record.subject_id !== subjectId + || record.project_id !== projectId + || (record.purpose ?? CALENDAR_SUBSCRIPTION_PURPOSE) !== CALENDAR_SUBSCRIPTION_PURPOSE + || record.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + || (record.revocation_applied !== true && record.revocation_applied !== false) + || statusOf(record, nowMs) !== 'revoked' + ) { + throw notFoundSubscription(); + } + return record; +} + +async function recordAuditBestEffort(auditSink, event) { + try { + await auditSink.record(event); + } catch { + // Durable subscription state is authoritative after the repository commits. + // Production persistence that requires guaranteed evidence should pair the + // state transition with a transactional outbox rather than retrying secrets. + } +} + +async function assertManage(projectAuthorization, subjectId, projectId) { + try { + await projectAuthorization.assertCanManage({ subjectId, projectId }); + } catch { + throw notFoundSubscription(); + } +} + +async function readMembershipVersion(membershipRevocation, subjectId, projectId) { + try { + return normalizeMembershipVersion(await membershipRevocation.assertActive({ subjectId, projectId })); + } catch { + throw unauthorizedSubscription(); + } +} + +/** + * Build the framework-neutral lifecycle for reusable calendar-subscription secrets. + * + * Plaintext subscription secrets are returned only from create/rotate and only + * their SHA-256 hashes cross the repository port. Repository adapters own the + * durable `calendar_subscriptions`, `subscription_rotations`, and + * `subscription_usage_events` state and must make usage/rotation/revocation + * transitions atomic. + * + * `authorize()` supplies the *issuance* membership version captured on the + * stored row. `recordUsageAtomically()` must reject unless, in one transaction, + * live membership equals that supplied version and the row's + * `membership_version` still equals it. Remove-then-rejoin therefore cannot + * revive an unrevoked secret; the operator must rotate (or create) to bind a + * new epoch. `rotateSubscriptionAtomically()` receives the *current* live + * membership version so a still-authorized operator can re-bind after rejoin + * while invalidating the previous secret. Both atomic ports must also reject + * wrong project/audience/purpose, `expires_at_ms <= now_ms`, and a non-null + * `revoked_at_ms`. The service fail-closes unless the returned row is + * `statusOf(...) === 'active'`. `revokeSubscriptionAtomically()` must set + * `revocation_applied: true` only on the first transition so a same-millisecond + * retry cannot emit a second audit event. Adapters without a shared transaction + * must atomically revoke affected subscriptions when membership changes; that + * revoke-on-membership-change path is mandatory, not an alternative to the + * epoch comparison. + * + * Audit delivery is best-effort after repository commits so an audit transport + * outage cannot cause a client to retry and accidentally expose multiple active + * secrets. Production adapters that require durable audit evidence should use a + * transactional outbox. + * + * @param {object} ports Injected infrastructure and authorization ports. + * @param {object} ports.repository CalendarSubscriptionRepository. + * @param {object} ports.clock Clock exposing nowMs(). + * @param {object} ports.randomSource Random source exposing randomBytes(). + * @param {object} ports.auditSink Secret-free audit sink exposing record(). + * @param {object} ports.projectAuthorization Project authorization port. + * @param {object} ports.membershipRevocation Membership revocation/version port. + * @returns {{create: Function, list: Function, authorize: Function, rotate: Function, revoke: Function}} Immutable service. + */ +export function createCalendarSubscriptionService({ + repository, + clock, + randomSource, + auditSink, + projectAuthorization, + membershipRevocation, +} = {}) { + requireMethod(repository, 'insertSubscription'); + requireMethod(repository, 'listSubscriptions'); + requireMethod(repository, 'findSubscriptionByHash'); + requireMethod(repository, 'recordUsageAtomically'); + requireMethod(repository, 'rotateSubscriptionAtomically'); + requireMethod(repository, 'revokeSubscriptionAtomically'); + requireMethod(clock, 'nowMs'); + requireMethod(randomSource, 'randomBytes'); + requireMethod(auditSink, 'record'); + requireMethod(projectAuthorization, 'assertCanManage'); + requireMethod(membershipRevocation, 'assertActive'); + + /** + * Mint a reusable calendar-read secret. Returns plaintext once; persists only the hash. + * @param {{subjectId: string, projectId: string, name: string, expiresAtMs: number}} request + * @returns {Promise} Frozen lifecycle view plus one-time `secret`. + */ + async function create({ subjectId, projectId, name, expiresAtMs }) { + validateIdentity(subjectId, projectId); + const normalizedName = normalizeName(name); + await assertManage(projectAuthorization, subjectId, projectId); + const membershipVersion = await readMembershipVersion(membershipRevocation, subjectId, projectId); + const nowMs = readNow(clock); + const normalizedExpiry = normalizeExpiry(expiresAtMs, nowMs); + const secret = encodeSecret(randomSource.randomBytes(SECRET_BYTES)); + const subscriptionId = encodeSubscriptionId(randomSource.randomBytes(SUBSCRIPTION_ID_BYTES)); + const record = { + subscription_id: subscriptionId, + secret_hash: hashSecret(secret), + subject_id: subjectId, + project_id: projectId, + name: normalizedName, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: membershipVersion, + created_at_ms: nowMs, + expires_at_ms: normalizedExpiry, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, + }; + await repository.insertSubscription(record); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.created', + subscription_id: subscriptionId, + subject_id: subjectId, + project_id: projectId, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + expires_at_ms: normalizedExpiry, + }); + return Object.freeze({ secret, ...viewOf(record, nowMs) }); + } + + /** + * List safe lifecycle metadata for the caller's project. Never returns secret or hash. + * @param {{subjectId: string, projectId: string}} request + * @returns {Promise>} + */ + async function list({ subjectId, projectId }) { + validateIdentity(subjectId, projectId); + await assertManage(projectAuthorization, subjectId, projectId); + const nowMs = readNow(clock); + const records = await repository.listSubscriptions({ subject_id: subjectId, project_id: projectId }); + if (!Array.isArray(records)) throw new TypeError('calendar-subscription repository must return an array from listSubscriptions()'); + return Object.freeze(records.map((record) => viewOf( + validateListedSubscription(record, { subjectId, projectId }), + nowMs, + ))); + } + + /** + * Authorize a calendar-read principal. Rejects exact expiry, revoke, and + * remove-then-rejoin unless the stored issuance membership epoch still matches live membership. + * @param {{secret: string, projectId: string}} request + * @returns {Promise<{subscriptionId: string, subjectId: string, projectId: string, purpose: string, audience: string}>} + */ + async function authorize({ secret, projectId }) { + if (typeof secret !== 'string' || !SECRET_PATTERN.test(secret) || !isBoundedString(projectId)) { + throw unauthorizedSubscription(); + } + const secretHash = hashSecret(secret); + const existing = await repository.findSubscriptionByHash(secretHash); + if ( + !existing + || existing.project_id !== projectId + || existing.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + || existing.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE + ) { + throw unauthorizedSubscription(); + } + const issuedMembershipVersion = normalizeMembershipVersion(existing.membership_version); + const liveMembershipVersion = await readMembershipVersion( + membershipRevocation, + existing.subject_id, + existing.project_id, + ); + if (liveMembershipVersion !== issuedMembershipVersion) { + throw unauthorizedSubscription(); + } + const nowMs = readNow(clock); + if (statusOf(existing, nowMs) !== 'active') { + throw unauthorizedSubscription(); + } + const used = await repository.recordUsageAtomically(secretHash, { + now_ms: nowMs, + project_id: projectId, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: issuedMembershipVersion, + }); + if ( + !used + || used.subscription_id !== existing.subscription_id + || used.subject_id !== existing.subject_id + || used.project_id !== existing.project_id + || used.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE + || used.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + || used.membership_version !== issuedMembershipVersion + || statusOf(used, nowMs) !== 'active' + ) { + throw unauthorizedSubscription(); + } + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.used', + subscription_id: used.subscription_id, + subject_id: used.subject_id, + project_id: used.project_id, + purpose: used.purpose, + audience: used.audience, + }); + return Object.freeze({ + subscriptionId: used.subscription_id, + subjectId: used.subject_id, + projectId: used.project_id, + purpose: used.purpose, + audience: used.audience, + }); + } + + /** + * Replace the secret, bind the current live membership epoch, and invalidate the previous secret. + * @param {{subjectId: string, projectId: string, subscriptionId: string, expiresAtMs: number}} request + * @returns {Promise} Frozen lifecycle view plus one-time `secret`. + */ + async function rotate({ subjectId, projectId, subscriptionId, expiresAtMs }) { + validateIdentity(subjectId, projectId); + const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); + await assertManage(projectAuthorization, subjectId, projectId); + const membershipVersion = await readMembershipVersion(membershipRevocation, subjectId, projectId); + const nowMs = readNow(clock); + const normalizedExpiry = normalizeExpiry(expiresAtMs, nowMs); + const secret = encodeSecret(randomSource.randomBytes(SECRET_BYTES)); + const rotated = await repository.rotateSubscriptionAtomically(normalizedSubscriptionId, { + subject_id: subjectId, + project_id: projectId, + new_secret_hash: hashSecret(secret), + now_ms: nowMs, + expires_at_ms: normalizedExpiry, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + membership_version: membershipVersion, + }); + if ( + !rotated + || rotated.subscription_id !== normalizedSubscriptionId + || rotated.subject_id !== subjectId + || rotated.project_id !== projectId + || rotated.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE + || rotated.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + || rotated.membership_version !== membershipVersion + || rotated.expires_at_ms !== normalizedExpiry + || statusOf(rotated, nowMs) !== 'active' + ) { + throw notFoundSubscription(); + } + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.rotated', + subscription_id: rotated.subscription_id, + subject_id: rotated.subject_id, + project_id: rotated.project_id, + purpose: rotated.purpose, + audience: rotated.audience, + expires_at_ms: rotated.expires_at_ms, + }); + return Object.freeze({ secret, ...viewOf(rotated, nowMs) }); + } + + /** + * Revoke a subscription. Repeat calls keep the original `revoked_at_ms` and do not re-audit. + * @param {{subjectId: string, projectId: string, subscriptionId: string}} request + * @returns {Promise} Frozen lifecycle view. + */ + async function revoke({ subjectId, projectId, subscriptionId }) { + validateIdentity(subjectId, projectId); + const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); + await assertManage(projectAuthorization, subjectId, projectId); + const nowMs = readNow(clock); + const revoked = validateRevokedSubscription( + await repository.revokeSubscriptionAtomically(normalizedSubscriptionId, { + subject_id: subjectId, + project_id: projectId, + now_ms: nowMs, + }), + { + subjectId, + projectId, + subscriptionId: normalizedSubscriptionId, + nowMs, + }, + ); + if (revoked.revocation_applied === true) { + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.revoked', + subscription_id: revoked.subscription_id, + subject_id: revoked.subject_id, + project_id: revoked.project_id, + purpose: revoked.purpose ?? CALENDAR_SUBSCRIPTION_PURPOSE, + audience: revoked.audience, + }); + } + return viewOf(revoked, nowMs); + } + + return Object.freeze({ create, list, authorize, rotate, revoke }); +} diff --git a/tests/unit/calendar-subscription-domain-edge.test.mjs b/tests/unit/calendar-subscription-domain-edge.test.mjs new file mode 100644 index 00000000..73031980 --- /dev/null +++ b/tests/unit/calendar-subscription-domain-edge.test.mjs @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { + CALENDAR_SUBSCRIPTION_AUDIENCE, + CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS, + CALENDAR_SUBSCRIPTION_PURPOSE, + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; + +const NOW = 1_900_000_000_000; +const VALID_SECRET = Buffer.alloc(32, 7).toString('base64url'); + +function ports(overrides = {}) { + const repository = { + insertSubscription: async () => {}, + listSubscriptions: async () => [], + findSubscriptionByHash: async () => null, + recordUsageAtomically: async () => null, + rotateSubscriptionAtomically: async () => null, + revokeSubscriptionAtomically: async () => null, + ...(overrides.repository || {}), + }; + return { + repository, + clock: overrides.clock || { nowMs: () => NOW }, + randomSource: overrides.randomSource || { randomBytes: (size) => new Uint8Array(size).fill(9) }, + auditSink: overrides.auditSink || { record: async () => {} }, + projectAuthorization: overrides.projectAuthorization || { assertCanManage: async () => {} }, + membershipRevocation: overrides.membershipRevocation || { assertActive: async () => 'membership-v1' }, + }; +} + +function service(overrides = {}) { + return createCalendarSubscriptionService(ports(overrides)); +} + +async function expectError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof CalendarSubscriptionError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +{ + const valid = ports(); + const requirements = [ + ['repository', 'insertSubscription'], + ['repository', 'listSubscriptions'], + ['repository', 'findSubscriptionByHash'], + ['repository', 'recordUsageAtomically'], + ['repository', 'rotateSubscriptionAtomically'], + ['repository', 'revokeSubscriptionAtomically'], + ['clock', 'nowMs'], + ['randomSource', 'randomBytes'], + ['auditSink', 'record'], + ['projectAuthorization', 'assertCanManage'], + ['membershipRevocation', 'assertActive'], + ]; + for (const [portName, method] of requirements) { + const broken = ports(); + broken[portName] = { ...broken[portName] }; + delete broken[portName][method]; + assert.throws( + () => createCalendarSubscriptionService(broken), + new RegExp(`calendar-subscription dependency must provide ${method}\\(\\)`), + ); + } + assert.ok(createCalendarSubscriptionService(valid)); + assert.throws(() => createCalendarSubscriptionService(), /insertSubscription/); +} + +{ + const svc = service(); + const invalidIdentities = [ + { subjectId: '', projectId: 'project-1' }, + { subjectId: ' user-1', projectId: 'project-1' }, + { subjectId: 'user\n1', projectId: 'project-1' }, + { subjectId: 'user-1', projectId: '' }, + { subjectId: 'user-1', projectId: ' project-1' }, + { subjectId: 1, projectId: 'project-1' }, + ]; + for (const identity of invalidIdentities) { + await expectError( + svc.create({ ...identity, name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_request_invalid', 400, + ); + } + for (const name of ['', ' Calendar', 'Calendar\u0000', 'x'.repeat(121), null]) { + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name, expiresAtMs: NOW + 10 }), + 'calendar_subscription_request_invalid', 400, + ); + } + for (const expiresAtMs of [ + NOW, + NOW - 1, + 1.5, + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER + 1, + NOW + CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS + 1, + ]) { + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs }), + 'calendar_subscription_expiry_invalid', 400, + ); + } +} + +{ + for (const version of [0, 4, 'v4']) { + const svc = service({ membershipRevocation: { assertActive: async () => version } }); + const created = await svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }); + assert.equal(created.status, 'active'); + } + for (const version of [-1, Number.NaN, '', ' v1', 'v1\u0000', 'x'.repeat(129), {}, null]) { + const svc = service({ membershipRevocation: { assertActive: async () => version } }); + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_unauthorized', 401, + ); + } + const revoked = service({ membershipRevocation: { assertActive: async () => { throw new Error('revoked'); } } }); + await expectError( + revoked.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_unauthorized', 401, + ); +} + +{ + const denied = service({ projectAuthorization: { assertCanManage: async () => { throw new Error('no'); } } }); + await expectError( + denied.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_not_found', 404, + ); + await expectError(denied.list({ subjectId: 'user-1', projectId: 'project-1' }), 'calendar_subscription_not_found', 404); +} + +{ + const badClock = service({ clock: { nowMs: () => -1 } }); + await assert.rejects( + badClock.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /clock must return a non-negative safe integer/, + ); + const fractionalClock = service({ clock: { nowMs: () => 1.5 } }); + await assert.rejects(fractionalClock.list({ subjectId: 'user-1', projectId: 'project-1' }), /clock must return/); +} + +{ + const badSecretBytes = service({ randomSource: { randomBytes: () => new Uint8Array(31) } }); + await assert.rejects( + badSecretBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 32 bytes/, + ); + let call = 0; + const badIdBytes = service({ randomSource: { randomBytes: (size) => { call += 1; return new Uint8Array(call === 1 ? size : 15); } } }); + await assert.rejects( + badIdBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 16 bytes for subscription id/, + ); + const notBytes = service({ randomSource: { randomBytes: () => 'not-bytes' } }); + await assert.rejects( + notBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 32 bytes/, + ); +} + +{ + const nonArray = service({ repository: { listSubscriptions: async () => ({}) } }); + await assert.rejects(nonArray.list({ subjectId: 'user-1', projectId: 'project-1' }), /must return an array/); + + const expiredRow = { + subscription_id: 'csub_expired', subject_id: 'user-1', project_id: 'project-1', name: 'Old', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, created_at_ms: NOW - 100, expires_at_ms: NOW, + last_used_at_ms: NOW - 10, rotated_at_ms: NOW - 20, revoked_at_ms: null, + }; + const revokedRow = { ...expiredRow, subscription_id: 'csub_revoked', revoked_at_ms: NOW - 1 }; + const listed = await service({ repository: { listSubscriptions: async () => [expiredRow, revokedRow] } }) + .list({ subjectId: 'user-1', projectId: 'project-1' }); + assert.deepEqual(listed.map((row) => row.status), ['expired', 'revoked']); + assert.equal(listed[0].lastUsedAtMs, NOW - 10); + assert.equal(listed[0].rotatedAtMs, NOW - 20); +} + +{ + const svc = service(); + for (const candidate of [null, '', 'short', `${VALID_SECRET}=`, VALID_SECRET]) { + const projectId = candidate === VALID_SECRET ? '' : 'project-1'; + await expectError(svc.authorize({ secret: candidate, projectId }), 'calendar_subscription_unauthorized', 401); + } + + const baseRecord = { + subscription_id: 'csub_123', subject_id: 'user-1', project_id: 'project-1', name: 'Calendar', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: 'membership-v1', created_at_ms: NOW - 1, expires_at_ms: NOW + 100, + last_used_at_ms: null, rotated_at_ms: null, revoked_at_ms: null, + }; + for (const record of [ + null, + { ...baseRecord, project_id: 'project-2' }, + { ...baseRecord, audience: 'other' }, + { ...baseRecord, purpose: 'stream' }, + ]) { + const candidate = service({ repository: { findSubscriptionByHash: async () => record } }); + await expectError(candidate.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + } + const atomicReject = service({ repository: { + findSubscriptionByHash: async () => baseRecord, + recordUsageAtomically: async () => null, + } }); + await expectError(atomicReject.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + + const membershipReject = service({ + repository: { findSubscriptionByHash: async () => baseRecord }, + membershipRevocation: { assertActive: async () => { throw new Error('gone'); } }, + }); + await expectError(membershipReject.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + + const exactExpiry = service({ + repository: { findSubscriptionByHash: async () => ({ ...baseRecord, expires_at_ms: NOW }) }, + }); + await expectError(exactExpiry.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + + let authorizeLiveVersion = 'membership-v1'; + const authorizeMidCheck = service({ + repository: { + findSubscriptionByHash: async () => ({ ...baseRecord, membership_version: 'membership-v1' }), + recordUsageAtomically: async (_hash, expected) => ( + authorizeLiveVersion === expected.membership_version ? { ...baseRecord, last_used_at_ms: NOW } : null + ), + }, + membershipRevocation: { + async assertActive() { + const captured = authorizeLiveVersion; + authorizeLiveVersion = 'membership-v2'; + return captured; + }, + }, + }); + await expectError( + authorizeMidCheck.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + + let rotateLiveVersion = 'membership-v1'; + const rotateMidCheck = service({ + repository: { + rotateSubscriptionAtomically: async (_id, expected) => ( + rotateLiveVersion === expected.membership_version + ? { ...baseRecord, secret_hash: 'rotated', rotated_at_ms: NOW, membership_version: expected.membership_version } + : null + ), + }, + membershipRevocation: { + async assertActive() { + const captured = rotateLiveVersion; + rotateLiveVersion = 'membership-v2'; + return captured; + }, + }, + }); + await expectError( + rotateMidCheck.rotate({ + subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_123', expiresAtMs: NOW + 10, + }), + 'calendar_subscription_not_found', + 404, + ); +} + +{ + const svc = service(); + for (const input of [ + { subjectId: '', projectId: 'project-1', subscriptionId: 'csub_1', expiresAtMs: NOW + 10 }, + { subjectId: 'user-1', projectId: 'project-1', subscriptionId: '', expiresAtMs: NOW + 10 }, + ]) { + await expectError(svc.rotate(input), 'calendar_subscription_request_invalid', 400); + } + await expectError( + svc.rotate({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_1', expiresAtMs: NOW }), + 'calendar_subscription_expiry_invalid', 400, + ); + await expectError( + svc.rotate({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_missing', expiresAtMs: NOW + 10 }), + 'calendar_subscription_not_found', 404, + ); + await expectError( + svc.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_missing' }), + 'calendar_subscription_not_found', 404, + ); + await expectError( + svc.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: '' }), + 'calendar_subscription_request_invalid', 400, + ); +} + +console.log('calendar subscription domain edge tests passed'); diff --git a/tests/unit/calendar-subscription-domain.test.mjs b/tests/unit/calendar-subscription-domain.test.mjs new file mode 100644 index 00000000..44381504 --- /dev/null +++ b/tests/unit/calendar-subscription-domain.test.mjs @@ -0,0 +1,403 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + CALENDAR_SUBSCRIPTION_AUDIENCE, + CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS, + CALENDAR_SUBSCRIPTION_PURPOSE, + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; + +function sequentialRandomSource() { + let seed = 1; + return { + randomBytes(size) { + const bytes = new Uint8Array(size); + for (let index = 0; index < size; index += 1) bytes[index] = (seed + index) % 256; + seed += size; + return bytes; + }, + }; +} + +function makeHarness({ nowMs = 1_800_000_000_000, auditThrows = false } = {}) { + const rows = new Map(); + const hashIndex = new Map(); + const rotations = []; + const usages = []; + const audits = []; + const activeMemberships = new Map([['user-1:project-1', 3]]); + + const repository = { + async insertSubscription(record) { + rows.set(record.subscription_id, structuredClone(record)); + hashIndex.set(record.secret_hash, record.subscription_id); + }, + async listSubscriptions({ subject_id, project_id }) { + return [...rows.values()].filter((row) => row.subject_id === subject_id && row.project_id === project_id); + }, + async findSubscriptionByHash(secretHash) { + const id = hashIndex.get(secretHash); + return id ? structuredClone(rows.get(id)) : null; + }, + async recordUsageAtomically(secretHash, expected) { + const id = hashIndex.get(secretHash); + const row = id ? rows.get(id) : null; + const liveVersion = activeMemberships.get(`${row?.subject_id}:${row?.project_id}`); + if ( + !row + || row.project_id !== expected.project_id + || row.audience !== expected.audience + || row.purpose !== expected.purpose + || row.revoked_at_ms !== null + || row.expires_at_ms <= expected.now_ms + || row.membership_version !== expected.membership_version + || liveVersion !== expected.membership_version + ) return null; + row.last_used_at_ms = expected.now_ms; + usages.push({ subscription_id: row.subscription_id, used_at_ms: expected.now_ms }); + return structuredClone(row); + }, + async rotateSubscriptionAtomically(subscriptionId, expected) { + const row = rows.get(subscriptionId); + const liveVersion = activeMemberships.get(`${row?.subject_id}:${row?.project_id}`); + if ( + !row + || row.subject_id !== expected.subject_id + || row.project_id !== expected.project_id + || row.purpose !== expected.purpose + || row.revoked_at_ms !== null + || row.expires_at_ms <= expected.now_ms + || liveVersion !== expected.membership_version + ) return null; + hashIndex.delete(row.secret_hash); + rotations.push({ + subscription_id: row.subscription_id, + previous_secret_hash: row.secret_hash, + rotated_at_ms: expected.now_ms, + }); + row.secret_hash = expected.new_secret_hash; + row.rotated_at_ms = expected.now_ms; + row.expires_at_ms = expected.expires_at_ms; + row.membership_version = expected.membership_version; + hashIndex.set(row.secret_hash, row.subscription_id); + return structuredClone(row); + }, + async revokeSubscriptionAtomically(subscriptionId, expected) { + const row = rows.get(subscriptionId); + if (!row || row.subject_id !== expected.subject_id || row.project_id !== expected.project_id) return null; + const revocationApplied = row.revoked_at_ms === null; + if (revocationApplied) row.revoked_at_ms = expected.now_ms; + return { ...structuredClone(row), revocation_applied: revocationApplied }; + }, + }; + + const projectAuthorization = { + async assertCanManage({ subjectId, projectId }) { + if (!activeMemberships.has(`${subjectId}:${projectId}`)) throw new Error('not authorized'); + }, + }; + const membershipRevocation = { + async assertActive({ subjectId, projectId }) { + const version = activeMemberships.get(`${subjectId}:${projectId}`); + if (version === undefined) throw new Error('revoked'); + return version; + }, + }; + const auditSink = { + async record(event) { + if (auditThrows) throw new Error('audit unavailable'); + audits.push(structuredClone(event)); + }, + }; + const clock = { nowMs: () => nowMs }; + const service = createCalendarSubscriptionService({ + repository, + clock, + randomSource: sequentialRandomSource(), + auditSink, + projectAuthorization, + membershipRevocation, + }); + + return { + service, + repository, + rows, + hashIndex, + rotations, + usages, + audits, + activeMemberships, + setNow(value) { nowMs = value; }, + }; +} + +async function expectDomainError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof CalendarSubscriptionError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Executive calendar', + expiresAtMs: 1_800_086_400_000, + }); + assert.equal(CALENDAR_SUBSCRIPTION_AUDIENCE, 'scopeweave:calendar'); + assert.match(created.secret, /^[A-Za-z0-9_-]{43}$/); + assert.match(created.subscriptionId, /^csub_[a-f0-9]{32}$/); + assert.equal(created.name, 'Executive calendar'); + assert.equal(created.projectId, 'project-1'); + assert.equal(created.subjectId, 'user-1'); + assert.equal(created.purpose, CALENDAR_SUBSCRIPTION_PURPOSE); + assert.equal(created.audience, CALENDAR_SUBSCRIPTION_AUDIENCE); + assert.equal(created.createdAtMs, 1_800_000_000_000); + assert.equal(created.expiresAtMs, 1_800_086_400_000); + assert.equal(created.lastUsedAtMs, null); + assert.equal(created.rotatedAtMs, null); + assert.equal(created.revokedAtMs, null); + assert.equal(created.status, 'active'); + assert.equal(Object.isFrozen(created), true); + + const stored = h.rows.get(created.subscriptionId); + assert.equal(stored.secret_hash, createHash('sha256').update(created.secret).digest('hex')); + assert.equal(stored.membership_version, 3); + assert.equal(JSON.stringify(stored).includes(created.secret), false); + assert.equal(JSON.stringify(h.audits).includes(created.secret), false); + assert.equal(JSON.stringify(h.audits).includes(stored.secret_hash), false); + assert.deepEqual(h.audits[0], { + event: 'calendar_subscription.created', + subscription_id: created.subscriptionId, + subject_id: 'user-1', + project_id: 'project-1', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + expires_at_ms: 1_800_086_400_000, + }); + + const listed = await h.service.list({ subjectId: 'user-1', projectId: 'project-1' }); + assert.equal(listed.length, 1); + assert.deepEqual(listed[0], { + subscriptionId: created.subscriptionId, + subjectId: 'user-1', + projectId: 'project-1', + name: 'Executive calendar', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + createdAtMs: 1_800_000_000_000, + expiresAtMs: 1_800_086_400_000, + lastUsedAtMs: null, + rotatedAtMs: null, + revokedAtMs: null, + status: 'active', + }); + assert.equal('secret' in listed[0], false); + assert.equal('secretHash' in listed[0], false); + assert.equal('secret_hash' in listed[0], false); + assert.equal('membership_version' in listed[0], false); + + h.setNow(1_800_000_005_000); + const principal = await h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + assert.deepEqual(principal, { + subscriptionId: created.subscriptionId, + subjectId: 'user-1', + projectId: 'project-1', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + }); + assert.deepEqual(h.usages, [{ subscription_id: created.subscriptionId, used_at_ms: 1_800_000_005_000 }]); + assert.equal(JSON.stringify(h.audits).includes(created.secret), false); + assert.equal(JSON.stringify(h.audits).includes(stored.secret_hash), false); + + h.setNow(1_800_000_010_000); + const rotated = await h.service.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + expiresAtMs: 1_800_172_800_000, + }); + assert.notEqual(rotated.secret, created.secret); + assert.equal(rotated.subscriptionId, created.subscriptionId); + assert.equal(rotated.rotatedAtMs, 1_800_000_010_000); + assert.equal(rotated.expiresAtMs, 1_800_172_800_000); + assert.equal(h.rotations.length, 1); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const rotatedPrincipal = await h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }); + assert.equal(rotatedPrincipal.subscriptionId, created.subscriptionId); + assert.equal(rotatedPrincipal.purpose, CALENDAR_SUBSCRIPTION_PURPOSE); + assert.equal(JSON.stringify(h.audits).includes(rotated.secret), false); + assert.equal(JSON.stringify(h.audits).includes(stored.secret_hash), false); + assert.equal(JSON.stringify(h.audits).includes(h.rows.get(created.subscriptionId).secret_hash), false); + + h.setNow(1_800_000_020_000); + const revoked = await h.service.revoke({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + }); + assert.equal(revoked.status, 'revoked'); + assert.equal(revoked.revokedAtMs, 1_800_000_020_000); + const revokedAgain = await h.service.revoke({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + }); + assert.equal(revokedAgain.revokedAtMs, revoked.revokedAtMs); + await expectDomainError( + h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + assert.equal(h.audits.filter((event) => event.event === 'calendar_subscription.revoked').length, 1); + assert.equal(JSON.stringify(h.audits).includes(rotated.secret), false); +} + +{ + const h = makeHarness({ auditThrows: true }); + const created = await h.service.create({ + subjectId: 'user-1', projectId: 'project-1', name: 'Ops', expiresAtMs: 1_800_000_100_000, + }); + assert.ok(h.rows.has(created.subscriptionId)); + const principal = await h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + assert.equal(principal.subjectId, 'user-1'); + const rotated = await h.service.rotate({ + subjectId: 'user-1', projectId: 'project-1', subscriptionId: created.subscriptionId, expiresAtMs: 1_800_000_200_000, + }); + assert.ok(rotated.secret); + const revoked = await h.service.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: created.subscriptionId }); + assert.equal(revoked.status, 'revoked'); +} + +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Exact expiry', + expiresAtMs: 1_800_000_060_000, + }); + h.setNow(created.expiresAtMs); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const listed = await h.service.list({ subjectId: 'user-1', projectId: 'project-1' }); + assert.equal(listed[0].status, 'expired'); +} + +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Rejoin must rotate', + expiresAtMs: 1_800_086_400_000, + }); + h.activeMemberships.delete('user-1:project-1'); + h.activeMemberships.set('user-1:project-1', 4); + assert.equal(h.rows.get(created.subscriptionId).revoked_at_ms, null); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const rotated = await h.service.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + expiresAtMs: 1_800_172_800_000, + }); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const principal = await h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }); + assert.equal(principal.purpose, CALENDAR_SUBSCRIPTION_PURPOSE); + assert.equal(h.rows.get(created.subscriptionId).membership_version, 4); +} + +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Rotate wins use', + expiresAtMs: 1_800_086_400_000, + }); + let releaseUsage; + const usageGate = new Promise((resolve) => { + releaseUsage = resolve; + }); + const originalUsage = h.repository.recordUsageAtomically.bind(h.repository); + h.repository.recordUsageAtomically = async (...args) => { + await usageGate; + return originalUsage(...args); + }; + const authorizePromise = h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + const rotated = await h.service.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + expiresAtMs: 1_800_172_800_000, + }); + releaseUsage(); + await expectDomainError(authorizePromise, 'calendar_subscription_unauthorized', 401); + const principal = await h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }); + assert.equal(principal.subscriptionId, created.subscriptionId); +} + +{ + const h = makeHarness({ nowMs: Date.UTC(2028, 1, 28, 12, 0, 0) }); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Leap-year feed', + expiresAtMs: Date.UTC(2028, 2, 1, 12, 0, 0), + }); + h.setNow(Date.UTC(2028, 1, 29, 12, 0, 0)); + const principal = await h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + assert.equal(principal.purpose, CALENDAR_SUBSCRIPTION_PURPOSE); + h.setNow(created.expiresAtMs); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); +} + +{ + const h = makeHarness(); + const maxExpiry = 1_800_000_000_000 + CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS; + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Max lifetime', + expiresAtMs: maxExpiry, + }); + assert.equal(created.expiresAtMs, maxExpiry); + await expectDomainError( + h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Too long', + expiresAtMs: maxExpiry + 1, + }), + 'calendar_subscription_expiry_invalid', + 400, + ); +} + +console.log('calendar subscription domain behavior tests passed'); diff --git a/tests/unit/calendar-subscription-return-boundary.test.mjs b/tests/unit/calendar-subscription-return-boundary.test.mjs new file mode 100644 index 00000000..3bfd0122 --- /dev/null +++ b/tests/unit/calendar-subscription-return-boundary.test.mjs @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { + CALENDAR_SUBSCRIPTION_AUDIENCE, + CALENDAR_SUBSCRIPTION_PURPOSE, + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; + +const NOW = 1_900_000_000_000; +const ROTATE_EXPIRY = NOW + 10; +const VALID_SECRET = Buffer.alloc(32, 7).toString('base64url'); + +const baseRecord = { + subscription_id: 'csub_123', + subject_id: 'user-1', + project_id: 'project-1', + name: 'Calendar', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: 'membership-v1', + created_at_ms: NOW - 1, + expires_at_ms: NOW + 100, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, +}; + +function service(repositoryOverrides, auditSink = { record: async () => {} }) { + return createCalendarSubscriptionService({ + repository: { + insertSubscription: async () => {}, + listSubscriptions: async () => [], + findSubscriptionByHash: async () => baseRecord, + recordUsageAtomically: async () => null, + rotateSubscriptionAtomically: async () => null, + revokeSubscriptionAtomically: async () => null, + ...repositoryOverrides, + }, + clock: { nowMs: () => NOW }, + randomSource: { randomBytes: (size) => new Uint8Array(size).fill(9) }, + auditSink, + projectAuthorization: { assertCanManage: async () => {} }, + membershipRevocation: { assertActive: async () => 'membership-v1' }, + }); +} + +async function expectDomainError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof CalendarSubscriptionError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +for (const returned of [ + { ...baseRecord, subscription_id: 'csub_other', last_used_at_ms: NOW }, + { ...baseRecord, subject_id: 'user-2', last_used_at_ms: NOW }, + { ...baseRecord, project_id: 'project-2', last_used_at_ms: NOW }, + { ...baseRecord, purpose: 'session', last_used_at_ms: NOW }, + { ...baseRecord, audience: 'scopeweave:other', last_used_at_ms: NOW }, + { ...baseRecord, membership_version: 'membership-v2', last_used_at_ms: NOW }, +]) { + const candidate = service({ recordUsageAtomically: async () => returned }); + await expectDomainError( + candidate.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); +} + +const rotatedRecord = { + ...baseRecord, + expires_at_ms: ROTATE_EXPIRY, + rotated_at_ms: NOW, +}; + +for (const returned of [ + { ...rotatedRecord, subscription_id: 'csub_other' }, + { ...rotatedRecord, subject_id: 'user-2' }, + { ...rotatedRecord, project_id: 'project-2' }, + { ...rotatedRecord, purpose: 'session' }, + { ...rotatedRecord, audience: 'scopeweave:other' }, + { ...rotatedRecord, membership_version: 'membership-v2' }, + { ...rotatedRecord, expires_at_ms: ROTATE_EXPIRY + 1 }, +]) { + const candidate = service({ rotateSubscriptionAtomically: async () => returned }); + await expectDomainError( + candidate.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: 'csub_123', + expiresAtMs: ROTATE_EXPIRY, + }), + 'calendar_subscription_not_found', + 404, + ); +} + +for (const returned of [ + null, + 'not-a-record', + [], + { ...baseRecord, subject_id: 'user-2' }, + { ...baseRecord, project_id: 'project-2' }, + { ...baseRecord, purpose: 'session' }, + { ...baseRecord, audience: 'scopeweave:other' }, +]) { + const candidate = service({ listSubscriptions: async () => [returned] }); + await expectDomainError( + candidate.list({ subjectId: 'user-1', projectId: 'project-1' }), + 'calendar_subscription_not_found', + 404, + ); +} + +const revokedRecord = { + ...baseRecord, + revoked_at_ms: NOW, + revocation_applied: true, +}; + +for (const returned of [ + 'not-a-record', + [], + { ...revokedRecord, subscription_id: 'csub_other' }, + { ...revokedRecord, subject_id: 'user-2' }, + { ...revokedRecord, project_id: 'project-2' }, + { ...revokedRecord, purpose: 'session' }, + { ...revokedRecord, audience: 'scopeweave:other' }, + { ...baseRecord, revocation_applied: true }, + { ...revokedRecord, revocation_applied: 'yes' }, +]) { + const audits = []; + const candidate = service( + { revokeSubscriptionAtomically: async () => returned }, + { record: async (event) => { audits.push(event); } }, + ); + await expectDomainError( + candidate.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_123' }), + 'calendar_subscription_not_found', + 404, + ); + assert.deepEqual(audits, [], 'untrusted revoke rows must not produce audit evidence'); +} + +console.log('calendar subscription atomic return boundary tests passed'); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index d4fe580b..46ab903e 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -39,6 +39,11 @@ assert.match( /--include=server\/access_grant_domain\.mjs/, 'the short-lived access-grant domain is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/calendar_subscription_domain\.mjs/, + 'the durable calendar-subscription domain is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/access-grant-domain\.test\.mjs/, @@ -49,6 +54,21 @@ assert.match( /tests\/unit\/access-grant-domain-edge\.test\.mjs/, 'the access-grant edge cases execute under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-domain\.test\.mjs/, + 'the calendar-subscription lifecycle contract executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /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-return-boundary\.test\.mjs/, + 'the calendar-subscription atomic-return trust-boundary regression executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/,