From 8048f4b0ba1071d37646d1c2462759276bb5a5fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:47:26 +0900 Subject: [PATCH 01/28] test(access): define short-lived grant domain contract --- tests/unit/access-grant-domain.test.mjs | 271 ++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 tests/unit/access-grant-domain.test.mjs diff --git a/tests/unit/access-grant-domain.test.mjs b/tests/unit/access-grant-domain.test.mjs new file mode 100644 index 00000000..449a1d08 --- /dev/null +++ b/tests/unit/access-grant-domain.test.mjs @@ -0,0 +1,271 @@ +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(); + } + + 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; + 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'); + }, + }; + 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 () => {} }, + }; + 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]{16}$/); + assert.deepEqual(authorizationCalls, [{ subjectId: 'user-7', projectId: 'project-42', purpose: 'stream', attachmentId: null }]); + + const tokenHash = createHash('sha256').update(grant.secret, 'utf8').digest('hex'); + 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'); From 792fb7ce7da48fb1ad0c27c7e5ac39c017b5527c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:47:53 +0900 Subject: [PATCH 02/28] test(access): register grant contract in canonical coverage --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index dc32b12b..3ef4ec58 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/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/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/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/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/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/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From 10d66e26db1cdf589ef766f8658fcc9d8a78dea8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:48:10 +0900 Subject: [PATCH 03/28] test(access): lock grant module into coverage producer --- tests/unit/coverage-script-contract.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..95ac1dfd 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,6 +34,16 @@ 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\/clearfolio-status-signal\.test\.mjs/, From b40668852ed75987f01ec11d9948581e3c1d815c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:49:50 +0900 Subject: [PATCH 04/28] test(access): cover fail-closed grant edge paths --- tests/unit/access-grant-domain-edge.test.mjs | 63 ++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/unit/access-grant-domain-edge.test.mjs 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..222b66f3 --- /dev/null +++ b/tests/unit/access-grant-domain-edge.test.mjs @@ -0,0 +1,63 @@ +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; } +} + +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 () => {} }, +}); + +{ + 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/); +} + +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'); +} + +console.log('✓ access-grant domain edge coverage passed'); From 585e91fc45069235b8384395e26c3be7f13d9c09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:50:11 +0900 Subject: [PATCH 05/28] test(access): exercise grant edge coverage --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3ef4ec58..6cf29d9c 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/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/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/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.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/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/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test: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/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", From 1cc1efbb00c22894057f25673393e0812a66301e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:50:48 +0900 Subject: [PATCH 06/28] feat(access): add opaque short-lived grant domain --- server/access_grant_domain.mjs | 253 +++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 server/access_grant_domain.mjs diff --git a/server/access_grant_domain.mjs b/server/access_grant_domain.mjs new file mode 100644 index 00000000..40bd83d3 --- /dev/null +++ b/server/access_grant_domain.mjs @@ -0,0 +1,253 @@ +import { createHash } from 'node:crypto'; + +const TOKEN_BYTES = 32; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const MAX_TTL_SECONDS = 300; + +/** 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({ + [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 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 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; +} + +/** + * 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. + * + * @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 = `agr_${tokenHash.slice(0, 16)}`; + 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 auditSink.record({ + 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); + if (!existing) throw unauthorizedGrant(); + try { + await membershipRevocation.assertActive({ + subjectId: existing.subject_id, + projectId: existing.project_id, + }); + } catch { + throw unauthorizedGrant(); + } + const consumed = await repository.consumeGrantAtomically(tokenHash, { + now_ms: readNow(clock), + purpose, + audience, + project_id: projectId, + attachment_id: normalizedAttachmentId, + }); + if (!consumed) throw unauthorizedGrant(); + await auditSink.record({ + 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 }); +} From 3fd83d34ee37d8aa6be01ad2ffcc5bf99501cc6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:52:50 +0900 Subject: [PATCH 07/28] docs(access): record short-lived grant trust boundary --- .../short-lived-access-grant-domain.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/doctoring/short-lived-access-grant-domain.md 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..a50642df --- /dev/null +++ b/docs/doctoring/short-lived-access-grant-domain.md @@ -0,0 +1,159 @@ +# 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] +``` + +Required repository methods are `insertGrant`, `findGrantByHash`, and +`consumeGrantAtomically`. 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. + +## 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 again before redemption so a grant cannot + blindly outlive membership removal. +7. Redemption requires an exact secret shape plus purpose, audience, project, + and attachment binding. Missing, malformed, expired, used, revoked, 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. + +The generated `grant_id` is an operational correlation identifier, not a bearer +credential. It is derived from a prefix of the token hash and cannot substitute +for the secret at the redemption boundary. + +## 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 and the first successful consumption one +transactionally atomic transition. A stale read followed by an unconditional +update is not sufficient. + +The adapter must also preserve hash-only storage across restart and prove that +schema migration, rollback, and recovery keep schema generation and grant 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; +- secret/hash exclusion from audit events; +- fixed purpose/audience/resource binding; +- maximum and exact TTL boundaries; +- inaccessible-project and revoked-membership behavior; +- 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 is registered explicitly in the repository `c8` producer, +and the coverage-registration contract prevents it from silently dropping out. +A focused Node V8 coverage run on the implementation source produced 100% +statement/line, branch, and function coverage. Hosted current-head CI remains +authoritative before merge. + +## 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 From 0419e6a3e79296dc4c5f7c2966d967ec29750729 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:53:25 +0900 Subject: [PATCH 08/28] docs(changelog): record access-grant domain foundation --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e6d29f..d1b3433b 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 From f038822f5f5b9f473fe8abbbcc70fcd44b383d75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:55:54 +0900 Subject: [PATCH 09/28] test(access): require grant identifiers independent of secret hash --- tests/unit/access-grant-domain.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/access-grant-domain.test.mjs b/tests/unit/access-grant-domain.test.mjs index 449a1d08..0e419f1b 100644 --- a/tests/unit/access-grant-domain.test.mjs +++ b/tests/unit/access-grant-domain.test.mjs @@ -119,10 +119,15 @@ for (const missing of [ 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]{16}$/); + 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); From 578808c575693cde1b65794baa09627f16f09792 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:56:27 +0900 Subject: [PATCH 10/28] test(access): cover independent grant-id entropy contract --- tests/unit/access-grant-domain-edge.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/access-grant-domain-edge.test.mjs b/tests/unit/access-grant-domain-edge.test.mjs index 222b66f3..9d97eeec 100644 --- a/tests/unit/access-grant-domain-edge.test.mjs +++ b/tests/unit/access-grant-domain-edge.test.mjs @@ -33,6 +33,22 @@ for (const randomBytes of [() => new Uint8Array(31), () => Array(32).fill(1)]) { }), /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({ From cf15fac3c838008d8570b83d329b59e6d3450ef7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:57:28 +0900 Subject: [PATCH 11/28] fix(access): decouple audit grant ids from secret hashes --- server/access_grant_domain.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/access_grant_domain.mjs b/server/access_grant_domain.mjs index 40bd83d3..68e0c339 100644 --- a/server/access_grant_domain.mjs +++ b/server/access_grant_domain.mjs @@ -1,6 +1,7 @@ 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; @@ -101,6 +102,13 @@ function encodeSecret(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) { @@ -171,7 +179,7 @@ export function createAccessGrantService({ } const secret = encodeSecret(randomSource.randomBytes(TOKEN_BYTES)); const tokenHash = hashSecret(secret); - const grantId = `agr_${tokenHash.slice(0, 16)}`; + const grantId = encodeGrantId(randomSource.randomBytes(GRANT_ID_BYTES)); const record = { grant_id: grantId, token_hash: tokenHash, From 79e529c2d0c36b79e00807c1081ad90828816273 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:58:19 +0900 Subject: [PATCH 12/28] docs(access): separate correlation ids from token hashes --- docs/doctoring/short-lived-access-grant-domain.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/short-lived-access-grant-domain.md b/docs/doctoring/short-lived-access-grant-domain.md index a50642df..c074531a 100644 --- a/docs/doctoring/short-lived-access-grant-domain.md +++ b/docs/doctoring/short-lived-access-grant-domain.md @@ -92,8 +92,9 @@ The implementation enforces these invariants before route integration: but never the plaintext secret or token hash. The generated `grant_id` is an operational correlation identifier, not a bearer -credential. It is derived from a prefix of the token hash and cannot substitute -for the secret at the redemption boundary. +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. ## Persistence contract for follow-up adapters @@ -120,6 +121,7 @@ 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; @@ -133,8 +135,8 @@ Focused contract tests cover: The production source is registered explicitly in the repository `c8` producer, and the coverage-registration contract prevents it from silently dropping out. A focused Node V8 coverage run on the implementation source produced 100% -statement/line, branch, and function coverage. Hosted current-head CI remains -authoritative before merge. +statement/line, branch, and function coverage before the independent-ID hardening; +hosted current-head coverage is authoritative for the resulting head. ## Rollback and compatibility From cde324ca738cbd3c4b1db172df7107e4f53819b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:59:14 +0900 Subject: [PATCH 13/28] test(coverage): lock access-grant edge cases into c8 --- tests/unit/coverage-script-contract.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 95ac1dfd..d4fe580b 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -44,6 +44,11 @@ assert.match( /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/, From a8f511bf6b3e2e27ea11d0729a669a23d1e092e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:00:08 +0900 Subject: [PATCH 14/28] test(access): reproduce audit and membership race failures --- tests/unit/access-grant-domain-edge.test.mjs | 63 +++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/tests/unit/access-grant-domain-edge.test.mjs b/tests/unit/access-grant-domain-edge.test.mjs index 9d97eeec..efb1bdb0 100644 --- a/tests/unit/access-grant-domain-edge.test.mjs +++ b/tests/unit/access-grant-domain-edge.test.mjs @@ -8,13 +8,32 @@ class MemoryRepository { 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 () => {} }, + membershipRevocation: { assertActive: async () => 1 }, }); { @@ -76,4 +95,46 @@ for (const nowMs of [() => Number.NaN, () => -1]) { }), (error) => error.code === 'access_grant_unauthorized'); } +{ + 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); +} + console.log('✓ access-grant domain edge coverage passed'); From fd24133ca1834d21dfd00184115ad76c822311b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:00:50 +0900 Subject: [PATCH 15/28] fix(access): close audit and membership race windows --- server/access_grant_domain.mjs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/server/access_grant_domain.mjs b/server/access_grant_domain.mjs index 68e0c339..1eeef7dc 100644 --- a/server/access_grant_domain.mjs +++ b/server/access_grant_domain.mjs @@ -117,6 +117,17 @@ function readNow(clock) { 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. * @@ -126,6 +137,18 @@ function readNow(clock) { * 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. 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. + * + * 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(). @@ -194,7 +217,7 @@ export function createAccessGrantService({ revoked_at_ms: null, }; await repository.insertGrant(record); - await auditSink.record({ + await recordAuditBestEffort(auditSink, { event: 'access_grant.minted', grant_id: grantId, subject_id: subjectId, @@ -222,8 +245,9 @@ export function createAccessGrantService({ const tokenHash = hashSecret(secret); const existing = await repository.findGrantByHash(tokenHash); if (!existing) throw unauthorizedGrant(); + let membershipVersion; try { - await membershipRevocation.assertActive({ + membershipVersion = await membershipRevocation.assertActive({ subjectId: existing.subject_id, projectId: existing.project_id, }); @@ -236,9 +260,10 @@ export function createAccessGrantService({ audience, project_id: projectId, attachment_id: normalizedAttachmentId, + membership_version: membershipVersion, }); if (!consumed) throw unauthorizedGrant(); - await auditSink.record({ + await recordAuditBestEffort(auditSink, { event: 'access_grant.consumed', grant_id: consumed.grant_id, subject_id: consumed.subject_id, From 248120bed784ca6370d0ee418aa8074fb5e820a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:01:34 +0900 Subject: [PATCH 16/28] docs(access): define race-safe membership and audit durability --- .../short-lived-access-grant-domain.md | 66 ++++++++++++------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/docs/doctoring/short-lived-access-grant-domain.md b/docs/doctoring/short-lived-access-grant-domain.md index c074531a..f25d4695 100644 --- a/docs/doctoring/short-lived-access-grant-domain.md +++ b/docs/doctoring/short-lived-access-grant-domain.md @@ -55,14 +55,23 @@ flowchart LR Domain --> Clock[AccessGrantClock] Domain --> Random[AccessGrantRandomSource] Domain --> Audit[AccessGrantAuditSink] - Repository --> Atomic[Atomic one-time consume] + Repository --> Atomic[Atomic one-time consume plus membership version check] + Repository --> Outbox[Production transactional audit outbox] ``` Required repository methods are `insertGrant`, `findGrantByHash`, and -`consumeGrantAtomically`. 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. +`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. ## Security invariants @@ -78,23 +87,30 @@ The implementation enforces these invariants before route integration: 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 again before redemption so a grant cannot - blindly outlive membership removal. +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, - and attachment binding. Missing, malformed, expired, used, revoked, wrong- - resource, or otherwise unusable grants collapse to the same unauthorized - result. + 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. 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. +material. This provides 128 bits of independent entropy without coupling the +identifier format to UUID semantics. ## Persistence contract for follow-up adapters @@ -102,13 +118,16 @@ 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 and the first successful consumption one -transactionally atomic transition. A stale read followed by an unconditional -update is not sufficient. +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. -The adapter must also preserve hash-only storage across restart and prove that -schema migration, rollback, and recovery keep schema generation and grant state -consistent. +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 @@ -126,17 +145,20 @@ Focused contract tests cover: - 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; +- 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 is registered explicitly in the repository `c8` producer, -and the coverage-registration contract prevents it from silently dropping out. -A focused Node V8 coverage run on the implementation source produced 100% -statement/line, branch, and function coverage before the independent-ID hardening; -hosted current-head coverage is authoritative for the resulting head. +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 From 1c7a12f4f8586e01e5dabc22cd67a839d1db0071 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:15:16 +0900 Subject: [PATCH 17/28] test(access): reject unusable membership versions before consume --- tests/unit/access-grant-domain-edge.test.mjs | 63 ++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/unit/access-grant-domain-edge.test.mjs b/tests/unit/access-grant-domain-edge.test.mjs index efb1bdb0..acdb4d73 100644 --- a/tests/unit/access-grant-domain-edge.test.mjs +++ b/tests/unit/access-grant-domain-edge.test.mjs @@ -95,6 +95,69 @@ for (const nowMs of [() => Number.NaN, () => -1]) { }), (error) => error.code === 'access_grant_unauthorized'); } +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; From 0443782363066989eb440fafe38ec97d7bf085d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:18:23 +0900 Subject: [PATCH 18/28] fix(access): require usable membership version before consume --- server/access_grant_domain.mjs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/server/access_grant_domain.mjs b/server/access_grant_domain.mjs index 1eeef7dc..0e3feebd 100644 --- a/server/access_grant_domain.mjs +++ b/server/access_grant_domain.mjs @@ -4,6 +4,8 @@ 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({ @@ -91,6 +93,20 @@ function unauthorizedGrant() { return new AccessGrantError('access_grant_unauthorized', 401); } +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'); } @@ -138,11 +154,13 @@ async function recordAuditBestEffort(auditSink, event) { * require a separate rotation/revocation lifecycle. * * MembershipRevocationPort.assertActive() returns an opaque membership version - * captured during the active-state check. 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. + * 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. * * 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. @@ -247,10 +265,10 @@ export function createAccessGrantService({ if (!existing) throw unauthorizedGrant(); let membershipVersion; try { - membershipVersion = await membershipRevocation.assertActive({ + membershipVersion = normalizeMembershipVersion(await membershipRevocation.assertActive({ subjectId: existing.subject_id, projectId: existing.project_id, - }); + })); } catch { throw unauthorizedGrant(); } From 59c10080696972b2955b229edf37a4e9a649dbbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:45:34 +0900 Subject: [PATCH 19/28] test(access): align harness with membership-version consume contract --- tests/unit/access-grant-domain.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/access-grant-domain.test.mjs b/tests/unit/access-grant-domain.test.mjs index 0e419f1b..5b60f288 100644 --- a/tests/unit/access-grant-domain.test.mjs +++ b/tests/unit/access-grant-domain.test.mjs @@ -10,6 +10,7 @@ import { class MemoryGrantRepository { constructor() { this.records = new Map(); + this.liveMembershipVersion = 1; } async insertGrant(record) { @@ -30,6 +31,7 @@ class MemoryGrantRepository { 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); } @@ -61,6 +63,7 @@ function makeHarness() { 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)); } }; @@ -99,7 +102,7 @@ for (const missing of [ randomSource: deterministicRandomSource(), auditSink: { record: async () => {} }, projectAuthorization: { assertCanIssue: async () => {} }, - membershipRevocation: { assertActive: async () => {} }, + membershipRevocation: { assertActive: async () => 1 }, }; delete dependencies[missing]; assert.throws(() => createAccessGrantService(dependencies), /access-grant dependency/); From b253e5e67f26454ce78ab5b52c59a6f5223b745e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:06:38 -0700 Subject: [PATCH 20/28] fix(stack): preserve protected Playwright baseline in access grant --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ad68b505..c056826b 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "hono": "^4.13.0" }, "devDependencies": { - "@playwright/test": "1.61.1", + "@playwright/test": "1.62.1", "c8": "12.0.0", "fast-check": "4.9.0" } -} +} \ No newline at end of file From 16b8a5286866d3db968f95f72bee3b8b922d464e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:07:14 -0700 Subject: [PATCH 21/28] fix(stack): preserve protected Playwright lockfile in access grant --- package-lock.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index da668c20..00a99254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "hono": "^4.13.0" }, "devDependencies": { - "@playwright/test": "1.61.1", + "@playwright/test": "1.62.1", "c8": "12.0.0", "fast-check": "4.9.0" }, @@ -81,19 +81,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@types/istanbul-lib-coverage": { @@ -581,35 +581,35 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/pure-rand": { From 5f39f5953cf56b497bc9e77a0ba1bc98c869d1bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:05:51 -0700 Subject: [PATCH 22/28] test(access): reject forged atomic consume receipts --- tests/unit/access-grant-domain-edge.test.mjs | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/access-grant-domain-edge.test.mjs b/tests/unit/access-grant-domain-edge.test.mjs index acdb4d73..309a1dc5 100644 --- a/tests/unit/access-grant-domain-edge.test.mjs +++ b/tests/unit/access-grant-domain-edge.test.mjs @@ -200,4 +200,27 @@ for (const membershipVersion of [ }), (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); +} + console.log('✓ access-grant domain edge coverage passed'); From c8261dae5a3e737316d70e857d870f7361efa057 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:06:48 -0700 Subject: [PATCH 23/28] fix(access): validate atomic consume return authority --- server/access_grant_domain.mjs | 51 +++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/server/access_grant_domain.mjs b/server/access_grant_domain.mjs index 0e3feebd..70bc4138 100644 --- a/server/access_grant_domain.mjs +++ b/server/access_grant_domain.mjs @@ -93,6 +93,27 @@ function unauthorizedGrant() { return new AccessGrantError('access_grant_unauthorized', 401); } +function validateConsumedGrant(existing, consumed, { purpose, audience, projectId, attachmentId }) { + if ( + !consumed + || typeof consumed !== 'object' + || Array.isArray(consumed) + || 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 ?? null) + || (consumed.attachment_id ?? null) !== (attachmentId ?? null) + ) { + throw unauthorizedGrant(); + } + return consumed; +} + function normalizeMembershipVersion(value) { if (Number.isSafeInteger(value) && value >= 0) return value; if ( @@ -160,7 +181,9 @@ async function recordAuditBestEffort(auditSink, event) { * 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. + * instead. The atomic consume return value is still treated as untrusted port + * data and must match the pre-consume grant plus requested binding 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. @@ -272,15 +295,23 @@ export function createAccessGrantService({ } catch { throw unauthorizedGrant(); } - const consumed = await repository.consumeGrantAtomically(tokenHash, { - now_ms: readNow(clock), - purpose, - audience, - project_id: projectId, - attachment_id: normalizedAttachmentId, - membership_version: membershipVersion, - }); - if (!consumed) throw unauthorizedGrant(); + const consumed = validateConsumedGrant( + existing, + await repository.consumeGrantAtomically(tokenHash, { + now_ms: readNow(clock), + purpose, + audience, + project_id: projectId, + attachment_id: normalizedAttachmentId, + membership_version: membershipVersion, + }), + { + purpose, + audience, + projectId, + attachmentId: normalizedAttachmentId, + }, + ); await recordAuditBestEffort(auditSink, { event: 'access_grant.consumed', grant_id: consumed.grant_id, From fd947972dfe3cfb18280453fcb74befde10fc267 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:07:42 -0700 Subject: [PATCH 24/28] docs(access): record atomic consume return trust boundary --- docs/doctoring/short-lived-access-grant-domain.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/short-lived-access-grant-domain.md b/docs/doctoring/short-lived-access-grant-domain.md index f25d4695..ebc29b2c 100644 --- a/docs/doctoring/short-lived-access-grant-domain.md +++ b/docs/doctoring/short-lived-access-grant-domain.md @@ -71,7 +71,12 @@ 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. +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 @@ -105,6 +110,11 @@ The implementation enforces these invariants before route integration: 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 @@ -146,6 +156,8 @@ Focused contract tests cover: - 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; From c5162f4428016a6b06cff8bdf3e096decbf1ef9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:43:08 -0700 Subject: [PATCH 25/28] test(access): reject uncommitted consume returns --- tests/unit/access-grant-domain-edge.test.mjs | 24 +++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/unit/access-grant-domain-edge.test.mjs b/tests/unit/access-grant-domain-edge.test.mjs index 309a1dc5..67c41806 100644 --- a/tests/unit/access-grant-domain-edge.test.mjs +++ b/tests/unit/access-grant-domain-edge.test.mjs @@ -223,4 +223,26 @@ for (const membershipVersion of [ }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); } -console.log('✓ access-grant domain edge coverage passed'); +{ + 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 From 60b1bfc5ed1afef6d5297e546c6d754c5634b38e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:47:54 -0700 Subject: [PATCH 26/28] fix(access): verify atomic consume transition --- server/access_grant_domain.mjs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/server/access_grant_domain.mjs b/server/access_grant_domain.mjs index 70bc4138..d4b45a4f 100644 --- a/server/access_grant_domain.mjs +++ b/server/access_grant_domain.mjs @@ -93,11 +93,19 @@ function unauthorizedGrant() { return new AccessGrantError('access_grant_unauthorized', 401); } -function validateConsumedGrant(existing, consumed, { purpose, audience, projectId, attachmentId }) { +function validateConsumedGrant(existing, consumed, { + purpose, + audience, + projectId, + attachmentId, + nowMs, +}) { if ( !consumed || typeof consumed !== 'object' || Array.isArray(consumed) + || existing.used_at_ms !== null + || consumed.used_at_ms !== nowMs || consumed.grant_id !== existing.grant_id || consumed.subject_id !== existing.subject_id || consumed.project_id !== existing.project_id @@ -182,8 +190,9 @@ async function recordAuditBestEffort(auditSink, event) { * 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 and must match the pre-consume grant plus requested binding before it can - * become the redeemed principal or an audit identity. + * data: it must match the pre-consume grant and 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. @@ -295,10 +304,11 @@ export function createAccessGrantService({ } catch { throw unauthorizedGrant(); } + const nowMs = readNow(clock); const consumed = validateConsumedGrant( existing, await repository.consumeGrantAtomically(tokenHash, { - now_ms: readNow(clock), + now_ms: nowMs, purpose, audience, project_id: projectId, @@ -310,6 +320,7 @@ export function createAccessGrantService({ audience, projectId, attachmentId: normalizedAttachmentId, + nowMs, }, ); await recordAuditBestEffort(auditSink, { @@ -332,4 +343,4 @@ export function createAccessGrantService({ } return Object.freeze({ mint, redeem }); -} +} \ No newline at end of file From 4e09443a9f9990490adeaaa5d4a1a3223490a849 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:22:29 -0700 Subject: [PATCH 27/28] test(access): reject inherited purposes and mutable consume aliases --- tests/unit/access-grant-domain-edge.test.mjs | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/access-grant-domain-edge.test.mjs b/tests/unit/access-grant-domain-edge.test.mjs index 67c41806..aa10d33e 100644 --- a/tests/unit/access-grant-domain-edge.test.mjs +++ b/tests/unit/access-grant-domain-edge.test.mjs @@ -95,6 +95,17 @@ for (const nowMs of [() => Number.NaN, () => -1]) { }), (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, @@ -223,6 +234,31 @@ for (const membershipVersion of [ }), (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) => { From f3ae1f1f4ee66024776507d75f8fb752b2bf748c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:25:06 -0700 Subject: [PATCH 28/28] fix(access): snapshot consume authority and harden purpose lookup --- server/access_grant_domain.mjs | 41 +++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/server/access_grant_domain.mjs b/server/access_grant_domain.mjs index d4b45a4f..6d6c0672 100644 --- a/server/access_grant_domain.mjs +++ b/server/access_grant_domain.mjs @@ -19,10 +19,10 @@ export const ACCESS_GRANT_AUDIENCES = Object.freeze({ ATTACHMENT_VIEW: 'scopeweave:attachment-view', }); -const PURPOSE_AUDIENCE = Object.freeze({ +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. @@ -93,6 +93,25 @@ 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, @@ -104,7 +123,6 @@ function validateConsumedGrant(existing, consumed, { !consumed || typeof consumed !== 'object' || Array.isArray(consumed) - || existing.used_at_ms !== null || consumed.used_at_ms !== nowMs || consumed.grant_id !== existing.grant_id || consumed.subject_id !== existing.subject_id @@ -114,7 +132,7 @@ function validateConsumedGrant(existing, consumed, { || consumed.purpose !== purpose || consumed.audience !== existing.audience || consumed.audience !== audience - || (consumed.attachment_id ?? null) !== (existing.attachment_id ?? null) + || (consumed.attachment_id ?? null) !== existing.attachment_id || (consumed.attachment_id ?? null) !== (attachmentId ?? null) ) { throw unauthorizedGrant(); @@ -190,9 +208,10 @@ async function recordAuditBestEffort(auditSink, event) { * 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 the pre-consume grant and 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. + * 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. @@ -294,19 +313,19 @@ export function createAccessGrantService({ const { normalizedAttachmentId } = validateRedeemBinding({ purpose, audience, projectId, attachmentId }); const tokenHash = hashSecret(secret); const existing = await repository.findGrantByHash(tokenHash); - if (!existing) throw unauthorizedGrant(); + const existingSnapshot = snapshotUnconsumedGrant(existing); let membershipVersion; try { membershipVersion = normalizeMembershipVersion(await membershipRevocation.assertActive({ - subjectId: existing.subject_id, - projectId: existing.project_id, + subjectId: existingSnapshot.subject_id, + projectId: existingSnapshot.project_id, })); } catch { throw unauthorizedGrant(); } const nowMs = readNow(clock); const consumed = validateConsumedGrant( - existing, + existingSnapshot, await repository.consumeGrantAtomically(tokenHash, { now_ms: nowMs, purpose,