diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be02c99e..a832f748 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,6 +12,14 @@ - `analytics.js`: EVM, S-curve, CPM, workload, cost, and requirements/RFI/RFP WBS-estimation readiness analysis. - `wbs.json`: seed data in the user-specified JSON array format. +- `server/app.mjs`: legacy/core Hono application and its established JSON, + collaboration, billing, export, and compatibility routes. +- `server/runtime_app.mjs`: **active stacked PR work; not yet protected-`develop` + truth**. Production composition wrapper that installs the durable calendar- + subscription schema/revocation hook at bootstrap, adds the bounded calendar + subscription lifecycle/feed routes, and then delegates every other request to + `server/app.mjs`. `server/server.mjs` switches to this wrapper only on the + active child branch. ## CI and security structure @@ -54,3 +62,14 @@ secret hash, frozen `calendar_read` purpose, issuance membership epoch, and normalized lifecycle/audit evidence. Protected route and browser UI migration remain later issue #413 slices. +- **Active calendar-runtime child; not yet protected-`develop` truth:** the + production composition exposes authenticated create/list/rotate/revoke + management routes and a project-bound `subscription=` ICS feed. Subscription + feed responses are private/no-store/no-referrer, reject mixed credentials, + and cannot authorize JSON APIs, SSE, attachments, or another project. Session + and PAT calendar compatibility remains temporarily available only through the + core database-backed authentication boundary so token-version revocation is + preserved during migration. Membership deletion transactionally revokes the + affected reusable subscriptions and writes revocation evidence. Customer + management UI and final retirement of the legacy `token=` calendar URL remain + later issue #413 slices. diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a6c9a1..8dd198a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 revocation evidence, a secret-free audit outbox, restart-survival tests, and c8 registration. Protected route and customer UI migration remain later #413 slices. +- **Active stacked calendar-runtime child; not yet protected-`develop` truth:** + added production composition for calendar subscriptions with authenticated + create/list/rotate/revoke management endpoints, one-time `subscription=` feed + URLs, project/purpose/audience binding, private no-store/no-referrer ICS + responses, mixed-credential rejection, token-version/session-epoch invalidation, + durable membership-removal revocation evidence, and exact API/coverage + regression registration. The staged legacy `token=` calendar path now passes + only through the authoritative database-backed authentication boundary; + customer management UI and final legacy query-token retirement remain later + issue #413 slices. ### Security diff --git a/docs/doctoring/calendar-subscription-runtime.md b/docs/doctoring/calendar-subscription-runtime.md new file mode 100644 index 00000000..bfa9c888 --- /dev/null +++ b/docs/doctoring/calendar-subscription-runtime.md @@ -0,0 +1,101 @@ +# Calendar subscription runtime composition + +Status: **active stacked child; not shipped on protected `develop`**. + +Issue: #413. Parent persistence authority: PR #541, which itself depends on the calendar-subscription domain in PR #539. + +## Buyer/security outcome + +The protected calendar feed still accepts a general ScopeWeave session credential in a URL query parameter. That credential carries substantially more authority than a calendar reader needs and may be exposed by URL-handling systems. The active runtime child composes the separately reviewed calendar credential domain and SQLite persistence into the production server boundary so an operator can create, list, rotate, and revoke a reusable project-calendar credential whose authority is fixed to `calendar_read` for one project and the `scopeweave:calendar` audience. + +This slice deliberately remains a staged migration. It adds the durable subscription path but retains the existing `token=` calendar compatibility path temporarily for existing clients. That compatibility path is no longer treated as an independent authentication implementation: it passes through the core `/api/me` authentication boundary, which performs the same database-backed `token_version` revocation checks as normal bearer sessions and PAT handling. The customer management UI and final removal of broad session credentials from calendar URLs remain later #413 work. + +## Runtime composition boundary + +`server/runtime_app.mjs` is a thin composition wrapper around `server/app.mjs` rather than a copy of the existing application. At bootstrap it: + +1. installs the normalized calendar-subscription schema supplied by the parent SQLite adapter; +2. installs an idempotent membership-removal trigger that marks affected reusable subscriptions revoked and writes secret-free revocation evidence before membership deletion commits; +3. wires project management authorization, the live membership/session epoch, cryptographic randomness, the SQLite repository, and the existing audit log into `createCalendarSubscriptionService(...)`; and +4. registers only the calendar-management and calendar-feed routes before delegating every unrelated request to the existing core app. + +`server/server.mjs` uses this composition only on the active child branch. Protected `develop` remains unchanged until the prerequisite stack and this child integrate under normal governance. + +## Credential and transport contract + +The create and rotate responses expose the 256-bit random subscription secret exactly once. Lifecycle listing never returns the plaintext secret, its SHA-256 hash, or the membership epoch. The returned feed path uses `subscription=` rather than `token=` and the reusable credential cannot authorize JSON APIs, SSE, attachments, another project, or another audience. + +Calendar-subscription create and rotate JSON bodies are capped at 4 KiB by Hono's `bodyLimit` middleware before JSON parsing. The same ceiling applies to an oversized declared `Content-Length` and to streamed request bytes when no length is declared. Oversized requests return the stable `calendar_subscription_body_too_large` error with HTTP 413 and `Cache-Control: no-store` before the calendar domain service or durable subscription state can be mutated. + +A subscription feed request is accepted only when all of these are true: + +- the secret has the required format and resolves to the current stored SHA-256 hash; +- project, `calendar_read` purpose, and `scopeweave:calendar` audience match; +- the stored issuance membership epoch equals the live `membership_id:token_version` epoch; +- the credential is neither expired nor revoked; +- the SQLite atomic usage transition independently rechecks the same live epoch; and +- no session query token or `Authorization` credential is mixed into the same subscription request. + +Successful subscription-feed responses set `Cache-Control: private, no-store`, `Referrer-Policy: no-referrer`, and `X-Content-Type-Options: nosniff`. The application does not log request bodies or credential values. Calendar values are escaped for RFC 5545 text and all-day `DTEND` remains exclusive. Every emitted RFC 5545 content line is folded to at most 75 UTF-8 octets using `CRLF` plus one SPACE for continuation, and folding iterates Unicode code points so it never splits a UTF-8 multi-octet character. Unfolding therefore reconstructs the complete customer-visible project/task text instead of truncating or corrupting non-ASCII names. + +Project task persistence predates the calendar runtime and can contain malformed or impossible date strings. Feed rendering therefore treats persisted task dates as untrusted input: a task is emitted only when both dates are canonical real `YYYY-MM-DD` UTC calendar days and its exclusive next-day `DTEND` is itself representable by RFC 5545's four-digit basic `DATE` form. Invalid months, normalized impossible days, and the `9999-12-31` upper boundary are omitted rather than causing HTTP 500 responses, silently changing the scheduled day, or emitting an extended-year value such as `+01000001`. + +## Revocation and recovery + +Rotation atomically replaces the current stored hash, so the previous feed URL stops working immediately. Explicit revocation preserves first-transition semantics from the parent domain/adapter. + +A `logout-all` or other `token_version` advance changes the live membership/session epoch. Existing reusable calendar credentials from the previous epoch fail authorization immediately; an authorized operator may then rotate the same subscription to bind a new secret to the new epoch. This gives account-level revocation an immediate effect without silently deleting operator-visible subscription state. + +Membership removal is stronger. The bootstrap trigger writes a `revoked` event to `calendar_subscription_audit_outbox` and sets `revoked_at_ms` on each affected active subscription within the same SQLite transaction as the membership deletion. Remove-then-rejoin therefore cannot resurrect a reusable secret even if membership identity is later recreated. The core `member.remove` audit record retains the administrative actor, while the calendar outbox retains credential-level revocation evidence without secret material. + +## Compatibility and retirement plan + +The legacy `token=` calendar path remains an explicit compatibility state, not the target design. During this phase it supports the same current session/PAT authentication semantics as the core API and rejects a request that supplies both query-token and `Authorization` authority. Stale session tokens fail after `token_version` revocation. + +The next product slice is the reviewed customer management interaction from the calendar-domain Figma contract. After the UI/client migration and operational acceptance demonstrate that supported calendar clients can create/save/rotate/revoke the dedicated feed URL, the legacy query-session path can be removed with a separate exact-head regression. Rollback must never represent broad session-JWT query transport as the preferred steady-state design. + +## TDD and executable acceptance + +The runtime API contract was committed before the production composition existed. The first test registered `tests/api/calendar-subscription-runtime.test.mjs` in the canonical API suite while the tested management routes were absent from `server/app.mjs`, establishing the RED boundary before the wrapper implementation. + +Two later feed-validity repairs were also established with executed RED evidence before production changes. Test-only `44eec30eb31f43ce9658c97d99af12f4f4d09ac0` persisted `2026-13-01` and `2026-02-30`; Server Tests run `32088800210`, job `95566742208`, reproduced the `RangeError: Invalid time value` feed failure before `isCalendarDay()` was hardened. Test-only `fa55b6f535d6f2c2f5a3420d31f1ae3425b38173` then persisted `9999-12-31`; Server Tests run `32089288786`, job `95568160158`, proved the feed emitted the malformed `DTEND;VALUE=DATE:+01000001` before exclusive-end rendering was bounded. Production commit `f25941198928ad285a2165f7785f27c0fba3bc71` made the second regression GREEN; Server Tests run `32089452553` completed successfully with both `unit-and-api` and `cloud-e2e` passing, while Dependency Review `32089452544` and OSV Scanner `32089452867` also passed on that contributor head. These runs are causal evidence, not final merge authority after later head movement. + +The interoperability repair was likewise test-first. Test-only head `e3bd931539f19ea342042a7c11aeaab78fe7af1e` added a long Korean task summary and required every physical iCalendar line to remain within 75 UTF-8 octets while unfolding preserved the complete Unicode value. Server Tests run `32583307921`, `unit-and-api` job `97055782749`, failed at the intended assertion because the renderer emitted an overlong line. Production commit `a73f974efc81085703fe3fe49233fce2162aebca` added UTF-8-safe RFC 5545 content-line folding. Server Tests run `32583381045` then completed successfully: `unit-and-api` job `97055956846` passed the registered API regression and the full unit/API suite, and `cloud-e2e` job `97055956941` passed. Dependency Review `32583381120` and OSV Scanner `32583381290` were also terminal success on that contributor head. The hosted Server Tests checked synthetic merge `cdac7ea7f6f61642d79b959691d74f2fe88317b9` (`Merge a73f974e... into 422f754e...`), so this is causal merge-result evidence rather than exact-contributor-head merge authorization until #523 reaches protected `develop`. + +Fresh source inspection then found a separate resource-boundary defect: create and rotate called `c.req.json()` without a request-body byte ceiling. The registered RED head `6127c8f022d079dcaed202edf15179746d17b2ce` added `tests/api/calendar-subscription-body-limit.test.mjs` and required both an oversized declared body and an oversized streamed body to fail before persistence. Server Tests run `32584476314`, `unit-and-api` job `97058694218`, failed at the intended declared-body assertion with HTTP 400 instead of the required 413, proving the application reached JSON/domain handling without a transport bound. That runner checked synthetic merge `568b372d310933999618d0c8d0329fef5540bb77` (`Merge 6127c8f... into 422f754e...`). + +Production commit `f990b985e1b6437a7309a65d9d1a6ca898fafc64` added Hono `bodyLimit` at 4 KiB only to the create/rotate management routes. Server Tests run `32584561011` then completed successfully: `unit-and-api` job `97058880349` passed the registered declared/streamed body regressions and full unit/API suite, and `cloud-e2e` job `97058880463` passed. Dependency Review `32584560985` and OSV Scanner `32584561245` were also terminal success. The hosted Server Tests checked synthetic merge `1fb30735a7d1846afdc6b87d05410bcae3f6c9a6` (`Merge f990b985... into 422f754e...`); therefore this is causal merge-result GREEN evidence and not exact-contributor-head merge authority while #523 remains outside protected `develop`. + +The current regression covers: + +- unauthenticated and cross-tenant management rejection; +- stable invalid-request status mapping; +- create/list and one-time secret disclosure; +- declared and streamed oversized create/rotate body rejection before durable state mutation; +- project/purpose/audience-bound feed authorization; +- private/no-store/no-referrer/nosniff response policy; +- malformed, impossible, and unrepresentable-exclusive-end task-date omission; +- RFC 5545 UTF-8 content-line folding with complete Unicode text after unfolding; +- mixed-credential fail-closed behavior; +- rotation and immediate previous-secret invalidation; +- explicit revocation; +- `logout-all` invalidation followed by authorized epoch re-binding; +- transactional membership-removal revocation plus audit-outbox evidence; +- staged legacy query-token compatibility through current database-backed session authority; and +- coverage-script registration for the production composition module. + +Hosted exact-head CI remains authoritative for full statement/branch coverage and broad regression evidence. Repository Server Tests still use GitHub's synthetic pull-request merge ref until the exact-head workflow repair in #523 is protected-integrated, so the bound contributor SHA plus the synthetic checkout identity must both be retained when interpreting current evidence. No predecessor-head result transfers after this branch or any prerequisite head moves. + +## Rollback + +Before protected integration, rollback is branch-local. After integration, a runtime rollback may disable the new lifecycle endpoints and restore the previous production composition only if incident containment requires it, but it must preserve the normalized subscription and audit data and must not delete revocation/rotation evidence. Re-enabling broad query-session calendar credentials as a permanent security posture is not an acceptable rollback target. Any schema removal requires a separately reviewed data migration/recovery plan. + +## References + +Desruisseaux, B. (Ed.). (2009). *Internet calendaring and scheduling core object specification (iCalendar)* (RFC 5545). Internet Engineering Task Force. https://doi.org/10.17487/RFC5545 + +Jones, M., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: Bearer token usage* (RFC 6750). Internet Engineering Task Force. https://doi.org/10.17487/RFC6750 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task Force. https://doi.org/10.17487/RFC9700 + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current practices* (BCP 225; RFC 8725). Internet Engineering Task Force. https://doi.org/10.17487/RFC8725 diff --git a/package.json b/package.json index 0a7eda09..46753130 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "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 && node tests/api/orchestrator-attribution.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 && node tests/api/calendar-subscription-runtime.test.mjs && node tests/api/calendar-subscription-body-limit.test.mjs && node tests/api/calendar-subscription-rate-limit.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/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-expiry.test.mjs && node tests/unit/calendar-subscription-sqlite-retention.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.test.mjs && node tests/unit/calendar-subscription-sqlite-race.test.mjs && node tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs && node tests/unit/coverage-script-contract.test.mjs && 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 --include=server/calendar_subscription_sqlite.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "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/runtime_app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.mjs --include=server/calendar_subscription_sqlite.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-expiry.test.mjs && node tests/unit/calendar-subscription-sqlite-retention.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.test.mjs && node tests/unit/calendar-subscription-sqlite-race.test.mjs && node tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/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", diff --git a/server/runtime_app.mjs b/server/runtime_app.mjs new file mode 100644 index 00000000..87d6aa8f --- /dev/null +++ b/server/runtime_app.mjs @@ -0,0 +1,404 @@ +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { randomBytes } from 'node:crypto'; +import { app as coreApp } from './app.mjs'; +import { hashApiToken, verifyToken } from './auth.mjs'; +import { db } from './db.mjs'; +import { + CalendarSubscriptionError, + createCalendarSubscriptionService, +} from './calendar_subscription_domain.mjs'; +import { + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from './calendar_subscription_sqlite.mjs'; + +const MANAGER_ROLES = new Set(['owner', 'admin']); +const CALENDAR_QUERY_PARAMETER = 'subscription'; +const CALENDAR_CONTENT_LINE_MAX_OCTETS = 75; +const CALENDAR_MANAGEMENT_BODY_MAX_BYTES = 4 * 1024; +const RUNTIME_RATE_LIMIT_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; +const RUNTIME_RATE_LIMIT_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; +const PRIVATE_NO_STORE_HEADERS = Object.freeze({ + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', +}); +const runtimeRateLimitBuckets = new Map(); + +installCalendarSubscriptionSchema(db); +db.exec(` + CREATE TRIGGER IF NOT EXISTS calendar_subscription_membership_revoke_trigger + BEFORE DELETE ON memberships + BEGIN + INSERT INTO calendar_subscription_audit_outbox( + subscription_id, event_type, subject_id, project_id, occurred_at_ms, delivered_at_ms + ) + SELECT subscription_id, + 'revoked', + subject_id, + project_id, + MAX(created_at_ms, CAST(strftime('%s', 'now') AS INTEGER) * 1000), + NULL + FROM calendar_subscriptions + WHERE subject_id = OLD.user_id + AND project_id IN (SELECT id FROM projects WHERE org_id = OLD.org_id) + AND revoked_at_ms IS NULL; + + UPDATE calendar_subscriptions + SET revoked_at_ms = MAX(created_at_ms, CAST(strftime('%s', 'now') AS INTEGER) * 1000) + WHERE subject_id = OLD.user_id + AND project_id IN (SELECT id FROM projects WHERE org_id = OLD.org_id) + AND revoked_at_ms IS NULL; + END; +`); + +function projectMembership(subjectId, projectId) { + return db.prepare(` + SELECT p.id AS project_id, p.org_id, p.name, p.tasks_json, m.id AS membership_id, + m.role, u.token_version + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE p.id = ? AND m.user_id = ? + `).get(projectId, subjectId); +} + +const projectAuthorization = Object.freeze({ + /** Require owner/admin authority without disclosing inaccessible projects. */ + async assertCanManage({ subjectId, projectId }) { + const row = projectMembership(subjectId, projectId); + if (!row || !MANAGER_ROLES.has(row.role)) throw new Error('calendar_subscription_project_not_manageable'); + }, +}); + +const membershipRevocation = Object.freeze({ + /** Return the same membership/session epoch enforced by the SQLite adapter. */ + async assertActive({ subjectId, projectId }) { + const row = projectMembership(subjectId, projectId); + if (!row) throw new Error('calendar_subscription_membership_inactive'); + return `${row.membership_id}:${row.token_version}`; + }, +}); + +const auditSink = Object.freeze({ + /** Mirror secret-free lifecycle metadata into the existing append-only audit log. */ + async record(event) { + const row = db.prepare('SELECT org_id FROM projects WHERE id = ?').get(event.project_id); + if (!row) return; + db.prepare(` + INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) + VALUES(?,?,?,?,?,?) + `).run( + row.org_id, + event.subject_id, + event.event, + 'calendar_subscription', + event.subscription_id, + JSON.stringify({ + project_id: event.project_id, + purpose: event.purpose, + audience: event.audience, + expires_at_ms: event.expires_at_ms ?? null, + }), + ); + }, +}); + +const calendarSubscriptionService = createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(db), + clock: { nowMs: () => Date.now() }, + randomSource: { randomBytes: (size) => randomBytes(size) }, + auditSink, + projectAuthorization, + membershipRevocation, +}); + +const calendarManagementBodyLimit = bodyLimit({ + maxSize: CALENDAR_MANAGEMENT_BODY_MAX_BYTES, + onError: (c) => c.json( + { error: 'calendar_subscription_body_too_large' }, + 413, + { 'Cache-Control': 'no-store' }, + ), +}); + +/** + * Apply the same configured per-client abuse ceiling to runtime-owned calendar + * routes without sending an internal HTTP request through the core limiter. + */ +async function calendarRateLimit(c, next) { + if (RUNTIME_RATE_LIMIT_MAX <= 0) { + await next(); + return; + } + + const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; + const now = Date.now(); + let bucket = runtimeRateLimitBuckets.get(key); + if (!bucket || bucket.resetAt <= now) { + bucket = { count: 0, resetAt: now + RUNTIME_RATE_LIMIT_WINDOW_MS }; + runtimeRateLimitBuckets.set(key, bucket); + } + bucket.count += 1; + if (bucket.count > RUNTIME_RATE_LIMIT_MAX) { + const retryAfterSeconds = Math.ceil((bucket.resetAt - now) / 1000); + return c.json( + { error: 'rate limit exceeded' }, + 429, + { 'Retry-After': String(retryAfterSeconds) }, + ); + } + await next(); +} + +/** + * Resolve the same bearer-session and PAT identities accepted by the core API + * without an internal `/api/me` sub-request that would consume another limiter bucket. + */ +function authenticatedPrincipalFromAuthorization(authorization) { + const token = authorization.startsWith('Bearer ') ? authorization.slice(7) : ''; + if (token.startsWith('swk_')) { + const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); + if (!row) return null; + db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); + return { sub: row.user_id, viaPat: true }; + } + + try { + const payload = verifyToken(token); + const user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!user || (payload.tv || 0) !== user.token_version) return null; + return payload; + } catch { + return null; + } +} + +async function authenticatedUserFromAuthorization(authorization) { + if (!authorization) return null; + const principal = authenticatedPrincipalFromAuthorization(authorization); + if (!principal) return null; + return db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(principal.sub) || null; +} + +async function authenticatedUser(c) { + return authenticatedUserFromAuthorization(c.req.header('authorization') || ''); +} + +function noStoreJson(c, payload, status = 200) { + return c.json(payload, status, { 'Cache-Control': 'no-store' }); +} + +function calendarUnauthorized(c) { + return c.json({ error: 'calendar_subscription_unauthorized' }, 401, PRIVATE_NO_STORE_HEADERS); +} + +async function calendarOperation(c, operation, successStatus = 200) { + const user = await authenticatedUser(c); + if (!user) return noStoreJson(c, { error: 'unauthorized' }, 401); + try { + return noStoreJson(c, await operation(String(user.id)), successStatus); + } catch (error) { + if (error instanceof CalendarSubscriptionError) { + return noStoreJson(c, { error: error.code }, error.status); + } + throw error; + } +} + +function escapeCalendarText(value) { + return String(value) + .replace(/\\/g, '\\\\') + .replace(/[,;]/g, (match) => `\\${match}`) + .replace(/\r\n|\r|\n/g, '\\n'); +} + +function foldCalendarContentLine(line) { + const segments = []; + let segment = ''; + let segmentBytes = 0; + + for (const character of line) { + const characterBytes = Buffer.byteLength(character, 'utf8'); + if (segmentBytes + characterBytes > CALENDAR_CONTENT_LINE_MAX_OCTETS) { + segments.push(segment); + segment = ' '; + segmentBytes = 1; + } + segment += character; + segmentBytes += characterBytes; + } + + segments.push(segment); + return segments.join('\r\n'); +} + +function compactCalendarDay(value) { + return String(value).replaceAll('-', ''); +} + +function currentCalendarTimestamp() { + return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +function isCalendarDay(value) { + const text = String(value); + if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return false; + const date = new Date(`${text}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === text; +} + +function nextCalendarDay(value) { + const date = new Date(`${value}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + 1); + const nextDay = date.toISOString().slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(nextDay) ? compactCalendarDay(nextDay) : null; +} + +function renderCalendarFeed(project) { + let tasks = []; + try { + tasks = JSON.parse(project.tasks_json); + } catch { + tasks = []; + } + if (!Array.isArray(tasks)) tasks = []; + const dtstamp = currentCalendarTimestamp(); + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//ScopeWeave//KO', + 'CALSCALE:GREGORIAN', + `X-WR-CALNAME:${escapeCalendarText(project.name)}`, + ]; + for (const task of tasks) { + const startDay = String(task?.plannedStartDate || ''); + const endDay = String(task?.plannedEndDate || ''); + if ( + !isCalendarDay(startDay) + || !isCalendarDay(endDay) + || endDay < startDay + ) continue; + const exclusiveEnd = nextCalendarDay(endDay); + if (!exclusiveEnd) continue; + lines.push( + 'BEGIN:VEVENT', + `UID:scopeweave-${project.project_id}-${escapeCalendarText(task.id)}`, + `DTSTAMP:${dtstamp}`, + `DTSTART;VALUE=DATE:${compactCalendarDay(startDay)}`, + `DTEND;VALUE=DATE:${exclusiveEnd}`, + `SUMMARY:${escapeCalendarText(task.name || task.task || task.id)}`, + 'END:VEVENT', + ); + } + lines.push('END:VCALENDAR'); + return `${lines.map(foldCalendarContentLine).join('\r\n')}\r\n`; +} + +function calendarFeedResponse(c, project) { + return c.text(renderCalendarFeed(project), 200, { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Disposition': `attachment; filename="scopeweave-${project.project_id}.ics"`, + ...PRIVATE_NO_STORE_HEADERS, + 'X-Content-Type-Options': 'nosniff', + }); +} + +/** + * Production composition layer. New capability routes are registered before + * falling through to the legacy/core Hono app so staged migrations can replace + * one transport at a time without copying the core application. + */ +export const app = new Hono(); + +app.post( + '/api/projects/:id/calendar-subscriptions', + calendarRateLimit, + calendarManagementBodyLimit, + (c) => calendarOperation(c, async (subjectId) => { + const projectId = c.req.param('id'); + const body = await c.req.json().catch(() => ({})); + const created = await calendarSubscriptionService.create({ + subjectId, + projectId, + name: body.name, + expiresAtMs: body.expiresAtMs, + }); + return Object.freeze({ + ...created, + feedPath: `/api/projects/${encodeURIComponent(projectId)}/calendar.ics?${CALENDAR_QUERY_PARAMETER}=${encodeURIComponent(created.secret)}`, + }); + }, 201), +); + +app.get( + '/api/projects/:id/calendar-subscriptions', + calendarRateLimit, + (c) => calendarOperation(c, async (subjectId) => ({ + subscriptions: await calendarSubscriptionService.list({ + subjectId, + projectId: c.req.param('id'), + }), + })), +); + +app.post( + '/api/projects/:id/calendar-subscriptions/:subscriptionId/rotate', + calendarRateLimit, + calendarManagementBodyLimit, + (c) => calendarOperation(c, async (subjectId) => { + const body = await c.req.json().catch(() => ({})); + const rotated = await calendarSubscriptionService.rotate({ + subjectId, + projectId: c.req.param('id'), + subscriptionId: c.req.param('subscriptionId'), + expiresAtMs: body.expiresAtMs, + }); + return Object.freeze({ + ...rotated, + feedPath: `/api/projects/${encodeURIComponent(c.req.param('id'))}/calendar.ics?${CALENDAR_QUERY_PARAMETER}=${encodeURIComponent(rotated.secret)}`, + }); + }), +); + +app.delete( + '/api/projects/:id/calendar-subscriptions/:subscriptionId', + calendarRateLimit, + (c) => calendarOperation(c, (subjectId) => ( + calendarSubscriptionService.revoke({ + subjectId, + projectId: c.req.param('id'), + subscriptionId: c.req.param('subscriptionId'), + }) + )), +); + +app.get('/api/projects/:id/calendar.ics', calendarRateLimit, async (c) => { + const secret = c.req.query(CALENDAR_QUERY_PARAMETER) || ''; + const queryToken = c.req.query('token') || ''; + const authorization = c.req.header('authorization') || ''; + if (secret) { + if (queryToken || authorization) return calendarUnauthorized(c); + try { + const principal = await calendarSubscriptionService.authorize({ + secret, + projectId: c.req.param('id'), + }); + const project = projectMembership(principal.subjectId, principal.projectId); + return project ? calendarFeedResponse(c, project) : calendarUnauthorized(c); + } catch (error) { + if (!(error instanceof CalendarSubscriptionError)) throw error; + return calendarUnauthorized(c); + } + } + + if (queryToken && authorization) return calendarUnauthorized(c); + const legacyAuthorization = authorization || (queryToken ? `Bearer ${queryToken}` : ''); + const user = await authenticatedUserFromAuthorization(legacyAuthorization); + if (!user) return calendarUnauthorized(c); + const project = projectMembership(String(user.id), c.req.param('id')); + if (!project) return c.json({ error: 'not found' }, 404, PRIVATE_NO_STORE_HEADERS); + return calendarFeedResponse(c, project); +}); + +app.all('*', (c) => coreApp.fetch(c.req.raw)); \ No newline at end of file diff --git a/server/server.mjs b/server/server.mjs index c84c2e25..9f9d7890 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -1,5 +1,5 @@ import { serve } from '@hono/node-server'; -import { app } from './app.mjs'; +import { app } from './runtime_app.mjs'; const port = Number(process.env.PORT) || 8787; serve({ fetch: app.fetch, port }, (info) => { diff --git a/tests/api/calendar-subscription-body-limit.test.mjs b/tests/api/calendar-subscription-body-limit.test.mjs new file mode 100644 index 00000000..0e884ab2 --- /dev/null +++ b/tests/api/calendar-subscription-body-limit.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/runtime_app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const encoder = new TextEncoder(); +const json = (value) => JSON.stringify(value); + +async function authenticatedProject() { + let response = await app.request('/api/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: json({ + email: 'calendar-body-limit@example.com', + password: 'password123', + name: 'Calendar Body Limit', + }), + }); + assert.equal(response.status, 200); + const token = (await response.json()).token; + const authorization = `Bearer ${token}`; + + response = await app.request('/api/projects', { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: json({ name: 'Calendar Body Limit Project' }), + }); + assert.equal(response.status, 200); + const project = await response.json(); + return { authorization, projectId: project.id }; +} + +const { authorization, projectId } = await authenticatedProject(); +const createPath = `/api/projects/${projectId}/calendar-subscriptions`; +const expiresAtMs = Date.now() + (7 * 24 * 60 * 60 * 1000); +const oversizedCreateBody = json({ + name: 'x'.repeat(5 * 1024), + expiresAtMs, +}); + +let response = await app.request(createPath, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(oversizedCreateBody)), + }, + body: oversizedCreateBody, +}); +assert.equal(response.status, 413, 'declared oversized create bodies fail before JSON buffering'); +assert.deepEqual(await response.json(), { error: 'calendar_subscription_body_too_large' }); +assert.equal(response.headers.get('cache-control'), 'no-store'); +assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM calendar_subscriptions').get().count, + 0, + 'rejected oversized create bodies cannot persist a subscription', +); + +response = await app.request(createPath, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: json({ name: 'Bounded calendar', expiresAtMs }), +}); +assert.equal(response.status, 201); +const created = await response.json(); + +const oversizedRotateBody = json({ + expiresAtMs: Date.now() + (14 * 24 * 60 * 60 * 1000), + padding: 'y'.repeat(5 * 1024), +}); +let sent = false; +const streamedBody = new ReadableStream({ + pull(controller) { + if (sent) { + controller.close(); + return; + } + sent = true; + controller.enqueue(encoder.encode(oversizedRotateBody)); + }, +}); + +response = await app.request( + `/api/projects/${projectId}/calendar-subscriptions/${created.subscriptionId}/rotate`, + { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: streamedBody, + duplex: 'half', + }, +); +assert.equal(response.status, 413, 'streamed oversized rotate bodies fail before JSON buffering'); +assert.deepEqual(await response.json(), { error: 'calendar_subscription_body_too_large' }); +assert.equal(response.headers.get('cache-control'), 'no-store'); + +const persisted = db.prepare(` + SELECT expires_at_ms + FROM calendar_subscriptions + WHERE subscription_id = ? +`).get(created.subscriptionId); +assert.equal( + persisted.expires_at_ms, + created.expiresAtMs, + 'rejected oversized rotate bodies cannot mutate subscription expiry', +); diff --git a/tests/api/calendar-subscription-rate-limit.test.mjs b/tests/api/calendar-subscription-rate-limit.test.mjs new file mode 100644 index 00000000..0733f079 --- /dev/null +++ b/tests/api/calendar-subscription-rate-limit.test.mjs @@ -0,0 +1,91 @@ +// Calendar runtime abuse-control regression — runs in its own process with a +// deliberately small per-client rate limit so runtime-only routes exercise the +// same boundary as the core API without collapsing auth checks into one bucket. +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '2'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '600000'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/runtime_app.mjs'); + +const json = (value) => JSON.stringify(value); +const req = (path, { ip = '203.0.113.10', headers = {}, ...options } = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + 'x-forwarded-for': ip, + ...headers, + }, +}); + +// Prepare one real project through the public fallback/core API. A dedicated +// setup address keeps fixture creation from consuming either runtime test bucket. +let response = await req('/api/auth/signup', { + ip: '192.0.2.10', + method: 'POST', + body: json({ email: 'calendar-limit-owner@example.com', password: 'password123', name: 'Owner' }), +}); +assert.equal(response.status, 200, 'fixture owner signs up'); +const { token } = await response.json(); +const auth = { authorization: `Bearer ${token}` }; + +response = await req('/api/projects', { + ip: '192.0.2.10', + method: 'POST', + headers: auth, + body: json({ name: 'Rate-limited calendar project' }), +}); +assert.equal(response.status, 200, 'fixture project is created'); +const project = await response.json(); + +// Subscription-secret probes terminate on the runtime app and therefore must +// not bypass the same per-IP abuse ceiling that protects the legacy/core API. +const feedPath = `/api/projects/${project.id}/calendar.ics?subscription=invalid-secret-probe`; +for (let attempt = 0; attempt < 2; attempt += 1) { + response = await req(feedPath, { ip: '203.0.113.20' }); + assert.equal(response.status, 401, 'invalid calendar secret fails closed below the abuse ceiling'); +} +response = await req(feedPath, { ip: '203.0.113.20' }); +assert.equal(response.status, 429, 'third calendar-secret probe is rate-limited'); +assert.ok(response.headers.get('retry-after'), 'runtime rate limit exposes Retry-After'); + +// Authenticated runtime management must use the caller's own bucket. The first +// client reaches its limit as 429, while an unrelated client remains usable. +for (let attempt = 0; attempt < 2; attempt += 1) { + response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + ip: '198.51.100.20', + headers: auth, + }); + assert.equal(response.status, 200, 'authenticated calendar management succeeds below its client limit'); +} +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + ip: '198.51.100.20', + headers: auth, +}); +assert.equal(response.status, 429, 'calendar management reports rate limiting rather than false 401'); +assert.ok(response.headers.get('retry-after'), 'management rate limit exposes Retry-After'); + +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + ip: '198.51.100.21', + headers: auth, +}); +assert.equal(response.status, 200, 'a separate client is not locked out by another calendar client'); + +// Preserve the existing PAT contract while removing the internal /api/me hop. +response = await req('/api/tokens', { + ip: '192.0.2.11', + method: 'POST', + headers: auth, + body: json({ name: 'calendar-runtime-test' }), +}); +assert.equal(response.status, 200, 'fixture PAT is issued through the core API'); +const { token: personalAccessToken } = await response.json(); +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + ip: '198.51.100.22', + headers: { authorization: `Bearer ${personalAccessToken}` }, +}); +assert.equal(response.status, 200, 'calendar management preserves PAT authentication'); + +console.log('calendar subscription rate-limit tests passed'); diff --git a/tests/api/calendar-subscription-runtime.test.mjs b/tests/api/calendar-subscription-runtime.test.mjs new file mode 100644 index 00000000..1920befa --- /dev/null +++ b/tests/api/calendar-subscription-runtime.test.mjs @@ -0,0 +1,316 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/runtime_app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const req = (path, opts = {}) => app.request(path, { + ...opts, + headers: { 'content-type': 'application/json', ...(opts.headers || {}) }, +}); +const json = (value) => JSON.stringify(value); +const longCalendarTaskName = '국제화일정'.repeat(24); + +let response = await req('/api/auth/signup', { + method: 'POST', + body: json({ email: 'calendar-owner@example.com', password: 'password123', name: 'Calendar Owner' }), +}); +assert.equal(response.status, 200); +const { token } = await response.json(); +let currentToken = token; +let auth = { authorization: `Bearer ${currentToken}` }; + +response = await req('/api/me', { headers: auth }); +assert.equal(response.status, 200); +const ownerMe = await response.json(); +const ownerOrgId = ownerMe.orgs[0].id; + +response = await req('/api/projects', { + method: 'POST', + headers: auth, + body: json({ name: 'Calendar Project' }), +}); +assert.equal(response.status, 200); +const project = await response.json(); + +response = await req(`/api/projects/${project.id}`, { + method: 'PUT', + headers: auth, + body: json({ + version: 1, + baseDate: '2026-08-01', + tasks: [ + { + id: 'calendar-task-1', + name: 'Ship calendar runtime', + plannedStartDate: '2026-08-18', + plannedEndDate: '2026-08-19', + }, + { + id: 'calendar-task-2', + task: 'Fallback task label', + plannedStartDate: '2026-08-20', + plannedEndDate: '2026-08-20', + }, + { + id: 'calendar-task-3', + plannedStartDate: 'not-a-date', + plannedEndDate: '2026-08-21', + }, + { + id: 'calendar-task-4', + name: 'Impossible calendar month', + plannedStartDate: '2026-13-01', + plannedEndDate: '2026-13-01', + }, + { + id: 'calendar-task-5', + name: 'Impossible calendar day', + plannedStartDate: '2026-02-30', + plannedEndDate: '2026-02-30', + }, + { + id: 'calendar-task-6', + name: 'Unrepresentable exclusive end date', + plannedStartDate: '9999-12-31', + plannedEndDate: '9999-12-31', + }, + { + id: 'calendar-task-7', + name: 'Reversed event range', + plannedStartDate: '2026-08-23', + plannedEndDate: '2026-08-21', + }, + { + id: 'calendar-task-8', + name: longCalendarTaskName, + plannedStartDate: '2026-08-24', + plannedEndDate: '2026-08-24', + }, + ], + }), +}); +assert.equal(response.status, 200); + +const expiresAtMs = Date.now() + (7 * 24 * 60 * 60 * 1000); +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + method: 'POST', + body: json({ name: 'Unauthenticated', expiresAtMs }), +}); +assert.equal(response.status, 401, 'management endpoints require normal authenticated authority'); + +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + method: 'POST', + headers: auth, + body: json({ name: '', expiresAtMs }), +}); +assert.equal(response.status, 400, 'domain validation errors retain stable client status'); + +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + method: 'POST', + headers: auth, + body: json({ name: 'Primary calendar', expiresAtMs }), +}); +assert.equal(response.status, 201, 'owner can create a calendar subscription'); +assert.equal(response.headers.get('cache-control'), 'no-store'); +const created = await response.json(); +assert.match(created.secret, /^[A-Za-z0-9_-]{43}$/); +assert.match(created.subscriptionId, /^csub_[a-f0-9]{32}$/); +assert.equal(created.projectId, String(project.id)); +assert.equal(created.purpose, 'calendar_read'); +assert.equal(created.audience, 'scopeweave:calendar'); +assert.ok(created.feedPath.includes('subscription=')); +assert.ok(!created.feedPath.includes('token=')); +assert.ok(created.feedPath.includes(encodeURIComponent(created.secret))); + +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { headers: auth }); +assert.equal(response.status, 200); +assert.equal(response.headers.get('cache-control'), 'no-store'); +const listed = await response.json(); +assert.equal(listed.subscriptions.length, 1); +assert.equal(listed.subscriptions[0].subscriptionId, created.subscriptionId); +assert.equal(listed.subscriptions[0].status, 'active'); +assert.ok(!('secret' in listed.subscriptions[0])); +assert.ok(!('secret_hash' in listed.subscriptions[0])); + +response = await req(created.feedPath); +assert.equal(response.status, 200, 'invalid persisted task dates must not make the calendar feed fail'); +assert.match(response.headers.get('content-type') || '', /^text\/calendar/); +assert.equal(response.headers.get('cache-control'), 'private, no-store'); +assert.equal(response.headers.get('referrer-policy'), 'no-referrer'); +assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); +const feed = await response.text(); +assert.match(feed, /BEGIN:VCALENDAR/); +assert.match(feed, /SUMMARY:Ship calendar runtime/); +assert.match(feed, /SUMMARY:Fallback task label/); +assert.doesNotMatch(feed, /calendar-task-3/, 'non-date task values are omitted from the feed'); +assert.doesNotMatch(feed, /calendar-task-4/, 'impossible calendar months are omitted from the feed'); +assert.doesNotMatch(feed, /calendar-task-5/, 'impossible calendar days are omitted from the feed'); +assert.doesNotMatch(feed, /calendar-task-6/, 'events whose exclusive end cannot be represented are omitted'); +assert.doesNotMatch(feed, /\+01000001/, 'the feed never emits an extended-year value as an RFC 5545 DATE'); +assert.doesNotMatch(feed, /calendar-task-7/, 'events whose end precedes their start are omitted'); +const calendarEvents = feed + .split('BEGIN:VEVENT\r\n') + .slice(1) + .map((segment) => segment.split('END:VEVENT\r\n')[0]); +assert.equal(calendarEvents.length, 3, 'only tasks with valid representable date ranges become VEVENTs'); +for (const calendarEvent of calendarEvents) { + const dtstampLines = calendarEvent + .split('\r\n') + .filter((line) => line.startsWith('DTSTAMP:')); + assert.equal(dtstampLines.length, 1, 'every RFC 5545 VEVENT carries exactly one DTSTAMP'); + assert.match(dtstampLines[0], /^DTSTAMP:\d{8}T\d{6}Z$/, 'DTSTAMP is a UTC DATE-TIME in basic iCalendar form'); +} +const physicalCalendarLines = feed.split('\r\n').filter(Boolean); +assert.equal( + physicalCalendarLines.every((line) => Buffer.byteLength(line, 'utf8') <= 75), + true, + 'RFC 5545 content lines are folded to at most 75 UTF-8 octets', +); +const unfoldedFeed = feed.replace(/\r\n[ \t]/g, ''); +assert.ok( + unfoldedFeed.includes(`SUMMARY:${longCalendarTaskName}`), + 'UTF-8 line folding preserves the complete customer-visible task summary after unfolding', +); + +response = await req(`${created.feedPath}&token=${encodeURIComponent(currentToken)}`); +assert.equal(response.status, 401, 'mixed subscription and session-query credentials fail closed'); +response = await req(created.feedPath, { headers: auth }); +assert.equal(response.status, 401, 'mixed subscription and Authorization credentials fail closed'); + +response = await req('/api/projects', { + method: 'POST', + headers: auth, + body: json({ name: 'Other Project' }), +}); +assert.equal(response.status, 200); +const otherProject = await response.json(); +response = await req(`/api/projects/${otherProject.id}/calendar.ics?subscription=${encodeURIComponent(created.secret)}`); +assert.equal(response.status, 401, 'subscription cannot cross project boundaries'); + +response = await req(`/api/projects/${project.id}/calendar-subscriptions/${created.subscriptionId}/rotate`, { + method: 'POST', + headers: auth, + body: json({ expiresAtMs: Date.now() + (14 * 24 * 60 * 60 * 1000) }), +}); +assert.equal(response.status, 200); +assert.equal(response.headers.get('cache-control'), 'no-store'); +const rotated = await response.json(); +assert.match(rotated.secret, /^[A-Za-z0-9_-]{43}$/); +assert.notEqual(rotated.secret, created.secret); + +response = await req(created.feedPath); +assert.equal(response.status, 401, 'rotation immediately invalidates the previous secret'); +response = await req(`/api/projects/${project.id}/calendar.ics?subscription=${encodeURIComponent(rotated.secret)}`); +assert.equal(response.status, 200, 'rotated secret authorizes the bound calendar feed'); + +response = await req('/api/auth/logout-all', { method: 'POST', headers: auth }); +assert.equal(response.status, 200, 'logout-all advances the session membership epoch'); +const logoutPayload = await response.json(); +assert.ok(logoutPayload.token, 'logout-all returns a replacement session'); +const staleToken = currentToken; +currentToken = logoutPayload.token; +auth = { authorization: `Bearer ${currentToken}` }; + +response = await req(`/api/projects/${project.id}/calendar.ics?token=${encodeURIComponent(staleToken)}`); +assert.equal(response.status, 401, 'legacy calendar transport rejects the stale general session'); +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + headers: { authorization: `Bearer ${staleToken}` }, +}); +assert.equal(response.status, 401, 'management API rejects the stale general session'); +response = await req(`/api/projects/${project.id}/calendar.ics?subscription=${encodeURIComponent(rotated.secret)}`); +assert.equal(response.status, 401, 'subscription issued under the previous session epoch is invalidated'); + +response = await req(`/api/projects/${project.id}/calendar-subscriptions/${created.subscriptionId}/rotate`, { + method: 'POST', + headers: auth, + body: json({ expiresAtMs: Date.now() + (21 * 24 * 60 * 60 * 1000) }), +}); +assert.equal(response.status, 200, 'freshly authorized rotation re-binds the subscription to the live epoch'); +const rebound = await response.json(); +assert.notEqual(rebound.secret, rotated.secret); +response = await req(`/api/projects/${project.id}/calendar.ics?subscription=${encodeURIComponent(rebound.secret)}`); +assert.equal(response.status, 200, 're-bound subscription works after session epoch advancement'); + +response = await req(`/api/projects/${project.id}/calendar-subscriptions/${created.subscriptionId}`, { + method: 'DELETE', + headers: auth, +}); +assert.equal(response.status, 200); +assert.equal(response.headers.get('cache-control'), 'no-store'); +const revoked = await response.json(); +assert.equal(revoked.status, 'revoked'); +assert.ok(!('secret' in revoked)); + +response = await req(`/api/projects/${project.id}/calendar.ics?subscription=${encodeURIComponent(rebound.secret)}`); +assert.equal(response.status, 401, 'revocation immediately invalidates the re-bound secret'); + +response = await req('/api/auth/signup', { + method: 'POST', + body: json({ email: 'calendar-outsider@example.com', password: 'password123', name: 'Outsider' }), +}); +assert.equal(response.status, 200); +const outsiderToken = (await response.json()).token; +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + method: 'POST', + headers: { authorization: `Bearer ${outsiderToken}` }, + body: json({ name: 'Probe', expiresAtMs }), +}); +assert.equal(response.status, 404, 'management authorization does not disclose cross-tenant project existence'); + +response = await req('/api/auth/signup', { + method: 'POST', + body: json({ email: 'calendar-admin@example.com', password: 'password123', name: 'Calendar Admin' }), +}); +assert.equal(response.status, 200); +const adminToken = (await response.json()).token; +const adminAuth = { authorization: `Bearer ${adminToken}` }; +response = await req(`/api/orgs/${ownerOrgId}/invites`, { + method: 'POST', + headers: auth, + body: json({ email: 'calendar-admin@example.com', role: 'admin' }), +}); +assert.equal(response.status, 200); +const adminInvite = await response.json(); +response = await req(`/api/invites/${adminInvite.token}/accept`, { method: 'POST', headers: adminAuth }); +assert.equal(response.status, 200); +response = await req('/api/me', { headers: adminAuth }); +const adminMe = await response.json(); +const adminUserId = adminMe.user.id; + +response = await req(`/api/projects/${project.id}/calendar-subscriptions`, { + method: 'POST', + headers: adminAuth, + body: json({ name: 'Admin calendar', expiresAtMs: Date.now() + (7 * 24 * 60 * 60 * 1000) }), +}); +assert.equal(response.status, 201, 'admin can manage a project calendar subscription'); +const adminSubscription = await response.json(); +response = await req(adminSubscription.feedPath); +assert.equal(response.status, 200, 'admin subscription works before membership removal'); + +response = await req(`/api/orgs/${ownerOrgId}/members/${adminUserId}`, { + method: 'DELETE', + headers: auth, +}); +assert.equal(response.status, 200, 'owner removes the admin membership'); +response = await req(adminSubscription.feedPath); +assert.equal(response.status, 401, 'membership removal immediately kills the reusable calendar secret'); +const removedRecord = db.prepare( + 'SELECT revoked_at_ms FROM calendar_subscriptions WHERE subscription_id = ?', +).get(adminSubscription.subscriptionId); +assert.ok(Number.isSafeInteger(removedRecord?.revoked_at_ms), 'membership deletion durably marks the subscription revoked'); +const removalEvidence = db.prepare( + "SELECT event_type FROM calendar_subscription_audit_outbox WHERE subscription_id = ? AND event_type = 'revoked' ORDER BY audit_event_id DESC LIMIT 1", +).get(adminSubscription.subscriptionId); +assert.equal(removalEvidence?.event_type, 'revoked', 'membership deletion persists secret-free revocation evidence'); + +response = await req(`/api/projects/${project.id}/calendar.ics?token=${encodeURIComponent(currentToken)}`); +assert.equal(response.status, 200, 'legacy session-query calendar transport remains during the staged migration'); +response = await req(`/api/projects/${project.id}/calendar.ics?token=${encodeURIComponent(currentToken)}`, { headers: auth }); +assert.equal(response.status, 401, 'mixed legacy query and Authorization credentials fail closed'); +response = await req(`/api/projects/${otherProject.id}/calendar.ics?token=${encodeURIComponent(currentToken)}`); +assert.equal(response.status, 200, 'current session retains access to another owned project'); +response = await req(`/api/projects/999999/calendar.ics?token=${encodeURIComponent(currentToken)}`); +assert.equal(response.status, 404, 'legacy authenticated transport preserves tenant-nondisclosing project lookup'); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 32ed191f..ae06ec99 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -49,6 +49,11 @@ assert.match( /--include=server\/calendar_subscription_sqlite\.mjs/, 'the durable calendar-subscription SQLite adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/runtime_app\.mjs/, + 'the calendar-subscription production composition is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/access-grant-domain\.test\.mjs/,