diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..d79cba93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added workflow ownership regression coverage so central review workflows stay inherited from `ContextualWisdomLab/.github`, not copied into this repository. +- Added a framework-neutral short-lived access-grant domain for the bounded + `stream` and `attachment_view` purposes, with injectable authorization, + membership-revocation, random-source, clock, audit, and atomic repository + ports. Route, database, calendar-subscription, and client migration remain + follow-up work under issue #413. ### Security @@ -50,6 +55,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added cross-device regression coverage proving that `logout-all` rejects stale tokens on bearer, calendar, SSE, and attachment-view transports while the replacement token continues through the same authentication boundary. +- Bound short-lived access grants to one project, purpose, audience and, for + attachment views, one attachment; capped their TTL at five minutes, persisted + only SHA-256 token hashes through the repository port, rechecked membership on + redemption, and required the repository to perform one-time consumption as an + atomic transition. ### Changed diff --git a/docs/doctoring/short-lived-access-grant-domain.md b/docs/doctoring/short-lived-access-grant-domain.md new file mode 100644 index 00000000..ebc29b2c --- /dev/null +++ b/docs/doctoring/short-lived-access-grant-domain.md @@ -0,0 +1,195 @@ +# Short-lived access-grant domain: evidence and design record + +## Status and bounded scope + +This record describes active pull-request work for issue #413. It is **not +protected-`develop` shipped truth** until the corresponding pull request merges. +The bounded slice establishes only the framework-neutral short-lived grant +policy and repository contract used later by HTTP and persistence adapters. + +This slice deliberately does **not** yet: + +- replace the existing session JWT query-string transports; +- add the authenticated grant exchange route; +- create SQLite or PostgreSQL grant tables; +- integrate SSE, attachment-view, or calendar clients; +- implement long-lived calendar-subscription secrets, rotation, or UI; or +- claim issue #413 is complete. + +Those operations need separately reviewable migrations, route adapters, +revocation hooks, browser acceptance tests, and recovery evidence. + +## Threat and decision + +A general session bearer token in a URI has more authority and a longer lifetime +than an SSE bootstrap or one attachment view requires. URI credentials may also +appear in browser history, reverse-proxy/access logs, observability systems, +copied URLs, screenshots, and incident artifacts. RFC 6750 therefore discourages +URI query transport because of its logging exposure, while RFC 9700 states that +OAuth clients must not pass access tokens in URI query parameters. + +Where a browser mechanism still requires URL-carried authority, ScopeWeave will +move toward a narrowly scoped opaque credential rather than another +resource-general JWT. The current domain slice implements two short-lived +purposes: + +- `stream` with audience `scopeweave:stream`; and +- `attachment_view` with audience `scopeweave:attachment-view` and one required + attachment identifier. + +Both have a hard maximum lifetime of 300 seconds. Calendar subscription +credentials are excluded because a long-lived subscription needs an independent +secret lifecycle, rotation, usage metadata, and user-facing revocation policy. + +## Ports and authority boundary + +The domain depends on explicit ports instead of Hono, SQLite, Clearfolio, or a +browser implementation: + +```mermaid +flowchart LR + Caller[Authenticated caller] --> Domain[Access-grant domain] + Domain --> Authz[ProjectAuthorizationPort] + Domain --> Membership[MembershipRevocationPort] + Domain --> Repository[AccessGrantRepository] + Domain --> Clock[AccessGrantClock] + Domain --> Random[AccessGrantRandomSource] + Domain --> Audit[AccessGrantAuditSink] + Repository --> Atomic[Atomic one-time consume plus membership version check] + Repository --> Outbox[Production transactional audit outbox] +``` + +Required repository methods are `insertGrant`, `findGrantByHash`, and +`consumeGrantAtomically`. `MembershipRevocationPort.assertActive()` returns an +opaque membership version captured during the active-state check; the domain +passes that version into `consumeGrantAtomically`, and a production repository +adapter must compare it with live membership state inside the same atomic +consume boundary. An adapter that cannot share that transaction boundary must +instead atomically revoke affected grants as part of membership removal. A +separate check followed by an unconditional consume is not compliant. + +The eventual SQLite and PostgreSQL adapters must run the same repository +contract. The repository—not the HTTP framework—owns the atomic state transition +that makes concurrent one-time consumption yield at most one success and closes +the revoke-between-check-and-consume race. A successful repository mutation does +not make its returned object trusted: the domain rechecks grant, subject, +project, purpose, audience, and attachment identity against the pre-consume +record and caller binding before that object may become a principal or audit +identity. A mismatched atomic return fails closed even though the one-time grant +may already have been consumed. + +## Security invariants + +The implementation enforces these invariants before route integration: + +1. Secrets contain 32 random bytes encoded with unpadded base64url. The random + source must return an actual 32-byte `Uint8Array`. +2. Only a SHA-256 token hash is passed to persistence; plaintext grant secrets + are never part of stored records or audit events. +3. Purpose and audience are fixed pairs rather than caller-extensible strings. +4. Stream grants cannot carry an attachment identifier; attachment-view grants + require exactly one bound attachment identifier. +5. Project authorization is checked before minting. An authorization failure is + intentionally represented by a generic not-authorized result suitable for a + tenant-nondisclosing route response. +6. Membership activity is checked before redemption and its captured membership + version is part of the atomic consume condition, so a revocation that wins the + race prevents consumption. +7. Redemption requires an exact secret shape plus purpose, audience, project, + attachment, membership-version, expiry, unused, and unrevoked conditions. + Missing, malformed, expired, used, revoked, stale-membership, wrong-resource, + or otherwise unusable grants collapse to the same unauthorized result. +8. Time values and expiry arithmetic must be non-negative safe integers. Exact + expiry is non-usable (`now >= expires_at`). +9. Successful redemption depends on the repository's atomic consume operation; + read-then-write consumption in a route adapter is not compliant. +10. Audit metadata may contain grant identifiers and bound resource metadata, + but never the plaintext secret or token hash. +11. Once `insertGrant` or `consumeGrantAtomically` durably commits, downstream + audit-delivery failure does not convert that completed operation into a + client-visible failure that could trigger unsafe retry. Production adapters + that require durable audit evidence must persist an audit outbox in the same + transaction and deliver it asynchronously. +12. The object returned by `consumeGrantAtomically` is untrusted adapter output. + Its grant, subject, project, purpose, audience, and attachment identities + must exactly match the pre-consume grant and requested binding before the + domain emits a principal or audit event. A forged or stale return object is + rejected with the same tenant-nondisclosing unauthorized result. + +The generated `grant_id` is an operational correlation identifier, not a bearer +credential. It uses an independent 16 random bytes and is never derived from the +secret or its token hash, so audit correlation does not disclose token-hash +material. This provides 128 bits of independent entropy without coupling the +identifier format to UUID semantics. + +## Persistence contract for follow-up adapters + +No database object is added in this slice. Follow-up persistence work must use +3NF and descriptive two-or-more-word `snake_case` object names, including the +issue-defined `access_grants`, `grant_consumptions`, and `grant_revocations` +objects where those responsibilities remain distinct. The adapter must make +expiry/revocation/use predicates, live membership-version comparison, and the +first successful consumption one transactionally atomic transition. A stale +read followed by an unconditional update is not sufficient. + +Production persistence must also preserve hash-only storage across restart and +use a transactionally durable audit-outbox record for grant state changes when +audit evidence is mandatory. The external `AccessGrantAuditSink` is a +post-commit delivery boundary; sink availability must not change a completed +grant result. Migration, rollback, and recovery evidence must keep schema +generation, grant state, membership versions, and outbox state consistent. + +## TDD and acceptance evidence + +The first contract commit intentionally imported the absent +`server/access_grant_domain.mjs`; Node returned `ERR_MODULE_NOT_FOUND`, providing +the RED evidence before implementation. The production module was added only +after the behavior and coverage registrations were committed. + +Focused contract tests cover: + +- dependency-port validation; +- 32-byte opaque-token generation and hash-only persistence; +- independently random non-secret grant identifiers; +- secret/hash exclusion from audit events; +- fixed purpose/audience/resource binding; +- maximum and exact TTL boundaries; +- inaccessible-project and revoked-membership behavior; +- revocation occurring after the membership check but before atomic consumption; +- forged atomic-consume return identities that attempt to substitute a different + subject or project after the durable one-time transition; +- audit-sink rejection after durable mint and consume transitions; +- malformed and unknown secrets; +- exact-expiry rejection; +- one-time replay rejection; and +- two concurrent redemption attempts producing exactly one success through the + repository's atomic consume contract. + +The production source and both access-grant behavior test files are registered +explicitly in the repository `c8` producer, and the coverage-registration +contract prevents them from silently dropping out. An earlier focused Node V8 +run produced 100% statement/line, branch, and function coverage before the latest +race/durability hardening; hosted exact-current-head coverage is authoritative +for the resulting implementation. + +## Rollback and compatibility + +This slice has no route, schema, migration, session-token, Clearfolio, or browser +behavior change. Rollback therefore removes the domain module, its contract and +edge tests, coverage registrations, this record, and the matching changelog +entry together. Existing protected behavior is unchanged until a later route +integration explicitly migrates a transport. + +## References + +Jones, M. B., & 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 8cefdc74..c056826b 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/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 --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.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/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: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/access_grant_domain.mjs b/server/access_grant_domain.mjs new file mode 100644 index 00000000..6d6c0672 --- /dev/null +++ b/server/access_grant_domain.mjs @@ -0,0 +1,365 @@ +import { createHash } from 'node:crypto'; + +const TOKEN_BYTES = 32; +const GRANT_ID_BYTES = 16; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const MAX_TTL_SECONDS = 300; +const MEMBERSHIP_VERSION_MAX_LENGTH = 128; +const MEMBERSHIP_VERSION_CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/u; + +/** Supported short-lived grant purposes in this bounded domain slice. */ +export const ACCESS_GRANT_PURPOSES = Object.freeze({ + STREAM: 'stream', + ATTACHMENT_VIEW: 'attachment_view', +}); + +/** Fixed resource-server audiences paired with each supported grant purpose. */ +export const ACCESS_GRANT_AUDIENCES = Object.freeze({ + STREAM: 'scopeweave:stream', + ATTACHMENT_VIEW: 'scopeweave:attachment-view', +}); + +const PURPOSE_AUDIENCE = Object.freeze(Object.assign(Object.create(null), { + [ACCESS_GRANT_PURPOSES.STREAM]: ACCESS_GRANT_AUDIENCES.STREAM, + [ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW]: ACCESS_GRANT_AUDIENCES.ATTACHMENT_VIEW, +})); + +/** + * Stable domain error safe for route adapters to map without exposing grant state. + */ +export class AccessGrantError extends Error { + /** + * @param {string} code Stable machine-readable error code. + * @param {number} status Suggested HTTP status for a thin route adapter. + */ + constructor(code, status) { + super(code); + this.name = 'AccessGrantError'; + this.code = code; + this.status = status; + } +} + +function requireMethod(port, method) { + if (!port || typeof port[method] !== 'function') { + throw new TypeError(`access-grant dependency must provide ${method}()`); + } +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeAttachmentId(purpose, attachmentId) { + if (purpose === ACCESS_GRANT_PURPOSES.ATTACHMENT_VIEW) { + return isNonEmptyString(attachmentId) ? attachmentId : undefined; + } + return attachmentId === undefined || attachmentId === null ? null : undefined; +} + +function validateMintRequest({ subjectId, projectId, purpose, audience, attachmentId, ttlSeconds }) { + const expectedAudience = PURPOSE_AUDIENCE[purpose]; + const normalizedAttachmentId = normalizeAttachmentId(purpose, attachmentId); + if ( + !isNonEmptyString(subjectId) + || !isNonEmptyString(projectId) + || !expectedAudience + || audience !== expectedAudience + || normalizedAttachmentId === undefined + ) { + throw new AccessGrantError('access_grant_request_invalid', 400); + } + if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_TTL_SECONDS) { + throw new AccessGrantError('access_grant_ttl_invalid', 400); + } + return { expectedAudience, normalizedAttachmentId }; +} + +function validateRedeemBinding({ purpose, audience, projectId, attachmentId }) { + const expectedAudience = PURPOSE_AUDIENCE[purpose]; + const normalizedAttachmentId = normalizeAttachmentId(purpose, attachmentId); + if ( + !expectedAudience + || audience !== expectedAudience + || !isNonEmptyString(projectId) + || normalizedAttachmentId === undefined + ) { + throw unauthorizedGrant(); + } + return { normalizedAttachmentId }; +} + +function unauthorizedGrant() { + return new AccessGrantError('access_grant_unauthorized', 401); +} + +function snapshotUnconsumedGrant(existing) { + if ( + !existing + || typeof existing !== 'object' + || Array.isArray(existing) + || existing.used_at_ms !== null + ) { + throw unauthorizedGrant(); + } + return Object.freeze({ + grant_id: existing.grant_id, + subject_id: existing.subject_id, + project_id: existing.project_id, + purpose: existing.purpose, + audience: existing.audience, + attachment_id: existing.attachment_id ?? null, + }); +} + +function validateConsumedGrant(existing, consumed, { + purpose, + audience, + projectId, + attachmentId, + nowMs, +}) { + if ( + !consumed + || typeof consumed !== 'object' + || Array.isArray(consumed) + || consumed.used_at_ms !== nowMs + || consumed.grant_id !== existing.grant_id + || consumed.subject_id !== existing.subject_id + || consumed.project_id !== existing.project_id + || consumed.project_id !== projectId + || consumed.purpose !== existing.purpose + || consumed.purpose !== purpose + || consumed.audience !== existing.audience + || consumed.audience !== audience + || (consumed.attachment_id ?? null) !== existing.attachment_id + || (consumed.attachment_id ?? null) !== (attachmentId ?? null) + ) { + throw unauthorizedGrant(); + } + return consumed; +} + +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() + && !MEMBERSHIP_VERSION_CONTROL_PATTERN.test(value) + ) { + return value; + } + throw unauthorizedGrant(); +} + +function hashSecret(secret) { + return createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +function encodeSecret(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== TOKEN_BYTES) { + throw new TypeError(`access-grant random source must return ${TOKEN_BYTES} bytes`); + } + return Buffer.from(bytes).toString('base64url'); +} + +function encodeGrantId(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== GRANT_ID_BYTES) { + throw new TypeError(`access-grant random source must return ${GRANT_ID_BYTES} bytes for grant id`); + } + return `agr_${Buffer.from(bytes).toString('hex')}`; +} + +function readNow(clock) { + const nowMs = clock.nowMs(); + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError('access-grant clock must return a non-negative safe integer'); + } + return nowMs; +} + +async function recordAuditBestEffort(auditSink, event) { + try { + await auditSink.record(event); + } catch { + // Durable grant state is authoritative once its repository transition + // commits. Production adapters should pair that transition with a durable + // audit outbox; a downstream audit-delivery outage must not make clients + // retry a mint or already-consumed one-time grant. + } +} + +/** + * Build a framework-neutral short-lived access-grant service. + * + * The repository owns durable state and must implement one-time consumption as + * an atomic transition. The service owns validation, purpose/audience binding, + * hash-only persistence, membership re-checks, and secret-free audit events. + * Calendar subscription secrets intentionally remain out of scope because they + * require a separate rotation/revocation lifecycle. + * + * MembershipRevocationPort.assertActive() returns an opaque membership version + * captured during the active-state check. The version must be either a + * non-negative safe integer or a trimmed, control-free string of at most 128 + * characters. AccessGrantRepository must compare that version against live + * membership state inside consumeGrantAtomically(), closing the + * revoke-between-check-and-consume race. Adapters without a shared transaction + * boundary must atomically revoke affected grants when membership changes + * instead. The atomic consume return value is still treated as untrusted port + * data: it must match an immutable snapshot of the pre-consume grant and the + * requested binding, and must prove the previously unused grant became used at + * this consume attempt's exact timestamp before it can become the redeemed + * principal or an audit identity. + * + * Audit delivery is post-commit and best-effort at this domain boundary so a + * sink outage never changes the result of an already durable grant operation. + * Production persistence adapters should use a transactional audit outbox when + * durable audit evidence is required. + * + * @param {object} ports Injected infrastructure and authorization ports. + * @param {object} ports.repository AccessGrantRepository implementation. + * @param {object} ports.clock AccessGrantClock with nowMs(). + * @param {object} ports.randomSource AccessGrantRandomSource with randomBytes(). + * @param {object} ports.auditSink AccessGrantAuditSink with record(). + * @param {object} ports.projectAuthorization ProjectAuthorizationPort. + * @param {object} ports.membershipRevocation MembershipRevocationPort. + * @returns {{mint: Function, redeem: Function}} Immutable grant service. + */ +export function createAccessGrantService({ + repository, + clock, + randomSource, + auditSink, + projectAuthorization, + membershipRevocation, +} = {}) { + requireMethod(repository, 'insertGrant'); + requireMethod(repository, 'findGrantByHash'); + requireMethod(repository, 'consumeGrantAtomically'); + requireMethod(clock, 'nowMs'); + requireMethod(randomSource, 'randomBytes'); + requireMethod(auditSink, 'record'); + requireMethod(projectAuthorization, 'assertCanIssue'); + requireMethod(membershipRevocation, 'assertActive'); + + async function mint({ subjectId, projectId, purpose, audience, attachmentId, ttlSeconds }) { + const { expectedAudience, normalizedAttachmentId } = validateMintRequest({ + subjectId, + projectId, + purpose, + audience, + attachmentId, + ttlSeconds, + }); + try { + await projectAuthorization.assertCanIssue({ + subjectId, + projectId, + purpose, + attachmentId: normalizedAttachmentId, + }); + } catch { + throw new AccessGrantError('access_grant_not_authorized', 404); + } + + const nowMs = readNow(clock); + const expiresAtMs = nowMs + (ttlSeconds * 1000); + if (!Number.isSafeInteger(expiresAtMs)) { + throw new AccessGrantError('access_grant_ttl_invalid', 400); + } + const secret = encodeSecret(randomSource.randomBytes(TOKEN_BYTES)); + const tokenHash = hashSecret(secret); + const grantId = encodeGrantId(randomSource.randomBytes(GRANT_ID_BYTES)); + const record = { + grant_id: grantId, + token_hash: tokenHash, + subject_id: subjectId, + project_id: projectId, + purpose, + audience: expectedAudience, + attachment_id: normalizedAttachmentId, + issued_at_ms: nowMs, + expires_at_ms: expiresAtMs, + used_at_ms: null, + revoked_at_ms: null, + }; + await repository.insertGrant(record); + await recordAuditBestEffort(auditSink, { + event: 'access_grant.minted', + grant_id: grantId, + subject_id: subjectId, + project_id: projectId, + purpose, + audience: expectedAudience, + attachment_id: normalizedAttachmentId, + expires_at_ms: expiresAtMs, + }); + return Object.freeze({ + secret, + grantId, + subjectId, + projectId, + purpose, + audience: expectedAudience, + attachmentId: normalizedAttachmentId, + expiresAtMs, + }); + } + + async function redeem({ secret, purpose, audience, projectId, attachmentId }) { + if (typeof secret !== 'string' || !TOKEN_PATTERN.test(secret)) throw unauthorizedGrant(); + const { normalizedAttachmentId } = validateRedeemBinding({ purpose, audience, projectId, attachmentId }); + const tokenHash = hashSecret(secret); + const existing = await repository.findGrantByHash(tokenHash); + const existingSnapshot = snapshotUnconsumedGrant(existing); + let membershipVersion; + try { + membershipVersion = normalizeMembershipVersion(await membershipRevocation.assertActive({ + subjectId: existingSnapshot.subject_id, + projectId: existingSnapshot.project_id, + })); + } catch { + throw unauthorizedGrant(); + } + const nowMs = readNow(clock); + const consumed = validateConsumedGrant( + existingSnapshot, + await repository.consumeGrantAtomically(tokenHash, { + now_ms: nowMs, + purpose, + audience, + project_id: projectId, + attachment_id: normalizedAttachmentId, + membership_version: membershipVersion, + }), + { + purpose, + audience, + projectId, + attachmentId: normalizedAttachmentId, + nowMs, + }, + ); + await recordAuditBestEffort(auditSink, { + event: 'access_grant.consumed', + grant_id: consumed.grant_id, + subject_id: consumed.subject_id, + project_id: consumed.project_id, + purpose: consumed.purpose, + audience: consumed.audience, + attachment_id: consumed.attachment_id, + }); + return Object.freeze({ + grantId: consumed.grant_id, + subjectId: consumed.subject_id, + projectId: consumed.project_id, + purpose: consumed.purpose, + audience: consumed.audience, + attachmentId: consumed.attachment_id, + }); + } + + return Object.freeze({ mint, redeem }); +} \ No newline at end of file diff --git a/tests/unit/access-grant-domain-edge.test.mjs b/tests/unit/access-grant-domain-edge.test.mjs new file mode 100644 index 00000000..aa10d33e --- /dev/null +++ b/tests/unit/access-grant-domain-edge.test.mjs @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; +import { createAccessGrantService } from '../../server/access_grant_domain.mjs'; + +class MemoryRepository { + constructor() { this.records = new Map(); } + async insertGrant(record) { this.records.set(record.token_hash, structuredClone(record)); } + async findGrantByHash(hash) { return this.records.get(hash) ?? null; } + async consumeGrantAtomically() { return null; } +} + +class ConsumableRepository extends MemoryRepository { + constructor() { + super(); + this.liveMembershipVersion = 1; + } + + async consumeGrantAtomically(hash, expected) { + const record = this.records.get(hash); + if (!record || record.used_at_ms !== null || record.revoked_at_ms !== null) return null; + if (expected.now_ms >= record.expires_at_ms) return null; + if (record.purpose !== expected.purpose || record.audience !== expected.audience) return null; + if (record.project_id !== expected.project_id) return null; + if ((record.attachment_id ?? null) !== (expected.attachment_id ?? null)) return null; + if (expected.membership_version !== undefined && expected.membership_version !== this.liveMembershipVersion) return null; + record.used_at_ms = expected.now_ms; + return structuredClone(record); + } +} + +const validPorts = () => ({ + repository: new MemoryRepository(), + clock: { nowMs: () => 1_000 }, + randomSource: { randomBytes: (size) => new Uint8Array(size).fill(7) }, + auditSink: { record: async () => {} }, + projectAuthorization: { assertCanIssue: async () => {} }, + membershipRevocation: { assertActive: async () => 1 }, +}); + +{ + const common = validPorts(); + assert.throws(() => createAccessGrantService({ ...common, repository: { insertGrant: async () => {} } }), /findGrantByHash/); + assert.throws(() => createAccessGrantService({ + ...common, + repository: { insertGrant: async () => {}, findGrantByHash: async () => null }, + }), /consumeGrantAtomically/); +} + +for (const randomBytes of [() => new Uint8Array(31), () => Array(32).fill(1)]) { + const service = createAccessGrantService({ ...validPorts(), randomSource: { randomBytes } }); + await assert.rejects(service.mint({ + subjectId: 'u', projectId: 'p', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10, + }), /random source must return 32 bytes/); +} + +{ + let calls = 0; + const service = createAccessGrantService({ + ...validPorts(), + randomSource: { + randomBytes(size) { + calls += 1; + return calls === 1 ? new Uint8Array(size).fill(9) : new Uint8Array(15).fill(9); + }, + }, + }); + await assert.rejects(service.mint({ + subjectId: 'u', projectId: 'p', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10, + }), /random source must return 16 bytes for grant id/); +} + +for (const nowMs of [() => Number.NaN, () => -1]) { + const service = createAccessGrantService({ ...validPorts(), clock: { nowMs } }); + await assert.rejects(service.mint({ + subjectId: 'u', projectId: 'p', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10, + }), /clock must return a non-negative safe integer/); +} + +{ + const service = createAccessGrantService({ ...validPorts(), clock: { nowMs: () => Number.MAX_SAFE_INTEGER - 500 } }); + await assert.rejects(service.mint({ + subjectId: 'u', projectId: 'p', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 1, + }), (error) => error.code === 'access_grant_ttl_invalid' && error.status === 400); +} + +{ + const service = createAccessGrantService(validPorts()); + await assert.rejects(service.mint({ + subjectId: null, projectId: 'p', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10, + }), (error) => error.code === 'access_grant_request_invalid'); + await assert.rejects(service.redeem({ + secret: null, purpose: 'stream', audience: 'scopeweave:stream', projectId: 'p', + }), (error) => error.code === 'access_grant_unauthorized'); + await assert.rejects(service.redeem({ + secret: 'A'.repeat(43), purpose: 'stream', audience: 'scopeweave:stream', projectId: 'p', + }), (error) => error.code === 'access_grant_unauthorized'); +} + +{ + const service = createAccessGrantService(validPorts()); + await assert.rejects(service.mint({ + subjectId: 'prototype-user', + projectId: 'prototype-project', + purpose: 'toString', + audience: Object.prototype.toString, + ttlSeconds: 10, + }), (error) => error.code === 'access_grant_request_invalid' && error.status === 400); +} + +for (const membershipVersion of [ + undefined, + null, + -1, + Number.NaN, + '', + ' ', + 'membership\nversion', + 'm'.repeat(129), + {}, +]) { + const repository = new ConsumableRepository(); + let consumeCalls = 0; + const consume = repository.consumeGrantAtomically.bind(repository); + repository.consumeGrantAtomically = async (...args) => { + consumeCalls += 1; + return consume(...args); + }; + const service = createAccessGrantService({ + ...validPorts(), + repository, + membershipRevocation: { assertActive: async () => membershipVersion }, + }); + const grant = await service.mint({ + subjectId: 'invalid-version-user', + projectId: 'invalid-version-project', + purpose: 'stream', + audience: 'scopeweave:stream', + ttlSeconds: 10, + }); + await assert.rejects(service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'invalid-version-project', + }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); + assert.equal(consumeCalls, 0, 'invalid membership versions must fail before the atomic consume boundary'); +} + +{ + const repository = new ConsumableRepository(); + repository.liveMembershipVersion = 'membership-v2'; + const service = createAccessGrantService({ + ...validPorts(), + repository, + membershipRevocation: { assertActive: async () => 'membership-v2' }, + }); + const grant = await service.mint({ + subjectId: 'string-version-user', + projectId: 'string-version-project', + purpose: 'stream', + audience: 'scopeweave:stream', + ttlSeconds: 10, + }); + const redeemed = await service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'string-version-project', + }); + assert.equal(redeemed.subjectId, 'string-version-user'); +} + +{ + const repository = new ConsumableRepository(); + let rejectAudit = true; + const service = createAccessGrantService({ + ...validPorts(), + repository, + auditSink: { async record() { if (rejectAudit) throw new Error('audit unavailable'); } }, + }); + const grant = await service.mint({ + subjectId: 'audit-user', projectId: 'audit-project', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10, + }); + assert.ok([...repository.records.values()].some(({ grant_id }) => grant_id === grant.grantId)); + rejectAudit = true; + const redeemed = await service.redeem({ + secret: grant.secret, purpose: 'stream', audience: 'scopeweave:stream', projectId: 'audit-project', + }); + assert.equal(redeemed.grantId, grant.grantId, 'audit delivery failure cannot turn durable consumption into a client-visible failure'); +} + +{ + const repository = new ConsumableRepository(); + let revokeDuringCheck = false; + const service = createAccessGrantService({ + ...validPorts(), + repository, + membershipRevocation: { + async assertActive() { + const capturedVersion = repository.liveMembershipVersion; + if (revokeDuringCheck) repository.liveMembershipVersion += 1; + return capturedVersion; + }, + }, + }); + const grant = await service.mint({ + subjectId: 'race-user', projectId: 'race-project', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10, + }); + revokeDuringCheck = true; + await assert.rejects(service.redeem({ + secret: grant.secret, purpose: 'stream', audience: 'scopeweave:stream', projectId: 'race-project', + }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); +} + +{ + const repository = new ConsumableRepository(); + const consume = repository.consumeGrantAtomically.bind(repository); + repository.consumeGrantAtomically = async (...args) => { + const consumed = await consume(...args); + return consumed ? { ...consumed, subject_id: 'foreign-subject', project_id: 'foreign-project' } : null; + }; + const service = createAccessGrantService({ ...validPorts(), repository }); + const grant = await service.mint({ + subjectId: 'return-boundary-user', + projectId: 'return-boundary-project', + purpose: 'stream', + audience: 'scopeweave:stream', + ttlSeconds: 10, + }); + await assert.rejects(service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'return-boundary-project', + }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); +} + +{ + const repository = new ConsumableRepository(); + repository.consumeGrantAtomically = async (hash, expected) => { + const record = repository.records.get(hash); + if (!record) return null; + record.used_at_ms = expected.now_ms; + record.subject_id = 'mutated-subject'; + return structuredClone(record); + }; + const service = createAccessGrantService({ ...validPorts(), repository }); + const grant = await service.mint({ + subjectId: 'alias-boundary-user', + projectId: 'alias-boundary-project', + purpose: 'stream', + audience: 'scopeweave:stream', + ttlSeconds: 10, + }); + await assert.rejects(service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'alias-boundary-project', + }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); +} + +{ + const repository = new ConsumableRepository(); + repository.consumeGrantAtomically = async (hash) => { + const record = repository.records.get(hash); + return record ? structuredClone(record) : null; + }; + const service = createAccessGrantService({ ...validPorts(), repository }); + const grant = await service.mint({ + subjectId: 'uncommitted-return-user', + projectId: 'uncommitted-return-project', + purpose: 'stream', + audience: 'scopeweave:stream', + ttlSeconds: 10, + }); + await assert.rejects(service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'uncommitted-return-project', + }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); +} + +console.log('✓ access-grant domain edge coverage passed'); \ No newline at end of file diff --git a/tests/unit/access-grant-domain.test.mjs b/tests/unit/access-grant-domain.test.mjs new file mode 100644 index 00000000..5b60f288 --- /dev/null +++ b/tests/unit/access-grant-domain.test.mjs @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + ACCESS_GRANT_AUDIENCES, + ACCESS_GRANT_PURPOSES, + AccessGrantError, + createAccessGrantService, +} from '../../server/access_grant_domain.mjs'; + +class MemoryGrantRepository { + constructor() { + this.records = new Map(); + this.liveMembershipVersion = 1; + } + + async insertGrant(record) { + if (this.records.has(record.token_hash)) throw new Error('duplicate token hash'); + this.records.set(record.token_hash, structuredClone(record)); + } + + async findGrantByHash(tokenHash) { + const record = this.records.get(tokenHash); + return record ? structuredClone(record) : null; + } + + async consumeGrantAtomically(tokenHash, expected) { + const record = this.records.get(tokenHash); + if (!record) return null; + if (record.used_at_ms !== null || record.revoked_at_ms !== null) return null; + if (expected.now_ms >= record.expires_at_ms) return null; + if (record.purpose !== expected.purpose || record.audience !== expected.audience) return null; + if (record.project_id !== expected.project_id) return null; + if ((record.attachment_id ?? null) !== (expected.attachment_id ?? null)) return null; + if (expected.membership_version !== this.liveMembershipVersion) return null; + record.used_at_ms = expected.now_ms; + return structuredClone(record); + } +} + +function deterministicRandomSource() { + let call = 0; + return { + randomBytes(size) { + call += 1; + return Uint8Array.from({ length: size }, (_, index) => (call * 31 + index) % 256); + }, + }; +} + +function makeHarness() { + const repository = new MemoryGrantRepository(); + const auditEvents = []; + const authorizationCalls = []; + const membershipCalls = []; + const clock = { current: Date.UTC(2026, 7, 15, 10, 0, 0), nowMs() { return this.current; } }; + const projectAuthorization = { + async assertCanIssue(input) { + authorizationCalls.push(structuredClone(input)); + if (input.projectId === 'hidden-project') throw new Error('project missing'); + }, + }; + const membershipRevocation = { + async assertActive(input) { + membershipCalls.push(structuredClone(input)); + if (input.subjectId === 'removed-user') throw new Error('membership revoked'); + return repository.liveMembershipVersion; + }, + }; + const auditSink = { async record(event) { auditEvents.push(structuredClone(event)); } }; + const service = createAccessGrantService({ + repository, + clock, + randomSource: deterministicRandomSource(), + auditSink, + projectAuthorization, + membershipRevocation, + }); + return { service, repository, auditEvents, authorizationCalls, membershipCalls, clock }; +} + +async function expectGrantError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof AccessGrantError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +for (const missing of [ + 'repository', + 'clock', + 'randomSource', + 'auditSink', + 'projectAuthorization', + 'membershipRevocation', +]) { + const harness = makeHarness(); + const dependencies = { + repository: harness.repository, + clock: harness.clock, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization: { assertCanIssue: async () => {} }, + membershipRevocation: { assertActive: async () => 1 }, + }; + delete dependencies[missing]; + assert.throws(() => createAccessGrantService(dependencies), /access-grant dependency/); +} + +{ + const { service, repository, auditEvents, authorizationCalls } = makeHarness(); + const grant = await service.mint({ + subjectId: 'user-7', + projectId: 'project-42', + purpose: ACCESS_GRANT_PURPOSES.STREAM, + audience: ACCESS_GRANT_AUDIENCES.STREAM, + ttlSeconds: 300, + }); + assert.match(grant.secret, /^[A-Za-z0-9_-]{43}$/); + assert.equal(grant.purpose, 'stream'); + assert.equal(grant.audience, 'scopeweave:stream'); + assert.equal(grant.attachmentId, null); + assert.equal(grant.expiresAtMs, Date.UTC(2026, 7, 15, 10, 5, 0)); + assert.match(grant.grantId, /^agr_[a-f0-9]{32}$/); + assert.deepEqual(authorizationCalls, [{ subjectId: 'user-7', projectId: 'project-42', purpose: 'stream', attachmentId: null }]); + + const tokenHash = createHash('sha256').update(grant.secret, 'utf8').digest('hex'); + assert.notEqual( + grant.grantId.slice(4), + tokenHash.slice(0, 32), + 'audit correlation identifiers are independently random, not secret-hash prefixes', + ); + const stored = repository.records.get(tokenHash); + assert.ok(stored, 'the repository is keyed by the SHA-256 token hash'); + assert.equal(stored.token_hash, tokenHash); + assert.equal(stored.subject_id, 'user-7'); + assert.equal(stored.project_id, 'project-42'); + assert.equal(stored.used_at_ms, null); + assert.equal(stored.revoked_at_ms, null); + assert.equal(JSON.stringify(stored).includes(grant.secret), false, 'plaintext secret is never persisted'); + assert.equal(JSON.stringify(auditEvents).includes(grant.secret), false, 'plaintext secret is never audited'); + assert.equal(JSON.stringify(auditEvents).includes(tokenHash), false, 'token hash is not copied into audit metadata'); + assert.deepEqual(auditEvents[0], { + event: 'access_grant.minted', + grant_id: grant.grantId, + subject_id: 'user-7', + project_id: 'project-42', + purpose: 'stream', + audience: 'scopeweave:stream', + attachment_id: null, + expires_at_ms: grant.expiresAtMs, + }); + + const redeemed = await service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'project-42', + }); + assert.equal(redeemed.grantId, grant.grantId); + assert.equal(redeemed.subjectId, 'user-7'); + assert.equal(redeemed.projectId, 'project-42'); + assert.equal(redeemed.attachmentId, null); + assert.equal(repository.records.get(tokenHash).used_at_ms, Date.UTC(2026, 7, 15, 10, 0, 0)); + assert.deepEqual(auditEvents[1], { + event: 'access_grant.consumed', + grant_id: grant.grantId, + subject_id: 'user-7', + project_id: 'project-42', + purpose: 'stream', + audience: 'scopeweave:stream', + attachment_id: null, + }); + await expectGrantError(service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'project-42', + }), 'access_grant_unauthorized', 401); +} + +{ + const { service } = makeHarness(); + const grant = await service.mint({ + subjectId: 'user-8', + projectId: 'project-43', + purpose: 'attachment_view', + audience: 'scopeweave:attachment-view', + attachmentId: 'attachment-9', + ttlSeconds: 60, + }); + assert.equal(grant.attachmentId, 'attachment-9'); + for (const input of [ + { purpose: 'stream', audience: 'scopeweave:stream', projectId: 'project-43', attachmentId: null }, + { purpose: 'attachment_view', audience: 'scopeweave:stream', projectId: 'project-43', attachmentId: 'attachment-9' }, + { purpose: 'attachment_view', audience: 'scopeweave:attachment-view', projectId: 'project-other', attachmentId: 'attachment-9' }, + { purpose: 'attachment_view', audience: 'scopeweave:attachment-view', projectId: 'project-43', attachmentId: 'attachment-other' }, + ]) { + await expectGrantError(service.redeem({ secret: grant.secret, ...input }), 'access_grant_unauthorized', 401); + } + const redeemed = await service.redeem({ + secret: grant.secret, + purpose: 'attachment_view', + audience: 'scopeweave:attachment-view', + projectId: 'project-43', + attachmentId: 'attachment-9', + }); + assert.equal(redeemed.attachmentId, 'attachment-9'); +} + +{ + const { service, clock } = makeHarness(); + const expiring = await service.mint({ + subjectId: 'user-expiry', projectId: 'project-expiry', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 1, + }); + clock.current = expiring.expiresAtMs; + await expectGrantError(service.redeem({ + secret: expiring.secret, purpose: 'stream', audience: 'scopeweave:stream', projectId: 'project-expiry', + }), 'access_grant_unauthorized', 401); +} + +{ + const { service } = makeHarness(); + for (const ttlSeconds of [0, 301, 1.5, Number.NaN]) { + await expectGrantError(service.mint({ + subjectId: 'user', projectId: 'project', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds, + }), 'access_grant_ttl_invalid', 400); + } + for (const bad of [ + { subjectId: '', projectId: 'p', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10 }, + { subjectId: 'u', projectId: '', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10 }, + { subjectId: 'u', projectId: 'p', purpose: 'calendar', audience: 'scopeweave:calendar', ttlSeconds: 10 }, + { subjectId: 'u', projectId: 'p', purpose: 'stream', audience: 'wrong', ttlSeconds: 10 }, + { subjectId: 'u', projectId: 'p', purpose: 'stream', audience: 'scopeweave:stream', attachmentId: 'unexpected', ttlSeconds: 10 }, + { subjectId: 'u', projectId: 'p', purpose: 'attachment_view', audience: 'scopeweave:attachment-view', ttlSeconds: 10 }, + ]) { + await expectGrantError(service.mint(bad), 'access_grant_request_invalid', 400); + } + await expectGrantError(service.mint({ + subjectId: 'u', projectId: 'hidden-project', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10, + }), 'access_grant_not_authorized', 404); +} + +{ + const { service, membershipCalls } = makeHarness(); + const grant = await service.mint({ + subjectId: 'removed-user', projectId: 'project-removed', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 10, + }); + await expectGrantError(service.redeem({ + secret: grant.secret, purpose: 'stream', audience: 'scopeweave:stream', projectId: 'project-removed', + }), 'access_grant_unauthorized', 401); + assert.deepEqual(membershipCalls, [{ subjectId: 'removed-user', projectId: 'project-removed' }]); +} + +{ + const { service } = makeHarness(); + for (const secret of ['', 'not-a-token', 'A'.repeat(42), 'A'.repeat(44), 'A'.repeat(42) + '!']) { + await expectGrantError(service.redeem({ + secret, purpose: 'stream', audience: 'scopeweave:stream', projectId: 'project', + }), 'access_grant_unauthorized', 401); + } +} + +{ + const { service } = makeHarness(); + const grant = await service.mint({ + subjectId: 'race-user', projectId: 'race-project', purpose: 'stream', audience: 'scopeweave:stream', ttlSeconds: 60, + }); + const results = await Promise.allSettled([ + service.redeem({ secret: grant.secret, purpose: 'stream', audience: 'scopeweave:stream', projectId: 'race-project' }), + service.redeem({ secret: grant.secret, purpose: 'stream', audience: 'scopeweave:stream', projectId: 'race-project' }), + ]); + assert.equal(results.filter(({ status }) => status === 'fulfilled').length, 1, 'exactly one concurrent consumer succeeds'); + const rejected = results.find(({ status }) => status === 'rejected'); + assert.equal(rejected.reason.code, 'access_grant_unauthorized'); +} + +console.log('✓ access-grant domain contract tests passed'); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..d4fe580b 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,6 +34,21 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/access_grant_domain\.mjs/, + 'the short-lived access-grant domain is instrumented', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/access-grant-domain\.test\.mjs/, + 'the access-grant behavior contract executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/access-grant-domain-edge\.test\.mjs/, + 'the access-grant edge cases execute under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/,