From c5ee17714dde918461a91ca5ebe78589c8ecbb9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:55:00 +0900 Subject: [PATCH 01/12] test(billing): define Stripe webhook trust boundary --- tests/unit/stripe-webhook-boundary.test.mjs | 193 ++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/unit/stripe-webhook-boundary.test.mjs diff --git a/tests/unit/stripe-webhook-boundary.test.mjs b/tests/unit/stripe-webhook-boundary.test.mjs new file mode 100644 index 00000000..c91d79c8 --- /dev/null +++ b/tests/unit/stripe-webhook-boundary.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import test from 'node:test'; + +const SECRET = 'whsec_scopeweave_webhook_test_secret'; +const NOW_SECONDS = 1_800_000_000; + +const { StripeWebhookError, verifyStripeWebhookRequest } = await import( + '../../server/stripe_webhook.mjs' +); + +function signatureHeader(bodyBytes, timestamp = NOW_SECONDS, secret = SECRET, extra = '') { + const digest = createHmac('sha256', secret) + .update(String(timestamp)) + .update('.') + .update(bodyBytes) + .digest('hex'); + return `t=${timestamp},v1=${digest}${extra}`; +} + +function webhookRequest(bodyBytes, { + signature = signatureHeader(bodyBytes), + contentLength, +} = {}) { + const headers = new Headers({ + 'content-type': 'application/json', + 'stripe-signature': signature, + }); + if (contentLength !== undefined) headers.set('content-length', String(contentLength)); + return new Request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers, + body: bodyBytes, + duplex: 'half', + }); +} + +function encoded(value) { + return new TextEncoder().encode(value); +} + +async function expectWebhookError(operation, code, status) { + await assert.rejects(operation, (error) => { + assert.ok(error instanceof StripeWebhookError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +test('verified webhook preserves the exact signed raw body and returns bounded event identity', async () => { + const bytes = encoded('{\n "id":"evt_scopeweave_1",\n "type":"checkout.session.completed",\n "data":{"object":{"client_reference_id":"7"}}\n}\n'); + const event = await verifyStripeWebhookRequest(webhookRequest(bytes), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }); + + assert.equal(event.id, 'evt_scopeweave_1'); + assert.equal(event.type, 'checkout.session.completed'); + assert.equal(event.data.object.client_reference_id, '7'); +}); + +test('signature verification fails when JSON-equivalent bytes differ from the signed body', async () => { + const signedBytes = encoded('{"id":"evt_raw","type":"checkout.session.completed"}'); + const mutatedBytes = encoded('{ "id": "evt_raw", "type": "checkout.session.completed" }'); + + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(mutatedBytes, { + signature: signatureHeader(signedBytes), + }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_signature_invalid', + 400, + ); +}); + +test('signature parser accepts one matching v1 value and rejects malformed, missing, stale, or future signatures', async () => { + const bytes = encoded('{"id":"evt_sig","type":"invoice.paid"}'); + const valid = signatureHeader(bytes); + const validDigest = valid.split('v1=')[1]; + + const multiple = webhookRequest(bytes, { + signature: `t=${NOW_SECONDS},v1=${'0'.repeat(64)},v1=${validDigest}`, + }); + assert.equal((await verifyStripeWebhookRequest(multiple, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + })).id, 'evt_sig'); + + for (const signature of [ + '', + `t=${NOW_SECONDS}`, + `v1=${validDigest}`, + `t=not-a-number,v1=${validDigest}`, + `t=${NOW_SECONDS},v1=xyz`, + `t=${NOW_SECONDS - 301},v1=${validDigest}`, + `t=${NOW_SECONDS + 301},v1=${validDigest}`, + ]) { + const request = webhookRequest(bytes, { signature }); + await expectWebhookError( + () => verifyStripeWebhookRequest(request, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_signature_invalid', + 400, + ); + } +}); + +test('body byte ceiling rejects declared and streamed oversize requests before JSON parsing', async () => { + const small = encoded('{"id":"evt_size","type":"invoice.paid"}'); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(small, { + contentLength: 262_145, + }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_body_too_large', + 413, + ); + + const large = encoded(JSON.stringify({ + id: 'evt_stream_size', + type: 'invoice.paid', + data: 'x'.repeat(262_144), + })); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(large), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_body_too_large', + 413, + ); +}); + +test('invalid content length, payload JSON, event identity, and verifier configuration fail closed', async () => { + const validBytes = encoded('{"id":"evt_valid","type":"invoice.paid"}'); + + for (const contentLength of ['-1', 'NaN', '1.5', '999999999999999999999999']) { + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(validBytes, { contentLength }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_request_invalid', + 400, + ); + } + + const malformed = encoded('{"id":'); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(malformed), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_payload_invalid', + 400, + ); + + for (const value of [ + null, + [], + {}, + { id: '', type: 'invoice.paid' }, + { id: 'evt_ok', type: '' }, + { id: 'x'.repeat(256), type: 'invoice.paid' }, + { id: 'evt_ok', type: 'x'.repeat(256) }, + ]) { + const bytes = encoded(JSON.stringify(value)); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(bytes), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_payload_invalid', + 400, + ); + } + + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(validBytes), { + secret: ' ', + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_not_configured', + 503, + ); +}); From 4317f3fc381c25e84b71b9af0b1a8459bf06710d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:55:21 +0900 Subject: [PATCH 02/12] test(billing): run Stripe webhook boundary regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aa27afca..5fba01fc 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env 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/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs", - "test:unit": "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/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs", + "test:unit": "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/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.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/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/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/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && npm run test:api", "test:e2e": "playwright test", From 26afb040189f2ab443cd5c0772de131f07cf45a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:57:08 +0900 Subject: [PATCH 03/12] test(billing): prove webhook replay window independently --- tests/unit/stripe-webhook-boundary.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/stripe-webhook-boundary.test.mjs b/tests/unit/stripe-webhook-boundary.test.mjs index c91d79c8..20ec2df3 100644 --- a/tests/unit/stripe-webhook-boundary.test.mjs +++ b/tests/unit/stripe-webhook-boundary.test.mjs @@ -95,8 +95,8 @@ test('signature parser accepts one matching v1 value and rejects malformed, miss `v1=${validDigest}`, `t=not-a-number,v1=${validDigest}`, `t=${NOW_SECONDS},v1=xyz`, - `t=${NOW_SECONDS - 301},v1=${validDigest}`, - `t=${NOW_SECONDS + 301},v1=${validDigest}`, + signatureHeader(bytes, NOW_SECONDS - 301), + signatureHeader(bytes, NOW_SECONDS + 301), ]) { const request = webhookRequest(bytes, { signature }); await expectWebhookError( From 493ae4f564fec7ef16c4bca71e814b01626914f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:58:01 +0900 Subject: [PATCH 04/12] fix(billing): verify raw Stripe webhook signatures --- server/stripe_webhook.mjs | 224 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 server/stripe_webhook.mjs diff --git a/server/stripe_webhook.mjs b/server/stripe_webhook.mjs new file mode 100644 index 00000000..1647fd67 --- /dev/null +++ b/server/stripe_webhook.mjs @@ -0,0 +1,224 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const STRIPE_WEBHOOK_MAX_BYTES = 256 * 1024; +const STRIPE_SIGNATURE_HEADER_MAX_LENGTH = 4096; +const STRIPE_SIGNATURE_TOLERANCE_SECONDS = 5 * 60; +const STRIPE_EVENT_FIELD_MAX_LENGTH = 255; +const HEX_SHA256_PATTERN = /^[0-9a-f]{64}$/i; +const DECIMAL_INTEGER_PATTERN = /^\d+$/; + +/** + * Stable, browser-safe Stripe webhook boundary failure. + * + * The error contains only a machine-readable classification and HTTP status; + * signatures, webhook secrets, raw provider payloads, and parser details never + * cross this boundary. + */ +export class StripeWebhookError extends Error { + /** + * Create one sanitized webhook verification failure. + * @param {string} code stable machine-readable error code + * @param {number} status HTTP response status for the adapter + */ + constructor(code, status) { + super(code); + this.name = 'StripeWebhookError'; + this.code = code; + this.status = status; + } +} + +function webhookError(code, status = 400) { + return new StripeWebhookError(code, status); +} + +function requireVerifierConfiguration(secret, nowSeconds) { + if (typeof secret !== 'string' || secret.trim().length === 0) { + throw webhookError('stripe_webhook_not_configured', 503); + } + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { + throw webhookError('stripe_webhook_request_invalid'); + } +} + +async function readBoundedRawBody(request) { + if (!request || typeof request !== 'object' || !request.headers) { + throw webhookError('stripe_webhook_request_invalid'); + } + + const declaredLength = request.headers.get('content-length'); + if (declaredLength !== null) { + const normalizedLength = declaredLength.trim(); + if (!DECIMAL_INTEGER_PATTERN.test(normalizedLength)) { + throw webhookError('stripe_webhook_request_invalid'); + } + const length = Number(normalizedLength); + if (!Number.isSafeInteger(length)) { + throw webhookError('stripe_webhook_request_invalid'); + } + if (length > STRIPE_WEBHOOK_MAX_BYTES) { + throw webhookError('stripe_webhook_body_too_large', 413); + } + } + + const reader = request.body?.getReader?.(); + if (!reader || typeof reader.read !== 'function') { + throw webhookError('stripe_webhook_request_invalid'); + } + + const chunks = []; + let totalBytes = 0; + try { + for (;;) { + let result; + try { + result = await reader.read(); + } catch { + throw webhookError('stripe_webhook_request_invalid'); + } + if (result.done) break; + if (!(result.value instanceof Uint8Array)) { + throw webhookError('stripe_webhook_request_invalid'); + } + totalBytes += result.value.byteLength; + if (totalBytes > STRIPE_WEBHOOK_MAX_BYTES) { + try { + await reader.cancel(); + } catch { + // Cancellation is best effort after the byte budget has failed closed. + } + throw webhookError('stripe_webhook_body_too_large', 413); + } + chunks.push(result.value); + } + } finally { + try { + reader.releaseLock?.(); + } catch { + // Reader cleanup cannot change the verification result. + } + } + + const body = Buffer.allocUnsafe(totalBytes); + let offset = 0; + for (const chunk of chunks) { + Buffer.from(chunk).copy(body, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseStripeSignatureHeader(header) { + if ( + typeof header !== 'string' + || header.length === 0 + || header.length > STRIPE_SIGNATURE_HEADER_MAX_LENGTH + ) { + throw webhookError('stripe_webhook_signature_invalid'); + } + + const timestamps = []; + const signatures = []; + for (const component of header.split(',')) { + const separator = component.indexOf('='); + if (separator <= 0) continue; + const key = component.slice(0, separator).trim(); + const value = component.slice(separator + 1).trim(); + if (key === 't') timestamps.push(value); + if (key === 'v1') signatures.push(value); + } + + if (timestamps.length !== 1 || !DECIMAL_INTEGER_PATTERN.test(timestamps[0])) { + throw webhookError('stripe_webhook_signature_invalid'); + } + const timestamp = Number(timestamps[0]); + if (!Number.isSafeInteger(timestamp) || timestamp < 0 || signatures.length === 0) { + throw webhookError('stripe_webhook_signature_invalid'); + } + + const validSignatures = signatures.filter((signature) => HEX_SHA256_PATTERN.test(signature)); + if (validSignatures.length === 0) { + throw webhookError('stripe_webhook_signature_invalid'); + } + return { timestamp, signatures: validSignatures }; +} + +function signatureMatches(body, signatureHeader, secret, nowSeconds) { + const { timestamp, signatures } = parseStripeSignatureHeader(signatureHeader); + if (Math.abs(nowSeconds - timestamp) > STRIPE_SIGNATURE_TOLERANCE_SECONDS) { + return false; + } + + const expected = createHmac('sha256', secret) + .update(String(timestamp)) + .update('.') + .update(body) + .digest(); + + let matched = false; + for (const signature of signatures) { + const candidate = Buffer.from(signature, 'hex'); + if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) { + matched = true; + } + } + return matched; +} + +function parseVerifiedEvent(body) { + let event; + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(body); + event = JSON.parse(text); + } catch { + throw webhookError('stripe_webhook_payload_invalid'); + } + + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw webhookError('stripe_webhook_payload_invalid'); + } + if ( + typeof event.id !== 'string' + || event.id.length === 0 + || event.id.length > STRIPE_EVENT_FIELD_MAX_LENGTH + || typeof event.type !== 'string' + || event.type.length === 0 + || event.type.length > STRIPE_EVENT_FIELD_MAX_LENGTH + ) { + throw webhookError('stripe_webhook_payload_invalid'); + } + return event; +} + +/** + * Verify and parse one Stripe webhook without mutating its signed request body. + * + * Stripe signs `timestamp + "." + raw request body`; JSON parsing therefore + * happens only after constant-time HMAC verification over the exact streamed + * bytes. The request body is capped at 256 KiB before buffering, the signature + * header is bounded, and the signed timestamp must be within five minutes of the + * server clock. Multiple `v1` values are accepted for endpoint-secret rotation. + * + * This function establishes transport authenticity only. It intentionally does + * not deduplicate event IDs, assume delivery ordering, or grant billing + * entitlements; those operations require durable provider-state reconciliation. + * + * @param {Request} request Fetch-compatible request containing the raw webhook body + * @param {object} options verifier configuration + * @param {string} options.secret Stripe endpoint signing secret + * @param {number} [options.nowSeconds] integer epoch seconds used for replay checks + * @returns {Promise>} verified bounded Stripe event object + * @throws {StripeWebhookError} for unconfigured, oversized, malformed, or unauthenticated requests + */ +export async function verifyStripeWebhookRequest(request, { + secret, + nowSeconds = Math.floor(Date.now() / 1000), +} = {}) { + requireVerifierConfiguration(secret, nowSeconds); + const body = await readBoundedRawBody(request); + const signatureHeader = request.headers.get('stripe-signature'); + if (!signatureMatches(body, signatureHeader, secret, nowSeconds)) { + throw webhookError('stripe_webhook_signature_invalid'); + } + return parseVerifiedEvent(body); +} From 62b4900f8ed0475130bc5ec10e59bdd0817f18d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:59:31 +0900 Subject: [PATCH 05/12] test(api): require authenticated Stripe webhook delivery --- tests/api/stripe-webhook.test.mjs | 135 ++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/api/stripe-webhook.test.mjs diff --git a/tests/api/stripe-webhook.test.mjs b/tests/api/stripe-webhook.test.mjs new file mode 100644 index 00000000..870379d7 --- /dev/null +++ b/tests/api/stripe-webhook.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import test from 'node:test'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_webhook'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; + +const { app } = await import('../../server/app.mjs?stripe-webhook-api-test=1'); + +const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; +const jsonHeaders = { 'content-type': 'application/json' }; + +function signatureHeader(body, timestamp = Math.floor(Date.now() / 1000)) { + const digest = createHmac('sha256', WEBHOOK_SECRET) + .update(String(timestamp)) + .update('.') + .update(body) + .digest('hex'); + return `t=${timestamp},v1=${digest}`; +} + +async function signupAndOrg() { + const signup = await app.request('https://scopeweave.example/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + email: `webhook-${Date.now()}-${Math.random()}@example.test`, + password: 'password123', + name: 'Webhook Owner', + }), + }); + assert.equal(signup.status, 200); + const { token } = await signup.json(); + const me = await app.request('https://scopeweave.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200); + const body = await me.json(); + return { token, orgId: body.orgs[0].id }; +} + +async function currentPlan(token) { + const response = await app.request('https://scopeweave.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 200); + return (await response.json()).orgs[0].plan; +} + +function checkoutCompletedBody(orgId) { + return JSON.stringify({ + id: `evt_checkout_${orgId}`, + type: 'checkout.session.completed', + data: { + object: { + id: `cs_test_${orgId}`, + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }, + }, + }); +} + +test('unsigned Stripe webhook cannot upgrade an organization', async () => { + const { token, orgId } = await signupAndOrg(); + assert.equal(await currentPlan(token), 'free'); + + const body = checkoutCompletedBody(orgId); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: jsonHeaders, + body, + }); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await currentPlan(token), 'free'); +}); + +test('verified webhook is acknowledged but does not grant entitlement before durable reconciliation', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { received: true }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal( + await currentPlan(token), + 'free', + 'authenticated delivery alone cannot bypass durable duplicate/order/provider-state reconciliation', + ); +}); + +test('stale signed delivery and raw-body mutation fail before entitlement state changes', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId); + const staleTimestamp = Math.floor(Date.now() / 1000) - 301; + + let response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body, staleTimestamp), + }, + body, + }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + + response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body: `${body}\n`, + }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(await currentPlan(token), 'free'); +}); From d08117540b925508d8843a1b7206f76879a9accb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:00:09 +0900 Subject: [PATCH 06/12] test(api): run Stripe webhook trust regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5fba01fc..45d59e28 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "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 --env-file=tests/api/smoke.env 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/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env 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/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs", "test:unit": "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/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.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/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/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/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && npm run test:api", From 20cd2ebae923474505b51e88711f9f3b47429f97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:16:28 +0900 Subject: [PATCH 07/12] fix(billing): enforce verified Stripe webhook boundary --- server/app.mjs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 450be878..d9fadd04 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -10,6 +10,7 @@ import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing. import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; +import { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -601,15 +602,21 @@ app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { return c.json(session); }); -// Stripe webhook (stub). Live mode should verify the signature with -// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. +// Stripe webhook verifies exact raw bytes before acknowledging delivery. A +// verified delivery still cannot mutate entitlements until durable event +// deduplication and out-of-order reconciliation bind it to provider state. app.post('/api/stripe/webhook', async (c) => { - const event = await c.req.json().catch(() => ({})); - if (event?.type === 'checkout.session.completed') { - const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; - if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + try { + await verifyStripeWebhookRequest(c.req.raw, { + secret: process.env.STRIPE_WEBHOOK_SECRET, + }); + return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); + } catch (error) { + if (error instanceof StripeWebhookError) { + return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); + } + return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); } - return c.json({ received: true }); }); // Dev-only: simulate a successful checkout upgrading the org to Pro. From 54e96c0222c04a0dfb6b43a0073008f5f9462473 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:17:40 +0900 Subject: [PATCH 08/12] test(coverage): require Stripe webhook instrumentation --- tests/unit/coverage-script-contract.test.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 802d0dd9..6e615e5e 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\/billing_checkout_attempt\.mjs/, 'the durable Checkout-attempt repository is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_webhook\.mjs/, + 'the Stripe webhook trust boundary is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -59,10 +64,15 @@ assert.match( /tests\/unit\/billing-provider-boundary\.test\.mjs/, 'the Stripe provider trust and transport regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-webhook-boundary\.test\.mjs/, + 'the Stripe webhook trust regression executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, 'coverage cases never recursively invoke a coverage wrapper', ); -console.log('✓ coverage script contract tests passed'); \ No newline at end of file +console.log('✓ coverage script contract tests passed'); From 5845898ec3632488df0c73df2634819c3a28254d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:18:40 +0900 Subject: [PATCH 09/12] test(coverage): instrument Stripe webhook boundary --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 45d59e28..151893bc 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env 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/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs", "test:unit": "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/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.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/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/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/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && npm run test:api", + "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/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/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/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.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", @@ -31,4 +31,4 @@ "c8": "12.0.0", "fast-check": "4.9.0" } -} \ No newline at end of file +} From 2e5dbcc82abbd6c36fbe3623ccc37d0debb7a1af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:20:37 +0900 Subject: [PATCH 10/12] docs(billing): trace Stripe webhook trust boundary --- .../stripe-webhook-trust-boundary.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/doctoring/stripe-webhook-trust-boundary.md diff --git a/docs/doctoring/stripe-webhook-trust-boundary.md b/docs/doctoring/stripe-webhook-trust-boundary.md new file mode 100644 index 00000000..2385f410 --- /dev/null +++ b/docs/doctoring/stripe-webhook-trust-boundary.md @@ -0,0 +1,51 @@ +# Stripe webhook trust boundary + +## Status + +**Active PR only — not protected-`develop` shipped truth.** This note describes the bounded implementation on PR #520, stacked on PR #516 for issue #488. The protected `develop` branch does not contain this behavior until the stack is independently reviewed and merged through live repository protection. + +## Buyer-visible risk closed by this slice + +A billing webhook is an unauthenticated Internet ingress unless the application proves that the exact bytes were signed by the payment provider. Before this slice, `/api/stripe/webhook` parsed arbitrary JSON and could promote an organization to Pro from caller-controlled fields. That made entitlement state writable by an untrusted request. + +PR #520 changes the boundary so a webhook is acknowledged only after verification of the exact raw request bytes. It also removes direct entitlement mutation from the webhook handler. A cryptographically valid delivery therefore proves delivery authenticity, but it does **not** by itself grant billing authority. Durable event deduplication, authoritative provider-state reconciliation, and out-of-order lifecycle handling remain separate follow-on work under #488. + +## Normative evidence and design decisions + +Stripe's webhook documentation requires signature verification against the raw, unmodified request body and warns that JSON parsing, whitespace changes, key reordering, or encoding changes invalidate verification. Stripe also documents a five-minute default timestamp tolerance to mitigate replay and recommends quickly returning a `2xx` response before complex processing. These requirements drive the implementation rather than model judgment. + +The ScopeWeave verifier therefore: + +- reads and signs the exact raw request bytes before JSON parsing; +- bounds both declared and streamed body size at 256 KiB and bounds the signature header at 4 KiB; +- requires one valid signed timestamp plus at least one SHA-256 `v1` signature; +- computes HMAC-SHA-256 over `timestamp + "." + raw_body` and compares candidate digests in constant time; +- applies a symmetric 300-second recency window using an injected clock in tests; +- rejects invalid UTF-8, malformed JSON, and missing or oversized event identity before acknowledging the delivery; +- maps expected verifier failures to sanitized stable error codes and returns `Cache-Control: no-store`; +- acknowledges a valid event without directly changing plan entitlements. + +The body/header limits are ScopeWeave defense-in-depth limits, not claims about Stripe protocol maxima. They bound memory and parser work at an Internet-facing trust boundary. + +## TDD and acceptance trace + +The implementation is covered by two realistic regression layers: + +1. `tests/unit/stripe-webhook-boundary.test.mjs` exercises exact-byte signatures, payload mutation, multiple `v1` signatures, stale/future timestamps, malformed headers, oversized declared and streamed bodies, invalid UTF-8/JSON, configuration errors, and event-identity bounds. +2. `tests/api/stripe-webhook.test.mjs` exercises the real Hono route and database state. It proves unsigned, stale, and mutated deliveries cannot upgrade an organization; a correctly signed delivery is acknowledged; and even a valid `checkout.session.completed` delivery cannot directly mutate the entitlement. + +`package.json` registers both the API regression and the webhook module/unit suite in the canonical c8 coverage path. `tests/unit/coverage-script-contract.test.mjs` fails if that instrumentation or regression registration is later removed. + +Hosted CI on the implementation head must be treated as head-specific evidence. A predecessor run is never sufficient after any subsequent source, test, or documentation commit. + +## Authority boundary still open + +This slice intentionally stops before durable billing lifecycle processing. The next billing authority layer must, at minimum, persist provider event identity for idempotent processing, tolerate duplicate and out-of-order delivery, reconcile against authoritative Stripe state before releasing held Checkout attempts, and produce auditable tenant-scoped evidence. PR #516 provides the adjacent reconciliation persistence boundary but remains a separate stacked dependency. + +No ScopeWeave document should describe webhook-driven Pro activation as shipped until those authority layers and the protected-stack merge are complete. + +## References (APA 7th) + +Stripe. (n.d.). *Receive Stripe events in your webhook endpoint*. Stripe Documentation. Retrieved August 16, 2026, from https://docs.stripe.com/webhooks + +Stripe. (n.d.). *Resolve webhook signature verification errors*. Stripe Documentation. Retrieved August 16, 2026, from https://docs.stripe.com/webhooks/signature From a0c09334c58e5438c9b683ad381b0b30ad7eaf86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 09:44:01 +0900 Subject: [PATCH 11/12] fix(billing): restore webhook child to exact parent tree --- .github/workflows/pages.yml | 2 +- ARCHITECTURE.md | 4 +- CHANGELOG.md | 18 ++-- CLAUDE.md | 4 +- Dockerfile | 2 +- Dockerfile.server | 2 +- README.md | 2 +- docs/doctoring/toast-status-accessibility.md | 95 ++++++++++++++++++++ index.html | 5 +- tests/api/smoke.mjs | 2 +- tests/e2e/scopeweave.spec.js | 1 + tests/e2e/toast-accessibility.spec.js | 24 +++++ tests/unit/toast-accessibility.test.mjs | 64 +++++++++++++ toast-state.css | 8 ++ 14 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 docs/doctoring/toast-status-accessibility.md create mode 100644 tests/e2e/toast-accessibility.spec.js create mode 100644 tests/unit/toast-accessibility.test.mjs create mode 100644 toast-state.css diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1edb57b3..8b0fba65 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -33,7 +33,7 @@ jobs: - name: Stage runtime files run: | mkdir -p _site/docs - cp index.html 404.html app.js styles.css wbs.json _site/ + cp index.html 404.html app.js cloud-sync.js analytics.js styles.css toast-state.css wbs.json _site/ cp docs/user-guide.md _site/docs/ - name: Upload static artifact diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3006c74b..4688d27b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,9 @@ - `index.html`: app shell and modal structure. - `styles.css`: responsive layout, table, badges, gantt, and modal - presentation. + presentation. `.toast.show` is the standalone producer state. +- `toast-state.css`: cloud overlay `.toast.visible` rendering so SaaS + status messages stay visually observable. - `app.js`: state, rendering, editing, validation, persistence, import/export, and Gantt logic. - `analytics.js`: EVM, S-curve, CPM, workload, cost, and requirements/RFI/RFP diff --git a/CHANGELOG.md b/CHANGELOG.md index 40635d5f..8e3cea3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Persisted a tenant/price-scoped Stripe Checkout attempt identity and opaque idempotency key before live Session creation, reusing unresolved identity only - inside a 23-hour safety window; network/abort and Stripe 5xx outcomes remain - pending to avoid duplicate side effects, while known 4xx and invalid successful - responses close the attempt before a later deliberate Checkout receives fresh - authority. + inside a 23-hour safety window; network/abort, Stripe 5xx, malformed or + untrusted successful responses, and local success-persistence failures remain + pending for same-key retry or reconciliation, while known Stripe 4xx outcomes + close the attempt before a later deliberate Checkout receives fresh authority. - Bounded hosted Stripe Checkout provider calls to one 15-second, no-retry attempt with a 1 MiB response ceiling before JSON parsing until durable idempotency exists; validated returned destinations as exact HTTPS @@ -80,6 +80,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 conversion identifiers from responses, reports attempted, changed, failed, skipped-data, and deferred-budget counters separately, and exposes fixed low-cardinality timeout, lookup, validation, and persistence failure counters. +- Toast notifications and synchronization feedback now expose advisory updates + as explicit polite, atomic WAI-ARIA status regions without adding keyboard + stops, and cloud toast feedback now has a shipped visual state so the same + message remains visible to sighted users. +- GitHub Pages, both Docker images, and the SaaS static allowlist now ship + `cloud-sync.js`, `analytics.js`, and `toast-state.css` with the documents + that load them, so share-error and cloud status toasts stay visible after + deploy. - 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다. - 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다. - `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다. @@ -106,4 +114,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하던 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. diff --git a/CLAUDE.md b/CLAUDE.md index b1f11c4d..81c1a819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,8 +56,8 @@ deploy guide is `docs/deploy.md`. ### Client (repo root) -- `index.html` — app shell + modals, strict CSP meta tag; loads `cloud-sync.js`, - `analytics.js`, then `app.js`. +- `index.html` — app shell + modals, strict CSP meta tag; loads `styles.css`, + `toast-state.css`, `cloud-sync.js`, `analytics.js`, then `app.js`. - `app.js` — all state, rendering, editing, validation, persistence, CSV import/export, and Gantt logic. The single global `tasks` array is the source of truth and `renderAll()` is the only rerender path (see `AGENTS.md`). diff --git a/Dockerfile b/Dockerfile index 8f39e187..ebc14bc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM nginx:1.25-alpine@sha256:516475cc129da42866742567714ddc681e5eed7b9ee0b9e9c015e464b4221a00 COPY infra/nginx/default.conf /etc/nginx/conf.d/default.conf -COPY index.html 404.html app.js styles.css wbs.json /usr/share/nginx/html/ +COPY index.html 404.html app.js cloud-sync.js analytics.js styles.css toast-state.css wbs.json /usr/share/nginx/html/ COPY docs/user-guide.md /usr/share/nginx/html/docs/ # Strix security scan recommendation: switch to non-root user diff --git a/Dockerfile.server b/Dockerfile.server index 579360f1..4f162de7 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -12,7 +12,7 @@ RUN npm ci --omit=dev && npm cache clean --force # Server + the static client files the allowlist serves. COPY server ./server -COPY index.html 404.html app.js cloud-sync.js analytics.js styles.css wbs.json ./ +COPY index.html 404.html app.js cloud-sync.js analytics.js styles.css toast-state.css wbs.json ./ ENV PORT=8787 ENV SCOPEWEAVE_DB=/data/scopeweave.db diff --git a/README.md b/README.md index ea8bb77a..6340c1f4 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ two modes: ## Architecture ``` -index.html + app.js + styles.css ← static client (eval-safe; no top-level imports) +index.html + app.js + styles.css + toast-state.css ← static client (eval-safe; no top-level imports) ├─ analytics.js ← EVM/S-curve/CPM (window.ScopeWeaveAnalytics) └─ cloud-sync.js ← opt-in cloud overlay (window.ScopeWeaveCloud) server/ diff --git a/docs/doctoring/toast-status-accessibility.md b/docs/doctoring/toast-status-accessibility.md new file mode 100644 index 00000000..cb684e53 --- /dev/null +++ b/docs/doctoring/toast-status-accessibility.md @@ -0,0 +1,95 @@ +# Toast and sync status accessibility and visibility evidence + +## Status and decision + +ScopeWeave treats transient toast text and synchronization feedback as +advisory status messages. The shipped contract is: + +- `#toast` has `role="status"`, `aria-live="polite"`, and + `aria-atomic="true"` and does not receive focus when its content + changes; +- `#sync-status` uses the same explicit status/polite/atomic semantics + without becoming a synthetic keyboard stop; and +- the cloud/SaaS toast producer's `.visible` state is backed by shipped + CSS that raises opacity to `1` and restores the translated element. + +The visual-state control matters because the base application producer +uses `.show` while `cloud-sync.js` adds and removes `.visible`. +`styles.css` renders `.toast.show`, so a cloud message can update its +live-region text while remaining visually transparent unless +`.toast.visible` is also rendered. After a share link fails, the next +action is to request a fresh share URL from the project owner. + +## Standards boundary + +WAI-ARIA 1.2 defines `status` as advisory live-region content and gives +the role implicit `aria-live="polite"` and `aria-atomic="true"` +semantics. It also advises authors not to move focus to a status message +as a result of the update. WCAG 2.2 Success Criterion 4.1.3 requires +status messages to be programmatically determinable so assistive +technology can present them without receiving focus. ScopeWeave keeps +the explicit live-region attributes in addition to the role so the +intended contract remains visible in markup and executable regression +evidence. + +The `.visible` compatibility rule is a product-integrity control, not a +separate WCAG success criterion. It prevents the same advisory toast +from being available to screen-reader users while remaining transparent +for sighted users. + +## Repair boundary + +PR #491 head `794ecbdf1416e883942dac2b836859ba6f9ac0f9` titled a CI +re-kick but deleted this slice and reverted already-landed orchestrator +and Microsoft Project XML hardening. This repair replays only the toast +and sync-status contract onto current `develop`. It does not change +orchestrator, XML import, authentication, or workflow files. + +`toast-state.css` must stay on every production serve path: the SaaS +static allowlist, both Docker images, and the GitHub Pages stage list. +A share-error toast that updates the live region while remaining +transparent is the buyer-visible failure this lock prevents. + +Do not use empty `ci: re-kick` commits to mutate the tree. Rollback must +remove the status semantics, `toast-state.css`, its production link, +allowlist and image copies, both focused toast regressions and their +test registrations, this doctoring record, and the CHANGELOG entry +together. + +## Executable acceptance evidence + +`tests/unit/toast-accessibility.test.mjs` reads the shipped +`index.html`, `cloud-sync.js`, and `toast-state.css`. It proves that: + +- the production toast exposes status/polite/atomic semantics; +- the production synchronization feedback exposes the same explicit + advisory status semantics; +- neither advisory status region becomes a synthetic keyboard stop; +- the cloud producer actually activates `.visible`; +- the production document loads `toast-state.css`; and +- `.toast.visible` is rendered with visible opacity and transform. + +`tests/e2e/toast-accessibility.spec.js` drives the production cloud +share-error path in Chromium using a valid-shaped but unavailable share +token. It requires the real toast to contain the customer-facing failure +guidance, retain the status semantics, carry `.visible`, reach computed +opacity of at least `0.99`, be visually visible, and leave keyboard +focus elsewhere. + +## Scope and security boundary + +This change does not alter toast or synchronization content, timing, +persistence, authentication, authorization, API semantics, credential +handling, tenant isolation, attachment behavior, Clearfolio integration, +database state, dependencies, workflows, or application +focus-management code. Urgent blocking errors that require immediate +interruption need a separate interaction design rather than silently +changing these advisory status regions to assertive alerts. + +## References + +World Wide Web Consortium. (2023). *Accessible Rich Internet +Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines +(WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ diff --git a/index.html b/index.html index a7f4b49c..d24b2a88 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ + @@ -44,7 +45,7 @@

ScopeWeave Planner

0.00%
- 브라우저 로컬 자동저장 사용 중 + 브라우저 로컬 자동저장 사용 중
@@ -95,7 +96,7 @@

ScopeWeave Planner

-
+