From 71d1381e5469bce03273c6ddff97ba979830ad4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:17:07 +0900 Subject: [PATCH 01/24] test(calendar): define durable subscription lifecycle contract --- .../calendar-subscription-domain.test.mjs | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 tests/unit/calendar-subscription-domain.test.mjs diff --git a/tests/unit/calendar-subscription-domain.test.mjs b/tests/unit/calendar-subscription-domain.test.mjs new file mode 100644 index 00000000..3739a975 --- /dev/null +++ b/tests/unit/calendar-subscription-domain.test.mjs @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + CALENDAR_SUBSCRIPTION_AUDIENCE, + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; + +function sequentialRandomSource() { + let seed = 1; + return { + randomBytes(size) { + const bytes = new Uint8Array(size); + for (let index = 0; index < size; index += 1) bytes[index] = (seed + index) % 256; + seed += size; + return bytes; + }, + }; +} + +function makeHarness({ nowMs = 1_800_000_000_000, auditThrows = false } = {}) { + const rows = new Map(); + const hashIndex = new Map(); + const rotations = []; + const usages = []; + const audits = []; + const activeMemberships = new Map([['user-1:project-1', 3]]); + + const repository = { + async insertSubscription(record) { + rows.set(record.subscription_id, structuredClone(record)); + hashIndex.set(record.secret_hash, record.subscription_id); + }, + async listSubscriptions({ subject_id, project_id }) { + return [...rows.values()].filter((row) => row.subject_id === subject_id && row.project_id === project_id); + }, + async findSubscriptionByHash(secretHash) { + const id = hashIndex.get(secretHash); + return id ? structuredClone(rows.get(id)) : null; + }, + async recordUsageAtomically(secretHash, expected) { + const id = hashIndex.get(secretHash); + const row = id ? rows.get(id) : null; + const liveVersion = activeMemberships.get(`${row?.subject_id}:${row?.project_id}`); + if ( + !row + || row.project_id !== expected.project_id + || row.audience !== expected.audience + || row.revoked_at_ms !== null + || row.expires_at_ms <= expected.now_ms + || liveVersion !== expected.membership_version + ) return null; + row.last_used_at_ms = expected.now_ms; + usages.push({ subscription_id: row.subscription_id, used_at_ms: expected.now_ms }); + return structuredClone(row); + }, + async rotateSubscriptionAtomically(subscriptionId, expected) { + const row = rows.get(subscriptionId); + const liveVersion = activeMemberships.get(`${row?.subject_id}:${row?.project_id}`); + if ( + !row + || row.subject_id !== expected.subject_id + || row.project_id !== expected.project_id + || row.revoked_at_ms !== null + || row.expires_at_ms <= expected.now_ms + || liveVersion !== expected.membership_version + ) return null; + hashIndex.delete(row.secret_hash); + rotations.push({ + subscription_id: row.subscription_id, + previous_secret_hash: row.secret_hash, + rotated_at_ms: expected.now_ms, + }); + row.secret_hash = expected.new_secret_hash; + row.rotated_at_ms = expected.now_ms; + row.expires_at_ms = expected.expires_at_ms; + row.membership_version = expected.membership_version; + hashIndex.set(row.secret_hash, row.subscription_id); + return structuredClone(row); + }, + async revokeSubscriptionAtomically(subscriptionId, expected) { + const row = rows.get(subscriptionId); + if (!row || row.subject_id !== expected.subject_id || row.project_id !== expected.project_id) return null; + if (row.revoked_at_ms === null) row.revoked_at_ms = expected.now_ms; + return structuredClone(row); + }, + }; + + const projectAuthorization = { + async assertCanManage({ subjectId, projectId }) { + if (!activeMemberships.has(`${subjectId}:${projectId}`)) throw new Error('not authorized'); + }, + }; + const membershipRevocation = { + async assertActive({ subjectId, projectId }) { + const version = activeMemberships.get(`${subjectId}:${projectId}`); + if (version === undefined) throw new Error('revoked'); + return version; + }, + }; + const auditSink = { + async record(event) { + if (auditThrows) throw new Error('audit unavailable'); + audits.push(structuredClone(event)); + }, + }; + const clock = { nowMs: () => nowMs }; + const service = createCalendarSubscriptionService({ + repository, + clock, + randomSource: sequentialRandomSource(), + auditSink, + projectAuthorization, + membershipRevocation, + }); + + return { + service, + repository, + rows, + hashIndex, + rotations, + usages, + audits, + activeMemberships, + setNow(value) { nowMs = value; }, + }; +} + +async function expectDomainError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof CalendarSubscriptionError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Executive calendar', + expiresAtMs: 1_800_086_400_000, + }); + assert.equal(CALENDAR_SUBSCRIPTION_AUDIENCE, 'scopeweave:calendar'); + assert.match(created.secret, /^[A-Za-z0-9_-]{43}$/); + assert.match(created.subscriptionId, /^csub_[a-f0-9]{32}$/); + assert.equal(created.name, 'Executive calendar'); + assert.equal(created.projectId, 'project-1'); + assert.equal(created.subjectId, 'user-1'); + assert.equal(created.audience, CALENDAR_SUBSCRIPTION_AUDIENCE); + assert.equal(created.createdAtMs, 1_800_000_000_000); + assert.equal(created.expiresAtMs, 1_800_086_400_000); + assert.equal(created.lastUsedAtMs, null); + assert.equal(created.rotatedAtMs, null); + assert.equal(created.revokedAtMs, null); + assert.equal(created.status, 'active'); + assert.equal(Object.isFrozen(created), true); + + const stored = h.rows.get(created.subscriptionId); + assert.equal(stored.secret_hash, createHash('sha256').update(created.secret).digest('hex')); + assert.equal(stored.membership_version, 3); + assert.equal(JSON.stringify(stored).includes(created.secret), false); + assert.equal(JSON.stringify(h.audits).includes(created.secret), false); + assert.equal(JSON.stringify(h.audits).includes(stored.secret_hash), false); + assert.deepEqual(h.audits[0], { + event: 'calendar_subscription.created', + subscription_id: created.subscriptionId, + subject_id: 'user-1', + project_id: 'project-1', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + expires_at_ms: 1_800_086_400_000, + }); + + const listed = await h.service.list({ subjectId: 'user-1', projectId: 'project-1' }); + assert.equal(listed.length, 1); + assert.deepEqual(listed[0], { + subscriptionId: created.subscriptionId, + subjectId: 'user-1', + projectId: 'project-1', + name: 'Executive calendar', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + createdAtMs: 1_800_000_000_000, + expiresAtMs: 1_800_086_400_000, + lastUsedAtMs: null, + rotatedAtMs: null, + revokedAtMs: null, + status: 'active', + }); + assert.equal('secret' in listed[0], false); + assert.equal('secretHash' in listed[0], false); + assert.equal('secret_hash' in listed[0], false); + assert.equal('membership_version' in listed[0], false); + + h.setNow(1_800_000_005_000); + const principal = await h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + assert.deepEqual(principal, { + subscriptionId: created.subscriptionId, + subjectId: 'user-1', + projectId: 'project-1', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + }); + assert.deepEqual(h.usages, [{ subscription_id: created.subscriptionId, used_at_ms: 1_800_000_005_000 }]); + + h.setNow(1_800_000_010_000); + const rotated = await h.service.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + expiresAtMs: 1_800_172_800_000, + }); + assert.notEqual(rotated.secret, created.secret); + assert.equal(rotated.subscriptionId, created.subscriptionId); + assert.equal(rotated.rotatedAtMs, 1_800_000_010_000); + assert.equal(rotated.expiresAtMs, 1_800_172_800_000); + assert.equal(h.rotations.length, 1); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const rotatedPrincipal = await h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }); + assert.equal(rotatedPrincipal.subscriptionId, created.subscriptionId); + + h.setNow(1_800_000_020_000); + const revoked = await h.service.revoke({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + }); + assert.equal(revoked.status, 'revoked'); + assert.equal(revoked.revokedAtMs, 1_800_000_020_000); + const revokedAgain = await h.service.revoke({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + }); + assert.equal(revokedAgain.revokedAtMs, revoked.revokedAtMs); + await expectDomainError( + h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); +} + +{ + const h = makeHarness({ auditThrows: true }); + const created = await h.service.create({ + subjectId: 'user-1', projectId: 'project-1', name: 'Ops', expiresAtMs: 1_800_000_100_000, + }); + assert.ok(h.rows.has(created.subscriptionId)); + const principal = await h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + assert.equal(principal.subjectId, 'user-1'); + const rotated = await h.service.rotate({ + subjectId: 'user-1', projectId: 'project-1', subscriptionId: created.subscriptionId, expiresAtMs: 1_800_000_200_000, + }); + assert.ok(rotated.secret); + const revoked = await h.service.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: created.subscriptionId }); + assert.equal(revoked.status, 'revoked'); +} + +console.log('calendar subscription domain behavior tests passed'); From 639db6c7b1e3a321fc05d25739738164b094ed2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:18:13 +0900 Subject: [PATCH 02/24] feat(calendar): add reusable scoped subscription domain --- server/calendar_subscription_domain.mjs | 338 ++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 server/calendar_subscription_domain.mjs diff --git a/server/calendar_subscription_domain.mjs b/server/calendar_subscription_domain.mjs new file mode 100644 index 00000000..f40e0717 --- /dev/null +++ b/server/calendar_subscription_domain.mjs @@ -0,0 +1,338 @@ +import { createHash } from 'node:crypto'; + +const SECRET_BYTES = 32; +const SUBSCRIPTION_ID_BYTES = 16; +const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const NAME_MAX_LENGTH = 120; +const MEMBERSHIP_VERSION_MAX_LENGTH = 128; +const CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/u; + +/** Fixed resource-server audience for reusable project calendar subscriptions. */ +export const CALENDAR_SUBSCRIPTION_AUDIENCE = 'scopeweave:calendar'; + +/** Stable domain error safe for thin HTTP adapters to map without secret-state disclosure. */ +export class CalendarSubscriptionError extends Error { + /** + * @param {string} code Stable machine-readable error code. + * @param {number} status Suggested HTTP status for an adapter. + */ + constructor(code, status) { + super(code); + this.name = 'CalendarSubscriptionError'; + this.code = code; + this.status = status; + } +} + +function requireMethod(port, method) { + if (!port || typeof port[method] !== 'function') { + throw new TypeError(`calendar-subscription dependency must provide ${method}()`); + } +} + +function isBoundedString(value) { + return typeof value === 'string' + && value.length > 0 + && value === value.trim() + && !CONTROL_PATTERN.test(value); +} + +function normalizeName(value) { + if (!isBoundedString(value) || value.length > NAME_MAX_LENGTH) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } + return value; +} + +function validateIdentity(subjectId, projectId) { + if (!isBoundedString(subjectId) || !isBoundedString(projectId)) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } +} + +function normalizeSubscriptionId(value) { + if (!isBoundedString(value)) { + throw new CalendarSubscriptionError('calendar_subscription_request_invalid', 400); + } + return value; +} + +function readNow(clock) { + const nowMs = clock.nowMs(); + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError('calendar-subscription clock must return a non-negative safe integer'); + } + return nowMs; +} + +function normalizeExpiry(expiresAtMs, nowMs) { + if (!Number.isSafeInteger(expiresAtMs) || expiresAtMs <= nowMs) { + throw new CalendarSubscriptionError('calendar_subscription_expiry_invalid', 400); + } + return expiresAtMs; +} + +function normalizeMembershipVersion(value) { + if (Number.isSafeInteger(value) && value >= 0) return value; + if ( + typeof value === 'string' + && value.length > 0 + && value.length <= MEMBERSHIP_VERSION_MAX_LENGTH + && value === value.trim() + && !CONTROL_PATTERN.test(value) + ) return value; + throw unauthorizedSubscription(); +} + +function encodeSecret(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== SECRET_BYTES) { + throw new TypeError(`calendar-subscription random source must return ${SECRET_BYTES} bytes`); + } + return Buffer.from(bytes).toString('base64url'); +} + +function encodeSubscriptionId(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== SUBSCRIPTION_ID_BYTES) { + throw new TypeError(`calendar-subscription random source must return ${SUBSCRIPTION_ID_BYTES} bytes for subscription id`); + } + return `csub_${Buffer.from(bytes).toString('hex')}`; +} + +function hashSecret(secret) { + return createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +function unauthorizedSubscription() { + return new CalendarSubscriptionError('calendar_subscription_unauthorized', 401); +} + +function notFoundSubscription() { + return new CalendarSubscriptionError('calendar_subscription_not_found', 404); +} + +function statusOf(record, nowMs) { + if (record.revoked_at_ms !== null && record.revoked_at_ms !== undefined) return 'revoked'; + if (record.expires_at_ms <= nowMs) return 'expired'; + return 'active'; +} + +function viewOf(record, nowMs) { + return Object.freeze({ + subscriptionId: record.subscription_id, + subjectId: record.subject_id, + projectId: record.project_id, + name: record.name, + audience: record.audience, + createdAtMs: record.created_at_ms, + expiresAtMs: record.expires_at_ms, + lastUsedAtMs: record.last_used_at_ms ?? null, + rotatedAtMs: record.rotated_at_ms ?? null, + revokedAtMs: record.revoked_at_ms ?? null, + status: statusOf(record, nowMs), + }); +} + +async function recordAuditBestEffort(auditSink, event) { + try { + await auditSink.record(event); + } catch { + // Durable subscription state is authoritative after the repository commits. + // Production persistence that requires guaranteed evidence should pair the + // state transition with a transactional outbox rather than retrying secrets. + } +} + +async function assertManage(projectAuthorization, subjectId, projectId) { + try { + await projectAuthorization.assertCanManage({ subjectId, projectId }); + } catch { + throw notFoundSubscription(); + } +} + +async function readMembershipVersion(membershipRevocation, subjectId, projectId) { + try { + return normalizeMembershipVersion(await membershipRevocation.assertActive({ subjectId, projectId })); + } catch { + throw unauthorizedSubscription(); + } +} + +/** + * Build the framework-neutral lifecycle for reusable calendar-subscription secrets. + * + * Plaintext subscription secrets are returned only from create/rotate and only + * their SHA-256 hashes cross the repository port. Repository adapters own the + * durable `calendar_subscriptions`, `subscription_rotations`, and + * `subscription_usage_events` state and must make usage/rotation/revocation + * transitions atomic. `recordUsageAtomically()` and + * `rotateSubscriptionAtomically()` must compare the supplied membership version + * with live membership state in the same transaction, closing the + * revoke-between-check-and-use race. Adapters without a shared transaction must + * atomically revoke affected subscriptions when membership changes. + * + * Audit delivery is best-effort after repository commits so an audit transport + * outage cannot cause a client to retry and accidentally expose multiple active + * secrets. Production adapters that require durable audit evidence should use a + * transactional outbox. + * + * @param {object} ports Injected infrastructure and authorization ports. + * @param {object} ports.repository CalendarSubscriptionRepository. + * @param {object} ports.clock Clock exposing nowMs(). + * @param {object} ports.randomSource Random source exposing randomBytes(). + * @param {object} ports.auditSink Secret-free audit sink exposing record(). + * @param {object} ports.projectAuthorization Project authorization port. + * @param {object} ports.membershipRevocation Membership revocation/version port. + * @returns {{create: Function, list: Function, authorize: Function, rotate: Function, revoke: Function}} Immutable service. + */ +export function createCalendarSubscriptionService({ + repository, + clock, + randomSource, + auditSink, + projectAuthorization, + membershipRevocation, +} = {}) { + requireMethod(repository, 'insertSubscription'); + requireMethod(repository, 'listSubscriptions'); + requireMethod(repository, 'findSubscriptionByHash'); + requireMethod(repository, 'recordUsageAtomically'); + requireMethod(repository, 'rotateSubscriptionAtomically'); + requireMethod(repository, 'revokeSubscriptionAtomically'); + requireMethod(clock, 'nowMs'); + requireMethod(randomSource, 'randomBytes'); + requireMethod(auditSink, 'record'); + requireMethod(projectAuthorization, 'assertCanManage'); + requireMethod(membershipRevocation, 'assertActive'); + + async function create({ subjectId, projectId, name, expiresAtMs }) { + validateIdentity(subjectId, projectId); + const normalizedName = normalizeName(name); + await assertManage(projectAuthorization, subjectId, projectId); + const membershipVersion = await readMembershipVersion(membershipRevocation, subjectId, projectId); + const nowMs = readNow(clock); + const normalizedExpiry = normalizeExpiry(expiresAtMs, nowMs); + const secret = encodeSecret(randomSource.randomBytes(SECRET_BYTES)); + const subscriptionId = encodeSubscriptionId(randomSource.randomBytes(SUBSCRIPTION_ID_BYTES)); + const record = { + subscription_id: subscriptionId, + secret_hash: hashSecret(secret), + subject_id: subjectId, + project_id: projectId, + name: normalizedName, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: membershipVersion, + created_at_ms: nowMs, + expires_at_ms: normalizedExpiry, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, + }; + await repository.insertSubscription(record); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.created', + subscription_id: subscriptionId, + subject_id: subjectId, + project_id: projectId, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + expires_at_ms: normalizedExpiry, + }); + return Object.freeze({ secret, ...viewOf(record, nowMs) }); + } + + async function list({ subjectId, projectId }) { + validateIdentity(subjectId, projectId); + await assertManage(projectAuthorization, subjectId, projectId); + const nowMs = readNow(clock); + const records = await repository.listSubscriptions({ subject_id: subjectId, project_id: projectId }); + if (!Array.isArray(records)) throw new TypeError('calendar-subscription repository must return an array from listSubscriptions()'); + return Object.freeze(records.map((record) => viewOf(record, nowMs))); + } + + async function authorize({ secret, projectId }) { + if (typeof secret !== 'string' || !SECRET_PATTERN.test(secret) || !isBoundedString(projectId)) { + throw unauthorizedSubscription(); + } + const secretHash = hashSecret(secret); + const existing = await repository.findSubscriptionByHash(secretHash); + if (!existing || existing.project_id !== projectId || existing.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE) { + throw unauthorizedSubscription(); + } + const membershipVersion = await readMembershipVersion( + membershipRevocation, + existing.subject_id, + existing.project_id, + ); + const used = await repository.recordUsageAtomically(secretHash, { + now_ms: readNow(clock), + project_id: projectId, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: membershipVersion, + }); + if (!used) throw unauthorizedSubscription(); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.used', + subscription_id: used.subscription_id, + subject_id: used.subject_id, + project_id: used.project_id, + audience: used.audience, + }); + return Object.freeze({ + subscriptionId: used.subscription_id, + subjectId: used.subject_id, + projectId: used.project_id, + audience: used.audience, + }); + } + + async function rotate({ subjectId, projectId, subscriptionId, expiresAtMs }) { + validateIdentity(subjectId, projectId); + const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); + await assertManage(projectAuthorization, subjectId, projectId); + const membershipVersion = await readMembershipVersion(membershipRevocation, subjectId, projectId); + const nowMs = readNow(clock); + const normalizedExpiry = normalizeExpiry(expiresAtMs, nowMs); + const secret = encodeSecret(randomSource.randomBytes(SECRET_BYTES)); + const rotated = await repository.rotateSubscriptionAtomically(normalizedSubscriptionId, { + subject_id: subjectId, + project_id: projectId, + new_secret_hash: hashSecret(secret), + now_ms: nowMs, + expires_at_ms: normalizedExpiry, + membership_version: membershipVersion, + }); + if (!rotated) throw notFoundSubscription(); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.rotated', + subscription_id: rotated.subscription_id, + subject_id: rotated.subject_id, + project_id: rotated.project_id, + audience: rotated.audience, + expires_at_ms: rotated.expires_at_ms, + }); + return Object.freeze({ secret, ...viewOf(rotated, nowMs) }); + } + + async function revoke({ subjectId, projectId, subscriptionId }) { + validateIdentity(subjectId, projectId); + const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); + await assertManage(projectAuthorization, subjectId, projectId); + const nowMs = readNow(clock); + const revoked = await repository.revokeSubscriptionAtomically(normalizedSubscriptionId, { + subject_id: subjectId, + project_id: projectId, + now_ms: nowMs, + }); + if (!revoked) throw notFoundSubscription(); + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.revoked', + subscription_id: revoked.subscription_id, + subject_id: revoked.subject_id, + project_id: revoked.project_id, + audience: revoked.audience, + }); + return viewOf(revoked, nowMs); + } + + return Object.freeze({ create, list, authorize, rotate, revoke }); +} From 0a65eb3df83d52adf731d30471ca490910abbbc0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:19:19 +0900 Subject: [PATCH 03/24] test(calendar): cover subscription domain failure boundaries --- ...calendar-subscription-domain-edge.test.mjs | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 tests/unit/calendar-subscription-domain-edge.test.mjs diff --git a/tests/unit/calendar-subscription-domain-edge.test.mjs b/tests/unit/calendar-subscription-domain-edge.test.mjs new file mode 100644 index 00000000..6bd31b85 --- /dev/null +++ b/tests/unit/calendar-subscription-domain-edge.test.mjs @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { + CALENDAR_SUBSCRIPTION_AUDIENCE, + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; + +const NOW = 1_900_000_000_000; +const VALID_SECRET = Buffer.alloc(32, 7).toString('base64url'); + +function ports(overrides = {}) { + const repository = { + insertSubscription: async () => {}, + listSubscriptions: async () => [], + findSubscriptionByHash: async () => null, + recordUsageAtomically: async () => null, + rotateSubscriptionAtomically: async () => null, + revokeSubscriptionAtomically: async () => null, + ...(overrides.repository || {}), + }; + return { + repository, + clock: overrides.clock || { nowMs: () => NOW }, + randomSource: overrides.randomSource || { randomBytes: (size) => new Uint8Array(size).fill(9) }, + auditSink: overrides.auditSink || { record: async () => {} }, + projectAuthorization: overrides.projectAuthorization || { assertCanManage: async () => {} }, + membershipRevocation: overrides.membershipRevocation || { assertActive: async () => 'membership-v1' }, + }; +} + +function service(overrides = {}) { + return createCalendarSubscriptionService(ports(overrides)); +} + +async function expectError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof CalendarSubscriptionError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +{ + const valid = ports(); + const requirements = [ + ['repository', 'insertSubscription'], + ['repository', 'listSubscriptions'], + ['repository', 'findSubscriptionByHash'], + ['repository', 'recordUsageAtomically'], + ['repository', 'rotateSubscriptionAtomically'], + ['repository', 'revokeSubscriptionAtomically'], + ['clock', 'nowMs'], + ['randomSource', 'randomBytes'], + ['auditSink', 'record'], + ['projectAuthorization', 'assertCanManage'], + ['membershipRevocation', 'assertActive'], + ]; + for (const [portName, method] of requirements) { + const broken = ports(); + broken[portName] = { ...broken[portName] }; + delete broken[portName][method]; + assert.throws( + () => createCalendarSubscriptionService(broken), + new RegExp(`calendar-subscription dependency must provide ${method}\\(\\)`), + ); + } + assert.ok(createCalendarSubscriptionService(valid)); + assert.throws(() => createCalendarSubscriptionService(), /insertSubscription/); +} + +{ + const svc = service(); + const invalidIdentities = [ + { subjectId: '', projectId: 'project-1' }, + { subjectId: ' user-1', projectId: 'project-1' }, + { subjectId: 'user\n1', projectId: 'project-1' }, + { subjectId: 'user-1', projectId: '' }, + { subjectId: 'user-1', projectId: ' project-1' }, + { subjectId: 1, projectId: 'project-1' }, + ]; + for (const identity of invalidIdentities) { + await expectError( + svc.create({ ...identity, name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_request_invalid', 400, + ); + } + for (const name of ['', ' Calendar', 'Calendar\u0000', 'x'.repeat(121), null]) { + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name, expiresAtMs: NOW + 10 }), + 'calendar_subscription_request_invalid', 400, + ); + } + for (const expiresAtMs of [NOW, NOW - 1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs }), + 'calendar_subscription_expiry_invalid', 400, + ); + } +} + +{ + for (const version of [0, 4, 'v4']) { + const svc = service({ membershipRevocation: { assertActive: async () => version } }); + const created = await svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }); + assert.equal(created.status, 'active'); + } + for (const version of [-1, Number.NaN, '', ' v1', 'v1\u0000', 'x'.repeat(129), {}, null]) { + const svc = service({ membershipRevocation: { assertActive: async () => version } }); + await expectError( + svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_unauthorized', 401, + ); + } + const revoked = service({ membershipRevocation: { assertActive: async () => { throw new Error('revoked'); } } }); + await expectError( + revoked.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_unauthorized', 401, + ); +} + +{ + const denied = service({ projectAuthorization: { assertCanManage: async () => { throw new Error('no'); } } }); + await expectError( + denied.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + 'calendar_subscription_not_found', 404, + ); + await expectError(denied.list({ subjectId: 'user-1', projectId: 'project-1' }), 'calendar_subscription_not_found', 404); +} + +{ + const badClock = service({ clock: { nowMs: () => -1 } }); + await assert.rejects( + badClock.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /clock must return a non-negative safe integer/, + ); + const fractionalClock = service({ clock: { nowMs: () => 1.5 } }); + await assert.rejects(fractionalClock.list({ subjectId: 'user-1', projectId: 'project-1' }), /clock must return/); +} + +{ + const badSecretBytes = service({ randomSource: { randomBytes: () => new Uint8Array(31) } }); + await assert.rejects( + badSecretBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 32 bytes/, + ); + let call = 0; + const badIdBytes = service({ randomSource: { randomBytes: (size) => { call += 1; return new Uint8Array(call === 1 ? size : 15); } } }); + await assert.rejects( + badIdBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 16 bytes for subscription id/, + ); + const notBytes = service({ randomSource: { randomBytes: () => 'not-bytes' } }); + await assert.rejects( + notBytes.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs: NOW + 10 }), + /must return 32 bytes/, + ); +} + +{ + const nonArray = service({ repository: { listSubscriptions: async () => ({}) } }); + await assert.rejects(nonArray.list({ subjectId: 'user-1', projectId: 'project-1' }), /must return an array/); + + const expiredRow = { + subscription_id: 'csub_expired', subject_id: 'user-1', project_id: 'project-1', name: 'Old', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, created_at_ms: NOW - 100, expires_at_ms: NOW, + last_used_at_ms: NOW - 10, rotated_at_ms: NOW - 20, revoked_at_ms: null, + }; + const revokedRow = { ...expiredRow, subscription_id: 'csub_revoked', revoked_at_ms: NOW - 1 }; + const listed = await service({ repository: { listSubscriptions: async () => [expiredRow, revokedRow] } }) + .list({ subjectId: 'user-1', projectId: 'project-1' }); + assert.deepEqual(listed.map((row) => row.status), ['expired', 'revoked']); + assert.equal(listed[0].lastUsedAtMs, NOW - 10); + assert.equal(listed[0].rotatedAtMs, NOW - 20); +} + +{ + const svc = service(); + for (const candidate of [null, '', 'short', `${VALID_SECRET}=`, VALID_SECRET]) { + const projectId = candidate === VALID_SECRET ? '' : 'project-1'; + await expectError(svc.authorize({ secret: candidate, projectId }), 'calendar_subscription_unauthorized', 401); + } + + const baseRecord = { + subscription_id: 'csub_123', subject_id: 'user-1', project_id: 'project-1', name: 'Calendar', + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, created_at_ms: NOW - 1, expires_at_ms: NOW + 100, + last_used_at_ms: null, rotated_at_ms: null, revoked_at_ms: null, + }; + for (const record of [null, { ...baseRecord, project_id: 'project-2' }, { ...baseRecord, audience: 'other' }]) { + const candidate = service({ repository: { findSubscriptionByHash: async () => record } }); + await expectError(candidate.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + } + const atomicReject = service({ repository: { + findSubscriptionByHash: async () => baseRecord, + recordUsageAtomically: async () => null, + } }); + await expectError(atomicReject.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + + const membershipReject = service({ + repository: { findSubscriptionByHash: async () => baseRecord }, + membershipRevocation: { assertActive: async () => { throw new Error('gone'); } }, + }); + await expectError(membershipReject.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); +} + +{ + const svc = service(); + for (const input of [ + { subjectId: '', projectId: 'project-1', subscriptionId: 'csub_1', expiresAtMs: NOW + 10 }, + { subjectId: 'user-1', projectId: 'project-1', subscriptionId: '', expiresAtMs: NOW + 10 }, + ]) { + await expectError(svc.rotate(input), 'calendar_subscription_request_invalid', 400); + } + await expectError( + svc.rotate({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_1', expiresAtMs: NOW }), + 'calendar_subscription_expiry_invalid', 400, + ); + await expectError( + svc.rotate({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_missing', expiresAtMs: NOW + 10 }), + 'calendar_subscription_not_found', 404, + ); + await expectError( + svc.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_missing' }), + 'calendar_subscription_not_found', 404, + ); + await expectError( + svc.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: '' }), + 'calendar_subscription_request_invalid', 400, + ); +} + +console.log('calendar subscription domain edge tests passed'); From e2df180f24d34257fb3d35e144e0ee2a06e286b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:19:37 +0900 Subject: [PATCH 04/24] test(calendar): register subscription coverage cases --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6cf29d9c..4acba389 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/access-grant-domain-edge.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/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/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/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 14185c2f3631c6a0c68af17f65b89b4c77ae66b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:19:53 +0900 Subject: [PATCH 05/24] test(coverage): lock calendar subscription evidence --- tests/unit/coverage-script-contract.test.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index d4fe580b..9ebfdb83 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -39,6 +39,11 @@ assert.match( /--include=server\/access_grant_domain\.mjs/, 'the short-lived access-grant domain is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/calendar_subscription_domain\.mjs/, + 'the durable calendar-subscription domain is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/access-grant-domain\.test\.mjs/, @@ -49,6 +54,16 @@ assert.match( /tests\/unit\/access-grant-domain-edge\.test\.mjs/, 'the access-grant edge cases execute under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-domain\.test\.mjs/, + 'the calendar-subscription lifecycle contract executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-domain-edge\.test\.mjs/, + 'the calendar-subscription failure boundaries execute under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, From 4f3d5ef9fb804d10b9e92e5c4cee93465cec72d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:22:09 +0900 Subject: [PATCH 06/24] docs(calendar): record reusable credential security boundary --- .../doctoring/calendar-subscription-domain.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/doctoring/calendar-subscription-domain.md diff --git a/docs/doctoring/calendar-subscription-domain.md b/docs/doctoring/calendar-subscription-domain.md new file mode 100644 index 00000000..ef381aab --- /dev/null +++ b/docs/doctoring/calendar-subscription-domain.md @@ -0,0 +1,59 @@ +# Calendar subscription credential domain + +Status: **active stacked PR work; not shipped on protected `develop`**. + +Issue: #413. + +## Why this slice exists + +Protected `develop` still accepts the general ScopeWeave session credential in the query string of `/api/projects/:id/calendar.ics`. Calendar clients commonly require a reusable URL rather than an `Authorization` header, but reusing a broad session JWT gives that URL the authority of a user session and exposes an unnecessarily powerful credential to URL-handling surfaces such as browser history, copied links, reverse proxies, observability systems, and downstream calendar clients. + +The IETF's OAuth bearer-token guidance is not a specification for ScopeWeave calendar subscriptions. It is relevant threat evidence: RFC 6750 warns against URI-query bearer-token transport because URLs are likely to be logged, while the current OAuth 2.0 Security BCP (RFC 9700, January 2025) further restricts token exposure and recommends limiting token privilege and audience. RFC 8725 provides current BCP guidance for JWT validation. ScopeWeave therefore treats the existing session-JWT calendar URL as a legacy authority boundary to retire, not as the target reusable-subscription design. + +This bounded slice introduces only a framework-neutral lifecycle domain. It does **not** change the protected calendar route, create database tables, migrate stored data, add HTTP endpoints, or implement the management UI. Issue #413 requires the UI interaction to be designed in Figma before UI implementation; that requirement remains outstanding and is not bypassed by this backend-only prerequisite. + +## Domain contract + +`server/calendar_subscription_domain.mjs` defines one fixed resource audience, `scopeweave:calendar`, and an injected service with five operations: create, list, authorize, rotate, and revoke. + +A newly created or rotated subscription secret is generated from 32 random bytes and encoded as unpadded base64url. The plaintext secret is returned only by `create()` or `rotate()`. Only its SHA-256 hash crosses the repository port. Audit events contain the independently random correlation identifier (`csub_` plus 128 random bits), actor/resource identifiers, audience, and lifecycle metadata, but never the plaintext secret or its hash. + +The reusable credential is deliberately separate from the short-lived `stream` and `attachment_view` grant domain in PR #506. A calendar subscription needs an operator-visible lifecycle—name, creation time, last-use time, expiry, rotation, revocation, purpose/resource, and status—whereas the short-lived access-grant domain is one-time and capped at five minutes. Reusing either the general session JWT or the one-time grant lifecycle would collapse materially different authority and recovery semantics. + +Creation and lifecycle management require project-management authorization. Every secret use re-checks active membership and passes the captured membership version into `recordUsageAtomically(...)`. Rotation similarly passes the current membership version into `rotateSubscriptionAtomically(...)`. Production adapters must compare that supplied version with live membership state in the same atomic transition, closing a revoke-between-check-and-use race. If a deployment cannot provide that shared transaction boundary, its membership-removal path must atomically revoke affected calendar subscriptions instead. + +Expiry is mandatory and exact expiry is unusable. This domain intentionally does not invent a universal lifetime: the product/API layer must choose a bounded policy appropriate to deployment risk and communicate it to operators. Rotation invalidates the previous secret as part of one atomic repository transition; revocation is idempotent from the operator perspective. Listing returns safe lifecycle metadata only and never permits the stored secret, secret hash, or membership-version value to be redisplayed. + +Audit delivery occurs only after the durable repository transition and is best-effort at this framework-neutral boundary. An audit transport outage must not convert an already completed secret creation, rotation, revocation, or usage record into a client-visible failure that encourages unsafe retry. Production persistence that requires durable evidence should couple the state change to a transactional audit outbox. + +## Persistence boundary for the follow-up adapter + +Issue #413 owns the intended normalized durable objects `calendar_subscriptions`, `subscription_rotations`, and `subscription_usage_events`. This PR does not create them. The separate schema-migration work in PR #500 establishes migration identity/fail-closed generation handling for the broader database modernization and must remain the schema-transition authority rather than being duplicated here. + +A future adapter must preserve at least these invariants: + +- `calendar_subscriptions` contains the subscription identity, project/subject binding, name, fixed audience, current secret hash, membership version, creation/expiry/last-use/rotation/revocation metadata; plaintext secrets are never persisted. +- `subscription_rotations` records non-secret correlation/evidence for each successful rotation without retaining previous plaintext secrets. +- `subscription_usage_events` records bounded operational usage evidence suitable for access/export investigation without turning request URLs or secrets into logs. +- create, rotate, revoke, and last-use updates are transactionally consistent with their lifecycle evidence, and membership invalidation cannot race a successful use or rotation into continued access. +- owned database object names remain descriptive multiword `snake_case` and the eventual relational design is normalized rather than embedding lifecycle history in an opaque JSON column. + +## Verification and traceability + +TDD began at commit `c86c878f6e9876fe9ca5c85d8b7e8b25fcb5ed77`, whose behavior contract imported an absent `server/calendar_subscription_domain.mjs`; the focused Node execution failed RED with `ERR_MODULE_NOT_FOUND`. Commit `1210d52225fa25e9f122e23fe80578334e64cff8` supplied the production domain. Commit `1bb891b9c34b997c0b618857093bba41a967cbe6` added failure-boundary coverage for port contracts, invalid identifiers/names/expiry, malformed membership versions, authorization denial, invalid clock/random sources, safe listing states, malformed/unknown/wrong-boundary credentials, and atomic lifecycle rejection. Both focused Node test files passed locally after implementation. + +The canonical repository coverage producer now instruments `server/calendar_subscription_domain.mjs` and executes both calendar-domain test files. Local `c8` evidence is unavailable in the current execution environment because the package is not locally cached; therefore hosted exact-head Istanbul evidence remains mandatory and no local statement/branch percentage is claimed as passing evidence. + +The protected `develop` truth remains unchanged by this active branch. Before integration, the exact child head must be reconciled after PR #506, all applicable repository/organization checks and security/dependency/supply-chain evidence must be terminal-success on the unchanged current head, owned production coverage must satisfy the repository contract, every valid review finding must be resolved, and the live rulesets' qualifying independent current-head/last-push approval must exist. Predecessor-head, skipped-required, neutral, synthetic, status-only, or model-only evidence does not transfer. + +## Rollback + +Until a persistence/route slice exists, rollback is code-only: remove this module, its focused tests, coverage registrations, doctoring entry, and changelog line together. No database migration or credential invalidation is required because this slice cannot yet issue a production calendar subscription. Once persistence is added, rollback must preserve the durable revocation/rotation history and must never restore the broad session-JWT query credential as the security-safe steady state. + +## References + +Jones, M., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: Bearer token usage* (RFC 6750). Internet Engineering Task Force. https://doi.org/10.17487/RFC6750 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task Force. https://doi.org/10.17487/RFC9700 + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current practices* (BCP 225; RFC 8725). Internet Engineering Task Force. https://doi.org/10.17487/RFC8725 From c764fa3a24681b6f623bd725313f75cff4efaa9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:22:55 +0900 Subject: [PATCH 07/24] docs(changelog): record calendar subscription domain --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b3433b..5bebb173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 membership-revocation, random-source, clock, audit, and atomic repository ports. Route, database, calendar-subscription, and client migration remain follow-up work under issue #413. +- Added a separate framework-neutral calendar-subscription credential domain + with one-time secret display on create/rotate, hash-only persistence ports, + project-scoped management, reusable feed authorization, membership-version + rechecks, safe lifecycle metadata, rotation, revocation, and usage evidence. + Runtime route, database adapter/migration, Figma interaction design, and UI + remain follow-up work under issue #413. ### Security From cabdf4ffe0cdb3739085b40be4fe00c3760b4ae4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:28:11 +0900 Subject: [PATCH 08/24] docs(calendar): trace Figma interaction contract --- .../doctoring/calendar-subscription-domain.md | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/calendar-subscription-domain.md b/docs/doctoring/calendar-subscription-domain.md index ef381aab..91b818a3 100644 --- a/docs/doctoring/calendar-subscription-domain.md +++ b/docs/doctoring/calendar-subscription-domain.md @@ -10,7 +10,25 @@ Protected `develop` still accepts the general ScopeWeave session credential in t The IETF's OAuth bearer-token guidance is not a specification for ScopeWeave calendar subscriptions. It is relevant threat evidence: RFC 6750 warns against URI-query bearer-token transport because URLs are likely to be logged, while the current OAuth 2.0 Security BCP (RFC 9700, January 2025) further restricts token exposure and recommends limiting token privilege and audience. RFC 8725 provides current BCP guidance for JWT validation. ScopeWeave therefore treats the existing session-JWT calendar URL as a legacy authority boundary to retire, not as the target reusable-subscription design. -This bounded slice introduces only a framework-neutral lifecycle domain. It does **not** change the protected calendar route, create database tables, migrate stored data, add HTTP endpoints, or implement the management UI. Issue #413 requires the UI interaction to be designed in Figma before UI implementation; that requirement remains outstanding and is not bypassed by this backend-only prerequisite. +This bounded slice introduces only a framework-neutral lifecycle domain. It does **not** change the protected calendar route, create database tables, migrate stored data, add HTTP endpoints, or implement the management UI. The product-interaction prerequisite from issue #413 is now captured in the Figma design described below; future UI implementation must conform to that reviewed interaction contract and the eventual API semantics rather than inventing an independent credential lifecycle. + +## Figma interaction design + +The editable product contract is `ScopeWeave Calendar Subscription Management — Issue 413`: . + +The design is active-PR evidence, not shipped-product evidence. It uses the current protected ScopeWeave visual tokens and reusable button/status components and covers seven explicit states: + +1. interaction/security contract; +2. subscription management list; +3. creation dialog; +4. one-time secret reveal; +5. rotation confirmation; +6. revocation confirmation; and +7. empty state. + +The interaction contract requires a recognizable subscription name and explicit expiry, read-only/current-project purpose copy, one-time secret display on create or rotate, an explicit copy/save acknowledgement, lifecycle metadata without stored secret material, immediate old-secret invalidation on rotation, named destructive consequences on revocation, and next-action-oriented failure/recovery copy. Active, expired, and revoked states use text in addition to color. + +Accessibility acceptance is part of the design rather than a later visual-polish step: the dialog title is the accessible name, helper/error content is associated with its control, focus enters the dialog and returns to its invoker, Escape never silently commits, primary actions retain visible focus, and copy/status feedback is announced without moving focus. UI implementation must add executable browser acceptance for these interactions before the Figma design can be represented as shipped behavior. ## Domain contract From 683a34d009daa3a85d8eb33b808cd76c996ee06e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:29:04 +0900 Subject: [PATCH 09/24] docs(changelog): trace calendar interaction design --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bebb173..b5976d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with one-time secret display on create/rotate, hash-only persistence ports, project-scoped management, reusable feed authorization, membership-version rechecks, safe lifecycle metadata, rotation, revocation, and usage evidence. - Runtime route, database adapter/migration, Figma interaction design, and UI - remain follow-up work under issue #413. + Runtime route, database adapter/migration, and UI implementation remain + follow-up work under issue #413; the required calendar-management interaction + contract is captured in Figma and traced in the doctoring record. ### Security From a115ffb15d1f74cc0b6fcd7e574a2e677eac14c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:09:16 +0000 Subject: [PATCH 10/24] fix(calendar): bind reusable secrets to issuance membership epoch Authorize calendar-read use against the stored membership epoch so remove-then-rejoin cannot revive an unrevoked feed secret. Freeze purpose calendar_read on the principal, cap lifetime at 366 days, reject exact expiry on the use path, and audit revocation only on the first transition. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 5 + CHANGELOG.md | 12 +- .../doctoring/calendar-subscription-domain.md | 12 +- server/calendar_subscription_domain.mjs | 121 ++++++++++++--- ...calendar-subscription-domain-edge.test.mjs | 73 ++++++++- .../calendar-subscription-domain.test.mjs | 143 +++++++++++++++++- 6 files changed, 333 insertions(+), 33 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4688d27b..c6e39610 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,3 +45,8 @@ contain. - Kubernetes/IaC security coverage remains a follow-up design lane for any future `infra/` or container packaging surface. +- Short-lived `stream` / `attachment_view` access grants and reusable + `calendar_read` subscriptions are separate credential domains. Calendar + use binds the stored issuance membership epoch; rotation re-binds the + live epoch and invalidates the previous secret. Neither domain may + restore a general session JWT in a URL. diff --git a/CHANGELOG.md b/CHANGELOG.md index b5976d7b..0be5bc6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,11 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 follow-up work under issue #413. - Added a separate framework-neutral calendar-subscription credential domain with one-time secret display on create/rotate, hash-only persistence ports, - project-scoped management, reusable feed authorization, membership-version - rechecks, safe lifecycle metadata, rotation, revocation, and usage evidence. - Runtime route, database adapter/migration, and UI implementation remain - follow-up work under issue #413; the required calendar-management interaction - contract is captured in Figma and traced in the doctoring record. + project-scoped management, reusable `calendar_read` authorization, issuance + membership-epoch binding so remove-then-rejoin cannot revive a secret, + a 366-day create/rotate lifetime cap, exact-expiry rejection, safe lifecycle + metadata, rotation, revocation, and usage evidence. Runtime route, database + adapter/migration, and UI implementation remain follow-up work under issue + #413; the required calendar-management interaction contract is captured in + Figma and traced in the doctoring record. ### Security diff --git a/docs/doctoring/calendar-subscription-domain.md b/docs/doctoring/calendar-subscription-domain.md index 91b818a3..4387f405 100644 --- a/docs/doctoring/calendar-subscription-domain.md +++ b/docs/doctoring/calendar-subscription-domain.md @@ -32,15 +32,15 @@ Accessibility acceptance is part of the design rather than a later visual-polish ## Domain contract -`server/calendar_subscription_domain.mjs` defines one fixed resource audience, `scopeweave:calendar`, and an injected service with five operations: create, list, authorize, rotate, and revoke. +`server/calendar_subscription_domain.mjs` defines one fixed resource audience, `scopeweave:calendar`, one fixed purpose, `calendar_read`, and an injected service with five operations: create, list, authorize, rotate, and revoke. The purpose is the buyer-visible authority boundary: a thin route may mint an ICS/calendar-feed principal from it and must not treat that principal as session-equivalent access to JSON APIs, SSE, attachments, or another project. RFC 5545 remains the interchange format for the eventual feed; this domain only issues the reusable credential that will later authorize that feed. -A newly created or rotated subscription secret is generated from 32 random bytes and encoded as unpadded base64url. The plaintext secret is returned only by `create()` or `rotate()`. Only its SHA-256 hash crosses the repository port. Audit events contain the independently random correlation identifier (`csub_` plus 128 random bits), actor/resource identifiers, audience, and lifecycle metadata, but never the plaintext secret or its hash. +A newly created or rotated subscription secret is generated from 32 random bytes and encoded as unpadded base64url. The plaintext secret is returned only by `create()` or `rotate()`. Only its SHA-256 hash crosses the repository port. Audit events contain the independently random correlation identifier (`csub_` plus 128 random bits), actor/resource identifiers, purpose, audience, and lifecycle metadata, but never the plaintext secret or its hash. Repeat revocation keeps the original `revoked_at_ms`. Adapters must set `revocation_applied: true` only on the first transition so a same-millisecond retry cannot emit a second audit event. The reusable credential is deliberately separate from the short-lived `stream` and `attachment_view` grant domain in PR #506. A calendar subscription needs an operator-visible lifecycle—name, creation time, last-use time, expiry, rotation, revocation, purpose/resource, and status—whereas the short-lived access-grant domain is one-time and capped at five minutes. Reusing either the general session JWT or the one-time grant lifecycle would collapse materially different authority and recovery semantics. -Creation and lifecycle management require project-management authorization. Every secret use re-checks active membership and passes the captured membership version into `recordUsageAtomically(...)`. Rotation similarly passes the current membership version into `rotateSubscriptionAtomically(...)`. Production adapters must compare that supplied version with live membership state in the same atomic transition, closing a revoke-between-check-and-use race. If a deployment cannot provide that shared transaction boundary, its membership-removal path must atomically revoke affected calendar subscriptions instead. +Creation and lifecycle management require project-management authorization. Every secret use re-checks active membership and passes the *issuance* membership version stored on the row into `recordUsageAtomically(...)`. The service also fail-closes when live membership no longer equals that issuance epoch, so remove-then-rejoin cannot revive an unrevoked secret. `rotateSubscriptionAtomically(...)` receives the *current* live membership version so a still-authorized operator can re-bind after rejoin while invalidating the previous secret. Production adapters must, in the same transaction, compare the supplied version with live membership, compare usage against the row's stored epoch, and reject wrong project/audience/purpose, exact expiry (`expires_at_ms <= now_ms`), and a non-null `revoked_at_ms`. The service fail-closes unless the returned row is active. Membership-removal paths must atomically revoke affected calendar subscriptions; that revoke-on-change path is mandatory, not a substitute for the epoch comparison. -Expiry is mandatory and exact expiry is unusable. This domain intentionally does not invent a universal lifetime: the product/API layer must choose a bounded policy appropriate to deployment risk and communicate it to operators. Rotation invalidates the previous secret as part of one atomic repository transition; revocation is idempotent from the operator perspective. Listing returns safe lifecycle metadata only and never permits the stored secret, secret hash, or membership-version value to be redisplayed. +Expiry is mandatory, exact expiry is unusable, and create/rotate reject a lifetime longer than 366 days from `nowMs` so a buggy caller cannot persist a decades-long feed secret. The product/API layer may choose a shorter policy and must communicate it to operators. Rotation invalidates the previous secret as part of one atomic repository transition; revocation is idempotent from the operator perspective. Listing returns safe lifecycle metadata only and never permits the stored secret, secret hash, or membership-version value to be redisplayed. Audit delivery occurs only after the durable repository transition and is best-effort at this framework-neutral boundary. An audit transport outage must not convert an already completed secret creation, rotation, revocation, or usage record into a client-visible failure that encourages unsafe retry. Production persistence that requires durable evidence should couple the state change to a transactional audit outbox. @@ -50,7 +50,7 @@ Issue #413 owns the intended normalized durable objects `calendar_subscriptions` A future adapter must preserve at least these invariants: -- `calendar_subscriptions` contains the subscription identity, project/subject binding, name, fixed audience, current secret hash, membership version, creation/expiry/last-use/rotation/revocation metadata; plaintext secrets are never persisted. +- `calendar_subscriptions` contains the subscription identity, project/subject binding, name, fixed `calendar_read` purpose, fixed audience, current secret hash, issuance membership version, creation/expiry/last-use/rotation/revocation metadata; plaintext secrets are never persisted. - `subscription_rotations` records non-secret correlation/evidence for each successful rotation without retaining previous plaintext secrets. - `subscription_usage_events` records bounded operational usage evidence suitable for access/export investigation without turning request URLs or secrets into logs. - create, rotate, revoke, and last-use updates are transactionally consistent with their lifecycle evidence, and membership invalidation cannot race a successful use or rotation into continued access. @@ -70,6 +70,8 @@ Until a persistence/route slice exists, rollback is code-only: remove this modul ## References +Desruisseaux, B. (Ed.). (2009). *Internet calendaring and scheduling core object specification (iCalendar)* (RFC 5545). Internet Engineering Task Force. https://doi.org/10.17487/RFC5545 + Jones, M., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: Bearer token usage* (RFC 6750). Internet Engineering Task Force. https://doi.org/10.17487/RFC6750 Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task Force. https://doi.org/10.17487/RFC9700 diff --git a/server/calendar_subscription_domain.mjs b/server/calendar_subscription_domain.mjs index f40e0717..21750739 100644 --- a/server/calendar_subscription_domain.mjs +++ b/server/calendar_subscription_domain.mjs @@ -6,10 +6,24 @@ const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; const NAME_MAX_LENGTH = 120; const MEMBERSHIP_VERSION_MAX_LENGTH = 128; const CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/u; +const MS_PER_DAY = 24 * 60 * 60 * 1000; /** Fixed resource-server audience for reusable project calendar subscriptions. */ export const CALENDAR_SUBSCRIPTION_AUDIENCE = 'scopeweave:calendar'; +/** + * Fixed purpose bound to ICS/calendar-feed read only. + * Thin HTTP adapters must not treat this principal as session-equivalent + * access to JSON APIs, SSE, attachments, or other projects. + */ +export const CALENDAR_SUBSCRIPTION_PURPOSE = 'calendar_read'; + +/** + * Inclusive upper bound on create/rotate lifetime, measured from `nowMs`. + * 366 days covers a leap-year span without allowing a decades-long feed secret. + */ +export const CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS = 366 * MS_PER_DAY; + /** Stable domain error safe for thin HTTP adapters to map without secret-state disclosure. */ export class CalendarSubscriptionError extends Error { /** @@ -69,6 +83,10 @@ function normalizeExpiry(expiresAtMs, nowMs) { if (!Number.isSafeInteger(expiresAtMs) || expiresAtMs <= nowMs) { throw new CalendarSubscriptionError('calendar_subscription_expiry_invalid', 400); } + const lifetimeMs = expiresAtMs - nowMs; + if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs > CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS) { + throw new CalendarSubscriptionError('calendar_subscription_expiry_invalid', 400); + } return expiresAtMs; } @@ -122,6 +140,7 @@ function viewOf(record, nowMs) { subjectId: record.subject_id, projectId: record.project_id, name: record.name, + purpose: record.purpose ?? CALENDAR_SUBSCRIPTION_PURPOSE, audience: record.audience, createdAtMs: record.created_at_ms, expiresAtMs: record.expires_at_ms, @@ -165,11 +184,24 @@ async function readMembershipVersion(membershipRevocation, subjectId, projectId) * their SHA-256 hashes cross the repository port. Repository adapters own the * durable `calendar_subscriptions`, `subscription_rotations`, and * `subscription_usage_events` state and must make usage/rotation/revocation - * transitions atomic. `recordUsageAtomically()` and - * `rotateSubscriptionAtomically()` must compare the supplied membership version - * with live membership state in the same transaction, closing the - * revoke-between-check-and-use race. Adapters without a shared transaction must - * atomically revoke affected subscriptions when membership changes. + * transitions atomic. + * + * `authorize()` supplies the *issuance* membership version captured on the + * stored row. `recordUsageAtomically()` must reject unless, in one transaction, + * live membership equals that supplied version and the row's + * `membership_version` still equals it. Remove-then-rejoin therefore cannot + * revive an unrevoked secret; the operator must rotate (or create) to bind a + * new epoch. `rotateSubscriptionAtomically()` receives the *current* live + * membership version so a still-authorized operator can re-bind after rejoin + * while invalidating the previous secret. Both atomic ports must also reject + * wrong project/audience/purpose, `expires_at_ms <= now_ms`, and a non-null + * `revoked_at_ms`. The service fail-closes unless the returned row is + * `statusOf(...) === 'active'`. `revokeSubscriptionAtomically()` must set + * `revocation_applied: true` only on the first transition so a same-millisecond + * retry cannot emit a second audit event. Adapters without a shared transaction + * must atomically revoke affected subscriptions when membership changes; that + * revoke-on-membership-change path is mandatory, not an alternative to the + * epoch comparison. * * Audit delivery is best-effort after repository commits so an audit transport * outage cannot cause a client to retry and accidentally expose multiple active @@ -205,6 +237,11 @@ export function createCalendarSubscriptionService({ requireMethod(projectAuthorization, 'assertCanManage'); requireMethod(membershipRevocation, 'assertActive'); + /** + * Mint a reusable calendar-read secret. Returns plaintext once; persists only the hash. + * @param {{subjectId: string, projectId: string, name: string, expiresAtMs: number}} request + * @returns {Promise} Frozen lifecycle view plus one-time `secret`. + */ async function create({ subjectId, projectId, name, expiresAtMs }) { validateIdentity(subjectId, projectId); const normalizedName = normalizeName(name); @@ -220,6 +257,7 @@ export function createCalendarSubscriptionService({ subject_id: subjectId, project_id: projectId, name: normalizedName, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, audience: CALENDAR_SUBSCRIPTION_AUDIENCE, membership_version: membershipVersion, created_at_ms: nowMs, @@ -234,12 +272,18 @@ export function createCalendarSubscriptionService({ subscription_id: subscriptionId, subject_id: subjectId, project_id: projectId, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, audience: CALENDAR_SUBSCRIPTION_AUDIENCE, expires_at_ms: normalizedExpiry, }); return Object.freeze({ secret, ...viewOf(record, nowMs) }); } + /** + * List safe lifecycle metadata for the caller's project. Never returns secret or hash. + * @param {{subjectId: string, projectId: string}} request + * @returns {Promise>} + */ async function list({ subjectId, projectId }) { validateIdentity(subjectId, projectId); await assertManage(projectAuthorization, subjectId, projectId); @@ -249,42 +293,71 @@ export function createCalendarSubscriptionService({ return Object.freeze(records.map((record) => viewOf(record, nowMs))); } + /** + * Authorize a calendar-read principal. Rejects exact expiry, revoke, and + * remove-then-rejoin unless the stored issuance membership epoch still matches live membership. + * @param {{secret: string, projectId: string}} request + * @returns {Promise<{subscriptionId: string, subjectId: string, projectId: string, purpose: string, audience: string}>} + */ async function authorize({ secret, projectId }) { if (typeof secret !== 'string' || !SECRET_PATTERN.test(secret) || !isBoundedString(projectId)) { throw unauthorizedSubscription(); } const secretHash = hashSecret(secret); const existing = await repository.findSubscriptionByHash(secretHash); - if (!existing || existing.project_id !== projectId || existing.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE) { + if ( + !existing + || existing.project_id !== projectId + || existing.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + || existing.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE + ) { throw unauthorizedSubscription(); } - const membershipVersion = await readMembershipVersion( + const issuedMembershipVersion = normalizeMembershipVersion(existing.membership_version); + const liveMembershipVersion = await readMembershipVersion( membershipRevocation, existing.subject_id, existing.project_id, ); + if (liveMembershipVersion !== issuedMembershipVersion) { + throw unauthorizedSubscription(); + } + const nowMs = readNow(clock); + if (statusOf(existing, nowMs) !== 'active') { + throw unauthorizedSubscription(); + } const used = await repository.recordUsageAtomically(secretHash, { - now_ms: readNow(clock), + now_ms: nowMs, project_id: projectId, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, audience: CALENDAR_SUBSCRIPTION_AUDIENCE, - membership_version: membershipVersion, + membership_version: issuedMembershipVersion, }); - if (!used) throw unauthorizedSubscription(); + if (!used || statusOf(used, nowMs) !== 'active' || used.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE) { + throw unauthorizedSubscription(); + } await recordAuditBestEffort(auditSink, { event: 'calendar_subscription.used', subscription_id: used.subscription_id, subject_id: used.subject_id, project_id: used.project_id, + purpose: used.purpose, audience: used.audience, }); return Object.freeze({ subscriptionId: used.subscription_id, subjectId: used.subject_id, projectId: used.project_id, + purpose: used.purpose, audience: used.audience, }); } + /** + * Replace the secret, bind the current live membership epoch, and invalidate the previous secret. + * @param {{subjectId: string, projectId: string, subscriptionId: string, expiresAtMs: number}} request + * @returns {Promise} Frozen lifecycle view plus one-time `secret`. + */ async function rotate({ subjectId, projectId, subscriptionId, expiresAtMs }) { validateIdentity(subjectId, projectId); const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); @@ -299,20 +372,29 @@ export function createCalendarSubscriptionService({ new_secret_hash: hashSecret(secret), now_ms: nowMs, expires_at_ms: normalizedExpiry, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, membership_version: membershipVersion, }); - if (!rotated) throw notFoundSubscription(); + if (!rotated || statusOf(rotated, nowMs) !== 'active' || rotated.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE) { + throw notFoundSubscription(); + } await recordAuditBestEffort(auditSink, { event: 'calendar_subscription.rotated', subscription_id: rotated.subscription_id, subject_id: rotated.subject_id, project_id: rotated.project_id, + purpose: rotated.purpose, audience: rotated.audience, expires_at_ms: rotated.expires_at_ms, }); return Object.freeze({ secret, ...viewOf(rotated, nowMs) }); } + /** + * Revoke a subscription. Repeat calls keep the original `revoked_at_ms` and do not re-audit. + * @param {{subjectId: string, projectId: string, subscriptionId: string}} request + * @returns {Promise} Frozen lifecycle view. + */ async function revoke({ subjectId, projectId, subscriptionId }) { validateIdentity(subjectId, projectId); const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); @@ -324,13 +406,16 @@ export function createCalendarSubscriptionService({ now_ms: nowMs, }); if (!revoked) throw notFoundSubscription(); - await recordAuditBestEffort(auditSink, { - event: 'calendar_subscription.revoked', - subscription_id: revoked.subscription_id, - subject_id: revoked.subject_id, - project_id: revoked.project_id, - audience: revoked.audience, - }); + if (revoked.revocation_applied === true) { + await recordAuditBestEffort(auditSink, { + event: 'calendar_subscription.revoked', + subscription_id: revoked.subscription_id, + subject_id: revoked.subject_id, + project_id: revoked.project_id, + purpose: revoked.purpose ?? CALENDAR_SUBSCRIPTION_PURPOSE, + audience: revoked.audience, + }); + } return viewOf(revoked, nowMs); } diff --git a/tests/unit/calendar-subscription-domain-edge.test.mjs b/tests/unit/calendar-subscription-domain-edge.test.mjs index 6bd31b85..73031980 100644 --- a/tests/unit/calendar-subscription-domain-edge.test.mjs +++ b/tests/unit/calendar-subscription-domain-edge.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { CALENDAR_SUBSCRIPTION_AUDIENCE, + CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS, + CALENDAR_SUBSCRIPTION_PURPOSE, CalendarSubscriptionError, createCalendarSubscriptionService, } from '../../server/calendar_subscription_domain.mjs'; @@ -91,7 +93,14 @@ async function expectError(promise, code, status) { 'calendar_subscription_request_invalid', 400, ); } - for (const expiresAtMs of [NOW, NOW - 1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + for (const expiresAtMs of [ + NOW, + NOW - 1, + 1.5, + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER + 1, + NOW + CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS + 1, + ]) { await expectError( svc.create({ subjectId: 'user-1', projectId: 'project-1', name: 'Calendar', expiresAtMs }), 'calendar_subscription_expiry_invalid', 400, @@ -183,10 +192,16 @@ async function expectError(promise, code, status) { const baseRecord = { subscription_id: 'csub_123', subject_id: 'user-1', project_id: 'project-1', name: 'Calendar', - audience: CALENDAR_SUBSCRIPTION_AUDIENCE, created_at_ms: NOW - 1, expires_at_ms: NOW + 100, + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: 'membership-v1', created_at_ms: NOW - 1, expires_at_ms: NOW + 100, last_used_at_ms: null, rotated_at_ms: null, revoked_at_ms: null, }; - for (const record of [null, { ...baseRecord, project_id: 'project-2' }, { ...baseRecord, audience: 'other' }]) { + for (const record of [ + null, + { ...baseRecord, project_id: 'project-2' }, + { ...baseRecord, audience: 'other' }, + { ...baseRecord, purpose: 'stream' }, + ]) { const candidate = service({ repository: { findSubscriptionByHash: async () => record } }); await expectError(candidate.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); } @@ -201,6 +216,58 @@ async function expectError(promise, code, status) { membershipRevocation: { assertActive: async () => { throw new Error('gone'); } }, }); await expectError(membershipReject.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + + const exactExpiry = service({ + repository: { findSubscriptionByHash: async () => ({ ...baseRecord, expires_at_ms: NOW }) }, + }); + await expectError(exactExpiry.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), 'calendar_subscription_unauthorized', 401); + + let authorizeLiveVersion = 'membership-v1'; + const authorizeMidCheck = service({ + repository: { + findSubscriptionByHash: async () => ({ ...baseRecord, membership_version: 'membership-v1' }), + recordUsageAtomically: async (_hash, expected) => ( + authorizeLiveVersion === expected.membership_version ? { ...baseRecord, last_used_at_ms: NOW } : null + ), + }, + membershipRevocation: { + async assertActive() { + const captured = authorizeLiveVersion; + authorizeLiveVersion = 'membership-v2'; + return captured; + }, + }, + }); + await expectError( + authorizeMidCheck.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + + let rotateLiveVersion = 'membership-v1'; + const rotateMidCheck = service({ + repository: { + rotateSubscriptionAtomically: async (_id, expected) => ( + rotateLiveVersion === expected.membership_version + ? { ...baseRecord, secret_hash: 'rotated', rotated_at_ms: NOW, membership_version: expected.membership_version } + : null + ), + }, + membershipRevocation: { + async assertActive() { + const captured = rotateLiveVersion; + rotateLiveVersion = 'membership-v2'; + return captured; + }, + }, + }); + await expectError( + rotateMidCheck.rotate({ + subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_123', expiresAtMs: NOW + 10, + }), + 'calendar_subscription_not_found', + 404, + ); } { diff --git a/tests/unit/calendar-subscription-domain.test.mjs b/tests/unit/calendar-subscription-domain.test.mjs index 3739a975..44381504 100644 --- a/tests/unit/calendar-subscription-domain.test.mjs +++ b/tests/unit/calendar-subscription-domain.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { CALENDAR_SUBSCRIPTION_AUDIENCE, + CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS, + CALENDAR_SUBSCRIPTION_PURPOSE, CalendarSubscriptionError, createCalendarSubscriptionService, } from '../../server/calendar_subscription_domain.mjs'; @@ -46,8 +48,10 @@ function makeHarness({ nowMs = 1_800_000_000_000, auditThrows = false } = {}) { !row || row.project_id !== expected.project_id || row.audience !== expected.audience + || row.purpose !== expected.purpose || row.revoked_at_ms !== null || row.expires_at_ms <= expected.now_ms + || row.membership_version !== expected.membership_version || liveVersion !== expected.membership_version ) return null; row.last_used_at_ms = expected.now_ms; @@ -61,6 +65,7 @@ function makeHarness({ nowMs = 1_800_000_000_000, auditThrows = false } = {}) { !row || row.subject_id !== expected.subject_id || row.project_id !== expected.project_id + || row.purpose !== expected.purpose || row.revoked_at_ms !== null || row.expires_at_ms <= expected.now_ms || liveVersion !== expected.membership_version @@ -81,8 +86,9 @@ function makeHarness({ nowMs = 1_800_000_000_000, auditThrows = false } = {}) { async revokeSubscriptionAtomically(subscriptionId, expected) { const row = rows.get(subscriptionId); if (!row || row.subject_id !== expected.subject_id || row.project_id !== expected.project_id) return null; - if (row.revoked_at_ms === null) row.revoked_at_ms = expected.now_ms; - return structuredClone(row); + const revocationApplied = row.revoked_at_ms === null; + if (revocationApplied) row.revoked_at_ms = expected.now_ms; + return { ...structuredClone(row), revocation_applied: revocationApplied }; }, }; @@ -150,6 +156,7 @@ async function expectDomainError(promise, code, status) { assert.equal(created.name, 'Executive calendar'); assert.equal(created.projectId, 'project-1'); assert.equal(created.subjectId, 'user-1'); + assert.equal(created.purpose, CALENDAR_SUBSCRIPTION_PURPOSE); assert.equal(created.audience, CALENDAR_SUBSCRIPTION_AUDIENCE); assert.equal(created.createdAtMs, 1_800_000_000_000); assert.equal(created.expiresAtMs, 1_800_086_400_000); @@ -170,6 +177,7 @@ async function expectDomainError(promise, code, status) { subscription_id: created.subscriptionId, subject_id: 'user-1', project_id: 'project-1', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, audience: CALENDAR_SUBSCRIPTION_AUDIENCE, expires_at_ms: 1_800_086_400_000, }); @@ -181,6 +189,7 @@ async function expectDomainError(promise, code, status) { subjectId: 'user-1', projectId: 'project-1', name: 'Executive calendar', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, audience: CALENDAR_SUBSCRIPTION_AUDIENCE, createdAtMs: 1_800_000_000_000, expiresAtMs: 1_800_086_400_000, @@ -200,9 +209,12 @@ async function expectDomainError(promise, code, status) { subscriptionId: created.subscriptionId, subjectId: 'user-1', projectId: 'project-1', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, audience: CALENDAR_SUBSCRIPTION_AUDIENCE, }); assert.deepEqual(h.usages, [{ subscription_id: created.subscriptionId, used_at_ms: 1_800_000_005_000 }]); + assert.equal(JSON.stringify(h.audits).includes(created.secret), false); + assert.equal(JSON.stringify(h.audits).includes(stored.secret_hash), false); h.setNow(1_800_000_010_000); const rotated = await h.service.rotate({ @@ -223,6 +235,10 @@ async function expectDomainError(promise, code, status) { ); const rotatedPrincipal = await h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }); assert.equal(rotatedPrincipal.subscriptionId, created.subscriptionId); + assert.equal(rotatedPrincipal.purpose, CALENDAR_SUBSCRIPTION_PURPOSE); + assert.equal(JSON.stringify(h.audits).includes(rotated.secret), false); + assert.equal(JSON.stringify(h.audits).includes(stored.secret_hash), false); + assert.equal(JSON.stringify(h.audits).includes(h.rows.get(created.subscriptionId).secret_hash), false); h.setNow(1_800_000_020_000); const revoked = await h.service.revoke({ @@ -243,6 +259,8 @@ async function expectDomainError(promise, code, status) { 'calendar_subscription_unauthorized', 401, ); + assert.equal(h.audits.filter((event) => event.event === 'calendar_subscription.revoked').length, 1); + assert.equal(JSON.stringify(h.audits).includes(rotated.secret), false); } { @@ -261,4 +279,125 @@ async function expectDomainError(promise, code, status) { assert.equal(revoked.status, 'revoked'); } +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Exact expiry', + expiresAtMs: 1_800_000_060_000, + }); + h.setNow(created.expiresAtMs); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const listed = await h.service.list({ subjectId: 'user-1', projectId: 'project-1' }); + assert.equal(listed[0].status, 'expired'); +} + +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Rejoin must rotate', + expiresAtMs: 1_800_086_400_000, + }); + h.activeMemberships.delete('user-1:project-1'); + h.activeMemberships.set('user-1:project-1', 4); + assert.equal(h.rows.get(created.subscriptionId).revoked_at_ms, null); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const rotated = await h.service.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + expiresAtMs: 1_800_172_800_000, + }); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); + const principal = await h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }); + assert.equal(principal.purpose, CALENDAR_SUBSCRIPTION_PURPOSE); + assert.equal(h.rows.get(created.subscriptionId).membership_version, 4); +} + +{ + const h = makeHarness(); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Rotate wins use', + expiresAtMs: 1_800_086_400_000, + }); + let releaseUsage; + const usageGate = new Promise((resolve) => { + releaseUsage = resolve; + }); + const originalUsage = h.repository.recordUsageAtomically.bind(h.repository); + h.repository.recordUsageAtomically = async (...args) => { + await usageGate; + return originalUsage(...args); + }; + const authorizePromise = h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + const rotated = await h.service.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: created.subscriptionId, + expiresAtMs: 1_800_172_800_000, + }); + releaseUsage(); + await expectDomainError(authorizePromise, 'calendar_subscription_unauthorized', 401); + const principal = await h.service.authorize({ secret: rotated.secret, projectId: 'project-1' }); + assert.equal(principal.subscriptionId, created.subscriptionId); +} + +{ + const h = makeHarness({ nowMs: Date.UTC(2028, 1, 28, 12, 0, 0) }); + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Leap-year feed', + expiresAtMs: Date.UTC(2028, 2, 1, 12, 0, 0), + }); + h.setNow(Date.UTC(2028, 1, 29, 12, 0, 0)); + const principal = await h.service.authorize({ secret: created.secret, projectId: 'project-1' }); + assert.equal(principal.purpose, CALENDAR_SUBSCRIPTION_PURPOSE); + h.setNow(created.expiresAtMs); + await expectDomainError( + h.service.authorize({ secret: created.secret, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); +} + +{ + const h = makeHarness(); + const maxExpiry = 1_800_000_000_000 + CALENDAR_SUBSCRIPTION_MAX_LIFETIME_MS; + const created = await h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Max lifetime', + expiresAtMs: maxExpiry, + }); + assert.equal(created.expiresAtMs, maxExpiry); + await expectDomainError( + h.service.create({ + subjectId: 'user-1', + projectId: 'project-1', + name: 'Too long', + expiresAtMs: maxExpiry + 1, + }), + 'calendar_subscription_expiry_invalid', + 400, + ); +} + console.log('calendar subscription domain behavior tests passed'); From 832ae12d7a92c386bf886b533f02a63ff7a7be22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:02:51 +0900 Subject: [PATCH 11/24] test(calendar): reject mismatched atomic return rows --- ...ndar-subscription-return-boundary.test.mjs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/unit/calendar-subscription-return-boundary.test.mjs diff --git a/tests/unit/calendar-subscription-return-boundary.test.mjs b/tests/unit/calendar-subscription-return-boundary.test.mjs new file mode 100644 index 00000000..04f3b7a7 --- /dev/null +++ b/tests/unit/calendar-subscription-return-boundary.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { + CALENDAR_SUBSCRIPTION_AUDIENCE, + CALENDAR_SUBSCRIPTION_PURPOSE, + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; + +const NOW = 1_900_000_000_000; +const VALID_SECRET = Buffer.alloc(32, 7).toString('base64url'); + +const baseRecord = { + subscription_id: 'csub_123', + subject_id: 'user-1', + project_id: 'project-1', + name: 'Calendar', + purpose: CALENDAR_SUBSCRIPTION_PURPOSE, + audience: CALENDAR_SUBSCRIPTION_AUDIENCE, + membership_version: 'membership-v1', + created_at_ms: NOW - 1, + expires_at_ms: NOW + 100, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, +}; + +function service(repositoryOverrides) { + return createCalendarSubscriptionService({ + repository: { + insertSubscription: async () => {}, + listSubscriptions: async () => [], + findSubscriptionByHash: async () => baseRecord, + recordUsageAtomically: async () => null, + rotateSubscriptionAtomically: async () => null, + revokeSubscriptionAtomically: async () => null, + ...repositoryOverrides, + }, + clock: { nowMs: () => NOW }, + randomSource: { randomBytes: (size) => new Uint8Array(size).fill(9) }, + auditSink: { record: async () => {} }, + projectAuthorization: { assertCanManage: async () => {} }, + membershipRevocation: { assertActive: async () => 'membership-v1' }, + }); +} + +async function expectDomainError(promise, code, status) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof CalendarSubscriptionError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +for (const returned of [ + { ...baseRecord, subscription_id: 'csub_other', last_used_at_ms: NOW }, + { ...baseRecord, subject_id: 'user-2', last_used_at_ms: NOW }, + { ...baseRecord, project_id: 'project-2', last_used_at_ms: NOW }, + { ...baseRecord, audience: 'scopeweave:other', last_used_at_ms: NOW }, + { ...baseRecord, membership_version: 'membership-v2', last_used_at_ms: NOW }, +]) { + const candidate = service({ recordUsageAtomically: async () => returned }); + await expectDomainError( + candidate.authorize({ secret: VALID_SECRET, projectId: 'project-1' }), + 'calendar_subscription_unauthorized', + 401, + ); +} + +for (const returned of [ + { ...baseRecord, subscription_id: 'csub_other', rotated_at_ms: NOW }, + { ...baseRecord, subject_id: 'user-2', rotated_at_ms: NOW }, + { ...baseRecord, project_id: 'project-2', rotated_at_ms: NOW }, + { ...baseRecord, audience: 'scopeweave:other', rotated_at_ms: NOW }, + { ...baseRecord, membership_version: 'membership-v2', rotated_at_ms: NOW }, +]) { + const candidate = service({ rotateSubscriptionAtomically: async () => returned }); + await expectDomainError( + candidate.rotate({ + subjectId: 'user-1', + projectId: 'project-1', + subscriptionId: 'csub_123', + expiresAtMs: NOW + 10, + }), + 'calendar_subscription_not_found', + 404, + ); +} + +console.log('calendar subscription atomic return boundary tests passed'); From e4277df35b98b627c8136cc0989774fd42a4b84f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:46:54 +0900 Subject: [PATCH 12/24] fix(calendar): validate atomic return identity --- server/calendar_subscription_domain.mjs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/server/calendar_subscription_domain.mjs b/server/calendar_subscription_domain.mjs index 21750739..d059f33f 100644 --- a/server/calendar_subscription_domain.mjs +++ b/server/calendar_subscription_domain.mjs @@ -333,7 +333,16 @@ export function createCalendarSubscriptionService({ audience: CALENDAR_SUBSCRIPTION_AUDIENCE, membership_version: issuedMembershipVersion, }); - if (!used || statusOf(used, nowMs) !== 'active' || used.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE) { + if ( + !used + || used.subscription_id !== existing.subscription_id + || used.subject_id !== existing.subject_id + || used.project_id !== existing.project_id + || used.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE + || used.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + || used.membership_version !== issuedMembershipVersion + || statusOf(used, nowMs) !== 'active' + ) { throw unauthorizedSubscription(); } await recordAuditBestEffort(auditSink, { @@ -375,7 +384,17 @@ export function createCalendarSubscriptionService({ purpose: CALENDAR_SUBSCRIPTION_PURPOSE, membership_version: membershipVersion, }); - if (!rotated || statusOf(rotated, nowMs) !== 'active' || rotated.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE) { + if ( + !rotated + || rotated.subscription_id !== normalizedSubscriptionId + || rotated.subject_id !== subjectId + || rotated.project_id !== projectId + || rotated.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE + || rotated.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + || rotated.membership_version !== membershipVersion + || rotated.expires_at_ms !== normalizedExpiry + || statusOf(rotated, nowMs) !== 'active' + ) { throw notFoundSubscription(); } await recordAuditBestEffort(auditSink, { From d0fe346648ae24fc5c1939f76c733ded067a3f51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:48:32 +0900 Subject: [PATCH 13/24] test(calendar): isolate atomic return invariants --- ...ndar-subscription-return-boundary.test.mjs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/unit/calendar-subscription-return-boundary.test.mjs b/tests/unit/calendar-subscription-return-boundary.test.mjs index 04f3b7a7..9799f694 100644 --- a/tests/unit/calendar-subscription-return-boundary.test.mjs +++ b/tests/unit/calendar-subscription-return-boundary.test.mjs @@ -7,6 +7,7 @@ import { } from '../../server/calendar_subscription_domain.mjs'; const NOW = 1_900_000_000_000; +const ROTATE_EXPIRY = NOW + 10; const VALID_SECRET = Buffer.alloc(32, 7).toString('base64url'); const baseRecord = { @@ -56,6 +57,7 @@ for (const returned of [ { ...baseRecord, subscription_id: 'csub_other', last_used_at_ms: NOW }, { ...baseRecord, subject_id: 'user-2', last_used_at_ms: NOW }, { ...baseRecord, project_id: 'project-2', last_used_at_ms: NOW }, + { ...baseRecord, purpose: 'session', last_used_at_ms: NOW }, { ...baseRecord, audience: 'scopeweave:other', last_used_at_ms: NOW }, { ...baseRecord, membership_version: 'membership-v2', last_used_at_ms: NOW }, ]) { @@ -67,12 +69,20 @@ for (const returned of [ ); } +const rotatedRecord = { + ...baseRecord, + expires_at_ms: ROTATE_EXPIRY, + rotated_at_ms: NOW, +}; + for (const returned of [ - { ...baseRecord, subscription_id: 'csub_other', rotated_at_ms: NOW }, - { ...baseRecord, subject_id: 'user-2', rotated_at_ms: NOW }, - { ...baseRecord, project_id: 'project-2', rotated_at_ms: NOW }, - { ...baseRecord, audience: 'scopeweave:other', rotated_at_ms: NOW }, - { ...baseRecord, membership_version: 'membership-v2', rotated_at_ms: NOW }, + { ...rotatedRecord, subscription_id: 'csub_other' }, + { ...rotatedRecord, subject_id: 'user-2' }, + { ...rotatedRecord, project_id: 'project-2' }, + { ...rotatedRecord, purpose: 'session' }, + { ...rotatedRecord, audience: 'scopeweave:other' }, + { ...rotatedRecord, membership_version: 'membership-v2' }, + { ...rotatedRecord, expires_at_ms: ROTATE_EXPIRY + 1 }, ]) { const candidate = service({ rotateSubscriptionAtomically: async () => returned }); await expectDomainError( @@ -80,7 +90,7 @@ for (const returned of [ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_123', - expiresAtMs: NOW + 10, + expiresAtMs: ROTATE_EXPIRY, }), 'calendar_subscription_not_found', 404, From 036ff73be3974bcc6d02d8d2cd90e5919e9c7ff0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 18:59:38 +0000 Subject: [PATCH 14/24] test(calendar): restore atomic-return coverage registration Rebase onto current #506 dropped the return-boundary entries from package.json and the coverage-script contract. Restore them so the isolated atomic-return regression stays in test:unit and c8. Co-authored-by: Seongho Bae --- package.json | 4 ++-- tests/unit/coverage-script-contract.test.mjs | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4acba389..bb3cb416 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/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/coverage-script-contract.test.mjs && 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/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-return-boundary.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/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/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-return-boundary.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/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", diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 9ebfdb83..46ab903e 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -64,6 +64,11 @@ assert.match( /tests\/unit\/calendar-subscription-domain-edge\.test\.mjs/, 'the calendar-subscription failure boundaries execute under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-return-boundary\.test\.mjs/, + 'the calendar-subscription atomic-return trust-boundary regression executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, From a25140576749419e2bdc89855e272d75ca2b5960 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 05:15:59 -0700 Subject: [PATCH 15/24] fix(stack): preserve protected parent semantics in calendar domain --- CHANGELOG.md | 14 +-- .../contextual-orchestrator-auto-default.md | 41 ++++++ docs/orchestrator-production.md | 22 ++++ package-lock.json | 8 +- package.json | 8 +- server/app.mjs | 5 +- server/orchestrator.mjs | 71 ++++++++++- tests/api/orchestrator-attribution.test.mjs | 89 +++++++++++++ tests/unit/orchestrator-attribution.test.mjs | 117 ++++++++++++++++++ tests/unit/orchestrator.test.mjs | 1 + 10 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 docs/doctoring/contextual-orchestrator-auto-default.md create mode 100644 tests/api/orchestrator-attribution.test.mjs create mode 100644 tests/unit/orchestrator-attribution.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be5bc6b..d79cba93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,15 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 membership-revocation, random-source, clock, audit, and atomic repository ports. Route, database, calendar-subscription, and client migration remain follow-up work under issue #413. -- Added a separate framework-neutral calendar-subscription credential domain - with one-time secret display on create/rotate, hash-only persistence ports, - project-scoped management, reusable `calendar_read` authorization, issuance - membership-epoch binding so remove-then-rejoin cannot revive a secret, - a 366-day create/rotate lifetime cap, exact-expiry rejection, safe lifecycle - metadata, rotation, revocation, and usage evidence. Runtime route, database - adapter/migration, and UI implementation remain follow-up work under issue - #413; the required calendar-management interaction contract is captured in - Figma and traced in the doctoring record. ### Security @@ -75,6 +66,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Switched the repository-local OpenCode development configuration from GitHub Models to an NVIDIA NIM-only candidate set while preserving organization-level review-workflow ownership in `ContextualWisdomLab/.github`. +- Production planning-analysis requests now combine tenant-bound, server-derived + contextual-orchestrator cost attribution with explicit `auto` orchestration + mode, delegating provider/model/topology policy to the shared service without + weakening ScopeWeave's authenticated, fail-closed transport or response + boundary controls. - Accepted XML whitespace before exact Microsoft Project element delimiters while preserving the linear, regex-free import scanner and rejecting attributes, longer names, non-XML whitespace, nested unmatched blocks, and diff --git a/docs/doctoring/contextual-orchestrator-auto-default.md b/docs/doctoring/contextual-orchestrator-auto-default.md new file mode 100644 index 00000000..c3d5d2f5 --- /dev/null +++ b/docs/doctoring/contextual-orchestrator-auto-default.md @@ -0,0 +1,41 @@ +# Contextual-orchestrator adaptive planning default + +## Status + +Active pull-request evidence. This record does not describe protected `develop` until the owning pull request is integrated. + +## Decision boundary + +ScopeWeave owns the meaning, authorization, cost attribution, and presentation of a planning-analysis request. The shared `contextual-orchestrator` service owns provider/model selection and the depth/topology of execution. Production ScopeWeave requests therefore send `orchestration_mode: "auto"` explicitly instead of relying on an implicit gateway default or selecting `route`/`conduct` locally. + +The binding dependency evidence verified for this slice is protected `ContextualWisdomLab/contextual-orchestrator` `main` commit `6841b71935e0b7cb98fb52bcb4709cc5100c8d87`. At that revision, `/v1/chat/completions` accepts `orchestration_mode`, permits `auto`, `route`, and `conduct`, accepts bounded attribution metadata, and routes execution through the orchestrator rather than treating the request model label as a provider lock. + +This decision does **not** promise a specific provider, model, worker count, topology, verifier strategy, or cost heuristic. Those remain shared-service policy and may evolve behind its versioned contract. + +## Attribution and tenant authority + +Authenticated project AI briefings attach `service=scopeweave` and the project organization as `account` only after membership-scoped project authorization. Browser request fields cannot select another tenant's accounting identity. The client forwards only supported attribution dimensions, accepts bounded strings or finite numeric identifiers, uses a prototype-free validated map, and omits empty attribution. These labels are accounting metadata and never grant execution-provider or model-selection authority. + +## Security and standalone behavior + +The change preserves the protected ScopeWeave orchestrator boundary: authenticated canonical provider origin, HTTPS outside explicit loopback development, bounded messages, 120-second request timeout, bounded streamed provider responses, sanitized failures, and deterministic text only under explicit `SCOPEWEAVE_DEV=1` development mode. No provider credential or caller-controlled execution policy is added. + +## TDD and overlap-convergence evidence + +The adaptive-mode work originally existed separately in PR #529 while cost attribution occupied the same production request-body boundary in PR #496. Keeping both as independent roots created a concrete future regression risk: whichever branch integrated second could erase the other request field. The older attribution owner was therefore made the canonical combined boundary rather than allowing two competing implementations. + +On the canonical branch, test-only commits `dc71cdff9dc258b8f196c35d9b92c1542e869043` and `5510058ae7437ede44fb7a7fd94351ac7f7d6b14` first require `orchestration_mode: "auto"` both on ordinary hardened requests and while tenant-bound attribution is present or omitted. Source commit `bd8878591bfa74b67ae2a36b122513d2c41e376f` then composes adaptive routing with the existing sanitized attribution request. Exact-current-head hosted evidence remains authoritative; predecessor checks are not reused. + +## Rollback + +Rollback of adaptive mode removes the explicit `orchestration_mode` field and its matching regression/documentation while preserving the tenant-bound attribution and hardened transport. Rollback of attribution separately removes only the attribution call-site, sanitizer, and attribution regressions. Neither rollback may restore stale pre-hardening orchestrator source or a self-modifying workflow. + +## APA 7th references + +Contextual Wisdom Lab. (2026). *contextual-orchestrator* (Commit 6841b71935e0b7cb98fb52bcb4709cc5100c8d87) [Computer software]. GitHub. + +Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor*. arXiv. https://doi.org/10.48550/arXiv.2512.04388 + +Sakana AI. (2026). *Sakana Fugu: Multi-agent system as a model*. https://sakana.ai/fugu/ + +Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator*. arXiv. https://doi.org/10.48550/arXiv.2512.04695 diff --git a/docs/orchestrator-production.md b/docs/orchestrator-production.md index c2c4c5c7..e090a17b 100644 --- a/docs/orchestrator-production.md +++ b/docs/orchestrator-production.md @@ -38,6 +38,28 @@ endpoint is absent. That variable must never be set in staging or production. ## Orchestration responsibility +ScopeWeave explicitly sends `orchestration_mode: "auto"` together with the +configured model and validated messages on production briefing requests. The +current protected `ContextualWisdomLab/contextual-orchestrator` `main` contract +verified for this change, commit +`6841b71935e0b7cb98fb52bcb4709cc5100c8d87`, accepts `auto`, `route`, and +`conduct` as orchestration modes. ScopeWeave chooses `auto` as its default so +execution policy can be optimized centrally without coupling this product to a +specific provider, worker count, topology, verifier pattern, or cost heuristic. +Those internal choices remain `contextual-orchestrator` authority and are not a +ScopeWeave compatibility promise. + +For authenticated project AI briefings, ScopeWeave also sends bounded business +cost attribution derived from server-side project state. `service=scopeweave` +and the authenticated project organization `account` are attached only after +membership-scoped project access succeeds. Caller payload fields cannot choose +another tenant's attribution. The client forwards only the orchestration +service's supported attribution dimensions, accepts only bounded string or +finite numeric values, holds validated labels in a prototype-free map, and +omits the attribution object entirely when no valid labels remain. Attribution +is accounting metadata only: it cannot select an execution provider, model, or +orchestration topology. + ScopeWeave intentionally sends only a versioned OpenAI-compatible request to the orchestration service. Model selection, single-model versus multi-agent allocation, task decomposition, role-specific reasoning effort, recursion diff --git a/package-lock.json b/package-lock.json index 859ec2a6..da668c20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "scopeweave", "version": "1.0.0", "dependencies": { - "@hono/node-server": "^2.0.12", + "@hono/node-server": "^2.1.1", "hono": "^4.13.0" }, "devDependencies": { @@ -31,9 +31,9 @@ } }, "node_modules/@hono/node-server": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", - "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index bb3cb416..570f2397 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "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/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-return-boundary.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-return-boundary.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-return-boundary.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/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/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-return-boundary.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", @@ -23,7 +23,7 @@ "fuzz": "node --test tests/fuzz/*.mjs" }, "dependencies": { - "@hono/node-server": "^2.0.12", + "@hono/node-server": "^2.1.1", "hono": "^4.13.0" }, "devDependencies": { diff --git a/server/app.mjs b/server/app.mjs index 03908830..c432a84f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -995,7 +995,10 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { const analysis = await orchestratorChat([ { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, { role: 'user', content: context }, - ]); + ], { + service: 'scopeweave', + account: String(p.org_id), + }); logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); return c.json({ analysis }); } catch (e) { diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index b3e8e400..fccf23d0 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -9,6 +9,17 @@ const MAX_CONTENT_LENGTH = 100_000; const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024; // WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`). const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +const MAX_ATTRIBUTION_VALUE_LENGTH = 256; +const ATTRIBUTION_DIMENSIONS = new Set([ + 'account', + 'service', + 'upstream_api', + 'model_name', + 'team', + 'group', + 'company', + 'provider', +]); export const orchestratorMock = process.env.SCOPEWEAVE_DEV === '1' && !OC_URL; @@ -137,6 +148,55 @@ function validatedMessages(messages) { }); } +/** + * Copy optional cost-attribution labels into the exact orchestrator allowlist. + * + * Unknown dimensions and empty values are omitted rather than forwarded to the + * strict contextual-orchestrator request validator. Values must be strings or + * finite numeric identifiers before normalization to bounded strings; complex + * objects and non-finite numbers fail closed instead of becoming misleading + * labels through implicit JavaScript string coercion. Execution model/provider + * identity remains controlled by the top-level request model and the + * orchestrator's own provider routing evidence; this object is business + * cost-allocation metadata only. + * + * @param {unknown} attribution optional business cost-attribution mapping + * @returns {Record|undefined} bounded allowed labels or undefined + */ +function sanitizedAttribution(attribution) { + if (attribution === undefined || attribution === null) return undefined; + if (typeof attribution !== 'object' || Array.isArray(attribution)) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution must be an object when provided.', + ); + } + + const safe = Object.create(null); + for (const [key, value] of Object.entries(attribution)) { + if (!ATTRIBUTION_DIMENSIONS.has(key) || value === undefined || value === null) continue; + if ( + typeof value !== 'string' + && (typeof value !== 'number' || !Number.isFinite(value)) + ) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution values must be strings or finite numbers.', + ); + } + const text = String(value).trim(); + if (!text) continue; + if (text.length > MAX_ATTRIBUTION_VALUE_LENGTH) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution value is outside the accepted boundary.', + ); + } + safe[key] = text; + } + return Object.keys(safe).length ? safe : undefined; +} + /** * Build the stable response-size failure used by declared and streamed limits. * @returns {OrchestratorConfigurationError} Operator-safe size error. @@ -275,11 +335,13 @@ async function rejectProviderResponse(response) { /** * Generate one AI briefing through contextual-orchestrator. * @param {unknown} messages OpenAI-compatible messages + * @param {unknown} [attribution] optional bounded business cost-attribution labels * @returns {Promise} */ -export async function chat(messages) { +export async function chat(messages, attribution) { const configuration = orchestratorConfiguration(); const safeMessages = validatedMessages(messages); + const safeAttribution = sanitizedAttribution(attribution); if (configuration.mock) { const user = safeMessages .filter((message) => message.role === 'user') @@ -303,7 +365,12 @@ export async function chat(messages) { 'content-type': 'application/json', authorization: `Bearer ${configuration.token}`, }, - body: JSON.stringify({ model: OC_MODEL, messages: safeMessages }), + body: JSON.stringify({ + model: OC_MODEL, + orchestration_mode: 'auto', + messages: safeMessages, + ...(safeAttribution ? { attribution: safeAttribution } : {}), + }), signal: AbortSignal.timeout(ORCHESTRATOR_TIMEOUT_MS), }); } catch { diff --git a/tests/api/orchestrator-attribution.test.mjs b/tests/api/orchestrator-attribution.test.mjs new file mode 100644 index 00000000..d07460a3 --- /dev/null +++ b/tests/api/orchestrator-attribution.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.SCOPEWEAVE_DEV; +process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; +process.env.ORCHESTRATOR_TOKEN = 'secret-token'; +process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; + +const providerCalls = []; +globalThis.fetch = async (url, init) => { + providerCalls.push({ url: String(url), init }); + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Grounded production response' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const { app } = await import(`../../server/app.mjs?attribution-api-test=${Date.now()}`); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const jsonBody = (value) => JSON.stringify(value); + +async function createAccount(email) { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email, password: 'password123', name: email }), + }); + assert.equal(response.status, 200, `${email} signup`); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + response = await jsonRequest('/api/me', { headers: auth }); + assert.equal(response.status, 200, `${email} account lookup`); + const account = await response.json(); + return { auth, orgId: account.orgs[0].id }; +} + +const owner = await createAccount('orchestrator-owner@scopeweave.test'); +const outsider = await createAccount('orchestrator-outsider@scopeweave.test'); + +let response = await jsonRequest('/api/projects', { + method: 'POST', + headers: owner.auth, + body: jsonBody({ name: 'Attribution Project' }), +}); +assert.equal(response.status, 200, 'owner creates attribution project'); +const projectId = (await response.json()).id; + +response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', + headers: owner.auth, + body: jsonBody({ account: String(outsider.orgId), service: 'spoofed-client-service' }), +}); +assert.equal(response.status, 200, 'authorized owner receives AI briefing'); +assert.equal(providerCalls.length, 1, 'authorized briefing performs one provider call'); +assert.equal(providerCalls[0].url, 'https://orchestrator.example/v1/chat/completions'); +const providerBody = JSON.parse(providerCalls[0].init.body); +assert.deepEqual( + providerBody.attribution, + { service: 'scopeweave', account: String(owner.orgId) }, + 'the authenticated server-side project organization owns cost attribution', +); +assert.notEqual( + providerBody.attribution.account, + String(outsider.orgId), + 'browser-supplied account data cannot spoof another tenant attribution', +); + +response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', + headers: outsider.auth, + body: jsonBody({ account: String(owner.orgId) }), +}); +assert.equal(response.status, 404, 'cross-tenant AI briefing hides project existence'); +assert.equal( + providerCalls.length, + 1, + 'cross-tenant requests are rejected before any contextual-orchestrator call', +); + +console.log('✓ AI briefing attribution tenant-boundary tests passed'); diff --git a/tests/unit/orchestrator-attribution.test.mjs b/tests/unit/orchestrator-attribution.test.mjs new file mode 100644 index 00000000..45934f6e --- /dev/null +++ b/tests/unit/orchestrator-attribution.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DEV = ''; +process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; +process.env.ORCHESTRATOR_TOKEN = 'secret-token'; +process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; + +const calls = []; +globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Grounded production response' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const { chat } = await import( + `../../server/orchestrator.mjs?attribution-test=${Date.now()}-${Math.random()}` +); + +const messages = [{ role: 'user', content: 'status' }]; + +assert.equal( + await chat(messages, { + service: 'scopeweave', + account: 42, + upstream_api: 'requested-upstream-label', + provider: 'requested-provider-label', + model_name: 'requested-model-label', + team: null, + group: '', + company: ' ', + unsupported_dimension: 'must-not-cross-boundary', + }), + 'Grounded production response', +); + +assert.equal(calls.length, 1); +const attributedBody = JSON.parse(calls[0].init.body); +assert.equal(attributedBody.model, 'nvidia/nemotron-3-super-120b-a12b'); +assert.equal(attributedBody.orchestration_mode, 'auto'); +assert.equal(Object.hasOwn(attributedBody, 'provider'), false); +assert.deepEqual(attributedBody.attribution, { + service: 'scopeweave', + account: '42', + upstream_api: 'requested-upstream-label', + provider: 'requested-provider-label', + model_name: 'requested-model-label', +}); +assert.equal( + Object.hasOwn(attributedBody.attribution, 'unsupported_dimension'), + false, + 'unknown attribution keys never cross the ScopeWeave boundary', +); + +await chat(messages, { unsupported_dimension: 'x', account: ' ' }); +const emptyBody = JSON.parse(calls[1].init.body); +assert.equal(emptyBody.orchestration_mode, 'auto'); +assert.equal( + Object.hasOwn(emptyBody, 'attribution'), + false, + 'an attribution field is omitted when no non-empty allowed dimensions remain', +); + +await chat(messages); +const legacyBody = JSON.parse(calls[2].init.body); +assert.deepEqual( + legacyBody, + { + model: 'nvidia/nemotron-3-super-120b-a12b', + orchestration_mode: 'auto', + messages, + }, + 'omitting attribution preserves the hardened adaptive request shape exactly', +); + +for (const invalidAttribution of [ + [], + 'scopeweave', + { service: 'x'.repeat(257) }, + { service: ['scopeweave'] }, + { account: { organization_id: 42 } }, + { team: Symbol('scopeweave') }, + { group: Number.NaN }, + { company: Number.POSITIVE_INFINITY }, +]) { + await assert.rejects( + chat(messages, invalidAttribution), + (error) => error.code === 'orchestrator_attribution_invalid', + 'malformed, non-scalar, non-finite, or unbounded attribution fails before provider transport', + ); +} +assert.equal(calls.length, 3, 'invalid attribution never reaches the provider'); + +const originalJsonStringify = JSON.stringify; +let serializedAttributionPrototype; +JSON.stringify = (value, ...args) => { + if (value?.attribution) { + serializedAttributionPrototype = Object.getPrototypeOf(value.attribution); + } + return originalJsonStringify(value, ...args); +}; +try { + await chat(messages, { service: 'scopeweave' }); +} finally { + JSON.stringify = originalJsonStringify; +} +assert.equal( + serializedAttributionPrototype, + null, + 'validated attribution is held in a prototype-free map before provider serialization', +); +assert.equal(calls.length, 4, 'prototype-free attribution still reaches the provider once'); + +console.log('✓ orchestrator attribution boundary tests passed'); \ No newline at end of file diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index 14de7136..87cfb647 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -98,6 +98,7 @@ try { assert.ok(calls[0].init.signal instanceof AbortSignal); assert.deepEqual(JSON.parse(calls[0].init.body), { model: 'nvidia/nemotron-3-super-120b-a12b', + orchestration_mode: 'auto', messages: [{ role: 'user', content: 'status' }], }); From 7c7632c75aa1da73b5ce116fe250459967f55d2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:58:06 -0700 Subject: [PATCH 16/24] docs(calendar): restore active domain changelog entry --- CHANGELOG.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d79cba93..dbc1d05d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 membership-revocation, random-source, clock, audit, and atomic repository ports. Route, database, calendar-subscription, and client migration remain follow-up work under issue #413. +- Added a separate framework-neutral calendar-subscription credential domain + with one-time secret display on create/rotate, hash-only persistence ports, + project-scoped management, reusable `calendar_read` authorization, issuance + membership-epoch binding so remove-then-rejoin cannot revive a secret, + a 366-day create/rotate lifetime cap, exact-expiry rejection, safe lifecycle + metadata, rotation, revocation, and usage evidence. Runtime route, database + adapter/migration, and UI implementation remain follow-up work under issue + #413; the required calendar-management interaction contract is captured in + Figma and traced in the doctoring record. ### Security @@ -112,8 +121,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `wbs.json` seed loading plus browser autosave and optional file sync. - Playwright E2E coverage for add/edit hierarchy flows, delete confirmation, subtree drag-and-drop, and JSON sync shape. -- GitHub Pages deployment workflow and operator documentation. +- `wbs.json` seed loading plus browser autosave and optional file sync. ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file From c41e8518fce2189cec68490c9abdf20b4c54a16f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:58:46 -0700 Subject: [PATCH 17/24] fix(docs): preserve release changelog history --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc1d05d..395ef325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,7 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `wbs.json` seed loading plus browser autosave and optional file sync. - Playwright E2E coverage for add/edit hierarchy flows, delete confirmation, subtree drag-and-drop, and JSON sync shape. -- `wbs.json` seed loading plus browser autosave and optional file sync. +- GitHub Pages deployment workflow and operator documentation. ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) From 75421f043895d0bfdcb10d0ec632d079bbdd5baf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:55:30 -0700 Subject: [PATCH 18/24] fix(stack): preserve current access-grant parent semantics --- .../short-lived-access-grant-domain.md | 14 ++- package-lock.json | 30 +++--- package.json | 2 +- server/access_grant_domain.mjs | 93 +++++++++++++++---- tests/unit/access-grant-domain-edge.test.mjs | 83 ++++++++++++++++- 5 files changed, 188 insertions(+), 34 deletions(-) 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; 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": { diff --git a/package.json b/package.json index 570f2397..bd0967ff 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,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" } diff --git a/server/access_grant_domain.mjs b/server/access_grant_domain.mjs index 0e3feebd..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,53 @@ function unauthorizedGrant() { return new AccessGrantError('access_grant_unauthorized', 401); } +function snapshotUnconsumedGrant(existing) { + if ( + !existing + || typeof existing !== 'object' + || Array.isArray(existing) + || existing.used_at_ms !== null + ) { + throw unauthorizedGrant(); + } + return Object.freeze({ + grant_id: existing.grant_id, + subject_id: existing.subject_id, + project_id: existing.project_id, + purpose: existing.purpose, + audience: existing.audience, + attachment_id: existing.attachment_id ?? null, + }); +} + +function validateConsumedGrant(existing, consumed, { + purpose, + audience, + projectId, + attachmentId, + nowMs, +}) { + if ( + !consumed + || typeof consumed !== 'object' + || Array.isArray(consumed) + || consumed.used_at_ms !== nowMs + || consumed.grant_id !== existing.grant_id + || consumed.subject_id !== existing.subject_id + || consumed.project_id !== existing.project_id + || consumed.project_id !== projectId + || consumed.purpose !== existing.purpose + || consumed.purpose !== purpose + || consumed.audience !== existing.audience + || consumed.audience !== audience + || (consumed.attachment_id ?? null) !== existing.attachment_id + || (consumed.attachment_id ?? null) !== (attachmentId ?? null) + ) { + throw unauthorizedGrant(); + } + return consumed; +} + function normalizeMembershipVersion(value) { if (Number.isSafeInteger(value) && value >= 0) return value; if ( @@ -160,7 +207,11 @@ 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: 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. @@ -262,25 +313,35 @@ 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 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 nowMs = readNow(clock); + const consumed = validateConsumedGrant( + existingSnapshot, + await repository.consumeGrantAtomically(tokenHash, { + now_ms: nowMs, + purpose, + audience, + project_id: projectId, + attachment_id: normalizedAttachmentId, + membership_version: membershipVersion, + }), + { + purpose, + audience, + projectId, + attachmentId: normalizedAttachmentId, + nowMs, + }, + ); await recordAuditBestEffort(auditSink, { event: 'access_grant.consumed', grant_id: consumed.grant_id, @@ -301,4 +362,4 @@ export function createAccessGrantService({ } return Object.freeze({ mint, redeem }); -} +} \ No newline at end of file diff --git a/tests/unit/access-grant-domain-edge.test.mjs b/tests/unit/access-grant-domain-edge.test.mjs index acdb4d73..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, @@ -200,4 +211,74 @@ 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(); + const consume = repository.consumeGrantAtomically.bind(repository); + repository.consumeGrantAtomically = async (...args) => { + const consumed = await consume(...args); + return consumed ? { ...consumed, subject_id: 'foreign-subject', project_id: 'foreign-project' } : null; + }; + const service = createAccessGrantService({ ...validPorts(), repository }); + const grant = await service.mint({ + subjectId: 'return-boundary-user', + projectId: 'return-boundary-project', + purpose: 'stream', + audience: 'scopeweave:stream', + ttlSeconds: 10, + }); + await assert.rejects(service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'return-boundary-project', + }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); +} + +{ + const repository = new ConsumableRepository(); + repository.consumeGrantAtomically = async (hash, expected) => { + const record = repository.records.get(hash); + if (!record) return null; + record.used_at_ms = expected.now_ms; + record.subject_id = 'mutated-subject'; + return structuredClone(record); + }; + const service = createAccessGrantService({ ...validPorts(), repository }); + const grant = await service.mint({ + subjectId: 'alias-boundary-user', + projectId: 'alias-boundary-project', + purpose: 'stream', + audience: 'scopeweave:stream', + ttlSeconds: 10, + }); + await assert.rejects(service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'alias-boundary-project', + }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); +} + +{ + const repository = new ConsumableRepository(); + repository.consumeGrantAtomically = async (hash) => { + const record = repository.records.get(hash); + return record ? structuredClone(record) : null; + }; + const service = createAccessGrantService({ ...validPorts(), repository }); + const grant = await service.mint({ + subjectId: 'uncommitted-return-user', + projectId: 'uncommitted-return-project', + purpose: 'stream', + audience: 'scopeweave:stream', + ttlSeconds: 10, + }); + await assert.rejects(service.redeem({ + secret: grant.secret, + purpose: 'stream', + audience: 'scopeweave:stream', + projectId: 'uncommitted-return-project', + }), (error) => error.code === 'access_grant_unauthorized' && error.status === 401); +} + +console.log('✓ access-grant domain edge coverage passed'); \ No newline at end of file From 857fa5b18db77cbb0031d6178aaec2df13ee6d87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:05:14 -0700 Subject: [PATCH 19/24] test(calendar): fail closed on invalid list rows --- .../calendar-subscription-return-boundary.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/calendar-subscription-return-boundary.test.mjs b/tests/unit/calendar-subscription-return-boundary.test.mjs index 9799f694..5e0ba3e4 100644 --- a/tests/unit/calendar-subscription-return-boundary.test.mjs +++ b/tests/unit/calendar-subscription-return-boundary.test.mjs @@ -97,4 +97,18 @@ for (const returned of [ ); } +for (const returned of [ + { ...baseRecord, subject_id: 'user-2' }, + { ...baseRecord, project_id: 'project-2' }, + { ...baseRecord, purpose: 'session' }, + { ...baseRecord, audience: 'scopeweave:other' }, +]) { + const candidate = service({ listSubscriptions: async () => [returned] }); + await expectDomainError( + candidate.list({ subjectId: 'user-1', projectId: 'project-1' }), + 'calendar_subscription_not_found', + 404, + ); +} + console.log('calendar subscription atomic return boundary tests passed'); From ab03f2a27da99b175b1d00027f1bc4c27c6b5a0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:08:58 -0700 Subject: [PATCH 20/24] fix(calendar): validate listed subscription boundaries --- server/calendar_subscription_domain.mjs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/server/calendar_subscription_domain.mjs b/server/calendar_subscription_domain.mjs index d059f33f..6c54d36c 100644 --- a/server/calendar_subscription_domain.mjs +++ b/server/calendar_subscription_domain.mjs @@ -151,6 +151,21 @@ function viewOf(record, nowMs) { }); } +function validateListedSubscription(record, { subjectId, projectId }) { + if ( + !record + || typeof record !== 'object' + || Array.isArray(record) + || record.subject_id !== subjectId + || record.project_id !== projectId + || record.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE + || record.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + ) { + throw notFoundSubscription(); + } + return record; +} + async function recordAuditBestEffort(auditSink, event) { try { await auditSink.record(event); @@ -290,7 +305,10 @@ export function createCalendarSubscriptionService({ const nowMs = readNow(clock); const records = await repository.listSubscriptions({ subject_id: subjectId, project_id: projectId }); if (!Array.isArray(records)) throw new TypeError('calendar-subscription repository must return an array from listSubscriptions()'); - return Object.freeze(records.map((record) => viewOf(record, nowMs))); + return Object.freeze(records.map((record) => viewOf( + validateListedSubscription(record, { subjectId, projectId }), + nowMs, + ))); } /** From b3d776e9dccf6f79a22aa5084185f2c353c6ba57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:11:29 -0700 Subject: [PATCH 21/24] fix(calendar): preserve legacy list purpose compatibility --- server/calendar_subscription_domain.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/calendar_subscription_domain.mjs b/server/calendar_subscription_domain.mjs index 6c54d36c..e0f4e35c 100644 --- a/server/calendar_subscription_domain.mjs +++ b/server/calendar_subscription_domain.mjs @@ -158,7 +158,7 @@ function validateListedSubscription(record, { subjectId, projectId }) { || Array.isArray(record) || record.subject_id !== subjectId || record.project_id !== projectId - || record.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE + || (record.purpose ?? CALENDAR_SUBSCRIPTION_PURPOSE) !== CALENDAR_SUBSCRIPTION_PURPOSE || record.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE ) { throw notFoundSubscription(); From 4c948016d4abeee7823783a46615d200f3291c02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 13:13:03 -0700 Subject: [PATCH 22/24] test(calendar): cover malformed listed rows --- tests/unit/calendar-subscription-return-boundary.test.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/calendar-subscription-return-boundary.test.mjs b/tests/unit/calendar-subscription-return-boundary.test.mjs index 5e0ba3e4..3612e54f 100644 --- a/tests/unit/calendar-subscription-return-boundary.test.mjs +++ b/tests/unit/calendar-subscription-return-boundary.test.mjs @@ -98,6 +98,9 @@ for (const returned of [ } for (const returned of [ + null, + 'not-a-record', + [], { ...baseRecord, subject_id: 'user-2' }, { ...baseRecord, project_id: 'project-2' }, { ...baseRecord, purpose: 'session' }, From 6875f5a7882e16223071c145091999c89fe42f29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:24:06 -0700 Subject: [PATCH 23/24] test(calendar): reject untrusted revoke return rows --- ...ndar-subscription-return-boundary.test.mjs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/tests/unit/calendar-subscription-return-boundary.test.mjs b/tests/unit/calendar-subscription-return-boundary.test.mjs index 3612e54f..3bfd0122 100644 --- a/tests/unit/calendar-subscription-return-boundary.test.mjs +++ b/tests/unit/calendar-subscription-return-boundary.test.mjs @@ -25,7 +25,7 @@ const baseRecord = { revoked_at_ms: null, }; -function service(repositoryOverrides) { +function service(repositoryOverrides, auditSink = { record: async () => {} }) { return createCalendarSubscriptionService({ repository: { insertSubscription: async () => {}, @@ -38,7 +38,7 @@ function service(repositoryOverrides) { }, clock: { nowMs: () => NOW }, randomSource: { randomBytes: (size) => new Uint8Array(size).fill(9) }, - auditSink: { record: async () => {} }, + auditSink, projectAuthorization: { assertCanManage: async () => {} }, membershipRevocation: { assertActive: async () => 'membership-v1' }, }); @@ -114,4 +114,34 @@ for (const returned of [ ); } +const revokedRecord = { + ...baseRecord, + revoked_at_ms: NOW, + revocation_applied: true, +}; + +for (const returned of [ + 'not-a-record', + [], + { ...revokedRecord, subscription_id: 'csub_other' }, + { ...revokedRecord, subject_id: 'user-2' }, + { ...revokedRecord, project_id: 'project-2' }, + { ...revokedRecord, purpose: 'session' }, + { ...revokedRecord, audience: 'scopeweave:other' }, + { ...baseRecord, revocation_applied: true }, + { ...revokedRecord, revocation_applied: 'yes' }, +]) { + const audits = []; + const candidate = service( + { revokeSubscriptionAtomically: async () => returned }, + { record: async (event) => { audits.push(event); } }, + ); + await expectDomainError( + candidate.revoke({ subjectId: 'user-1', projectId: 'project-1', subscriptionId: 'csub_123' }), + 'calendar_subscription_not_found', + 404, + ); + assert.deepEqual(audits, [], 'untrusted revoke rows must not produce audit evidence'); +} + console.log('calendar subscription atomic return boundary tests passed'); From e2f560078d3e2862c5dc6c395c159d12dfe1e6fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:28:33 -0700 Subject: [PATCH 24/24] fix(calendar): validate atomic revoke return boundary --- server/calendar_subscription_domain.mjs | 39 +++++++++++++++++++++---- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/server/calendar_subscription_domain.mjs b/server/calendar_subscription_domain.mjs index e0f4e35c..0f94a5ee 100644 --- a/server/calendar_subscription_domain.mjs +++ b/server/calendar_subscription_domain.mjs @@ -166,6 +166,26 @@ function validateListedSubscription(record, { subjectId, projectId }) { return record; } +function validateRevokedSubscription(record, { + subjectId, projectId, subscriptionId, nowMs, +}) { + if ( + !record + || typeof record !== 'object' + || Array.isArray(record) + || record.subscription_id !== subscriptionId + || record.subject_id !== subjectId + || record.project_id !== projectId + || (record.purpose ?? CALENDAR_SUBSCRIPTION_PURPOSE) !== CALENDAR_SUBSCRIPTION_PURPOSE + || record.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE + || (record.revocation_applied !== true && record.revocation_applied !== false) + || statusOf(record, nowMs) !== 'revoked' + ) { + throw notFoundSubscription(); + } + return record; +} + async function recordAuditBestEffort(auditSink, event) { try { await auditSink.record(event); @@ -437,12 +457,19 @@ export function createCalendarSubscriptionService({ const normalizedSubscriptionId = normalizeSubscriptionId(subscriptionId); await assertManage(projectAuthorization, subjectId, projectId); const nowMs = readNow(clock); - const revoked = await repository.revokeSubscriptionAtomically(normalizedSubscriptionId, { - subject_id: subjectId, - project_id: projectId, - now_ms: nowMs, - }); - if (!revoked) throw notFoundSubscription(); + const revoked = validateRevokedSubscription( + await repository.revokeSubscriptionAtomically(normalizedSubscriptionId, { + subject_id: subjectId, + project_id: projectId, + now_ms: nowMs, + }), + { + subjectId, + projectId, + subscriptionId: normalizedSubscriptionId, + nowMs, + }, + ); if (revoked.revocation_applied === true) { await recordAuditBestEffort(auditSink, { event: 'calendar_subscription.revoked',