From 6e4632b6ac24f1192e9bf0ae7510ba9311bb1e08 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Tue, 28 Jul 2026 17:36:02 +0700 Subject: [PATCH 01/21] feat(widget): gate host submit by exact origin --- .../host-submit-origin-policy.test.ts | 59 ++++++ .../bug-reports/host-submit-origin-policy.ts | 69 +++++++ .../config-host-submit-origin.test.ts | 169 ++++++++++++++++++ .../src/routes/api/widget/config[.]json.ts | 34 +++- .../quackback-report-submit-contract-v1.json | 64 +++++++ 5 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts create mode 100644 apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts create mode 100644 apps/web/src/routes/api/widget/__tests__/config-host-submit-origin.test.ts create mode 100644 docs/fixtures/quackback-report-submit-contract-v1.json diff --git a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts new file mode 100644 index 000000000..30f8da911 --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts @@ -0,0 +1,59 @@ +import contract from '../../../../../../../../docs/fixtures/quackback-report-submit-contract-v1.json' +import { afterEach, describe, expect, it } from 'vitest' +import { + isBugReportHostOriginAllowed, + normalizeBugReportHostOrigin, + parseBugReportHostSubmitOrigins, +} from '../host-submit-origin-policy' + +describe('bug report host submit origin policy', () => { + afterEach(() => { + delete process.env.BUG_REPORT_HOST_SUBMIT_ORIGINS + delete process.env.TRUSTED_ORIGINS + }) + + it('fails the entire configured list closed when any normalized member is invalid or duplicated', () => { + expect(parseBugReportHostSubmitOrigins(undefined)).toEqual([]) + expect(parseBugReportHostSubmitOrigins(' ')).toEqual([]) + expect(parseBugReportHostSubmitOrigins('https://App.Example:443/')).toEqual([ + 'https://app.example', + ]) + expect( + parseBugReportHostSubmitOrigins('https://a.example, https://b.example') + ).toEqual(['https://a.example', 'https://b.example']) + expect(parseBugReportHostSubmitOrigins('https://a.example,')).toEqual([]) + expect(parseBugReportHostSubmitOrigins('https://a.example/path')).toEqual([]) + expect( + parseBugReportHostSubmitOrigins('https://a.example,https://A.EXAMPLE:443') + ).toEqual([]) + expect(parseBugReportHostSubmitOrigins('http://app.example')).toEqual([]) + expect( + parseBugReportHostSubmitOrigins('http://localhost:3000', { + allowHttpLocalhostForTest: true, + }) + ).toEqual(['http://localhost:3000']) + }) + + it('rejects opaque and ambiguous candidate origins', () => { + expect(normalizeBugReportHostOrigin('null')).toBeNull() + expect(normalizeBugReportHostOrigin('https://a.example, https://b.example')).toBeNull() + }) + + it('returns a frozen allow-list and authorizes only an exact normalized origin', () => { + const raw = ['https://allowed.example', 'https://other.example'].join( + contract.originPolicy.delimiter + ) + const parsed = parseBugReportHostSubmitOrigins(raw) + + expect(Object.isFrozen(parsed)).toBe(true) + expect(isBugReportHostOriginAllowed('https://ALLOWED.example:443', raw)).toBe(true) + expect(isBugReportHostOriginAllowed('https://sub.allowed.example', raw)).toBe(false) + }) + + it('does not treat TRUSTED_ORIGINS as host-submit authorization', () => { + process.env.TRUSTED_ORIGINS = 'https://allowed.example' + delete process.env[contract.originPolicy.environmentVariable] + + expect(isBugReportHostOriginAllowed('https://allowed.example')).toBe(false) + }) +}) diff --git a/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts b/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts new file mode 100644 index 000000000..4ec1b26f5 --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts @@ -0,0 +1,69 @@ +export interface BugReportHostOriginParseOptions { + allowHttpLocalhostForTest?: boolean +} + +const EMPTY_ORIGINS: readonly string[] = Object.freeze([]) +const TEST_HTTP_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']) +const ORIGIN_PATTERN = /^(https?):\/\/[^/?#]+\/?$/ + +export function normalizeBugReportHostOrigin( + candidate: unknown, + options?: BugReportHostOriginParseOptions +): string | null { + if (typeof candidate !== 'string') return null + + const value = candidate.trim() + if (!value || value.includes(',') || !ORIGIN_PATTERN.test(value)) return null + + let parsed: URL + try { + parsed = new URL(value) + } catch { + return null + } + + if (parsed.username || parsed.password || parsed.pathname !== '/') return null + + if (parsed.protocol === 'https:') return parsed.origin + if ( + parsed.protocol === 'http:' && + options?.allowHttpLocalhostForTest === true && + TEST_HTTP_HOSTS.has(parsed.hostname) + ) { + return parsed.origin + } + + return null +} + +export function parseBugReportHostSubmitOrigins( + raw: unknown, + options?: BugReportHostOriginParseOptions +): readonly string[] { + if (typeof raw !== 'string' || raw.trim() === '') return EMPTY_ORIGINS + + const normalized: string[] = [] + const seen = new Set() + + for (const member of raw.split(',')) { + if (member.trim() === '') return EMPTY_ORIGINS + + const origin = normalizeBugReportHostOrigin(member, options) + if (origin === null || seen.has(origin)) return EMPTY_ORIGINS + + seen.add(origin) + normalized.push(origin) + } + + return Object.freeze(normalized) +} + +export function isBugReportHostOriginAllowed( + candidate: unknown, + raw: unknown = process.env.BUG_REPORT_HOST_SUBMIT_ORIGINS +): boolean { + const origin = normalizeBugReportHostOrigin(candidate) + if (origin === null) return false + + return parseBugReportHostSubmitOrigins(raw).includes(origin) +} diff --git a/apps/web/src/routes/api/widget/__tests__/config-host-submit-origin.test.ts b/apps/web/src/routes/api/widget/__tests__/config-host-submit-origin.test.ts new file mode 100644 index 000000000..f39823d3d --- /dev/null +++ b/apps/web/src/routes/api/widget/__tests__/config-host-submit-origin.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getPublicWidgetConfig: vi.fn(), + getBrandingConfig: vi.fn(), + getCustomCss: vi.fn(), +})) + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (options: unknown) => ({ options })), +})) + +vi.mock('@/lib/server/domains/settings/settings.widget', () => ({ + getPublicWidgetConfig: mocks.getPublicWidgetConfig, +})) + +vi.mock('@/lib/server/domains/settings/settings.media', () => ({ + getBrandingConfig: mocks.getBrandingConfig, + getCustomCss: mocks.getCustomCss, +})) + +import { Route } from '../config[.]json' + +interface RouteOptions { + server: { + handlers: { + GET: (context: { request: Request }) => Promise + } + } +} + +const GET = (Route as unknown as { options: RouteOptions }).options.server.handlers.GET + +function publicWidgetConfig({ + bugReportEnabled = true, + receiptsEnabled = true, +}: { + bugReportEnabled?: boolean + receiptsEnabled?: boolean +} = {}) { + return { + enabled: true, + defaultBoard: 'roadmap', + position: 'bottom-right', + tabs: { feedback: true, changelog: false, help: true, chat: false, home: true }, + hmacRequired: false, + chat: { enabled: false }, + bugReport: { + enabled: bugReportEnabled, + receipts: receiptsEnabled, + boardSlug: 'bug-reports', + mediaEvidence: false, + privateEvidence: false, + screenRecording: false, + broadMediaUpload: false, + }, + } +} + +async function readConfig({ origin }: { origin?: string } = {}) { + const headers = new Headers() + if (origin !== undefined) headers.set('Origin', origin) + + const response = await GET({ + request: new Request('https://quackback.example/api/widget/config.json', { headers }), + }) + const result = { + body: await response.json(), + headers: { + 'access-control-allow-origin': response.headers.get('access-control-allow-origin'), + 'cache-control': response.headers.get('cache-control'), + vary: response.headers.get('vary'), + }, + } + + expect(result.headers['access-control-allow-origin']).not.toBe('*') + return result +} + +describe('GET /api/widget/config.json host-submit projection', () => { + beforeEach(() => { + process.env.BUG_REPORT_HOST_SUBMIT_ORIGINS = 'https://allowed.example' + mocks.getPublicWidgetConfig.mockReset().mockResolvedValue(publicWidgetConfig()) + mocks.getBrandingConfig.mockReset().mockResolvedValue({ themeMode: 'user' }) + mocks.getCustomCss.mockReset().mockResolvedValue(null) + }) + + afterEach(() => { + delete process.env.BUG_REPORT_HOST_SUBMIT_ORIGINS + }) + + it('enables host submit only for an allowed request origin', async () => { + expect(await readConfig({ origin: 'https://allowed.example' })).toMatchObject({ + body: { bugReportHostSubmit: true }, + headers: { + 'access-control-allow-origin': 'https://allowed.example', + 'cache-control': 'private, no-store', + vary: 'Origin', + }, + }) + + expect(await readConfig({ origin: 'https://other.example' })).toMatchObject({ + body: { bugReportHostSubmit: false }, + headers: { + 'access-control-allow-origin': 'https://other.example', + 'cache-control': 'private, no-store', + vary: 'Origin', + }, + }) + }) + + it('omits ACAO and fails closed for absent or ambiguous Origin headers', async () => { + expect(await readConfig()).toMatchObject({ + body: { bugReportHostSubmit: false }, + headers: { + 'access-control-allow-origin': null, + 'cache-control': 'private, no-store', + vary: 'Origin', + }, + }) + expect( + await readConfig({ origin: 'https://allowed.example, https://other.example' }) + ).toMatchObject({ + body: { bugReportHostSubmit: false }, + headers: { + 'access-control-allow-origin': null, + 'cache-control': 'private, no-store', + vary: 'Origin', + }, + }) + }) + + it('requires both bug reporting and receipts to be enabled', async () => { + mocks.getPublicWidgetConfig.mockResolvedValueOnce( + publicWidgetConfig({ receiptsEnabled: false }) + ) + expect(await readConfig({ origin: 'https://allowed.example' })).toMatchObject({ + body: { bugReportHostSubmit: false }, + }) + + mocks.getPublicWidgetConfig.mockResolvedValueOnce( + publicWidgetConfig({ bugReportEnabled: false }) + ) + expect(await readConfig({ origin: 'https://allowed.example' })).toMatchObject({ + body: { bugReportHostSubmit: false }, + }) + }) + + it('fails the projection closed when the environment list is malformed', async () => { + process.env.BUG_REPORT_HOST_SUBMIT_ORIGINS = + 'https://allowed.example,https://ALLOWED.example:443' + + expect(await readConfig({ origin: 'https://allowed.example' })).toMatchObject({ + body: { bugReportHostSubmit: false }, + headers: { + 'access-control-allow-origin': 'https://allowed.example', + }, + }) + }) + + it('normalizes the exact request origin before authorization and ACAO projection', async () => { + expect(await readConfig({ origin: 'https://ALLOWED.example:443' })).toMatchObject({ + body: { bugReportHostSubmit: true }, + headers: { + 'access-control-allow-origin': 'https://allowed.example', + }, + }) + }) +}) diff --git a/apps/web/src/routes/api/widget/config[.]json.ts b/apps/web/src/routes/api/widget/config[.]json.ts index 0b57f6b7c..36e77a0de 100644 --- a/apps/web/src/routes/api/widget/config[.]json.ts +++ b/apps/web/src/routes/api/widget/config[.]json.ts @@ -1,4 +1,8 @@ import { createFileRoute } from '@tanstack/react-router' +import { + isBugReportHostOriginAllowed, + normalizeBugReportHostOrigin, +} from '@/lib/server/domains/bug-reports/host-submit-origin-policy' import { projectBugReportMediaEvidence } from '@/lib/shared/bugreport/media-evidence-gates' interface ServerTheme { @@ -24,15 +28,22 @@ interface ServerConfig { bugReportScreenRecording?: boolean /** Imported image/animation/video gate; requires private evidence. */ bugReportBroadMediaUpload?: boolean + /** Exact-origin gate for host-mediated report submission. */ + bugReportHostSubmit: boolean } -function jsonResponse(body: unknown, maxAge: number): Response { +function jsonResponse(body: unknown, requestOrigin: string | null): Response { + const headers = new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'private, no-store', + Vary: 'Origin', + }) + if (requestOrigin !== null) { + headers.set('Access-Control-Allow-Origin', requestOrigin) + } + return new Response(JSON.stringify(body), { - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': `public, max-age=${maxAge}`, - }, + headers, }) } @@ -82,7 +93,8 @@ async function extractThemeFromCss(css: string): Promise { export const Route = createFileRoute('/api/widget/config.json')({ server: { handlers: { - GET: async () => { + GET: async ({ request }) => { + const requestOrigin = normalizeBugReportHostOrigin(request.headers.get('Origin')) const { getPublicWidgetConfig } = await import('@/lib/server/domains/settings/settings.widget') const { getBrandingConfig, getCustomCss } = @@ -93,7 +105,7 @@ export const Route = createFileRoute('/api/widget/config.json')({ const widgetConfig = await getPublicWidgetConfig() if (!widgetConfig.enabled) { - return jsonResponse({ enabled: false }, 60) + return jsonResponse({ enabled: false, bugReportHostSubmit: false }, requestOrigin) } const theme: ServerTheme = {} @@ -137,9 +149,13 @@ export const Route = createFileRoute('/api/widget/config.json')({ bugReportPrivateEvidence: mediaEvidence.privateEvidence, bugReportScreenRecording: mediaEvidence.screenRecording, bugReportBroadMediaUpload: mediaEvidence.broadMediaUpload, + bugReportHostSubmit: + widgetConfig.bugReport?.enabled === true && + widgetConfig.bugReport.receipts === true && + isBugReportHostOriginAllowed(requestOrigin), } - return jsonResponse(config, 3600) + return jsonResponse(config, requestOrigin) }, }, }, diff --git a/docs/fixtures/quackback-report-submit-contract-v1.json b/docs/fixtures/quackback-report-submit-contract-v1.json new file mode 100644 index 000000000..ba5466f49 --- /dev/null +++ b/docs/fixtures/quackback-report-submit-contract-v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": "QuackbackReportSubmitContractV1", + "adapterContract": "iplaycafe.quackback.report-submit/1", + "requestType": "quackback:report-submit", + "resultType": "quackback:report-submit-result", + "attemptTimeoutMs": 10000, + "originAuthorizationTimeoutMs": 2000, + "preparation": { + "initOption": "prepareHostReportSubmit", + "enabledValue": true, + "defaultValue": false, + "requiresDeferredLauncher": true + }, + "originPolicy": { + "environmentVariable": "BUG_REPORT_HOST_SUBMIT_ORIGINS", + "delimiter": ",", + "productionSchemes": ["https:"], + "testHttpHosts": ["localhost", "127.0.0.1", "[::1]"], + "entireListFailsClosed": true + }, + "inputLimits": { + "summaryCodeUnits": 2000, + "impactCodeUnits": 1000, + "titleCodeUnits": 200 + }, + "patterns": { + "uuidV4": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "reportRef": "^qbr_[A-Za-z0-9_-]{24}$" + }, + "readiness": { + "sdkVersion": "0.13.1-ipc.22", + "feedbackContract": "iplaycafe.feedback/7", + "lifecycleVersion": 4, + "diagnosticsVersion": 2, + "locales": ["en", "th"], + "features": [ + "direct-report", + "screenshot", + "text-only", + "lifecycle-events", + "media-lifecycle-events", + "private-evidence-attach", + "private-report-receipts", + "host-report-submit", + "broad-media-upload", + "launcher-activation" + ] + }, + "exactKeys": { + "input": ["clientSubmissionId", "summary", "impact"], + "request": ["type", "data"], + "requestData": ["contract", "requestId", "clientSubmissionId", "summary", "impact"], + "result": ["type", "data"], + "successData": ["contract", "requestId", "accepted", "receipt"], + "failureData": ["contract", "requestId", "accepted", "reason"], + "publicSuccessResult": ["accepted", "receipt"], + "publicFailureResult": ["accepted", "reason"], + "receiptRequired": ["schemaVersion", "reportRef", "status", "createdAt", "updatedAt"], + "receiptOptional": ["fixedInRelease"] + }, + "statuses": ["received", "triaging", "needs_info", "in_progress", "verifying", "fixed", "closed"], + "failureReasons": ["aborted", "invalid_request", "unavailable", "unauthorized", "retryable_failure"], + "telemetryForbiddenFields": ["summary", "impact", "clientSubmissionId", "requestId", "reportRef", "boardId", "postId", "principalId", "email", "url", "rawError", "evidenceId"] +} From 82309719735bbc072fdef4f4fcf306ec26700313 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Tue, 28 Jul 2026 18:01:41 +0700 Subject: [PATCH 02/21] feat(widget): add authenticated host submit bridge --- .../__tests__/bug-report-host-submit.test.ts | 399 ++++++++++++++++++ .../src/lib/client/bug-report-host-submit.ts | 270 ++++++++++++ .../domains/bug-reports/host-submit-errors.ts | 12 + .../auth-helpers-host-failures.test.ts | 228 ++++++++++ .../bug-report-receipts-boundary.test.ts | 323 +++++++++++++- .../portal-access-host-outcome.test.ts | 235 +++++++++++ .../src/lib/server/functions/auth-helpers.ts | 90 +++- .../functions/bug-report-host-submit.ts | 50 +++ .../server/functions/bug-report-receipts.ts | 112 ++++- .../src/lib/server/functions/portal-access.ts | 322 +++++++------- .../__tests__/host-submit-contract.test.ts | 223 ++++++++++ .../shared/bugreport/host-submit-contract.ts | 297 +++++++++++++ apps/web/src/routes/widget/index.tsx | 47 ++- 13 files changed, 2409 insertions(+), 199 deletions(-) create mode 100644 apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts create mode 100644 apps/web/src/lib/client/bug-report-host-submit.ts create mode 100644 apps/web/src/lib/server/domains/bug-reports/host-submit-errors.ts create mode 100644 apps/web/src/lib/server/functions/__tests__/auth-helpers-host-failures.test.ts create mode 100644 apps/web/src/lib/server/functions/__tests__/portal-access-host-outcome.test.ts create mode 100644 apps/web/src/lib/server/functions/bug-report-host-submit.ts create mode 100644 apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts create mode 100644 apps/web/src/lib/shared/bugreport/host-submit-contract.ts diff --git a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts new file mode 100644 index 000000000..6fd32bcd9 --- /dev/null +++ b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts @@ -0,0 +1,399 @@ +import { readFileSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + installBugReportHostParentBinding, + installBugReportHostSubmitBridge, +} from '../bug-report-host-submit' + +const REQUEST_ID = '11111111-1111-4111-8111-111111111111' +const SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' +const CONTRACT = 'iplaycafe.quackback.report-submit/1' +const RECEIPT = { + schemaVersion: 'BugReportReceiptV1', + reportRef: 'qbr_abcdefghijklmnopqrstuvwx', + status: 'received', + createdAt: '2026-07-28T01:00:00.000Z', + updatedAt: '2026-07-28T02:00:00.000Z', +} as const + +function validRequest(overrides: Record = {}) { + return { + type: 'quackback:report-submit', + data: { + contract: CONTRACT, + requestId: REQUEST_ID, + clientSubmissionId: SUBMISSION_ID, + summary: ' Save button\n\ndoes nothing ', + impact: ' Cannot finish checkout ', + ...overrides, + }, + } +} + +function expectedResult(data: Record) { + return { + type: 'quackback:report-submit-result', + data: { + contract: CONTRACT, + requestId: REQUEST_ID, + ...data, + }, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function createWindowHarness() { + const listeners = new Set<(event: MessageEvent) => void>() + const parent = { postMessage: vi.fn() } + const target = { + parent, + addEventListener: vi.fn((type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message') listeners.add(listener) + }), + removeEventListener: vi.fn((type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message') listeners.delete(listener) + }), + } + return { + parent, + target: target as unknown as Window, + dispatch({ + source = parent, + origin = 'https://app.example', + data, + }: { + source?: unknown + origin?: string + data: unknown + }) { + const event = { source, origin, data } as MessageEvent + for (const listener of [...listeners]) listener(event) + }, + } +} + +async function flushAsyncWork() { + for (let index = 0; index < 12; index += 1) await Promise.resolve() +} + +beforeEach(() => { + vi.useRealTimers() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('authenticated host submit bridge', () => { + it('binds the exact current parent and replies to its immutable exact origin', async () => { + const harness = createWindowHarness() + let generation = 1 + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const submit = vi.fn().mockResolvedValue({ accepted: true, receipt: RECEIPT }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => generation, + target: harness.target, + }) + const dispose = installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify', data: { email: 'private' } } }) + await flushAsyncWork() + expect(authorize).toHaveBeenCalledWith('https://app.example') + expect(Object.isFrozen(parentBinding.current())).toBe(true) + + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + + expect(submit).toHaveBeenCalledWith({ + clientSubmissionId: SUBMISSION_ID, + title: 'Save button', + content: 'Save button\n\ndoes nothing\n\nImpact:\nCannot finish checkout', + }) + const validResult = expectedResult({ accepted: true, receipt: RECEIPT }) + expect(harness.parent.postMessage).toHaveBeenCalledWith(validResult, 'https://app.example') + expect(harness.parent.postMessage.mock.calls.some((call) => call[1] === '*')).toBe(false) + + dispose() + parentBinding.dispose() + generation += 1 + }) + + it('cannot rebind a generation to another allowlisted origin on the same WindowProxy', async () => { + const harness = createWindowHarness() + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 7, + target: harness.target, + }) + + harness.dispatch({ origin: 'https://app.example', data: { type: 'quackback:identify' } }) + await flushAsyncWork() + harness.dispatch({ origin: 'https://admin.example', data: { type: 'quackback:identify' } }) + await flushAsyncWork() + + expect(parentBinding.current()).toMatchObject({ + generation: 7, + origin: 'https://app.example', + source: harness.parent, + }) + expect(authorize).toHaveBeenCalledTimes(1) + }) + + it('clears a replaced generation and requires a fresh parent handshake', async () => { + const harness = createWindowHarness() + let generation = 1 + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => generation, + target: harness.target, + }) + const submit = vi.fn().mockResolvedValue({ accepted: true, receipt: RECEIPT }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + generation += 1 + parentBinding.clear() + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + expect(submit).not.toHaveBeenCalled() + + harness.dispatch({ + origin: 'https://admin.example', + data: { type: 'quackback:identify' }, + }) + await flushAsyncWork() + expect(parentBinding.current()).toMatchObject({ + generation: 2, + origin: 'https://admin.example', + }) + }) + + it('never lets a same-origin non-parent sender establish or replace the binding', async () => { + const harness = createWindowHarness() + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const binding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + const attacker = { postMessage: vi.fn() } + + harness.dispatch({ + source: attacker, + origin: 'https://app.example', + data: { type: 'quackback:identify' }, + }) + await flushAsyncWork() + expect(binding.current()).toBeNull() + + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + harness.dispatch({ + source: attacker, + origin: 'https://app.example', + data: { type: 'quackback:identify' }, + }) + await flushAsyncWork() + expect(binding.current()?.source).toBe(harness.parent) + }) + + it.each([ + ['false', { allowed: false }], + ['malformed', { allowed: 'yes' }], + ])('does not bind when origin authorization is %s', async (_name, authorization) => { + const harness = createWindowHarness() + const binding = installBugReportHostParentBinding({ + authorizeOrigin: vi.fn().mockResolvedValue(authorization), + currentGeneration: () => 1, + target: harness.target, + }) + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + expect(binding.current()).toBeNull() + }) + + it('rejects late authorization, generation changes, and parent replacement during bind', async () => { + vi.useFakeTimers() + for (const race of ['late', 'generation', 'parent'] as const) { + const harness = createWindowHarness() + let generation = 1 + const pending = deferred<{ allowed: boolean }>() + const binding = installBugReportHostParentBinding({ + authorizeOrigin: () => pending.promise, + currentGeneration: () => generation, + target: harness.target, + timeoutMs: 2_000, + }) + harness.dispatch({ data: { type: 'quackback:identify' } }) + if (race === 'late') { + await vi.advanceTimersByTimeAsync(2_001) + } else if (race === 'generation') { + generation += 1 + } else { + ;(harness.target as unknown as { parent: unknown }).parent = { postMessage: vi.fn() } + } + pending.resolve({ allowed: true }) + await flushAsyncWork() + expect(binding.current()).toBeNull() + binding.dispose() + } + }) + + it('reauthorizes the bound origin before parsing or submitting each report', async () => { + const harness = createWindowHarness() + const authorize = vi + .fn() + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false }) + const binding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + const submit = vi.fn() + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => binding.current(), + submit, + target: harness.target, + }) + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + + expect(submit).not.toHaveBeenCalled() + expect(harness.parent.postMessage).not.toHaveBeenCalled() + }) + + it('sends invalid_request only for safely correlatable malformed input after all gates', async () => { + const harness = createWindowHarness() + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const binding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + const submit = vi.fn() + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => binding.current(), + submit, + target: harness.target, + }) + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + + harness.dispatch({ data: validRequest({ summary: 42, extra: 'malformed' }) }) + await flushAsyncWork() + expect(harness.parent.postMessage).toHaveBeenCalledWith( + expectedResult({ accepted: false, reason: 'invalid_request' }), + 'https://app.example' + ) + expect(submit).not.toHaveBeenCalled() + + harness.parent.postMessage.mockClear() + harness.dispatch({ data: validRequest({ requestId: 'invalid' }) }) + await flushAsyncWork() + expect(harness.parent.postMessage).not.toHaveBeenCalled() + + const accessor = validRequest({ extra: true }) + let accessed = false + Object.defineProperty(accessor.data, 'extra', { + enumerable: true, + get() { + accessed = true + return 'private' + }, + }) + harness.dispatch({ data: accessor }) + await flushAsyncWork() + expect(accessed).toBe(false) + expect(harness.parent.postMessage).not.toHaveBeenCalled() + }) + + it('rejects wrong source/origin and safely maps anonymous or exceptional provider failures', async () => { + const harness = createWindowHarness() + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const binding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + const submit = vi + .fn() + .mockResolvedValueOnce({ accepted: false, reason: 'unauthorized' }) + .mockRejectedValueOnce(new Error('canary-private-server-exception')) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => binding.current(), + submit, + target: harness.target, + }) + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + + harness.dispatch({ source: { postMessage: vi.fn() }, data: validRequest() }) + harness.dispatch({ origin: 'https://admin.example', data: validRequest() }) + await flushAsyncWork() + expect(submit).not.toHaveBeenCalled() + + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + expect(harness.parent.postMessage).toHaveBeenLastCalledWith( + expectedResult({ accepted: false, reason: 'unauthorized' }), + 'https://app.example' + ) + + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + expect(harness.parent.postMessage).toHaveBeenLastCalledWith( + expectedResult({ accepted: false, reason: 'retryable_failure' }), + 'https://app.example' + ) + expect(JSON.stringify(harness.parent.postMessage.mock.calls)).not.toContain( + 'canary-private-server-exception' + ) + }) +}) + +describe('widget route host-submit wiring', () => { + it('installs current-origin authorization and the in-memory Bearer header closure', () => { + const source = readFileSync( + new URL('../../../routes/widget/index.tsx', import.meta.url), + 'utf8' + ) + + expect(source).toMatch( + /authorizeBugReportHostOriginFn\(\{\s*data:\s*\{\s*candidateOrigin\s*\},\s*\}\)/ + ) + expect(source).toMatch( + /submitHostBugReportFn\(\{\s*data:\s*input,\s*headers:\s*getWidgetAuthHeaders\(\),\s*\}\)/ + ) + expect(source).not.toMatch(/submitHostBugReportFn\(\{\s*data: input\s*\}\)/) + }) +}) diff --git a/apps/web/src/lib/client/bug-report-host-submit.ts b/apps/web/src/lib/client/bug-report-host-submit.ts new file mode 100644 index 000000000..0b2982b71 --- /dev/null +++ b/apps/web/src/lib/client/bug-report-host-submit.ts @@ -0,0 +1,270 @@ +import { + mapHostSubmitText, + parseHostSubmitReceipt, + parseHostSubmitRequestCorrelation, + parseHostSubmitRequestMessage, + type HostSubmitProviderFailureReason, + type HostSubmitServerResult, +} from '@/lib/shared/bugreport/host-submit-contract' + +const CONTRACT = 'iplaycafe.quackback.report-submit/1' as const +const RESULT_TYPE = 'quackback:report-submit-result' as const +const DEFAULT_TIMEOUT_MS = 2_000 +const PROVIDER_FAILURE_REASONS = new Set([ + 'unavailable', + 'unauthorized', + 'retryable_failure', +]) + +export type BugReportHostParentBinding = Readonly<{ + generation: number + origin: string + source: Window +}> + +export type BugReportHostParentBindingController = { + current(): BugReportHostParentBinding | null + clear(): void + dispose(): void +} + +function hasMessageType(value: unknown, expectedType: string): boolean { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + try { + const descriptor = Object.getOwnPropertyDescriptor(value, 'type') + return ( + descriptor?.enumerable === true && 'value' in descriptor && descriptor.value === expectedType + ) + } catch { + return false + } +} + +function parseAuthorizationResult(value: unknown): boolean { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + try { + const keys = Reflect.ownKeys(value) + if (keys.length !== 1 || keys[0] !== 'allowed') return false + const descriptor = Object.getOwnPropertyDescriptor(value, 'allowed') + return descriptor?.enumerable === true && 'value' in descriptor && descriptor.value === true + } catch { + return false + } +} + +async function authorizeWithin( + authorizeOrigin: (candidateOrigin: string) => Promise<{ allowed: boolean }>, + origin: string, + timeoutMs: number +): Promise { + let timeout: ReturnType | undefined + const timedOut = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs) + }) + const authorization = Promise.resolve() + .then(() => authorizeOrigin(origin)) + .then(parseAuthorizationResult, () => false) + try { + return await Promise.race([authorization, timedOut]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + +export function installBugReportHostParentBinding(options: { + authorizeOrigin(candidateOrigin: string): Promise<{ allowed: boolean }> + currentGeneration(): number + target?: Window + timeoutMs?: number +}): BugReportHostParentBindingController { + const target = options.target ?? window + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + let binding: BugReportHostParentBinding | null = null + let epoch = 0 + let disposed = false + + const clear = () => { + binding = null + epoch += 1 + } + + const current = () => { + if ( + binding && + (binding.generation !== options.currentGeneration() || binding.source !== target.parent) + ) { + clear() + } + return binding + } + + const handleMessage = async (event: MessageEvent) => { + if ( + disposed || + event.source !== target.parent || + !hasMessageType(event.data, 'quackback:identify') + ) { + return + } + if (current()) return + + const generation = options.currentGeneration() + const origin = event.origin + const source = event.source as Window + const startingEpoch = epoch + if (!(await authorizeWithin(options.authorizeOrigin, origin, timeoutMs))) return + if ( + disposed || + epoch !== startingEpoch || + binding !== null || + options.currentGeneration() !== generation || + target.parent !== source + ) { + return + } + binding = Object.freeze({ generation, origin, source }) + } + + target.addEventListener('message', handleMessage) + return { + current, + clear, + dispose() { + if (disposed) return + disposed = true + target.removeEventListener('message', handleMessage) + clear() + }, + } +} + +function normalizeServerResult(value: unknown): HostSubmitServerResult { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { accepted: false, reason: 'retryable_failure' } + } + try { + const keys = Reflect.ownKeys(value) + const descriptors = Object.getOwnPropertyDescriptors(value) + if ( + keys.some( + (key) => + typeof key !== 'string' || + descriptors[key]?.enumerable !== true || + !('value' in descriptors[key]) + ) + ) { + return { accepted: false, reason: 'retryable_failure' } + } + const accepted = descriptors.accepted?.value + if (accepted === true && keys.length === 2 && keys.includes('receipt')) { + const receipt = parseHostSubmitReceipt(descriptors.receipt?.value) + return receipt + ? { accepted: true, receipt } + : { accepted: false, reason: 'retryable_failure' } + } + if (accepted === false && keys.length === 2 && keys.includes('reason')) { + const reason = descriptors.reason?.value + if ( + typeof reason === 'string' && + PROVIDER_FAILURE_REASONS.has(reason as HostSubmitProviderFailureReason) + ) { + return { accepted: false, reason: reason as HostSubmitProviderFailureReason } + } + } + } catch { + // A malformed provider response is a bounded retryable failure. + } + return { accepted: false, reason: 'retryable_failure' } +} + +function resultMessage( + requestId: string, + result: HostSubmitServerResult | { accepted: false; reason: 'invalid_request' } +): Record { + return { + type: RESULT_TYPE, + data: { + contract: CONTRACT, + requestId, + ...result, + }, + } +} + +function bindingStillCurrent( + target: Window, + currentBinding: () => BugReportHostParentBinding | null, + copied: BugReportHostParentBinding +): boolean { + const current = currentBinding() + return ( + current !== null && + current.generation === copied.generation && + current.origin === copied.origin && + current.source === copied.source && + target.parent === copied.source + ) +} + +export function installBugReportHostSubmitBridge(options: { + authorizeOrigin(candidateOrigin: string): Promise<{ allowed: boolean }> + currentBinding(): BugReportHostParentBinding | null + submit(input: { + clientSubmissionId: string + title: string + content: string + }): Promise + target?: Window + timeoutMs?: number +}): () => void { + const target = options.target ?? window + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + let disposed = false + + const handleMessage = async (event: MessageEvent) => { + if (disposed) return + const binding = options.currentBinding() + if ( + !binding || + event.source !== binding.source || + event.origin !== binding.origin || + target.parent !== binding.source + ) { + return + } + if (!hasMessageType(event.data, 'quackback:report-submit')) return + const copied = Object.freeze({ ...binding }) + if (!(await authorizeWithin(options.authorizeOrigin, copied.origin, timeoutMs))) return + if (disposed || !bindingStillCurrent(target, options.currentBinding, copied)) return + + const request = parseHostSubmitRequestMessage(event.data) + if (!request) { + const correlation = parseHostSubmitRequestCorrelation(event.data) + if (!correlation) return + copied.source.postMessage( + resultMessage(correlation.requestId, { + accepted: false, + reason: 'invalid_request', + }), + copied.origin + ) + return + } + + let result: HostSubmitServerResult + try { + result = normalizeServerResult(await options.submit(mapHostSubmitText(request))) + } catch { + result = { accepted: false, reason: 'retryable_failure' } + } + if (disposed || !bindingStillCurrent(target, options.currentBinding, copied)) return + copied.source.postMessage(resultMessage(request.requestId, result), copied.origin) + } + + target.addEventListener('message', handleMessage) + return () => { + if (disposed) return + disposed = true + target.removeEventListener('message', handleMessage) + } +} diff --git a/apps/web/src/lib/server/domains/bug-reports/host-submit-errors.ts b/apps/web/src/lib/server/domains/bug-reports/host-submit-errors.ts new file mode 100644 index 000000000..0842d83a1 --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-errors.ts @@ -0,0 +1,12 @@ +import type { HostSubmitProviderFailureReason } from '@/lib/shared/bugreport/host-submit-contract' + +export class HostBugReportSubmitError extends Error { + constructor(readonly reason: HostSubmitProviderFailureReason) { + super('Host bug-report submission failed') + this.name = 'HostBugReportSubmitError' + } +} + +export function mapHostSubmitError(error: unknown): HostSubmitProviderFailureReason { + return error instanceof HostBugReportSubmitError ? error.reason : 'retryable_failure' +} diff --git a/apps/web/src/lib/server/functions/__tests__/auth-helpers-host-failures.test.ts b/apps/web/src/lib/server/functions/__tests__/auth-helpers-host-failures.test.ts new file mode 100644 index 000000000..fd0943cd0 --- /dev/null +++ b/apps/web/src/lib/server/functions/__tests__/auth-helpers-host-failures.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + HostBugReportSubmitError, + mapHostSubmitError, +} from '@/lib/server/domains/bug-reports/host-submit-errors' + +const state = vi.hoisted(() => ({ + getSession: vi.fn(), + getSettings: vi.fn(), + principalFindFirst: vi.fn(), + isWidgetTeamSession: vi.fn(), + log: vi.fn(), +})) + +vi.mock('@/lib/server/auth', () => ({ + auth: { + api: { getSession: (...args: unknown[]) => state.getSession(...args) }, + }, +})) + +vi.mock('@tanstack/react-start/server', () => ({ + getRequestHeaders: () => new Headers({ authorization: 'Bearer test' }), +})) + +vi.mock('../workspace', () => ({ + getSettings: (...args: unknown[]) => state.getSettings(...args), +})) + +vi.mock('@/lib/server/db', () => ({ + db: { + query: { + principal: { findFirst: (...args: unknown[]) => state.principalFindFirst(...args) }, + }, + }, + principal: { userId: 'principal.user_id' }, + eq: vi.fn((column: unknown, value: unknown) => ({ column, value })), +})) + +vi.mock('@/lib/server/auth/widget-session-guard', () => ({ + isWidgetTeamSession: (...args: unknown[]) => state.isWidgetTeamSession(...args), +})) + +vi.mock('@/lib/server/logger', () => ({ + logger: { + child: () => ({ debug: state.log, warn: state.log, error: state.log }), + }, +})) + +vi.mock('@/lib/server/domains/segments/segment-membership.service', () => ({ + segmentIdsForPrincipal: vi.fn(async () => new Set()), +})) + +import { requireAuth, requireAuthWithFailures, type RequireAuthFailures } from '../auth-helpers' + +function hostFailures() { + return { + unauthenticated: vi.fn(() => new HostBugReportSubmitError('unauthorized')), + forbidden: vi.fn(() => new HostBugReportSubmitError('unauthorized')), + unavailable: vi.fn(() => new HostBugReportSubmitError('retryable_failure')), + } satisfies RequireAuthFailures +} + +beforeEach(() => { + vi.clearAllMocks() + state.getSession.mockResolvedValue({ + session: { id: 'session_1' }, + user: { + id: 'user_1', + email: 'customer@example.test', + name: 'Customer', + image: null, + }, + }) + state.getSettings.mockResolvedValue({ + id: 'workspace_1', + slug: 'acme', + name: 'Acme', + logoKey: null, + }) + state.principalFindFirst.mockResolvedValue({ + id: 'principal_1', + userId: 'user_1', + role: 'user', + type: 'user', + }) + state.isWidgetTeamSession.mockResolvedValue(false) +}) + +describe('requireAuthWithFailures host boundary', () => { + it('classifies absent session with the injected unauthenticated failure', async () => { + state.getSession.mockResolvedValue(null) + const failures = hostFailures() + + const error = await requireAuthWithFailures(undefined, failures).then( + () => null, + (caught: unknown) => caught + ) + + expect(failures.unauthenticated).toHaveBeenCalledOnce() + expect(mapHostSubmitError(error)).toBe('unauthorized') + }) + + it('classifies absent principal and role rejection with the injected forbidden failure', async () => { + const missingFailures = hostFailures() + state.principalFindFirst.mockResolvedValueOnce(undefined) + const missing = await requireAuthWithFailures(undefined, missingFailures).then( + () => null, + (caught: unknown) => caught + ) + expect(missingFailures.forbidden).toHaveBeenCalledOnce() + expect(mapHostSubmitError(missing)).toBe('unauthorized') + + const roleFailures = hostFailures() + const rejected = await requireAuthWithFailures({ roles: ['admin'] }, roleFailures).then( + () => null, + (caught: unknown) => caught + ) + expect(roleFailures.forbidden).toHaveBeenCalledOnce() + expect(mapHostSubmitError(rejected)).toBe('unauthorized') + }) + + it.each([ + ['session', () => state.getSession.mockRejectedValue(new Error('canary-session-one'))], + ['workspace', () => state.getSettings.mockRejectedValue(new Error('canary-workspace-two'))], + [ + 'principal', + () => state.principalFindFirst.mockRejectedValue(new Error('canary-principal-three')), + ], + [ + 'widget provenance', + () => { + state.principalFindFirst.mockResolvedValue({ + id: 'principal_1', + userId: 'user_1', + role: 'admin', + type: 'user', + }) + state.isWidgetTeamSession.mockRejectedValue(new Error('canary-widget-four')) + }, + ], + ])('maps a thrown %s dependency through unavailable without raw leakage', async (_name, fail) => { + fail() + const failures = hostFailures() + + const error = await requireAuthWithFailures(undefined, failures).then( + () => null, + (caught: unknown) => caught + ) + const serialized = JSON.stringify({ + reason: mapHostSubmitError(error), + logs: state.log.mock.calls, + unavailableCalls: failures.unavailable.mock.calls, + }) + + expect(failures.unavailable).toHaveBeenCalledOnce() + expect(mapHostSubmitError(error)).toBe('retryable_failure') + expect(serialized).not.toContain('canary-') + }) + + it('types a missing workspace value as unavailable', async () => { + state.getSettings.mockResolvedValue(null) + const failures = hostFailures() + + const error = await requireAuthWithFailures(undefined, failures).then( + () => null, + (caught: unknown) => caught + ) + + expect(failures.unavailable).toHaveBeenCalledOnce() + expect(mapHostSubmitError(error)).toBe('retryable_failure') + }) + + it('does not classify by exception message', async () => { + const reasons = [] + for (const message of [ + 'Authentication required', + 'Access denied: Not a team member', + 'Bug-report board is not available', + ]) { + state.getSession.mockRejectedValueOnce(new Error(message)) + const error = await requireAuthWithFailures(undefined, hostFailures()).then( + () => null, + (caught: unknown) => caught + ) + reasons.push(mapHostSubmitError(error)) + } + expect(reasons).toEqual(['retryable_failure', 'retryable_failure', 'retryable_failure']) + }) +}) + +describe('requireAuth legacy wrapper compatibility', () => { + it('retains its current return shape', async () => { + await expect(requireAuth({ roles: ['user'] })).resolves.toEqual({ + settings: { + id: 'workspace_1', + slug: 'acme', + name: 'Acme', + logoKey: null, + }, + user: { + id: 'user_1', + email: 'customer@example.test', + name: 'Customer', + image: null, + }, + principal: { + id: 'principal_1', + role: 'user', + type: 'user', + }, + }) + }) + + it('retains its current user-facing authentication and authorization messages', async () => { + state.getSession.mockResolvedValueOnce(null) + await expect(requireAuth()).rejects.toThrow('Authentication required') + + state.getSettings.mockResolvedValueOnce(null) + await expect(requireAuth()).rejects.toThrow('Workspace not configured') + + state.principalFindFirst.mockResolvedValueOnce(undefined) + await expect(requireAuth()).rejects.toThrow('Access denied: Not a team member') + + await expect(requireAuth({ roles: ['admin'] })).rejects.toThrow( + 'Access denied: Requires [admin], got user' + ) + }) +}) diff --git a/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts b/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts index 2a1f2854a..ac136a2d6 100644 --- a/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts @@ -17,18 +17,29 @@ function sourceSlice(start: string, end?: string) { const state = vi.hoisted(() => ({ principal: 'principal_A', + principalType: 'user', existingSubmission: true, + portalOutcome: { + kind: 'decision', + decision: { granted: true, reason: 'public' }, + } as + | { kind: 'decision'; decision: { granted: boolean; reason: string } } + | { kind: 'dependency_failure' }, createPost: vi.fn(), createComment: vi.fn(), completeEffects: vi.fn(), processEvent: vi.fn(), log: vi.fn(), getPublicBoard: vi.fn(), + getPublicBoardBySlug: vi.fn(), + canCreatePost: vi.fn(), + getWidgetConfig: vi.fn(), getMember: vi.fn(), getDefaultStatus: vi.fn(), getSettings: vi.fn(), verifyMedia: vi.fn(), receiptTransaction: vi.fn(), + receipts: new Map>(), eqCalls: [] as Array<[unknown, unknown]>, principalField: Symbol('principal_id'), clientField: Symbol('client_submission_id'), @@ -154,7 +165,15 @@ vi.mock('@/lib/server/db', () => { vi.mock('../auth-helpers', () => ({ requireAuth: async () => ({ - principal: { id: state.principal, type: 'user', role: 'user' }, + principal: { id: state.principal, type: state.principalType, role: 'user' }, + user: { + id: state.principal === 'principal_A' ? 'user_A' : 'user_B', + name: 'Private canary', + email: 'private-canary@example.test', + }, + }), + requireAuthWithFailures: async () => ({ + principal: { id: state.principal, type: state.principalType, role: 'user' }, user: { id: state.principal === 'principal_A' ? 'user_A' : 'user_B', name: 'Private canary', @@ -163,7 +182,7 @@ vi.mock('../auth-helpers', () => ({ }), policyActorFromAuth: async () => ({ principalId: state.principal, - principalType: 'user', + principalType: state.principalType, role: 'user', segmentIds: new Set(), }), @@ -171,18 +190,11 @@ vi.mock('../auth-helpers', () => ({ vi.mock('../portal-access', () => ({ resolvePortalAccessForRequest: async () => ({ granted: true }), + resolvePortalAccessForHostSubmit: async () => state.portalOutcome, })) vi.mock('@/lib/server/domains/settings/settings.widget', () => ({ - getWidgetConfig: async () => ({ - bugReport: { - enabled: true, - receipts: true, - boardSlug: 'bug-reports', - mediaEvidence: true, - privateEvidence: true, - }, - }), + getWidgetConfig: (...args: unknown[]) => state.getWidgetConfig(...args), })) vi.mock('@/lib/server/domains/bug-reports/receipt.store', () => ({ @@ -211,6 +223,10 @@ vi.mock('@/lib/server/domains/comments/comment.service', () => ({ vi.mock('@/lib/server/domains/boards/board.public', () => ({ getPublicBoardById: (...args: unknown[]) => state.getPublicBoard(...args), + getPublicBoardBySlug: (...args: unknown[]) => state.getPublicBoardBySlug(...args), +})) +vi.mock('@/lib/server/policy', () => ({ + canCreatePost: (...args: unknown[]) => state.canCreatePost(...args), })) vi.mock('@/lib/server/domains/statuses/status.service', () => ({ getDefaultStatus: (...args: unknown[]) => state.getDefaultStatus(...args), @@ -239,6 +255,14 @@ import { listMyBugReportsFn as listMyBugReportsHandler, submitBugReportFn as submitBugReportHandler, } from '../bug-report-receipts' +import { + hostBugReportServerInputSchema, + submitConfiguredHostBugReport, +} from '../bug-report-host-submit' +import { + HostBugReportSubmitError, + mapHostSubmitError, +} from '@/lib/server/domains/bug-reports/host-submit-errors' describe('authenticated bug-report receipt handlers', () => { it('keeps request-header server imports inside async server execution paths', () => { @@ -263,6 +287,7 @@ describe('authenticated bug-report receipt handlers', () => { const logicHandlerContracts = [ { name: 'submitBugReportHandler', + expectedIdentifiers: 3, serverPath: sourceSlice( 'export const submitBugReportFn', 'async function listMyBugReportsHandler' @@ -270,6 +295,7 @@ describe('authenticated bug-report receipt handlers', () => { }, { name: 'listMyBugReportsHandler', + expectedIdentifiers: 2, serverPath: sourceSlice( 'export const listMyBugReportsFn', 'async function resolveOwnedBugReport' @@ -277,23 +303,31 @@ describe('authenticated bug-report receipt handlers', () => { }, { name: 'createBugReportReplyHandler', + expectedIdentifiers: 2, serverPath: sourceSlice('export const createBugReportReplyFn'), }, ] - for (const { name: handlerName, serverPath } of logicHandlerContracts) { + for (const { name: handlerName, expectedIdentifiers, serverPath } of logicHandlerContracts) { const privateDeclaration = new RegExp(`^async function ${handlerName}\\(\\{$`, 'gm') const identifier = new RegExp(`\\b${handlerName}\\b`, 'g') expect(Array.from(receiptFunctionSource.matchAll(privateDeclaration))).toHaveLength(1) - expect(Array.from(receiptFunctionSource.matchAll(identifier))).toHaveLength(2) + expect(Array.from(receiptFunctionSource.matchAll(identifier))).toHaveLength( + expectedIdentifiers + ) expect(Array.from(serverPath.matchAll(identifier))).toHaveLength(1) } }) beforeEach(() => { state.principal = 'principal_A' + state.principalType = 'user' state.existingSubmission = true - state.createPost.mockReset() + state.portalOutcome = { + kind: 'decision', + decision: { granted: true, reason: 'public' }, + } + state.createPost.mockReset().mockResolvedValue({ id: POST_ID }) state.createComment.mockReset().mockResolvedValue({}) state.completeEffects.mockReset().mockRejectedValue(new Error('queue unavailable')) state.processEvent.mockReset() @@ -301,6 +335,19 @@ describe('authenticated bug-report receipt handlers', () => { state.getPublicBoard .mockReset() .mockResolvedValue({ id: 'board_bug_reports', slug: 'bug-reports', name: 'Bug reports' }) + state.getPublicBoardBySlug + .mockReset() + .mockResolvedValue({ id: 'board_bug_reports', slug: 'bug-reports', name: 'Bug reports' }) + state.canCreatePost.mockReset().mockReturnValue({ allowed: true, requiresApproval: false }) + state.getWidgetConfig.mockReset().mockResolvedValue({ + bugReport: { + enabled: true, + receipts: true, + boardSlug: 'bug-reports', + mediaEvidence: true, + privateEvidence: true, + }, + }) state.getMember.mockReset().mockResolvedValue({ id: 'member_A' }) state.getDefaultStatus.mockReset().mockResolvedValue({ id: 'status_open' }) state.getSettings.mockReset().mockResolvedValue({ id: 'tenant_A' }) @@ -318,7 +365,40 @@ describe('authenticated bug-report receipt handlers', () => { ], }, }) - state.receiptTransaction.mockReset() + state.receipts.clear() + state.receiptTransaction + .mockReset() + .mockImplementation( + async ( + run: (transaction: { + driver: Record + find(principalId: string, clientSubmissionId: string): Promise + reserve(record: Record): Promise + attachPost( + principalId: string, + clientSubmissionId: string, + postId: string + ): Promise + }) => Promise + ) => + run({ + driver: {}, + async find(principalId, clientSubmissionId) { + return state.receipts.get(`${principalId}:${clientSubmissionId}`) ?? null + }, + async reserve(record) { + const key = `${record.principalId}:${record.clientSubmissionId}` + if (state.receipts.has(key)) return false + state.receipts.set(key, record) + return true + }, + async attachPost(principalId, clientSubmissionId, postId) { + const key = `${principalId}:${clientSubmissionId}` + const record = state.receipts.get(key) + if (record) state.receipts.set(key, { ...record, postId }) + }, + }) + ) state.eqCalls.length = 0 }) @@ -464,3 +544,216 @@ describe('authenticated bug-report receipt handlers', () => { expect(state.createComment).not.toHaveBeenCalled() }) }) + +describe('configured host bug-report boundary', () => { + beforeEach(() => { + state.principal = 'principal_A' + state.principalType = 'user' + state.existingSubmission = false + state.portalOutcome = { + kind: 'decision', + decision: { granted: true, reason: 'public' }, + } + state.createPost.mockReset().mockResolvedValue({ id: POST_ID }) + state.completeEffects.mockReset().mockResolvedValue(undefined) + state.processEvent.mockReset() + state.log.mockReset() + state.getPublicBoardBySlug + .mockReset() + .mockResolvedValue({ id: 'board_bug_reports', slug: 'bug-reports', name: 'Bug reports' }) + state.canCreatePost.mockReset().mockReturnValue({ allowed: true, requiresApproval: false }) + state.getWidgetConfig.mockReset().mockResolvedValue({ + bugReport: { + enabled: true, + receipts: true, + boardSlug: 'bug-reports', + mediaEvidence: false, + privateEvidence: false, + }, + }) + state.getMember.mockReset().mockResolvedValue({ id: 'member_A' }) + state.getDefaultStatus.mockReset().mockResolvedValue({ id: 'status_open' }) + state.getSettings.mockReset().mockResolvedValue({ id: 'tenant_A' }) + state.receipts.clear() + state.receiptTransaction + .mockReset() + .mockImplementation( + async ( + run: (transaction: { + driver: Record + find(principalId: string, clientSubmissionId: string): Promise + reserve(record: Record): Promise + attachPost( + principalId: string, + clientSubmissionId: string, + postId: string + ): Promise + }) => Promise + ) => + run({ + driver: {}, + async find(principalId, clientSubmissionId) { + return state.receipts.get(`${principalId}:${clientSubmissionId}`) ?? null + }, + async reserve(record) { + const key = `${record.principalId}:${record.clientSubmissionId}` + if (state.receipts.has(key)) return false + state.receipts.set(key, record) + return true + }, + async attachPost(principalId, clientSubmissionId, postId) { + const key = `${principalId}:${clientSubmissionId}` + const record = state.receipts.get(key) + if (record) state.receipts.set(key, { ...record, postId }) + }, + }) + ) + }) + + it('accepts only the provider-safe host fields and resolves the configured board slug', async () => { + const hostInput = hostBugReportServerInputSchema.parse({ + clientSubmissionId: CLIENT_ID, + title: 'Save button', + content: 'Save button does nothing', + }) + + expect(Object.keys(hostInput).sort()).toEqual(['clientSubmissionId', 'content', 'title']) + for (const forbidden of ['boardId', 'principalId', 'status', 'media', 'context']) { + expect( + hostBugReportServerInputSchema.safeParse({ ...hostInput, [forbidden]: 'forbidden' }).success + ).toBe(false) + } + + const result = await submitConfiguredHostBugReport(hostInput) + expect(result.accepted).toBe(true) + expect(state.getPublicBoardBySlug).toHaveBeenCalledWith( + 'bug-reports', + expect.objectContaining({ principalId: 'principal_A' }) + ) + expect(state.createPost.mock.calls[0][0]).toEqual( + expect.objectContaining({ + boardId: 'board_bug_reports', + title: 'Save button', + content: 'Save button does nothing', + }) + ) + }) + + it('returns the same private receipt for a principal retry and creates one post', async () => { + const input = { + clientSubmissionId: CLIENT_ID, + title: 'Save button', + content: 'Save button does nothing', + } + + const first = await submitConfiguredHostBugReport(input) + const second = await submitConfiguredHostBugReport(input) + + expect(first.accepted).toBe(true) + expect(second.accepted).toBe(true) + if (first.accepted && second.accepted) { + expect(first.receipt.reportRef).toBe(second.receipt.reportRef) + } + expect(state.createPost).toHaveBeenCalledTimes(1) + }) + + it('keeps the same client id isolated between principals', async () => { + const input = { + clientSubmissionId: CLIENT_ID, + title: 'Save button', + content: 'Save button does nothing', + } + const first = await submitConfiguredHostBugReport(input) + state.principal = 'principal_B' + const second = await submitConfiguredHostBugReport(input) + + expect(first.accepted).toBe(true) + expect(second.accepted).toBe(true) + if (first.accepted && second.accepted) { + expect(first.receipt.reportRef).not.toBe(second.receipt.reportRef) + } + expect(state.createPost).toHaveBeenCalledTimes(2) + }) + + it('maps expired/anonymous auth and a completed portal denial to unauthorized', async () => { + state.principalType = 'anonymous' + await expect( + submitConfiguredHostBugReport({ + clientSubmissionId: CLIENT_ID, + title: 'Anonymous', + content: '', + }) + ).resolves.toEqual({ accepted: false, reason: 'unauthorized' }) + + state.principalType = 'user' + state.portalOutcome = { + kind: 'decision', + decision: { granted: false, reason: 'unauthenticated' }, + } + await expect( + submitConfiguredHostBugReport({ + clientSubmissionId: CLIENT_ID, + title: 'Expired', + content: '', + }) + ).resolves.toEqual({ accepted: false, reason: 'unauthorized' }) + }) + + it('maps disabled gates and a missing/ineligible configured board to unavailable', async () => { + state.getWidgetConfig.mockResolvedValueOnce({ + bugReport: { enabled: false, receipts: false, boardSlug: 'bug-reports' }, + }) + await expect( + submitConfiguredHostBugReport({ + clientSubmissionId: CLIENT_ID, + title: 'Disabled', + content: '', + }) + ).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + + state.getPublicBoardBySlug.mockResolvedValueOnce(null) + await expect( + submitConfiguredHostBugReport({ + clientSubmissionId: CLIENT_ID, + title: 'Missing board', + content: '', + }) + ).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + + state.canCreatePost.mockReturnValueOnce({ allowed: false, reason: 'Private policy detail' }) + await expect( + submitConfiguredHostBugReport({ + clientSubmissionId: CLIENT_ID, + title: 'Ineligible board', + content: '', + }) + ).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + }) + + it('maps typed failures deterministically and never classifies raw messages', () => { + expect(mapHostSubmitError(new HostBugReportSubmitError('unauthorized'))).toBe('unauthorized') + expect(mapHostSubmitError(new HostBugReportSubmitError('unavailable'))).toBe('unavailable') + expect(mapHostSubmitError(new Error('canary-private-provider-message'))).toBe( + 'retryable_failure' + ) + }) + + it('serializes and observes no raw provider exception canary', async () => { + state.createPost.mockRejectedValue(new Error('canary-private-provider-message')) + + const result = await submitConfiguredHostBugReport({ + clientSubmissionId: CLIENT_ID, + title: 'Safe title', + content: 'Safe content', + }) + const observed = JSON.stringify({ + result, + logs: state.log.mock.calls, + effects: state.completeEffects.mock.calls, + hooks: state.processEvent.mock.calls, + }) + + expect(result).toEqual({ accepted: false, reason: 'retryable_failure' }) + expect(observed).not.toContain('canary-private-provider-message') + }) +}) diff --git a/apps/web/src/lib/server/functions/__tests__/portal-access-host-outcome.test.ts b/apps/web/src/lib/server/functions/__tests__/portal-access-host-outcome.test.ts new file mode 100644 index 000000000..33e6bcdd4 --- /dev/null +++ b/apps/web/src/lib/server/functions/__tests__/portal-access-host-outcome.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + getSession: vi.fn(), + principalFindFirst: vi.fn(), + invitationFindFirst: vi.fn(), + widgetOriginFindFirst: vi.fn(), + isWidgetTeamSession: vi.fn(), + getPortalConfig: vi.fn(), + getWidgetConfig: vi.fn(), + segmentIdsForPrincipal: vi.fn(), + log: vi.fn(), +})) + +vi.mock('@tanstack/react-start/server', () => ({ + getRequestHeaders: () => new Headers({ authorization: 'Bearer test' }), +})) + +vi.mock('@tanstack/react-start', () => ({ + createServerFn: () => { + const chain = { + validator: () => chain, + handler: (handler: unknown) => handler, + } + return chain + }, + createServerOnlyFn: (fn: T) => fn, +})) + +vi.mock('@/lib/server/auth/index', () => ({ + auth: { + api: { getSession: (...args: unknown[]) => state.getSession(...args) }, + }, +})) + +vi.mock('@/lib/server/db', () => ({ + db: { + query: { + principal: { findFirst: (...args: unknown[]) => state.principalFindFirst(...args) }, + invitation: { findFirst: (...args: unknown[]) => state.invitationFindFirst(...args) }, + widgetOriginSession: { + findFirst: (...args: unknown[]) => state.widgetOriginFindFirst(...args), + }, + }, + }, + principal: { userId: 'principal.user_id' }, + invitation: { email: 'invitation.email', kind: 'invitation.kind', status: 'invitation.status' }, + widgetOriginSession: { sessionId: 'widget_origin_session.session_id' }, + eq: vi.fn((column: unknown, value: unknown) => ({ column, value })), + and: vi.fn((...conditions: unknown[]) => conditions), +})) + +vi.mock('@/lib/server/auth/widget-session-guard', () => ({ + isWidgetTeamSession: (...args: unknown[]) => state.isWidgetTeamSession(...args), +})) + +vi.mock('@/lib/server/domains/settings/settings.service', () => ({ + getPortalConfig: () => state.getPortalConfig(), + updatePortalConfig: vi.fn(), +})) + +vi.mock('@/lib/server/domains/settings/settings.widget', () => ({ + getWidgetConfig: () => state.getWidgetConfig(), +})) + +vi.mock('@/lib/server/domains/segments/segment-membership.service', () => ({ + segmentIdsForPrincipal: (...args: unknown[]) => state.segmentIdsForPrincipal(...args), +})) + +vi.mock('@/lib/server/logger', () => ({ + logger: { + child: () => ({ debug: state.log, warn: state.log, error: state.log }), + }, +})) + +import { resolvePortalAccessForHostSubmit, resolvePortalAccessForRequest } from '../portal-access' +import { NotFoundError } from '@/lib/shared/errors' + +type DependencyName = + | 'session' + | 'principal' + | 'invitation' + | 'widget provenance' + | 'widget marker' + | 'portal config' + | 'widget config' + | 'segment' + +const SIGNED_SESSION = { + session: { id: 'session_1' }, + user: { + id: 'user_1', + email: 'customer@example.test', + emailVerified: true, + }, +} + +function failDependency(name: DependencyName, canary: string) { + switch (name) { + case 'session': + state.getSession.mockRejectedValue(new Error(canary)) + state.getPortalConfig.mockResolvedValue({ access: { visibility: 'public' } }) + break + case 'principal': + state.principalFindFirst.mockRejectedValue(new Error(canary)) + state.getPortalConfig.mockResolvedValue({ access: { visibility: 'public' } }) + break + case 'invitation': + state.invitationFindFirst.mockRejectedValue(new Error(canary)) + state.getPortalConfig.mockResolvedValue({ access: { visibility: 'private' } }) + break + case 'widget provenance': + state.principalFindFirst.mockResolvedValue({ + id: 'principal_1', + type: 'user', + role: 'admin', + }) + state.isWidgetTeamSession.mockRejectedValue(new Error(canary)) + state.getPortalConfig.mockResolvedValue({ access: { visibility: 'public' } }) + break + case 'widget marker': + state.widgetOriginFindFirst.mockRejectedValue(new Error(canary)) + state.getPortalConfig.mockResolvedValue({ + access: { visibility: 'private', widgetSignIn: true }, + }) + state.getWidgetConfig.mockResolvedValue({ identifyVerification: true }) + break + case 'portal config': + state.getSession.mockResolvedValue(null) + state.getPortalConfig.mockRejectedValue(new Error(canary)) + break + case 'widget config': + state.widgetOriginFindFirst.mockResolvedValue({ sessionId: 'session_1' }) + state.getPortalConfig.mockResolvedValue({ + access: { visibility: 'private', widgetSignIn: true }, + }) + state.getWidgetConfig.mockRejectedValue(new Error(canary)) + break + case 'segment': + state.getPortalConfig.mockResolvedValue({ + access: { visibility: 'private', allowedSegmentIds: ['segment_allowed'] }, + }) + state.segmentIdsForPrincipal.mockRejectedValue(new Error(canary)) + break + } +} + +beforeEach(() => { + vi.clearAllMocks() + state.getSession.mockResolvedValue(SIGNED_SESSION) + state.principalFindFirst.mockResolvedValue({ + id: 'principal_1', + type: 'user', + role: 'user', + }) + state.invitationFindFirst.mockResolvedValue(null) + state.widgetOriginFindFirst.mockResolvedValue(null) + state.isWidgetTeamSession.mockResolvedValue(false) + state.getPortalConfig.mockResolvedValue({ + access: { visibility: 'private', allowedDomains: [] }, + }) + state.getWidgetConfig.mockResolvedValue({ identifyVerification: false }) + state.segmentIdsForPrincipal.mockResolvedValue(new Set()) +}) + +describe('resolvePortalAccessForHostSubmit', () => { + it.each([ + 'session', + 'principal', + 'invitation', + 'widget provenance', + 'widget marker', + 'portal config', + 'widget config', + 'segment', + ] as const)('returns a non-leaking dependency outcome when %s lookup throws', async (name) => { + const canary = `canary-private-${name.replace(' ', '-')}` + failDependency(name, canary) + + const outcome = await resolvePortalAccessForHostSubmit() + const serialized = JSON.stringify({ outcome, logs: state.log.mock.calls }) + + expect(outcome).toEqual({ kind: 'dependency_failure' }) + expect(serialized).not.toContain(canary) + }) + + it('keeps a completed private-policy denial distinct from dependency failure', async () => { + await expect(resolvePortalAccessForHostSubmit()).resolves.toEqual({ + kind: 'decision', + decision: { granted: false, reason: 'unauthorized' }, + }) + }) + + it('treats a missing settings row as a dependency failure for the host path', async () => { + state.getPortalConfig.mockRejectedValue( + new NotFoundError('SETTINGS_NOT_FOUND', 'canary-missing-settings') + ) + + await expect(resolvePortalAccessForHostSubmit()).resolves.toEqual({ + kind: 'dependency_failure', + }) + expect(JSON.stringify(state.log.mock.calls)).not.toContain('canary-missing-settings') + }) +}) + +describe('resolvePortalAccessForRequest legacy compatibility', () => { + it.each([ + ['session', { granted: true, reason: 'public' }], + ['principal', { granted: true, reason: 'public' }], + ['invitation', { granted: false, reason: 'unauthorized' }], + ['widget provenance', { granted: true, reason: 'public' }], + ['widget marker', { granted: false, reason: 'unauthorized' }], + ['portal config', { granted: false, reason: 'unauthenticated' }], + ['widget config', { granted: false, reason: 'unauthorized' }], + ['segment', { granted: false, reason: 'unauthorized' }], + ] as const)( + 'remains never-throw with its existing %s failure decision', + async (name, expected) => { + failDependency(name, `legacy-${name}`) + await expect(resolvePortalAccessForRequest()).resolves.toEqual(expected) + } + ) + + it('continues failing open for a missing settings row', async () => { + state.getSession.mockResolvedValue(null) + state.getPortalConfig.mockRejectedValue( + new NotFoundError('SETTINGS_NOT_FOUND', 'Settings not found') + ) + + await expect(resolvePortalAccessForRequest()).resolves.toEqual({ + granted: true, + reason: 'public', + }) + }) +}) diff --git a/apps/web/src/lib/server/functions/auth-helpers.ts b/apps/web/src/lib/server/functions/auth-helpers.ts index 045411698..556c9084d 100644 --- a/apps/web/src/lib/server/functions/auth-helpers.ts +++ b/apps/web/src/lib/server/functions/auth-helpers.ts @@ -47,9 +47,13 @@ export function hasAuthCredentials(): boolean { * Get session directly from better-auth (not through server function). * This avoids nested server function call issues. */ +async function getSessionDirectUnchecked(): Promise { + return auth.api.getSession({ headers: getRequestHeaders() }) +} + async function getSessionDirect(): Promise { try { - return await auth.api.getSession({ headers: getRequestHeaders() }) + return await getSessionDirectUnchecked() } catch (error) { log.error({ err: error }, 'get session failed') return null @@ -78,6 +82,90 @@ export interface AuthContext { } } +export type RequireAuthFailures = { + unauthenticated(message: string): Error + forbidden(message: string): Error + unavailable(message: string): Error +} + +/** + * Host-only authentication boundary. Dependency failures are classified at + * their call site and the underlying exception is deliberately discarded. + */ +export async function requireAuthWithFailures( + options: { roles?: Role[] } | undefined, + failures: RequireAuthFailures +): Promise { + let session: SessionResult | null + try { + session = await getSessionDirectUnchecked() + } catch { + throw failures.unavailable('Authentication service unavailable') + } + if (!session?.user) { + throw failures.unauthenticated('Authentication required') + } + const userId = session.user.id as UserId + + let appSettings: Awaited> + try { + appSettings = await getSettings() + } catch { + throw failures.unavailable('Workspace lookup unavailable') + } + if (!appSettings) { + throw failures.unavailable('Workspace not configured') + } + + let principalRecord: Awaited> + try { + principalRecord = await db.query.principal.findFirst({ + where: eq(principal.userId, userId), + }) + } catch { + throw failures.unavailable('Principal lookup unavailable') + } + if (!principalRecord) { + throw failures.forbidden('Access denied: Not a team member') + } + + let widgetTeamSession: boolean + try { + widgetTeamSession = await isWidgetTeamSession(session.session.id, principalRecord.role) + } catch { + throw failures.unavailable('Session provenance unavailable') + } + if (widgetTeamSession) { + throw failures.forbidden('Access denied: Widget identity sessions cannot access team accounts') + } + + if (options?.roles && !options.roles.includes(principalRecord.role as Role)) { + throw failures.forbidden( + `Access denied: Requires [${options.roles.join(', ')}], got ${principalRecord.role}` + ) + } + + return { + settings: { + id: appSettings.id as WorkspaceId, + slug: appSettings.slug, + name: appSettings.name, + logoKey: appSettings.logoKey ?? null, + }, + user: { + id: userId, + email: session.user.email, + name: session.user.name, + image: session.user.image ?? null, + }, + principal: { + id: principalRecord.id as PrincipalId, + role: principalRecord.role as Role, + type: principalRecord.type, + }, + } +} + /** * Require authentication with optional role check. * Throws if user is not authenticated or doesn't have required role. diff --git a/apps/web/src/lib/server/functions/bug-report-host-submit.ts b/apps/web/src/lib/server/functions/bug-report-host-submit.ts new file mode 100644 index 000000000..1be280da4 --- /dev/null +++ b/apps/web/src/lib/server/functions/bug-report-host-submit.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' +import { createServerFn } from '@tanstack/react-start' +import { isBugReportHostOriginAllowed } from '@/lib/server/domains/bug-reports/host-submit-origin-policy' +import { mapHostSubmitError } from '@/lib/server/domains/bug-reports/host-submit-errors' +import { + parseHostSubmitReceipt, + type HostSubmitServerResult, +} from '@/lib/shared/bugreport/host-submit-contract' +import { submitConfiguredBugReportHandler } from './bug-report-receipts' + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export const hostBugReportServerInputSchema = z + .object({ + clientSubmissionId: z.string().regex(UUID_V4), + title: z.string().min(1).max(200), + content: z.string().max(10_000), + }) + .strict() + +export async function submitConfiguredHostBugReport( + data: z.infer +): Promise { + try { + const { getRequestHeaders } = await import('@tanstack/react-start/server') + const receipt = parseHostSubmitReceipt( + await submitConfiguredBugReportHandler({ + data, + headers: getRequestHeaders(), + }) + ) + return receipt ? { accepted: true, receipt } : { accepted: false, reason: 'retryable_failure' } + } catch (error) { + return { accepted: false, reason: mapHostSubmitError(error) } + } +} + +export const authorizeBugReportHostOriginFn = createServerFn({ method: 'POST' }) + .validator(z.object({ candidateOrigin: z.string() }).strict()) + .handler(async ({ data }) => { + const { setResponseHeader } = await import('@tanstack/react-start/server') + setResponseHeader('Cache-Control', 'private, no-store') + return { + allowed: isBugReportHostOriginAllowed(data.candidateOrigin), + } + }) + +export const submitHostBugReportFn = createServerFn({ method: 'POST' }) + .validator(hostBugReportServerInputSchema) + .handler(async ({ data }) => submitConfiguredHostBugReport(data)) diff --git a/apps/web/src/lib/server/functions/bug-report-receipts.ts b/apps/web/src/lib/server/functions/bug-report-receipts.ts index b454ab07b..b776dfaf3 100644 --- a/apps/web/src/lib/server/functions/bug-report-receipts.ts +++ b/apps/web/src/lib/server/functions/bug-report-receipts.ts @@ -32,7 +32,7 @@ import { import { projectBugReportMediaEvidence } from '@/lib/shared/bugreport/media-evidence-gates' import { verifyBugReportMediaForPost } from '@/lib/server/evidence/capability' import { sanitizeTiptapContent } from '@/lib/server/sanitize-tiptap' -import { getPublicBoardById } from '@/lib/server/domains/boards/board.public' +import { getPublicBoardById, getPublicBoardBySlug } from '@/lib/server/domains/boards/board.public' import { getDefaultStatus } from '@/lib/server/domains/statuses/status.service' import { getMemberByUser } from '@/lib/server/domains/principals/principal.service' import { createPost, stagePostCreationEffects } from '@/lib/server/domains/posts/post.service' @@ -51,8 +51,15 @@ import { } from '@/lib/server/domains/bug-reports/receipt.store' import { getSettings } from './workspace' import { getWidgetConfig } from '@/lib/server/domains/settings/settings.widget' -import { policyActorFromAuth, requireAuth } from './auth-helpers' -import { resolvePortalAccessForRequest } from './portal-access' +import { + policyActorFromAuth, + requireAuth, + requireAuthWithFailures, + type RequireAuthFailures, +} from './auth-helpers' +import { resolvePortalAccessForHostSubmit, resolvePortalAccessForRequest } from './portal-access' +import { HostBugReportSubmitError } from '@/lib/server/domains/bug-reports/host-submit-errors' +import { canCreatePost } from '@/lib/server/policy' const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -84,28 +91,86 @@ async function requireBugReportPrincipal() { return auth } -function requireReceiptFeature(widgetConfig: Awaited>) { +const hostAuthFailures: RequireAuthFailures = { + unauthenticated: () => new HostBugReportSubmitError('unauthorized'), + forbidden: () => new HostBugReportSubmitError('unauthorized'), + unavailable: () => new HostBugReportSubmitError('retryable_failure'), +} + +async function requireHostBugReportPrincipal() { + const access = await resolvePortalAccessForHostSubmit() + if (access.kind === 'dependency_failure') { + throw new HostBugReportSubmitError('retryable_failure') + } + if (!access.decision.granted) { + throw new HostBugReportSubmitError('unauthorized') + } + const auth = await requireAuthWithFailures( + { roles: ['admin', 'member', 'user'] }, + hostAuthFailures + ) + if (auth.principal.type === 'anonymous') { + throw new HostBugReportSubmitError('unauthorized') + } + return auth +} + +type BugReportFailures = { + receiptsDisabled(): Error + boardUnavailable(): Error +} + +const legacyBugReportFailures: BugReportFailures = { + receiptsDisabled: () => new Error('Bug-report receipts are not enabled'), + boardUnavailable: () => new Error('Bug-report board is not available'), +} + +const hostBugReportFailures: BugReportFailures = { + receiptsDisabled: () => new HostBugReportSubmitError('unavailable'), + boardUnavailable: () => new HostBugReportSubmitError('unavailable'), +} + +function requireReceiptFeature( + widgetConfig: Awaited>, + failures: BugReportFailures = legacyBugReportFailures +) { if (widgetConfig.bugReport?.enabled !== true || widgetConfig.bugReport.receipts !== true) { - throw new Error('Bug-report receipts are not enabled') + throw failures.receiptsDisabled() } } async function submitBugReportHandler({ data, headers, + configuredHost = false, }: { - data: z.infer + data: + | z.infer + | { clientSubmissionId: string; title: string; content: string } headers: Headers + configuredHost?: boolean }): Promise { - const auth = await requireBugReportPrincipal() + const failures = configuredHost ? hostBugReportFailures : legacyBugReportFailures + const auth = configuredHost + ? await requireHostBugReportPrincipal() + : await requireBugReportPrincipal() const widgetConfig = await getWidgetConfig() - requireReceiptFeature(widgetConfig) + requireReceiptFeature(widgetConfig, failures) const actor = await policyActorFromAuth(auth) - const boardId = data.boardId as BoardId - const board = await getPublicBoardById(boardId, actor) - if (!board || board.slug !== (widgetConfig.bugReport?.boardSlug ?? 'bug-reports')) { - throw new Error('Bug-report board is not available') + const configuredBoardSlug = widgetConfig.bugReport?.boardSlug ?? 'bug-reports' + const board = configuredHost + ? await getPublicBoardBySlug(configuredBoardSlug, actor) + : await getPublicBoardById( + (data as z.infer).boardId as BoardId, + actor + ) + if (!board || board.slug !== configuredBoardSlug) { + throw failures.boardUnavailable() } + if (configuredHost && !canCreatePost(actor, board, undefined).allowed) { + throw failures.boardUnavailable() + } + const boardId = board.id as BoardId const [existing] = await db .select() .from(bugReportSubmissions) @@ -131,8 +196,12 @@ async function submitBugReportHandler({ if (!principalRecord) throw new Error('Signed-in principal not found') if (!settings) throw new Error('Organization settings not found') - const technicalContext = parseTechnicalContext(data.technicalContext) - const mediaCandidate = parseBugReportMedia(data.bugReportMedia) + const technicalContext = parseTechnicalContext( + 'technicalContext' in data ? data.technicalContext : undefined + ) + const mediaCandidate = parseBugReportMedia( + 'bugReportMedia' in data ? data.bugReportMedia : undefined + ) const mediaGates = projectBugReportMediaEvidence(widgetConfig.bugReport) let media = null if (mediaCandidate) { @@ -175,7 +244,10 @@ async function submitBugReportHandler({ boardId, title: data.title, content: data.content, - contentJson: data.contentJson ? sanitizeTiptapContent(data.contentJson) : undefined, + contentJson: + 'contentJson' in data && data.contentJson + ? sanitizeTiptapContent(data.contentJson) + : undefined, statusId: defaultStatus?.id, widgetMetadata: Object.keys(widgetMetadata).length > 0 ? widgetMetadata : undefined, }, @@ -198,6 +270,16 @@ async function submitBugReportHandler({ return result.receipt } +export async function submitConfiguredBugReportHandler({ + data, + headers, +}: { + data: { clientSubmissionId: string; title: string; content: string } + headers: Headers +}): Promise { + return submitBugReportHandler({ data, headers, configuredHost: true }) +} + export const submitBugReportFn = createServerFn({ method: 'POST' }) .validator(submitBugReportSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/lib/server/functions/portal-access.ts b/apps/web/src/lib/server/functions/portal-access.ts index 84adac13d..bde886816 100644 --- a/apps/web/src/lib/server/functions/portal-access.ts +++ b/apps/web/src/lib/server/functions/portal-access.ts @@ -36,6 +36,10 @@ export type PortalAccessDecision = reason: 'unauthenticated' | 'unauthorized' } +export type PortalAccessHostOutcome = + | { kind: 'decision'; decision: PortalAccessDecision } + | { kind: 'dependency_failure' } + /** * Resolve the portal-access decision for the CURRENT request. * @@ -58,189 +62,177 @@ export type PortalAccessDecision = * anonymous (isAnonymousPrincipal = true, role = null). A DB error during * principal resolution must never grant access to a private portal. */ -export const resolvePortalAccessForRequest = createServerOnlyFn( - async (): Promise => { - const { auth } = await import('@/lib/server/auth/index') - const { db, principal, eq } = await import('@/lib/server/db') - const { getRequestHeaders } = await import('@tanstack/react-start/server') - const headers = getRequestHeaders() +function resolvePortalAccessCore(options: { + dependencyMode: 'legacy' +}): Promise +function resolvePortalAccessCore(options: { + dependencyMode: 'typed-host' +}): Promise +async function resolvePortalAccessCore(options: { + dependencyMode: 'legacy' | 'typed-host' +}): Promise { + const typedHost = options.dependencyMode === 'typed-host' + const dependencyFailure = { kind: 'dependency_failure' } as const + const finish = (decision: PortalAccessDecision) => + typedHost ? ({ kind: 'decision', decision } as const) : decision + + const { auth } = await import('@/lib/server/auth/index') + const { db, principal, eq } = await import('@/lib/server/db') + const { getRequestHeaders } = await import('@tanstack/react-start/server') + const headers = getRequestHeaders() + + let session: Awaited> | null = null + try { + session = await auth.api.getSession({ headers }) + } catch { + if (typedHost) return dependencyFailure + } - // Resolve the caller's session — no client-supplied identity accepted. - let session: Awaited> | null = null + let role: 'admin' | 'member' | 'user' | null = null + let userEmail: string | null = null + let emailVerified = false + let isAnonymousPrincipal = false + let resolvedPrincipalId: string | null = null + + if (session?.user) { + userEmail = session.user.email + emailVerified = session.user.emailVerified + let principalRecord: { type: string; role: string | null; id: string } | undefined try { - session = await auth.api.getSession({ headers }) + principalRecord = await db.query.principal.findFirst({ + where: eq(principal.userId, session.user.id as UserId), + columns: { type: true, role: true, id: true }, + }) } catch { - // No session available; treat as anonymous. + if (typedHost) return dependencyFailure + isAnonymousPrincipal = true } - - let role: 'admin' | 'member' | 'user' | null = null - let userEmail: string | null = null - let emailVerified = false - let isAnonymousPrincipal = false - let resolvedPrincipalId: string | null = null - - if (session?.user) { - userEmail = session.user.email - emailVerified = session.user.emailVerified - - // Resolve principalType so anonymous Better Auth sessions are not - // counted as authenticated portal sessions. - // Fail CLOSED on DB error: treat the session as anonymous so a lookup - // failure never grants access to a private portal. - let principalRecord: { type: string; role: string | null; id: string } | undefined - try { - principalRecord = await db.query.principal.findFirst({ - where: eq(principal.userId, session.user.id as UserId), - columns: { type: true, role: true, id: true }, - }) - } catch { - // Principal lookup failed — treat caller as anonymous (fail closed). - isAnonymousPrincipal = true - } - if (!isAnonymousPrincipal) { - if (principalRecord?.type === 'anonymous') { + if (!isAnonymousPrincipal) { + if (principalRecord?.type === 'anonymous') isAnonymousPrincipal = true + const currentRole = (principalRecord?.role as 'admin' | 'member' | 'user' | null) ?? null + let widgetTeamSession = false + if (principalRecord && session.session?.id) { + try { + widgetTeamSession = await ( + await import('@/lib/server/auth/widget-session-guard') + ).isWidgetTeamSession(session.session.id, currentRole) + } catch { + if (typedHost) return dependencyFailure isAnonymousPrincipal = true } - const currentRole = (principalRecord?.role as 'admin' | 'member' | 'user' | null) ?? null - let widgetTeamSession = false - if (principalRecord && session.session?.id) { - try { - widgetTeamSession = await ( - await import('@/lib/server/auth/widget-session-guard') - ).isWidgetTeamSession(session.session.id, currentRole) - } catch { - // Provenance is part of the same privilege decision as role. If it - // cannot be read, preserve this resolver's never-throw contract by - // treating the caller as anonymous rather than granting team access. - isAnonymousPrincipal = true - } - } - if (widgetTeamSession) isAnonymousPrincipal = true - if (!isAnonymousPrincipal) { - role = currentRole - resolvedPrincipalId = principalRecord?.id ?? null - } } - } - - const isAuthenticated = !!session?.user && !isAnonymousPrincipal - - // Check for an accepted portal invite — only when the caller is a - // verified authenticated user (both conditions required before hitting DB). - // Fail CLOSED on DB error: if the lookup fails, assume no invite so a DB - // outage never grants access to a private portal. - let hasAcceptedPortalInvite = false - if (isAuthenticated && emailVerified && userEmail) { - const { invitation, and: dbAnd } = await import('@/lib/server/db') - // Lowercase the session email before the SQL comparison — the send path - // always normalizes to lowercase on insert, but an OAuth provider may - // return a mixed-case address that is stored on the session as-is. - const normalizedEmail = userEmail.toLowerCase() - try { - const inviteRow = await db.query.invitation.findFirst({ - where: dbAnd( - eq(invitation.email, normalizedEmail), - eq(invitation.kind, 'portal'), - // Accepted invites are permanent until revoked — expiry only governs - // pending invites. Dropping the expires_at check here prevents a - // user losing access once the invite's pending window passes. - eq(invitation.status, 'accepted') - ), - columns: { id: true }, - }) - hasAcceptedPortalInvite = !!inviteRow - } catch { - // Invite lookup failed — assume no invite (fail closed). - hasAcceptedPortalInvite = false + if (widgetTeamSession) isAnonymousPrincipal = true + if (!isAnonymousPrincipal) { + role = currentRole + resolvedPrincipalId = principalRecord?.id ?? null } } + } - // Look up the widget origin marker for the current session. - // Fail CLOSED on DB error: a lookup failure never grants widget access. - let hasViaWidgetMarker = false - if (isAuthenticated && session?.session?.id) { - const { widgetOriginSession } = await import('@/lib/server/db') - try { - const markerRow = await db.query.widgetOriginSession.findFirst({ - where: eq(widgetOriginSession.sessionId, session.session.id), - columns: { sessionId: true }, - }) - hasViaWidgetMarker = !!markerRow - } catch { - // DB error — fail closed (no widget marker). - hasViaWidgetMarker = false - } + const isAuthenticated = !!session?.user && !isAnonymousPrincipal + let hasAcceptedPortalInvite = false + if (isAuthenticated && emailVerified && userEmail) { + const { invitation, and: dbAnd } = await import('@/lib/server/db') + const normalizedEmail = userEmail.toLowerCase() + try { + const inviteRow = await db.query.invitation.findFirst({ + where: dbAnd( + eq(invitation.email, normalizedEmail), + eq(invitation.kind, 'portal'), + eq(invitation.status, 'accepted') + ), + columns: { id: true }, + }) + hasAcceptedPortalInvite = !!inviteRow + } catch { + if (typedHost) return dependencyFailure + hasAcceptedPortalInvite = false } + } - // Read the full portal config + widget config server-side — never leaves this function. - // Two distinct failure modes: - // - NotFoundError (no settings row): fresh un-onboarded install, fail - // OPEN to a public portal so it keeps working. - // - Anything else (DB error, JSON parse, transient infra): fail CLOSED. - // A private portal must never silently become public on transient errors. - let result: { granted: boolean; reason: string } + let hasViaWidgetMarker = false + if (isAuthenticated && session?.session?.id) { + const { widgetOriginSession } = await import('@/lib/server/db') try { - const [{ getPortalConfig }, { getWidgetConfig }, { evaluatePortalAccess }] = - await Promise.all([ - import('@/lib/server/domains/settings/settings.service'), - import('@/lib/server/domains/settings/settings.widget'), - import('@/lib/server/domains/settings/portal-access'), - ]) - const [portalConfig, widgetConfig] = await Promise.all([ - getPortalConfig(), - getWidgetConfig().catch(() => null), - ]) - const identifyVerificationEnabled = widgetConfig?.identifyVerification ?? false - - // Check segment membership — only when authenticated and the config lists allowed segments. - // Fail CLOSED on DB error: a lookup failure never grants access. - const allowedSegmentIds = portalConfig.access?.allowedSegmentIds ?? [] - let isInAllowedSegment = false - if (isAuthenticated && resolvedPrincipalId && allowedSegmentIds.length > 0) { - try { - const { segmentIdsForPrincipal } = - await import('@/lib/server/domains/segments/segment-membership.service') - const memberSet = await segmentIdsForPrincipal(resolvedPrincipalId as PrincipalId) - isInAllowedSegment = allowedSegmentIds.some((id) => memberSet.has(id as SegmentId)) - } catch (err) { - log.warn({ err }, 'segment lookup failed, failing closed') - isInAllowedSegment = false - } - } - - result = evaluatePortalAccess({ - visibility: portalConfig.access?.visibility ?? 'public', - role, - isAuthenticated, - userEmail, - emailVerified, - allowedDomains: portalConfig.access?.allowedDomains ?? [], - hasAcceptedPortalInvite, - widgetSignInEnabled: portalConfig.access?.widgetSignIn ?? false, - hasViaWidgetMarker, - identifyVerificationEnabled, - isInAllowedSegment, + const markerRow = await db.query.widgetOriginSession.findFirst({ + where: eq(widgetOriginSession.sessionId, session.session.id), + columns: { sessionId: true }, }) - } catch (err) { - const { NotFoundError } = await import('@/lib/shared/errors') - if (err instanceof NotFoundError) { - // No settings row — un-onboarded install, treat as public. - return { granted: true, reason: 'public' } - } - // Any other throw (DB error, cache deserialization, etc.) must fail - // closed. An authenticated visitor gets the unauthorized screen; an - // anonymous one gets bounced to login. - log.error({ err }, 'resolve failed, failing closed') - return { - granted: false, - reason: isAuthenticated ? 'unauthorized' : 'unauthenticated', + hasViaWidgetMarker = !!markerRow + } catch { + if (typedHost) return dependencyFailure + hasViaWidgetMarker = false + } + } + + let result: { granted: boolean; reason: string } + try { + const [{ getPortalConfig }, { getWidgetConfig }, { evaluatePortalAccess }] = await Promise.all([ + import('@/lib/server/domains/settings/settings.service'), + import('@/lib/server/domains/settings/settings.widget'), + import('@/lib/server/domains/settings/portal-access'), + ]) + const [portalResult, widgetResult] = await Promise.allSettled([ + getPortalConfig(), + getWidgetConfig(), + ]) + if (portalResult.status === 'rejected') throw portalResult.reason + if (widgetResult.status === 'rejected' && typedHost) return dependencyFailure + const portalConfig = portalResult.value + const widgetConfig = widgetResult.status === 'fulfilled' ? widgetResult.value : null + const identifyVerificationEnabled = widgetConfig?.identifyVerification ?? false + + const allowedSegmentIds = portalConfig.access?.allowedSegmentIds ?? [] + let isInAllowedSegment = false + if (isAuthenticated && resolvedPrincipalId && allowedSegmentIds.length > 0) { + try { + const { segmentIdsForPrincipal } = + await import('@/lib/server/domains/segments/segment-membership.service') + const memberSet = await segmentIdsForPrincipal(resolvedPrincipalId as PrincipalId) + isInAllowedSegment = allowedSegmentIds.some((id) => memberSet.has(id as SegmentId)) + } catch (err) { + if (typedHost) return dependencyFailure + log.warn({ err }, 'segment lookup failed, failing closed') + isInAllowedSegment = false } } - // Return only the decision. Never include allowedDomains, widgetSignIn, - // or any other policy input — those must stay server-side. - return { granted: result.granted, reason: result.reason } as PortalAccessDecision + result = evaluatePortalAccess({ + visibility: portalConfig.access?.visibility ?? 'public', + role, + isAuthenticated, + userEmail, + emailVerified, + allowedDomains: portalConfig.access?.allowedDomains ?? [], + hasAcceptedPortalInvite, + widgetSignInEnabled: portalConfig.access?.widgetSignIn ?? false, + hasViaWidgetMarker, + identifyVerificationEnabled, + isInAllowedSegment, + }) + } catch (err) { + if (typedHost) return dependencyFailure + const { NotFoundError } = await import('@/lib/shared/errors') + if (err instanceof NotFoundError) { + return finish({ granted: true, reason: 'public' }) + } + log.error({ err }, 'resolve failed, failing closed') + return finish({ + granted: false, + reason: isAuthenticated ? 'unauthorized' : 'unauthenticated', + }) } + + return finish({ granted: result.granted, reason: result.reason } as PortalAccessDecision) +} + +export const resolvePortalAccessForRequest = createServerOnlyFn( + async (): Promise => resolvePortalAccessCore({ dependencyMode: 'legacy' }) +) + +export const resolvePortalAccessForHostSubmit = createServerOnlyFn( + async (): Promise => + resolvePortalAccessCore({ dependencyMode: 'typed-host' }) ) /** diff --git a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts new file mode 100644 index 000000000..2639035a6 --- /dev/null +++ b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts @@ -0,0 +1,223 @@ +import contract from '../../../../../../../docs/fixtures/quackback-report-submit-contract-v1.json' +import { describe, expect, it } from 'vitest' +import { + mapHostSubmitText, + parseHostSubmitReceipt, + parseHostSubmitRequestCorrelation, + parseHostSubmitRequestMessage, + parseHostSubmitResultMessage, +} from '../host-submit-contract' + +const REQUEST_ID = '11111111-1111-4111-8111-111111111111' +const SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' +const RECEIPT = { + schemaVersion: 'BugReportReceiptV1', + reportRef: 'qbr_abcdefghijklmnopqrstuvwx', + status: 'received', + createdAt: '2026-07-28T01:00:00.000Z', + updatedAt: '2026-07-28T02:00:00.000Z', +} as const + +function request(data: Record = {}) { + return { + type: contract.requestType, + data: { + contract: contract.adapterContract, + requestId: REQUEST_ID, + clientSubmissionId: SUBMISSION_ID, + summary: 'Save button does nothing', + impact: 'Cannot finish checkout', + ...data, + }, + } +} + +describe('host submit request contract', () => { + it('accepts only the exact request envelope and exact provider-safe input keys', () => { + expect(parseHostSubmitRequestMessage(request())).toEqual(request().data) + + for (const value of [ + [], + request({ boardId: 'board_private' }), + request({ principalId: 'principal_private' }), + request({ status: 'fixed' }), + request({ media: [] }), + request({ context: {} }), + { ...request(), rawError: 'private' }, + ]) { + expect(parseHostSubmitRequestMessage(value)).toBeNull() + } + }) + + it('enforces UUIDv4 and JavaScript code-unit limits', () => { + expect( + parseHostSubmitRequestMessage(request({ requestId: crypto.randomUUID() })) + ).not.toBeNull() + expect( + parseHostSubmitRequestMessage(request({ clientSubmissionId: crypto.randomUUID() })) + ).not.toBeNull() + + expect(parseHostSubmitRequestMessage(request({ requestId: 'not-a-uuid' }))).toBeNull() + expect( + parseHostSubmitRequestMessage( + request({ clientSubmissionId: '22222222-2222-1222-8222-222222222222' }) + ) + ).toBeNull() + expect(parseHostSubmitRequestMessage(request({ summary: 's'.repeat(2_000) }))).not.toBeNull() + expect(parseHostSubmitRequestMessage(request({ summary: 's'.repeat(2_001) }))).toBeNull() + expect(parseHostSubmitRequestMessage(request({ impact: 'i'.repeat(1_000) }))).not.toBeNull() + expect(parseHostSubmitRequestMessage(request({ impact: 'i'.repeat(1_001) }))).toBeNull() + expect(parseHostSubmitRequestMessage(request({ summary: '😀'.repeat(1_001) }))).toBeNull() + }) + + it('rejects arrays, inherited records, symbols, non-enumerable fields, and accessors', () => { + const inherited = Object.create(request()) + const symbolKey = request() as Record + symbolKey[Symbol('hidden')] = true + const nonEnumerable = request() + Object.defineProperty(nonEnumerable.data, 'impact', { + value: 'Cannot finish checkout', + enumerable: false, + }) + const accessor = request() + let accessed = false + Object.defineProperty(accessor.data, 'summary', { + enumerable: true, + get() { + accessed = true + return 'private' + }, + }) + + for (const value of [inherited, symbolKey, nonEnumerable, accessor]) { + expect(parseHostSubmitRequestMessage(value)).toBeNull() + } + expect(accessed).toBe(false) + }) + + it('recovers only a descriptor-safe exact correlation from malformed request data', () => { + expect(parseHostSubmitRequestCorrelation(request({ summary: 42, extra: 'malformed' }))).toEqual( + { requestId: REQUEST_ID } + ) + expect(parseHostSubmitRequestCorrelation({ ...request(), extra: true })).toBeNull() + expect(parseHostSubmitRequestCorrelation(request({ requestId: 'invalid' }))).toBeNull() + expect(parseHostSubmitRequestCorrelation({ ...request(), type: 'quackback:other' })).toBeNull() + + const accessor = request({ extra: true }) + let accessed = false + Object.defineProperty(accessor.data, 'extra', { + enumerable: true, + get() { + accessed = true + return 'private' + }, + }) + expect(parseHostSubmitRequestCorrelation(accessor)).toBeNull() + expect(accessed).toBe(false) + }) + + it('does not recover correlation through an own __proto__ payload', () => { + const data = Object.create(null) + Object.defineProperty(data, '__proto__', { + enumerable: true, + value: { + contract: contract.adapterContract, + requestId: REQUEST_ID, + }, + }) + + expect( + parseHostSubmitRequestCorrelation({ + type: contract.requestType, + data, + }) + ).toBeNull() + }) +}) + +describe('host submit result and receipt contract', () => { + it('accepts every status, valid timestamps, and optional fixedInRelease', () => { + for (const status of contract.statuses) { + expect(parseHostSubmitReceipt({ ...RECEIPT, status })).toEqual({ ...RECEIPT, status }) + } + expect(parseHostSubmitReceipt({ ...RECEIPT, fixedInRelease: '2026.07.28' })).toEqual({ + ...RECEIPT, + fixedInRelease: '2026.07.28', + }) + }) + + it('rejects invalid report refs, timestamps, chronology, and raw or unknown fields', () => { + for (const value of [ + { ...RECEIPT, reportRef: 'post_private' }, + { ...RECEIPT, status: 'open' }, + { ...RECEIPT, createdAt: 'yesterday' }, + { ...RECEIPT, updatedAt: '2026-07-28T00:59:59.999Z' }, + { ...RECEIPT, rawError: 'private provider detail' }, + { ...RECEIPT, postId: 'post_private' }, + ]) { + expect(parseHostSubmitReceipt(value)).toBeNull() + } + }) + + it('parses exact success and failure result messages without raw error fields', () => { + const success = { + type: contract.resultType, + data: { + contract: contract.adapterContract, + requestId: REQUEST_ID, + accepted: true, + receipt: RECEIPT, + }, + } + expect(parseHostSubmitResultMessage(success)).toEqual(success.data) + + for (const reason of contract.failureReasons) { + const failure = { + type: contract.resultType, + data: { + contract: contract.adapterContract, + requestId: REQUEST_ID, + accepted: false, + reason, + }, + } + expect(parseHostSubmitResultMessage(failure)).toEqual(failure.data) + } + + expect( + parseHostSubmitResultMessage({ + ...success, + data: { ...success.data, rawError: 'private provider detail' }, + }) + ).toBeNull() + }) +}) + +describe('host submit text mapping', () => { + it('normalizes and maps the provider-safe input deterministically', () => { + expect( + mapHostSubmitText({ + clientSubmissionId: SUBMISSION_ID, + summary: ' Save button\n\ndoes nothing ', + impact: ' Cannot finish checkout ', + }) + ).toEqual({ + clientSubmissionId: SUBMISSION_ID, + title: 'Save button', + content: 'Save button\n\ndoes nothing\n\nImpact:\nCannot finish checkout', + }) + }) + + it('surrogate-safely truncates only the first logical line used as the title', () => { + const summary = `${'a'.repeat(199)}😀 continues\nsecond line` + const mapped = mapHostSubmitText({ + clientSubmissionId: SUBMISSION_ID, + summary, + impact: 'Checkout is blocked', + }) + + expect(mapped.title.length).toBeLessThanOrEqual(200) + expect(mapped.title.endsWith('\ud83d')).toBe(false) + expect(mapped.content).toBe(`${summary}\n\nImpact:\nCheckout is blocked`) + }) +}) diff --git a/apps/web/src/lib/shared/bugreport/host-submit-contract.ts b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts new file mode 100644 index 000000000..9e398f4b2 --- /dev/null +++ b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts @@ -0,0 +1,297 @@ +export type HostSubmitFailureReason = + | 'aborted' + | 'invalid_request' + | 'unavailable' + | 'unauthorized' + | 'retryable_failure' + +export type HostSubmitProviderFailureReason = 'unavailable' | 'unauthorized' | 'retryable_failure' + +export type SubmitBugReportInputV1 = { + clientSubmissionId: string + summary: string + impact: string +} + +export type HostSubmitRequestData = { + contract: 'iplaycafe.quackback.report-submit/1' + requestId: string + clientSubmissionId: string + summary: string + impact: string +} + +export type HostSubmitStatus = + | 'received' + | 'triaging' + | 'needs_info' + | 'in_progress' + | 'verifying' + | 'fixed' + | 'closed' + +export type HostSubmitReceiptV1 = { + schemaVersion: 'BugReportReceiptV1' + reportRef: string + status: HostSubmitStatus + createdAt: string + updatedAt: string + fixedInRelease?: string +} + +export type HostSubmitServerResult = + | { accepted: true; receipt: HostSubmitReceiptV1 } + | { accepted: false; reason: HostSubmitProviderFailureReason } + +export type HostSubmitResultData = + | { + contract: 'iplaycafe.quackback.report-submit/1' + requestId: string + accepted: true + receipt: HostSubmitReceiptV1 + } + | { + contract: 'iplaycafe.quackback.report-submit/1' + requestId: string + accepted: false + reason: HostSubmitFailureReason + } + +const CONTRACT = 'iplaycafe.quackback.report-submit/1' as const +const REQUEST_TYPE = 'quackback:report-submit' +const RESULT_TYPE = 'quackback:report-submit-result' +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const REPORT_REF = /^qbr_[A-Za-z0-9_-]{24}$/ +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ +const HOST_SUBMIT_STATUSES = new Set([ + 'received', + 'triaging', + 'needs_info', + 'in_progress', + 'verifying', + 'fixed', + 'closed', +]) +const HOST_SUBMIT_FAILURE_REASONS = new Set([ + 'aborted', + 'invalid_request', + 'unavailable', + 'unauthorized', + 'retryable_failure', +]) + +type DataRecord = Record + +function readDataRecord(value: unknown): DataRecord | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null + + try { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return null + + const descriptors = Object.getOwnPropertyDescriptors(value) + const record: DataRecord = Object.create(null) as DataRecord + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return null + const descriptor = descriptors[key] + if (!descriptor?.enumerable || !('value' in descriptor)) return null + record[key] = descriptor.value + } + return record + } catch { + return null + } +} + +function readExactDataRecord(value: unknown, expectedKeys: readonly string[]): DataRecord | null { + const record = readDataRecord(value) + if (!record) return null + const keys = Object.keys(record) + if (keys.length !== expectedKeys.length) return null + const expected = new Set(expectedKeys) + return keys.every((key) => expected.has(key)) ? record : null +} + +function isUuidV4(value: unknown): value is string { + return typeof value === 'string' && UUID_V4.test(value) +} + +function isBoundedText(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength +} + +function isIsoTimestamp(value: unknown): value is string { + if (typeof value !== 'string' || !ISO_TIMESTAMP.test(value)) return false + const time = Date.parse(value) + return Number.isFinite(time) && new Date(time).toISOString() === value +} + +function parseHostSubmitInput(value: unknown): SubmitBugReportInputV1 | null { + const record = readExactDataRecord(value, ['clientSubmissionId', 'summary', 'impact']) + if ( + !record || + !isUuidV4(record.clientSubmissionId) || + !isBoundedText(record.summary, 2_000) || + !isBoundedText(record.impact, 1_000) || + record.summary.trim().length === 0 || + record.impact.trim().length === 0 + ) { + return null + } + return { + clientSubmissionId: record.clientSubmissionId, + summary: record.summary, + impact: record.impact, + } +} + +function readExactEnvelope( + value: unknown, + expectedType: string +): { type: string; data: unknown } | null { + const envelope = readExactDataRecord(value, ['type', 'data']) + if (!envelope || envelope.type !== expectedType) return null + return { type: expectedType, data: envelope.data } +} + +export function parseHostSubmitRequestMessage(value: unknown): HostSubmitRequestData | null { + const envelope = readExactEnvelope(value, REQUEST_TYPE) + if (!envelope) return null + const data = readExactDataRecord(envelope.data, [ + 'contract', + 'requestId', + 'clientSubmissionId', + 'summary', + 'impact', + ]) + if (!data || data.contract !== CONTRACT || !isUuidV4(data.requestId)) return null + + const input = parseHostSubmitInput({ + clientSubmissionId: data.clientSubmissionId, + summary: data.summary, + impact: data.impact, + }) + if (!input) return null + return { + contract: CONTRACT, + requestId: data.requestId, + ...input, + } +} + +export function parseHostSubmitRequestCorrelation(value: unknown): { requestId: string } | null { + const envelope = readExactEnvelope(value, REQUEST_TYPE) + if (!envelope) return null + const data = readDataRecord(envelope.data) + if (!data || data.contract !== CONTRACT || !isUuidV4(data.requestId)) return null + return { requestId: data.requestId } +} + +export function parseHostSubmitReceipt(value: unknown): HostSubmitReceiptV1 | null { + const record = readDataRecord(value) + if (!record) return null + const keys = Object.keys(record) + const requiredKeys = ['schemaVersion', 'reportRef', 'status', 'createdAt', 'updatedAt'] + const expectedKeys = + 'fixedInRelease' in record ? [...requiredKeys, 'fixedInRelease'] : requiredKeys + if ( + keys.length !== expectedKeys.length || + !keys.every((key) => expectedKeys.includes(key)) || + record.schemaVersion !== 'BugReportReceiptV1' || + typeof record.reportRef !== 'string' || + !REPORT_REF.test(record.reportRef) || + typeof record.status !== 'string' || + !HOST_SUBMIT_STATUSES.has(record.status as HostSubmitStatus) || + !isIsoTimestamp(record.createdAt) || + !isIsoTimestamp(record.updatedAt) || + Date.parse(record.updatedAt) < Date.parse(record.createdAt) || + ('fixedInRelease' in record && + (typeof record.fixedInRelease !== 'string' || record.fixedInRelease.length === 0)) + ) { + return null + } + + return { + schemaVersion: 'BugReportReceiptV1', + reportRef: record.reportRef, + status: record.status as HostSubmitStatus, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ...('fixedInRelease' in record ? { fixedInRelease: record.fixedInRelease as string } : {}), + } +} + +export function parseHostSubmitResultMessage(value: unknown): HostSubmitResultData | null { + const envelope = readExactEnvelope(value, RESULT_TYPE) + if (!envelope) return null + const data = readDataRecord(envelope.data) + if (!data || data.contract !== CONTRACT || !isUuidV4(data.requestId)) return null + + if (data.accepted === true) { + const exact = readExactDataRecord(envelope.data, [ + 'contract', + 'requestId', + 'accepted', + 'receipt', + ]) + const receipt = exact ? parseHostSubmitReceipt(exact.receipt) : null + return receipt + ? { + contract: CONTRACT, + requestId: data.requestId, + accepted: true, + receipt, + } + : null + } + + if (data.accepted === false) { + const exact = readExactDataRecord(envelope.data, [ + 'contract', + 'requestId', + 'accepted', + 'reason', + ]) + if ( + !exact || + typeof exact.reason !== 'string' || + !HOST_SUBMIT_FAILURE_REASONS.has(exact.reason as HostSubmitFailureReason) + ) { + return null + } + return { + contract: CONTRACT, + requestId: data.requestId, + accepted: false, + reason: exact.reason as HostSubmitFailureReason, + } + } + + return null +} + +function normalizeText(value: string): string { + return value.replace(/\r\n?/g, '\n').trim() +} + +function truncateTitle(value: string): string { + let title = value.slice(0, 200) + const finalCodeUnit = title.charCodeAt(title.length - 1) + if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) title = title.slice(0, -1) + return title +} + +export function mapHostSubmitText(input: SubmitBugReportInputV1): { + clientSubmissionId: string + title: string + content: string +} { + const summary = normalizeText(input.summary) + const impact = normalizeText(input.impact) + const title = truncateTitle(summary.split('\n', 1)[0].trim()) + return { + clientSubmissionId: input.clientSubmissionId, + title, + content: `${summary}\n\nImpact:\n${impact}`, + } +} diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index e71ad6e01..bcc9f857e 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -30,6 +30,10 @@ import { WidgetBugReport, type CapturePayload } from '@/components/widget/widget import { WidgetMyReports } from '@/components/widget/widget-my-reports' import { useWidgetAuth } from '@/components/widget/widget-auth-provider' import { sendToHost } from '@/lib/client/widget-bridge' +import { + installBugReportHostParentBinding, + installBugReportHostSubmitBridge, +} from '@/lib/client/bug-report-host-submit' import { installNetRecorder } from '@/lib/client/capture/net-recorder' import { projectBugReportMediaEvidence } from '@/lib/shared/bugreport/media-evidence-gates' import { portalQueries } from '@/lib/client/queries/portal' @@ -39,6 +43,10 @@ import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use import { CHAT_PRESENCE_QUERY_KEY } from '@/components/widget/use-chat-presence' import { createBugReportFlowId, isBugReportFlowId } from '@/lib/client/bug-report-flow' import type { BugReportReceiptV1 } from '@/lib/server/domains/bug-reports/receipt.service' +import { + authorizeBugReportHostOriginFn, + submitHostBugReportFn, +} from '@/lib/server/functions/bug-report-host-submit' /** How long the widget waits for the host embed's capture-result before * degrading to a text-only report (old cached embeds never reply). */ @@ -213,6 +221,10 @@ function WidgetPage() { portalOrigin, } = Route.useLoaderData() const { ensureSession, sessionVersion, emitEvent, isIdentified, hmacRequired } = useWidgetAuth() + const widgetSessionGenerationRef = useRef(0) + const hostParentBindingRef = useRef | null>( + null + ) // Bug-report capture wiring: `undefined` means the user has not requested a // screenshot, `null` means a requested capture is pending, and an object is @@ -443,6 +455,8 @@ function WidgetPage() { // the auth provider can install the new token. This also protects older // SDKs that do not send the explicit reset message first. if (msg.type === 'quackback:identify') { + widgetSessionGenerationRef.current += 1 + hostParentBindingRef.current?.clear() const flowId = activeBugReportFlowIdRef.current updatePendingBugReportOpen(null) if (flowId) { @@ -521,6 +535,35 @@ function WidgetPage() { return () => window.removeEventListener('message', handleMessage) }, [tabs, openChat, clearBugReportFlow, updatePendingBugReportOpen]) + useEffect(() => { + const authorizeOrigin = (candidateOrigin: string) => + authorizeBugReportHostOriginFn({ + data: { candidateOrigin }, + }) + + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin, + currentGeneration: () => widgetSessionGenerationRef.current, + }) + hostParentBindingRef.current = parentBinding + + const disposeHostSubmit = installBugReportHostSubmitBridge({ + authorizeOrigin, + currentBinding: () => parentBinding.current(), + submit: (input) => + submitHostBugReportFn({ + data: input, + headers: getWidgetAuthHeaders(), + }), + }) + + return () => { + disposeHostSubmit() + parentBinding.dispose() + if (hostParentBindingRef.current === parentBinding) hostParentBindingRef.current = null + } + }, []) + const handlePostCreated = useCallback((post: SuccessPost) => { setCreatedPosts((prev) => [ { @@ -706,9 +749,7 @@ function WidgetPage() { } setCreatedPosts((current: typeof createdPosts) => [ result as (typeof createdPosts)[number], - ...current.filter( - (post: (typeof createdPosts)[number]) => post.id !== result.id - ), + ...current.filter((post: (typeof createdPosts)[number]) => post.id !== result.id), ]) }} onViewReports={ From c5d074cdde0f763315255e4eedc28a008d487d7b Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Tue, 28 Jul 2026 18:13:26 +0700 Subject: [PATCH 03/21] fix(widget): allow empty host report impact --- .../__tests__/host-submit-contract.test.ts | 21 +++++++++++++++++++ .../shared/bugreport/host-submit-contract.ts | 8 +++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts index 2639035a6..e4d4e3e1a 100644 --- a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts +++ b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts @@ -70,6 +70,13 @@ describe('host submit request contract', () => { expect(parseHostSubmitRequestMessage(request({ summary: '😀'.repeat(1_001) }))).toBeNull() }) + it('accepts an empty optional impact', () => { + expect(parseHostSubmitRequestMessage(request({ impact: '' }))).toEqual({ + ...request().data, + impact: '', + }) + }) + it('rejects arrays, inherited records, symbols, non-enumerable fields, and accessors', () => { const inherited = Object.create(request()) const symbolKey = request() as Record @@ -220,4 +227,18 @@ describe('host submit text mapping', () => { expect(mapped.title.endsWith('\ud83d')).toBe(false) expect(mapped.content).toBe(`${summary}\n\nImpact:\nCheckout is blocked`) }) + + it('omits the Impact section when normalized impact is empty', () => { + expect( + mapHostSubmitText({ + clientSubmissionId: SUBMISSION_ID, + summary: ' Save button does nothing ', + impact: ' ', + }) + ).toEqual({ + clientSubmissionId: SUBMISSION_ID, + title: 'Save button does nothing', + content: 'Save button does nothing', + }) + }) }) diff --git a/apps/web/src/lib/shared/bugreport/host-submit-contract.ts b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts index 9e398f4b2..d1b914981 100644 --- a/apps/web/src/lib/shared/bugreport/host-submit-contract.ts +++ b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts @@ -132,9 +132,9 @@ function parseHostSubmitInput(value: unknown): SubmitBugReportInputV1 | null { !record || !isUuidV4(record.clientSubmissionId) || !isBoundedText(record.summary, 2_000) || - !isBoundedText(record.impact, 1_000) || - record.summary.trim().length === 0 || - record.impact.trim().length === 0 + typeof record.impact !== 'string' || + record.impact.length > 1_000 || + record.summary.trim().length === 0 ) { return null } @@ -292,6 +292,6 @@ export function mapHostSubmitText(input: SubmitBugReportInputV1): { return { clientSubmissionId: input.clientSubmissionId, title, - content: `${summary}\n\nImpact:\n${impact}`, + content: impact ? `${summary}\n\nImpact:\n${impact}` : summary, } } From eeafb48d6ed3580e856e9711f9af43111c3d89d3 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Tue, 28 Jul 2026 18:29:02 +0700 Subject: [PATCH 04/21] feat(widget-sdk): add bounded host report submit --- .../src/__tests__/browser-queue.test.ts | 78 ++++ packages/widget/src/browser-queue.ts | 4 + .../src/core/__tests__/postmessage.test.ts | 41 ++ .../src/core/__tests__/report-submit.test.ts | 278 +++++++++++ .../src/core/__tests__/sdk-capture.test.ts | 434 +++++++++++++++++- packages/widget/src/core/postmessage.ts | 4 + packages/widget/src/core/report-submit.ts | 223 +++++++++ packages/widget/src/core/sdk.ts | 251 +++++++++- packages/widget/src/index.ts | 18 + packages/widget/src/types.ts | 42 +- 10 files changed, 1358 insertions(+), 15 deletions(-) create mode 100644 packages/widget/src/__tests__/browser-queue.test.ts create mode 100644 packages/widget/src/core/__tests__/report-submit.test.ts create mode 100644 packages/widget/src/core/report-submit.ts diff --git a/packages/widget/src/__tests__/browser-queue.test.ts b/packages/widget/src/__tests__/browser-queue.test.ts new file mode 100644 index 000000000..6139da003 --- /dev/null +++ b/packages/widget/src/__tests__/browser-queue.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { dispatch } = vi.hoisted(() => ({ + dispatch: vi.fn(), +})) + +vi.mock('../core/sdk', () => ({ + createSDK: () => ({ + dispatch, + }), +})) + +const SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' + +function makeArguments(...values: unknown[]): IArguments { + return (function collect(..._args: unknown[]) { + // The pre-live embed queue stores the engine's IArguments object, not an + // Array. This deliberately mirrors that browser shape. + // eslint-disable-next-line prefer-rest-params + return arguments + })(...values) +} + +describe('script-tag browser queue', () => { + beforeEach(() => { + vi.resetModules() + dispatch.mockReset() + delete window.Quackback + delete window.__QUACKBACK_URL__ + }) + + afterEach(() => { + vi.restoreAllMocks() + delete window.Quackback + delete window.__QUACKBACK_URL__ + }) + + it('discards inherited submit calls with private text but returns the live SDK promise', async () => { + window.Quackback = Object.assign( + function queuedCall() { + return undefined + }, + { + q: [ + makeArguments('submitBugReport', { + clientSubmissionId: SUBMISSION_ID, + summary: 'canary-private-queued-summary', + impact: '', + }), + makeArguments('metadata', { route: '/checkout' }), + ], + } + ) + const liveResult = Promise.resolve({ accepted: false, reason: 'unavailable' }) + dispatch.mockImplementation((command: string) => + command === 'submitBugReport' ? liveResult : undefined + ) + + await import('../browser-queue') + + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith('metadata', { route: '/checkout' }, undefined) + expect(JSON.stringify(dispatch.mock.calls)).not.toContain('canary-private-queued-summary') + + const returned = window.Quackback?.( + 'submitBugReport', + { + clientSubmissionId: SUBMISSION_ID, + summary: 'live summary', + impact: '', + }, + {} + ) + expect(returned).toBe(liveResult) + await expect(returned).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + }) +}) diff --git a/packages/widget/src/browser-queue.ts b/packages/widget/src/browser-queue.ts index 374d98648..525033a5f 100644 --- a/packages/widget/src/browser-queue.ts +++ b/packages/widget/src/browser-queue.ts @@ -49,6 +49,10 @@ w.Quackback = function (...args: unknown[]) { // Replay any queued commands. for (const args of queued) { const a = args as unknown as unknown[] + // A pre-live queue is ambient page state and can outlive the caller that + // supplied private report text. Never inherit/replay that protected command; + // callers receive a Promise only from the installed live dispatcher. + if (a[0] === 'submitBugReport') continue dispatch(a[0], a[1], a[2]) } diff --git a/packages/widget/src/core/__tests__/postmessage.test.ts b/packages/widget/src/core/__tests__/postmessage.test.ts index dc3d620c3..db7988a10 100644 --- a/packages/widget/src/core/__tests__/postmessage.test.ts +++ b/packages/widget/src/core/__tests__/postmessage.test.ts @@ -45,4 +45,45 @@ describe('widget postMessage bridge', () => { bridge.dispose() }) + + it('passes a host report result through only from the exact current source and origin', () => { + const trustedSource = { postMessage: vi.fn() } as unknown as Window + const staleSource = { postMessage: vi.fn() } as unknown as Window + let currentSource = trustedSource + const bridge = createBridge({ + getIframe: () => ({ contentWindow: currentSource }) as HTMLIFrameElement, + origin: ORIGIN, + }) + const handler = vi.fn() + bridge.onMessage(handler) + const result = { + type: 'quackback:report-submit-result', + data: { + contract: 'iplaycafe.quackback.report-submit/1', + requestId: '11111111-1111-4111-8111-111111111111', + accepted: false, + reason: 'unavailable', + }, + } + + for (const event of [ + { origin: 'https://other.acme.test', source: trustedSource }, + { origin: ORIGIN, source: staleSource }, + ]) { + window.dispatchEvent(new MessageEvent('message', { ...event, data: result })) + } + expect(handler).not.toHaveBeenCalled() + + window.dispatchEvent( + new MessageEvent('message', { origin: ORIGIN, source: trustedSource, data: result }) + ) + expect(handler).toHaveBeenCalledWith(result) + + currentSource = staleSource + window.dispatchEvent( + new MessageEvent('message', { origin: ORIGIN, source: trustedSource, data: result }) + ) + expect(handler).toHaveBeenCalledTimes(1) + bridge.dispose() + }) }) diff --git a/packages/widget/src/core/__tests__/report-submit.test.ts b/packages/widget/src/core/__tests__/report-submit.test.ts new file mode 100644 index 000000000..0932f19ff --- /dev/null +++ b/packages/widget/src/core/__tests__/report-submit.test.ts @@ -0,0 +1,278 @@ +import contract from '../../../../../docs/fixtures/quackback-report-submit-contract-v1.json' +import { describe, expect, it } from 'vitest' +import { + HOST_REPORT_SUBMIT_CONTRACT, + HOST_REPORT_SUBMIT_TIMEOUT_MS, + createHostSubmitRequest, + parseHostSubmitResultForRequest, + parseSubmitBugReportContext, + parseSubmitBugReportInput, +} from '../report-submit' + +const REQUEST_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_REQUEST_ID = '33333333-3333-4333-8333-333333333333' +const SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' +const RECEIPT = { + schemaVersion: 'BugReportReceiptV1', + reportRef: 'qbr_abcdefghijklmnopqrstuvwx', + status: 'received', + createdAt: '2026-07-28T01:00:00.000Z', + updatedAt: '2026-07-28T02:00:00.000Z', +} as const + +function success(receipt: unknown = RECEIPT, requestId = REQUEST_ID): Record { + return { + type: contract.resultType, + data: { + contract: contract.adapterContract, + requestId, + accepted: true, + receipt, + }, + } +} + +function failure(reason: unknown, requestId = REQUEST_ID): Record { + return { + type: contract.resultType, + data: { + contract: contract.adapterContract, + requestId, + accepted: false, + reason, + }, + } +} + +function addAccessor(record: object, key: string): { value: object; accessed: () => boolean } { + let wasAccessed = false + Object.defineProperty(record, key, { + enumerable: true, + get() { + wasAccessed = true + return 'canary-private-accessor' + }, + }) + return { value: record, accessed: () => wasAccessed } +} + +function addSymbol(record: object): object { + Object.defineProperty(record, Symbol('canary-private-symbol'), { + enumerable: true, + value: 'canary-private-symbol-value', + }) + return record +} + +function addNonEnumerableExtra(record: object): object { + Object.defineProperty(record, 'rawError', { + enumerable: false, + value: 'canary-private-hidden-error', + }) + return record +} + +describe('public host report submit input and context', () => { + it('tracks the canonical contract constants and accepts exact bounded input unchanged', () => { + expect(HOST_REPORT_SUBMIT_CONTRACT).toBe(contract.adapterContract) + expect(HOST_REPORT_SUBMIT_TIMEOUT_MS).toBe(contract.attemptTimeoutMs) + expect( + parseSubmitBugReportInput({ + clientSubmissionId: SUBMISSION_ID, + summary: 'Save does nothing', + impact: '', + }) + ).toEqual({ + clientSubmissionId: SUBMISSION_ID, + summary: 'Save does nothing', + impact: '', + }) + expect( + parseSubmitBugReportInput({ + clientSubmissionId: SUBMISSION_ID, + summary: 's'.repeat(contract.inputLimits.summaryCodeUnits), + impact: 'i'.repeat(contract.inputLimits.impactCodeUnits), + }) + ).not.toBeNull() + }) + + it('rejects inherited, extra, malformed, and out-of-bounds input records', () => { + expect( + parseSubmitBugReportInput( + Object.create({ + clientSubmissionId: SUBMISSION_ID, + }) + ) + ).toBeNull() + for (const value of [ + { + clientSubmissionId: SUBMISSION_ID, + summary: 'x', + impact: '', + boardId: 'forbidden', + }, + { + clientSubmissionId: 'not-a-v4-uuid', + summary: 'x', + impact: '', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: '', + impact: '', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: ' ', + impact: '', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: 's'.repeat(contract.inputLimits.summaryCodeUnits + 1), + impact: '', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: 'x', + impact: 'i'.repeat(contract.inputLimits.impactCodeUnits + 1), + }, + ]) { + expect(parseSubmitBugReportInput(value)).toBeNull() + } + }) + + it('validates exact context keys and requires a real AbortSignal', () => { + const controller = new AbortController() + expect(parseSubmitBugReportContext({})).toEqual({}) + expect(parseSubmitBugReportContext({ signal: controller.signal })).toEqual({ + signal: controller.signal, + }) + expect(parseSubmitBugReportContext(Object.create({}))).toBeNull() + expect(parseSubmitBugReportContext({ signal: {} })).toBeNull() + expect(parseSubmitBugReportContext({ signal: controller.signal, extra: true })).toBeNull() + }) + + it('rejects accessors, symbols, and non-enumerable extras without invoking getters', () => { + const inputBase = { + clientSubmissionId: SUBMISSION_ID, + summary: 'x', + impact: '', + } + const inputAccessor = addAccessor({ ...inputBase }, 'summary') + const contextAccessor = addAccessor({}, 'signal') + + expect(parseSubmitBugReportInput(inputAccessor.value)).toBeNull() + expect(parseSubmitBugReportInput(addSymbol({ ...inputBase }))).toBeNull() + expect(parseSubmitBugReportInput(addNonEnumerableExtra({ ...inputBase }))).toBeNull() + expect(parseSubmitBugReportContext(contextAccessor.value)).toBeNull() + expect(parseSubmitBugReportContext(addSymbol({}))).toBeNull() + expect(parseSubmitBugReportContext(addNonEnumerableExtra({}))).toBeNull() + expect(inputAccessor.accessed()).toBe(false) + expect(contextAccessor.accessed()).toBe(false) + }) + + it('creates the exact provider request while preserving both correlation IDs', () => { + expect( + createHostSubmitRequest(REQUEST_ID, { + clientSubmissionId: SUBMISSION_ID, + summary: 'Save does nothing', + impact: '', + }) + ).toEqual({ + type: contract.requestType, + data: { + contract: contract.adapterContract, + requestId: REQUEST_ID, + clientSubmissionId: SUBMISSION_ID, + summary: 'Save does nothing', + impact: '', + }, + }) + }) +}) + +describe('public host report submit result parser', () => { + it('returns only the exact public success keys for every receipt status', () => { + for (const status of contract.statuses) { + expect(parseHostSubmitResultForRequest(success({ ...RECEIPT, status }), REQUEST_ID)).toEqual({ + accepted: true, + receipt: { ...RECEIPT, status }, + }) + } + expect( + parseHostSubmitResultForRequest( + success({ ...RECEIPT, fixedInRelease: '2026.07.28' }), + REQUEST_ID + ) + ).toEqual({ + accepted: true, + receipt: { ...RECEIPT, fixedInRelease: '2026.07.28' }, + }) + }) + + it('returns only the exact public failure keys for every canonical reason', () => { + for (const reason of contract.failureReasons) { + expect(parseHostSubmitResultForRequest(failure(reason), REQUEST_ID)).toEqual({ + accepted: false, + reason, + }) + } + }) + + it('rejects wrong correlation, unknown keys, raw errors, and invalid chronology', () => { + for (const value of [ + success(RECEIPT, OTHER_REQUEST_ID), + { ...success(), extra: true }, + { + ...success(), + data: { + ...(success().data as object), + rawError: 'canary-private-raw-error', + }, + }, + success({ ...RECEIPT, postId: 'canary-private-post-id' }), + success({ ...RECEIPT, reportRef: 'canary-private-report-ref' }), + success({ ...RECEIPT, status: 'open' }), + success({ ...RECEIPT, createdAt: 'not-a-timestamp' }), + success({ ...RECEIPT, updatedAt: '2026-07-28T00:59:59.999Z' }), + success({ ...RECEIPT, fixedInRelease: '' }), + failure('raw-provider-error'), + ]) { + expect(parseHostSubmitResultForRequest(value, REQUEST_ID)).toBeNull() + } + }) + + it('rejects hostile envelope records without invoking accessors', () => { + const accessor = addAccessor(success(), 'type') + expect(parseHostSubmitResultForRequest(accessor.value, REQUEST_ID)).toBeNull() + expect(parseHostSubmitResultForRequest(addSymbol(success()), REQUEST_ID)).toBeNull() + expect(parseHostSubmitResultForRequest(addNonEnumerableExtra(success()), REQUEST_ID)).toBeNull() + expect(accessor.accessed()).toBe(false) + }) + + it('rejects hostile result data records without invoking accessors', () => { + const accessorValue = success() + const accessor = addAccessor(accessorValue.data as object, 'accepted') + const symbolValue = success() + addSymbol(symbolValue.data as object) + const hiddenValue = success() + addNonEnumerableExtra(hiddenValue.data as object) + + expect(parseHostSubmitResultForRequest(accessorValue, REQUEST_ID)).toBeNull() + expect(parseHostSubmitResultForRequest(symbolValue, REQUEST_ID)).toBeNull() + expect(parseHostSubmitResultForRequest(hiddenValue, REQUEST_ID)).toBeNull() + expect(accessor.accessed()).toBe(false) + }) + + it('rejects hostile receipt records without invoking accessors', () => { + const accessorReceipt = addAccessor({ ...RECEIPT }, 'reportRef') + expect(parseHostSubmitResultForRequest(success(accessorReceipt.value), REQUEST_ID)).toBeNull() + expect( + parseHostSubmitResultForRequest(success(addSymbol({ ...RECEIPT })), REQUEST_ID) + ).toBeNull() + expect( + parseHostSubmitResultForRequest(success(addNonEnumerableExtra({ ...RECEIPT })), REQUEST_ID) + ).toBeNull() + expect(accessorReceipt.accessed()).toBe(false) + }) +}) diff --git a/packages/widget/src/core/__tests__/sdk-capture.test.ts b/packages/widget/src/core/__tests__/sdk-capture.test.ts index a580c40e2..a50746575 100644 --- a/packages/widget/src/core/__tests__/sdk-capture.test.ts +++ b/packages/widget/src/core/__tests__/sdk-capture.test.ts @@ -162,7 +162,7 @@ describe('sdk — bug-report capture wiring', () => { window.history.replaceState(null, '', '/') }) - it('emits the exact deeply-frozen feedback/6 readiness contract', async () => { + it('emits the exact deeply-frozen feedback/7 readiness contract', async () => { mockConfig(true) stubIframe() const sdk = createSDK() @@ -182,8 +182,8 @@ describe('sdk — bug-report capture wiring', () => { 'features', ]) expect(seen[0]).toEqual({ - sdkVersion: '0.13.1-ipc.21', - feedbackContract: 'iplaycafe.feedback/6', + sdkVersion: '0.13.1-ipc.22', + feedbackContract: 'iplaycafe.feedback/7', lifecycleVersion: 4, diagnosticsVersion: 2, locales: ['en', 'th'], @@ -197,6 +197,7 @@ describe('sdk — bug-report capture wiring', () => { 'private-report-receipts', 'broad-media-upload', 'launcher-activation', + 'host-report-submit', ], }) const payload = seen[0] as { locales: unknown; features: unknown } @@ -241,7 +242,7 @@ describe('sdk — bug-report capture wiring', () => { resolveConfig({ ok: true, json: async () => ({ theme: {}, bugReportCapture: true }) }) await flush() expect(seen).toHaveLength(1) - expect((seen[0] as { feedbackContract?: string }).feedbackContract).toBe('iplaycafe.feedback/6') + expect((seen[0] as { feedbackContract?: string }).feedbackContract).toBe('iplaycafe.feedback/7') }) it('forwards only sanitized media lifecycle buckets for the active report', async () => { @@ -1176,3 +1177,428 @@ describe('sdk — bug-report capture wiring', () => { ).toBe(false) }) }) + +const HOST_SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' +const HOST_SUBMIT_INPUT = { + clientSubmissionId: HOST_SUBMISSION_ID, + summary: 'Save does nothing', + impact: '', +} +const HOST_RECEIPT = { + schemaVersion: 'BugReportReceiptV1', + reportRef: 'qbr_abcdefghijklmnopqrstuvwx', + status: 'received', + createdAt: '2026-07-28T01:00:00.000Z', + updatedAt: '2026-07-28T02:00:00.000Z', +} as const + +function hostSubmitRequests(post: ReturnType) { + return post.mock.calls + .map(([message]) => message as { type?: string; data?: Record }) + .filter((message) => message.type === 'quackback:report-submit') +} + +function latestHostSubmitRequest(post: ReturnType) { + const requests = hostSubmitRequests(post) + return requests[requests.length - 1] +} + +function fireHostSubmitResult( + requestId: string, + result: Record, + options: { source?: Window; origin?: string } = {} +) { + window.dispatchEvent( + new MessageEvent('message', { + origin: options.origin ?? ORIGIN, + source: options.source ?? iframeSource, + data: { + type: 'quackback:report-submit-result', + data: { + contract: 'iplaycafe.quackback.report-submit/1', + requestId, + ...result, + }, + }, + }) + ) +} + +async function initializeHostSubmit( + sdk: ReturnType, + post = stubIframe() +): Promise> { + mockConfig(true) + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireReady({ id: 'actor_1', name: 'Actor', email: 'actor@example.test' }) + post.mockClear() + return post +} + +describe('sdk — bounded public host report submit', () => { + beforeEach(() => { + document.body.innerHTML = '' + document.head.innerHTML = '' + window.history.replaceState(null, '', '/') + }) + + afterEach(() => { + vi.useRealTimers() + while (sdks.length) { + try { + sdks.pop()!.dispatch('destroy') + } catch { + /* already torn down */ + } + } + vi.restoreAllMocks() + delete (window as { __QuackbackCapture?: unknown }).__QuackbackCapture + delete (window as { posthog?: unknown }).posthog + }) + + it('returns bounded failures before readiness and for invalid public records', async () => { + const sdk = createSDK() + await expect(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)).resolves.toEqual({ + accepted: false, + reason: 'unavailable', + }) + await expect( + sdk.dispatch('submitBugReport', { ...HOST_SUBMIT_INPUT, boardId: 'forbidden' }) + ).resolves.toEqual({ + accepted: false, + reason: 'invalid_request', + }) + await expect( + sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT, { signal: {} }) + ).resolves.toEqual({ + accepted: false, + reason: 'invalid_request', + }) + }) + + it('requires exact feedback/7 readiness and an acknowledged signed-in identity', async () => { + mockConfig(false) + const post = stubIframe() + const sdk = createSDK() + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireReady({ id: 'actor_1', name: 'Actor', email: 'actor@example.test' }) + await expect(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)).resolves.toEqual({ + accepted: false, + reason: 'unavailable', + }) + expect(hostSubmitRequests(post)).toHaveLength(0) + + mockConfig(true) + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireReady() + await expect(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)).resolves.toEqual({ + accepted: false, + reason: 'unavailable', + }) + expect(hostSubmitRequests(post)).toHaveLength(0) + }) + + it('sends one exact request with a fresh request ID and resolves exact safe results', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + const resultPromise = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + const requests = hostSubmitRequests(post) + expect(requests).toHaveLength(1) + expect(requests[0]?.data).toEqual({ + contract: 'iplaycafe.quackback.report-submit/1', + requestId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ), + ...HOST_SUBMIT_INPUT, + }) + expect(requests[0]?.data?.requestId).not.toBe(HOST_SUBMISSION_ID) + const requestId = requests[0]?.data?.requestId as string + fireHostSubmitResult(requestId, { accepted: true, receipt: HOST_RECEIPT }) + await expect(resultPromise).resolves.toEqual({ accepted: true, receipt: HOST_RECEIPT }) + + for (const reason of [ + 'aborted', + 'invalid_request', + 'unavailable', + 'unauthorized', + 'retryable_failure', + ]) { + const next = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + const nextRequest = latestHostSubmitRequest(post)! + fireHostSubmitResult(nextRequest.data?.requestId as string, { accepted: false, reason }) + await expect(next).resolves.toEqual({ accepted: false, reason }) + } + }) + + it('cleans a pending request before abort settlement and ignores its late result', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + const controller = new AbortController() + const order: string[] = [] + const originalClearTimeout = window.clearTimeout.bind(window) + vi.spyOn(window, 'clearTimeout').mockImplementation((timer) => { + order.push('clearTimeout') + originalClearTimeout(timer) + }) + const originalRemove = controller.signal.removeEventListener.bind(controller.signal) + vi.spyOn(controller.signal, 'removeEventListener').mockImplementation((...args) => { + order.push('removeAbort') + originalRemove(...args) + }) + + const pending = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT, { + signal: controller.signal, + }) as Promise + const requestId = latestHostSubmitRequest(post)?.data?.requestId as string + void pending.then(() => order.push('resolve')) + controller.abort() + + await expect(pending).resolves.toEqual({ accepted: false, reason: 'aborted' }) + expect(order.slice(-3)).toEqual(['clearTimeout', 'removeAbort', 'resolve']) + fireHostSubmitResult(requestId, { accepted: true, receipt: HOST_RECEIPT }) + await Promise.resolve() + expect(order.filter((step) => step === 'resolve')).toHaveLength(1) + }) + + it('times out at exactly ten seconds and ignores a late result', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + vi.useFakeTimers() + const pending = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + const requestId = latestHostSubmitRequest(post)?.data?.requestId as string + let settled = false + void pending.then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(9_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toEqual({ accepted: false, reason: 'retryable_failure' }) + fireHostSubmitResult(requestId, { accepted: true, receipt: HOST_RECEIPT }) + await Promise.resolve() + expect(settled).toBe(true) + }) + + it('settles old requests on destroy and re-init before accepting a new generation', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + const destroyed = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + sdk.dispatch('destroy') + await expect(destroyed).resolves.toEqual({ + accepted: false, + reason: 'retryable_failure', + }) + + mockConfig(true) + stubIframe() + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireReady({ id: 'actor_2', name: 'Actor 2', email: 'actor2@example.test' }) + const reinitialized = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await expect(reinitialized).resolves.toEqual({ + accepted: false, + reason: 'retryable_failure', + }) + expect(hostSubmitRequests(post)).toHaveLength(1) + }) + + it('invalidates on load, DOM replacement, and changed contentWindow and ignores stale traffic', async () => { + mockConfig(true) + const oldSource = { postMessage: vi.fn() } as unknown as Window + const replacementSource = { postMessage: vi.fn() } as unknown as Window + const reloadedSource = { postMessage: vi.fn() } as unknown as Window + const sources = new WeakMap() + let fallbackSource = oldSource + iframeSource = oldSource + vi.spyOn(HTMLIFrameElement.prototype, 'contentWindow', 'get').mockImplementation(function ( + this: HTMLIFrameElement + ) { + return sources.get(this) ?? fallbackSource + }) + const sdk = createSDK() + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + const oldIframe = document.querySelector('.quackback-widget-iframe') as HTMLIFrameElement + sources.set(oldIframe, oldSource) + fireReady({ id: 'actor_old', name: 'Old Actor', email: 'old@example.test' }) + oldSource.postMessage = vi.fn() + + const afterLoad = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + const loadRequest = latestHostSubmitRequest(oldSource.postMessage as ReturnType) + ?.data?.requestId as string + oldIframe.dispatchEvent(new Event('load')) + await expect(afterLoad).resolves.toEqual({ + accepted: false, + reason: 'retryable_failure', + }) + fireHostSubmitResult( + loadRequest, + { accepted: true, receipt: HOST_RECEIPT }, + { source: oldSource } + ) + + iframeSource = oldSource + fireReady({ id: 'actor_old', name: 'Old Actor', email: 'old@example.test' }) + const beforeReplacement = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + const replacement = document.createElement('iframe') + replacement.className = 'quackback-widget-iframe' + sources.set(replacement, replacementSource) + oldIframe.replaceWith(replacement) + await flush() + await expect(beforeReplacement).resolves.toEqual({ + accepted: false, + reason: 'retryable_failure', + }) + + iframeSource = oldSource + fireReady({ id: 'stale', name: 'Stale', email: 'stale@example.test' }) + await expect(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)).resolves.toEqual({ + accepted: false, + reason: 'unavailable', + }) + + iframeSource = replacementSource + fireReady({ id: 'actor_new', name: 'New Actor', email: 'new@example.test' }) + oldIframe.dispatchEvent(new Event('load')) + const beforeWindowChange = sdk.dispatch( + 'submitBugReport', + HOST_SUBMIT_INPUT + ) as Promise + fallbackSource = reloadedSource + sources.set(replacement, reloadedSource) + replacement.dispatchEvent(new Event('load')) + await expect(beforeWindowChange).resolves.toEqual({ + accepted: false, + reason: 'retryable_failure', + }) + + iframeSource = replacementSource + fireReady({ id: 'stale', name: 'Stale', email: 'stale@example.test' }) + iframeSource = reloadedSource + await expect(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)).resolves.toEqual({ + accepted: false, + reason: 'unavailable', + }) + }) + + it('ignores wrong source, origin, request ID, and late duplicate results', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + const pending = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + const requestId = latestHostSubmitRequest(post)?.data?.requestId as string + let settlements = 0 + void pending.then(() => { + settlements += 1 + }) + + fireHostSubmitResult( + requestId, + { accepted: true, receipt: HOST_RECEIPT }, + { + origin: 'https://wrong.example.test', + } + ) + fireHostSubmitResult( + requestId, + { accepted: true, receipt: HOST_RECEIPT }, + { + source: { postMessage: vi.fn() } as unknown as Window, + } + ) + fireHostSubmitResult('33333333-3333-4333-8333-333333333333', { + accepted: true, + receipt: HOST_RECEIPT, + }) + await Promise.resolve() + expect(settlements).toBe(0) + + fireHostSubmitResult(requestId, { accepted: false, reason: 'unauthorized' }) + await expect(pending).resolves.toEqual({ accepted: false, reason: 'unauthorized' }) + fireHostSubmitResult(requestId, { accepted: true, receipt: HOST_RECEIPT }) + await Promise.resolve() + expect(settlements).toBe(1) + }) + + it('correlates concurrent requests independently when results arrive out of order', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + const first = sdk.dispatch('submitBugReport', { + ...HOST_SUBMIT_INPUT, + summary: 'first', + }) as Promise + const second = sdk.dispatch('submitBugReport', { + ...HOST_SUBMIT_INPUT, + summary: 'second', + }) as Promise + const [firstRequest, secondRequest] = hostSubmitRequests(post).slice(-2) + expect(firstRequest?.data?.requestId).not.toBe(secondRequest?.data?.requestId) + + fireHostSubmitResult(secondRequest?.data?.requestId as string, { + accepted: false, + reason: 'unavailable', + }) + fireHostSubmitResult(firstRequest?.data?.requestId as string, { + accepted: true, + receipt: HOST_RECEIPT, + }) + await expect(second).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + await expect(first).resolves.toEqual({ accepted: true, receipt: HOST_RECEIPT }) + }) + + it('keeps protected request and receipt values out of every non-transport sink', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + const consoleSinks = [ + vi.spyOn(console, 'debug').mockImplementation(() => undefined), + vi.spyOn(console, 'info').mockImplementation(() => undefined), + vi.spyOn(console, 'warn').mockImplementation(() => undefined), + vi.spyOn(console, 'error').mockImplementation(() => undefined), + ] + const emitterSink = vi.fn() + for (const eventName of [ + 'ready', + 'identify', + 'bug-report:opened', + 'bug-report:submitted', + 'bug-report:failed', + ] as const) { + sdk.dispatch('on', eventName, emitterSink) + } + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireReady({ id: 'actor_1', name: 'Actor', email: 'actor@example.test' }) + post.mockClear() + + const pending = sdk.dispatch('submitBugReport', { + ...HOST_SUBMIT_INPUT, + summary: 'canary-private-summary', + }) as Promise + const requestId = latestHostSubmitRequest(post)?.data?.requestId as string + fireHostSubmitResult('canary-request-id', { + accepted: true, + receipt: { ...HOST_RECEIPT, reportRef: 'canary-report-ref' }, + }) + fireHostSubmitResult(requestId, { accepted: false, reason: 'retryable_failure' }) + await pending + + const nonTransportMessages = post.mock.calls + .map(([message]) => message) + .filter((message) => (message as { type?: string }).type !== 'quackback:report-submit') + const serializedSinks = JSON.stringify({ + console: consoleSinks.flatMap((sink) => sink.mock.calls), + emitter: emitterSink.mock.calls, + lifecycle: emitterSink.mock.calls, + metadata: nonTransportMessages, + diagnostics: nonTransportMessages, + }) + expect(serializedSinks).not.toContain('canary-private-summary') + expect(serializedSinks).not.toContain('canary-request-id') + expect(serializedSinks).not.toContain('canary-report-ref') + }) +}) diff --git a/packages/widget/src/core/postmessage.ts b/packages/widget/src/core/postmessage.ts index 59d4abd7d..26b8cbc62 100644 --- a/packages/widget/src/core/postmessage.ts +++ b/packages/widget/src/core/postmessage.ts @@ -1,3 +1,5 @@ +import type { HostSubmitRequestMessage } from './report-submit' + export type InboundMessage = | { type: 'quackback:init'; data?: unknown } | { type: 'quackback:identify'; data: unknown } @@ -10,6 +12,7 @@ export type InboundMessage = | { type: 'quackback:capture-result'; data: unknown } // Clears a single report component/draft without changing public SDK state. | { type: 'quackback:bug-report-reset'; data: unknown } + | HostSubmitRequestMessage export type OutboundMessage = | { type: 'quackback:ready' } @@ -22,6 +25,7 @@ export type OutboundMessage = // widget chrome is excluded host-side, so the panel can stay open). | { type: 'quackback:capture-request'; flowId: string } | { type: 'quackback:bug-report-submit-started'; flowId: string } + | { type: 'quackback:report-submit-result'; data: unknown } // Metadata-only layout coordination. No screenshot pixels, annotations, or // report text cross this boundary. | { type: 'quackback:bug-report-editor-layout'; flowId: string; expanded: boolean } diff --git a/packages/widget/src/core/report-submit.ts b/packages/widget/src/core/report-submit.ts new file mode 100644 index 000000000..ba4009e20 --- /dev/null +++ b/packages/widget/src/core/report-submit.ts @@ -0,0 +1,223 @@ +import type { + BugReportReceiptV1, + BugReportStatusV1, + HostSubmitFailureReason, + SubmitBugReportContextV1, + SubmitBugReportInputV1, + SubmitBugReportResultV1, +} from '../types' + +export const HOST_REPORT_SUBMIT_CONTRACT = 'iplaycafe.quackback.report-submit/1' as const +export const HOST_REPORT_SUBMIT_TIMEOUT_MS = 10_000 + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const REPORT_REF = /^qbr_[A-Za-z0-9_-]{24}$/ +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ +const HOST_SUBMIT_STATUSES = new Set([ + 'received', + 'triaging', + 'needs_info', + 'in_progress', + 'verifying', + 'fixed', + 'closed', +]) +const HOST_SUBMIT_FAILURE_REASONS = new Set([ + 'aborted', + 'invalid_request', + 'unavailable', + 'unauthorized', + 'retryable_failure', +]) + +type DataRecord = Record + +export type HostSubmitRequestMessage = { + type: 'quackback:report-submit' + data: { + contract: typeof HOST_REPORT_SUBMIT_CONTRACT + requestId: string + clientSubmissionId: string + summary: string + impact: string + } +} + +function readDataRecord(value: unknown): DataRecord | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null + + try { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return null + + const keys = Reflect.ownKeys(value) + const descriptors = Object.getOwnPropertyDescriptors(value) + const record: DataRecord = Object.create(null) as DataRecord + for (const key of keys) { + if (typeof key !== 'string') return null + const descriptor = descriptors[key] + if (!descriptor?.enumerable || !('value' in descriptor)) return null + record[key] = descriptor.value + } + return record + } catch { + return null + } +} + +function readExactDataRecord(value: unknown, expectedKeys: readonly string[]): DataRecord | null { + const record = readDataRecord(value) + if (!record) return null + const keys = Object.keys(record) + if (keys.length !== expectedKeys.length) return null + const expected = new Set(expectedKeys) + return keys.every((key) => expected.has(key)) ? record : null +} + +function isUuidV4(value: unknown): value is string { + return typeof value === 'string' && UUID_V4.test(value) +} + +function isIsoTimestamp(value: unknown): value is string { + if (typeof value !== 'string' || !ISO_TIMESTAMP.test(value)) return false + const time = Date.parse(value) + return Number.isFinite(time) && new Date(time).toISOString() === value +} + +function parseReceipt(value: unknown): BugReportReceiptV1 | null { + const record = readDataRecord(value) + if (!record) return null + const required = ['schemaVersion', 'reportRef', 'status', 'createdAt', 'updatedAt'] + const hasFixedInRelease = Object.prototype.hasOwnProperty.call(record, 'fixedInRelease') + const expected = hasFixedInRelease ? [...required, 'fixedInRelease'] : required + const keys = Object.keys(record) + if ( + keys.length !== expected.length || + !keys.every((key) => expected.includes(key)) || + record.schemaVersion !== 'BugReportReceiptV1' || + typeof record.reportRef !== 'string' || + !REPORT_REF.test(record.reportRef) || + typeof record.status !== 'string' || + !HOST_SUBMIT_STATUSES.has(record.status as BugReportStatusV1) || + !isIsoTimestamp(record.createdAt) || + !isIsoTimestamp(record.updatedAt) || + Date.parse(record.updatedAt) < Date.parse(record.createdAt) || + (hasFixedInRelease && + (typeof record.fixedInRelease !== 'string' || record.fixedInRelease.length === 0)) + ) { + return null + } + + return { + schemaVersion: 'BugReportReceiptV1', + reportRef: record.reportRef, + status: record.status as BugReportStatusV1, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ...(hasFixedInRelease ? { fixedInRelease: record.fixedInRelease as string } : {}), + } +} + +export function parseSubmitBugReportInput(value: unknown): SubmitBugReportInputV1 | null { + const record = readExactDataRecord(value, ['clientSubmissionId', 'summary', 'impact']) + if ( + !record || + !isUuidV4(record.clientSubmissionId) || + typeof record.summary !== 'string' || + record.summary.length === 0 || + record.summary.length > 2_000 || + record.summary.trim().length === 0 || + typeof record.impact !== 'string' || + record.impact.length > 1_000 + ) { + return null + } + return { + clientSubmissionId: record.clientSubmissionId, + summary: record.summary, + impact: record.impact, + } +} + +export function parseSubmitBugReportContext(value: unknown): SubmitBugReportContextV1 | null { + const record = readDataRecord(value) + if (!record) return null + const keys = Object.keys(record) + if (keys.length === 0) return {} + if ( + keys.length !== 1 || + keys[0] !== 'signal' || + typeof AbortSignal === 'undefined' || + !(record.signal instanceof AbortSignal) + ) { + return null + } + return { signal: record.signal } +} + +export function createHostSubmitRequest( + requestId: string, + input: SubmitBugReportInputV1 +): HostSubmitRequestMessage { + return { + type: 'quackback:report-submit', + data: { + contract: HOST_REPORT_SUBMIT_CONTRACT, + requestId, + clientSubmissionId: input.clientSubmissionId, + summary: input.summary, + impact: input.impact, + }, + } +} + +export function parseHostSubmitResultForRequest( + value: unknown, + requestId: string +): SubmitBugReportResultV1 | null { + const envelope = readExactDataRecord(value, ['type', 'data']) + if (!envelope || envelope.type !== 'quackback:report-submit-result') return null + + const data = readDataRecord(envelope.data) + if ( + !data || + data.contract !== HOST_REPORT_SUBMIT_CONTRACT || + data.requestId !== requestId || + !isUuidV4(data.requestId) + ) { + return null + } + + if (data.accepted === true) { + const exact = readExactDataRecord(envelope.data, [ + 'contract', + 'requestId', + 'accepted', + 'receipt', + ]) + const receipt = exact ? parseReceipt(exact.receipt) : null + return receipt ? { accepted: true, receipt } : null + } + + if (data.accepted === false) { + const exact = readExactDataRecord(envelope.data, [ + 'contract', + 'requestId', + 'accepted', + 'reason', + ]) + if ( + !exact || + typeof exact.reason !== 'string' || + !HOST_SUBMIT_FAILURE_REASONS.has(exact.reason as HostSubmitFailureReason) + ) { + return null + } + return { + accepted: false, + reason: exact.reason as HostSubmitFailureReason, + } + } + + return null +} diff --git a/packages/widget/src/core/sdk.ts b/packages/widget/src/core/sdk.ts index dd0b161c3..ddd1b078b 100644 --- a/packages/widget/src/core/sdk.ts +++ b/packages/widget/src/core/sdk.ts @@ -8,6 +8,7 @@ import type { LauncherActivationAction, LauncherActivationSource, LauncherConfig, + SubmitBugReportResultV1, } from '../types' import { createEmitter } from './events' import { createBridge, type Bridge } from './postmessage' @@ -30,13 +31,20 @@ import { type BugReportCaptureMode, } from './bug-report-events' import { removeStyles } from './style' +import { + HOST_REPORT_SUBMIT_TIMEOUT_MS, + createHostSubmitRequest, + parseHostSubmitResultForRequest, + parseSubmitBugReportContext, + parseSubmitBugReportInput, +} from './report-submit' // Public host-readiness contract for the iPLAYCAFE direct-report integration. // Keep this exact, ordered, and deeply frozen: consumers fail closed to generic // Help unless every capability/version marker matches. const FEEDBACK_READY = Object.freeze({ - sdkVersion: '0.13.1-ipc.21', - feedbackContract: 'iplaycafe.feedback/6', + sdkVersion: '0.13.1-ipc.22', + feedbackContract: 'iplaycafe.feedback/7', lifecycleVersion: 4, diagnosticsVersion: 2, locales: Object.freeze(['en', 'th'] as const), @@ -50,6 +58,7 @@ const FEEDBACK_READY = Object.freeze({ 'private-report-receipts', 'broad-media-upload', 'launcher-activation', + 'host-report-submit', ] as const), }) const GENERIC_READY = Object.freeze({}) @@ -70,6 +79,7 @@ type Command = | 'on' | 'off' | 'reportBug' + | 'submitBugReport' export interface SDK { dispatch(command: Command, arg1?: unknown, arg2?: unknown): unknown @@ -117,6 +127,20 @@ export function createSDK(): SDK { let serverConfigSettled = false let hostReadyEmitted = false let initGeneration = 0 + let hostSubmitV7Eligible = false + let transportGeneration = 0 + let currentIframe: HTMLIFrameElement | null = null + let currentIframeWindow: Window | null = null + let iframeWrapper: HTMLElement | null = null + let iframeObserver: MutationObserver | null = null + let removeIframeLoad: (() => void) | null = null + type PendingHostSubmit = { + generation: number + resolve(result: SubmitBugReportResultV1): void + timer: number + removeAbort: () => void + } + const pendingHostSubmits = new Map() // null while config.json is unresolved. reportBug calls during this window // are queued so an immediate init -> reportBug flow cannot silently no-op. let captureAvailable: boolean | null = null @@ -143,7 +167,8 @@ export function createSDK(): SDK { // Capability means usable now, not merely compiled into this image. An // older/disabled workspace emits the legacy empty object so hosts keep the // generic Help fallback instead of exposing a reportBug no-op. - emitter.emit('ready', captureEnabled ? FEEDBACK_READY : GENERIC_READY) + hostSubmitV7Eligible = captureEnabled + emitter.emit('ready', hostSubmitV7Eligible ? FEEDBACK_READY : GENERIC_READY) } function sendMobileState(): void { @@ -154,6 +179,113 @@ export function createSDK(): SDK { return new URL(config!.instanceUrl).origin } + function settlePendingHostSubmit( + requestId: string, + pending: PendingHostSubmit, + result: SubmitBugReportResultV1 + ): void { + if (pendingHostSubmits.get(requestId) !== pending) return + pendingHostSubmits.delete(requestId) + window.clearTimeout(pending.timer) + pending.removeAbort() + pending.resolve(result) + } + + function settleAllHostSubmits(): void { + for (const [requestId, pending] of pendingHostSubmits) { + settlePendingHostSubmit(requestId, pending, { + accepted: false, + reason: 'retryable_failure', + }) + } + } + + function invalidateHostSubmitTransport(): void { + transportGeneration += 1 + ready = false + identityResolved = false + currentUser = null + hostReadyEmitted = false + hostSubmitV7Eligible = false + if (config && widgetSessionStarted) { + pendingIdentify = deferredIdentity + pendingIdentifyPresent = true + } + removeIframeLoad?.() + removeIframeLoad = null + bridge?.dispose() + bridge = null + currentIframe = null + currentIframeWindow = null + settleAllHostSubmits() + } + + function installHostSubmitTransport(iframe: HTMLIFrameElement): void { + const source = iframe.contentWindow + const generation = transportGeneration + currentIframe = iframe + currentIframeWindow = source + bridge = createBridge({ + getIframe: () => + generation === transportGeneration && + currentIframe === iframe && + currentIframeWindow === source && + iframe.contentWindow === source + ? iframe + : null, + origin: iframeOrigin(), + }) + bridge.onMessage((message) => { + if ( + generation !== transportGeneration || + currentIframe !== iframe || + currentIframeWindow !== source || + iframe.contentWindow !== source + ) { + return + } + onIframeMessage(message, generation) + }) + const onLoad = () => { + if (currentIframe !== iframe) return + invalidateHostSubmitTransport() + installHostSubmitTransport(iframe) + } + iframe.addEventListener('load', onLoad) + removeIframeLoad = () => iframe.removeEventListener('load', onLoad) + } + + function replaceHostSubmitTransport(iframe: HTMLIFrameElement | null): void { + invalidateHostSubmitTransport() + if (iframe) installHostSubmitTransport(iframe) + } + + function observeIframeReplacement(wrapper: HTMLElement): void { + iframeWrapper = wrapper + iframeObserver = new MutationObserver(() => { + if (iframeWrapper !== wrapper) return + const candidate = Array.from(wrapper.children).find( + (child): child is HTMLIFrameElement => + child instanceof HTMLIFrameElement && child.classList.contains('quackback-widget-iframe') + ) + if (candidate !== currentIframe) { + replaceHostSubmitTransport(candidate ?? null) + } else if (candidate && candidate.contentWindow !== currentIframeWindow) { + replaceHostSubmitTransport(candidate) + } + }) + iframeObserver.observe(wrapper, { childList: true }) + } + + function refreshHostSubmitTransport(): boolean { + if (!currentIframe) return false + if (currentIframe.contentWindow !== currentIframeWindow) { + replaceHostSubmitTransport(currentIframe) + return false + } + return true + } + function rememberCompletedFlow(flowId: string): void { completedBugReportFlows.add(flowId) while (completedBugReportFlows.size > 32) { @@ -264,7 +396,8 @@ export function createSDK(): SDK { dispatch('open') } - function onIframeMessage(msg: { type: string; [k: string]: unknown }) { + function onIframeMessage(msg: { type: string; [k: string]: unknown }, generation: number) { + if (generation !== transportGeneration) return switch (msg.type) { case 'quackback:ready': ready = true @@ -427,6 +560,16 @@ export function createSDK(): SDK { panel?.setLayout(m.expanded ? 'screenshot-editor' : 'bug-report') break } + case 'quackback:report-submit-result': { + for (const [requestId, pending] of pendingHostSubmits) { + if (pending.generation !== generation) continue + const result = parseHostSubmitResultForRequest(msg, requestId) + if (!result) continue + settlePendingHostSubmit(requestId, pending, result) + break + } + break + } } } @@ -518,6 +661,10 @@ export function createSDK(): SDK { function ensurePanel(): PanelHandle { if (panel) return panel + // Creating/replacing an iframe is a transport boundary even before its + // first load. Retire every prior generation before the DOM can expose the + // new browsing context. + invalidateHostSubmitTransport() panel = createPanel({ widgetUrl: `${config!.instanceUrl}/widget`, placement: config!.placement ?? 'right', @@ -526,11 +673,9 @@ export function createSDK(): SDK { locale: config!.locale, onBackdropClick: () => dispatch('close'), }) - bridge = createBridge({ - getIframe: () => panel!.iframe, - origin: iframeOrigin(), - }) - bridge.onMessage(onIframeMessage) + installHostSubmitTransport(panel.iframe) + const wrapper = panel.iframe.parentElement + if (wrapper) observeIframeReplacement(wrapper) mobileMql = window.matchMedia('(max-width: 639px)') mobileMql.addEventListener('change', sendMobileState) mobileCleanup = () => mobileMql!.removeEventListener('change', sendMobileState) @@ -669,6 +814,86 @@ export function createSDK(): SDK { } } + function submitBugReport( + inputValue: unknown, + contextValue: unknown + ): Promise { + const input = parseSubmitBugReportInput(inputValue) + const context = parseSubmitBugReportContext(contextValue === undefined ? {} : contextValue) + if (!input || !context) { + return Promise.resolve({ accepted: false, reason: 'invalid_request' }) + } + if (context.signal?.aborted) { + return Promise.resolve({ accepted: false, reason: 'aborted' }) + } + if ( + !config || + !refreshHostSubmitTransport() || + !bridge || + !currentIframe || + !currentIframeWindow || + !ready || + !serverConfigSettled || + !hostSubmitV7Eligible || + !identityResolved || + !currentUser + ) { + return Promise.resolve({ accepted: false, reason: 'unavailable' }) + } + + let requestId: string + try { + requestId = crypto.randomUUID() + } catch { + return Promise.resolve({ accepted: false, reason: 'retryable_failure' }) + } + const generation = transportGeneration + const activeBridge = bridge + + return new Promise((resolve) => { + const onAbort = () => { + settlePendingHostSubmit(requestId, pending, { + accepted: false, + reason: 'aborted', + }) + } + const removeAbort = context.signal + ? () => context.signal!.removeEventListener('abort', onAbort) + : () => undefined + const pending: PendingHostSubmit = { + generation, + resolve, + timer: 0, + removeAbort, + } + pending.timer = window.setTimeout(() => { + settlePendingHostSubmit(requestId, pending, { + accepted: false, + reason: 'retryable_failure', + }) + }, HOST_REPORT_SUBMIT_TIMEOUT_MS) + pendingHostSubmits.set(requestId, pending) + context.signal?.addEventListener('abort', onAbort) + if (context.signal?.aborted) { + onAbort() + return + } + if ( + generation !== transportGeneration || + activeBridge !== bridge || + currentIframe?.contentWindow !== currentIframeWindow + ) { + settlePendingHostSubmit(requestId, pending, { + accepted: false, + reason: 'retryable_failure', + }) + return + } + const request = createHostSubmitRequest(requestId, input) + activeBridge.send(request.type, request.data) + }) + } + function dispatch(cmd: Command, a?: unknown, b?: unknown): unknown { switch (cmd) { case 'init': { @@ -830,14 +1055,19 @@ export function createSDK(): SDK { startBugReport(requestedEntrypoint) return } + case 'submitBugReport': + return submitBugReport(a, b) case 'destroy': initGeneration += 1 launcherActivationAbort?.abort() launcherActivationAbort = null + iframeObserver?.disconnect() + iframeObserver = null + iframeWrapper = null + invalidateHostSubmitTransport() resetActiveBugReport() panel?.destroy() launcher?.remove() - bridge?.dispose() mobileCleanup?.() mobileCleanup = null mobileMql = null @@ -856,6 +1086,7 @@ export function createSDK(): SDK { identityResolved = false serverConfigSettled = false hostReadyEmitted = false + hostSubmitV7Eligible = false serverConfig = null widgetSessionStarted = false deferredIdentity = ANONYMOUS_IDENTITY diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index b14e7980b..ad8f6adb1 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -12,6 +12,12 @@ import type { LauncherActivationContext, LauncherActivationSource, LauncherConfig, + SubmitBugReportInputV1, + SubmitBugReportContextV1, + SubmitBugReportResultV1, + BugReportReceiptV1, + BugReportStatusV1, + HostSubmitFailureReason, } from './types' export type { @@ -27,6 +33,12 @@ export type { LauncherActivationContext, LauncherActivationSource, LauncherConfig, + SubmitBugReportInputV1, + SubmitBugReportContextV1, + SubmitBugReportResultV1, + BugReportReceiptV1, + BugReportStatusV1, + HostSubmitFailureReason, } const sdk = createSDK() @@ -55,6 +67,12 @@ export const Quackback = { reportBug(): void { sdk.dispatch('reportBug') }, + submitBugReport( + input: SubmitBugReportInputV1, + context: SubmitBugReportContextV1 = {} + ): Promise { + return sdk.dispatch('submitBugReport', input, context) as Promise + }, showLauncher(): void { sdk.dispatch('showLauncher') }, diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index 11f319a6d..c290e84ce 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -113,9 +113,48 @@ export interface WidgetUser { avatarUrl?: string | null } +export type HostSubmitFailureReason = + | 'aborted' + | 'invalid_request' + | 'unavailable' + | 'unauthorized' + | 'retryable_failure' + +export type BugReportStatusV1 = + | 'received' + | 'triaging' + | 'needs_info' + | 'in_progress' + | 'verifying' + | 'fixed' + | 'closed' + +export interface BugReportReceiptV1 { + schemaVersion: 'BugReportReceiptV1' + reportRef: string + status: BugReportStatusV1 + createdAt: string + updatedAt: string + fixedInRelease?: string +} + +export interface SubmitBugReportInputV1 { + clientSubmissionId: string + summary: string + impact: string +} + +export interface SubmitBugReportContextV1 { + signal?: AbortSignal +} + +export type SubmitBugReportResultV1 = + | { accepted: true; receipt: BugReportReceiptV1 } + | { accepted: false; reason: HostSubmitFailureReason } + export interface FeedbackReady { readonly sdkVersion: string - readonly feedbackContract: 'iplaycafe.feedback/6' + readonly feedbackContract: 'iplaycafe.feedback/7' readonly lifecycleVersion: 4 readonly diagnosticsVersion: 2 readonly locales: readonly ['en', 'th'] @@ -129,6 +168,7 @@ export interface FeedbackReady { 'private-report-receipts', 'broad-media-upload', 'launcher-activation', + 'host-report-submit', ] } From e177405cbc72485c6f2c3283361dbfedc1af571c Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Tue, 28 Jul 2026 18:47:08 +0700 Subject: [PATCH 05/21] fix(widget-sdk): align submit readiness and identity gate --- .../src/core/__tests__/sdk-capture.test.ts | 65 +++++++++++++++---- packages/widget/src/core/report-submit.ts | 2 +- packages/widget/src/core/sdk.ts | 56 +++++++++++++++- packages/widget/src/types.ts | 2 +- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/packages/widget/src/core/__tests__/sdk-capture.test.ts b/packages/widget/src/core/__tests__/sdk-capture.test.ts index a50746575..069278250 100644 --- a/packages/widget/src/core/__tests__/sdk-capture.test.ts +++ b/packages/widget/src/core/__tests__/sdk-capture.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createSDK as createSdkRaw } from '../sdk' +import reportSubmitContract from '../../../../../docs/fixtures/quackback-report-submit-contract-v1.json' // Track every SDK so afterEach can destroy them — an sdk installs a document // keydown listener at init, and a leaked listener from a prior test would fire @@ -187,18 +188,7 @@ describe('sdk — bug-report capture wiring', () => { lifecycleVersion: 4, diagnosticsVersion: 2, locales: ['en', 'th'], - features: [ - 'direct-report', - 'screenshot', - 'text-only', - 'lifecycle-events', - 'media-lifecycle-events', - 'private-evidence-attach', - 'private-report-receipts', - 'broad-media-upload', - 'launcher-activation', - 'host-report-submit', - ], + features: [...reportSubmitContract.readiness.features], }) const payload = seen[0] as { locales: unknown; features: unknown } expect(Object.isFrozen(payload)).toBe(true) @@ -1231,7 +1221,15 @@ async function initializeHostSubmit( mockConfig(true) sdk.dispatch('init', { instanceUrl: ORIGIN }) await flush() - fireReady({ id: 'actor_1', name: 'Actor', email: 'actor@example.test' }) + const user = { id: 'actor_1', name: 'Actor', email: 'actor@example.test' } + fireReady(user) + window.dispatchEvent( + new MessageEvent('message', { + origin: ORIGIN, + source: iframeSource, + data: { type: 'quackback:auth-change', user }, + }) + ) post.mockClear() return post } @@ -1301,6 +1299,47 @@ describe('sdk — bounded public host report submit', () => { expect(hostSubmitRequests(post)).toHaveLength(0) }) + it('rejects failed and descriptor-invalid signed-in identity acknowledgements', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireWidgetReady() + post.mockClear() + + const emailGetter = vi.fn(() => 'actor@example.test') + const malformedUser = { id: 'actor_1', name: 'Actor' } + Object.defineProperty(malformedUser, 'email', { + enumerable: true, + get: emailGetter, + }) + const acknowledgements = [ + { success: false, user: {} }, + { success: true, user: malformedUser }, + ] + const pending: Promise[] = [] + + for (const acknowledgement of acknowledgements) { + fireIdentifyResult({ id: 'actor_1', name: 'Actor', email: 'actor@example.test' }) + window.dispatchEvent( + new MessageEvent('message', { + origin: ORIGIN, + source: iframeSource, + data: { type: 'quackback:identify-result', ...acknowledgement }, + }) + ) + pending.push(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise) + } + + expect(hostSubmitRequests(post)).toHaveLength(0) + expect(emailGetter).not.toHaveBeenCalled() + await expect(Promise.all(pending)).resolves.toEqual([ + { accepted: false, reason: 'unavailable' }, + { accepted: false, reason: 'unavailable' }, + ]) + }) + it('sends one exact request with a fresh request ID and resolves exact safe results', async () => { const sdk = createSDK() const post = await initializeHostSubmit(sdk) diff --git a/packages/widget/src/core/report-submit.ts b/packages/widget/src/core/report-submit.ts index ba4009e20..700a7fffb 100644 --- a/packages/widget/src/core/report-submit.ts +++ b/packages/widget/src/core/report-submit.ts @@ -43,7 +43,7 @@ export type HostSubmitRequestMessage = { } } -function readDataRecord(value: unknown): DataRecord | null { +export function readDataRecord(value: unknown): DataRecord | null { if (typeof value !== 'object' || value === null || Array.isArray(value)) return null try { diff --git a/packages/widget/src/core/sdk.ts b/packages/widget/src/core/sdk.ts index ddd1b078b..37a284480 100644 --- a/packages/widget/src/core/sdk.ts +++ b/packages/widget/src/core/sdk.ts @@ -37,6 +37,7 @@ import { parseHostSubmitResultForRequest, parseSubmitBugReportContext, parseSubmitBugReportInput, + readDataRecord, } from './report-submit' // Public host-readiness contract for the iPLAYCAFE direct-report integration. @@ -56,15 +57,16 @@ const FEEDBACK_READY = Object.freeze({ 'media-lifecycle-events', 'private-evidence-attach', 'private-report-receipts', + 'host-report-submit', 'broad-media-upload', 'launcher-activation', - 'host-report-submit', ] as const), }) const GENERIC_READY = Object.freeze({}) const ANONYMOUS_IDENTITY = Object.freeze({ anonymous: true as const }) const LAUNCHER_ACTIVATION_TIMEOUT_MS = 25_000 const LAUNCHER_ACTIVATION_ABORTED = Symbol('launcher-activation-aborted') +const WIDGET_USER_KEYS = new Set(['id', 'name', 'email', 'avatarUrl']) type Command = | 'init' @@ -97,6 +99,38 @@ function isSafeHttpUrl(raw: string): boolean { } } +function readAcknowledgedSignedInUser(message: unknown): WidgetUser | null { + const acknowledgement = readDataRecord(message) + if (acknowledgement?.type !== 'quackback:identify-result' || acknowledgement.success !== true) { + return null + } + const user = readDataRecord(acknowledgement.user) + if (!user) return null + const userKeys = Object.keys(user) + if ( + userKeys.length < 3 || + userKeys.length > 4 || + !userKeys.every((key) => WIDGET_USER_KEYS.has(key)) || + typeof user.id !== 'string' || + typeof user.name !== 'string' || + typeof user.email !== 'string' || + (Object.prototype.hasOwnProperty.call(user, 'avatarUrl') && + user.avatarUrl !== null && + typeof user.avatarUrl !== 'string') + ) { + return null + } + + return { + id: user.id, + name: user.name, + email: user.email, + ...(Object.prototype.hasOwnProperty.call(user, 'avatarUrl') + ? { avatarUrl: user.avatarUrl as string | null } + : {}), + } +} + export function createSDK(): SDK { let config: InitOptions | null = null let launcher: LauncherHandle | null = null @@ -112,6 +146,7 @@ export function createSDK(): SDK { let panelOpen = false let currentUser: WidgetUser | null = null let identityResolved = false + let hostSubmitAcknowledgedUser: WidgetUser | null = null let mobileMql: MediaQueryList | null = null let mobileCleanup: (() => void) | null = null // Bug-report capture state — active only when the server config enables it. @@ -205,6 +240,7 @@ export function createSDK(): SDK { ready = false identityResolved = false currentUser = null + hostSubmitAcknowledgedUser = null hostReadyEmitted = false hostSubmitV7Eligible = false if (config && widgetSessionStarted) { @@ -432,6 +468,7 @@ export function createSDK(): SDK { } currentUser = m.user ?? null identityResolved = true + hostSubmitAcknowledgedUser = readAcknowledgedSignedInUser(msg) emitter.emit('identify', { success: !!m.success, user: currentUser, @@ -443,6 +480,16 @@ export function createSDK(): SDK { } case 'quackback:auth-change': { const m = msg as { user?: WidgetUser } + const previousAcknowledgedUser = hostSubmitAcknowledgedUser + const nextAcknowledgedUser = + previousAcknowledgedUser && + readAcknowledgedSignedInUser({ + type: 'quackback:identify-result', + success: true, + user: m.user, + }) + hostSubmitAcknowledgedUser = + nextAcknowledgedUser?.id === previousAcknowledgedUser?.id ? nextAcknowledgedUser : null currentUser = m.user ?? null break } @@ -703,6 +750,7 @@ export function createSDK(): SDK { // draft/screenshot can never be posted with B's eventual session token. resetActiveBugReport() identityResolved = false + hostSubmitAcknowledgedUser = null if (ready && bridge) bridge.send('quackback:identify', data) else { pendingIdentify = data @@ -836,7 +884,8 @@ export function createSDK(): SDK { !serverConfigSettled || !hostSubmitV7Eligible || !identityResolved || - !currentUser + !currentUser || + !hostSubmitAcknowledgedUser ) { return Promise.resolve({ accepted: false, reason: 'unavailable' }) } @@ -944,6 +993,7 @@ export function createSDK(): SDK { if (!widgetSessionStarted && launcherConfig()?.deferWidgetUntilActivate === true) { currentUser = null identityResolved = false + hostSubmitAcknowledgedUser = null return } ensureWidgetSession(nextIdentity, true) @@ -953,6 +1003,7 @@ export function createSDK(): SDK { deferredIdentity = ANONYMOUS_IDENTITY currentUser = null identityResolved = false + hostSubmitAcknowledgedUser = null if (!widgetSessionStarted && launcherConfig()?.deferWidgetUntilActivate === true) return resetActiveBugReport() panel?.hide({ resetLayoutAfterClose: true }) @@ -1084,6 +1135,7 @@ export function createSDK(): SDK { panelOpen = false currentUser = null identityResolved = false + hostSubmitAcknowledgedUser = null serverConfigSettled = false hostReadyEmitted = false hostSubmitV7Eligible = false diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index c290e84ce..510e12df7 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -166,9 +166,9 @@ export interface FeedbackReady { 'media-lifecycle-events', 'private-evidence-attach', 'private-report-receipts', + 'host-report-submit', 'broad-media-upload', 'launcher-activation', - 'host-report-submit', ] } From 778e7684cd19b85876e36e03fb5ed042bb84e9db Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Tue, 28 Jul 2026 18:58:55 +0700 Subject: [PATCH 06/21] feat(widget-sdk): prepare submit transport without capture --- .../src/core/__tests__/sdk-capture.test.ts | 235 +++++++++++++++++- packages/widget/src/core/config.ts | 2 + packages/widget/src/core/sdk.ts | 79 ++++-- packages/widget/src/index.ts | 2 + packages/widget/src/types.ts | 5 + 5 files changed, 294 insertions(+), 29 deletions(-) diff --git a/packages/widget/src/core/__tests__/sdk-capture.test.ts b/packages/widget/src/core/__tests__/sdk-capture.test.ts index 069278250..b5cb29e5a 100644 --- a/packages/widget/src/core/__tests__/sdk-capture.test.ts +++ b/packages/widget/src/core/__tests__/sdk-capture.test.ts @@ -122,11 +122,14 @@ function fireEditorLayout(flowId: unknown, expanded: unknown) { ) } -/** Mock config.json → optionally enabling capture. */ -function mockConfig(bugReportCapture: boolean) { +/** Mock config.json → optionally enabling capture and host submit. */ +function mockConfig(bugReportCapture: boolean, bugReportHostSubmit = bugReportCapture) { vi.stubGlobal( 'fetch', - vi.fn(async () => ({ ok: true, json: async () => ({ theme: {}, bugReportCapture }) })) + vi.fn(async () => ({ + ok: true, + json: async () => ({ theme: {}, bugReportCapture, bugReportHostSubmit }), + })) ) } @@ -163,6 +166,227 @@ describe('sdk — bug-report capture wiring', () => { window.history.replaceState(null, '', '/') }) + describe('deferred host-submit preparation boundary', () => { + it.each([ + ['omitted', undefined], + ['false', false], + ['non-boolean', 'true'], + ])( + 'keeps the V3-V6 deferred shortcut and starts no transport or capture when prepare is %s', + async (_label, prepareHostReportSubmit) => { + mockConfig(true, true) + stubIframe() + const originalConsoleError = console.error + const sdk = createSDK() + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + ...(prepareHostReportSubmit === undefined + ? {} + : { prepareHostReportSubmit: prepareHostReportSubmit as unknown as boolean }), + }) + await flush() + + expect(document.querySelector('.quackback-widget-iframe')).toBeNull() + expect(document.querySelector('.quackback-open')).toBeNull() + expect(console.error).toBe(originalConsoleError) + + const shortcut = new KeyboardEvent('keydown', { + key: 'B', + ctrlKey: true, + shiftKey: true, + cancelable: true, + }) + document.dispatchEvent(shortcut) + await flush() + + expect(shortcut.defaultPrevented).toBe(true) + expect(document.querySelectorAll('.quackback-widget-iframe')).toHaveLength(1) + expect(console.error).not.toBe(originalConsoleError) + } + ) + + it('prepares one hidden transport but advertises only generic readiness without host submit', async () => { + mockConfig(true, false) + stubIframe() + const originalConsoleError = console.error + const sdk = createSDK() + const readyEvents: unknown[] = [] + sdk.dispatch('on', 'ready', (payload: unknown) => readyEvents.push(payload)) + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + }) + await flush() + fireWidgetReady() + + expect(document.querySelectorAll('.quackback-widget-iframe')).toHaveLength(1) + expect(document.querySelector('.quackback-open')).toBeNull() + expect(console.error).toBe(originalConsoleError) + expect(readyEvents).toEqual([{}]) + }) + + it('prepares one exact-V7 anonymous transport without capture, diagnostics, shortcut, or lifecycle', async () => { + mockConfig(true, true) + const post = stubIframe() + const originalConsoleError = console.error + const addDocumentListener = vi.spyOn(document, 'addEventListener') + const sdk = createSDK() + const lifecycleEvents: unknown[] = [] + const readyEvents: unknown[] = [] + const beforeActivate = vi.fn(async () => 'open' as const) + sdk.dispatch('on', 'bug-report:opened', (payload: unknown) => lifecycleEvents.push(payload)) + sdk.dispatch('on', 'ready', (payload: unknown) => readyEvents.push(payload)) + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true, beforeActivate }, + prepareHostReportSubmit: true, + }) + await flush() + fireWidgetReady() + + expect(document.querySelectorAll('.quackback-widget-iframe')).toHaveLength(1) + expect(document.querySelector('.quackback-open')).toBeNull() + expect(console.error).toBe(originalConsoleError) + expect( + addDocumentListener.mock.calls.filter(([eventName]) => eventName === 'keydown') + ).toHaveLength(0) + expect(beforeActivate).not.toHaveBeenCalled() + expect(lifecycleEvents).toEqual([]) + expect(post).toHaveBeenCalledWith( + { type: 'quackback:identify', data: { anonymous: true } }, + ORIGIN + ) + expect( + post.mock.calls.some( + ([message]) => (message as { type?: string }).type === 'quackback:capture-result' + ) + ).toBe(false) + expect(readyEvents).toEqual([ + { + sdkVersion: '0.13.1-ipc.22', + feedbackContract: 'iplaycafe.feedback/7', + lifecycleVersion: 4, + diagnosticsVersion: 2, + locales: ['en', 'th'], + features: [...reportSubmitContract.readiness.features], + }, + ]) + + sdk.dispatch('open') + const installedConsoleError = console.error + expect(installedConsoleError).not.toBe(originalConsoleError) + expect( + addDocumentListener.mock.calls.filter(([eventName]) => eventName === 'keydown') + ).toHaveLength(1) + + sdk.dispatch('open') + expect(console.error).toBe(installedConsoleError) + expect( + addDocumentListener.mock.calls.filter(([eventName]) => eventName === 'keydown') + ).toHaveLength(1) + }) + + it.each([ + ['omitted', undefined], + ['false', false], + ['non-boolean', 'true'], + ['true', true], + ])( + 'keeps normal interactive initialization when defer is false and prepare is %s', + async (_label, prepareHostReportSubmit) => { + mockConfig(true, true) + stubIframe() + const originalConsoleError = console.error + const sdk = createSDK() + const readyEvents: unknown[] = [] + sdk.dispatch('on', 'ready', (payload: unknown) => readyEvents.push(payload)) + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: false }, + ...(prepareHostReportSubmit === undefined + ? {} + : { prepareHostReportSubmit: prepareHostReportSubmit as unknown as boolean }), + }) + await flush() + fireWidgetReady() + + expect(document.querySelectorAll('.quackback-widget-iframe')).toHaveLength(1) + expect(console.error).not.toBe(originalConsoleError) + expect((readyEvents[0] as { feedbackContract?: string }).feedbackContract).toBe( + 'iplaycafe.feedback/7' + ) + } + ) + + it('revokes V7 and pending submit state across replacement, re-init, and destroy', async () => { + mockConfig(true, true) + const post = stubIframe() + const sdk = createSDK() + const readyEvents: unknown[] = [] + sdk.dispatch('on', 'ready', (payload: unknown) => readyEvents.push(payload)) + const initOptions = { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + } as const + sdk.dispatch('init', initOptions) + await flush() + fireReady({ id: 'actor_old', name: 'Old Actor', email: 'old@example.test' }) + expect((readyEvents[0] as { feedbackContract?: string }).feedbackContract).toBe( + 'iplaycafe.feedback/7' + ) + + const replacementPending = sdk.dispatch( + 'submitBugReport', + HOST_SUBMIT_INPUT + ) as Promise + const oldIframe = document.querySelector('.quackback-widget-iframe') as HTMLIFrameElement + const replacement = document.createElement('iframe') + replacement.className = 'quackback-widget-iframe' + oldIframe.replaceWith(replacement) + await flush() + + await expect(replacementPending).resolves.toEqual({ + accepted: false, + reason: 'retryable_failure', + }) + expect(readyEvents).toHaveLength(2) + expect(readyEvents[1]).toEqual({}) + + fireWidgetReady() + expect(readyEvents).toHaveLength(3) + expect((readyEvents[2] as { feedbackContract?: string }).feedbackContract).toBe( + 'iplaycafe.feedback/7' + ) + fireWidgetReady() + expect(readyEvents).toHaveLength(3) + await expect(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)).resolves.toEqual({ + accepted: false, + reason: 'unavailable', + }) + + fireIdentifyResult({ id: 'actor_new', name: 'New Actor', email: 'new@example.test' }) + post.mockClear() + const reinitPending = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + sdk.dispatch('init', initOptions) + await expect(reinitPending).resolves.toEqual({ + accepted: false, + reason: 'retryable_failure', + }) + expect(readyEvents[3]).toEqual({}) + + await flush() + fireWidgetReady() + expect((readyEvents[4] as { feedbackContract?: string }).feedbackContract).toBe( + 'iplaycafe.feedback/7' + ) + sdk.dispatch('destroy') + expect(readyEvents[5]).toEqual({}) + }) + }) + it('emits the exact deeply-frozen feedback/7 readiness contract', async () => { mockConfig(true) stubIframe() @@ -229,7 +453,10 @@ describe('sdk — bug-report capture wiring', () => { fireReady() expect(seen).toEqual([]) - resolveConfig({ ok: true, json: async () => ({ theme: {}, bugReportCapture: true }) }) + resolveConfig({ + ok: true, + json: async () => ({ theme: {}, bugReportCapture: true, bugReportHostSubmit: true }), + }) await flush() expect(seen).toHaveLength(1) expect((seen[0] as { feedbackContract?: string }).feedbackContract).toBe('iplaycafe.feedback/7') diff --git a/packages/widget/src/core/config.ts b/packages/widget/src/core/config.ts index a9026bb1f..fb3c5e614 100644 --- a/packages/widget/src/core/config.ts +++ b/packages/widget/src/core/config.ts @@ -21,6 +21,8 @@ export interface ServerConfig { * (older servers) means off. */ bugReportCapture?: boolean + /** Whether the current request origin may use the host submit transport. */ + bugReportHostSubmit?: boolean /** Default-off private evidence gates exposed only as readiness metadata. */ bugReportMediaEvidence?: boolean bugReportPrivateEvidence?: boolean diff --git a/packages/widget/src/core/sdk.ts b/packages/widget/src/core/sdk.ts index 37a284480..bcf6bc59d 100644 --- a/packages/widget/src/core/sdk.ts +++ b/packages/widget/src/core/sdk.ts @@ -154,7 +154,8 @@ export function createSDK(): SDK { let collector: Collector | null = null let captureKeyHandler: ((e: KeyboardEvent) => void) | null = null let serverConfig: ServerConfig | null = null - let widgetSessionStarted = false + let widgetTransportStarted = false + let interactiveCaptureAuthorized = false let deferredIdentity: Identity | typeof ANONYMOUS_IDENTITY = ANONYMOUS_IDENTITY let launcherActivationInFlight = false let launcherActivationAbort: AbortController | null = null @@ -202,7 +203,10 @@ export function createSDK(): SDK { // Capability means usable now, not merely compiled into this image. An // older/disabled workspace emits the legacy empty object so hosts keep the // generic Help fallback instead of exposing a reportBug no-op. - hostSubmitV7Eligible = captureEnabled + hostSubmitV7Eligible = + serverConfig?.bugReportCapture === true && + serverConfig.bugReportHostSubmit === true && + widgetTransportStarted emitter.emit('ready', hostSubmitV7Eligible ? FEEDBACK_READY : GENERIC_READY) } @@ -226,24 +230,29 @@ export function createSDK(): SDK { pending.resolve(result) } - function settleAllHostSubmits(): void { + function settleAllHostSubmits( + result: SubmitBugReportResultV1 = { + accepted: false, + reason: 'retryable_failure', + } + ): void { for (const [requestId, pending] of pendingHostSubmits) { - settlePendingHostSubmit(requestId, pending, { - accepted: false, - reason: 'retryable_failure', - }) + settlePendingHostSubmit(requestId, pending, result) } } function invalidateHostSubmitTransport(): void { + const revokeExactReadiness = hostReadyEmitted && hostSubmitV7Eligible transportGeneration += 1 ready = false + settleAllHostSubmits({ accepted: false, reason: 'retryable_failure' }) identityResolved = false currentUser = null hostSubmitAcknowledgedUser = null hostReadyEmitted = false hostSubmitV7Eligible = false - if (config && widgetSessionStarted) { + if (revokeExactReadiness) emitter.emit('ready', GENERIC_READY) + if (config && widgetTransportStarted) { pendingIdentify = deferredIdentity pendingIdentifyPresent = true } @@ -253,7 +262,6 @@ export function createSDK(): SDK { bridge = null currentIframe = null currentIframeWindow = null - settleAllHostSubmits() } function installHostSubmitTransport(iframe: HTMLIFrameElement): void { @@ -681,7 +689,7 @@ export function createSDK(): SDK { document.addEventListener('keydown', captureKeyHandler) } - /** Install the collector only after an explicit widget-session activation. */ + /** Install the collector only after explicit interactive capture authorization. */ function setupCaptureIfEnabled(serverCfg: ServerConfig): void { if (serverCfg.bugReportCapture !== true || captureEnabled) return captureEnabled = true @@ -733,14 +741,25 @@ export function createSDK(): SDK { return config?.launcher && typeof config.launcher === 'object' ? config.launcher : null } - function ensureWidgetSession(identity?: unknown, explicitIdentity = false): void { - if (widgetSessionStarted) { + function installInteractiveCaptureIfEligible(serverCfg: ServerConfig): void { + if (!interactiveCaptureAuthorized) return + setupCaptureShortcutIfEnabled(serverCfg) + setupCaptureIfEnabled(serverCfg) + } + + function authorizeInteractiveCapture(): void { + if (interactiveCaptureAuthorized) return + interactiveCaptureAuthorized = true + if (serverConfig) installInteractiveCaptureIfEligible(serverConfig) + } + + function ensureWidgetTransport(identity?: unknown, explicitIdentity = false): void { + if (widgetTransportStarted) { if (explicitIdentity) sendIdentity(identity) return } ensurePanel() - if (serverConfig) setupCaptureIfEnabled(serverConfig) - widgetSessionStarted = true + widgetTransportStarted = true sendIdentity(explicitIdentity ? identity : deferredIdentity) } @@ -814,7 +833,8 @@ export function createSDK(): SDK { let activationTimer: number | null = null let activationAbortHandler: (() => void) | null = null try { - ensureWidgetSession() + authorizeInteractiveCapture() + ensureWidgetTransport() if (launcherOptions?.beforeActivate) { // Once a host preflight exists, only its explicit valid return may // navigate. Undefined/malformed JavaScript results fail closed too. @@ -958,12 +978,16 @@ export function createSDK(): SDK { hostReadyEmitted = false captureAvailable = null serverConfig = null - widgetSessionStarted = false + widgetTransportStarted = false + interactiveCaptureAuthorized = false deferredIdentity = config.identity ?? ANONYMOUS_IDENTITY launcherActivationInFlight = false launcherActivationAbort = null createLauncherIfNeeded() - if (launcherConfig()?.deferWidgetUntilActivate !== true) ensureWidgetSession() + const deferredLauncher = launcherConfig()?.deferWidgetUntilActivate === true + const shouldPrepare = next.prepareHostReportSubmit === true && deferredLauncher + if (!deferredLauncher || shouldPrepare) ensureWidgetTransport() + if (!deferredLauncher) authorizeInteractiveCapture() const reveal = revealLauncherOnce() const fallback = window.setTimeout(reveal, LAUNCHER_REVEAL_FALLBACK_MS) void fetchServerConfig(config.instanceUrl) @@ -971,8 +995,10 @@ export function createSDK(): SDK { if (generation !== initGeneration || !config) return serverConfig = serverCfg applyServerTheme(serverCfg) - setupCaptureShortcutIfEnabled(serverCfg) - if (widgetSessionStarted) setupCaptureIfEnabled(serverCfg) + // V3-V6 compatibility: deferred launchers without preparation keep + // their intent-only shortcut even though no transport exists yet. + if (deferredLauncher && !shouldPrepare) setupCaptureShortcutIfEnabled(serverCfg) + installInteractiveCaptureIfEligible(serverCfg) captureAvailable = serverCfg.bugReportCapture === true flushPendingReportBug() }) @@ -990,13 +1016,13 @@ export function createSDK(): SDK { case 'identify': { const nextIdentity = (a as Identity | undefined) ?? ANONYMOUS_IDENTITY deferredIdentity = nextIdentity - if (!widgetSessionStarted && launcherConfig()?.deferWidgetUntilActivate === true) { + if (!widgetTransportStarted && launcherConfig()?.deferWidgetUntilActivate === true) { currentUser = null identityResolved = false hostSubmitAcknowledgedUser = null return } - ensureWidgetSession(nextIdentity, true) + ensureWidgetTransport(nextIdentity, true) return } case 'logout': @@ -1004,7 +1030,7 @@ export function createSDK(): SDK { currentUser = null identityResolved = false hostSubmitAcknowledgedUser = null - if (!widgetSessionStarted && launcherConfig()?.deferWidgetUntilActivate === true) return + if (!widgetTransportStarted && launcherConfig()?.deferWidgetUntilActivate === true) return resetActiveBugReport() panel?.hide({ resetLayoutAfterClose: true }) launcher?.setOpen(false) @@ -1016,7 +1042,8 @@ export function createSDK(): SDK { } return case 'open': { - ensureWidgetSession() + authorizeInteractiveCapture() + ensureWidgetTransport() const opts = (a as OpenOptions) ?? {} const view = (opts as { view?: string }).view let wireOpts: unknown = opts @@ -1085,7 +1112,8 @@ export function createSDK(): SDK { } case 'reportBug': { if (!config) return - ensureWidgetSession() + authorizeInteractiveCapture() + ensureWidgetTransport() const requestedEntrypoint = (a as { entrypoint?: unknown } | undefined)?.entrypoint === 'shortcut' ? 'shortcut' @@ -1140,7 +1168,8 @@ export function createSDK(): SDK { hostReadyEmitted = false hostSubmitV7Eligible = false serverConfig = null - widgetSessionStarted = false + widgetTransportStarted = false + interactiveCaptureAuthorized = false deferredIdentity = ANONYMOUS_IDENTITY launcherActivationInFlight = false config = null diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index ad8f6adb1..4352756cf 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -18,6 +18,7 @@ import type { BugReportReceiptV1, BugReportStatusV1, HostSubmitFailureReason, + FeedbackReady, } from './types' export type { @@ -39,6 +40,7 @@ export type { BugReportReceiptV1, BugReportStatusV1, HostSubmitFailureReason, + FeedbackReady, } const sdk = createSDK() diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index 510e12df7..24eadac3d 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -67,6 +67,11 @@ export interface InitOptions { locale?: (typeof WIDGET_LOCALES)[number] | (string & {}) /** Bundle identity into init — shorthand for init + identify. */ identity?: Identity + /** + * Prepare a hidden host-submit transport for a deferred launcher. Only the + * literal boolean true is honored; capture remains user-authorized. + */ + prepareHostReportSubmit?: boolean } /** From de5c9cabce7fdf643f8ff782bf2db3fb16e7dddf Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Tue, 28 Jul 2026 19:09:07 +0700 Subject: [PATCH 07/21] fix(widget-sdk): gate prepared lifecycle traffic --- .../src/core/__tests__/sdk-capture.test.ts | 108 ++++++++++++++++++ packages/widget/src/core/sdk.ts | 4 + 2 files changed, 112 insertions(+) diff --git a/packages/widget/src/core/__tests__/sdk-capture.test.ts b/packages/widget/src/core/__tests__/sdk-capture.test.ts index b5cb29e5a..dc403f6b5 100644 --- a/packages/widget/src/core/__tests__/sdk-capture.test.ts +++ b/packages/widget/src/core/__tests__/sdk-capture.test.ts @@ -288,6 +288,114 @@ describe('sdk — bug-report capture wiring', () => { ).toHaveLength(1) }) + it('rejects prepared-only lifecycle and capture traffic until explicit user intent', async () => { + mockConfig(true, true) + const post = stubIframe() + const sdk = createSDK() + const readyEvents: unknown[] = [] + const identified = vi.fn() + const opened = vi.fn() + const queued = vi.fn() + const statusViewed = vi.fn() + const captured = vi.fn() + const abandoned = vi.fn() + sdk.dispatch('on', 'ready', (payload: unknown) => readyEvents.push(payload)) + sdk.dispatch('on', 'identify', identified) + sdk.dispatch('on', 'bug-report:opened', opened) + sdk.dispatch('on', 'bug-report:queued', queued) + sdk.dispatch('on', 'bug-report:status-viewed', statusViewed) + sdk.dispatch('on', 'bug-report:capture-completed', captured) + sdk.dispatch('on', 'bug-report:abandoned', abandoned) + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + }) + await flush() + const user = { id: 'actor_1', name: 'Actor', email: 'actor@example.test' } + fireReady(user) + post.mockClear() + + fireLifecycle( + 'bug-report:opened', + { entrypoint: 'widget', authenticated: true }, + WIDGET_FLOW_ID + ) + fireLifecycle( + 'bug-report:queued', + { queueReason: 'offline', authenticated: true }, + WIDGET_FLOW_ID + ) + fireLifecycle( + 'bug-report:status-viewed', + { status: 'received', source: 'receipt' }, + WIDGET_FLOW_ID + ) + fireCaptureRequest(WIDGET_FLOW_ID) + fireEditorLayout(WIDGET_FLOW_ID, true) + await flush() + + expect(opened).not.toHaveBeenCalled() + expect(queued).not.toHaveBeenCalled() + expect(statusViewed).not.toHaveBeenCalled() + expect(captured).not.toHaveBeenCalled() + expect(abandoned).not.toHaveBeenCalled() + expect( + document + .querySelector('.quackback-panel') + ?.classList.contains('quackback-screenshot-editor-layout') + ).toBe(false) + expect((readyEvents[0] as { feedbackContract?: string }).feedbackContract).toBe( + 'iplaycafe.feedback/7' + ) + expect(identified).toHaveBeenCalledWith({ + success: true, + user, + anonymous: false, + error: undefined, + }) + + const pendingSubmit = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + const request = latestHostSubmitRequest(post) + expect(request).toBeDefined() + fireHostSubmitResult(request?.data?.requestId as string, { + accepted: false, + reason: 'unavailable', + }) + await expect(pendingSubmit).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + + sdk.dispatch('open') + expect(abandoned).not.toHaveBeenCalled() + fireLifecycle( + 'bug-report:opened', + { entrypoint: 'widget', authenticated: true }, + WIDGET_FLOW_ID + ) + fireLifecycle( + 'bug-report:queued', + { queueReason: 'offline', authenticated: true }, + WIDGET_FLOW_ID + ) + fireLifecycle( + 'bug-report:status-viewed', + { status: 'received', source: 'receipt' }, + WIDGET_FLOW_ID + ) + fireCaptureRequest(WIDGET_FLOW_ID) + fireEditorLayout(WIDGET_FLOW_ID, true) + await flush() + + expect(opened).toHaveBeenCalledWith({ entrypoint: 'widget', authenticated: true }) + expect(queued).toHaveBeenCalledWith({ queueReason: 'offline', authenticated: true }) + expect(statusViewed).toHaveBeenCalledWith({ status: 'received', source: 'receipt' }) + expect(captured).toHaveBeenCalledTimes(1) + expect( + document + .querySelector('.quackback-panel') + ?.classList.contains('quackback-screenshot-editor-layout') + ).toBe(true) + }) + it.each([ ['omitted', undefined], ['false', false], diff --git a/packages/widget/src/core/sdk.ts b/packages/widget/src/core/sdk.ts index bcf6bc59d..30db30ad3 100644 --- a/packages/widget/src/core/sdk.ts +++ b/packages/widget/src/core/sdk.ts @@ -502,6 +502,7 @@ export function createSDK(): SDK { break } case 'quackback:event': { + if (!interactiveCaptureAuthorized) break const m = msg as { name?: string; payload?: unknown } if (!m.name) break const lifecycle = sanitizeBugReportEvent(m.name, m.payload) @@ -587,6 +588,7 @@ export function createSDK(): SDK { break } case 'quackback:capture-request': { + if (!interactiveCaptureAuthorized) break const flowId = bugReportFlowId((msg as { flowId?: unknown }).flowId) if (!flowId || activeBugReport?.flowId !== flowId) break // The widget's bug-report view asked for a fresh capture. The widget @@ -600,12 +602,14 @@ export function createSDK(): SDK { break } case 'quackback:bug-report-submit-started': { + if (!interactiveCaptureAuthorized) break const flowId = bugReportFlowId((msg as { flowId?: unknown }).flowId) if (!flowId || activeBugReport?.flowId !== flowId) break activeBugReport.submitting = true break } case 'quackback:bug-report-editor-layout': { + if (!interactiveCaptureAuthorized) break const m = msg as { flowId?: unknown; expanded?: unknown } const flowId = bugReportFlowId(m.flowId) // The bridge has already authenticated exact iframe origin + source. From 1607eb9324fe20db66a2456b8cff4993827409c8 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Tue, 28 Jul 2026 19:31:39 +0700 Subject: [PATCH 08/21] fix(widget): keep configured host submit server-only --- .../bug-report-receipts-boundary.test.ts | 10 ++++++++++ .../server/functions/bug-report-receipts.ts | 20 +++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts b/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts index ac136a2d6..f0f4b166e 100644 --- a/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts @@ -43,6 +43,7 @@ const state = vi.hoisted(() => ({ eqCalls: [] as Array<[unknown, unknown]>, principalField: Symbol('principal_id'), clientField: Symbol('client_submission_id'), + serverOnlyHandlers: [] as unknown[], })) const REPORT_REF = 'qbr_abcdefghijklmnopqrstuvwx' @@ -57,6 +58,10 @@ vi.mock('@tanstack/react-start', () => ({ } return chain }, + createServerOnlyFn: (handler: T): T => { + state.serverOnlyHandlers.push(handler) + return handler + }, })) vi.mock('@tanstack/react-start/server', () => ({ @@ -253,6 +258,7 @@ vi.mock('@/lib/server/logger', () => ({ import { createBugReportReplyFn as createBugReportReplyHandler, listMyBugReportsFn as listMyBugReportsHandler, + submitConfiguredBugReportHandler, submitBugReportFn as submitBugReportHandler, } from '../bug-report-receipts' import { @@ -546,6 +552,10 @@ describe('authenticated bug-report receipt handlers', () => { }) describe('configured host bug-report boundary', () => { + it('registers the exported configured host handler as server-only', () => { + expect(state.serverOnlyHandlers).toContain(submitConfiguredBugReportHandler) + }) + beforeEach(() => { state.principal = 'principal_A' state.principalType = 'user' diff --git a/apps/web/src/lib/server/functions/bug-report-receipts.ts b/apps/web/src/lib/server/functions/bug-report-receipts.ts index b776dfaf3..9fe09e676 100644 --- a/apps/web/src/lib/server/functions/bug-report-receipts.ts +++ b/apps/web/src/lib/server/functions/bug-report-receipts.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { createServerFn } from '@tanstack/react-start' +import { createServerFn, createServerOnlyFn } from '@tanstack/react-start' import { aliasedTable, isNull } from 'drizzle-orm' import { type BoardId, type PrincipalId, type UserId } from '@quackback/ids' import { @@ -270,15 +270,15 @@ async function submitBugReportHandler({ return result.receipt } -export async function submitConfiguredBugReportHandler({ - data, - headers, -}: { - data: { clientSubmissionId: string; title: string; content: string } - headers: Headers -}): Promise { - return submitBugReportHandler({ data, headers, configuredHost: true }) -} +export const submitConfiguredBugReportHandler = createServerOnlyFn( + async ({ + data, + headers, + }: { + data: { clientSubmissionId: string; title: string; content: string } + headers: Headers + }): Promise => submitBugReportHandler({ data, headers, configuredHost: true }) +) export const submitBugReportFn = createServerFn({ method: 'POST' }) .validator(submitBugReportSchema) From 75b31194eb75d86734c8719cc68eae72e1406ebc Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 04:12:04 +0700 Subject: [PATCH 09/21] fix(widget): fence host-submit origin and binding races --- .../__tests__/bug-report-host-submit.test.ts | 238 +++++++++++++++++- .../src/lib/client/bug-report-host-submit.ts | 108 +++++++- .../host-submit-origin-policy.test.ts | 50 +++- .../bug-reports/host-submit-origin-policy.ts | 25 +- apps/web/src/routes/widget/index.tsx | 1 + 5 files changed, 401 insertions(+), 21 deletions(-) diff --git a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts index 6fd32bcd9..b895d0538 100644 --- a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts +++ b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts @@ -82,7 +82,7 @@ function createWindowHarness() { } async function flushAsyncWork() { - for (let index = 0; index < 12; index += 1) await Promise.resolve() + for (let index = 0; index < 24; index += 1) await Promise.resolve() } beforeEach(() => { @@ -108,6 +108,7 @@ describe('authenticated host submit bridge', () => { const dispose = installBugReportHostSubmitBridge({ authorizeOrigin: authorize, currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), submit, target: harness.target, }) @@ -134,6 +135,234 @@ describe('authenticated host submit bridge', () => { generation += 1 }) + it('waits for the exact pending identify authorization before handling a concurrent report', async () => { + const harness = createWindowHarness() + const pendingIdentifyAuthorization = deferred<{ allowed: boolean }>() + const authorize = vi + .fn() + .mockReturnValueOnce(pendingIdentifyAuthorization.promise) + .mockResolvedValueOnce({ allowed: true }) + const submit = vi.fn().mockResolvedValue({ accepted: true, receipt: RECEIPT }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + expect(submit).not.toHaveBeenCalled() + + pendingIdentifyAuthorization.resolve({ allowed: true }) + await flushAsyncWork() + + expect(submit).toHaveBeenCalledTimes(1) + expect(authorize).toHaveBeenCalledTimes(2) + expect(harness.parent.postMessage).toHaveBeenCalledWith( + expectedResult({ accepted: true, receipt: RECEIPT }), + 'https://app.example' + ) + }) + + it('fails a report closed when its exact pending identify authorization is denied', async () => { + const harness = createWindowHarness() + const pendingIdentifyAuthorization = deferred<{ allowed: boolean }>() + const authorize = vi.fn().mockReturnValue(pendingIdentifyAuthorization.promise) + const submit = vi.fn() + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + harness.dispatch({ data: validRequest() }) + pendingIdentifyAuthorization.resolve({ allowed: false }) + await flushAsyncWork() + + expect(authorize).toHaveBeenCalledTimes(1) + expect(parentBinding.current()).toBeNull() + expect(submit).not.toHaveBeenCalled() + expect(harness.parent.postMessage).not.toHaveBeenCalled() + }) + + it.each(['generation', 'clear', 'bridge-dispose', 'binding-dispose'] as const)( + 'does not revive a pending report after %s invalidates its binding flight', + async (invalidation) => { + const harness = createWindowHarness() + let generation = 1 + const pendingIdentifyAuthorization = deferred<{ allowed: boolean }>() + const authorize = vi.fn().mockReturnValue(pendingIdentifyAuthorization.promise) + const submit = vi.fn() + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => generation, + target: harness.target, + }) + const disposeBridge = installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + harness.dispatch({ data: validRequest() }) + if (invalidation === 'generation') generation += 1 + if (invalidation === 'clear') parentBinding.clear() + if (invalidation === 'bridge-dispose') disposeBridge() + if (invalidation === 'binding-dispose') parentBinding.dispose() + pendingIdentifyAuthorization.resolve({ allowed: true }) + await flushAsyncWork() + + expect(submit).not.toHaveBeenCalled() + expect(harness.parent.postMessage).not.toHaveBeenCalled() + } + ) + + it('does not let wrong-source or wrong-origin reports await or consume an exact pending bind', async () => { + const harness = createWindowHarness() + const attacker = { postMessage: vi.fn() } + const pendingIdentifyAuthorization = deferred<{ allowed: boolean }>() + const authorize = vi + .fn() + .mockReturnValueOnce(pendingIdentifyAuthorization.promise) + .mockResolvedValueOnce({ allowed: true }) + const submit = vi.fn().mockResolvedValue({ accepted: true, receipt: RECEIPT }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + harness.dispatch({ source: attacker, data: validRequest() }) + harness.dispatch({ origin: 'https://admin.example', data: validRequest() }) + pendingIdentifyAuthorization.resolve({ allowed: true }) + await flushAsyncWork() + expect(submit).not.toHaveBeenCalled() + expect(attacker.postMessage).not.toHaveBeenCalled() + expect(harness.parent.postMessage).not.toHaveBeenCalled() + + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + expect(submit).toHaveBeenCalledTimes(1) + expect(authorize).toHaveBeenCalledTimes(2) + }) + + it('keeps a waiting report attached to the first identify candidate', async () => { + const harness = createWindowHarness() + const pendingIdentifyAuthorization = deferred<{ allowed: boolean }>() + const authorize = vi + .fn() + .mockReturnValueOnce(pendingIdentifyAuthorization.promise) + .mockResolvedValueOnce({ allowed: true }) + const submit = vi.fn().mockResolvedValue({ accepted: true, receipt: RECEIPT }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ origin: 'https://app.example', data: { type: 'quackback:identify' } }) + harness.dispatch({ origin: 'https://app.example', data: validRequest() }) + harness.dispatch({ origin: 'https://admin.example', data: { type: 'quackback:identify' } }) + pendingIdentifyAuthorization.resolve({ allowed: true }) + await flushAsyncWork() + + expect(parentBinding.current()).toMatchObject({ + generation: 1, + origin: 'https://app.example', + source: harness.parent, + }) + expect(authorize).toHaveBeenCalledTimes(2) + expect(submit).toHaveBeenCalledTimes(1) + expect(harness.parent.postMessage).toHaveBeenCalledWith( + expectedResult({ accepted: true, receipt: RECEIPT }), + 'https://app.example' + ) + }) + + it('never switches a waiting report to a replacement generation pending at the same origin', async () => { + const harness = createWindowHarness() + let generation = 1 + const firstAuthorization = deferred<{ allowed: boolean }>() + const replacementAuthorization = deferred<{ allowed: boolean }>() + const authorize = vi + .fn() + .mockReturnValueOnce(firstAuthorization.promise) + .mockReturnValueOnce(replacementAuthorization.promise) + .mockResolvedValueOnce({ allowed: true }) + const submit = vi.fn().mockResolvedValue({ accepted: true, receipt: RECEIPT }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => generation, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + harness.dispatch({ data: validRequest() }) + generation += 1 + parentBinding.clear() + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + + firstAuthorization.resolve({ allowed: true }) + replacementAuthorization.resolve({ allowed: true }) + await flushAsyncWork() + + expect(parentBinding.current()).toMatchObject({ + generation: 2, + origin: 'https://app.example', + source: harness.parent, + }) + expect(authorize).toHaveBeenCalledTimes(2) + expect(submit).not.toHaveBeenCalled() + expect(harness.parent.postMessage).not.toHaveBeenCalled() + + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + expect(authorize).toHaveBeenCalledTimes(3) + expect(submit).toHaveBeenCalledTimes(1) + }) + it('cannot rebind a generation to another allowlisted origin on the same WindowProxy', async () => { const harness = createWindowHarness() const authorize = vi.fn().mockResolvedValue({ allowed: true }) @@ -169,6 +398,7 @@ describe('authenticated host submit bridge', () => { installBugReportHostSubmitBridge({ authorizeOrigin: authorize, currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), submit, target: harness.target, }) @@ -278,6 +508,7 @@ describe('authenticated host submit bridge', () => { installBugReportHostSubmitBridge({ authorizeOrigin: authorize, currentBinding: () => binding.current(), + resolveBinding: (source, origin) => binding.resolve(source, origin), submit, target: harness.target, }) @@ -302,6 +533,7 @@ describe('authenticated host submit bridge', () => { installBugReportHostSubmitBridge({ authorizeOrigin: authorize, currentBinding: () => binding.current(), + resolveBinding: (source, origin) => binding.resolve(source, origin), submit, target: harness.target, }) @@ -351,6 +583,7 @@ describe('authenticated host submit bridge', () => { installBugReportHostSubmitBridge({ authorizeOrigin: authorize, currentBinding: () => binding.current(), + resolveBinding: (source, origin) => binding.resolve(source, origin), submit, target: harness.target, }) @@ -391,6 +624,9 @@ describe('widget route host-submit wiring', () => { expect(source).toMatch( /authorizeBugReportHostOriginFn\(\{\s*data:\s*\{\s*candidateOrigin\s*\},\s*\}\)/ ) + expect(source).toMatch( + /resolveBinding:\s*\(source,\s*origin\)\s*=>\s*parentBinding\.resolve\(source,\s*origin\)/ + ) expect(source).toMatch( /submitHostBugReportFn\(\{\s*data:\s*input,\s*headers:\s*getWidgetAuthHeaders\(\),\s*\}\)/ ) diff --git a/apps/web/src/lib/client/bug-report-host-submit.ts b/apps/web/src/lib/client/bug-report-host-submit.ts index 0b2982b71..da7a3fd36 100644 --- a/apps/web/src/lib/client/bug-report-host-submit.ts +++ b/apps/web/src/lib/client/bug-report-host-submit.ts @@ -24,10 +24,23 @@ export type BugReportHostParentBinding = Readonly<{ export type BugReportHostParentBindingController = { current(): BugReportHostParentBinding | null + resolve( + source: MessageEventSource | null, + origin: string + ): Promise clear(): void dispose(): void } +type PendingBugReportHostParentBinding = Readonly<{ + generation: number + origin: string + source: Window + epoch: number + token: symbol + ready: Promise +}> + function hasMessageType(value: unknown, expectedType: string): boolean { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false try { @@ -80,11 +93,13 @@ export function installBugReportHostParentBinding(options: { const target = options.target ?? window const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS let binding: BugReportHostParentBinding | null = null + let pending: PendingBugReportHostParentBinding | null = null let epoch = 0 let disposed = false const clear = () => { binding = null + pending = null epoch += 1 } @@ -98,6 +113,46 @@ export function installBugReportHostParentBinding(options: { return binding } + const resolve = async ( + source: MessageEventSource | null, + origin: string + ): Promise => { + const established = current() + if ( + established && + established.source === source && + established.origin === origin && + target.parent === source + ) { + return established + } + + const candidate = pending + if ( + disposed || + candidate === null || + candidate.source !== source || + candidate.origin !== origin || + candidate.epoch !== epoch || + candidate.generation !== options.currentGeneration() || + target.parent !== source + ) { + return null + } + + const resolved = await candidate.ready + if (disposed || resolved === null) return null + + const latest = current() + return latest && + latest === resolved && + latest.source === source && + latest.origin === origin && + target.parent === source + ? latest + : null + } + const handleMessage = async (event: MessageEvent) => { if ( disposed || @@ -112,22 +167,44 @@ export function installBugReportHostParentBinding(options: { const origin = event.origin const source = event.source as Window const startingEpoch = epoch - if (!(await authorizeWithin(options.authorizeOrigin, origin, timeoutMs))) return - if ( - disposed || - epoch !== startingEpoch || - binding !== null || - options.currentGeneration() !== generation || - target.parent !== source - ) { - return + if (pending !== null) return + + const token = Symbol() + const ready = Promise.resolve().then(async () => { + if (!(await authorizeWithin(options.authorizeOrigin, origin, timeoutMs))) return null + if ( + disposed || + pending?.token !== token || + epoch !== startingEpoch || + binding !== null || + options.currentGeneration() !== generation || + target.parent !== source + ) { + return null + } + binding = Object.freeze({ generation, origin, source }) + return binding + }) + const candidate: PendingBugReportHostParentBinding = Object.freeze({ + generation, + origin, + source, + epoch: startingEpoch, + token, + ready, + }) + pending = candidate + try { + await ready + } finally { + if (pending === candidate) pending = null } - binding = Object.freeze({ generation, origin, source }) } target.addEventListener('message', handleMessage) return { current, + resolve, clear, dispose() { if (disposed) return @@ -209,6 +286,10 @@ function bindingStillCurrent( export function installBugReportHostSubmitBridge(options: { authorizeOrigin(candidateOrigin: string): Promise<{ allowed: boolean }> currentBinding(): BugReportHostParentBinding | null + resolveBinding( + source: MessageEventSource | null, + origin: string + ): Promise submit(input: { clientSubmissionId: string title: string @@ -223,8 +304,12 @@ export function installBugReportHostSubmitBridge(options: { const handleMessage = async (event: MessageEvent) => { if (disposed) return - const binding = options.currentBinding() + if (!hasMessageType(event.data, 'quackback:report-submit')) return + + const binding = + options.currentBinding() ?? (await options.resolveBinding(event.source, event.origin)) if ( + disposed || !binding || event.source !== binding.source || event.origin !== binding.origin || @@ -232,7 +317,6 @@ export function installBugReportHostSubmitBridge(options: { ) { return } - if (!hasMessageType(event.data, 'quackback:report-submit')) return const copied = Object.freeze({ ...binding }) if (!(await authorizeWithin(options.authorizeOrigin, copied.origin, timeoutMs))) return if (disposed || !bindingStillCurrent(target, options.currentBinding, copied)) return diff --git a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts index 30f8da911..2938abe5f 100644 --- a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts @@ -18,14 +18,13 @@ describe('bug report host submit origin policy', () => { expect(parseBugReportHostSubmitOrigins('https://App.Example:443/')).toEqual([ 'https://app.example', ]) - expect( - parseBugReportHostSubmitOrigins('https://a.example, https://b.example') - ).toEqual(['https://a.example', 'https://b.example']) + expect(parseBugReportHostSubmitOrigins('https://a.example, https://b.example')).toEqual([ + 'https://a.example', + 'https://b.example', + ]) expect(parseBugReportHostSubmitOrigins('https://a.example,')).toEqual([]) expect(parseBugReportHostSubmitOrigins('https://a.example/path')).toEqual([]) - expect( - parseBugReportHostSubmitOrigins('https://a.example,https://A.EXAMPLE:443') - ).toEqual([]) + expect(parseBugReportHostSubmitOrigins('https://a.example,https://A.EXAMPLE:443')).toEqual([]) expect(parseBugReportHostSubmitOrigins('http://app.example')).toEqual([]) expect( parseBugReportHostSubmitOrigins('http://localhost:3000', { @@ -34,6 +33,45 @@ describe('bug report host submit origin policy', () => { ).toEqual(['http://localhost:3000']) }) + it('rejects wildcard origins and poisons a mixed allow-list', () => { + expect(parseBugReportHostSubmitOrigins('https://*.example')).toEqual([]) + expect(parseBugReportHostSubmitOrigins('https://allowed.example,https://*.example')).toEqual([]) + expect(parseBugReportHostSubmitOrigins('https://%2a.example')).toEqual([]) + expect(parseBugReportHostSubmitOrigins('https://allowed.example,https://%2A.example')).toEqual( + [] + ) + }) + + it.each([ + ['TAB', '\t'], + ['CR', '\r'], + ['LF', '\n'], + ])('rejects embedded %s controls and poisons a mixed allow-list', (_name, control) => { + const ambiguous = `https://app${control}.example` + + expect(normalizeBugReportHostOrigin(ambiguous)).toBeNull() + expect(parseBugReportHostSubmitOrigins(ambiguous)).toEqual([]) + expect(parseBugReportHostSubmitOrigins(`https://allowed.example,${ambiguous}`)).toEqual([]) + }) + + it('rejects internal ASCII whitespace without rejecting harmless outer spaces', () => { + expect(normalizeBugReportHostOrigin('https://app .example')).toBeNull() + expect(parseBugReportHostSubmitOrigins(' https://allowed.example ')).toEqual([ + 'https://allowed.example', + ]) + }) + + it.each([ + ['percent-encoded dot', 'https://example%2Ecom'], + ['percent-encoded default-ignorable', 'https://exa%E2%80%8Bmple.com'], + ['raw default-ignorable', 'https://exa\u200Bmple.com'], + ['outer non-ASCII whitespace', '\u00A0https://allowed.example'], + ])('rejects %s before URL canonicalization and poisons a mixed list', (_name, candidate) => { + expect(normalizeBugReportHostOrigin(candidate)).toBeNull() + expect(parseBugReportHostSubmitOrigins(candidate)).toEqual([]) + expect(parseBugReportHostSubmitOrigins(`https://allowed.example,${candidate}`)).toEqual([]) + }) + it('rejects opaque and ambiguous candidate origins', () => { expect(normalizeBugReportHostOrigin('null')).toBeNull() expect(normalizeBugReportHostOrigin('https://a.example, https://b.example')).toBeNull() diff --git a/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts b/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts index 4ec1b26f5..fa4486ce6 100644 --- a/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts @@ -5,15 +5,36 @@ export interface BugReportHostOriginParseOptions { const EMPTY_ORIGINS: readonly string[] = Object.freeze([]) const TEST_HTTP_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']) const ORIGIN_PATTERN = /^(https?):\/\/[^/?#]+\/?$/ +const DEFAULT_IGNORABLE_PATTERN = /\p{Default_Ignorable_Code_Point}/u +const PERCENT_ENCODING_PATTERN = /%/ +const WHITESPACE_PATTERN = /\s/u + +function containsControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0 + if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return true + } + return false +} export function normalizeBugReportHostOrigin( candidate: unknown, options?: BugReportHostOriginParseOptions ): string | null { if (typeof candidate !== 'string') return null + if (containsControlCharacter(candidate) || DEFAULT_IGNORABLE_PATTERN.test(candidate)) return null - const value = candidate.trim() - if (!value || value.includes(',') || !ORIGIN_PATTERN.test(value)) return null + const value = candidate.replace(/^ +/, '').replace(/ +$/, '') + if ( + !value || + value.includes(',') || + value.includes('*') || + PERCENT_ENCODING_PATTERN.test(value) || + WHITESPACE_PATTERN.test(value) || + !ORIGIN_PATTERN.test(value) + ) { + return null + } let parsed: URL try { diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index bcc9f857e..c5bce5c32 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -550,6 +550,7 @@ function WidgetPage() { const disposeHostSubmit = installBugReportHostSubmitBridge({ authorizeOrigin, currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), submit: (input) => submitHostBugReportFn({ data: input, From b7a05e66e992d88e2f549bc9181afe3628f32cc3 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 04:22:13 +0700 Subject: [PATCH 10/21] fix(widget): close host-submit ABA and origin gaps --- .../__tests__/bug-report-host-submit.test.ts | 86 +++++++++++++++++++ .../src/lib/client/bug-report-host-submit.ts | 24 ++---- .../host-submit-origin-policy.test.ts | 9 ++ .../bug-reports/host-submit-origin-policy.ts | 1 + 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts index b895d0538..ae70a6a3a 100644 --- a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts +++ b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts @@ -363,6 +363,92 @@ describe('authenticated host submit bridge', () => { expect(submit).toHaveBeenCalledTimes(1) }) + it('does not submit through a same-tuple replacement binding after fresh authorization begins', async () => { + const harness = createWindowHarness() + const pendingFreshAuthorization = deferred<{ allowed: boolean }>() + const authorize = vi + .fn() + .mockResolvedValueOnce({ allowed: true }) + .mockReturnValueOnce(pendingFreshAuthorization.promise) + .mockResolvedValueOnce({ allowed: true }) + const submit = vi.fn().mockResolvedValue({ accepted: true, receipt: RECEIPT }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + const original = parentBinding.current() + expect(original).not.toBeNull() + + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + expect(authorize).toHaveBeenCalledTimes(2) + + parentBinding.clear() + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + const replacement = parentBinding.current() + expect(replacement).not.toBe(original) + expect(replacement).toEqual(original) + + pendingFreshAuthorization.resolve({ allowed: true }) + await flushAsyncWork() + + expect(authorize).toHaveBeenCalledTimes(3) + expect(submit).not.toHaveBeenCalled() + expect(harness.parent.postMessage).not.toHaveBeenCalled() + }) + + it('suppresses a stale reply when provider submit outlives a same-tuple replacement binding', async () => { + const harness = createWindowHarness() + const pendingSubmit = deferred<{ accepted: true; receipt: typeof RECEIPT }>() + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const submit = vi.fn().mockReturnValue(pendingSubmit.promise) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + const original = parentBinding.current() + expect(original).not.toBeNull() + + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + expect(submit).toHaveBeenCalledTimes(1) + + parentBinding.clear() + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + const replacement = parentBinding.current() + expect(replacement).not.toBe(original) + expect(replacement).toEqual(original) + + pendingSubmit.resolve({ accepted: true, receipt: RECEIPT }) + await flushAsyncWork() + + expect(harness.parent.postMessage).not.toHaveBeenCalled() + }) + it('cannot rebind a generation to another allowlisted origin on the same WindowProxy', async () => { const harness = createWindowHarness() const authorize = vi.fn().mockResolvedValue({ allowed: true }) diff --git a/apps/web/src/lib/client/bug-report-host-submit.ts b/apps/web/src/lib/client/bug-report-host-submit.ts index da7a3fd36..6fdfc218a 100644 --- a/apps/web/src/lib/client/bug-report-host-submit.ts +++ b/apps/web/src/lib/client/bug-report-host-submit.ts @@ -271,16 +271,10 @@ function resultMessage( function bindingStillCurrent( target: Window, currentBinding: () => BugReportHostParentBinding | null, - copied: BugReportHostParentBinding + captured: BugReportHostParentBinding ): boolean { const current = currentBinding() - return ( - current !== null && - current.generation === copied.generation && - current.origin === copied.origin && - current.source === copied.source && - target.parent === copied.source - ) + return current === captured && target.parent === captured.source } export function installBugReportHostSubmitBridge(options: { @@ -317,20 +311,20 @@ export function installBugReportHostSubmitBridge(options: { ) { return } - const copied = Object.freeze({ ...binding }) - if (!(await authorizeWithin(options.authorizeOrigin, copied.origin, timeoutMs))) return - if (disposed || !bindingStillCurrent(target, options.currentBinding, copied)) return + const captured = binding + if (!(await authorizeWithin(options.authorizeOrigin, captured.origin, timeoutMs))) return + if (disposed || !bindingStillCurrent(target, options.currentBinding, captured)) return const request = parseHostSubmitRequestMessage(event.data) if (!request) { const correlation = parseHostSubmitRequestCorrelation(event.data) if (!correlation) return - copied.source.postMessage( + captured.source.postMessage( resultMessage(correlation.requestId, { accepted: false, reason: 'invalid_request', }), - copied.origin + captured.origin ) return } @@ -341,8 +335,8 @@ export function installBugReportHostSubmitBridge(options: { } catch { result = { accepted: false, reason: 'retryable_failure' } } - if (disposed || !bindingStillCurrent(target, options.currentBinding, copied)) return - copied.source.postMessage(resultMessage(request.requestId, result), copied.origin) + if (disposed || !bindingStillCurrent(target, options.currentBinding, captured)) return + captured.source.postMessage(resultMessage(request.requestId, result), captured.origin) } target.addEventListener('message', handleMessage) diff --git a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts index 2938abe5f..3fd1ab5b5 100644 --- a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts @@ -72,6 +72,15 @@ describe('bug report host submit origin policy', () => { expect(parseBugReportHostSubmitOrigins(`https://allowed.example,${candidate}`)).toEqual([]) }) + it.each([ + ['trailing backslash', 'https://allowed.example\\'], + ['embedded backslash', 'https://allowed\\example'], + ])('rejects %s before URL canonicalization and poisons a mixed list', (_name, candidate) => { + expect(normalizeBugReportHostOrigin(candidate)).toBeNull() + expect(parseBugReportHostSubmitOrigins(candidate)).toEqual([]) + expect(parseBugReportHostSubmitOrigins(`https://safe.example,${candidate}`)).toEqual([]) + }) + it('rejects opaque and ambiguous candidate origins', () => { expect(normalizeBugReportHostOrigin('null')).toBeNull() expect(normalizeBugReportHostOrigin('https://a.example, https://b.example')).toBeNull() diff --git a/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts b/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts index fa4486ce6..ed96de0e6 100644 --- a/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts @@ -29,6 +29,7 @@ export function normalizeBugReportHostOrigin( !value || value.includes(',') || value.includes('*') || + value.includes('\\') || PERCENT_ENCODING_PATTERN.test(value) || WHITESPACE_PATTERN.test(value) || !ORIGIN_PATTERN.test(value) From d3428990dc94711e6e3d84c62f682066afebecd2 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 04:46:12 +0700 Subject: [PATCH 11/21] fix(widget): defer prepared init identity until activation Keep prepared transport anonymous until explicit open, reportBug, or launcher intent, and require a fresh identity acknowledgement after promotion. Fail closed across replacement and lifecycle resets. --- .../src/core/__tests__/sdk-capture.test.ts | 193 ++++++++++++++++++ packages/widget/src/core/sdk.ts | 26 ++- 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/packages/widget/src/core/__tests__/sdk-capture.test.ts b/packages/widget/src/core/__tests__/sdk-capture.test.ts index dc403f6b5..dd5d74163 100644 --- a/packages/widget/src/core/__tests__/sdk-capture.test.ts +++ b/packages/widget/src/core/__tests__/sdk-capture.test.ts @@ -34,6 +34,12 @@ function stubIframe() { return postMessage } +function identifyPayloads(post = iframePost): unknown[] { + return post.mock.calls + .filter(([message]) => (message as { type?: unknown }).type === 'quackback:identify') + .map(([message]) => (message as { data?: unknown }).data) +} + function fireWidgetReady() { window.dispatchEvent( new MessageEvent('message', { @@ -288,6 +294,193 @@ describe('sdk — bug-report capture wiring', () => { ).toHaveLength(1) }) + it('never publishes a supplied init identity while the prepared transport remains hidden', async () => { + mockConfig(true, true) + const post = stubIframe() + const sdk = createSDK() + const initIdentity = { + id: 'actor_init', + email: 'init-private@example.test', + name: 'Init Actor', + } + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + identity: initIdentity, + }) + await flush() + fireWidgetReady() + + expect(identifyPayloads(post)).toEqual([{ anonymous: true }]) + expect(JSON.stringify(post.mock.calls)).not.toContain(initIdentity.email) + }) + + it.each(['open', 'reportBug'] as const)( + 'promotes a supplied prepared init identity exactly once on explicit %s activation', + async (command) => { + mockConfig(true, true) + const post = stubIframe() + const sdk = createSDK() + const initIdentity = { + id: 'actor_init', + email: 'init-private@example.test', + name: 'Init Actor', + } + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + identity: initIdentity, + }) + await flush() + fireReady() + post.mockClear() + + sdk.dispatch(command) + expect(identifyPayloads(post)).toEqual([initIdentity]) + await expect(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)).resolves.toEqual({ + accepted: false, + reason: 'unavailable', + }) + + sdk.dispatch(command) + expect(identifyPayloads(post)).toEqual([initIdentity]) + } + ) + + it('promotes a supplied prepared init identity on explicit launcher activation', async () => { + mockConfig(true, true) + const post = stubIframe() + const beforeActivate = vi.fn(async () => false) + const sdk = createSDK() + const initIdentity = { + id: 'actor_init', + email: 'init-private@example.test', + name: 'Init Actor', + } + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true, beforeActivate }, + prepareHostReportSubmit: true, + identity: initIdentity, + }) + await flush() + fireReady() + post.mockClear() + + ;(document.querySelector('.quackback-launcher') as HTMLButtonElement).click() + await flush() + + expect(beforeActivate).toHaveBeenCalledOnce() + expect(identifyPayloads(post)).toEqual([initIdentity]) + }) + + it('lets explicit identify supersede a pending prepared init identity without later replay', async () => { + mockConfig(true, true) + const post = stubIframe() + const sdk = createSDK() + const initIdentity = { + id: 'actor_init', + email: 'init-private@example.test', + name: 'Init Actor', + } + const explicitIdentity = { + id: 'actor_explicit', + email: 'explicit@example.test', + name: 'Explicit Actor', + } + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + identity: initIdentity, + }) + await flush() + fireReady() + post.mockClear() + + sdk.dispatch('identify', explicitIdentity) + expect(identifyPayloads(post)).toEqual([explicitIdentity]) + fireIdentifyResult({ + id: explicitIdentity.id, + name: explicitIdentity.name, + email: explicitIdentity.email, + }) + post.mockClear() + + sdk.dispatch('open') + sdk.dispatch('reportBug') + expect(identifyPayloads(post)).toEqual([]) + expect(JSON.stringify(post.mock.calls)).not.toContain(initIdentity.email) + }) + + it('replays only anonymous identity when an unactivated prepared iframe is replaced', async () => { + mockConfig(true, true) + const post = stubIframe() + const sdk = createSDK() + const initIdentity = { + id: 'actor_init', + email: 'init-private@example.test', + name: 'Init Actor', + } + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + identity: initIdentity, + }) + await flush() + fireReady() + post.mockClear() + + const oldIframe = document.querySelector('.quackback-widget-iframe') as HTMLIFrameElement + const replacement = document.createElement('iframe') + replacement.className = 'quackback-widget-iframe' + oldIframe.replaceWith(replacement) + await flush() + fireWidgetReady() + + expect(identifyPayloads(post)).toEqual([{ anonymous: true }]) + expect(JSON.stringify(post.mock.calls)).not.toContain(initIdentity.email) + }) + + it('drops every unpromoted prepared init identity across logout, re-init, and destroy', async () => { + mockConfig(true, true) + const post = stubIframe() + const sdk = createSDK() + const firstIdentity = { + id: 'actor_first', + email: 'first-private@example.test', + name: 'First Actor', + } + const secondIdentity = { + id: 'actor_second', + email: 'second-private@example.test', + name: 'Second Actor', + } + const prepared = (identity: typeof firstIdentity) => ({ + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true as const }, + prepareHostReportSubmit: true, + identity, + }) + + sdk.dispatch('init', prepared(firstIdentity)) + sdk.dispatch('logout') + sdk.dispatch('init', prepared(secondIdentity)) + await flush() + fireWidgetReady() + expect(identifyPayloads(post)).toEqual([{ anonymous: true }]) + expect(JSON.stringify(post.mock.calls)).not.toContain(firstIdentity.email) + expect(JSON.stringify(post.mock.calls)).not.toContain(secondIdentity.email) + + sdk.dispatch('destroy') + post.mockClear() + fireWidgetReady() + expect(post).not.toHaveBeenCalled() + }) + it('rejects prepared-only lifecycle and capture traffic until explicit user intent', async () => { mockConfig(true, true) const post = stubIframe() diff --git a/packages/widget/src/core/sdk.ts b/packages/widget/src/core/sdk.ts index 30db30ad3..cad87a186 100644 --- a/packages/widget/src/core/sdk.ts +++ b/packages/widget/src/core/sdk.ts @@ -157,6 +157,9 @@ export function createSDK(): SDK { let widgetTransportStarted = false let interactiveCaptureAuthorized = false let deferredIdentity: Identity | typeof ANONYMOUS_IDENTITY = ANONYMOUS_IDENTITY + // A prepared deferred transport must remain anonymous until the user + // explicitly activates it. Keep init identity host-side only until then. + let pendingPreparedIdentity: Identity | null = null let launcherActivationInFlight = false let launcherActivationAbort: AbortController | null = null let pendingCaptureResult: (CaptureResultData & { flowId: string }) | null = null @@ -253,7 +256,9 @@ export function createSDK(): SDK { hostSubmitV7Eligible = false if (revokeExactReadiness) emitter.emit('ready', GENERIC_READY) if (config && widgetTransportStarted) { - pendingIdentify = deferredIdentity + // Replacing a still-hidden prepared iframe must not smuggle its retained + // init identity across the transport boundary. + pendingIdentify = pendingPreparedIdentity ? ANONYMOUS_IDENTITY : deferredIdentity pendingIdentifyPresent = true } removeIframeLoad?.() @@ -767,6 +772,15 @@ export function createSDK(): SDK { sendIdentity(explicitIdentity ? identity : deferredIdentity) } + function promotePreparedIdentity(): void { + if (!pendingPreparedIdentity) return + const identity = pendingPreparedIdentity + pendingPreparedIdentity = null + // Promotion starts a new actor generation and therefore requires a fresh + // iframe acknowledgement before host submission can become available. + sendIdentity(identity) + } + function sendIdentity(data: unknown) { // An SDK identity command changes the actor generation. Tear down the // report synchronously before the iframe starts the identify request so A's @@ -839,6 +853,7 @@ export function createSDK(): SDK { try { authorizeInteractiveCapture() ensureWidgetTransport() + promotePreparedIdentity() if (launcherOptions?.beforeActivate) { // Once a host preflight exists, only its explicit valid return may // navigate. Undefined/malformed JavaScript results fail closed too. @@ -990,7 +1005,9 @@ export function createSDK(): SDK { createLauncherIfNeeded() const deferredLauncher = launcherConfig()?.deferWidgetUntilActivate === true const shouldPrepare = next.prepareHostReportSubmit === true && deferredLauncher - if (!deferredLauncher || shouldPrepare) ensureWidgetTransport() + pendingPreparedIdentity = shouldPrepare ? (config.identity ?? null) : null + if (!deferredLauncher) ensureWidgetTransport() + else if (shouldPrepare) ensureWidgetTransport(ANONYMOUS_IDENTITY, true) if (!deferredLauncher) authorizeInteractiveCapture() const reveal = revealLauncherOnce() const fallback = window.setTimeout(reveal, LAUNCHER_REVEAL_FALLBACK_MS) @@ -1019,6 +1036,7 @@ export function createSDK(): SDK { } case 'identify': { const nextIdentity = (a as Identity | undefined) ?? ANONYMOUS_IDENTITY + pendingPreparedIdentity = null deferredIdentity = nextIdentity if (!widgetTransportStarted && launcherConfig()?.deferWidgetUntilActivate === true) { currentUser = null @@ -1030,6 +1048,7 @@ export function createSDK(): SDK { return } case 'logout': + pendingPreparedIdentity = null deferredIdentity = ANONYMOUS_IDENTITY currentUser = null identityResolved = false @@ -1048,6 +1067,7 @@ export function createSDK(): SDK { case 'open': { authorizeInteractiveCapture() ensureWidgetTransport() + promotePreparedIdentity() const opts = (a as OpenOptions) ?? {} const view = (opts as { view?: string }).view let wireOpts: unknown = opts @@ -1118,6 +1138,7 @@ export function createSDK(): SDK { if (!config) return authorizeInteractiveCapture() ensureWidgetTransport() + promotePreparedIdentity() const requestedEntrypoint = (a as { entrypoint?: unknown } | undefined)?.entrypoint === 'shortcut' ? 'shortcut' @@ -1175,6 +1196,7 @@ export function createSDK(): SDK { widgetTransportStarted = false interactiveCaptureAuthorized = false deferredIdentity = ANONYMOUS_IDENTITY + pendingPreparedIdentity = null launcherActivationInFlight = false config = null return From 4de3b4e06ade163215b48777f14eeb65983c3db1 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 04:50:15 +0700 Subject: [PATCH 12/21] fix(widget): align host submit contract boundaries Reject non-canonical whitespace on both sides of the adapter, preserve valid empty release strings, normalize safe titles, and scrub pre-live queue references even when replay throws. --- .../__tests__/bug-report-host-submit.test.ts | 4 +- .../__tests__/host-submit-contract.test.ts | 27 +++++++-- .../shared/bugreport/host-submit-contract.ts | 8 +-- .../src/__tests__/browser-queue.test.ts | 56 ++++++++++++++++--- packages/widget/src/browser-queue.ts | 23 +++++--- .../src/core/__tests__/report-submit.test.ts | 32 ++++++++++- packages/widget/src/core/report-submit.ts | 8 +-- 7 files changed, 126 insertions(+), 32 deletions(-) diff --git a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts index ae70a6a3a..d181f9b8a 100644 --- a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts +++ b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts @@ -23,8 +23,8 @@ function validRequest(overrides: Record = {}) { contract: CONTRACT, requestId: REQUEST_ID, clientSubmissionId: SUBMISSION_ID, - summary: ' Save button\n\ndoes nothing ', - impact: ' Cannot finish checkout ', + summary: 'Save button\n\ndoes nothing', + impact: 'Cannot finish checkout', ...overrides, }, } diff --git a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts index e4d4e3e1a..effc5b923 100644 --- a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts +++ b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts @@ -77,6 +77,18 @@ describe('host submit request contract', () => { }) }) + it('requires summary and impact to be exact already-trimmed strings', () => { + for (const value of [ + request({ summary: ' Save button does nothing' }), + request({ summary: 'Save button does nothing ' }), + request({ impact: ' Cannot finish checkout' }), + request({ impact: 'Cannot finish checkout ' }), + request({ impact: ' ' }), + ]) { + expect(parseHostSubmitRequestMessage(value)).toBeNull() + } + }) + it('rejects arrays, inherited records, symbols, non-enumerable fields, and accessors', () => { const inherited = Object.create(request()) const symbolKey = request() as Record @@ -151,6 +163,10 @@ describe('host submit result and receipt contract', () => { ...RECEIPT, fixedInRelease: '2026.07.28', }) + expect(parseHostSubmitReceipt({ ...RECEIPT, fixedInRelease: '' })).toEqual({ + ...RECEIPT, + fixedInRelease: '', + }) }) it('rejects invalid report refs, timestamps, chronology, and raw or unknown fields', () => { @@ -205,25 +221,26 @@ describe('host submit text mapping', () => { expect( mapHostSubmitText({ clientSubmissionId: SUBMISSION_ID, - summary: ' Save button\n\ndoes nothing ', + summary: ' Save \t button\n\ndoes nothing ', impact: ' Cannot finish checkout ', }) ).toEqual({ clientSubmissionId: SUBMISSION_ID, title: 'Save button', - content: 'Save button\n\ndoes nothing\n\nImpact:\nCannot finish checkout', + content: 'Save \t button\n\ndoes nothing\n\nImpact:\nCannot finish checkout', }) }) - it('surrogate-safely truncates only the first logical line used as the title', () => { - const summary = `${'a'.repeat(199)}😀 continues\nsecond line` + it('collapses title whitespace before surrogate-safe first-line truncation', () => { + const summary = `${'a'.repeat(198)} \t 😀 continues\nsecond line` const mapped = mapHostSubmitText({ clientSubmissionId: SUBMISSION_ID, summary, impact: 'Checkout is blocked', }) - expect(mapped.title.length).toBeLessThanOrEqual(200) + expect(mapped.title).toBe(`${'a'.repeat(198)} `) + expect(mapped.title.length).toBe(199) expect(mapped.title.endsWith('\ud83d')).toBe(false) expect(mapped.content).toBe(`${summary}\n\nImpact:\nCheckout is blocked`) }) diff --git a/apps/web/src/lib/shared/bugreport/host-submit-contract.ts b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts index d1b914981..4361ec086 100644 --- a/apps/web/src/lib/shared/bugreport/host-submit-contract.ts +++ b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts @@ -134,7 +134,8 @@ function parseHostSubmitInput(value: unknown): SubmitBugReportInputV1 | null { !isBoundedText(record.summary, 2_000) || typeof record.impact !== 'string' || record.impact.length > 1_000 || - record.summary.trim().length === 0 + record.summary !== record.summary.trim() || + record.impact !== record.impact.trim() ) { return null } @@ -205,8 +206,7 @@ export function parseHostSubmitReceipt(value: unknown): HostSubmitReceiptV1 | nu !isIsoTimestamp(record.createdAt) || !isIsoTimestamp(record.updatedAt) || Date.parse(record.updatedAt) < Date.parse(record.createdAt) || - ('fixedInRelease' in record && - (typeof record.fixedInRelease !== 'string' || record.fixedInRelease.length === 0)) + ('fixedInRelease' in record && typeof record.fixedInRelease !== 'string') ) { return null } @@ -288,7 +288,7 @@ export function mapHostSubmitText(input: SubmitBugReportInputV1): { } { const summary = normalizeText(input.summary) const impact = normalizeText(input.impact) - const title = truncateTitle(summary.split('\n', 1)[0].trim()) + const title = truncateTitle(summary.split('\n', 1)[0].trim().replace(/\s+/gu, ' ')) return { clientSubmissionId: input.clientSubmissionId, title, diff --git a/packages/widget/src/__tests__/browser-queue.test.ts b/packages/widget/src/__tests__/browser-queue.test.ts index 6139da003..ef2f45981 100644 --- a/packages/widget/src/__tests__/browser-queue.test.ts +++ b/packages/widget/src/__tests__/browser-queue.test.ts @@ -22,6 +22,14 @@ function makeArguments(...values: unknown[]): IArguments { })(...values) } +function captureQueueCopy(source: IArguments[]): IArguments[] { + const copied = [...source] + const arrayFrom = Array.from + vi.spyOn(Array, 'from').mockImplementation(((value: ArrayLike | Iterable) => + value === source ? copied : arrayFrom(value)) as typeof Array.from) + return copied +} + describe('script-tag browser queue', () => { beforeEach(() => { vi.resetModules() @@ -37,19 +45,21 @@ describe('script-tag browser queue', () => { }) it('discards inherited submit calls with private text but returns the live SDK promise', async () => { + const sourceQueue = [ + makeArguments('submitBugReport', { + clientSubmissionId: SUBMISSION_ID, + summary: 'canary-private-queued-summary', + impact: '', + }), + makeArguments('metadata', { route: '/checkout' }), + ] + const copiedQueue = captureQueueCopy(sourceQueue) window.Quackback = Object.assign( function queuedCall() { return undefined }, { - q: [ - makeArguments('submitBugReport', { - clientSubmissionId: SUBMISSION_ID, - summary: 'canary-private-queued-summary', - impact: '', - }), - makeArguments('metadata', { route: '/checkout' }), - ], + q: sourceQueue, } ) const liveResult = Promise.resolve({ accepted: false, reason: 'unavailable' }) @@ -62,6 +72,7 @@ describe('script-tag browser queue', () => { expect(dispatch).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledWith('metadata', { route: '/checkout' }, undefined) expect(JSON.stringify(dispatch.mock.calls)).not.toContain('canary-private-queued-summary') + expect(copiedQueue).toEqual([]) const returned = window.Quackback?.( 'submitBugReport', @@ -75,4 +86,33 @@ describe('script-tag browser queue', () => { expect(returned).toBe(liveResult) await expect(returned).resolves.toEqual({ accepted: false, reason: 'unavailable' }) }) + + it('clears every copied queue reference even when replay dispatch throws', async () => { + const sourceQueue = [ + makeArguments('metadata', { route: '/checkout' }), + makeArguments('submitBugReport', { + clientSubmissionId: SUBMISSION_ID, + summary: 'canary-private-never-replayed-summary', + impact: '', + }), + ] + const copiedQueue = captureQueueCopy(sourceQueue) + window.Quackback = Object.assign( + function queuedCall() { + return undefined + }, + { q: sourceQueue } + ) + dispatch.mockImplementation(() => { + throw new Error('queued dispatch failed') + }) + + await expect(import('../browser-queue')).rejects.toThrow('queued dispatch failed') + + expect(copiedQueue).toEqual([]) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(JSON.stringify(dispatch.mock.calls)).not.toContain( + 'canary-private-never-replayed-summary' + ) + }) }) diff --git a/packages/widget/src/browser-queue.ts b/packages/widget/src/browser-queue.ts index 525033a5f..64f2fe35a 100644 --- a/packages/widget/src/browser-queue.ts +++ b/packages/widget/src/browser-queue.ts @@ -46,14 +46,21 @@ w.Quackback = function (...args: unknown[]) { return dispatch(args[0], args[1], args[2]) } -// Replay any queued commands. -for (const args of queued) { - const a = args as unknown as unknown[] - // A pre-live queue is ambient page state and can outlive the caller that - // supplied private report text. Never inherit/replay that protected command; - // callers receive a Promise only from the installed live dispatcher. - if (a[0] === 'submitBugReport') continue - dispatch(a[0], a[1], a[2]) +// Replay queued commands while releasing each captured IArguments reference +// before dispatch. The outer finally scrubs anything left if a host command +// throws, so private pre-live report text cannot remain retained by this module. +try { + while (queued.length > 0) { + const args = queued.shift()! + const a = args as unknown as unknown[] + // A pre-live queue is ambient page state and can outlive the caller that + // supplied private report text. Never inherit/replay that protected command; + // callers receive a Promise only from the installed live dispatcher. + if (a[0] === 'submitBugReport') continue + dispatch(a[0], a[1], a[2]) + } +} finally { + queued.length = 0 } // Deferred so an explicit `Quackback("init", ...)` from host code can pre-empt diff --git a/packages/widget/src/core/__tests__/report-submit.test.ts b/packages/widget/src/core/__tests__/report-submit.test.ts index 0932f19ff..fa4ab4644 100644 --- a/packages/widget/src/core/__tests__/report-submit.test.ts +++ b/packages/widget/src/core/__tests__/report-submit.test.ts @@ -126,6 +126,31 @@ describe('public host report submit input and context', () => { summary: ' ', impact: '', }, + { + clientSubmissionId: SUBMISSION_ID, + summary: ' x', + impact: '', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: 'x ', + impact: '', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: 'x', + impact: ' impact', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: 'x', + impact: 'impact ', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: 'x', + impact: ' ', + }, { clientSubmissionId: SUBMISSION_ID, summary: 's'.repeat(contract.inputLimits.summaryCodeUnits + 1), @@ -208,6 +233,12 @@ describe('public host report submit result parser', () => { accepted: true, receipt: { ...RECEIPT, fixedInRelease: '2026.07.28' }, }) + expect( + parseHostSubmitResultForRequest(success({ ...RECEIPT, fixedInRelease: '' }), REQUEST_ID) + ).toEqual({ + accepted: true, + receipt: { ...RECEIPT, fixedInRelease: '' }, + }) }) it('returns only the exact public failure keys for every canonical reason', () => { @@ -235,7 +266,6 @@ describe('public host report submit result parser', () => { success({ ...RECEIPT, status: 'open' }), success({ ...RECEIPT, createdAt: 'not-a-timestamp' }), success({ ...RECEIPT, updatedAt: '2026-07-28T00:59:59.999Z' }), - success({ ...RECEIPT, fixedInRelease: '' }), failure('raw-provider-error'), ]) { expect(parseHostSubmitResultForRequest(value, REQUEST_ID)).toBeNull() diff --git a/packages/widget/src/core/report-submit.ts b/packages/widget/src/core/report-submit.ts index 700a7fffb..f4f377804 100644 --- a/packages/widget/src/core/report-submit.ts +++ b/packages/widget/src/core/report-submit.ts @@ -102,8 +102,7 @@ function parseReceipt(value: unknown): BugReportReceiptV1 | null { !isIsoTimestamp(record.createdAt) || !isIsoTimestamp(record.updatedAt) || Date.parse(record.updatedAt) < Date.parse(record.createdAt) || - (hasFixedInRelease && - (typeof record.fixedInRelease !== 'string' || record.fixedInRelease.length === 0)) + (hasFixedInRelease && typeof record.fixedInRelease !== 'string') ) { return null } @@ -126,9 +125,10 @@ export function parseSubmitBugReportInput(value: unknown): SubmitBugReportInputV typeof record.summary !== 'string' || record.summary.length === 0 || record.summary.length > 2_000 || - record.summary.trim().length === 0 || + record.summary !== record.summary.trim() || typeof record.impact !== 'string' || - record.impact.length > 1_000 + record.impact.length > 1_000 || + record.impact !== record.impact.trim() ) { return null } From 025471cf2e00d187ea9fd980ba4c92935d39ad47 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 06:04:01 +0700 Subject: [PATCH 13/21] quackback: correlate widget identity attempts Bind identity acknowledgements to transport generations so stale provider responses cannot authorize host submissions. Retire provider sessions at correlated actor boundaries and keep writes fail-closed until the current identity settles. --- .../__tests__/identify-precedence.test.ts | 10 + ...idget-auth-provider-identity-race.test.tsx | 713 ++++++++++++++++++ .../components/widget/identify-precedence.ts | 10 +- .../widget/widget-auth-provider.tsx | 377 +++++++-- apps/web/src/lib/shared/widget/types.ts | 9 + packages/widget/__tests__/postmessage.test.ts | 8 +- packages/widget/__tests__/sdk.test.ts | 29 +- .../src/core/__tests__/postmessage.test.ts | 55 ++ .../src/core/__tests__/sdk-capture.test.ts | 288 ++++++- packages/widget/src/core/postmessage.ts | 49 +- packages/widget/src/core/sdk.ts | 293 +++++-- 11 files changed, 1718 insertions(+), 123 deletions(-) create mode 100644 apps/web/src/components/widget/__tests__/widget-auth-provider-identity-race.test.tsx diff --git a/apps/web/src/components/widget/__tests__/identify-precedence.test.ts b/apps/web/src/components/widget/__tests__/identify-precedence.test.ts index e05c0b02b..662723637 100644 --- a/apps/web/src/components/widget/__tests__/identify-precedence.test.ts +++ b/apps/web/src/components/widget/__tests__/identify-precedence.test.ts @@ -35,6 +35,16 @@ describe('resolveIdentifyAction', () => { expect(action).toBe('skip') }) + it('lets a correlated host-authoritative anonymous command override a portal session', () => { + const action = resolveIdentifyAction({ + identifyData: { anonymous: true }, + hasPortalSession: true, + sessionSource: 'portal', + hostAuthoritative: true, + }) + expect(action).toBe('authoritative-anonymous') + }) + it('skips anonymous identify when portal session is already hydrated', () => { const action = resolveIdentifyAction({ identifyData: { anonymous: true }, diff --git a/apps/web/src/components/widget/__tests__/widget-auth-provider-identity-race.test.tsx b/apps/web/src/components/widget/__tests__/widget-auth-provider-identity-race.test.tsx new file mode 100644 index 000000000..8e859020d --- /dev/null +++ b/apps/web/src/components/widget/__tests__/widget-auth-provider-identity-race.test.tsx @@ -0,0 +1,713 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installInMemoryLocalStorage } from '@/test/local-storage' +import { clearWidgetToken, getWidgetToken, persistAnonymousToken } from '@/lib/client/widget-auth' + +installInMemoryLocalStorage() + +vi.mock('@/lib/client/auth-client', () => ({ + authClient: { signIn: { anonymous: vi.fn().mockResolvedValue({ data: null, error: null }) } }, +})) +vi.mock('@/lib/server/functions/widget', () => ({ createWidgetIdentifyTokenFn: vi.fn() })) +vi.mock('@/lib/shared/i18n', async (orig) => ({ + ...(await orig()), + loadMessages: vi.fn().mockResolvedValue({}), +})) + +import { WidgetAuthProvider, useWidgetAuth } from '../widget-auth-provider' +import { createWidgetIdentifyTokenFn } from '@/lib/server/functions/widget' + +const HOST_ORIGIN = 'https://app.example.test' +const USER_A = { + id: 'actor_a', + name: 'Actor A', + email: 'a-private@example.test', + avatarUrl: null, +} +const USER_B = { + id: 'actor_b', + name: 'Actor B', + email: 'b-private@example.test', + avatarUrl: null, +} + +type Deferred = { + promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function response( + body: unknown, + options: { ok?: boolean; status?: number } = {} +): Pick { + return { + ok: options.ok ?? true, + status: options.status ?? 200, + json: async () => body, + } +} + +function UserProbe({ onWrite }: { onWrite?: () => void }) { + const { user, sessionVersion, identifyWithEmail, ensureSessionThen } = useWidgetAuth() + return ( + <> + + {user?.id ?? 'anonymous'} + + )} diff --git a/apps/web/src/components/widget/widget-changelog-detail.tsx b/apps/web/src/components/widget/widget-changelog-detail.tsx index c0332f51e..115bbf17f 100644 --- a/apps/web/src/components/widget/widget-changelog-detail.tsx +++ b/apps/web/src/components/widget/widget-changelog-detail.tsx @@ -8,7 +8,7 @@ import { EmbedHydration } from '@/components/shared/embed-hydration' import type { ChangelogId } from '@quackback/ids' import type { JSONContent } from '@tiptap/react' import { WidgetPortalTitle } from './widget-portal-title' -import { sendToHost } from '@/lib/client/widget-bridge' +import { useWidgetAuth } from './widget-auth-provider' function formatDate(iso: string) { return new Date(iso).toLocaleDateString('en-US', { @@ -24,13 +24,14 @@ interface WidgetChangelogDetailProps { export function WidgetChangelogDetail({ entryId }: WidgetChangelogDetailProps) { const { data: entry, isLoading } = useQuery(publicChangelogQueries.detail(entryId as ChangelogId)) + const { sendPrivilegedHostMessage } = useWidgetAuth() const changelogEntryId = entry?.id const handleViewOnPortal = useCallback(() => { if (!changelogEntryId) return const url = `${window.location.origin}/changelog/${changelogEntryId}` - sendToHost({ type: 'quackback:navigate', url }) - }, [changelogEntryId]) + sendPrivilegedHostMessage({ type: 'quackback:navigate', url }) + }, [changelogEntryId, sendPrivilegedHostMessage]) if (isLoading) { return ( diff --git a/apps/web/src/components/widget/widget-help-detail.tsx b/apps/web/src/components/widget/widget-help-detail.tsx index 45d1eeb80..3ed6bd286 100644 --- a/apps/web/src/components/widget/widget-help-detail.tsx +++ b/apps/web/src/components/widget/widget-help-detail.tsx @@ -6,7 +6,7 @@ import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-editor' import type { JSONContent } from '@tiptap/react' import { WidgetPortalTitle } from './widget-portal-title' -import { sendToHost } from '@/lib/client/widget-bridge' +import { useWidgetAuth } from './widget-auth-provider' interface WidgetHelpDetailProps { articleSlug: string @@ -14,12 +14,13 @@ interface WidgetHelpDetailProps { export function WidgetHelpDetail({ articleSlug }: WidgetHelpDetailProps) { const { data: article, isLoading } = useQuery(publicHelpCenterQueries.articleBySlug(articleSlug)) + const { sendPrivilegedHostMessage } = useWidgetAuth() const handleViewOnPortal = useCallback(() => { if (!article) return const url = `${window.location.origin}/hc/articles/${article.category.slug}/${article.slug}` - sendToHost({ type: 'quackback:navigate', url }) - }, [article]) + sendPrivilegedHostMessage({ type: 'quackback:navigate', url }) + }, [article, sendPrivilegedHostMessage]) if (isLoading) { return ( diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 7f9e73e64..3fc05b957 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -22,7 +22,6 @@ import { listPublicPostsFn } from '@/lib/server/functions/public-posts' import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll' import { WidgetVoteButton } from './widget-vote-button' import { useWidgetAuth } from './widget-auth-provider' -import { sendToHost } from '@/lib/client/widget-bridge' import type { PostId } from '@quackback/ids' import { RichTextEditor } from '@/components/ui/rich-text-editor' import { useWidgetImageUpload } from '@/lib/client/hooks/use-image-upload' @@ -193,6 +192,7 @@ export function WidgetHomeAnimated({ emitEvent, metadata, identifyWithEmail, + sendPrivilegedHostMessage, } = useWidgetAuth() const { upload: uploadImage } = useWidgetImageUpload() const inputRef = useRef(null) @@ -323,10 +323,13 @@ export function WidgetHomeAnimated({ if (!hmacRequired && onPostSelect) { onPostSelect(postId) } else { - sendToHost({ type: 'quackback:navigate', url: `${window.location.origin}/auth/login` }) + sendPrivilegedHostMessage({ + type: 'quackback:navigate', + url: `${window.location.origin}/auth/login`, + }) } }, - [hmacRequired, onPostSelect] + [hmacRequired, onPostSelect, sendPrivilegedHostMessage] ) // An identified viewer denied by the board's vote tier (segments/team) is an @@ -451,7 +454,10 @@ export function WidgetHomeAnimated({ } } else if (!canPost) { if (hmacRequired) { - sendToHost({ type: 'quackback:navigate', url: `${window.location.origin}/auth/login` }) + sendPrivilegedHostMessage({ + type: 'quackback:navigate', + url: `${window.location.origin}/auth/login`, + }) setIsSubmitting(false) return } diff --git a/apps/web/src/components/widget/widget-vote-button.tsx b/apps/web/src/components/widget/widget-vote-button.tsx index 4007183ac..a568c0ab2 100644 --- a/apps/web/src/components/widget/widget-vote-button.tsx +++ b/apps/web/src/components/widget/widget-vote-button.tsx @@ -32,11 +32,12 @@ export function WidgetVoteButton({ compact = false, }: WidgetVoteButtonProps) { const intl = useIntl() - const { sessionVersion } = useWidgetAuth() + const { sessionVersion, emitEvent } = useWidgetAuth() const { voteCount, hasVoted, isPending, handleVote } = useWidgetVote({ postId, voteCount: initialVoteCount, sessionVersion, + onVoteChanged: (event) => emitEvent('vote', event), }) const isHandlingRef = useRef(false) diff --git a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts index ea52ffc3e..47880da5f 100644 --- a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts +++ b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts @@ -1,5 +1,7 @@ import { readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodeHostSubmitReportDigestInput } from '@/lib/shared/bugreport/host-submit-contract' import { installBugReportHostParentBinding, installBugReportHostSubmitBridge, @@ -8,6 +10,10 @@ import { const REQUEST_ID = '11111111-1111-4111-8111-111111111111' const SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' const CONTRACT = 'iplaycafe.quackback.report-submit/1' +const PROVIDER_ORIGIN = 'https://feedback.example' +const HOST_ORIGIN = 'https://app.example' +const SUMMARY = 'Save button\n\ndoes nothing' +const IMPACT = 'Cannot finish checkout' const RECEIPT = { schemaVersion: 'BugReportReceiptV1', reportRef: 'qbr_abcdefghijklmnopqrstuvwx', @@ -16,6 +22,30 @@ const RECEIPT = { updatedAt: '2026-07-28T02:00:00.000Z', } as const +function assertion(overrides: Record = {}) { + const nowSeconds = Math.floor(Date.now() / 1000) + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') + const payload = Buffer.from( + JSON.stringify({ + purpose: 'iplaycafe.quackback.host-submit/1', + aud: PROVIDER_ORIGIN, + sub: 'host-actor-1', + hostOrigin: HOST_ORIGIN, + contract: CONTRACT, + reportDigest: createHash('sha256') + .update(encodeHostSubmitReportDigestInput(SUMMARY, IMPACT)) + .digest('hex'), + clientSubmissionId: SUBMISSION_ID, + requestId: REQUEST_ID, + jti: 'host-submit-jti-00000001', + iat: nowSeconds, + exp: nowSeconds + 30, + ...overrides, + }) + ).toString('base64url') + return `${header}.${payload}.${'s'.repeat(43)}` +} + function validRequest(overrides: Record = {}) { return { type: 'quackback:report-submit', @@ -23,8 +53,9 @@ function validRequest(overrides: Record = {}) { contract: CONTRACT, requestId: REQUEST_ID, clientSubmissionId: SUBMISSION_ID, - summary: 'Save button\n\ndoes nothing', - impact: 'Cannot finish checkout', + summary: SUMMARY, + impact: IMPACT, + hostSubmitAssertion: assertion(), ...overrides, }, } @@ -56,6 +87,7 @@ function createWindowHarness() { const parent = { postMessage: vi.fn() } const target = { parent, + location: { origin: PROVIDER_ORIGIN }, addEventListener: vi.fn((type: string, listener: (event: MessageEvent) => void) => { if (type === 'message') listeners.add(listener) }), @@ -87,6 +119,16 @@ async function flushAsyncWork() { beforeEach(() => { vi.useRealTimers() + vi.spyOn(globalThis.crypto.subtle, 'digest').mockImplementation(async (_algorithm, data) => { + const bytes = ArrayBuffer.isView(data) + ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) + : Buffer.from(data) + const digest = createHash('sha256').update(bytes).digest() + return digest.buffer.slice( + digest.byteOffset, + digest.byteOffset + digest.byteLength + ) as ArrayBuffer + }) }) afterEach(() => { @@ -122,9 +164,13 @@ describe('authenticated host submit bridge', () => { await flushAsyncWork() expect(submit).toHaveBeenCalledWith({ + contract: CONTRACT, + requestId: REQUEST_ID, clientSubmissionId: SUBMISSION_ID, - title: 'Save button', - content: 'Save button\n\ndoes nothing\n\nImpact:\nCannot finish checkout', + summary: SUMMARY, + impact: IMPACT, + hostOrigin: HOST_ORIGIN, + hostSubmitAssertion: expect.stringMatching(/^[^.]+\.[^.]+\.[A-Za-z0-9_-]{43}$/), }) const validResult = expectedResult({ accepted: true, receipt: RECEIPT }) expect(harness.parent.postMessage).toHaveBeenCalledWith(validResult, 'https://app.example') @@ -135,6 +181,38 @@ describe('authenticated host submit bridge', () => { generation += 1 }) + it.each([ + ['summary', { summary: 'Save button sometimes works' }], + ['impact', { impact: 'Checkout is slow' }], + ])('rejects a post-signing %s mutation before calling the server', async (_name, mutation) => { + const harness = createWindowHarness() + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const submit = vi.fn() + const binding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => binding.current(), + resolveBinding: (source, origin) => binding.resolve(source, origin), + submit, + target: harness.target, + }) + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + + harness.dispatch({ data: validRequest(mutation) }) + await flushAsyncWork() + + expect(submit).not.toHaveBeenCalled() + expect(harness.parent.postMessage).toHaveBeenLastCalledWith( + expectedResult({ accepted: false, reason: 'invalid_request' }), + HOST_ORIGIN + ) + }) + it('waits for the exact pending identify authorization before handling a concurrent report', async () => { const harness = createWindowHarness() const pendingIdentifyAuthorization = deferred<{ allowed: boolean }>() diff --git a/apps/web/src/lib/client/__tests__/widget-bridge.test.ts b/apps/web/src/lib/client/__tests__/widget-bridge.test.ts index f1aeba098..444f17ba9 100644 --- a/apps/web/src/lib/client/__tests__/widget-bridge.test.ts +++ b/apps/web/src/lib/client/__tests__/widget-bridge.test.ts @@ -93,15 +93,43 @@ describe('widget-bridge', () => { expect(dispatch.mock.calls[0][1]).toEqual(msg) }) - it('passes full message object to postMessage unchanged', async () => { - const { sendToHost } = await import('../widget-bridge') - const msg = { + it.each([ + { type: 'quackback:identify-result', success: true, - user: { id: 'u1', name: 'Test' }, - } - sendToHost(msg) - expect(window.parent.postMessage).toHaveBeenCalledWith(msg, '*') + user: { id: 'u1', email: 'private@example.com' }, + token: 'identity-token', + }, + { + type: 'quackback:event', + name: 'bug-report:submitted', + payload: { title: 'private title', content: 'private report', postId: 'post_internal' }, + }, + { + type: 'quackback:navigate', + url: 'https://feedback.example.test/post/post_internal?ott=secret', + }, + { type: 'quackback:capture-request', flowId: 'flow_internal' }, + { type: 'quackback:bug-report-submit-started', flowId: 'flow_internal' }, + { type: 'quackback:layout', flowId: 'flow_internal', height: 640 }, + { + type: 'quackback:bug-report-submit-request', + requestId: 'request_internal', + input: { title: 'private title', content: 'private report' }, + }, + ])('never broadcasts privileged or sensitive browser message $type', async (message) => { + const { sendToHost } = await import('../widget-bridge') + sendToHost(message) + expect(window.parent.postMessage).not.toHaveBeenCalled() + }) + + it.each([ + { type: 'quackback:ready', token: 'must-not-leak' }, + { type: 'quackback:close', reportRef: 'report_internal' }, + ])('rejects public control messages with smuggled fields', async (message) => { + const { sendToHost } = await import('../widget-bridge') + sendToHost(message) + expect(window.parent.postMessage).not.toHaveBeenCalled() }) }) diff --git a/apps/web/src/lib/client/bug-report-host-submit.ts b/apps/web/src/lib/client/bug-report-host-submit.ts index 021628f74..48ffd4d2c 100644 --- a/apps/web/src/lib/client/bug-report-host-submit.ts +++ b/apps/web/src/lib/client/bug-report-host-submit.ts @@ -1,5 +1,6 @@ import { - mapHostSubmitText, + computeHostSubmitReportDigest, + parseHostSubmitAssertionForProvider, parseHostSubmitReceipt, parseHostSubmitRequestCorrelation, parseHostSubmitRequestMessage, @@ -333,9 +334,13 @@ export function installBugReportHostSubmitBridge(options: { origin: string ): Promise submit(input: { + contract: typeof CONTRACT + requestId: string clientSubmissionId: string - title: string - content: string + summary: string + impact: string + hostOrigin: string + hostSubmitAssertion: string }): Promise target?: Window timeoutMs?: number @@ -376,10 +381,56 @@ export function installBugReportHostSubmitBridge(options: { ) return } + const providerOrigin = target.location?.origin + let reportDigest: string + try { + reportDigest = await computeHostSubmitReportDigest(request.summary, request.impact) + } catch { + captured.source.postMessage( + resultMessage(request.requestId, { + accepted: false, + reason: 'retryable_failure', + }), + captured.origin + ) + return + } + if (disposed || !bindingStillCurrent(target, options.currentBinding, captured)) return + if ( + typeof providerOrigin !== 'string' || + !parseHostSubmitAssertionForProvider(request.hostSubmitAssertion, { + audience: providerOrigin, + hostOrigin: captured.origin, + contract: CONTRACT, + reportDigest, + clientSubmissionId: request.clientSubmissionId, + requestId: request.requestId, + }) + ) { + captured.source.postMessage( + resultMessage(request.requestId, { + accepted: false, + reason: 'invalid_request', + }), + captured.origin + ) + return + } + if (disposed || !bindingStillCurrent(target, options.currentBinding, captured)) return let result: HostSubmitServerResult try { - result = normalizeServerResult(await options.submit(mapHostSubmitText(request))) + result = normalizeServerResult( + await options.submit({ + contract: CONTRACT, + requestId: request.requestId, + clientSubmissionId: request.clientSubmissionId, + summary: request.summary, + impact: request.impact, + hostOrigin: captured.origin, + hostSubmitAssertion: request.hostSubmitAssertion, + }) + ) } catch { result = { accepted: false, reason: 'retryable_failure' } } diff --git a/apps/web/src/lib/client/hooks/use-widget-vote.ts b/apps/web/src/lib/client/hooks/use-widget-vote.ts index b20f4330f..244aee806 100644 --- a/apps/web/src/lib/client/hooks/use-widget-vote.ts +++ b/apps/web/src/lib/client/hooks/use-widget-vote.ts @@ -10,7 +10,6 @@ import { useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toggleVoteFn, getVotedPostsFn } from '@/lib/server/functions/public-posts' import { getWidgetAuthHeaders, hasWidgetToken } from '@/lib/client/widget-auth' -import { sendToHost } from '@/lib/client/widget-bridge' import { voteCountKeys } from './use-post-vote' import type { PostId } from '@quackback/ids' @@ -35,6 +34,7 @@ interface UseWidgetVoteOptions { /** Session version from WidgetAuthProvider — triggers refetch after identify */ sessionVersion?: number enabled?: boolean + onVoteChanged?: (event: { postId: PostId; voted: boolean; voteCount: number }) => void } export function useWidgetVote({ @@ -42,6 +42,7 @@ export function useWidgetVote({ voteCount, sessionVersion = 0, enabled = true, + onVoteChanged, }: UseWidgetVoteOptions) { const queryClient = useQueryClient() // Ref tracks latest sessionVersion so mutation callbacks always write to the @@ -109,11 +110,7 @@ export function useWidgetVote({ else next.delete(id) return next }) - sendToHost({ - type: 'quackback:event', - name: 'vote', - payload: { postId: id, voted: data.voted, voteCount: data.voteCount }, - }) + onVoteChanged?.({ postId: id, voted: data.voted, voteCount: data.voteCount }) }, }) diff --git a/apps/web/src/lib/client/widget-bridge.ts b/apps/web/src/lib/client/widget-bridge.ts index 608c1a45b..3ddb7d78a 100644 --- a/apps/web/src/lib/client/widget-bridge.ts +++ b/apps/web/src/lib/client/widget-bridge.ts @@ -8,6 +8,20 @@ declare global { } } +function isPublicControlMessage(message: Record): boolean { + try { + const keys = Reflect.ownKeys(message) + if (keys.length !== 1 || keys[0] !== 'type') return false + + const typeDescriptor = Object.getOwnPropertyDescriptor(message, 'type') + if (!typeDescriptor || !('value' in typeDescriptor)) return false + + return typeDescriptor.value === 'quackback:ready' || typeDescriptor.value === 'quackback:close' + } catch { + return false + } +} + export function sendToHost(message: Record): void { if (window.__quackbackNative?.dispatch) { const rawType = typeof message.type === 'string' ? message.type : '' @@ -17,6 +31,13 @@ export function sendToHost(message: Record): void { window.__quackbackNative.dispatch(eventType, message) return } + + // Browser messages that contain identity, report, navigation, or internal + // correlation data must use the WidgetAuthProvider's frozen exact-origin + // channel. The wildcard channel is deliberately limited to metadata-free + // bootstrap controls needed before the host origin is authenticated. + if (!isPublicControlMessage(message)) return + window.parent.postMessage(message, '*') } diff --git a/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts b/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts index 17acf793e..6328d4378 100644 --- a/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts @@ -3,8 +3,12 @@ import { isTable } from 'drizzle-orm' import { getTableConfig } from 'drizzle-orm/pg-core' import * as schema from '@/lib/server/db' import { + ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS, ANONYMOUS_PRINCIPAL_FK_POLICY, ANONYMOUS_PRINCIPAL_MERGE_KEYS, + ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES, + ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE, + createAnonymousPrincipalFkExecutionTracker, } from '../anonymous-principal-fk-policy' import { EXECUTED_ANONYMOUS_PRINCIPAL_MERGE_KEYS, @@ -45,6 +49,32 @@ describe('anonymous principal FK merge inventory', () => { ) }) + it('binds every schema FK policy to a required executable merge stage', () => { + expect(Object.keys(ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS).sort()).toEqual( + schemaPrincipalForeignKeys() + ) + for (const [key, policy] of Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY)) { + expect( + ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS[ + key as keyof typeof ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS + ] + ).toBe(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE[policy]) + } + }) + + it('fails closed before source deletion when any bound handler stage was not executed', () => { + const tracker = createAnonymousPrincipalFkExecutionTracker() + for (const stage of ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES.slice(0, -1)) { + tracker.complete(stage) + } + expect(() => tracker.assertComplete()).toThrow( + 'Anonymous principal FK handler stage was not executed' + ) + + tracker.complete(ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES.at(-1)!) + expect(() => tracker.assertComplete()).not.toThrow() + }) + it('guards every target-only FK before source-principal deletion', () => { expect([...GUARDED_TARGET_ONLY_ANONYMOUS_PRINCIPAL_KEYS].sort()).toEqual( Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY) diff --git a/apps/web/src/lib/server/auth/__tests__/identify-merge.test.ts b/apps/web/src/lib/server/auth/__tests__/identify-merge.test.ts index 81133ad05..7c3c31fe4 100644 --- a/apps/web/src/lib/server/auth/__tests__/identify-merge.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/identify-merge.test.ts @@ -184,6 +184,10 @@ function commit(commitNow: Date | null = now) { return resolveAndMergeAnonymousToken({ previousToken: PREVIOUS_TOKEN, targetToken: TARGET_TOKEN, + targetActor: { + userId: targetSession.userId, + principalId: targetPrincipal.id, + }, ...(commitNow === null ? {} : { now: commitNow }), }) } @@ -471,6 +475,38 @@ describe('resolveAndMergeAnonymousToken — two-phase commit boundary', () => { expect(mocks.userLockForUpdate).not.toHaveBeenCalled() }) + it.each([ + [ + 'user', + { + userId: 'user_other' as UserId, + principalId: targetPrincipal.id, + }, + ], + [ + 'principal', + { + userId: targetSession.userId, + principalId: 'principal_other' as PrincipalId, + }, + ], + ])( + 'rejects a target token that no longer resolves to the stable %s actor', + async (_name, targetActor) => { + await expect( + resolveAndMergeAnonymousToken({ + previousToken: PREVIOUS_TOKEN, + targetToken: TARGET_TOKEN, + targetActor, + now, + }) + ).resolves.toEqual({ status: 'target_invalid' }) + + expect(mocks.sourceHintRead).not.toHaveBeenCalled() + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() + } + ) + it('rolls back and rejects when the atomic merge body fails, leaving retry ownership to the caller', async () => { const failure = new Error('database failure must not be serialized') mocks.mergeInTransaction.mockRejectedValueOnce(failure) diff --git a/apps/web/src/lib/server/auth/__tests__/identity-merge-rate-limit.test.ts b/apps/web/src/lib/server/auth/__tests__/identity-merge-rate-limit.test.ts index c67d30457..0b44711da 100644 --- a/apps/web/src/lib/server/auth/__tests__/identity-merge-rate-limit.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/identity-merge-rate-limit.test.ts @@ -3,19 +3,37 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ incrementBuckets: vi.fn(), bucketRetryAfter: vi.fn(), + execute: vi.fn(), })) vi.mock('@/lib/server/utils/redis-rate-bucket', () => ({ incrementBuckets: (...args: unknown[]) => mocks.incrementBuckets(...args), bucketRetryAfter: (...args: unknown[]) => mocks.bucketRetryAfter(...args), })) +vi.mock('@/lib/server/db', () => ({ + db: { execute: (...args: unknown[]) => mocks.execute(...args) }, + sql: Object.assign( + (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }), + { raw: (value: string) => value } + ), +})) + +import { + IDENTITY_MERGE_BACKLOG_LIMIT, + checkIdentityMergeActorCapacity, + checkIdentityMergeRateLimit, +} from '../identity-merge-rate-limit' -import { checkIdentityMergeRateLimit } from '../identity-merge-rate-limit' +const ACTOR = { + userId: 'user_target', + principalId: 'principal_target', +} as const describe('checkIdentityMergeRateLimit', () => { beforeEach(() => { vi.clearAllMocks() mocks.bucketRetryAfter.mockResolvedValue(123) + mocks.execute.mockResolvedValue([{ remaining: 0 }]) }) it('uses only a token fingerprint in Redis keys and allows bounded traffic', async () => { @@ -28,7 +46,8 @@ describe('checkIdentityMergeRateLimit', () => { const specs = mocks.incrementBuckets.mock.calls[0]![0] as Array<{ key: string }> expect(specs[0]!.key).toMatch(/^identity-merge:target:[a-f0-9]{64}$/) expect(specs[0]!.key).not.toContain('private-target-token') - expect(specs[1]!.key).toBe('identity-merge:ip:203.0.113.8') + expect(specs[1]!.key).toMatch(/^identity-merge:ip:[a-f0-9]{64}$/) + expect(specs[1]!.key).not.toContain('203.0.113.8') }) it('fails closed and retryably when Redis is unavailable', async () => { @@ -55,4 +74,66 @@ describe('checkIdentityMergeRateLimit', () => { retryAfter: 123, }) }) + + it('bounds a stable actor plus the global daily capacity without raw actor identifiers', async () => { + mocks.incrementBuckets.mockResolvedValue([1, 1]) + + await expect(checkIdentityMergeActorCapacity(ACTOR)).resolves.toEqual({ + allowed: true, + }) + + const specs = mocks.incrementBuckets.mock.calls[0]![0] as Array<{ + key: string + windowSeconds: number + }> + expect(specs).toHaveLength(2) + expect(specs[0]!.key).toMatch(/^identity-merge:actor:[a-f0-9]{64}$/) + expect(specs[0]!.key).not.toContain(ACTOR.userId) + expect(specs[0]!.key).not.toContain(ACTOR.principalId) + expect(specs[1]!.key).toBe('identity-merge:global:v1') + expect(specs.every((spec) => spec.windowSeconds === 86_400)).toBe(true) + expect(mocks.execute).toHaveBeenCalledTimes(1) + }) + + it('fails closed when stable-actor capacity or backlog state is unavailable', async () => { + mocks.incrementBuckets.mockResolvedValue([null, null]) + await expect(checkIdentityMergeActorCapacity(ACTOR)).resolves.toEqual({ + allowed: false, + reason: 'unavailable', + retryAfter: 30, + }) + + mocks.incrementBuckets.mockResolvedValue([1, 1]) + mocks.execute.mockRejectedValueOnce(new Error('private database failure')) + await expect(checkIdentityMergeActorCapacity(ACTOR)).resolves.toEqual({ + allowed: false, + reason: 'unavailable', + retryAfter: 30, + }) + }) + + it('rejects an actor, global, or retained-tombstone backlog over capacity', async () => { + mocks.incrementBuckets + .mockResolvedValueOnce([101, 1]) + .mockResolvedValueOnce([1, 1_001]) + .mockResolvedValueOnce([1, 1]) + + await expect(checkIdentityMergeActorCapacity(ACTOR)).resolves.toEqual({ + allowed: false, + reason: 'limited', + retryAfter: 123, + }) + await expect(checkIdentityMergeActorCapacity(ACTOR)).resolves.toEqual({ + allowed: false, + reason: 'limited', + retryAfter: 123, + }) + + mocks.execute.mockResolvedValueOnce([{ remaining: IDENTITY_MERGE_BACKLOG_LIMIT + 1 }]) + await expect(checkIdentityMergeActorCapacity(ACTOR)).resolves.toEqual({ + allowed: false, + reason: 'limited', + retryAfter: 300, + }) + }) }) diff --git a/apps/web/src/lib/server/auth/__tests__/identity-merge.postgres.test.ts b/apps/web/src/lib/server/auth/__tests__/identity-merge.postgres.test.ts index 22fc2e9b0..85519bea0 100644 --- a/apps/web/src/lib/server/auth/__tests__/identity-merge.postgres.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/identity-merge.postgres.test.ts @@ -356,6 +356,10 @@ describe.skipIf(!database)('identity merge canonical locks (PostgreSQL 18)', () const dedicatedMerge = resolveAndMergeAnonymousTokenWithDatabase(database, { previousToken: dedicatedSourceToken, targetToken, + targetActor: { + userId: targetUserId, + principalId: targetPrincipalId, + }, }) const unsubscribe = processUnsubscribeTokenWithDatabase(database, unsubscribeToken) const allMutations = Promise.all([genericMerge, dedicatedMerge, unsubscribe]) diff --git a/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts b/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts index e7e64b0a6..25928f5bc 100644 --- a/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts +++ b/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts @@ -52,6 +52,73 @@ export const ANONYMOUS_PRINCIPAL_FK_POLICY = { } as const satisfies Record export type AnonymousPrincipalFkKey = keyof typeof ANONYMOUS_PRINCIPAL_FK_POLICY +export type AnonymousPrincipalFkPolicy = + (typeof ANONYMOUS_PRINCIPAL_FK_POLICY)[AnonymousPrincipalFkKey] + +export const ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE = Object.freeze({ + target_only: 'target_only_guard', + reparent: 'direct_reparent', + dedupe: 'dedupe_and_reparent', + conservative_merge: 'conservative_fold_and_reparent', +} as const satisfies Record) + +export type AnonymousPrincipalFkHandlerStage = + (typeof ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE)[AnonymousPrincipalFkPolicy] + +/** + * Explicit binding from every principal FK to the concrete merge phase that + * handles it. Do not derive this table from the policy: requiring a second, + * typed declaration is what makes a newly classified FK fail compilation + * until its SQL handler has been deliberately audited. + */ +export const ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS = Object.freeze({ + 'api_keys.created_by_id': 'target_only_guard', + 'api_keys.principal_id': 'target_only_guard', + 'bug_report_submissions.principal_id': 'direct_reparent', + 'changelog_entries.principal_id': 'target_only_guard', + 'chat_message_flags.principal_id': 'target_only_guard', + 'chat_message_mentions.principal_id': 'target_only_guard', + 'chat_message_reactions.principal_id': 'target_only_guard', + 'chat_messages.deleted_by_principal_id': 'direct_reparent', + 'chat_messages.principal_id': 'direct_reparent', + 'comment_edit_history.editor_principal_id': 'direct_reparent', + 'comment_reactions.principal_id': 'dedupe_and_reparent', + 'comments.deleted_by_principal_id': 'direct_reparent', + 'comments.principal_id': 'direct_reparent', + 'conversations.assigned_agent_principal_id': 'target_only_guard', + 'conversations.visitor_principal_id': 'direct_reparent', + 'external_user_mappings.principal_id': 'direct_reparent', + 'feedback_suggestions.resolved_by_principal_id': 'target_only_guard', + 'in_app_notifications.principal_id': 'direct_reparent', + 'integration_platform_credentials.configured_by_principal_id': 'target_only_guard', + 'integrations.connected_by_principal_id': 'target_only_guard', + 'integrations.principal_id': 'target_only_guard', + 'kb_article_feedback.principal_id': 'dedupe_and_reparent', + 'kb_articles.principal_id': 'target_only_guard', + 'merge_suggestions.resolved_by_principal_id': 'target_only_guard', + 'notification_preferences.principal_id': 'conservative_fold_and_reparent', + 'post_activity.principal_id': 'direct_reparent', + 'post_edit_history.editor_principal_id': 'direct_reparent', + 'post_mentions.principal_id': 'target_only_guard', + 'post_notes.principal_id': 'target_only_guard', + 'post_subscriptions.principal_id': 'conservative_fold_and_reparent', + 'posts.deleted_by_principal_id': 'direct_reparent', + 'posts.merged_by_principal_id': 'target_only_guard', + 'posts.owner_principal_id': 'target_only_guard', + 'posts.principal_id': 'direct_reparent', + 'posts.tracked_by_principal_id': 'target_only_guard', + 'push_devices.principal_id': 'target_only_guard', + 'raw_feedback_items.principal_id': 'direct_reparent', + 'unsubscribe_tokens.principal_id': 'direct_reparent', + 'user_segments.principal_id': 'conservative_fold_and_reparent', + 'votes.added_by_principal_id': 'target_only_guard', + 'votes.principal_id': 'dedupe_and_reparent', + 'webhooks.created_by_id': 'target_only_guard', +} as const satisfies Record) + +export const ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES = Object.freeze( + Array.from(new Set(Object.values(ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS))).sort() +) as readonly AnonymousPrincipalFkHandlerStage[] export const ANONYMOUS_PRINCIPAL_MERGE_KEYS = Object.freeze( Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY) @@ -59,3 +126,29 @@ export const ANONYMOUS_PRINCIPAL_MERGE_KEYS = Object.freeze( .map(([key]) => key) .sort() ) as readonly AnonymousPrincipalFkKey[] + +export const ANONYMOUS_PRINCIPAL_TARGET_ONLY_KEYS = Object.freeze( + Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY) + .filter(([, policy]) => policy === 'target_only') + .map(([key]) => key) + .sort() +) as readonly AnonymousPrincipalFkKey[] + +export function createAnonymousPrincipalFkExecutionTracker(): { + complete(stage: AnonymousPrincipalFkHandlerStage): void + assertComplete(): void +} { + const completed = new Set() + return { + complete(stage) { + completed.add(stage) + }, + assertComplete() { + for (const stage of ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES) { + if (!completed.has(stage)) { + throw new Error('Anonymous principal FK handler stage was not executed') + } + } + }, + } +} diff --git a/apps/web/src/lib/server/auth/identify-merge.ts b/apps/web/src/lib/server/auth/identify-merge.ts index 4ea4e2996..d83aba578 100644 --- a/apps/web/src/lib/server/auth/identify-merge.ts +++ b/apps/web/src/lib/server/auth/identify-merge.ts @@ -42,10 +42,17 @@ interface ResolveAndMergeParams { previousToken: string /** Newly identified widget token supplied as the endpoint Bearer. */ targetToken: string + /** Stable actor resolved from the target token before capacity admission. */ + targetActor: IdentityMergeTargetActor /** Test seam for the post-session-lock clock capture. */ now?: Date } +export interface IdentityMergeTargetActor { + userId: UserId + principalId: PrincipalId +} + interface ResolveLegacyMergeParams { previousToken: string | null | undefined targetPrincipalId: PrincipalId @@ -104,14 +111,69 @@ export async function resolveAndMergeAnonymousToken( return resolveAndMergeAnonymousTokenWithDatabase(db, params) } +export async function resolveIdentityMergeTargetActor( + targetToken: string, + now = new Date() +): Promise { + if (!targetToken) return null + return db.transaction(async (tx) => { + const targetSessions = await tx + .select({ + targetSessionId: session.id, + token: session.token, + userId: session.userId, + expiresAt: session.expiresAt, + }) + .from(session) + .where(eq(session.token, targetToken)) + .limit(1) + const targetSession = targetSessions[0] + if (!targetSession || targetSession.token !== targetToken || targetSession.expiresAt <= now) { + return null + } + const provenance = await tx + .select({ sessionId: widgetIdentifiedSession.sessionId }) + .from(widgetIdentifiedSession) + .where(eq(widgetIdentifiedSession.sessionId, targetSession.targetSessionId)) + if (!provenance.some((row) => row.sessionId === targetSession.targetSessionId)) { + return null + } + const targetPrincipals = await tx + .select({ + principalId: principal.id, + userId: principal.userId, + type: principal.type, + role: principal.role, + }) + .from(principal) + .where(eq(principal.userId, targetSession.userId)) + .limit(1) + const targetPrincipal = targetPrincipals[0] + if ( + !targetPrincipal || + targetPrincipal.userId !== targetSession.userId || + targetPrincipal.type !== 'user' || + targetPrincipal.role !== 'user' + ) { + return null + } + return { + userId: targetSession.userId as UserId, + principalId: targetPrincipal.principalId as PrincipalId, + } + }) +} + /** Database-injected entry point for PostgreSQL concurrency verification. */ export async function resolveAndMergeAnonymousTokenWithDatabase( database: Pick, params: ResolveAndMergeParams ): Promise { - const { previousToken, targetToken } = params + const { previousToken, targetToken, targetActor } = params - if (!targetToken) return { status: 'target_invalid' } + if (!targetToken || !targetActor?.userId || !targetActor.principalId) { + return { status: 'target_invalid' } + } if (!previousToken) return { status: 'conflict' } return database.transaction(async (tx) => { @@ -130,7 +192,12 @@ export async function resolveAndMergeAnonymousTokenWithDatabase( .where(eq(session.token, targetToken)) .limit(1) const targetHint = targetHints[0] - if (!targetHint || targetHint.token !== targetToken || targetHint.expiresAt <= preflightAt) { + if ( + !targetHint || + targetHint.token !== targetToken || + targetHint.userId !== targetActor.userId || + targetHint.expiresAt <= preflightAt + ) { return { status: 'target_invalid' } } const targetProvenance = await tx @@ -152,6 +219,7 @@ export async function resolveAndMergeAnonymousTokenWithDatabase( const targetPrincipalHint = targetPrincipalHints[0] if ( !targetPrincipalHint || + targetPrincipalHint.targetPrincipalId !== targetActor.principalId || targetPrincipalHint.type !== 'user' || targetPrincipalHint.role !== 'user' ) { @@ -204,7 +272,7 @@ export async function resolveAndMergeAnonymousTokenWithDatabase( if ( !targetSession || targetSession.token !== targetToken || - targetSession.userId !== targetHint.userId || + targetSession.userId !== targetActor.userId || targetSession.expiresAt <= consumedAt ) { return { status: 'target_invalid' } @@ -236,7 +304,13 @@ export async function resolveAndMergeAnonymousTokenWithDatabase( const targetPrincipal = principals.find( (candidate) => candidate.userId === targetSession.userId ) - if (!targetPrincipal || targetPrincipal.type !== 'user' || targetPrincipal.role !== 'user') { + if ( + !targetPrincipal || + targetPrincipal.id !== targetActor.principalId || + targetPrincipal.userId !== targetActor.userId || + targetPrincipal.type !== 'user' || + targetPrincipal.role !== 'user' + ) { return { status: 'target_invalid' } } diff --git a/apps/web/src/lib/server/auth/identity-merge-rate-limit.ts b/apps/web/src/lib/server/auth/identity-merge-rate-limit.ts index ccf5b331b..b9a98d1bb 100644 --- a/apps/web/src/lib/server/auth/identity-merge-rate-limit.ts +++ b/apps/web/src/lib/server/auth/identity-merge-rate-limit.ts @@ -6,12 +6,14 @@ * limiting one target session. Redis failure is fail-closed/retryable: losing * a merge attempt is safe because the iframe preserves its anonymous token. */ -import { createHash } from 'crypto' +import { createHash } from 'node:crypto' +import { db, sql } from '@/lib/server/db' import { bucketRetryAfter, incrementBuckets, type RateBucketSpec, } from '@/lib/server/utils/redis-rate-bucket' +import { IDENTITY_MERGE_TOMBSTONE_USER_AGENT } from './identity-merge-tombstone' export type IdentityMergeRateLimitResult = | { allowed: true } @@ -20,9 +22,30 @@ export type IdentityMergeRateLimitResult = const WINDOW_SECONDS = 15 * 60 const TARGET_LIMIT = 20 const IP_LIMIT = 40 +const CAPACITY_WINDOW_SECONDS = 86_400 +const ACTOR_DAILY_LIMIT = 100 +const GLOBAL_DAILY_LIMIT = 1_000 +export const IDENTITY_MERGE_BACKLOG_LIMIT = 8_000 -function targetFingerprint(targetToken: string): string { - return createHash('sha256').update(targetToken).digest('hex') +export interface IdentityMergeCapacityActor { + userId: string + principalId: string +} + +function fingerprint(domain: string, value: string): string { + return createHash('sha256').update(domain).update('\0').update(value).digest('hex') +} + +function resultRows(result: unknown): T[] { + if (Array.isArray(result)) return result as T[] + if ( + result !== null && + typeof result === 'object' && + Array.isArray((result as { rows?: unknown }).rows) + ) { + return (result as { rows: T[] }).rows + } + return [] } export async function checkIdentityMergeRateLimit( @@ -30,11 +53,11 @@ export async function checkIdentityMergeRateLimit( targetToken: string ): Promise { const targetSpec: RateBucketSpec = { - key: `identity-merge:target:${targetFingerprint(targetToken)}`, + key: `identity-merge:target:${fingerprint('identity-merge-target-v1', targetToken)}`, windowSeconds: WINDOW_SECONDS, } const ipSpec: RateBucketSpec = { - key: `identity-merge:ip:${clientIp}`, + key: `identity-merge:ip:${fingerprint('identity-merge-ip-v1', clientIp)}`, windowSeconds: WINDOW_SECONDS, } const [targetCount, ipCount] = await incrementBuckets([targetSpec, ipSpec]) @@ -60,3 +83,63 @@ export async function checkIdentityMergeRateLimit( } return { allowed: true } } + +export async function checkIdentityMergeActorCapacity( + actor: IdentityMergeCapacityActor +): Promise { + if (!actor.userId || !actor.principalId) { + return { allowed: false, reason: 'unavailable', retryAfter: 30 } + } + const actorSpec: RateBucketSpec = { + key: `identity-merge:actor:${fingerprint( + 'identity-merge-actor-v1', + `${actor.userId}\0${actor.principalId}` + )}`, + windowSeconds: CAPACITY_WINDOW_SECONDS, + } + const globalSpec: RateBucketSpec = { + key: 'identity-merge:global:v1', + windowSeconds: CAPACITY_WINDOW_SECONDS, + } + const [actorCount, globalCount] = await incrementBuckets([actorSpec, globalSpec]) + if (actorCount === null || globalCount === null) { + return { allowed: false, reason: 'unavailable', retryAfter: 30 } + } + if (actorCount > ACTOR_DAILY_LIMIT) { + return { + allowed: false, + reason: 'limited', + retryAfter: await bucketRetryAfter(actorSpec), + } + } + if (globalCount > GLOBAL_DAILY_LIMIT) { + return { + allowed: false, + reason: 'limited', + retryAfter: await bucketRetryAfter(globalSpec), + } + } + + try { + const result = await db.execute(sql` + SELECT COUNT(*)::integer AS remaining + FROM ( + SELECT 1 + FROM session + WHERE user_agent = ${IDENTITY_MERGE_TOMBSTONE_USER_AGENT} + LIMIT ${IDENTITY_MERGE_BACKLOG_LIMIT + 1} + ) retained + `) + const remaining = Number(resultRows<{ remaining?: number | string }>(result)[0]?.remaining) + if (!Number.isSafeInteger(remaining) || remaining < 0) { + return { allowed: false, reason: 'unavailable', retryAfter: 30 } + } + if (remaining > IDENTITY_MERGE_BACKLOG_LIMIT) { + return { allowed: false, reason: 'limited', retryAfter: 300 } + } + } catch { + return { allowed: false, reason: 'unavailable', retryAfter: 30 } + } + + return { allowed: true } +} diff --git a/apps/web/src/lib/server/auth/merge-anonymous.ts b/apps/web/src/lib/server/auth/merge-anonymous.ts index 83d592c0f..bb4024e31 100644 --- a/apps/web/src/lib/server/auth/merge-anonymous.ts +++ b/apps/web/src/lib/server/auth/merge-anonymous.ts @@ -13,6 +13,12 @@ import { createId, toUuid, type PrincipalId, type UserId } from '@quackback/ids' import { IDENTITY_MERGE_TOMBSTONE_USER_AGENT } from './identity-merge-tombstone' import { lockIdentityActorUsers } from './identity-merge-locks' +import { + ANONYMOUS_PRINCIPAL_MERGE_KEYS, + ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE, + ANONYMOUS_PRINCIPAL_TARGET_ONLY_KEYS, + createAnonymousPrincipalFkExecutionTracker, +} from './anonymous-principal-fk-policy' import { db, type Database, @@ -90,53 +96,9 @@ export interface PreserveConsumedSession { * test compares this list with every non-target-only registry entry so adding * a classification without adding a merge handler cannot pass unnoticed. */ -export const EXECUTED_ANONYMOUS_PRINCIPAL_MERGE_KEYS = Object.freeze([ - 'bug_report_submissions.principal_id', - 'chat_messages.deleted_by_principal_id', - 'chat_messages.principal_id', - 'comment_edit_history.editor_principal_id', - 'comment_reactions.principal_id', - 'comments.deleted_by_principal_id', - 'comments.principal_id', - 'conversations.visitor_principal_id', - 'external_user_mappings.principal_id', - 'in_app_notifications.principal_id', - 'kb_article_feedback.principal_id', - 'notification_preferences.principal_id', - 'post_activity.principal_id', - 'post_edit_history.editor_principal_id', - 'post_subscriptions.principal_id', - 'posts.deleted_by_principal_id', - 'posts.principal_id', - 'raw_feedback_items.principal_id', - 'unsubscribe_tokens.principal_id', - 'user_segments.principal_id', - 'votes.principal_id', -] as const) - -export const GUARDED_TARGET_ONLY_ANONYMOUS_PRINCIPAL_KEYS = Object.freeze([ - 'api_keys.created_by_id', - 'api_keys.principal_id', - 'changelog_entries.principal_id', - 'chat_message_flags.principal_id', - 'chat_message_mentions.principal_id', - 'chat_message_reactions.principal_id', - 'conversations.assigned_agent_principal_id', - 'feedback_suggestions.resolved_by_principal_id', - 'integration_platform_credentials.configured_by_principal_id', - 'integrations.connected_by_principal_id', - 'integrations.principal_id', - 'kb_articles.principal_id', - 'merge_suggestions.resolved_by_principal_id', - 'post_mentions.principal_id', - 'post_notes.principal_id', - 'posts.merged_by_principal_id', - 'posts.owner_principal_id', - 'posts.tracked_by_principal_id', - 'push_devices.principal_id', - 'votes.added_by_principal_id', - 'webhooks.created_by_id', -] as const) +export const EXECUTED_ANONYMOUS_PRINCIPAL_MERGE_KEYS = ANONYMOUS_PRINCIPAL_MERGE_KEYS + +export const GUARDED_TARGET_ONLY_ANONYMOUS_PRINCIPAL_KEYS = ANONYMOUS_PRINCIPAL_TARGET_ONLY_KEYS export async function mergeAnonymousToIdentifiedInTransaction( tx: Transaction, @@ -148,6 +110,7 @@ export async function mergeAnonymousToIdentifiedInTransaction( const anonPrincipalUuid = toUuid(anonPrincipalId) const targetPrincipalUuid = toUuid(targetPrincipalId) const conservativePreferenceUuid = toUuid(createId('notif_pref')) + const fkExecution = createAnonymousPrincipalFkExecutionTracker() // `target_only` is an application invariant, not a cross-table database // constraint. Fail closed if impossible/legacy rows exist: deleting the @@ -231,6 +194,7 @@ export async function mergeAnonymousToIdentifiedInTransaction( if (targetOnlyReferencePresent !== false) { throw new Error('Anonymous principal has target-only references') } + fkExecution.complete(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE.target_only) // Lock every conflict/evidence row before reading or folding it. Principal // FOR UPDATE locks (held by both callers) block new FK inserts; these row @@ -630,6 +594,14 @@ export async function mergeAnonymousToIdentifiedInTransaction( .where(eq(inAppNotifications.principalId, anonPrincipalId)), ]) + // Each policy is bound to one real stage above. The assertion remains + // immediately before source deletion so a newly classified FK cannot be + // represented only in metadata and then silently cascade. + fkExecution.complete(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE.dedupe) + fkExecution.complete(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE.conservative_merge) + fkExecution.complete(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE.reparent) + fkExecution.assertComplete() + // 7. The widget commit keeps the exact consumed source token as an expired, // target-owned tombstone. Reparent it before deleting the anonymous user so // the user FK cascade cannot erase the bounded idempotency marker. diff --git a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-assertion.test.ts b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-assertion.test.ts new file mode 100644 index 000000000..1d894d703 --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-assertion.test.ts @@ -0,0 +1,153 @@ +import { createHmac } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { + HOST_SUBMIT_ASSERTION_PURPOSE, + computeHostSubmitReportDigestServer, + consumeHostSubmitAssertionOnce, + verifyHostSubmitAssertion, +} from '../host-submit-assertion' + +const SECRET = 'test-widget-secret-that-is-long-enough-for-hs256' +const NOW_SECONDS = 1_800_000_000 +const EXPECTED = { + audience: 'https://feedback.example.test', + actorSubject: 'customer-user-123', + hostOrigin: 'https://app.example.test', + contract: 'iplaycafe.quackback.report-submit/1', + reportDigest: 'f7319703af570f5daee887fc6f17ae7d487d0acca1b10c37234e649eb1119f9e', + clientSubmissionId: '22222222-2222-4222-8222-222222222222', + requestId: '11111111-1111-4111-8111-111111111111', +} as const + +function sign(claimOverrides: Record = {}, headerOverrides = {}) { + const header = Buffer.from( + JSON.stringify({ alg: 'HS256', typ: 'JWT', ...headerOverrides }) + ).toString('base64url') + const payload = Buffer.from( + JSON.stringify({ + purpose: HOST_SUBMIT_ASSERTION_PURPOSE, + aud: EXPECTED.audience, + sub: EXPECTED.actorSubject, + hostOrigin: EXPECTED.hostOrigin, + contract: EXPECTED.contract, + reportDigest: EXPECTED.reportDigest, + clientSubmissionId: EXPECTED.clientSubmissionId, + requestId: EXPECTED.requestId, + jti: 'host-submit-jti-00000001', + iat: NOW_SECONDS, + exp: NOW_SECONDS + 30, + ...claimOverrides, + }) + ).toString('base64url') + const signature = createHmac('sha256', SECRET).update(`${header}.${payload}`).digest('base64url') + return `${header}.${payload}.${signature}` +} + +describe('verifyHostSubmitAssertion', () => { + it('accepts one exact short-lived HS256 host assertion', () => { + expect(verifyHostSubmitAssertion(sign(), EXPECTED, SECRET, NOW_SECONDS * 1000)).toEqual({ + purpose: HOST_SUBMIT_ASSERTION_PURPOSE, + aud: EXPECTED.audience, + sub: EXPECTED.actorSubject, + hostOrigin: EXPECTED.hostOrigin, + contract: EXPECTED.contract, + reportDigest: EXPECTED.reportDigest, + clientSubmissionId: EXPECTED.clientSubmissionId, + requestId: EXPECTED.requestId, + jti: 'host-submit-jti-00000001', + iat: NOW_SECONDS, + exp: NOW_SECONDS + 30, + }) + }) + + it.each([ + ['purpose', { purpose: 'another-purpose' }], + ['audience', { aud: 'https://other-feedback.example.test' }], + ['actor', { sub: 'another-customer-user' }], + ['origin', { hostOrigin: 'https://other-app.example.test' }], + ['non-canonical origin', { hostOrigin: 'https://app.example.test/' }], + ['contract', { contract: 'iplaycafe.quackback.report-submit/2' }], + ['report digest', { reportDigest: '0'.repeat(64) }], + ['client submission', { clientSubmissionId: '33333333-3333-4333-8333-333333333333' }], + ['request', { requestId: '44444444-4444-4444-8444-444444444444' }], + ['expired', { iat: NOW_SECONDS - 31, exp: NOW_SECONDS - 1 }], + ['excessive lifetime', { exp: NOW_SECONDS + 31 }], + ['future issued', { iat: NOW_SECONDS + 6, exp: NOW_SECONDS + 30 }], + ])('rejects a wrong %s claim', (_name, override) => { + expect( + verifyHostSubmitAssertion(sign(override), EXPECTED, SECRET, NOW_SECONDS * 1000) + ).toBeNull() + }) + + it('rejects missing/extra claims, wrong algorithms, tampering, and non-canonical base64url', () => { + const withExtra = sign({ extra: true }) + const missingJti = sign({ jti: undefined }) + const wrongAlgorithm = sign({}, { alg: 'none' }) + const [header, payload, signature] = sign().split('.') + const tampered = `${header}.${Buffer.from( + JSON.stringify({ + ...JSON.parse(Buffer.from(payload!, 'base64url').toString('utf8')), + sub: 'tampered', + }) + ).toString('base64url')}.${signature}` + const padded = `${header}.${payload}=.${signature}` + + for (const token of [withExtra, missingJti, wrongAlgorithm, tampered, padded, 'not.a.jwt']) { + expect(verifyHostSubmitAssertion(token, EXPECTED, SECRET, NOW_SECONDS * 1000)).toBeNull() + } + }) +}) + +describe('computeHostSubmitReportDigestServer', () => { + it.each([ + ['Save does nothing', '', 'f7319703af570f5daee887fc6f17ae7d487d0acca1b10c37234e649eb1119f9e'], + [ + 'บันทึกไม่ได้', + 'ผู้ใช้ติดขัด', + '23035ded7c21ce87e9ff9f263257bc9fb5f0529db4947d79bb84fd51e81a7e79', + ], + [ + 'broken \ud800', + 'tail \udfff', + '6cdf96a256364463a41754848065ccb59309db014d71ffe9b68f8a7edd3b5b99', + ], + ])('matches the locked server-side digest vector', (summary, impact, digest) => { + expect(computeHostSubmitReportDigestServer(summary, impact)).toBe(digest) + }) +}) + +describe('consumeHostSubmitAssertionOnce', () => { + it('uses a hashed one-time key and rejects a replay', async () => { + const set = vi.fn().mockResolvedValueOnce('OK').mockResolvedValueOnce(null) + const redis = { set } + const claims = verifyHostSubmitAssertion(sign(), EXPECTED, SECRET, NOW_SECONDS * 1000)! + + await expect(consumeHostSubmitAssertionOnce(claims, NOW_SECONDS * 1000, redis)).resolves.toBe( + 'consumed' + ) + await expect(consumeHostSubmitAssertionOnce(claims, NOW_SECONDS * 1000, redis)).resolves.toBe( + 'replayed' + ) + + expect(set).toHaveBeenCalledWith( + expect.stringMatching(/^host-submit-assertion:used:[a-f0-9]{64}$/), + '1', + 'EX', + 30, + 'NX' + ) + expect(set.mock.calls[0]![0]).not.toContain(claims.jti) + }) + + it('fails closed when Redis is unavailable or the assertion is no longer live', async () => { + const claims = verifyHostSubmitAssertion(sign(), EXPECTED, SECRET, NOW_SECONDS * 1000)! + const unavailable = { set: vi.fn().mockRejectedValue(new Error('private redis failure')) } + + await expect( + consumeHostSubmitAssertionOnce(claims, NOW_SECONDS * 1000, unavailable) + ).resolves.toBe('unavailable') + await expect( + consumeHostSubmitAssertionOnce(claims, (NOW_SECONDS + 30) * 1000, unavailable) + ).resolves.toBe('replayed') + }) +}) diff --git a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-authorization.test.ts b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-authorization.test.ts new file mode 100644 index 000000000..732bae7ef --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-authorization.test.ts @@ -0,0 +1,152 @@ +import { createHmac } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { HostBugReportSubmitError } from '../host-submit-errors' +import { computeHostSubmitReportDigestServer } from '../host-submit-assertion' +import { + authorizeHostSubmitMutation, + type HostSubmitAuthorizationDependencies, +} from '../host-submit-authorization' + +const SECRET = 'test-widget-secret-that-is-long-enough-for-hs256' +const NOW_SECONDS = 1_800_000_000 +const INPUT = { + contract: 'iplaycafe.quackback.report-submit/1', + requestId: '11111111-1111-4111-8111-111111111111', + clientSubmissionId: '22222222-2222-4222-8222-222222222222', + summary: 'Save button does nothing', + impact: 'Cannot finish checkout', + title: 'Save button', + content: 'Save button does nothing', + hostOrigin: 'https://app.example.test', + hostSubmitAssertion: '', +} as const + +function sign(overrides: Record = {}) { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') + const payload = Buffer.from( + JSON.stringify({ + purpose: 'iplaycafe.quackback.host-submit/1', + aud: 'https://feedback.example.test', + sub: 'customer-user-123', + hostOrigin: INPUT.hostOrigin, + contract: INPUT.contract, + reportDigest: computeHostSubmitReportDigestServer(INPUT.summary, INPUT.impact), + clientSubmissionId: INPUT.clientSubmissionId, + requestId: INPUT.requestId, + jti: 'host-submit-jti-00000001', + iat: NOW_SECONDS, + exp: NOW_SECONDS + 30, + ...overrides, + }) + ).toString('base64url') + const signature = createHmac('sha256', SECRET).update(`${header}.${payload}`).digest('base64url') + return `${header}.${payload}.${signature}` +} + +function dependencies( + overrides: Partial = {} +): HostSubmitAuthorizationDependencies { + return { + resolveActorExternalId: vi.fn().mockResolvedValue('customer-user-123'), + getWidgetSecret: vi.fn().mockResolvedValue(SECRET), + getAudience: vi.fn(() => 'https://feedback.example.test'), + isOriginAllowed: vi.fn(() => true), + consumeAssertion: vi.fn().mockResolvedValue('consumed'), + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + now: vi.fn(() => NOW_SECONDS * 1000), + ...overrides, + } +} + +describe('authorizeHostSubmitMutation', () => { + it('verifies and consumes the assertion before admitting the authenticated stable actor', async () => { + const deps = dependencies() + await expect( + authorizeHostSubmitMutation( + { + data: { ...INPUT, hostSubmitAssertion: sign() }, + actor: { userId: 'user_internal', principalId: 'principal_internal' }, + headers: new Headers({ 'cf-connecting-ip': '203.0.113.8' }), + }, + deps + ) + ).resolves.toBeUndefined() + + expect(deps.consumeAssertion).toHaveBeenCalledWith( + expect.objectContaining({ sub: 'customer-user-123' }), + NOW_SECONDS * 1000 + ) + expect(deps.checkRateLimit).toHaveBeenCalledWith('principal_internal', '203.0.113.8') + }) + + it.each([ + ['missing assertion', '', {}], + ['wrong actor', sign({ sub: 'another-user' }), {}], + ['wrong origin', sign({ hostOrigin: 'https://other-app.example.test' }), {}], + ['wrong request', sign({ requestId: '33333333-3333-4333-8333-333333333333' }), {}], + ['expired', sign({ iat: NOW_SECONDS - 31, exp: NOW_SECONDS - 1 }), {}], + ['replay', sign(), { consumeAssertion: vi.fn().mockResolvedValue('replayed') }], + ])('rejects %s before any downstream mutation', async (_name, assertion, override) => { + const deps = dependencies(override) + await expect( + authorizeHostSubmitMutation( + { + data: { ...INPUT, hostSubmitAssertion: assertion }, + actor: { userId: 'user_internal', principalId: 'principal_internal' }, + headers: new Headers(), + }, + deps + ) + ).rejects.toEqual(new HostBugReportSubmitError('unauthorized')) + expect(deps.checkRateLimit).not.toHaveBeenCalled() + }) + + it('fails closed and retryably on one-use or rate dependency failure', async () => { + for (const override of [ + { consumeAssertion: vi.fn().mockResolvedValue('unavailable') }, + { + consumeAssertion: vi.fn().mockResolvedValue('consumed'), + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: false, reason: 'unavailable', retryAfter: 30 }), + }, + { resolveActorExternalId: vi.fn().mockRejectedValue(new Error('private database failure')) }, + ]) { + const deps = dependencies(override) + await expect( + authorizeHostSubmitMutation( + { + data: { ...INPUT, hostSubmitAssertion: sign() }, + actor: { userId: 'user_internal', principalId: 'principal_internal' }, + headers: new Headers(), + }, + deps + ) + ).rejects.toEqual(new HostBugReportSubmitError('retryable_failure')) + } + }) + + it.each([ + ['summary', { summary: 'Save button sometimes works' }], + ['impact', { impact: 'Checkout is slow' }], + ])( + 'rejects a post-signing %s mutation before consuming the assertion', + async (_name, mutation) => { + const deps = dependencies() + + await expect( + authorizeHostSubmitMutation( + { + data: { ...INPUT, ...mutation, hostSubmitAssertion: sign() }, + actor: { userId: 'user_internal', principalId: 'principal_internal' }, + headers: new Headers(), + }, + deps + ) + ).rejects.toEqual(new HostBugReportSubmitError('unauthorized')) + + expect(deps.consumeAssertion).not.toHaveBeenCalled() + expect(deps.checkRateLimit).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-rate-limit.test.ts b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-rate-limit.test.ts new file mode 100644 index 000000000..802d773ec --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-rate-limit.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + incrementBuckets: vi.fn(), + bucketRetryAfter: vi.fn(), +})) + +vi.mock('@/lib/server/utils/redis-rate-bucket', () => ({ + incrementBuckets: (...args: unknown[]) => mocks.incrementBuckets(...args), + bucketRetryAfter: (...args: unknown[]) => mocks.bucketRetryAfter(...args), +})) + +import { checkHostSubmitRateLimit } from '../host-submit-rate-limit' + +describe('checkHostSubmitRateLimit', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.bucketRetryAfter.mockResolvedValue(77) + }) + + it('hashes both principal and IP subjects and admits traffic at the exact limits', async () => { + mocks.incrementBuckets.mockResolvedValue([20, 100]) + + await expect(checkHostSubmitRateLimit('principal-private', '203.0.113.8')).resolves.toEqual({ + allowed: true, + }) + + const specs = mocks.incrementBuckets.mock.calls[0]![0] as Array<{ key: string }> + expect(specs.map((spec) => spec.key)).toEqual([ + expect.stringMatching(/^host-submit:principal:[a-f0-9]{64}$/), + expect.stringMatching(/^host-submit:ip:[a-f0-9]{64}$/), + ]) + expect(JSON.stringify(specs)).not.toContain('principal-private') + expect(JSON.stringify(specs)).not.toContain('203.0.113.8') + }) + + it('fails closed on dependency errors and limits either bucket above its boundary', async () => { + mocks.incrementBuckets + .mockResolvedValueOnce([null, 1]) + .mockResolvedValueOnce([21, 1]) + .mockResolvedValueOnce([1, 101]) + + await expect(checkHostSubmitRateLimit('p', 'unknown')).resolves.toEqual({ + allowed: false, + reason: 'unavailable', + retryAfter: 30, + }) + await expect(checkHostSubmitRateLimit('p', 'ip')).resolves.toEqual({ + allowed: false, + reason: 'limited', + retryAfter: 77, + }) + await expect(checkHostSubmitRateLimit('p', 'ip')).resolves.toEqual({ + allowed: false, + reason: 'limited', + retryAfter: 77, + }) + }) +}) diff --git a/apps/web/src/lib/server/domains/bug-reports/host-submit-assertion.ts b/apps/web/src/lib/server/domains/bug-reports/host-submit-assertion.ts new file mode 100644 index 000000000..6d896ce8c --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-assertion.ts @@ -0,0 +1,225 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto' +import { getRedis } from '@/lib/server/redis' +import { encodeHostSubmitReportDigestInput } from '@/lib/shared/bugreport/host-submit-contract' +import { normalizeBugReportHostOrigin } from './host-submit-origin-policy' + +export const HOST_SUBMIT_ASSERTION_PURPOSE = 'iplaycafe.quackback.host-submit/1' as const +export const HOST_SUBMIT_ASSERTION_MAX_TTL_SECONDS = 30 + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const OPAQUE_ID = /^[A-Za-z0-9._:@/-]{1,256}$/ +const JTI = /^[A-Za-z0-9_-]{16,128}$/ +const REPORT_DIGEST = /^[a-f0-9]{64}$/ +const TOKEN = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/ +const FUTURE_IAT_SKEW_SECONDS = 5 +const USED_KEY_DOMAIN = 'iplaycafe/quackback-host-submit/assertion-jti/v1' + +export interface HostSubmitAssertionExpected { + audience: string + actorSubject: string + hostOrigin: string + contract: 'iplaycafe.quackback.report-submit/1' + reportDigest: string + clientSubmissionId: string + requestId: string +} + +export interface HostSubmitAssertionClaims { + purpose: typeof HOST_SUBMIT_ASSERTION_PURPOSE + aud: string + sub: string + hostOrigin: string + contract: 'iplaycafe.quackback.report-submit/1' + reportDigest: string + clientSubmissionId: string + requestId: string + jti: string + iat: number + exp: number +} + +interface AssertionRedis { + set( + key: string, + value: string, + expiryMode: 'EX', + ttlSeconds: number, + existenceMode: 'NX' + ): Promise +} + +function exactRecord( + value: unknown, + expectedKeys: readonly string[] +): Record | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + const actualKeys = Object.keys(record).sort() + const sortedExpected = [...expectedKeys].sort() + if ( + actualKeys.length !== sortedExpected.length || + !actualKeys.every((key, index) => key === sortedExpected[index]) + ) { + return null + } + return record +} + +function decodeCanonicalJson(segment: string): unknown { + const decoded = Buffer.from(segment, 'base64url') + if (decoded.toString('base64url') !== segment) throw new Error('non-canonical base64url') + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decoded)) +} + +function canonicalAudience(value: string): string | null { + try { + const parsed = new URL(value) + const localHttp = + parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname) + if ( + parsed.username || + parsed.password || + parsed.pathname !== '/' || + parsed.search || + parsed.hash + ) { + return null + } + return parsed.protocol === 'https:' || localHttp ? parsed.origin : null + } catch { + return null + } +} + +export function computeHostSubmitReportDigestServer(summary: string, impact: string): string { + return createHash('sha256') + .update(encodeHostSubmitReportDigestInput(summary, impact)) + .digest('hex') +} + +function isExpectedReportDigest(actual: unknown, expected: string): boolean { + if (typeof actual !== 'string' || !REPORT_DIGEST.test(actual) || !REPORT_DIGEST.test(expected)) { + return false + } + return timingSafeEqual(Buffer.from(actual, 'ascii'), Buffer.from(expected, 'ascii')) +} + +export function verifyHostSubmitAssertion( + token: string, + expected: HostSubmitAssertionExpected, + secret: string, + nowMs = Date.now() +): HostSubmitAssertionClaims | null { + if ( + typeof token !== 'string' || + token.length > 4096 || + typeof secret !== 'string' || + secret.length < 32 || + !Number.isSafeInteger(nowMs) || + nowMs < 0 || + !OPAQUE_ID.test(expected.actorSubject) || + !REPORT_DIGEST.test(expected.reportDigest) || + !UUID_V4.test(expected.clientSubmissionId) || + !UUID_V4.test(expected.requestId) + ) { + return null + } + const expectedAudience = canonicalAudience(expected.audience) + const expectedHostOrigin = normalizeBugReportHostOrigin(expected.hostOrigin) + if ( + expectedAudience === null || + expectedAudience !== expected.audience || + expectedHostOrigin === null || + expectedHostOrigin !== expected.hostOrigin + ) { + return null + } + + const match = TOKEN.exec(token) + if (!match) return null + const [, headerSegment, payloadSegment, signatureSegment] = match + + let header: Record | null + let claims: Record | null + try { + header = exactRecord(decodeCanonicalJson(headerSegment), ['alg', 'typ']) + claims = exactRecord(decodeCanonicalJson(payloadSegment), [ + 'purpose', + 'aud', + 'sub', + 'hostOrigin', + 'contract', + 'reportDigest', + 'clientSubmissionId', + 'requestId', + 'jti', + 'iat', + 'exp', + ]) + } catch { + return null + } + if (!header || header.alg !== 'HS256' || header.typ !== 'JWT' || !claims) return null + + const provided = Buffer.from(signatureSegment, 'base64url') + if (provided.toString('base64url') !== signatureSegment) return null + const signed = `${headerSegment}.${payloadSegment}` + const signature = createHmac('sha256', secret).update(signed).digest() + if (provided.byteLength !== signature.byteLength || !timingSafeEqual(provided, signature)) { + return null + } + + const nowSeconds = Math.floor(nowMs / 1000) + const hostOrigin = + typeof claims.hostOrigin === 'string' ? normalizeBugReportHostOrigin(claims.hostOrigin) : null + if ( + claims.purpose !== HOST_SUBMIT_ASSERTION_PURPOSE || + claims.aud !== expectedAudience || + claims.sub !== expected.actorSubject || + !OPAQUE_ID.test(claims.sub) || + claims.hostOrigin !== expectedHostOrigin || + hostOrigin !== claims.hostOrigin || + claims.contract !== expected.contract || + !isExpectedReportDigest(claims.reportDigest, expected.reportDigest) || + claims.clientSubmissionId !== expected.clientSubmissionId || + claims.requestId !== expected.requestId || + typeof claims.jti !== 'string' || + !JTI.test(claims.jti) || + !Number.isSafeInteger(claims.iat) || + !Number.isSafeInteger(claims.exp) || + (claims.iat as number) > nowSeconds + FUTURE_IAT_SKEW_SECONDS || + (claims.exp as number) <= nowSeconds || + (claims.exp as number) <= (claims.iat as number) || + (claims.exp as number) - (claims.iat as number) > HOST_SUBMIT_ASSERTION_MAX_TTL_SECONDS + ) { + return null + } + + return Object.freeze({ ...(claims as unknown as HostSubmitAssertionClaims) }) +} + +export async function consumeHostSubmitAssertionOnce( + claims: HostSubmitAssertionClaims, + nowMs = Date.now(), + redis: AssertionRedis = getRedis() +): Promise<'consumed' | 'replayed' | 'unavailable'> { + const remaining = claims.exp - Math.floor(nowMs / 1000) + if (remaining <= 0 || remaining > HOST_SUBMIT_ASSERTION_MAX_TTL_SECONDS) return 'replayed' + const fingerprint = createHash('sha256') + .update(USED_KEY_DOMAIN) + .update('\0') + .update(claims.jti) + .digest('hex') + try { + const result = await redis.set( + `host-submit-assertion:used:${fingerprint}`, + '1', + 'EX', + remaining, + 'NX' + ) + return result === 'OK' ? 'consumed' : 'replayed' + } catch { + return 'unavailable' + } +} diff --git a/apps/web/src/lib/server/domains/bug-reports/host-submit-authorization.ts b/apps/web/src/lib/server/domains/bug-reports/host-submit-authorization.ts new file mode 100644 index 000000000..9320acbac --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-authorization.ts @@ -0,0 +1,110 @@ +import { config } from '@/lib/server/config' +import type { UserId } from '@quackback/ids' +import { db, eq, user } from '@/lib/server/db' +import { getClientIp } from '@/lib/server/domains/api/rate-limit' +import { getWidgetSecret } from '@/lib/server/domains/settings/settings.widget' +import { + computeHostSubmitReportDigestServer, + consumeHostSubmitAssertionOnce, + verifyHostSubmitAssertion, + type HostSubmitAssertionClaims, +} from './host-submit-assertion' +import { HostBugReportSubmitError } from './host-submit-errors' +import { isBugReportHostOriginAllowed } from './host-submit-origin-policy' +import { checkHostSubmitRateLimit, type HostSubmitRateLimitResult } from './host-submit-rate-limit' + +export interface HostSubmitAuthorizationData { + contract: 'iplaycafe.quackback.report-submit/1' + requestId: string + clientSubmissionId: string + summary: string + impact: string + title: string + content: string + hostOrigin: string + hostSubmitAssertion: string +} + +export interface HostSubmitAuthorizationDependencies { + resolveActorExternalId(userId: string): Promise + getWidgetSecret(): Promise + getAudience(): string + isOriginAllowed(origin: string): boolean + consumeAssertion( + claims: HostSubmitAssertionClaims, + nowMs: number + ): Promise<'consumed' | 'replayed' | 'unavailable'> + checkRateLimit(principalId: string, clientIp: string): Promise + now(): number +} + +async function resolveActorExternalId(userId: string): Promise { + const [record] = await db + .select({ externalId: user.externalId }) + .from(user) + .where(eq(user.id, userId as UserId)) + .limit(1) + return record?.externalId ?? null +} + +const defaultDependencies: HostSubmitAuthorizationDependencies = { + resolveActorExternalId, + getWidgetSecret, + getAudience: () => new URL(config.baseUrl).origin, + isOriginAllowed: isBugReportHostOriginAllowed, + consumeAssertion: consumeHostSubmitAssertionOnce, + checkRateLimit: checkHostSubmitRateLimit, + now: Date.now, +} + +export async function authorizeHostSubmitMutation( + input: { + data: HostSubmitAuthorizationData + actor: { userId: string; principalId: string } + headers: Headers + }, + dependencies: HostSubmitAuthorizationDependencies = defaultDependencies +): Promise { + try { + if (!dependencies.isOriginAllowed(input.data.hostOrigin)) { + throw new HostBugReportSubmitError('unauthorized') + } + const [actorSubject, secret] = await Promise.all([ + dependencies.resolveActorExternalId(input.actor.userId), + dependencies.getWidgetSecret(), + ]) + if (!actorSubject) throw new HostBugReportSubmitError('unauthorized') + if (!secret) throw new HostBugReportSubmitError('unavailable') + + const nowMs = dependencies.now() + const reportDigest = computeHostSubmitReportDigestServer(input.data.summary, input.data.impact) + const claims = verifyHostSubmitAssertion( + input.data.hostSubmitAssertion, + { + audience: dependencies.getAudience(), + actorSubject, + hostOrigin: input.data.hostOrigin, + contract: input.data.contract, + reportDigest, + clientSubmissionId: input.data.clientSubmissionId, + requestId: input.data.requestId, + }, + secret, + nowMs + ) + if (!claims) throw new HostBugReportSubmitError('unauthorized') + + const consumed = await dependencies.consumeAssertion(claims, nowMs) + if (consumed === 'replayed') throw new HostBugReportSubmitError('unauthorized') + if (consumed !== 'consumed') throw new HostBugReportSubmitError('retryable_failure') + + const rate = await dependencies.checkRateLimit( + input.actor.principalId, + getClientIp(input.headers) + ) + if (!rate.allowed) throw new HostBugReportSubmitError('retryable_failure') + } catch (error) { + if (error instanceof HostBugReportSubmitError) throw error + throw new HostBugReportSubmitError('retryable_failure') + } +} diff --git a/apps/web/src/lib/server/domains/bug-reports/host-submit-rate-limit.ts b/apps/web/src/lib/server/domains/bug-reports/host-submit-rate-limit.ts new file mode 100644 index 000000000..b9208f3fc --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-rate-limit.ts @@ -0,0 +1,55 @@ +import { createHash } from 'node:crypto' +import { + bucketRetryAfter, + incrementBuckets, + type RateBucketSpec, +} from '@/lib/server/utils/redis-rate-bucket' + +export type HostSubmitRateLimitResult = + | { allowed: true } + | { allowed: false; reason: 'limited' | 'unavailable'; retryAfter: number } + +const WINDOW_SECONDS = 15 * 60 +const PRINCIPAL_LIMIT = 20 +const IP_LIMIT = 100 + +function fingerprint(domain: 'principal' | 'ip', value: string): string { + return createHash('sha256') + .update(`iplaycafe/quackback-host-submit/${domain}/v1`) + .update('\0') + .update(value) + .digest('hex') +} + +export async function checkHostSubmitRateLimit( + principalId: string, + clientIp: string +): Promise { + const principalSpec: RateBucketSpec = { + key: `host-submit:principal:${fingerprint('principal', principalId)}`, + windowSeconds: WINDOW_SECONDS, + } + const ipSpec: RateBucketSpec = { + key: `host-submit:ip:${fingerprint('ip', clientIp)}`, + windowSeconds: WINDOW_SECONDS, + } + const [principalCount, ipCount] = await incrementBuckets([principalSpec, ipSpec]) + if (principalCount === null || ipCount === null) { + return { allowed: false, reason: 'unavailable', retryAfter: 30 } + } + if (principalCount > PRINCIPAL_LIMIT) { + return { + allowed: false, + reason: 'limited', + retryAfter: await bucketRetryAfter(principalSpec), + } + } + if (ipCount > IP_LIMIT) { + return { + allowed: false, + reason: 'limited', + retryAfter: await bucketRetryAfter(ipSpec), + } + } + return { allowed: true } +} diff --git a/apps/web/src/lib/server/domains/principals/__tests__/anon-sweep-race.test.ts b/apps/web/src/lib/server/domains/principals/__tests__/anon-sweep-race.test.ts new file mode 100644 index 000000000..d3ec91590 --- /dev/null +++ b/apps/web/src/lib/server/domains/principals/__tests__/anon-sweep-race.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const sql = Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings: Array.from(strings), + values, + })), + { + raw: vi.fn((value: string) => ({ raw: value })), + join: vi.fn((chunks: unknown[], separator: unknown) => ({ chunks, separator })), + } + ) + return { + execute: vi.fn(), + transactionExecute: vi.fn(), + transaction: vi.fn(), + sql, + } +}) + +vi.mock('@/lib/server/db', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + db: { + execute: (...args: unknown[]) => mocks.execute(...args), + transaction: (...args: unknown[]) => mocks.transaction(...args), + }, + sql: mocks.sql, + } +}) + +vi.mock('@/lib/server/logger', () => ({ + logger: { + child: () => ({ warn: vi.fn() }), + }, +})) + +import { sweepAnonymousPrincipals } from '../anon-sweep.service' + +const PRINCIPAL_ID = 'principal_anon' +const USER_ID = 'user_anon' + +function statement(callIndex: number): string { + const query = mocks.transactionExecute.mock.calls[callIndex]![0] as { + strings: string[] + } + return query.strings.join('?').replace(/\s+/g, ' ').trim() +} + +describe('sweepAnonymousPrincipals race safety', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.execute.mockResolvedValue([{ principal_id: PRINCIPAL_ID, user_id: USER_ID }]) + mocks.transaction.mockImplementation( + async (run: (tx: { execute: typeof mocks.transactionExecute }) => unknown) => + run({ execute: mocks.transactionExecute }) + ) + }) + + it('locks user, all sessions, then principal before revalidating references and exact deletes', async () => { + mocks.transactionExecute + .mockResolvedValueOnce([{ id: USER_ID }]) + .mockResolvedValueOnce([{ id: 'expired_session' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: PRINCIPAL_ID }]) + .mockResolvedValueOnce([{ id: PRINCIPAL_ID }]) + .mockResolvedValueOnce([{ id: 'expired_session' }]) + .mockResolvedValueOnce([{ id: PRINCIPAL_ID }]) + .mockResolvedValueOnce([{ id: USER_ID }]) + + await expect(sweepAnonymousPrincipals()).resolves.toEqual({ + candidates: 1, + deleted: 1, + }) + + expect(statement(0)).toContain('FOR UPDATE') + expect(statement(1)).toContain('ORDER BY id FOR UPDATE') + expect(statement(2)).toContain('expires_at > now()') + expect(statement(3)).toContain("type = 'anonymous'") + expect(statement(3)).toContain('FOR UPDATE') + expect( + ( + mocks.transactionExecute.mock.calls[4]![0] as { + values: Array<{ chunks?: unknown[] }> + } + ).values.some((value) => Array.isArray(value?.chunks) && value.chunks.length > 0) + ).toBe(true) + expect(statement(5)).toContain('DELETE FROM') + expect(statement(5)).toContain('RETURNING id') + expect(statement(6)).toContain('RETURNING id') + expect(statement(7)).toContain('RETURNING id') + }) + + it('skips without deleting when a session became live after candidate discovery', async () => { + mocks.transactionExecute + .mockResolvedValueOnce([{ id: USER_ID }]) + .mockResolvedValueOnce([{ id: 'new_live_session' }]) + .mockResolvedValueOnce([{ id: 'new_live_session' }]) + + await expect(sweepAnonymousPrincipals()).resolves.toEqual({ + candidates: 1, + deleted: 0, + }) + + expect(mocks.transactionExecute).toHaveBeenCalledTimes(3) + expect( + mocks.transactionExecute.mock.calls.some(([query]) => + (query as { strings: string[] }).strings.join(' ').includes('DELETE FROM') + ) + ).toBe(false) + }) + + it('skips without deleting when a principal reference appeared after candidate discovery', async () => { + mocks.transactionExecute + .mockResolvedValueOnce([{ id: USER_ID }]) + .mockResolvedValueOnce([{ id: 'expired_session' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: PRINCIPAL_ID }]) + .mockResolvedValueOnce([]) + + await expect(sweepAnonymousPrincipals()).resolves.toEqual({ + candidates: 1, + deleted: 0, + }) + + expect(mocks.transactionExecute).toHaveBeenCalledTimes(5) + expect( + mocks.transactionExecute.mock.calls.some(([query]) => + (query as { strings: string[] }).strings.join(' ').includes('DELETE FROM') + ) + ).toBe(false) + }) +}) diff --git a/apps/web/src/lib/server/domains/principals/anon-sweep.service.ts b/apps/web/src/lib/server/domains/principals/anon-sweep.service.ts index f9c6aca88..fdf9c2e4b 100644 --- a/apps/web/src/lib/server/domains/principals/anon-sweep.service.ts +++ b/apps/web/src/lib/server/domains/principals/anon-sweep.service.ts @@ -18,7 +18,7 @@ * Each principal is still removed in its own transaction so an unexpected * restrict reference skips just that row rather than failing the batch. */ -import { db, eq, sql, principal, session, user } from '@/lib/server/db' +import { db, sql } from '@/lib/server/db' import { logger } from '@/lib/server/logger' import { IDENTITY_MERGE_TOMBSTONE_RETENTION_DAYS, @@ -68,6 +68,31 @@ function anonymousSweepReferenceGuards() { ) } +function resultRows(result: unknown): T[] { + if (Array.isArray(result)) return result as T[] + if ( + result !== null && + typeof result === 'object' && + Array.isArray((result as { rows?: unknown }).rows) + ) { + return (result as { rows: T[] }).rows + } + return [] +} + +function isExactIdRow(result: unknown, id: string): boolean { + const rows = resultRows<{ id?: unknown }>(result) + return rows.length === 1 && rows[0]?.id === id +} + +function returnedIds(result: unknown): string[] | null { + const rows = resultRows<{ id?: unknown }>(result) + if (rows.some((row) => typeof row.id !== 'string')) return null + return rows.map((row) => row.id as string).sort() +} + +class AnonymousSweepDeleteMismatch extends Error {} + /** * Delete only expired source-session markers created by identity merge. * @@ -170,15 +195,102 @@ export async function sweepAnonymousPrincipals(opts?: { let deleted = 0 for (const t of targets) { try { - await db.transaction(async (tx) => { - await tx.delete(principal).where(eq(principal.id, t.principal_id as never)) - await tx.delete(session).where(eq(session.userId, t.user_id as never)) - await tx.delete(user).where(eq(user.id, t.user_id as never)) + const removed = await db.transaction(async (tx) => { + // Lock order is global and deliberate. Holding the user row blocks a + // concurrent session FK insert; locking every existing session blocks + // expiry extension; the principal lock blocks new principal-FK rows. + const lockedUser = await tx.execute(sql` + SELECT id + FROM "user" + WHERE id = ${t.user_id} + FOR UPDATE + `) + if (!isExactIdRow(lockedUser, t.user_id)) return false + + const lockedSessions = await tx.execute(sql` + SELECT id + FROM session + WHERE user_id = ${t.user_id} + ORDER BY id + FOR UPDATE + `) + const lockedSessionIds = returnedIds(lockedSessions) + if (lockedSessionIds === null) return false + + const liveSessions = await tx.execute(sql` + SELECT id + FROM session + WHERE user_id = ${t.user_id} + AND expires_at > now() + LIMIT 1 + `) + if (resultRows(liveSessions).length !== 0) return false + + const lockedPrincipal = await tx.execute(sql` + SELECT id + FROM principal + WHERE id = ${t.principal_id} + AND user_id = ${t.user_id} + AND type = 'anonymous' + AND created_at < ${cutoffIso}::timestamptz + FOR UPDATE + `) + if (!isExactIdRow(lockedPrincipal, t.principal_id)) return false + + const stillUnreferenced = await tx.execute(sql` + SELECT pr.id + FROM principal pr + WHERE pr.id = ${t.principal_id} + AND ${anonymousSweepReferenceGuards()} + `) + if (!isExactIdRow(stillUnreferenced, t.principal_id)) return false + + const deletedSessions = returnedIds( + await tx.execute(sql` + DELETE FROM session + WHERE user_id = ${t.user_id} + AND expires_at <= now() + RETURNING id + `) + ) + if ( + deletedSessions === null || + deletedSessions.length !== lockedSessionIds.length || + deletedSessions.some((id, index) => id !== lockedSessionIds[index]) + ) { + throw new AnonymousSweepDeleteMismatch() + } + + const deletedPrincipal = await tx.execute(sql` + DELETE FROM principal + WHERE id = ${t.principal_id} + AND user_id = ${t.user_id} + RETURNING id + `) + if (!isExactIdRow(deletedPrincipal, t.principal_id)) { + throw new AnonymousSweepDeleteMismatch() + } + + const deletedUser = await tx.execute(sql` + DELETE FROM "user" + WHERE id = ${t.user_id} + RETURNING id + `) + if (!isExactIdRow(deletedUser, t.user_id)) { + throw new AnonymousSweepDeleteMismatch() + } + return true }) - deleted++ + if (removed) deleted++ } catch (err) { // An unexpected referencing row (FK restrict) — leave it and move on. - log.warn({ principal_id: t.principal_id, err }, 'anon-sweep skipped principal') + log.warn( + { + failureClass: + err instanceof AnonymousSweepDeleteMismatch ? 'delete_mismatch' : 'database', + }, + 'anon-sweep skipped principal' + ) } } diff --git a/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts b/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts index f0f4b166e..46cdae45e 100644 --- a/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/bug-report-receipts-boundary.test.ts @@ -38,6 +38,7 @@ const state = vi.hoisted(() => ({ getDefaultStatus: vi.fn(), getSettings: vi.fn(), verifyMedia: vi.fn(), + authorizeHostSubmit: vi.fn(), receiptTransaction: vi.fn(), receipts: new Map>(), eqCalls: [] as Array<[unknown, unknown]>, @@ -49,6 +50,30 @@ const state = vi.hoisted(() => ({ const REPORT_REF = 'qbr_abcdefghijklmnopqrstuvwx' const CLIENT_ID = '11111111-1111-4111-8111-111111111111' const POST_ID = '22222222-2222-4222-8222-222222222222' +const HOST_REQUEST_ID = '33333333-3333-4333-8333-333333333333' + +function hostInput( + overrides: Partial<{ + contract: 'iplaycafe.quackback.report-submit/1' + requestId: string + clientSubmissionId: string + summary: string + impact: string + hostOrigin: string + hostSubmitAssertion: string + }> = {} +) { + return { + contract: 'iplaycafe.quackback.report-submit/1' as const, + requestId: HOST_REQUEST_ID, + clientSubmissionId: CLIENT_ID, + summary: 'Save button does nothing', + impact: '', + hostOrigin: 'https://app.example.test', + hostSubmitAssertion: 'header.payload.signature', + ...overrides, + } +} vi.mock('@tanstack/react-start', () => ({ createServerFn: () => { @@ -201,6 +226,9 @@ vi.mock('../portal-access', () => ({ vi.mock('@/lib/server/domains/settings/settings.widget', () => ({ getWidgetConfig: (...args: unknown[]) => state.getWidgetConfig(...args), })) +vi.mock('@/lib/server/domains/bug-reports/host-submit-authorization', () => ({ + authorizeHostSubmitMutation: (...args: unknown[]) => state.authorizeHostSubmit(...args), +})) vi.mock('@/lib/server/domains/bug-reports/receipt.store', () => ({ bugReportReceiptStore: { @@ -334,6 +362,7 @@ describe('authenticated bug-report receipt handlers', () => { decision: { granted: true, reason: 'public' }, } state.createPost.mockReset().mockResolvedValue({ id: POST_ID }) + state.authorizeHostSubmit.mockReset().mockResolvedValue(undefined) state.createComment.mockReset().mockResolvedValue({}) state.completeEffects.mockReset().mockRejectedValue(new Error('queue unavailable')) state.processEvent.mockReset() @@ -621,20 +650,34 @@ describe('configured host bug-report boundary', () => { }) it('accepts only the provider-safe host fields and resolves the configured board slug', async () => { - const hostInput = hostBugReportServerInputSchema.parse({ - clientSubmissionId: CLIENT_ID, - title: 'Save button', - content: 'Save button does nothing', - }) - - expect(Object.keys(hostInput).sort()).toEqual(['clientSubmissionId', 'content', 'title']) + const parsedHostInput = hostBugReportServerInputSchema.parse(hostInput()) + + expect(Object.keys(parsedHostInput).sort()).toEqual([ + 'clientSubmissionId', + 'contract', + 'hostOrigin', + 'hostSubmitAssertion', + 'impact', + 'requestId', + 'summary', + ]) for (const forbidden of ['boardId', 'principalId', 'status', 'media', 'context']) { expect( - hostBugReportServerInputSchema.safeParse({ ...hostInput, [forbidden]: 'forbidden' }).success + hostBugReportServerInputSchema.safeParse({ + ...parsedHostInput, + [forbidden]: 'forbidden', + }).success ).toBe(false) } + expect( + hostBugReportServerInputSchema.safeParse({ + clientSubmissionId: CLIENT_ID, + summary: 'Bearer alone', + impact: '', + }).success + ).toBe(false) - const result = await submitConfiguredHostBugReport(hostInput) + const result = await submitConfiguredHostBugReport(parsedHostInput) expect(result.accepted).toBe(true) expect(state.getPublicBoardBySlug).toHaveBeenCalledWith( 'bug-reports', @@ -643,21 +686,47 @@ describe('configured host bug-report boundary', () => { expect(state.createPost.mock.calls[0][0]).toEqual( expect.objectContaining({ boardId: 'board_bug_reports', - title: 'Save button', + title: 'Save button does nothing', content: 'Save button does nothing', }) ) }) - it('returns the same private receipt for a principal retry and creates one post', async () => { - const input = { - clientSubmissionId: CLIENT_ID, - title: 'Save button', - content: 'Save button does nothing', - } + it('admits the host assertion before config, receipt lookup, or mutation', async () => { + state.authorizeHostSubmit.mockRejectedValueOnce(new HostBugReportSubmitError('unauthorized')) + + await expect(submitConfiguredHostBugReport(hostInput())).resolves.toEqual({ + accepted: false, + reason: 'unauthorized', + }) - const first = await submitConfiguredHostBugReport(input) - const second = await submitConfiguredHostBugReport(input) + expect(state.authorizeHostSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + contract: 'iplaycafe.quackback.report-submit/1', + requestId: HOST_REQUEST_ID, + summary: 'Save button does nothing', + impact: '', + hostOrigin: 'https://app.example.test', + hostSubmitAssertion: 'header.payload.signature', + }), + actor: { userId: 'user_A', principalId: 'principal_A' }, + }) + ) + expect(state.getWidgetConfig).not.toHaveBeenCalled() + expect(state.getPublicBoardBySlug).not.toHaveBeenCalled() + expect(state.receiptTransaction).not.toHaveBeenCalled() + expect(state.createPost).not.toHaveBeenCalled() + }) + + it('returns the same private receipt for a principal retry and creates one post', async () => { + const first = await submitConfiguredHostBugReport(hostInput()) + const second = await submitConfiguredHostBugReport( + hostInput({ + requestId: '44444444-4444-4444-8444-444444444444', + hostSubmitAssertion: 'fresh.header.signature', + }) + ) expect(first.accepted).toBe(true) expect(second.accepted).toBe(true) @@ -668,14 +737,14 @@ describe('configured host bug-report boundary', () => { }) it('keeps the same client id isolated between principals', async () => { - const input = { - clientSubmissionId: CLIENT_ID, - title: 'Save button', - content: 'Save button does nothing', - } - const first = await submitConfiguredHostBugReport(input) + const first = await submitConfiguredHostBugReport(hostInput()) state.principal = 'principal_B' - const second = await submitConfiguredHostBugReport(input) + const second = await submitConfiguredHostBugReport( + hostInput({ + requestId: '44444444-4444-4444-8444-444444444444', + hostSubmitAssertion: 'other.header.signature', + }) + ) expect(first.accepted).toBe(true) expect(second.accepted).toBe(true) @@ -688,11 +757,12 @@ describe('configured host bug-report boundary', () => { it('maps expired/anonymous auth and a completed portal denial to unauthorized', async () => { state.principalType = 'anonymous' await expect( - submitConfiguredHostBugReport({ - clientSubmissionId: CLIENT_ID, - title: 'Anonymous', - content: '', - }) + submitConfiguredHostBugReport( + hostInput({ + summary: 'Anonymous', + impact: '', + }) + ) ).resolves.toEqual({ accepted: false, reason: 'unauthorized' }) state.principalType = 'user' @@ -701,11 +771,12 @@ describe('configured host bug-report boundary', () => { decision: { granted: false, reason: 'unauthenticated' }, } await expect( - submitConfiguredHostBugReport({ - clientSubmissionId: CLIENT_ID, - title: 'Expired', - content: '', - }) + submitConfiguredHostBugReport( + hostInput({ + summary: 'Expired', + impact: '', + }) + ) ).resolves.toEqual({ accepted: false, reason: 'unauthorized' }) }) @@ -714,29 +785,32 @@ describe('configured host bug-report boundary', () => { bugReport: { enabled: false, receipts: false, boardSlug: 'bug-reports' }, }) await expect( - submitConfiguredHostBugReport({ - clientSubmissionId: CLIENT_ID, - title: 'Disabled', - content: '', - }) + submitConfiguredHostBugReport( + hostInput({ + summary: 'Disabled', + impact: '', + }) + ) ).resolves.toEqual({ accepted: false, reason: 'unavailable' }) state.getPublicBoardBySlug.mockResolvedValueOnce(null) await expect( - submitConfiguredHostBugReport({ - clientSubmissionId: CLIENT_ID, - title: 'Missing board', - content: '', - }) + submitConfiguredHostBugReport( + hostInput({ + summary: 'Missing board', + impact: '', + }) + ) ).resolves.toEqual({ accepted: false, reason: 'unavailable' }) state.canCreatePost.mockReturnValueOnce({ allowed: false, reason: 'Private policy detail' }) await expect( - submitConfiguredHostBugReport({ - clientSubmissionId: CLIENT_ID, - title: 'Ineligible board', - content: '', - }) + submitConfiguredHostBugReport( + hostInput({ + summary: 'Ineligible board', + impact: '', + }) + ) ).resolves.toEqual({ accepted: false, reason: 'unavailable' }) }) @@ -751,11 +825,9 @@ describe('configured host bug-report boundary', () => { it('serializes and observes no raw provider exception canary', async () => { state.createPost.mockRejectedValue(new Error('canary-private-provider-message')) - const result = await submitConfiguredHostBugReport({ - clientSubmissionId: CLIENT_ID, - title: 'Safe title', - content: 'Safe content', - }) + const result = await submitConfiguredHostBugReport( + hostInput({ summary: 'Safe title', impact: 'Safe content' }) + ) const observed = JSON.stringify({ result, logs: state.log.mock.calls, diff --git a/apps/web/src/lib/server/functions/bug-report-host-submit.ts b/apps/web/src/lib/server/functions/bug-report-host-submit.ts index 1be280da4..31e25ffb3 100644 --- a/apps/web/src/lib/server/functions/bug-report-host-submit.ts +++ b/apps/web/src/lib/server/functions/bug-report-host-submit.ts @@ -3,6 +3,7 @@ import { createServerFn } from '@tanstack/react-start' import { isBugReportHostOriginAllowed } from '@/lib/server/domains/bug-reports/host-submit-origin-policy' import { mapHostSubmitError } from '@/lib/server/domains/bug-reports/host-submit-errors' import { + mapHostSubmitText, parseHostSubmitReceipt, type HostSubmitServerResult, } from '@/lib/shared/bugreport/host-submit-contract' @@ -12,9 +13,20 @@ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f export const hostBugReportServerInputSchema = z .object({ + contract: z.literal('iplaycafe.quackback.report-submit/1'), + requestId: z.string().regex(UUID_V4), clientSubmissionId: z.string().regex(UUID_V4), - title: z.string().min(1).max(200), - content: z.string().max(10_000), + summary: z + .string() + .min(1) + .max(2_000) + .refine((value) => value === value.trim()), + impact: z + .string() + .max(1_000) + .refine((value) => value === value.trim()), + hostOrigin: z.string().min(1).max(2_048), + hostSubmitAssertion: z.string().min(1).max(4_096), }) .strict() @@ -25,7 +37,10 @@ export async function submitConfiguredHostBugReport( const { getRequestHeaders } = await import('@tanstack/react-start/server') const receipt = parseHostSubmitReceipt( await submitConfiguredBugReportHandler({ - data, + data: { + ...data, + ...mapHostSubmitText(data), + }, headers: getRequestHeaders(), }) ) @@ -47,4 +62,8 @@ export const authorizeBugReportHostOriginFn = createServerFn({ method: 'POST' }) export const submitHostBugReportFn = createServerFn({ method: 'POST' }) .validator(hostBugReportServerInputSchema) - .handler(async ({ data }) => submitConfiguredHostBugReport(data)) + .handler(async ({ data }) => { + const { setResponseHeader } = await import('@tanstack/react-start/server') + setResponseHeader('Cache-Control', 'private, no-store') + return submitConfiguredHostBugReport(data) + }) diff --git a/apps/web/src/lib/server/functions/bug-report-receipts.ts b/apps/web/src/lib/server/functions/bug-report-receipts.ts index 9fe09e676..16caec464 100644 --- a/apps/web/src/lib/server/functions/bug-report-receipts.ts +++ b/apps/web/src/lib/server/functions/bug-report-receipts.ts @@ -59,6 +59,10 @@ import { } from './auth-helpers' import { resolvePortalAccessForHostSubmit, resolvePortalAccessForRequest } from './portal-access' import { HostBugReportSubmitError } from '@/lib/server/domains/bug-reports/host-submit-errors' +import { + authorizeHostSubmitMutation, + type HostSubmitAuthorizationData, +} from '@/lib/server/domains/bug-reports/host-submit-authorization' import { canCreatePost } from '@/lib/server/policy' const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -144,9 +148,7 @@ async function submitBugReportHandler({ headers, configuredHost = false, }: { - data: - | z.infer - | { clientSubmissionId: string; title: string; content: string } + data: z.infer | HostSubmitAuthorizationData headers: Headers configuredHost?: boolean }): Promise { @@ -154,6 +156,16 @@ async function submitBugReportHandler({ const auth = configuredHost ? await requireHostBugReportPrincipal() : await requireBugReportPrincipal() + if (configuredHost) { + await authorizeHostSubmitMutation({ + data: data as HostSubmitAuthorizationData, + actor: { + userId: auth.user.id, + principalId: auth.principal.id, + }, + headers, + }) + } const widgetConfig = await getWidgetConfig() requireReceiptFeature(widgetConfig, failures) const actor = await policyActorFromAuth(auth) @@ -275,7 +287,7 @@ export const submitConfiguredBugReportHandler = createServerOnlyFn( data, headers, }: { - data: { clientSubmissionId: string; title: string; content: string } + data: HostSubmitAuthorizationData headers: Headers }): Promise => submitBugReportHandler({ data, headers, configuredHost: true }) ) diff --git a/apps/web/src/lib/server/utils/__tests__/bounded-json-body.test.ts b/apps/web/src/lib/server/utils/__tests__/bounded-json-body.test.ts new file mode 100644 index 000000000..ad30f57ce --- /dev/null +++ b/apps/web/src/lib/server/utils/__tests__/bounded-json-body.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { readBoundedJson } from '../bounded-json-body' + +const encoder = new TextEncoder() + +function streamingRequest( + chunks: Uint8Array[], + options: { declaredLength?: string } = {} +): { request: Request; cancelled: ReturnType } { + const cancelled = vi.fn() + let index = 0 + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks[index] + if (chunk) { + index += 1 + controller.enqueue(chunk) + } else { + controller.close() + } + }, + cancel: cancelled, + }) + const headers = new Headers({ 'content-type': 'application/json' }) + if (options.declaredLength !== undefined) { + headers.set('content-length', options.declaredLength) + } + return { + request: new Request('https://feedback.example.test/api/widget/identify', { + method: 'POST', + headers, + body, + duplex: 'half', + } as RequestInit & { duplex: 'half' }), + cancelled, + } +} + +describe('readBoundedJson', () => { + it('preserves valid custom attributes below the byte cap', async () => { + const value = { + id: 'actor', + email: 'actor@example.test', + plan: 'enterprise', + seatCount: 42, + nested: { safe: true }, + } + const bytes = encoder.encode(JSON.stringify(value)) + const { request } = streamingRequest([bytes.subarray(0, 7), bytes.subarray(7)]) + + await expect(readBoundedJson(request, bytes.byteLength)).resolves.toEqual({ + kind: 'ok', + value, + }) + }) + + it('cancels without reading when the declared length is over the cap', async () => { + const { request, cancelled } = streamingRequest([encoder.encode('{}')], { + declaredLength: '4097', + }) + + await expect(readBoundedJson(request, 4096)).resolves.toEqual({ kind: 'too_large' }) + expect(cancelled).toHaveBeenCalledTimes(1) + }) + + it('rejects a chunked body once cumulative bytes exceed the cap and cancels the reader', async () => { + const { request, cancelled } = streamingRequest([ + encoder.encode('{"id":"actor",'), + encoder.encode(`"padding":"${'x'.repeat(100)}"}`), + ]) + + await expect(readBoundedJson(request, 32)).resolves.toEqual({ kind: 'too_large' }) + expect(cancelled).toHaveBeenCalledTimes(1) + }) + + it('does not trust a lying small content-length header', async () => { + const { request, cancelled } = streamingRequest( + [encoder.encode(`{"padding":"${'x'.repeat(100)}"}`)], + { declaredLength: '2' } + ) + + await expect(readBoundedJson(request, 32)).resolves.toEqual({ kind: 'too_large' }) + expect(cancelled).toHaveBeenCalledTimes(1) + }) + + it.each(['-1', '1.5', 'not-a-number'])( + 'rejects an invalid declared content length %s', + async (declaredLength) => { + const { request } = streamingRequest([encoder.encode('{}')], { declaredLength }) + + await expect(readBoundedJson(request, 4096)).resolves.toEqual({ kind: 'invalid' }) + } + ) + + it('rejects malformed UTF-8 and malformed JSON without exposing parser details', async () => { + const invalidUtf8 = streamingRequest([Uint8Array.from([0xc3, 0x28])]).request + const invalidJson = streamingRequest([encoder.encode('{"id":')]).request + + await expect(readBoundedJson(invalidUtf8, 4096)).resolves.toEqual({ kind: 'invalid' }) + await expect(readBoundedJson(invalidJson, 4096)).resolves.toEqual({ kind: 'invalid' }) + }) +}) diff --git a/apps/web/src/lib/server/utils/__tests__/redis-rate-bucket.test.ts b/apps/web/src/lib/server/utils/__tests__/redis-rate-bucket.test.ts index 94b425486..e4dab0d97 100644 --- a/apps/web/src/lib/server/utils/__tests__/redis-rate-bucket.test.ts +++ b/apps/web/src/lib/server/utils/__tests__/redis-rate-bucket.test.ts @@ -51,6 +51,37 @@ describe('incrementBucket', () => { const result = await incrementBucket({ key: 'k', windowSeconds: 60 }) expect(result.count).toBeNull() }) + + it.each([ + [ + 'INCR', + [ + [new Error('incr failed'), null], + [null, 1], + ], + ], + [ + 'EXPIRE', + [ + [null, 1], + [new Error('expire failed'), null], + ], + ], + ['missing reply', [[null, 1]]], + [ + 'invalid count', + [ + [null, 'not-a-count'], + [null, 1], + ], + ], + ])('returns count=null when the %s pipeline slot fails', async (_name, replies) => { + mockExec.mockResolvedValueOnce(replies) + + await expect(incrementBucket({ key: 'k', windowSeconds: 60 })).resolves.toEqual({ + count: null, + }) + }) }) describe('incrementBuckets', () => { @@ -78,6 +109,25 @@ describe('incrementBuckets', () => { expect(counts).toEqual([null, null]) }) + it('fails only the specs whose INCR or EXPIRE pipeline slot errored', async () => { + mockExec.mockResolvedValueOnce([ + [new Error('a incr failed'), null], + [null, 1], + [null, 4], + [new Error('b expire failed'), null], + [null, 9], + [null, 1], + ]) + + const counts = await incrementBuckets([ + { key: 'a', windowSeconds: 60 }, + { key: 'b', windowSeconds: 60 }, + { key: 'c', windowSeconds: 60 }, + ]) + + expect(counts).toEqual([null, null, 9]) + }) + it('returns an empty array for zero specs (no pipeline call)', async () => { const counts = await incrementBuckets([]) expect(counts).toEqual([]) diff --git a/apps/web/src/lib/server/utils/bounded-json-body.ts b/apps/web/src/lib/server/utils/bounded-json-body.ts new file mode 100644 index 000000000..874d41f15 --- /dev/null +++ b/apps/web/src/lib/server/utils/bounded-json-body.ts @@ -0,0 +1,75 @@ +export type BoundedJsonResult = + | { kind: 'ok'; value: unknown } + | { kind: 'invalid' } + | { kind: 'too_large' } + +async function cancelBody(body: ReadableStream | null): Promise { + if (!body || body.locked) return + const reader = body.getReader() + try { + await reader.cancel() + } catch { + // The response remains bounded even when transport cancellation fails. + } finally { + reader.releaseLock() + } +} + +/** + * Read JSON without ever buffering more than `maxBytes`. + * + * Content-Length is only a fast rejection path. The streaming count remains + * authoritative because clients and intermediaries can omit or lie about it. + */ +export async function readBoundedJson( + request: Request, + maxBytes: number +): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new TypeError('maxBytes must be a positive safe integer') + } + + const declaredLength = request.headers.get('content-length') + if (declaredLength !== null) { + if (!/^\d+$/.test(declaredLength)) return { kind: 'invalid' } + if (Number(declaredLength) > maxBytes) { + await cancelBody(request.body) + return { kind: 'too_large' } + } + } + + const body = request.body + if (!body || body.locked) return { kind: 'invalid' } + const reader = body.getReader() + const decoder = new TextDecoder('utf-8', { fatal: true }) + let byteLength = 0 + let text = '' + + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) break + byteLength += chunk.value.byteLength + if (byteLength > maxBytes) { + try { + await reader.cancel() + } catch { + // Return the same bounded result even when transport cancellation fails. + } + return { kind: 'too_large' } + } + text += decoder.decode(chunk.value, { stream: true }) + } + text += decoder.decode() + } catch { + return { kind: 'invalid' } + } finally { + reader.releaseLock() + } + + try { + return { kind: 'ok', value: JSON.parse(text) } + } catch { + return { kind: 'invalid' } + } +} diff --git a/apps/web/src/lib/server/utils/redis-rate-bucket.ts b/apps/web/src/lib/server/utils/redis-rate-bucket.ts index 99d5278d6..5a4d3a9a0 100644 --- a/apps/web/src/lib/server/utils/redis-rate-bucket.ts +++ b/apps/web/src/lib/server/utils/redis-rate-bucket.ts @@ -19,6 +19,28 @@ export interface RateBucketResult { count: number | null } +type PipelineReply = readonly [unknown, unknown] + +function isPipelineReply(value: unknown): value is PipelineReply { + return Array.isArray(value) && value.length >= 2 +} + +/** + * Decode one INCR + EXPIRE pair. Redis pipelines resolve even when an + * individual command failed, so checking only `exec()` rejection silently + * converts `[Error, null]` into count zero. The caller decides whether a null + * bucket fails open or closed. + */ +function countFromPipelinePair(results: unknown, pairIndex: number): number | null { + if (!Array.isArray(results)) return null + const increment = results[pairIndex * 2] + const expiry = results[pairIndex * 2 + 1] + if (!isPipelineReply(increment) || !isPipelineReply(expiry)) return null + if (increment[0] != null || expiry[0] != null) return null + const count = Number(increment[1]) + return Number.isSafeInteger(count) && count >= 0 ? count : null +} + /** Increment one bucket. Returns the new count, or `null` on Redis error. */ export async function incrementBucket(spec: RateBucketSpec): Promise { try { @@ -26,9 +48,10 @@ export async function incrementBucket(spec: RateBucketSpec): Promise null) - // Each spec contributes 2 commands; INCR is the even-indexed reply. - return specs.map((_, i) => Number(results[i * 2]?.[1] ?? 0)) + return specs.map((_, i) => countFromPipelinePair(results, i)) } catch (error) { - log.error({ err: error }, 'pipeline increment failed, failing open') + log.error({ err: error }, 'pipeline increment failed') return specs.map(() => null) } } diff --git a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts index effc5b923..777d736e3 100644 --- a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts +++ b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts @@ -1,6 +1,7 @@ import contract from '../../../../../../../docs/fixtures/quackback-report-submit-contract-v1.json' import { describe, expect, it } from 'vitest' import { + computeHostSubmitReportDigest, mapHostSubmitText, parseHostSubmitReceipt, parseHostSubmitRequestCorrelation, @@ -27,11 +28,63 @@ function request(data: Record = {}) { clientSubmissionId: SUBMISSION_ID, summary: 'Save button does nothing', impact: 'Cannot finish checkout', + hostSubmitAssertion: `e30.e30.${'s'.repeat(43)}`, ...data, }, } } +describe('host submit report digest', () => { + it.each([ + [ + 'ASCII', + 'Save does nothing', + '', + 'f7319703af570f5daee887fc6f17ae7d487d0acca1b10c37234e649eb1119f9e', + ], + [ + 'CRLF', + 'Line 1\r\nLine 2', + 'Impact\r\nHigh', + '8d7a4bfc19324df13962f76b5b390ca66882f004162cf5850de6057f2e377909', + ], + [ + 'Thai', + 'บันทึกไม่ได้', + 'ผู้ใช้ติดขัด', + '23035ded7c21ce87e9ff9f263257bc9fb5f0529db4947d79bb84fd51e81a7e79', + ], + [ + 'emoji', + 'emoji 😀', + 'impact 🚫', + 'b5c6bc1b0754730c7742b9a9dfc59cd9efed3c8c8f6ce648fa3c6d6c0343341c', + ], + [ + 'unpaired surrogate', + 'broken \ud800', + 'tail \udfff', + '6cdf96a256364463a41754848065ccb59309db014d71ffe9b68f8a7edd3b5b99', + ], + ])( + 'matches the locked length-framed UTF-8 %s vector', + async (_name, summary, impact, expected) => { + await expect(computeHostSubmitReportDigest(summary, impact)).resolves.toBe(expected) + } + ) + + it('binds summary and impact independently without normalizing content', async () => { + const baseline = await computeHostSubmitReportDigest('Line 1\r\nLine 2', 'High') + + await expect(computeHostSubmitReportDigest('Line 1\nLine 2', 'High')).resolves.not.toBe( + baseline + ) + await expect(computeHostSubmitReportDigest('Line 1\r\nLine 2', 'Low')).resolves.not.toBe( + baseline + ) + }) +}) + describe('host submit request contract', () => { it('accepts only the exact request envelope and exact provider-safe input keys', () => { expect(parseHostSubmitRequestMessage(request())).toEqual(request().data) diff --git a/apps/web/src/lib/shared/bugreport/host-submit-contract.ts b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts index 4361ec086..271c7e676 100644 --- a/apps/web/src/lib/shared/bugreport/host-submit-contract.ts +++ b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts @@ -11,6 +11,7 @@ export type SubmitBugReportInputV1 = { clientSubmissionId: string summary: string impact: string + hostSubmitAssertion: string } export type HostSubmitRequestData = { @@ -19,6 +20,7 @@ export type HostSubmitRequestData = { clientSubmissionId: string summary: string impact: string + hostSubmitAssertion: string } export type HostSubmitStatus = @@ -63,6 +65,10 @@ const RESULT_TYPE = 'quackback:report-submit-result' const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const REPORT_REF = /^qbr_[A-Za-z0-9_-]{24}$/ const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ +const HOST_SUBMIT_ASSERTION = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/ +const HOST_SUBMIT_ASSERTION_PURPOSE = 'iplaycafe.quackback.host-submit/1' +export const HOST_SUBMIT_REPORT_DIGEST_DOMAIN = 'iplaycafe.quackback.report-digest/1' as const +const REPORT_DIGEST = /^[a-f0-9]{64}$/ const HOST_SUBMIT_STATUSES = new Set([ 'received', 'triaging', @@ -82,6 +88,36 @@ const HOST_SUBMIT_FAILURE_REASONS = new Set([ type DataRecord = Record +export function encodeHostSubmitReportDigestInput(summary: string, impact: string): Uint8Array { + const encoder = new TextEncoder() + const summaryBytes = encoder.encode(summary) + const impactBytes = encoder.encode(impact) + const prefix = encoder.encode( + `${HOST_SUBMIT_REPORT_DIGEST_DOMAIN}\nsummary:${summaryBytes.byteLength}\n` + ) + const separator = encoder.encode(`\nimpact:${impactBytes.byteLength}\n`) + const framed = new Uint8Array( + prefix.byteLength + summaryBytes.byteLength + separator.byteLength + impactBytes.byteLength + ) + let offset = 0 + for (const part of [prefix, summaryBytes, separator, impactBytes]) { + framed.set(part, offset) + offset += part.byteLength + } + return framed +} + +export async function computeHostSubmitReportDigest( + summary: string, + impact: string +): Promise { + const framed = encodeHostSubmitReportDigestInput(summary, impact) + const digestInput = new ArrayBuffer(framed.byteLength) + new Uint8Array(digestInput).set(framed) + const digest = await globalThis.crypto.subtle.digest('SHA-256', digestInput) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') +} + function readDataRecord(value: unknown): DataRecord | null { if (typeof value !== 'object' || value === null || Array.isArray(value)) return null @@ -127,7 +163,12 @@ function isIsoTimestamp(value: unknown): value is string { } function parseHostSubmitInput(value: unknown): SubmitBugReportInputV1 | null { - const record = readExactDataRecord(value, ['clientSubmissionId', 'summary', 'impact']) + const record = readExactDataRecord(value, [ + 'clientSubmissionId', + 'summary', + 'impact', + 'hostSubmitAssertion', + ]) if ( !record || !isUuidV4(record.clientSubmissionId) || @@ -135,7 +176,10 @@ function parseHostSubmitInput(value: unknown): SubmitBugReportInputV1 | null { typeof record.impact !== 'string' || record.impact.length > 1_000 || record.summary !== record.summary.trim() || - record.impact !== record.impact.trim() + record.impact !== record.impact.trim() || + typeof record.hostSubmitAssertion !== 'string' || + record.hostSubmitAssertion.length > 4_096 || + !HOST_SUBMIT_ASSERTION.test(record.hostSubmitAssertion) ) { return null } @@ -143,6 +187,7 @@ function parseHostSubmitInput(value: unknown): SubmitBugReportInputV1 | null { clientSubmissionId: record.clientSubmissionId, summary: record.summary, impact: record.impact, + hostSubmitAssertion: record.hostSubmitAssertion, } } @@ -164,6 +209,7 @@ export function parseHostSubmitRequestMessage(value: unknown): HostSubmitRequest 'clientSubmissionId', 'summary', 'impact', + 'hostSubmitAssertion', ]) if (!data || data.contract !== CONTRACT || !isUuidV4(data.requestId)) return null @@ -171,6 +217,7 @@ export function parseHostSubmitRequestMessage(value: unknown): HostSubmitRequest clientSubmissionId: data.clientSubmissionId, summary: data.summary, impact: data.impact, + hostSubmitAssertion: data.hostSubmitAssertion, }) if (!input) return null return { @@ -180,6 +227,145 @@ export function parseHostSubmitRequestMessage(value: unknown): HostSubmitRequest } } +function decodeCanonicalBase64UrlJson(segment: string): DataRecord | null { + try { + const padded = segment.replace(/-/g, '+').replace(/_/g, '/') + const padding = '='.repeat((4 - (padded.length % 4)) % 4) + const binary = atob(`${padded}${padding}`) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + const canonical = btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, '') + if (canonical !== segment) return null + return readDataRecord(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))) + } catch { + return null + } +} + +function canonicalHostSubmitOrigin(value: string): string | null { + try { + const parsed = new URL(value) + const localHttp = + parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname) + if ( + (parsed.protocol !== 'https:' && !localHttp) || + parsed.username || + parsed.password || + parsed.pathname !== '/' || + parsed.search || + parsed.hash + ) { + return null + } + return parsed.origin + } catch { + return null + } +} + +function reportDigestsMatch(actual: unknown, expected: string): boolean { + if (typeof actual !== 'string' || !REPORT_DIGEST.test(actual) || !REPORT_DIGEST.test(expected)) { + return false + } + let difference = 0 + for (let index = 0; index < 64; index += 1) { + difference |= actual.charCodeAt(index) ^ expected.charCodeAt(index) + } + return difference === 0 +} + +/** + * Browser-side structural preview only. The server independently verifies the + * HS256 signature and actor before mutation; this parser prevents a frozen + * source/origin tuple from forwarding an assertion bound to another transport. + */ +export function parseHostSubmitAssertionForProvider( + assertion: string, + expected: { + audience: string + hostOrigin: string + contract: HostSubmitRequestData['contract'] + reportDigest: string + clientSubmissionId: string + requestId: string + nowMs?: number + } +): { expiresAtSeconds: number } | null { + if ( + assertion.length > 4_096 || + !HOST_SUBMIT_ASSERTION.test(assertion) || + !isUuidV4(expected.clientSubmissionId) || + !isUuidV4(expected.requestId) + ) { + return null + } + const audience = canonicalHostSubmitOrigin(expected.audience) + const hostOrigin = canonicalHostSubmitOrigin(expected.hostOrigin) + if ( + audience === null || + audience !== expected.audience || + hostOrigin === null || + hostOrigin !== expected.hostOrigin + ) { + return null + } + const [headerSegment, payloadSegment] = assertion.split('.') + const header = decodeCanonicalBase64UrlJson(headerSegment!) + const claims = decodeCanonicalBase64UrlJson(payloadSegment!) + const exactHeader = header ? readExactDataRecord(header, ['alg', 'typ']) : null + const exactClaims = claims + ? readExactDataRecord(claims, [ + 'purpose', + 'aud', + 'sub', + 'hostOrigin', + 'contract', + 'reportDigest', + 'clientSubmissionId', + 'requestId', + 'jti', + 'iat', + 'exp', + ]) + : null + if ( + !exactHeader || + exactHeader.alg !== 'HS256' || + exactHeader.typ !== 'JWT' || + !exactClaims || + exactClaims.purpose !== HOST_SUBMIT_ASSERTION_PURPOSE || + exactClaims.aud !== audience || + typeof exactClaims.sub !== 'string' || + exactClaims.sub.length === 0 || + exactClaims.sub.length > 256 || + exactClaims.hostOrigin !== hostOrigin || + exactClaims.contract !== expected.contract || + !reportDigestsMatch(exactClaims.reportDigest, expected.reportDigest) || + exactClaims.clientSubmissionId !== expected.clientSubmissionId || + exactClaims.requestId !== expected.requestId || + typeof exactClaims.jti !== 'string' || + !/^[A-Za-z0-9_-]{16,128}$/.test(exactClaims.jti) || + !Number.isSafeInteger(exactClaims.iat) || + !Number.isSafeInteger(exactClaims.exp) + ) { + return null + } + const nowMs = expected.nowMs ?? Date.now() + if (!Number.isSafeInteger(nowMs) || nowMs < 0) return null + const nowSeconds = Math.floor(nowMs / 1000) + if ( + (exactClaims.iat as number) > nowSeconds + 5 || + (exactClaims.exp as number) <= nowSeconds || + (exactClaims.exp as number) <= (exactClaims.iat as number) || + (exactClaims.exp as number) - (exactClaims.iat as number) > 30 + ) { + return null + } + return { expiresAtSeconds: exactClaims.exp as number } +} + export function parseHostSubmitRequestCorrelation(value: unknown): { requestId: string } | null { const envelope = readExactEnvelope(value, REQUEST_TYPE) if (!envelope) return null @@ -281,7 +467,9 @@ function truncateTitle(value: string): string { return title } -export function mapHostSubmitText(input: SubmitBugReportInputV1): { +export function mapHostSubmitText( + input: Pick +): { clientSubmissionId: string title: string content: string diff --git a/apps/web/src/routes/api/widget/__tests__/identify-merge-route.test.ts b/apps/web/src/routes/api/widget/__tests__/identify-merge-route.test.ts index a137a226c..70d8c2d63 100644 --- a/apps/web/src/routes/api/widget/__tests__/identify-merge-route.test.ts +++ b/apps/web/src/routes/api/widget/__tests__/identify-merge-route.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { resolveMerge, checkMergeRate } = vi.hoisted(() => ({ +const { resolveMerge, resolveTargetActor, checkMergeRate, checkActorCapacity } = vi.hoisted(() => ({ resolveMerge: vi.fn(), + resolveTargetActor: vi.fn(), checkMergeRate: vi.fn(), + checkActorCapacity: vi.fn(), })) vi.mock('@tanstack/react-router', () => ({ @@ -11,9 +13,11 @@ vi.mock('@tanstack/react-router', () => ({ vi.mock('@/lib/server/auth/identify-merge', () => ({ resolveAndMergeAnonymousToken: (...args: unknown[]) => resolveMerge(...args), + resolveIdentityMergeTargetActor: (...args: unknown[]) => resolveTargetActor(...args), })) vi.mock('@/lib/server/auth/identity-merge-rate-limit', () => ({ checkIdentityMergeRateLimit: (...args: unknown[]) => checkMergeRate(...args), + checkIdentityMergeActorCapacity: (...args: unknown[]) => checkActorCapacity(...args), })) import { IDENTITY_MERGE_BODY_MAX_BYTES, Route } from '../identify.merge' @@ -27,6 +31,10 @@ type RouteOptions = { } const POST = (Route as unknown as { options: RouteOptions }).options.server.handlers.POST +const TARGET_ACTOR = { + userId: 'user_target', + principalId: 'principal_target', +} as const function request( body: unknown, @@ -75,6 +83,8 @@ describe('POST /api/widget/identify/merge', () => { beforeEach(() => { vi.clearAllMocks() checkMergeRate.mockResolvedValue({ allowed: true }) + checkActorCapacity.mockResolvedValue({ allowed: true }) + resolveTargetActor.mockResolvedValue(TARGET_ACTOR) }) it.each(['merged', 'already_merged', 'not_applicable'] as const)( @@ -89,7 +99,10 @@ describe('POST /api/widget/identify/merge', () => { expect(resolveMerge).toHaveBeenCalledWith({ previousToken: 'anonymous-source-token', targetToken: 'identified-target-token', + targetActor: TARGET_ACTOR, }) + expect(resolveTargetActor).toHaveBeenCalledWith('identified-target-token') + expect(checkActorCapacity).toHaveBeenCalledWith(TARGET_ACTOR) } ) @@ -102,6 +115,7 @@ describe('POST /api/widget/identify/merge', () => { }) expect(resolveMerge).not.toHaveBeenCalled() expect(checkMergeRate).not.toHaveBeenCalled() + expect(resolveTargetActor).not.toHaveBeenCalled() }) it('fails closed on unavailable rate state and bounds an exceeded bucket', async () => { @@ -129,6 +143,43 @@ describe('POST /api/widget/identify/merge', () => { error: { code: 'IDENTITY_MERGE_RATE_LIMITED' }, }) expect(resolveMerge).not.toHaveBeenCalled() + expect(resolveTargetActor).not.toHaveBeenCalled() + }) + + it('rejects an invalid stable target actor before capacity or merge', async () => { + resolveTargetActor.mockResolvedValueOnce(null) + + const response = await request({ previousToken: 'anonymous-source-token' }) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + error: { code: 'IDENTITY_MERGE_REJECTED' }, + }) + expect(checkActorCapacity).not.toHaveBeenCalled() + expect(resolveMerge).not.toHaveBeenCalled() + }) + + it('fails closed on unavailable stable-actor capacity and bounds actor limits', async () => { + checkActorCapacity + .mockResolvedValueOnce({ + allowed: false, + reason: 'unavailable', + retryAfter: 30, + }) + .mockResolvedValueOnce({ + allowed: false, + reason: 'limited', + retryAfter: 300, + }) + + const unavailable = await request({ previousToken: 'anonymous-source-token' }) + expect(unavailable.status).toBe(503) + expect(unavailable.headers.get('retry-after')).toBe('30') + + const limited = await request({ previousToken: 'anonymous-source-token' }) + expect(limited.status).toBe(429) + expect(limited.headers.get('retry-after')).toBe('300') + expect(resolveMerge).not.toHaveBeenCalled() }) it.each([ diff --git a/apps/web/src/routes/api/widget/__tests__/identify-team-role-guard.test.ts b/apps/web/src/routes/api/widget/__tests__/identify-team-role-guard.test.ts index e9d950186..a9f81446a 100644 --- a/apps/web/src/routes/api/widget/__tests__/identify-team-role-guard.test.ts +++ b/apps/web/src/routes/api/widget/__tests__/identify-team-role-guard.test.ts @@ -103,11 +103,15 @@ vi.mock('@/lib/server/domains/segments/segment-membership.service', () => ({ reconcileWidgetMemberships: vi.fn(async () => undefined), })) +vi.mock('@/lib/server/auth/identify-rate-limit', () => ({ + checkIdentifyRateLimit: vi.fn(async () => ({ allowed: true })), +})) + vi.mock('@quackback/ids', () => ({ generateId: vi.fn((kind: string) => `${kind}_generated`), })) -import { Route } from '../identify' +import { IDENTIFY_BODY_MAX_BYTES, Route } from '../identify' type RouteOpts = { server: { @@ -128,6 +132,17 @@ function postIdentify(body: Record): Promise { }) } +function postRawIdentify(body: BodyInit, headers: HeadersInit = {}): Promise { + return POST({ + request: new Request('http://test/api/widget/identify', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + ...(body instanceof ReadableStream ? { duplex: 'half' as const } : {}), + } as RequestInit & { duplex?: 'half' }), + }) +} + beforeEach(() => { vi.clearAllMocks() mockUserFindFirst.mockReset() @@ -307,3 +322,67 @@ describe('POST /api/widget/identify — widget session isolation', () => { expect(mockInsertValues.mock.calls.some(([value]) => 'token' in (value as object))).toBe(false) }) }) + +describe('POST /api/widget/identify — bounded streaming body', () => { + it('rejects a declared oversize body before JSON parsing', async () => { + const response = await postRawIdentify('{}', { + 'content-length': String(IDENTIFY_BODY_MAX_BYTES + 1), + }) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'PAYLOAD_TOO_LARGE', + message: 'Request body is too large', + }, + }) + expect(mockUserFindFirst).not.toHaveBeenCalled() + }) + + it('rejects chunked oversize JSON even without content-length', async () => { + const encoded = new TextEncoder().encode( + JSON.stringify({ + id: 'actor', + email: 'actor@example.test', + custom: 'x'.repeat(IDENTIFY_BODY_MAX_BYTES), + }) + ) + let offset = 0 + const body = new ReadableStream({ + pull(controller) { + if (offset >= encoded.byteLength) { + controller.close() + return + } + const end = Math.min(offset + 1024, encoded.byteLength) + controller.enqueue(encoded.subarray(offset, end)) + offset = end + }, + }) + + const response = await postRawIdentify(body) + + expect(response.status).toBe(413) + expect(mockUserFindFirst).not.toHaveBeenCalled() + }) + + it('does not trust a lying small content-length and keeps valid custom attributes compatible', async () => { + const oversized = JSON.stringify({ + id: 'actor', + email: 'actor@example.test', + custom: 'x'.repeat(IDENTIFY_BODY_MAX_BYTES), + }) + const rejected = await postRawIdentify(oversized, { 'content-length': '2' }) + expect(rejected.status).toBe(413) + + mockUserFindFirst.mockResolvedValue(null) + mockPrincipalFindFirst.mockResolvedValue(null) + const accepted = await postIdentify({ + id: 'actor', + email: 'actor@example.test', + plan: 'enterprise', + seatCount: 42, + }) + expect(accepted.status).toBe(200) + }) +}) diff --git a/apps/web/src/routes/api/widget/identify.merge.ts b/apps/web/src/routes/api/widget/identify.merge.ts index 43d1fb6ba..cce0acccf 100644 --- a/apps/web/src/routes/api/widget/identify.merge.ts +++ b/apps/web/src/routes/api/widget/identify.merge.ts @@ -1,8 +1,15 @@ import { createFileRoute } from '@tanstack/react-router' import { z } from 'zod' -import { resolveAndMergeAnonymousToken } from '@/lib/server/auth/identify-merge' -import { checkIdentityMergeRateLimit } from '@/lib/server/auth/identity-merge-rate-limit' +import { + resolveAndMergeAnonymousToken, + resolveIdentityMergeTargetActor, +} from '@/lib/server/auth/identify-merge' +import { + checkIdentityMergeActorCapacity, + checkIdentityMergeRateLimit, +} from '@/lib/server/auth/identity-merge-rate-limit' import { getClientIp } from '@/lib/server/domains/api/rate-limit' +import { readBoundedJson } from '@/lib/server/utils/bounded-json-body' const TOKEN_MAX_LENGTH = 512 export const IDENTITY_MERGE_BODY_MAX_BYTES = 2_048 @@ -30,69 +37,6 @@ function readTargetBearer(request: Request): string | null { return token } -type BoundedJsonResult = - | { kind: 'ok'; value: unknown } - | { kind: 'invalid' } - | { kind: 'too_large' } - -async function cancelBody(body: ReadableStream | null): Promise { - if (!body || body.locked) return - const reader = body.getReader() - try { - await reader.cancel() - } catch { - // The response is already bounded; cancellation is best effort. - } finally { - reader.releaseLock() - } -} - -async function readBoundedJson(request: Request): Promise { - const declaredLength = request.headers.get('content-length') - if (declaredLength !== null) { - if (!/^\d+$/.test(declaredLength)) return { kind: 'invalid' } - if (Number(declaredLength) > IDENTITY_MERGE_BODY_MAX_BYTES) { - await cancelBody(request.body) - return { kind: 'too_large' } - } - } - - const body = request.body - if (!body || body.locked) return { kind: 'invalid' } - const reader = body.getReader() - const decoder = new TextDecoder('utf-8', { fatal: true }) - let byteLength = 0 - let text = '' - - try { - while (true) { - const chunk = await reader.read() - if (chunk.done) break - byteLength += chunk.value.byteLength - if (byteLength > IDENTITY_MERGE_BODY_MAX_BYTES) { - try { - await reader.cancel() - } catch { - // Return the same bounded result even when transport cancellation fails. - } - return { kind: 'too_large' } - } - text += decoder.decode(chunk.value, { stream: true }) - } - text += decoder.decode() - } catch { - return { kind: 'invalid' } - } finally { - reader.releaseLock() - } - - try { - return { kind: 'ok', value: JSON.parse(text) } - } catch { - return { kind: 'invalid' } - } -} - export const Route = createFileRoute('/api/widget/identify/merge')({ server: { handlers: { @@ -100,7 +44,7 @@ export const Route = createFileRoute('/api/widget/identify/merge')({ const targetToken = readTargetBearer(request) if (!targetToken) return error('IDENTITY_MERGE_REJECTED', 401) - const body = await readBoundedJson(request) + const body = await readBoundedJson(request, IDENTITY_MERGE_BODY_MAX_BYTES) if (body.kind === 'too_large') return error('IDENTITY_MERGE_REJECTED', 413) if (body.kind === 'invalid') return error('IDENTITY_MERGE_REJECTED', 400) const parsed = mergeSchema.safeParse(body.value) @@ -114,9 +58,20 @@ export const Route = createFileRoute('/api/widget/identify/merge')({ } try { + const targetActor = await resolveIdentityMergeTargetActor(targetToken) + if (!targetActor) return error('IDENTITY_MERGE_REJECTED', 401) + + const capacity = await checkIdentityMergeActorCapacity(targetActor) + if (!capacity.allowed) { + return capacity.reason === 'limited' + ? error('IDENTITY_MERGE_RATE_LIMITED', 429, capacity.retryAfter) + : error('IDENTITY_MERGE_RETRYABLE', 503, capacity.retryAfter) + } + const result = await resolveAndMergeAnonymousToken({ previousToken: parsed.data.previousToken, targetToken, + targetActor, }) switch (result.status) { case 'merged': diff --git a/apps/web/src/routes/api/widget/identify.ts b/apps/web/src/routes/api/widget/identify.ts index 3bf836b9e..f95214c30 100644 --- a/apps/web/src/routes/api/widget/identify.ts +++ b/apps/web/src/routes/api/widget/identify.ts @@ -27,7 +27,9 @@ import { import { reconcileWidgetMemberships } from '@/lib/server/domains/segments/segment-membership.service' import { captureCountryFromHeaders } from '@/lib/server/auth/country-capture' import { checkIdentifyRateLimit } from '@/lib/server/auth/identify-rate-limit' +import { readBoundedJson } from '@/lib/server/utils/bounded-json-body' +export const IDENTIFY_BODY_MAX_BYTES = 64 * 1024 const identifySchema = z .object({ // Verified path @@ -164,13 +166,18 @@ export const Route = createFileRoute('/api/widget/identify')({ return jsonError('WIDGET_DISABLED', 'Widget is not enabled', 403) } - let body: z.infer - try { - const raw = await request.json() - body = identifySchema.parse(raw) - } catch { + const raw = await readBoundedJson(request, IDENTIFY_BODY_MAX_BYTES) + if (raw.kind === 'too_large') { + return jsonError('PAYLOAD_TOO_LARGE', 'Request body is too large', 413) + } + if (raw.kind === 'invalid') { + return jsonError('VALIDATION_ERROR', 'Invalid request body', 400) + } + const parsed = identifySchema.safeParse(raw.value) + if (!parsed.success) { return jsonError('VALIDATION_ERROR', 'Invalid request body', 400) } + const body: z.infer = parsed.data // Determine identity source: verified JWT or unverified body fields let claims: Record diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index bcd657670..885ef2e22 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -29,7 +29,6 @@ import { WidgetMessagesSection } from '@/components/widget/widget-messages-secti import { WidgetBugReport, type CapturePayload } from '@/components/widget/widget-bug-report' import { WidgetMyReports } from '@/components/widget/widget-my-reports' import { useWidgetAuth } from '@/components/widget/widget-auth-provider' -import { sendToHost } from '@/lib/client/widget-bridge' import { installBugReportHostSubmitBridge } from '@/lib/client/bug-report-host-submit' import { installNetRecorder } from '@/lib/client/capture/net-recorder' import { projectBugReportMediaEvidence } from '@/lib/shared/bugreport/media-evidence-gates' @@ -226,6 +225,7 @@ function WidgetPage() { hostIdentityVersion, currentHostParentBinding, resolveHostParentBinding, + sendPrivilegedHostMessage, } = useWidgetAuth() // Bug-report capture wiring: `undefined` means the user has not requested a @@ -372,12 +372,15 @@ function WidgetPage() { }, []) // Ask the host embed for a (fresh) page capture; resets the give-up timer. - const requestCapture = useCallback((flowId: string) => { - if (activeBugReportFlowIdRef.current !== flowId) return - setCaptureData(null) - setCaptureTimedOut(false) - sendToHost({ type: 'quackback:capture-request', flowId }) - }, []) + const requestCapture = useCallback( + (flowId: string) => { + if (activeBugReportFlowIdRef.current !== flowId) return + setCaptureData(null) + setCaptureTimedOut(false) + sendPrivilegedHostMessage({ type: 'quackback:capture-request', flowId }) + }, + [sendPrivilegedHostMessage] + ) // Open the bug-report view only after actor-specific board resolution. The // iframe is the lifecycle authority. Capture remains a separate, explicit @@ -722,7 +725,7 @@ function WidgetPage() { isFlowActive={() => activeBugReportFlowIdRef.current === activeBugReportFlowId} onSubmitStarted={() => { if (activeBugReportFlowIdRef.current !== activeBugReportFlowId) return - sendToHost({ + sendPrivilegedHostMessage({ type: 'quackback:bug-report-submit-started', flowId: activeBugReportFlowId, }) diff --git a/docs/fixtures/quackback-report-submit-contract-v1.json b/docs/fixtures/quackback-report-submit-contract-v1.json index ba5466f49..3b1b108db 100644 --- a/docs/fixtures/quackback-report-submit-contract-v1.json +++ b/docs/fixtures/quackback-report-submit-contract-v1.json @@ -18,10 +18,39 @@ "testHttpHosts": ["localhost", "127.0.0.1", "[::1]"], "entireListFailsClosed": true }, + "assertionPolicy": { + "purpose": "iplaycafe.quackback.host-submit/1", + "algorithm": "HS256", + "maxCodeUnits": 4096, + "maxTtlSeconds": 30, + "maxFutureIatSkewSeconds": 5, + "exactHeaderKeys": ["alg", "typ"], + "exactClaimKeys": [ + "purpose", + "aud", + "sub", + "hostOrigin", + "contract", + "reportDigest", + "clientSubmissionId", + "requestId", + "jti", + "iat", + "exp" + ], + "reportDigest": { + "algorithm": "SHA-256", + "encoding": "lowercase-hex", + "domain": "iplaycafe.quackback.report-digest/1", + "framing": "UTF-8(domain + \"\\nsummary:\" + summaryUtf8.byteLength + \"\\n\") || summaryUtf8 || UTF-8(\"\\nimpact:\" + impactUtf8.byteLength + \"\\n\") || impactUtf8", + "normalization": "none" + } + }, "inputLimits": { "summaryCodeUnits": 2000, "impactCodeUnits": 1000, - "titleCodeUnits": 200 + "titleCodeUnits": 200, + "hostSubmitAssertionCodeUnits": 4096 }, "patterns": { "uuidV4": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", @@ -47,9 +76,19 @@ ] }, "exactKeys": { - "input": ["clientSubmissionId", "summary", "impact"], + "input": ["clientSubmissionId", "summary", "impact", "hostSubmitAssertion"], + "hostAdapterInput": ["clientSubmissionId", "summary", "impact"], + "signerRequest": ["clientSubmissionId", "reportDigest"], + "signerResponse": ["hostSubmitAssertion"], "request": ["type", "data"], - "requestData": ["contract", "requestId", "clientSubmissionId", "summary", "impact"], + "requestData": [ + "contract", + "requestId", + "clientSubmissionId", + "summary", + "impact", + "hostSubmitAssertion" + ], "result": ["type", "data"], "successData": ["contract", "requestId", "accepted", "receipt"], "failureData": ["contract", "requestId", "accepted", "reason"], @@ -59,6 +98,26 @@ "receiptOptional": ["fixedInRelease"] }, "statuses": ["received", "triaging", "needs_info", "in_progress", "verifying", "fixed", "closed"], - "failureReasons": ["aborted", "invalid_request", "unavailable", "unauthorized", "retryable_failure"], - "telemetryForbiddenFields": ["summary", "impact", "clientSubmissionId", "requestId", "reportRef", "boardId", "postId", "principalId", "email", "url", "rawError", "evidenceId"] + "failureReasons": [ + "aborted", + "invalid_request", + "unavailable", + "unauthorized", + "retryable_failure" + ], + "telemetryForbiddenFields": [ + "summary", + "impact", + "hostSubmitAssertion", + "clientSubmissionId", + "requestId", + "reportRef", + "boardId", + "postId", + "principalId", + "email", + "url", + "rawError", + "evidenceId" + ] } diff --git a/packages/widget/src/core/__tests__/report-submit.test.ts b/packages/widget/src/core/__tests__/report-submit.test.ts index fa4ab4644..28d1954a8 100644 --- a/packages/widget/src/core/__tests__/report-submit.test.ts +++ b/packages/widget/src/core/__tests__/report-submit.test.ts @@ -4,6 +4,7 @@ import { HOST_REPORT_SUBMIT_CONTRACT, HOST_REPORT_SUBMIT_TIMEOUT_MS, createHostSubmitRequest, + parseHostSubmitAssertionForTransport, parseHostSubmitResultForRequest, parseSubmitBugReportContext, parseSubmitBugReportInput, @@ -12,6 +13,37 @@ import { const REQUEST_ID = '11111111-1111-4111-8111-111111111111' const OTHER_REQUEST_ID = '33333333-3333-4333-8333-333333333333' const SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' +const PROVIDER_ORIGIN = 'https://feedback.example.test' +const HOST_ORIGIN = 'https://app.example.test' +const NOW_SECONDS = 1_800_000_000 + +function encodeBase64UrlJson(value: unknown): string { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '') +} + +function assertion(overrides: Record = {}) { + const header = encodeBase64UrlJson({ alg: 'HS256', typ: 'JWT' }) + const payload = encodeBase64UrlJson({ + purpose: 'iplaycafe.quackback.host-submit/1', + aud: PROVIDER_ORIGIN, + sub: 'customer-user-123', + hostOrigin: HOST_ORIGIN, + contract: contract.adapterContract, + reportDigest: 'f7319703af570f5daee887fc6f17ae7d487d0acca1b10c37234e649eb1119f9e', + clientSubmissionId: SUBMISSION_ID, + requestId: REQUEST_ID, + jti: 'host-submit-jti-00000001', + iat: NOW_SECONDS, + exp: NOW_SECONDS + 30, + ...overrides, + }) + return `${header}.${payload}.${'s'.repeat(43)}` +} + +const HOST_SUBMIT_ASSERTION = assertion() const RECEIPT = { schemaVersion: 'BugReportReceiptV1', reportRef: 'qbr_abcdefghijklmnopqrstuvwx', @@ -81,17 +113,20 @@ describe('public host report submit input and context', () => { clientSubmissionId: SUBMISSION_ID, summary: 'Save does nothing', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }) ).toEqual({ clientSubmissionId: SUBMISSION_ID, summary: 'Save does nothing', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }) expect( parseSubmitBugReportInput({ clientSubmissionId: SUBMISSION_ID, summary: 's'.repeat(contract.inputLimits.summaryCodeUnits), impact: 'i'.repeat(contract.inputLimits.impactCodeUnits), + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }) ).not.toBeNull() }) @@ -109,57 +144,80 @@ describe('public host report submit input and context', () => { clientSubmissionId: SUBMISSION_ID, summary: 'x', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, boardId: 'forbidden', }, { clientSubmissionId: 'not-a-v4-uuid', summary: 'x', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: '', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: ' ', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: ' x', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: 'x ', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: 'x', impact: ' impact', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: 'x', impact: 'impact ', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: 'x', impact: ' ', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: 's'.repeat(contract.inputLimits.summaryCodeUnits + 1), impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, { clientSubmissionId: SUBMISSION_ID, summary: 'x', impact: 'i'.repeat(contract.inputLimits.impactCodeUnits + 1), + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: 'x', + impact: '', + hostSubmitAssertion: '', + }, + { + clientSubmissionId: SUBMISSION_ID, + summary: 'x', + impact: '', + hostSubmitAssertion: 'x'.repeat(contract.inputLimits.hostSubmitAssertionCodeUnits + 1), }, ]) { expect(parseSubmitBugReportInput(value)).toBeNull() @@ -182,6 +240,7 @@ describe('public host report submit input and context', () => { clientSubmissionId: SUBMISSION_ID, summary: 'x', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, } const inputAccessor = addAccessor({ ...inputBase }, 'summary') const contextAccessor = addAccessor({}, 'signal') @@ -202,18 +261,59 @@ describe('public host report submit input and context', () => { clientSubmissionId: SUBMISSION_ID, summary: 'Save does nothing', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }) ).toEqual({ type: contract.requestType, data: { - contract: contract.adapterContract, + contract: HOST_REPORT_SUBMIT_CONTRACT, requestId: REQUEST_ID, clientSubmissionId: SUBMISSION_ID, summary: 'Save does nothing', impact: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, }, }) }) + + it('descriptor-safely extracts the signed request ID only after exact transport cross-checks', () => { + expect( + parseHostSubmitAssertionForTransport(HOST_SUBMIT_ASSERTION, { + audience: PROVIDER_ORIGIN, + hostOrigin: HOST_ORIGIN, + contract: HOST_REPORT_SUBMIT_CONTRACT, + clientSubmissionId: SUBMISSION_ID, + nowMs: NOW_SECONDS * 1000, + }) + ).toEqual({ + requestId: REQUEST_ID, + reportDigest: 'f7319703af570f5daee887fc6f17ae7d487d0acca1b10c37234e649eb1119f9e', + expiresAtSeconds: NOW_SECONDS + 30, + }) + + for (const candidate of [ + assertion({ aud: 'https://other-feedback.example.test' }), + assertion({ hostOrigin: 'https://other-app.example.test' }), + assertion({ contract: 'iplaycafe.quackback.report-submit/2' }), + assertion({ reportDigest: 'not-a-digest' }), + assertion({ clientSubmissionId: OTHER_REQUEST_ID }), + assertion({ requestId: 'not-a-uuid' }), + assertion({ iat: NOW_SECONDS + 6 }), + assertion({ exp: NOW_SECONDS + 31 }), + assertion({ extra: true }), + 'not.a.jwt', + ]) { + expect( + parseHostSubmitAssertionForTransport(candidate, { + audience: PROVIDER_ORIGIN, + hostOrigin: HOST_ORIGIN, + contract: HOST_REPORT_SUBMIT_CONTRACT, + clientSubmissionId: SUBMISSION_ID, + nowMs: NOW_SECONDS * 1000, + }) + ).toBeNull() + } + }) }) describe('public host report submit result parser', () => { diff --git a/packages/widget/src/core/__tests__/sdk-capture.test.ts b/packages/widget/src/core/__tests__/sdk-capture.test.ts index dfe096464..140a70fb6 100644 --- a/packages/widget/src/core/__tests__/sdk-capture.test.ts +++ b/packages/widget/src/core/__tests__/sdk-capture.test.ts @@ -1777,11 +1777,54 @@ describe('sdk — bug-report capture wiring', () => { }) const HOST_SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' -const HOST_SUBMIT_INPUT = { - clientSubmissionId: HOST_SUBMISSION_ID, - summary: 'Save does nothing', - impact: '', +const HOST_REQUEST_ID = '11111111-1111-4111-8111-111111111111' + +function hostSubmitAssertion( + requestId: string, + clientSubmissionId: string, + overrides: Record = {} +) { + const nowSeconds = Math.floor(Date.now() / 1000) + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, '') + const payload = btoa( + JSON.stringify({ + purpose: 'iplaycafe.quackback.host-submit/1', + aud: ORIGIN, + sub: 'host-actor-1', + hostOrigin: window.location.origin, + contract: 'iplaycafe.quackback.report-submit/1', + reportDigest: 'f7319703af570f5daee887fc6f17ae7d487d0acca1b10c37234e649eb1119f9e', + clientSubmissionId, + requestId, + jti: `host-submit-${requestId}`, + iat: nowSeconds, + exp: nowSeconds + 30, + ...overrides, + }) + ) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, '') + return `${header}.${payload}.${'s'.repeat(43)}` } + +function makeHostSubmitInput( + requestId = HOST_REQUEST_ID, + clientSubmissionId = HOST_SUBMISSION_ID, + overrides: Partial<{ summary: string; impact: string; hostSubmitAssertion: string }> = {} +) { + return { + clientSubmissionId, + summary: 'Save does nothing', + impact: '', + hostSubmitAssertion: hostSubmitAssertion(requestId, clientSubmissionId), + ...overrides, + } +} +let HOST_SUBMIT_INPUT = makeHostSubmitInput() const HOST_RECEIPT = { schemaVersion: 'BugReportReceiptV1', reportRef: 'qbr_abcdefghijklmnopqrstuvwx', @@ -1844,6 +1887,7 @@ async function initializeHostSubmit( describe('sdk — bounded public host report submit', () => { beforeEach(() => { + HOST_SUBMIT_INPUT = makeHostSubmitInput() document.body.innerHTML = '' document.head.innerHTML = '' window.history.replaceState(null, '', '/') @@ -2454,7 +2498,7 @@ describe('sdk — bounded public host report submit', () => { ]) }) - it('sends one exact request with a fresh request ID and resolves exact safe results', async () => { + it('uses the exact request ID embedded in the assertion and resolves exact safe results', async () => { const sdk = createSDK() const post = await initializeHostSubmit(sdk) const resultPromise = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise @@ -2462,9 +2506,7 @@ describe('sdk — bounded public host report submit', () => { expect(requests).toHaveLength(1) expect(requests[0]?.data).toEqual({ contract: 'iplaycafe.quackback.report-submit/1', - requestId: expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - ), + requestId: HOST_REQUEST_ID, ...HOST_SUBMIT_INPUT, }) expect(requests[0]?.data?.requestId).not.toBe(HOST_SUBMISSION_ID) @@ -2472,20 +2514,88 @@ describe('sdk — bounded public host report submit', () => { fireHostSubmitResult(requestId, { accepted: true, receipt: HOST_RECEIPT }) await expect(resultPromise).resolves.toEqual({ accepted: true, receipt: HOST_RECEIPT }) - for (const reason of [ + for (const [index, reason] of [ 'aborted', 'invalid_request', 'unavailable', 'unauthorized', 'retryable_failure', - ]) { - const next = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + ].entries()) { + const requestId = `11111111-1111-4111-8111-${(index + 2).toString(16).padStart(12, '0')}` + const next = sdk.dispatch( + 'submitBugReport', + makeHostSubmitInput(requestId) + ) as Promise const nextRequest = latestHostSubmitRequest(post)! fireHostSubmitResult(nextRequest.data?.requestId as string, { accepted: false, reason }) await expect(next).resolves.toEqual({ accepted: false, reason }) } }) + it('rejects malformed assertions and never posts them to the iframe', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + for (const assertionCandidate of [ + 'not.a.jwt', + hostSubmitAssertion(HOST_REQUEST_ID, HOST_SUBMISSION_ID, { + hostOrigin: 'https://wrong-host.example.test', + }), + hostSubmitAssertion(HOST_REQUEST_ID, HOST_SUBMISSION_ID, { + aud: 'https://wrong-provider.example.test', + }), + hostSubmitAssertion(HOST_REQUEST_ID, HOST_SUBMISSION_ID, { + requestId: 'not-a-uuid', + }), + ]) { + await expect( + sdk.dispatch( + 'submitBugReport', + makeHostSubmitInput(HOST_REQUEST_ID, HOST_SUBMISSION_ID, { + hostSubmitAssertion: assertionCandidate, + }) + ) + ).resolves.toEqual({ accepted: false, reason: 'invalid_request' }) + } + expect(hostSubmitRequests(post)).toHaveLength(0) + }) + + it('rejects duplicate signed request IDs before send and ignores their late result', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + const input = makeHostSubmitInput() + const first = sdk.dispatch('submitBugReport', input) as Promise + + await expect(sdk.dispatch('submitBugReport', input)).resolves.toEqual({ + accepted: false, + reason: 'invalid_request', + }) + expect(hostSubmitRequests(post)).toHaveLength(1) + + fireHostSubmitResult(HOST_REQUEST_ID, { accepted: false, reason: 'unavailable' }) + await expect(first).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + await expect(sdk.dispatch('submitBugReport', input)).resolves.toEqual({ + accepted: false, + reason: 'invalid_request', + }) + fireHostSubmitResult(HOST_REQUEST_ID, { accepted: true, receipt: HOST_RECEIPT }) + expect(hostSubmitRequests(post)).toHaveLength(1) + }) + + it('retires an in-flight assertion synchronously when the host actor changes', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + const pending = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + + sdk.dispatch('identify', { id: 'actor_b', email: 'actor-b@example.test' }) + + await expect(pending).resolves.toEqual({ + accepted: false, + reason: 'retryable_failure', + }) + fireHostSubmitResult(HOST_REQUEST_ID, { accepted: true, receipt: HOST_RECEIPT }) + expect(hostSubmitRequests(post)).toHaveLength(1) + }) + it('cleans a pending request before abort settlement and ignores its late result', async () => { const sdk = createSDK() const post = await initializeHostSubmit(sdk) @@ -2680,14 +2790,18 @@ describe('sdk — bounded public host report submit', () => { it('correlates concurrent requests independently when results arrive out of order', async () => { const sdk = createSDK() const post = await initializeHostSubmit(sdk) - const first = sdk.dispatch('submitBugReport', { - ...HOST_SUBMIT_INPUT, - summary: 'first', - }) as Promise - const second = sdk.dispatch('submitBugReport', { - ...HOST_SUBMIT_INPUT, - summary: 'second', - }) as Promise + const first = sdk.dispatch( + 'submitBugReport', + makeHostSubmitInput('11111111-1111-4111-8111-000000000011', HOST_SUBMISSION_ID, { + summary: 'first', + }) + ) as Promise + const second = sdk.dispatch( + 'submitBugReport', + makeHostSubmitInput('11111111-1111-4111-8111-000000000012', HOST_SUBMISSION_ID, { + summary: 'second', + }) + ) as Promise const [firstRequest, secondRequest] = hostSubmitRequests(post).slice(-2) expect(firstRequest?.data?.requestId).not.toBe(secondRequest?.data?.requestId) @@ -2751,6 +2865,7 @@ describe('sdk — bounded public host report submit', () => { diagnostics: nonTransportMessages, }) expect(serializedSinks).not.toContain('canary-private-summary') + expect(serializedSinks).not.toContain(HOST_SUBMIT_INPUT.hostSubmitAssertion) expect(serializedSinks).not.toContain('canary-request-id') expect(serializedSinks).not.toContain('canary-report-ref') }) diff --git a/packages/widget/src/core/report-submit.ts b/packages/widget/src/core/report-submit.ts index f4f377804..a09cdb0d2 100644 --- a/packages/widget/src/core/report-submit.ts +++ b/packages/widget/src/core/report-submit.ts @@ -13,6 +13,12 @@ export const HOST_REPORT_SUBMIT_TIMEOUT_MS = 10_000 const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const REPORT_REF = /^qbr_[A-Za-z0-9_-]{24}$/ const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ +const HOST_SUBMIT_ASSERTION = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/ +const REPORT_DIGEST = /^[a-f0-9]{64}$/ +const HOST_SUBMIT_ASSERTION_PURPOSE = 'iplaycafe.quackback.host-submit/1' +const HOST_SUBMIT_ASSERTION_MAX_LENGTH = 4096 +const HOST_SUBMIT_ASSERTION_MAX_TTL_SECONDS = 30 +const HOST_SUBMIT_ASSERTION_FUTURE_SKEW_SECONDS = 5 const HOST_SUBMIT_STATUSES = new Set([ 'received', 'triaging', @@ -40,6 +46,7 @@ export type HostSubmitRequestMessage = { clientSubmissionId: string summary: string impact: string + hostSubmitAssertion: string } } @@ -118,7 +125,12 @@ function parseReceipt(value: unknown): BugReportReceiptV1 | null { } export function parseSubmitBugReportInput(value: unknown): SubmitBugReportInputV1 | null { - const record = readExactDataRecord(value, ['clientSubmissionId', 'summary', 'impact']) + const record = readExactDataRecord(value, [ + 'clientSubmissionId', + 'summary', + 'impact', + 'hostSubmitAssertion', + ]) if ( !record || !isUuidV4(record.clientSubmissionId) || @@ -128,7 +140,10 @@ export function parseSubmitBugReportInput(value: unknown): SubmitBugReportInputV record.summary !== record.summary.trim() || typeof record.impact !== 'string' || record.impact.length > 1_000 || - record.impact !== record.impact.trim() + record.impact !== record.impact.trim() || + typeof record.hostSubmitAssertion !== 'string' || + record.hostSubmitAssertion.length > HOST_SUBMIT_ASSERTION_MAX_LENGTH || + !HOST_SUBMIT_ASSERTION.test(record.hostSubmitAssertion) ) { return null } @@ -136,6 +151,7 @@ export function parseSubmitBugReportInput(value: unknown): SubmitBugReportInputV clientSubmissionId: record.clientSubmissionId, summary: record.summary, impact: record.impact, + hostSubmitAssertion: record.hostSubmitAssertion, } } @@ -167,10 +183,138 @@ export function createHostSubmitRequest( clientSubmissionId: input.clientSubmissionId, summary: input.summary, impact: input.impact, + hostSubmitAssertion: input.hostSubmitAssertion, }, } } +function decodeCanonicalBase64UrlJson(segment: string): DataRecord | null { + try { + const padded = segment.replace(/-/g, '+').replace(/_/g, '/') + const padding = '='.repeat((4 - (padded.length % 4)) % 4) + const binary = atob(`${padded}${padding}`) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + const canonical = btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, '') + if (canonical !== segment) return null + return readDataRecord(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))) + } catch { + return null + } +} + +function canonicalHttpsOrigin(value: string): string | null { + try { + const parsed = new URL(value) + const localHttp = + parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname) + if ( + (parsed.protocol !== 'https:' && !localHttp) || + parsed.username || + parsed.password || + parsed.pathname !== '/' || + parsed.search || + parsed.hash + ) { + return null + } + return parsed.origin + } catch { + return null + } +} + +export function parseHostSubmitAssertionForTransport( + assertion: string, + expected: { + audience: string + hostOrigin: string + contract: typeof HOST_REPORT_SUBMIT_CONTRACT + clientSubmissionId: string + nowMs?: number + } +): { requestId: string; reportDigest: string; expiresAtSeconds: number } | null { + if ( + assertion.length > HOST_SUBMIT_ASSERTION_MAX_LENGTH || + !HOST_SUBMIT_ASSERTION.test(assertion) || + !isUuidV4(expected.clientSubmissionId) + ) { + return null + } + const audience = canonicalHttpsOrigin(expected.audience) + const hostOrigin = canonicalHttpsOrigin(expected.hostOrigin) + if ( + audience === null || + audience !== expected.audience || + hostOrigin === null || + hostOrigin !== expected.hostOrigin + ) { + return null + } + + const [headerSegment, payloadSegment] = assertion.split('.') + const header = decodeCanonicalBase64UrlJson(headerSegment!) + const claims = decodeCanonicalBase64UrlJson(payloadSegment!) + const exactHeader = header ? readExactDataRecord(header, ['alg', 'typ']) : null + const exactClaims = claims + ? readExactDataRecord(claims, [ + 'purpose', + 'aud', + 'sub', + 'hostOrigin', + 'contract', + 'reportDigest', + 'clientSubmissionId', + 'requestId', + 'jti', + 'iat', + 'exp', + ]) + : null + if ( + !exactHeader || + exactHeader.alg !== 'HS256' || + exactHeader.typ !== 'JWT' || + !exactClaims || + exactClaims.purpose !== HOST_SUBMIT_ASSERTION_PURPOSE || + exactClaims.aud !== audience || + typeof exactClaims.sub !== 'string' || + exactClaims.sub.length === 0 || + exactClaims.sub.length > 256 || + exactClaims.hostOrigin !== hostOrigin || + exactClaims.contract !== expected.contract || + typeof exactClaims.reportDigest !== 'string' || + !REPORT_DIGEST.test(exactClaims.reportDigest) || + exactClaims.clientSubmissionId !== expected.clientSubmissionId || + !isUuidV4(exactClaims.requestId) || + typeof exactClaims.jti !== 'string' || + !/^[A-Za-z0-9_-]{16,128}$/.test(exactClaims.jti) || + !Number.isSafeInteger(exactClaims.iat) || + !Number.isSafeInteger(exactClaims.exp) + ) { + return null + } + const nowMs = expected.nowMs ?? Date.now() + if (!Number.isSafeInteger(nowMs) || nowMs < 0) return null + const nowSeconds = Math.floor(nowMs / 1000) + if ( + (exactClaims.iat as number) > nowSeconds + HOST_SUBMIT_ASSERTION_FUTURE_SKEW_SECONDS || + (exactClaims.exp as number) <= nowSeconds || + (exactClaims.exp as number) <= (exactClaims.iat as number) || + (exactClaims.exp as number) - (exactClaims.iat as number) > + HOST_SUBMIT_ASSERTION_MAX_TTL_SECONDS + ) { + return null + } + return { + requestId: exactClaims.requestId, + reportDigest: exactClaims.reportDigest, + expiresAtSeconds: exactClaims.exp as number, + } +} + export function parseHostSubmitResultForRequest( value: unknown, requestId: string diff --git a/packages/widget/src/core/sdk.ts b/packages/widget/src/core/sdk.ts index f07415d28..58bda04b9 100644 --- a/packages/widget/src/core/sdk.ts +++ b/packages/widget/src/core/sdk.ts @@ -32,8 +32,10 @@ import { } from './bug-report-events' import { removeStyles } from './style' import { + HOST_REPORT_SUBMIT_CONTRACT, HOST_REPORT_SUBMIT_TIMEOUT_MS, createHostSubmitRequest, + parseHostSubmitAssertionForTransport, parseHostSubmitResultForRequest, parseSubmitBugReportContext, parseSubmitBugReportInput, @@ -278,6 +280,10 @@ export function createSDK(): SDK { removeAbort: () => void } const pendingHostSubmits = new Map() + // A signed request ID is one-shot for this iframe generation. Retaining + // settled IDs prevents a late result for an older assertion from settling a + // newer call that tried to reuse the same correlation ID. + const usedHostSubmitRequestIds = new Set() // null while config.json is unresolved. reportBug calls during this window // are queued so an immediate init -> reportBug flow cannot silently no-op. let captureAvailable: boolean | null = null @@ -391,6 +397,7 @@ export function createSDK(): SDK { transportGeneration += 1 ready = false settleAllHostSubmits({ accepted: false, reason: 'retryable_failure' }) + usedHostSubmitRequestIds.clear() identityResolved = false currentUser = null hostSubmitAcknowledgedUser = null @@ -1014,6 +1021,7 @@ export function createSDK(): SDK { // draft/screenshot can never be posted with B's eventual session token. resetActiveBugReport() pendingReportBug = null + settleAllHostSubmits({ accepted: false, reason: 'retryable_failure' }) identityResolved = false currentUser = null hostSubmitAcknowledgedUser = null @@ -1161,12 +1169,26 @@ export function createSDK(): SDK { return Promise.resolve({ accepted: false, reason: 'unavailable' }) } - let requestId: string - try { - requestId = crypto.randomUUID() - } catch { + const assertion = parseHostSubmitAssertionForTransport(input.hostSubmitAssertion, { + audience: iframeOrigin(), + hostOrigin: window.location.origin, + contract: HOST_REPORT_SUBMIT_CONTRACT, + clientSubmissionId: input.clientSubmissionId, + }) + if (!assertion) { + return Promise.resolve({ accepted: false, reason: 'invalid_request' }) + } + const requestId = assertion.requestId + if (usedHostSubmitRequestIds.has(requestId)) { + return Promise.resolve({ accepted: false, reason: 'invalid_request' }) + } + // Bound retained IDs per iframe generation. A saturated client fails + // closed until a fresh transport is installed instead of evicting an ID + // that could still receive a late result. + if (usedHostSubmitRequestIds.size >= 2_048) { return Promise.resolve({ accepted: false, reason: 'retryable_failure' }) } + usedHostSubmitRequestIds.add(requestId) const generation = transportGeneration const activeBridge = bridge diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index 24eadac3d..6dfbb988a 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -147,6 +147,11 @@ export interface SubmitBugReportInputV1 { clientSubmissionId: string summary: string impact: string + /** + * Short-lived, one-use HS256 assertion minted by the authenticated host + * backend. It binds this submission to the host actor, origin and request ID. + */ + hostSubmitAssertion: string } export interface SubmitBugReportContextV1 { From 9cc6f0b9a5e4c607fc319d5f0e413a805d3af69b Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 11:39:23 +0700 Subject: [PATCH 16/21] quackback: close submit privacy and FK proof gaps Treat the content-derived report digest as forbidden telemetry and prove it stays out of provider sinks. Replace stage aliases with a validated per-FK callable registry so every guard and merge operation must own the right FK and complete before source deletion. --- .../__tests__/bug-report-host-submit.test.ts | 39 + .../anonymous-principal-fk-policy.test.ts | 181 ++- .../auth/anonymous-principal-fk-policy.ts | 176 +-- .../src/lib/server/auth/merge-anonymous.ts | 1151 +++++++++++------ .../__tests__/host-submit-contract.test.ts | 4 + .../quackback-report-submit-contract-v1.json | 1 + 6 files changed, 1003 insertions(+), 549 deletions(-) diff --git a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts index 47880da5f..cfadcd2f0 100644 --- a/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts +++ b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts @@ -137,6 +137,45 @@ afterEach(() => { }) describe('authenticated host submit bridge', () => { + it('never emits or logs the content-derived report digest', async () => { + const reportDigest = createHash('sha256') + .update(encodeHostSubmitReportDigestInput(SUMMARY, IMPACT)) + .digest('hex') + const harness = createWindowHarness() + const consoleSinks = [ + vi.spyOn(console, 'debug').mockImplementation(() => undefined), + vi.spyOn(console, 'info').mockImplementation(() => undefined), + vi.spyOn(console, 'warn').mockImplementation(() => undefined), + vi.spyOn(console, 'error').mockImplementation(() => undefined), + ] + const authorize = vi.fn().mockResolvedValue({ allowed: true }) + const submit = vi.fn().mockResolvedValue({ accepted: false, reason: 'retryable_failure' }) + const parentBinding = installBugReportHostParentBinding({ + authorizeOrigin: authorize, + currentGeneration: () => 1, + target: harness.target, + }) + installBugReportHostSubmitBridge({ + authorizeOrigin: authorize, + currentBinding: () => parentBinding.current(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + submit, + target: harness.target, + }) + + harness.dispatch({ data: { type: 'quackback:identify' } }) + await flushAsyncWork() + harness.parent.postMessage.mockClear() + harness.dispatch({ data: validRequest() }) + await flushAsyncWork() + + const serializedSinks = JSON.stringify({ + parentMessages: harness.parent.postMessage.mock.calls, + console: consoleSinks.flatMap((sink) => sink.mock.calls), + }) + expect(serializedSinks).not.toContain(reportDigest) + }) + it('binds the exact current parent and replies to its immutable exact origin', async () => { const harness = createWindowHarness() let generation = 1 diff --git a/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts b/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts index 6328d4378..c06b75f16 100644 --- a/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts @@ -1,19 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { isTable } from 'drizzle-orm' import { getTableConfig } from 'drizzle-orm/pg-core' import * as schema from '@/lib/server/db' -import { - ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS, - ANONYMOUS_PRINCIPAL_FK_POLICY, - ANONYMOUS_PRINCIPAL_MERGE_KEYS, - ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES, - ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE, - createAnonymousPrincipalFkExecutionTracker, -} from '../anonymous-principal-fk-policy' -import { - EXECUTED_ANONYMOUS_PRINCIPAL_MERGE_KEYS, - GUARDED_TARGET_ONLY_ANONYMOUS_PRINCIPAL_KEYS, -} from '../merge-anonymous' +import * as fkPolicyModule from '../anonymous-principal-fk-policy' +import * as mergeModule from '../merge-anonymous' +import { ANONYMOUS_PRINCIPAL_FK_POLICY } from '../anonymous-principal-fk-policy' import { ANONYMOUS_SWEEP_PRINCIPAL_FK_POLICY } from '@/lib/server/domains/principals/anonymous-sweep-principal-fk-policy' function schemaPrincipalForeignKeys(): string[] { @@ -32,64 +23,152 @@ function schemaPrincipalForeignKeys(): string[] { return [...keys].sort() } +type TestExecutableHandler = { + owner: string + policy: string + operations: Record unknown> +} + +type TestExecutionRegistry = { + invoke(key: string, operation: string, context: unknown): Promise + assertComplete(): void +} + +type TestRegistryFactory = ( + handlers: Record, + policy?: Record +) => TestExecutionRegistry + +function executableRegistryFactory(): TestRegistryFactory { + const candidate = ( + fkPolicyModule as unknown as { + createAnonymousPrincipalFkExecutionRegistry?: unknown + } + ).createAnonymousPrincipalFkExecutionRegistry + expect(candidate).toBeTypeOf('function') + return candidate as TestRegistryFactory +} + +function fakeExecutableHandlers(): Record { + return Object.fromEntries( + Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY).map(([key, policy]) => [ + key, + { + owner: key, + policy, + operations: { + apply: vi.fn((context: unknown) => ({ key, context })), + }, + }, + ]) + ) +} + describe('anonymous principal FK merge inventory', () => { it('requires an explicit policy for every schema FK targeting principal.id', () => { expect(Object.keys(ANONYMOUS_PRINCIPAL_FK_POLICY).sort()).toEqual(schemaPrincipalForeignKeys()) }) - it('keeps the executable merge inventory equal to every non-target-only policy', () => { - expect([...EXECUTED_ANONYMOUS_PRINCIPAL_MERGE_KEYS].sort()).toEqual( - ANONYMOUS_PRINCIPAL_MERGE_KEYS + it('requires every principal FK to block the truly-empty anonymous sweep', () => { + expect(Object.keys(ANONYMOUS_SWEEP_PRINCIPAL_FK_POLICY).sort()).toEqual( + schemaPrincipalForeignKeys() ) - expect([...EXECUTED_ANONYMOUS_PRINCIPAL_MERGE_KEYS].sort()).toEqual( - Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY) - .filter(([, policy]) => policy !== 'target_only') - .map(([key]) => key) - .sort() + expect(new Set(Object.values(ANONYMOUS_SWEEP_PRINCIPAL_FK_POLICY))).toEqual( + new Set(['blocks_sweep']) ) }) +}) - it('binds every schema FK policy to a required executable merge stage', () => { - expect(Object.keys(ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS).sort()).toEqual( - schemaPrincipalForeignKeys() - ) - for (const [key, policy] of Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY)) { - expect( - ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS[ - key as keyof typeof ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS - ] - ).toBe(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE[policy]) - } +describe('anonymous principal FK executable ownership', () => { + it('binds every production FK to a validated callable handler operation', () => { + const createRegistry = executableRegistryFactory() + const handlers = ( + mergeModule as unknown as { + ANONYMOUS_PRINCIPAL_FK_EXECUTABLE_HANDLERS?: Record + } + ).ANONYMOUS_PRINCIPAL_FK_EXECUTABLE_HANDLERS + + expect(handlers).toBeDefined() + expect(() => createRegistry(handlers!)).not.toThrow() + expect(Object.keys(handlers!).sort()).toEqual(schemaPrincipalForeignKeys()) }) - it('fails closed before source deletion when any bound handler stage was not executed', () => { - const tracker = createAnonymousPrincipalFkExecutionTracker() - for (const stage of ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES.slice(0, -1)) { - tracker.complete(stage) + it('invokes every per-FK callable and proves every declared operation completed', async () => { + const createRegistry = executableRegistryFactory() + const handlers = fakeExecutableHandlers() + const registry = createRegistry(handlers) + + for (const key of Object.keys(ANONYMOUS_PRINCIPAL_FK_POLICY)) { + await expect(registry.invoke(key, 'apply', { marker: key })).resolves.toEqual({ + key, + context: { marker: key }, + }) } - expect(() => tracker.assertComplete()).toThrow( - 'Anonymous principal FK handler stage was not executed' + expect(() => registry.assertComplete()).not.toThrow() + for (const handler of Object.values(handlers)) { + expect(handler.operations.apply).toHaveBeenCalledTimes(1) + } + }) + + it('fails closed when a handler binding is omitted', () => { + const createRegistry = executableRegistryFactory() + const handlers = fakeExecutableHandlers() + delete handlers['votes.principal_id'] + + expect(() => createRegistry(handlers)).toThrow( + 'Anonymous principal FK executable handler coverage mismatch' ) + }) - tracker.complete(ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES.at(-1)!) - expect(() => tracker.assertComplete()).not.toThrow() + it('fails closed when a declared operation is not callable', () => { + const createRegistry = executableRegistryFactory() + const handlers = fakeExecutableHandlers() + handlers['votes.principal_id'] = { + owner: 'votes.principal_id', + policy: 'dedupe', + operations: { apply: null as unknown as (context: unknown) => unknown }, + } + + expect(() => createRegistry(handlers)).toThrow( + 'Anonymous principal FK executable handler is not callable' + ) }) - it('guards every target-only FK before source-principal deletion', () => { - expect([...GUARDED_TARGET_ONLY_ANONYMOUS_PRINCIPAL_KEYS].sort()).toEqual( - Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY) - .filter(([, policy]) => policy === 'target_only') - .map(([key]) => key) - .sort() + it('fails closed when an FK is bound to the wrong policy', () => { + const createRegistry = executableRegistryFactory() + const handlers = fakeExecutableHandlers() + handlers['votes.principal_id'] = { + ...handlers['votes.principal_id']!, + policy: 'reparent', + } + + expect(() => createRegistry(handlers)).toThrow( + 'Anonymous principal FK executable handler policy mismatch' ) }) - it('requires every principal FK to block the truly-empty anonymous sweep', () => { - expect(Object.keys(ANONYMOUS_SWEEP_PRINCIPAL_FK_POLICY).sort()).toEqual( - schemaPrincipalForeignKeys() + it('fails closed when a callable owned by one FK is bound to another FK with the same policy', () => { + const createRegistry = executableRegistryFactory() + const handlers = fakeExecutableHandlers() + handlers['posts.principal_id'] = handlers['comments.principal_id']! + + expect(() => createRegistry(handlers)).toThrow( + 'Anonymous principal FK executable handler owner mismatch' ) - expect(new Set(Object.values(ANONYMOUS_SWEEP_PRINCIPAL_FK_POLICY))).toEqual( - new Set(['blocks_sweep']) + }) + + it('fails closed before source deletion when any callable operation was not invoked', async () => { + const createRegistry = executableRegistryFactory() + const handlers = fakeExecutableHandlers() + const registry = createRegistry(handlers) + const keys = Object.keys(ANONYMOUS_PRINCIPAL_FK_POLICY) + + for (const key of keys.slice(0, -1)) { + await registry.invoke(key, 'apply', null) + } + + expect(() => registry.assertComplete()).toThrow( + 'Anonymous principal FK executable handler operation was not invoked' ) }) }) diff --git a/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts b/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts index 25928f5bc..c9c0b045a 100644 --- a/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts +++ b/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts @@ -55,78 +55,6 @@ export type AnonymousPrincipalFkKey = keyof typeof ANONYMOUS_PRINCIPAL_FK_POLICY export type AnonymousPrincipalFkPolicy = (typeof ANONYMOUS_PRINCIPAL_FK_POLICY)[AnonymousPrincipalFkKey] -export const ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE = Object.freeze({ - target_only: 'target_only_guard', - reparent: 'direct_reparent', - dedupe: 'dedupe_and_reparent', - conservative_merge: 'conservative_fold_and_reparent', -} as const satisfies Record) - -export type AnonymousPrincipalFkHandlerStage = - (typeof ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE)[AnonymousPrincipalFkPolicy] - -/** - * Explicit binding from every principal FK to the concrete merge phase that - * handles it. Do not derive this table from the policy: requiring a second, - * typed declaration is what makes a newly classified FK fail compilation - * until its SQL handler has been deliberately audited. - */ -export const ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS = Object.freeze({ - 'api_keys.created_by_id': 'target_only_guard', - 'api_keys.principal_id': 'target_only_guard', - 'bug_report_submissions.principal_id': 'direct_reparent', - 'changelog_entries.principal_id': 'target_only_guard', - 'chat_message_flags.principal_id': 'target_only_guard', - 'chat_message_mentions.principal_id': 'target_only_guard', - 'chat_message_reactions.principal_id': 'target_only_guard', - 'chat_messages.deleted_by_principal_id': 'direct_reparent', - 'chat_messages.principal_id': 'direct_reparent', - 'comment_edit_history.editor_principal_id': 'direct_reparent', - 'comment_reactions.principal_id': 'dedupe_and_reparent', - 'comments.deleted_by_principal_id': 'direct_reparent', - 'comments.principal_id': 'direct_reparent', - 'conversations.assigned_agent_principal_id': 'target_only_guard', - 'conversations.visitor_principal_id': 'direct_reparent', - 'external_user_mappings.principal_id': 'direct_reparent', - 'feedback_suggestions.resolved_by_principal_id': 'target_only_guard', - 'in_app_notifications.principal_id': 'direct_reparent', - 'integration_platform_credentials.configured_by_principal_id': 'target_only_guard', - 'integrations.connected_by_principal_id': 'target_only_guard', - 'integrations.principal_id': 'target_only_guard', - 'kb_article_feedback.principal_id': 'dedupe_and_reparent', - 'kb_articles.principal_id': 'target_only_guard', - 'merge_suggestions.resolved_by_principal_id': 'target_only_guard', - 'notification_preferences.principal_id': 'conservative_fold_and_reparent', - 'post_activity.principal_id': 'direct_reparent', - 'post_edit_history.editor_principal_id': 'direct_reparent', - 'post_mentions.principal_id': 'target_only_guard', - 'post_notes.principal_id': 'target_only_guard', - 'post_subscriptions.principal_id': 'conservative_fold_and_reparent', - 'posts.deleted_by_principal_id': 'direct_reparent', - 'posts.merged_by_principal_id': 'target_only_guard', - 'posts.owner_principal_id': 'target_only_guard', - 'posts.principal_id': 'direct_reparent', - 'posts.tracked_by_principal_id': 'target_only_guard', - 'push_devices.principal_id': 'target_only_guard', - 'raw_feedback_items.principal_id': 'direct_reparent', - 'unsubscribe_tokens.principal_id': 'direct_reparent', - 'user_segments.principal_id': 'conservative_fold_and_reparent', - 'votes.added_by_principal_id': 'target_only_guard', - 'votes.principal_id': 'dedupe_and_reparent', - 'webhooks.created_by_id': 'target_only_guard', -} as const satisfies Record) - -export const ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES = Object.freeze( - Array.from(new Set(Object.values(ANONYMOUS_PRINCIPAL_FK_HANDLER_BINDINGS))).sort() -) as readonly AnonymousPrincipalFkHandlerStage[] - -export const ANONYMOUS_PRINCIPAL_MERGE_KEYS = Object.freeze( - Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY) - .filter(([, policy]) => policy !== 'target_only') - .map(([key]) => key) - .sort() -) as readonly AnonymousPrincipalFkKey[] - export const ANONYMOUS_PRINCIPAL_TARGET_ONLY_KEYS = Object.freeze( Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY) .filter(([, policy]) => policy === 'target_only') @@ -134,19 +62,107 @@ export const ANONYMOUS_PRINCIPAL_TARGET_ONLY_KEYS = Object.freeze( .sort() ) as readonly AnonymousPrincipalFkKey[] -export function createAnonymousPrincipalFkExecutionTracker(): { - complete(stage: AnonymousPrincipalFkHandlerStage): void +export type AnonymousPrincipalFkExecutableOperation = ( + context: Context +) => unknown | Promise + +export interface AnonymousPrincipalFkExecutableHandler { + owner: AnonymousPrincipalFkKey + policy: AnonymousPrincipalFkPolicy + operations: Readonly>> +} + +export type AnonymousPrincipalFkExecutableHandlers = Readonly<{ + [Key in AnonymousPrincipalFkKey]: AnonymousPrincipalFkExecutableHandler & { + owner: Key + policy: (typeof ANONYMOUS_PRINCIPAL_FK_POLICY)[Key] + } +}> + +function ownDataRecord(value: unknown): Record | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + for (const key of Reflect.ownKeys(record)) { + if (typeof key !== 'string') return null + const descriptor = Object.getOwnPropertyDescriptor(record, key) + if (!descriptor?.enumerable || !('value' in descriptor)) return null + } + return record +} + +/** + * Validate and execute the per-FK ownership registry. + * + * Every declared operation must run successfully before source deletion. The + * validated snapshot prevents later mutation of the caller's registry from + * turning this proof into a metadata-only assertion. + */ +export function createAnonymousPrincipalFkExecutionRegistry( + handlers: unknown, + policy: Readonly> = ANONYMOUS_PRINCIPAL_FK_POLICY +): { + invoke(key: string, operation: string, context: Context): Promise assertComplete(): void } { - const completed = new Set() + const handlerRecord = ownDataRecord(handlers) + const expectedKeys = Object.keys(policy).sort() + const actualKeys = handlerRecord ? Object.keys(handlerRecord).sort() : [] + if ( + !handlerRecord || + actualKeys.length !== expectedKeys.length || + !actualKeys.every((key, index) => key === expectedKeys[index]) + ) { + throw new Error('Anonymous principal FK executable handler coverage mismatch') + } + + const snapshot = new Map< + string, + ReadonlyMap> + >() + const requiredOperations = new Set() + for (const key of expectedKeys) { + const handler = ownDataRecord(handlerRecord[key]) + if (!handler || handler.owner !== key) { + throw new Error('Anonymous principal FK executable handler owner mismatch') + } + if (handler.policy !== policy[key]) { + throw new Error('Anonymous principal FK executable handler policy mismatch') + } + const operations = ownDataRecord(handler.operations) + const operationNames = operations ? Object.keys(operations).sort() : [] + if (!operations || operationNames.length === 0) { + throw new Error('Anonymous principal FK executable handler is not callable') + } + const operationSnapshot = new Map>() + for (const operationName of operationNames) { + const operation = operations[operationName] + if (typeof operation !== 'function') { + throw new Error('Anonymous principal FK executable handler is not callable') + } + operationSnapshot.set( + operationName, + operation as AnonymousPrincipalFkExecutableOperation + ) + requiredOperations.add(`${key}\0${operationName}`) + } + snapshot.set(key, operationSnapshot) + } + + const completedOperations = new Set() return { - complete(stage) { - completed.add(stage) + async invoke(key, operation, context) { + const executable = snapshot.get(key)?.get(operation) + if (!executable) { + throw new Error('Anonymous principal FK executable handler operation is not registered') + } + const result = await executable(context) + completedOperations.add(`${key}\0${operation}`) + return result }, assertComplete() { - for (const stage of ANONYMOUS_PRINCIPAL_REQUIRED_HANDLER_STAGES) { - if (!completed.has(stage)) { - throw new Error('Anonymous principal FK handler stage was not executed') + for (const required of requiredOperations) { + if (!completedOperations.has(required)) { + throw new Error('Anonymous principal FK executable handler operation was not invoked') } } }, diff --git a/apps/web/src/lib/server/auth/merge-anonymous.ts b/apps/web/src/lib/server/auth/merge-anonymous.ts index bb4024e31..85f741f3e 100644 --- a/apps/web/src/lib/server/auth/merge-anonymous.ts +++ b/apps/web/src/lib/server/auth/merge-anonymous.ts @@ -14,10 +14,11 @@ import { createId, toUuid, type PrincipalId, type UserId } from '@quackback/ids' import { IDENTITY_MERGE_TOMBSTONE_USER_AGENT } from './identity-merge-tombstone' import { lockIdentityActorUsers } from './identity-merge-locks' import { - ANONYMOUS_PRINCIPAL_MERGE_KEYS, - ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE, ANONYMOUS_PRINCIPAL_TARGET_ONLY_KEYS, - createAnonymousPrincipalFkExecutionTracker, + createAnonymousPrincipalFkExecutionRegistry, + type AnonymousPrincipalFkExecutableHandler, + type AnonymousPrincipalFkExecutableHandlers, + type AnonymousPrincipalFkKey, } from './anonymous-principal-fk-policy' import { db, @@ -91,101 +92,720 @@ export interface PreserveConsumedSession { consumedAt: Date } -/** - * Independently declared beside the executable handlers. The schema-policy - * test compares this list with every non-target-only registry entry so adding - * a classification without adding a merge handler cannot pass unnoticed. - */ -export const EXECUTED_ANONYMOUS_PRINCIPAL_MERGE_KEYS = ANONYMOUS_PRINCIPAL_MERGE_KEYS +type CommentRow = typeof comments.$inferSelect -export const GUARDED_TARGET_ONLY_ANONYMOUS_PRINCIPAL_KEYS = ANONYMOUS_PRINCIPAL_TARGET_ONLY_KEYS +interface AnonymousPrincipalFkHandlerContext { + tx: Transaction + anonPrincipalId: PrincipalId + targetPrincipalId: PrincipalId + anonPrincipalUuid: ReturnType + targetPrincipalUuid: ReturnType + conservativePreferenceUuid: ReturnType + anonDisplayName: string + targetDisplayName: string + state: { + anonCommentIds: CommentRow['id'][] + } +} -export async function mergeAnonymousToIdentifiedInTransaction( - tx: Transaction, - params: MergeAnonymousParams, - preserve?: PreserveConsumedSession -): Promise { - const { anonPrincipalId, targetPrincipalId, anonUserId, anonDisplayName, targetDisplayName } = - params - const anonPrincipalUuid = toUuid(anonPrincipalId) - const targetPrincipalUuid = toUuid(targetPrincipalId) - const conservativePreferenceUuid = toUuid(createId('notif_pref')) - const fkExecution = createAnonymousPrincipalFkExecutionTracker() +type AnonymousPrincipalFkHandler = + AnonymousPrincipalFkExecutableHandler +type AnonymousPrincipalFkOperation = AnonymousPrincipalFkHandler['operations'][string] - // `target_only` is an application invariant, not a cross-table database - // constraint. Fail closed if impossible/legacy rows exist: deleting the - // source must never silently cascade or null out data the merge does not own. - const targetOnlyReferences = await tx.execute(sql` - SELECT ( - EXISTS (SELECT 1 FROM ${apiKeys} WHERE ${apiKeys.createdById} = ${anonPrincipalUuid}) - OR EXISTS (SELECT 1 FROM ${apiKeys} WHERE ${apiKeys.principalId} = ${anonPrincipalUuid}) - OR EXISTS ( +function targetOnlyGuard( + owner: Owner, + guard: AnonymousPrincipalFkOperation +): AnonymousPrincipalFkHandler & { owner: Owner; policy: 'target_only' } { + return { owner, policy: 'target_only', operations: { guard } } +} + +function mergeHandler< + Owner extends AnonymousPrincipalFkKey, + Policy extends AnonymousPrincipalFkHandler['policy'], +>( + owner: Owner, + policy: Policy, + operations: Record +): AnonymousPrincipalFkHandler & { owner: Owner; policy: Policy } { + return { owner, policy, operations } +} + +/** + * Every principal FK owns at least one concrete callable operation. The + * execution registry validates this map against the schema policy, invokes the + * operations in the established transaction order, and refuses source + * deletion until every declared operation completed. + */ +export const ANONYMOUS_PRINCIPAL_FK_EXECUTABLE_HANDLERS = Object.freeze({ + 'api_keys.created_by_id': targetOnlyGuard( + 'api_keys.created_by_id', + ({ anonPrincipalUuid }) => + sql`EXISTS (SELECT 1 FROM ${apiKeys} WHERE ${apiKeys.createdById} = ${anonPrincipalUuid})` + ), + 'api_keys.principal_id': targetOnlyGuard( + 'api_keys.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS (SELECT 1 FROM ${apiKeys} WHERE ${apiKeys.principalId} = ${anonPrincipalUuid})` + ), + 'bug_report_submissions.principal_id': mergeHandler( + 'bug_report_submissions.principal_id', + 'reparent', + { + sanitize: ({ tx, anonPrincipalId }) => + tx + .update(bugReportSubmissions) + .set({ clientSubmissionId: null }) + .where(eq(bugReportSubmissions.principalId, anonPrincipalId)), + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(bugReportSubmissions) + .set({ principalId: targetPrincipalId }) + .where(eq(bugReportSubmissions.principalId, anonPrincipalId)), + } + ), + 'changelog_entries.principal_id': targetOnlyGuard( + 'changelog_entries.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${changelogEntries} WHERE ${changelogEntries.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'chat_message_flags.principal_id': targetOnlyGuard( + 'chat_message_flags.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${chatMessageFlags} WHERE ${chatMessageFlags.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'chat_message_mentions.principal_id': targetOnlyGuard( + 'chat_message_mentions.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${chatMessageMentions} WHERE ${chatMessageMentions.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'chat_message_reactions.principal_id': targetOnlyGuard( + 'chat_message_reactions.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${chatMessageReactions} WHERE ${chatMessageReactions.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'chat_messages.deleted_by_principal_id': mergeHandler( + 'chat_messages.deleted_by_principal_id', + 'reparent', + { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(chatMessages) + .set({ deletedByPrincipalId: targetPrincipalId }) + .where(eq(chatMessages.deletedByPrincipalId, anonPrincipalId)), + } + ), + 'chat_messages.principal_id': mergeHandler('chat_messages.principal_id', 'reparent', { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(chatMessages) + .set({ principalId: targetPrincipalId }) + .where(eq(chatMessages.principalId, anonPrincipalId)), + }), + 'comment_edit_history.editor_principal_id': mergeHandler( + 'comment_edit_history.editor_principal_id', + 'reparent', + { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(commentEditHistory) + .set({ editorPrincipalId: targetPrincipalId }) + .where(eq(commentEditHistory.editorPrincipalId, anonPrincipalId)), + } + ), + 'comment_reactions.principal_id': mergeHandler('comment_reactions.principal_id', 'dedupe', { + lock: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + SELECT id FROM ${commentReactions} + WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) + ORDER BY principal_id, comment_id, emoji, id + FOR UPDATE + `), + dedupe: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + DELETE FROM ${commentReactions} source + USING ${commentReactions} target + WHERE source.principal_id = ${anonPrincipalUuid} + AND target.principal_id = ${targetPrincipalUuid} + AND source.comment_id = target.comment_id + AND source.emoji = target.emoji + `), + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(commentReactions) + .set({ principalId: targetPrincipalId }) + .where(eq(commentReactions.principalId, anonPrincipalId)), + }), + 'comments.deleted_by_principal_id': mergeHandler('comments.deleted_by_principal_id', 'reparent', { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(comments) + .set({ deletedByPrincipalId: targetPrincipalId }) + .where(eq(comments.deletedByPrincipalId, anonPrincipalId)), + }), + 'comments.principal_id': mergeHandler('comments.principal_id', 'reparent', { + capture: async ({ tx, anonPrincipalId, state }) => { + const rows = await tx + .select({ id: comments.id }) + .from(comments) + .where(eq(comments.principalId, anonPrincipalId)) + state.anonCommentIds = rows.map((row) => row.id) + }, + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(comments) + .set({ principalId: targetPrincipalId }) + .where(eq(comments.principalId, anonPrincipalId)), + notification_cleanup: async ({ + tx, + state, + targetPrincipalId, + anonDisplayName, + targetDisplayName, + }) => { + if (state.anonCommentIds.length === 0) return + await tx + .delete(inAppNotifications) + .where( + and( + eq(inAppNotifications.principalId, targetPrincipalId), + inArray(inAppNotifications.commentId, state.anonCommentIds) + ) + ) + const displayName = anonDisplayName || 'Anonymous' + await tx + .update(inAppNotifications) + .set({ + title: sql`REPLACE(${inAppNotifications.title}, ${displayName}, ${targetDisplayName})`, + }) + .where(inArray(inAppNotifications.commentId, state.anonCommentIds)) + }, + }), + 'conversations.assigned_agent_principal_id': targetOnlyGuard( + 'conversations.assigned_agent_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${conversations} WHERE ${conversations.assignedAgentPrincipalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'conversations.visitor_principal_id': mergeHandler( + 'conversations.visitor_principal_id', + 'reparent', + { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(conversations) + .set({ visitorPrincipalId: targetPrincipalId }) + .where(eq(conversations.visitorPrincipalId, anonPrincipalId)), + } + ), + 'external_user_mappings.principal_id': mergeHandler( + 'external_user_mappings.principal_id', + 'reparent', + { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(externalUserMappings) + .set({ principalId: targetPrincipalId }) + .where(eq(externalUserMappings.principalId, anonPrincipalId)), + } + ), + 'feedback_suggestions.resolved_by_principal_id': targetOnlyGuard( + 'feedback_suggestions.resolved_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${feedbackSuggestions} WHERE ${feedbackSuggestions.resolvedByPrincipalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'in_app_notifications.principal_id': mergeHandler( + 'in_app_notifications.principal_id', + 'reparent', + { + reparent_late: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(inAppNotifications) + .set({ principalId: targetPrincipalId }) + .where(eq(inAppNotifications.principalId, anonPrincipalId)), + } + ), + 'integration_platform_credentials.configured_by_principal_id': targetOnlyGuard( + 'integration_platform_credentials.configured_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${integrationPlatformCredentials} WHERE ${integrationPlatformCredentials.configuredByPrincipalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'integrations.connected_by_principal_id': targetOnlyGuard( + 'integrations.connected_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${integrations} WHERE ${integrations.connectedByPrincipalId} = ${anonPrincipalUuid} - OR ${integrations.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'integrations.principal_id': targetOnlyGuard( + 'integrations.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${integrations} + WHERE ${integrations.principalId} = ${anonPrincipalUuid} + )` + ), + 'kb_article_feedback.principal_id': mergeHandler('kb_article_feedback.principal_id', 'dedupe', { + lock: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + SELECT id FROM ${helpCenterArticleFeedback} + WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) + ORDER BY principal_id, article_id, id + FOR UPDATE + `), + dedupe: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + WITH deleted AS ( + DELETE FROM ${helpCenterArticleFeedback} source + USING ${helpCenterArticleFeedback} target + WHERE source.principal_id = ${anonPrincipalUuid} + AND target.principal_id = ${targetPrincipalUuid} + AND source.article_id = target.article_id + RETURNING source.article_id, source.helpful + ), + removed AS ( + SELECT + article_id, + COUNT(*) FILTER (WHERE helpful) AS helpful_count, + COUNT(*) FILTER (WHERE NOT helpful) AS not_helpful_count + FROM deleted + GROUP BY article_id + ) + UPDATE ${helpCenterArticles} + SET + helpful_count = GREATEST( + 0, + ${helpCenterArticles.helpfulCount} - removed.helpful_count::integer + ), + not_helpful_count = GREATEST( + 0, + ${helpCenterArticles.notHelpfulCount} - removed.not_helpful_count::integer + ) + FROM removed + WHERE ${helpCenterArticles.id} = removed.article_id + `), + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(helpCenterArticleFeedback) + .set({ principalId: targetPrincipalId }) + .where(eq(helpCenterArticleFeedback.principalId, anonPrincipalId)), + }), + 'kb_articles.principal_id': targetOnlyGuard( + 'kb_articles.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${helpCenterArticles} WHERE ${helpCenterArticles.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'merge_suggestions.resolved_by_principal_id': targetOnlyGuard( + 'merge_suggestions.resolved_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${mergeSuggestions} WHERE ${mergeSuggestions.resolvedByPrincipalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'notification_preferences.principal_id': mergeHandler( + 'notification_preferences.principal_id', + 'conservative_merge', + { + lock: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + SELECT id FROM ${notificationPreferences} + WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) + ORDER BY principal_id, id + FOR UPDATE + `), + merge: async ({ tx, anonPrincipalUuid, targetPrincipalUuid, conservativePreferenceUuid }) => { + await tx.execute(sql` + INSERT INTO ${notificationPreferences} ( + id, + principal_id, + email_status_change, + email_new_comment, + email_muted, + created_at, + updated_at + ) + SELECT + ${conservativePreferenceUuid}, + ${targetPrincipalUuid}, + TRUE, + TRUE, + TRUE, + NOW(), + NOW() + WHERE EXISTS ( + SELECT 1 + FROM ${unsubscribeTokens} + WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) + AND action = 'unsubscribe_all' + AND used_at IS NOT NULL + ) + ON CONFLICT (principal_id) + DO UPDATE SET + email_muted = TRUE, + updated_at = NOW() + `) + await tx.execute(sql` + UPDATE ${notificationPreferences} + SET + email_status_change = + ${notificationPreferences.emailStatusChange} AND source.email_status_change, + email_new_comment = + ${notificationPreferences.emailNewComment} AND source.email_new_comment, + email_muted = + ${notificationPreferences.emailMuted} OR source.email_muted, + updated_at = NOW() + FROM ${notificationPreferences} source + WHERE ${notificationPreferences.principalId} = ${targetPrincipalUuid} + AND source.principal_id = ${anonPrincipalUuid} + `) + await tx.execute(sql` + DELETE FROM ${notificationPreferences} source + USING ${notificationPreferences} target + WHERE source.principal_id = ${anonPrincipalUuid} + AND target.principal_id = ${targetPrincipalUuid} + `) + }, + reparent_late: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(notificationPreferences) + .set({ principalId: targetPrincipalId }) + .where(eq(notificationPreferences.principalId, anonPrincipalId)), + } + ), + 'post_activity.principal_id': mergeHandler('post_activity.principal_id', 'reparent', { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(postActivity) + .set({ principalId: targetPrincipalId }) + .where(eq(postActivity.principalId, anonPrincipalId)), + }), + 'post_edit_history.editor_principal_id': mergeHandler( + 'post_edit_history.editor_principal_id', + 'reparent', + { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(postEditHistory) + .set({ editorPrincipalId: targetPrincipalId }) + .where(eq(postEditHistory.editorPrincipalId, anonPrincipalId)), + } + ), + 'post_mentions.principal_id': targetOnlyGuard( + 'post_mentions.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${postMentions} WHERE ${postMentions.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'post_notes.principal_id': targetOnlyGuard( + 'post_notes.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${postNotes} WHERE ${postNotes.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'post_subscriptions.principal_id': mergeHandler( + 'post_subscriptions.principal_id', + 'conservative_merge', + { + lock: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + SELECT id FROM ${postSubscriptions} + WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) + ORDER BY principal_id, post_id, id + FOR UPDATE + `), + merge: async ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => { + await tx.execute(sql` + UPDATE ${postSubscriptions} + SET + notify_comments = + ${postSubscriptions.notifyComments} AND source.notify_comments, + notify_status_changes = + ${postSubscriptions.notifyStatusChanges} AND source.notify_status_changes, + updated_at = NOW() + FROM ${postSubscriptions} source + WHERE ${postSubscriptions.principalId} = ${targetPrincipalUuid} + AND source.principal_id = ${anonPrincipalUuid} + AND source.post_id = ${postSubscriptions.postId} + `) + await tx.execute(sql` + DELETE FROM ${postSubscriptions} + WHERE ${postSubscriptions.principalId} = ${targetPrincipalUuid} + AND NOT EXISTS ( + SELECT 1 + FROM ${postSubscriptions} source + WHERE source.principal_id = ${anonPrincipalUuid} + AND source.post_id = ${postSubscriptions.postId} + ) + `) + await tx.execute(sql` + DELETE FROM ${postSubscriptions} + WHERE ${postSubscriptions.principalId} = ${anonPrincipalUuid} + `) + }, + } + ), + 'posts.deleted_by_principal_id': mergeHandler('posts.deleted_by_principal_id', 'reparent', { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(posts) + .set({ deletedByPrincipalId: targetPrincipalId }) + .where(eq(posts.deletedByPrincipalId, anonPrincipalId)), + }), + 'posts.merged_by_principal_id': targetOnlyGuard( + 'posts.merged_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${posts} WHERE ${posts.mergedByPrincipalId} = ${anonPrincipalUuid} - OR ${posts.ownerPrincipalId} = ${anonPrincipalUuid} - OR ${posts.trackedByPrincipalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'posts.owner_principal_id': targetOnlyGuard( + 'posts.owner_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${posts} + WHERE ${posts.ownerPrincipalId} = ${anonPrincipalUuid} + )` + ), + 'posts.principal_id': mergeHandler('posts.principal_id', 'reparent', { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(posts) + .set({ principalId: targetPrincipalId }) + .where(eq(posts.principalId, anonPrincipalId)), + }), + 'posts.tracked_by_principal_id': targetOnlyGuard( + 'posts.tracked_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${posts} + WHERE ${posts.trackedByPrincipalId} = ${anonPrincipalUuid} + )` + ), + 'push_devices.principal_id': targetOnlyGuard( + 'push_devices.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${pushDevices} WHERE ${pushDevices.principalId} = ${anonPrincipalUuid} - ) - OR EXISTS ( + )` + ), + 'raw_feedback_items.principal_id': mergeHandler('raw_feedback_items.principal_id', 'reparent', { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(rawFeedbackItems) + .set({ + principalId: targetPrincipalId, + author: sql`jsonb_set( + ${rawFeedbackItems.author}, + '{principalId}', + to_jsonb(${targetPrincipalId}::text), + true + )`, + }) + .where(eq(rawFeedbackItems.principalId, anonPrincipalId)), + }), + 'unsubscribe_tokens.principal_id': mergeHandler('unsubscribe_tokens.principal_id', 'reparent', { + lock: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + SELECT id FROM ${unsubscribeTokens} + WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) + ORDER BY principal_id, id + FOR UPDATE + `), + reparent_late: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(unsubscribeTokens) + .set({ principalId: targetPrincipalId }) + .where(eq(unsubscribeTokens.principalId, anonPrincipalId)), + }), + 'user_segments.principal_id': mergeHandler('user_segments.principal_id', 'conservative_merge', { + lock: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + SELECT principal_id FROM ${userSegments} + WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) + ORDER BY principal_id, segment_id + FOR UPDATE + `), + merge: async ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => { + await tx.execute(sql` + UPDATE ${userSegments} + SET + added_by = CASE + WHEN ( + CASE source.added_by + WHEN 'manual' THEN 3 + WHEN 'api' THEN 2 + ELSE 1 + END + ) > ( + CASE ${userSegments.addedBy} + WHEN 'manual' THEN 3 + WHEN 'api' THEN 2 + ELSE 1 + END + ) + THEN source.added_by + ELSE ${userSegments.addedBy} + END, + added_at = LEAST(${userSegments.addedAt}, source.added_at) + FROM ${userSegments} source + WHERE ${userSegments.principalId} = ${targetPrincipalUuid} + AND source.principal_id = ${anonPrincipalUuid} + AND source.segment_id = ${userSegments.segmentId} + AND source.added_by IN ('manual', 'api') + `) + await tx.execute(sql` + DELETE FROM ${userSegments} source + USING ${userSegments} target + WHERE source.principal_id = ${anonPrincipalUuid} + AND target.principal_id = ${targetPrincipalUuid} + AND source.segment_id = target.segment_id + AND source.added_by IN ('manual', 'api') + `) + await tx.execute(sql` + DELETE FROM ${userSegments} + WHERE ${userSegments.principalId} = ${anonPrincipalUuid} + AND ${userSegments.addedBy} NOT IN ('manual', 'api') + `) + }, + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(userSegments) + .set({ principalId: targetPrincipalId }) + .where(eq(userSegments.principalId, anonPrincipalId)), + }), + 'votes.added_by_principal_id': targetOnlyGuard( + 'votes.added_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${votes} WHERE ${votes.addedByPrincipalId} = ${anonPrincipalUuid} + )` + ), + 'votes.principal_id': mergeHandler('votes.principal_id', 'dedupe', { + lock: ({ tx, anonPrincipalUuid, targetPrincipalUuid }) => + tx.execute(sql` + SELECT id FROM ${votes} + WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) + ORDER BY principal_id, post_id, id + FOR UPDATE + `), + dedupe: async ({ tx, anonPrincipalId, targetPrincipalId }) => { + const existingVotedPostIds = await tx + .select({ postId: votes.postId }) + .from(votes) + .where(eq(votes.principalId, targetPrincipalId)) + if (existingVotedPostIds.length === 0) return + await tx.delete(votes).where( + and( + eq(votes.principalId, anonPrincipalId), + inArray( + votes.postId, + existingVotedPostIds.map((vote) => vote.postId) + ) + ) ) - OR EXISTS ( + }, + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => + tx + .update(votes) + .set({ principalId: targetPrincipalId }) + .where(eq(votes.principalId, anonPrincipalId)), + recount: ({ tx, targetPrincipalUuid }) => + tx.execute(sql` + UPDATE ${posts} + SET vote_count = ( + SELECT COUNT(*)::integer + FROM ${votes} + WHERE ${votes.postId} = ${posts.id} + ) + WHERE ${posts.id} IN ( + SELECT ${votes.postId} + FROM ${votes} + WHERE ${votes.principalId} = ${targetPrincipalUuid} + ) + `), + }), + 'webhooks.created_by_id': targetOnlyGuard( + 'webhooks.created_by_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( SELECT 1 FROM ${webhooks} WHERE ${webhooks.createdById} = ${anonPrincipalUuid} - ) + )` + ), +} as const satisfies AnonymousPrincipalFkExecutableHandlers) + +export async function mergeAnonymousToIdentifiedInTransaction( + tx: Transaction, + params: MergeAnonymousParams, + preserve?: PreserveConsumedSession +): Promise { + const { anonPrincipalId, targetPrincipalId, anonUserId, anonDisplayName, targetDisplayName } = + params + const anonPrincipalUuid = toUuid(anonPrincipalId) + const targetPrincipalUuid = toUuid(targetPrincipalId) + const conservativePreferenceUuid = toUuid(createId('notif_pref')) + const fkContext: AnonymousPrincipalFkHandlerContext = { + tx, + anonPrincipalId, + targetPrincipalId, + anonPrincipalUuid, + targetPrincipalUuid, + conservativePreferenceUuid, + anonDisplayName, + targetDisplayName, + state: { anonCommentIds: [] }, + } + const fkExecution = + createAnonymousPrincipalFkExecutionRegistry( + ANONYMOUS_PRINCIPAL_FK_EXECUTABLE_HANDLERS + ) + + // `target_only` is an application invariant, not a cross-table database + // constraint. Fail closed if impossible/legacy rows exist: deleting the + // source must never silently cascade or null out data the merge does not own. + const targetOnlyGuardClauses: ReturnType[] = [] + for (const key of ANONYMOUS_PRINCIPAL_TARGET_ONLY_KEYS) { + targetOnlyGuardClauses.push( + (await fkExecution.invoke(key, 'guard', fkContext)) as ReturnType + ) + } + const targetOnlyGuardExpression = targetOnlyGuardClauses.reduce( + (combined, clause) => sql`${combined} OR ${clause}` + ) + const targetOnlyReferences = await tx.execute(sql` + SELECT ( + ${targetOnlyGuardExpression} ) AS target_only_reference_present `) const targetOnlyReferencePresent = ( @@ -194,7 +814,6 @@ export async function mergeAnonymousToIdentifiedInTransaction( if (targetOnlyReferencePresent !== false) { throw new Error('Anonymous principal has target-only references') } - fkExecution.complete(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE.target_only) // Lock every conflict/evidence row before reading or folding it. Principal // FOR UPDATE locks (held by both callers) block new FK inserts; these row @@ -202,404 +821,100 @@ export async function mergeAnonymousToIdentifiedInTransaction( // Unsubscribe processing locks principal → token → preference/subscription. // Merge must use the exact same child-row order or an unsubscribe action can // deadlock token→subscription against merge's subscription→token. - await tx.execute(sql` - SELECT id FROM ${unsubscribeTokens} - WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) - ORDER BY principal_id, id - FOR UPDATE - `) - await tx.execute(sql` - SELECT id FROM ${postSubscriptions} - WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) - ORDER BY principal_id, post_id, id - FOR UPDATE - `) - await tx.execute(sql` - SELECT id FROM ${notificationPreferences} - WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) - ORDER BY principal_id, id - FOR UPDATE - `) - await tx.execute(sql` - SELECT id FROM ${votes} - WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) - ORDER BY principal_id, post_id, id - FOR UPDATE - `) - await tx.execute(sql` - SELECT id FROM ${commentReactions} - WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) - ORDER BY principal_id, comment_id, emoji, id - FOR UPDATE - `) - await tx.execute(sql` - SELECT id FROM ${helpCenterArticleFeedback} - WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) - ORDER BY principal_id, article_id, id - FOR UPDATE - `) - await tx.execute(sql` - SELECT principal_id FROM ${userSegments} - WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) - ORDER BY principal_id, segment_id - FOR UPDATE - `) + for (const key of [ + 'unsubscribe_tokens.principal_id', + 'post_subscriptions.principal_id', + 'notification_preferences.principal_id', + 'votes.principal_id', + 'comment_reactions.principal_id', + 'kb_article_feedback.principal_id', + 'user_segments.principal_id', + ] as const) { + await fkExecution.invoke(key, 'lock', fkContext) + } // 1. Handle vote conflicts: delete anon votes that overlap with target's existing votes - const existingVotedPostIds = await tx - .select({ postId: votes.postId }) - .from(votes) - .where(eq(votes.principalId, targetPrincipalId)) - - if (existingVotedPostIds.length > 0) { - await tx.delete(votes).where( - and( - eq(votes.principalId, anonPrincipalId), - inArray( - votes.postId, - existingVotedPostIds.map((v) => v.postId) - ) - ) - ) - } + await fkExecution.invoke('votes.principal_id', 'dedupe', fkContext) // 2. Get anonymous comment IDs before transfer (for notification cleanup) - const anonCommentIds = await tx - .select({ id: comments.id }) - .from(comments) - .where(eq(comments.principalId, anonPrincipalId)) + await fkExecution.invoke('comments.principal_id', 'capture', fkContext) // 3. Resolve uniqueness conflicts before reparenting. These statements are // intentionally set-based so a concurrent target preference write is // serialized by PostgreSQL row locks rather than read/modified in JS. - await tx.execute(sql` - UPDATE ${postSubscriptions} - SET - notify_comments = - ${postSubscriptions.notifyComments} AND source.notify_comments, - notify_status_changes = - ${postSubscriptions.notifyStatusChanges} AND source.notify_status_changes, - updated_at = NOW() - FROM ${postSubscriptions} source - WHERE ${postSubscriptions.principalId} = ${targetPrincipalUuid} - AND source.principal_id = ${anonPrincipalUuid} - AND source.post_id = ${postSubscriptions.postId} - `) - await tx.execute(sql` - DELETE FROM ${postSubscriptions} - WHERE ${postSubscriptions.principalId} = ${targetPrincipalUuid} - AND NOT EXISTS ( - SELECT 1 - FROM ${postSubscriptions} source - WHERE source.principal_id = ${anonPrincipalUuid} - AND source.post_id = ${postSubscriptions.postId} - ) - `) - await tx.execute(sql` - DELETE FROM ${postSubscriptions} - WHERE ${postSubscriptions.principalId} = ${anonPrincipalUuid} - `) + await fkExecution.invoke('post_subscriptions.principal_id', 'merge', fkContext) // The unsubscribe action historically marked the token before materializing // emailMuted in a separate statement. Consult that durable evidence so a // crash between those writes cannot make identify re-enable global email. - await tx.execute(sql` - INSERT INTO ${notificationPreferences} ( - id, - principal_id, - email_status_change, - email_new_comment, - email_muted, - created_at, - updated_at - ) - SELECT - ${conservativePreferenceUuid}, - ${targetPrincipalUuid}, - TRUE, - TRUE, - TRUE, - NOW(), - NOW() - WHERE EXISTS ( - SELECT 1 - FROM ${unsubscribeTokens} - WHERE principal_id IN (${anonPrincipalUuid}, ${targetPrincipalUuid}) - AND action = 'unsubscribe_all' - AND used_at IS NOT NULL - ) - ON CONFLICT (principal_id) - DO UPDATE SET - email_muted = TRUE, - updated_at = NOW() - `) - - await tx.execute(sql` - UPDATE ${notificationPreferences} - SET - email_status_change = - ${notificationPreferences.emailStatusChange} AND source.email_status_change, - email_new_comment = - ${notificationPreferences.emailNewComment} AND source.email_new_comment, - email_muted = - ${notificationPreferences.emailMuted} OR source.email_muted, - updated_at = NOW() - FROM ${notificationPreferences} source - WHERE ${notificationPreferences.principalId} = ${targetPrincipalUuid} - AND source.principal_id = ${anonPrincipalUuid} - `) - await tx.execute(sql` - DELETE FROM ${notificationPreferences} source - USING ${notificationPreferences} target - WHERE source.principal_id = ${anonPrincipalUuid} - AND target.principal_id = ${targetPrincipalUuid} - `) + await fkExecution.invoke('notification_preferences.principal_id', 'merge', fkContext) - await tx.execute(sql` - DELETE FROM ${commentReactions} source - USING ${commentReactions} target - WHERE source.principal_id = ${anonPrincipalUuid} - AND target.principal_id = ${targetPrincipalUuid} - AND source.comment_id = target.comment_id - AND source.emoji = target.emoji - `) + await fkExecution.invoke('comment_reactions.principal_id', 'dedupe', fkContext) // A single actor may have only one KB verdict per article. Target wins on a // duplicate; compensate the denormalized article counter for every source // row removed instead of silently inflating it. - await tx.execute(sql` - WITH deleted AS ( - DELETE FROM ${helpCenterArticleFeedback} source - USING ${helpCenterArticleFeedback} target - WHERE source.principal_id = ${anonPrincipalUuid} - AND target.principal_id = ${targetPrincipalUuid} - AND source.article_id = target.article_id - RETURNING source.article_id, source.helpful - ), - removed AS ( - SELECT - article_id, - COUNT(*) FILTER (WHERE helpful) AS helpful_count, - COUNT(*) FILTER (WHERE NOT helpful) AS not_helpful_count - FROM deleted - GROUP BY article_id - ) - UPDATE ${helpCenterArticles} - SET - helpful_count = GREATEST( - 0, - ${helpCenterArticles.helpfulCount} - removed.helpful_count::integer - ), - not_helpful_count = GREATEST( - 0, - ${helpCenterArticles.notHelpfulCount} - removed.not_helpful_count::integer - ) - FROM removed - WHERE ${helpCenterArticles.id} = removed.article_id - `) + await fkExecution.invoke('kb_article_feedback.principal_id', 'dedupe', fkContext) // Only durable manual/API assertions survive identity merge. Dynamic rows // were evaluated from source-user metadata that is about to be deleted; // SSO/widget rows must be reconciled from the target's current verified // claims, never inherited from the anonymous source. - await tx.execute(sql` - UPDATE ${userSegments} - SET - added_by = CASE - WHEN ( - CASE source.added_by - WHEN 'manual' THEN 3 - WHEN 'api' THEN 2 - ELSE 1 - END - ) > ( - CASE ${userSegments.addedBy} - WHEN 'manual' THEN 3 - WHEN 'api' THEN 2 - ELSE 1 - END - ) - THEN source.added_by - ELSE ${userSegments.addedBy} - END, - added_at = LEAST(${userSegments.addedAt}, source.added_at) - FROM ${userSegments} source - WHERE ${userSegments.principalId} = ${targetPrincipalUuid} - AND source.principal_id = ${anonPrincipalUuid} - AND source.segment_id = ${userSegments.segmentId} - AND source.added_by IN ('manual', 'api') - `) - await tx.execute(sql` - DELETE FROM ${userSegments} source - USING ${userSegments} target - WHERE source.principal_id = ${anonPrincipalUuid} - AND target.principal_id = ${targetPrincipalUuid} - AND source.segment_id = target.segment_id - AND source.added_by IN ('manual', 'api') - `) - await tx.execute(sql` - DELETE FROM ${userSegments} - WHERE ${userSegments.principalId} = ${anonPrincipalUuid} - AND ${userSegments.addedBy} NOT IN ('manual', 'api') - `) + await fkExecution.invoke('user_segments.principal_id', 'merge', fkContext) // Identity-scoped outbox ids are purged on actor change. Preserve the opaque // report receipt/post/effect ledger, but clear the old retry key before // reparenting so it cannot collide with a target submission. - await tx - .update(bugReportSubmissions) - .set({ clientSubmissionId: null }) - .where(eq(bugReportSubmissions.principalId, anonPrincipalId)) + await fkExecution.invoke('bug_report_submissions.principal_id', 'sanitize', fkContext) // 4. Transfer actor-owned rows and historical attribution. // Chat rows use onDelete:'restrict', so re-pointing them here is mandatory — // otherwise the anon-principal delete below throws and breaks the merge. - await Promise.all([ - tx - .update(votes) - .set({ principalId: targetPrincipalId }) - .where(eq(votes.principalId, anonPrincipalId)), - tx - .update(comments) - .set({ principalId: targetPrincipalId }) - .where(eq(comments.principalId, anonPrincipalId)), - tx - .update(posts) - .set({ principalId: targetPrincipalId }) - .where(eq(posts.principalId, anonPrincipalId)), - tx - .update(conversations) - .set({ visitorPrincipalId: targetPrincipalId }) - .where(eq(conversations.visitorPrincipalId, anonPrincipalId)), - tx - .update(chatMessages) - .set({ principalId: targetPrincipalId }) - .where(eq(chatMessages.principalId, anonPrincipalId)), - tx - .update(commentReactions) - .set({ principalId: targetPrincipalId }) - .where(eq(commentReactions.principalId, anonPrincipalId)), - tx - .update(postEditHistory) - .set({ editorPrincipalId: targetPrincipalId }) - .where(eq(postEditHistory.editorPrincipalId, anonPrincipalId)), - tx - .update(commentEditHistory) - .set({ editorPrincipalId: targetPrincipalId }) - .where(eq(commentEditHistory.editorPrincipalId, anonPrincipalId)), - tx - .update(postActivity) - .set({ principalId: targetPrincipalId }) - .where(eq(postActivity.principalId, anonPrincipalId)), - tx - .update(posts) - .set({ deletedByPrincipalId: targetPrincipalId }) - .where(eq(posts.deletedByPrincipalId, anonPrincipalId)), - tx - .update(comments) - .set({ deletedByPrincipalId: targetPrincipalId }) - .where(eq(comments.deletedByPrincipalId, anonPrincipalId)), - tx - .update(chatMessages) - .set({ deletedByPrincipalId: targetPrincipalId }) - .where(eq(chatMessages.deletedByPrincipalId, anonPrincipalId)), - tx - .update(bugReportSubmissions) - .set({ principalId: targetPrincipalId }) - .where(eq(bugReportSubmissions.principalId, anonPrincipalId)), - tx - .update(rawFeedbackItems) - .set({ - principalId: targetPrincipalId, - author: sql`jsonb_set( - ${rawFeedbackItems.author}, - '{principalId}', - to_jsonb(${targetPrincipalId}::text), - true - )`, - }) - .where(eq(rawFeedbackItems.principalId, anonPrincipalId)), - tx - .update(externalUserMappings) - .set({ principalId: targetPrincipalId }) - .where(eq(externalUserMappings.principalId, anonPrincipalId)), - tx - .update(helpCenterArticleFeedback) - .set({ principalId: targetPrincipalId }) - .where(eq(helpCenterArticleFeedback.principalId, anonPrincipalId)), - tx - .update(userSegments) - .set({ principalId: targetPrincipalId }) - .where(eq(userSegments.principalId, anonPrincipalId)), - ]) + await Promise.all( + [ + 'votes.principal_id', + 'comments.principal_id', + 'posts.principal_id', + 'conversations.visitor_principal_id', + 'chat_messages.principal_id', + 'comment_reactions.principal_id', + 'post_edit_history.editor_principal_id', + 'comment_edit_history.editor_principal_id', + 'post_activity.principal_id', + 'posts.deleted_by_principal_id', + 'comments.deleted_by_principal_id', + 'chat_messages.deleted_by_principal_id', + 'bug_report_submissions.principal_id', + 'raw_feedback_items.principal_id', + 'external_user_mappings.principal_id', + 'kb_article_feedback.principal_id', + 'user_segments.principal_id', + ].map((key) => fkExecution.invoke(key, 'reparent', fkContext)) + ) // Vote dedupe removes a physical row while posts.vote_count is maintained by // application code (no trigger). Recompute every target-voted post from the // authoritative rows so ranking/counts cannot stay inflated. - await tx.execute(sql` - UPDATE ${posts} - SET vote_count = ( - SELECT COUNT(*)::integer - FROM ${votes} - WHERE ${votes.postId} = ${posts.id} - ) - WHERE ${posts.id} IN ( - SELECT ${votes.postId} - FROM ${votes} - WHERE ${votes.principalId} = ${targetPrincipalUuid} - ) - `) + await fkExecution.invoke('votes.principal_id', 'recount', fkContext) // 5. Fix notifications for transferred comments - if (anonCommentIds.length > 0) { - const commentIds = anonCommentIds.map((c) => c.id) - - // Delete self-notifications (recipient = target principal, about anonymous comments) - await tx - .delete(inAppNotifications) - .where( - and( - eq(inAppNotifications.principalId, targetPrincipalId), - inArray(inAppNotifications.commentId, commentIds) - ) - ) - - // Update remaining notification titles: replace anonymous name with real name - const displayName = anonDisplayName || 'Anonymous' - await tx - .update(inAppNotifications) - .set({ - title: sql`REPLACE(${inAppNotifications.title}, ${displayName}, ${targetDisplayName})`, - }) - .where(inArray(inAppNotifications.commentId, commentIds)) - } + await fkExecution.invoke('comments.principal_id', 'notification_cleanup', fkContext) // 6. Transfer remaining notification state. Subscription presence has no // durable negative/tombstone representation, so the privacy-safe temporary // policy above keeps only source∩target and transfers no one-sided rows. // This intentionally sacrifices close-loop email continuity until a durable // negative state is approved; My Reports continuity remains intact. - await Promise.all([ - tx - .update(notificationPreferences) - .set({ principalId: targetPrincipalId }) - .where(eq(notificationPreferences.principalId, anonPrincipalId)), - tx - .update(unsubscribeTokens) - .set({ principalId: targetPrincipalId }) - .where(eq(unsubscribeTokens.principalId, anonPrincipalId)), - tx - .update(inAppNotifications) - .set({ principalId: targetPrincipalId }) - .where(eq(inAppNotifications.principalId, anonPrincipalId)), - ]) + await Promise.all( + [ + 'notification_preferences.principal_id', + 'unsubscribe_tokens.principal_id', + 'in_app_notifications.principal_id', + ].map((key) => fkExecution.invoke(key, 'reparent_late', fkContext)) + ) - // Each policy is bound to one real stage above. The assertion remains - // immediately before source deletion so a newly classified FK cannot be - // represented only in metadata and then silently cascade. - fkExecution.complete(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE.dedupe) - fkExecution.complete(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE.conservative_merge) - fkExecution.complete(ANONYMOUS_PRINCIPAL_POLICY_HANDLER_STAGE.reparent) + // The assertion remains immediately before source deletion. It proves every + // callable owned by all 42 FK bindings completed, not merely a broad stage. fkExecution.assertComplete() // 7. The widget commit keeps the exact consumed source token as an expired, diff --git a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts index 777d736e3..90a62a0f7 100644 --- a/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts +++ b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts @@ -35,6 +35,10 @@ function request(data: Record = {}) { } describe('host submit report digest', () => { + it('classifies the content-derived report digest as telemetry-forbidden', () => { + expect(contract.telemetryForbiddenFields).toContain('reportDigest') + }) + it.each([ [ 'ASCII', diff --git a/docs/fixtures/quackback-report-submit-contract-v1.json b/docs/fixtures/quackback-report-submit-contract-v1.json index 3b1b108db..c792ac51d 100644 --- a/docs/fixtures/quackback-report-submit-contract-v1.json +++ b/docs/fixtures/quackback-report-submit-contract-v1.json @@ -108,6 +108,7 @@ "telemetryForbiddenFields": [ "summary", "impact", + "reportDigest", "hostSubmitAssertion", "clientSubmissionId", "requestId", From 149526271aa2be83e78945387c91804fc559a392 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 13:47:33 +0700 Subject: [PATCH 17/21] fix(widget): invalidate report on host identity change Prevent the prior actor's report flow from remaining live while exact-origin identity authorization completes asynchronously. --- apps/web/src/routes/widget/index.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index 885ef2e22..31d4710b5 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -467,6 +467,21 @@ function WidgetPage() { const msg = event.data if (!msg || typeof msg !== 'object') return + // Invalidate the active report synchronously at the message boundary. + // The auth provider verifies the exact source/origin before accepting the + // identity, but that verification is asynchronous; waiting for its + // hostIdentityVersion update would leave the old actor's flow live. + if (msg.type === 'quackback:identify') { + const flowId = activeBugReportFlowIdRef.current + updatePendingBugReportOpen(null) + if (flowId) { + clearBugReportFlow(flowId) + setActiveTab(resolveInitialTab(tabs)) + setView(resolveInitialView(tabs)) + } + return + } + if (msg.type === 'quackback:bug-report-reset') { const flowId = (msg.data as { flowId?: unknown } | undefined)?.flowId if (!isBugReportFlowId(flowId)) return From 1c3603d8a35efa05d60086af79ef0ca2969a2a71 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 13:52:45 +0700 Subject: [PATCH 18/21] fix(widget): clear release lint blockers Keep vendored capture code outside first-party linting and preserve fail-closed effect gating without a useless assignment. --- .../web/src/lib/server/domains/bug-reports/effects.service.ts | 4 ++-- eslint.config.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/server/domains/bug-reports/effects.service.ts b/apps/web/src/lib/server/domains/bug-reports/effects.service.ts index 6b76cc1c5..9f8943ac8 100644 --- a/apps/web/src/lib/server/domains/bug-reports/effects.service.ts +++ b/apps/web/src/lib/server/domains/bug-reports/effects.service.ts @@ -27,11 +27,11 @@ export async function dispatchBugReportPostEffectIfEnabled(input: { const moderationState = await input.readModerationState() if (moderationState !== 'published') return - let enabled = false + let enabled: boolean try { enabled = (await input.isEnabled()) === true } catch { - enabled = false + throw new BugReportEffectsDisabledError() } if (!enabled) throw new BugReportEffectsDisabledError() await input.announce(input.postId, input.eventId) diff --git a/eslint.config.js b/eslint.config.js index 2c885f75e..e777758d4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,7 @@ export default tseslint.config( "**/build/**", "**/.agents/**", "**/.claude/**", + "packages/widget/src/vendor/**", "**/*.config.js", "**/*.config.mjs", "**/next-env.d.ts", From 3b536aacb5d3fd0cd0e142c9f25cc651602cc18e Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 14:02:06 +0700 Subject: [PATCH 19/21] test: align release fixtures with strict types Keep media, route, recorder, and help-center fixtures assignable to the production contracts enforced by CI typechecking. --- .../widget/__tests__/widget-media-attachment.test.tsx | 8 ++++---- .../lib/client/capture/__tests__/screen-recording.test.ts | 6 +++--- .../src/lib/shared/bugreport/__tests__/assemble.test.ts | 2 +- .../api/v1/help-center/__tests__/categories.test.ts | 8 ++++++++ 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/widget/__tests__/widget-media-attachment.test.tsx b/apps/web/src/components/widget/__tests__/widget-media-attachment.test.tsx index 6d9b32475..1d8152a34 100644 --- a/apps/web/src/components/widget/__tests__/widget-media-attachment.test.tsx +++ b/apps/web/src/components/widget/__tests__/widget-media-attachment.test.tsx @@ -463,11 +463,11 @@ describe('imported media evidence', () => { const attach: WidgetEvidenceClient['attach'] = vi.fn(async () => ({ reference: { evidenceId: 'qbe_33333333-3333-4333-8333-333333333333', - kind: 'image', - status: 'uploaded', + kind: 'image' as const, + status: 'uploaded' as const, revision: 2, - processingRequired: true, - sanitizedDerivativeAvailable: false, + processingRequired: true as const, + sanitizedDerivativeAvailable: false as const, }, discard, })) diff --git a/apps/web/src/lib/client/capture/__tests__/screen-recording.test.ts b/apps/web/src/lib/client/capture/__tests__/screen-recording.test.ts index e8302b101..39a9792a0 100644 --- a/apps/web/src/lib/client/capture/__tests__/screen-recording.test.ts +++ b/apps/web/src/lib/client/capture/__tests__/screen-recording.test.ts @@ -246,7 +246,7 @@ describe('screen recording contract', () => { attempted.push(mimeType) if (mimeType?.startsWith('video/mp4')) throw new DOMException('no mp4', 'NotSupportedError') - return new FakeRecorder(mimeType) as unknown as MediaRecorder + return new FakeRecorder(mimeType ?? 'video/webm') as unknown as MediaRecorder }, }), limits: limits(), @@ -439,7 +439,7 @@ describe('screen recording contract', () => { const session = new ScreenRecordingSession({ dependencies: dependencies({ createRecorder: (_stream, mimeType) => { - recorder = new FakeRecorder(mimeType) + recorder = new FakeRecorder(mimeType ?? 'video/webm') return recorder as unknown as MediaRecorder }, }), @@ -450,7 +450,7 @@ describe('screen recording contract', () => { await session.begin() await waitForEvent(sink.events, 'bytes') session.stop() - recorder?.dispatchEvent(new Event('error')) + ;(recorder as FakeRecorder | null)?.dispatchEvent(new Event('error')) expect(await waitForEvent(sink.events, 'failure')).toMatchObject({ code: 'recorder-failed' }) expect(sink.events.some((event) => event.type === 'result')).toBe(false) diff --git a/apps/web/src/lib/shared/bugreport/__tests__/assemble.test.ts b/apps/web/src/lib/shared/bugreport/__tests__/assemble.test.ts index d0875ecc2..4133fff6e 100644 --- a/apps/web/src/lib/shared/bugreport/__tests__/assemble.test.ts +++ b/apps/web/src/lib/shared/bugreport/__tests__/assemble.test.ts @@ -381,7 +381,7 @@ describe('assembleBugReport — wire technical_context (V2 allow-list)', () => { 'https://app.example.dev/settings?email=PII_CANARY', ]) { const input = richInput() - input.host!.route = { url: route, referrer: '' } + input.host!.route = { url: route } const report = assembleBugReport(input) expect(report.technicalContext?.route, route).toBeUndefined() expect(report.contentMarkdown, route).not.toContain(`route ${route}`) diff --git a/apps/web/src/routes/api/v1/help-center/__tests__/categories.test.ts b/apps/web/src/routes/api/v1/help-center/__tests__/categories.test.ts index a5802d31d..11770d8f0 100644 --- a/apps/web/src/routes/api/v1/help-center/__tests__/categories.test.ts +++ b/apps/web/src/routes/api/v1/help-center/__tests__/categories.test.ts @@ -118,6 +118,7 @@ describe('GET /api/v1/help-center/categories', () => { icon: '\u{1F4DA}', parentId: null, isPublic: true, + adminOnly: false, position: 0, articleCount: 5, publishedArticleCount: 5, @@ -154,6 +155,7 @@ describe('GET /api/v1/help-center/categories', () => { icon: null, parentId: null, isPublic: true, + adminOnly: false, position: 1, articleCount: 0, publishedArticleCount: 0, @@ -193,6 +195,7 @@ describe('POST /api/v1/help-center/categories', () => { icon: '\u{1F4B0}', parentId: 'category_01jk0000000000000000000001' as HelpCenterCategoryId, isPublic: true, + adminOnly: false, position: 0, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), @@ -231,6 +234,7 @@ describe('POST /api/v1/help-center/categories', () => { icon: null, parentId: null, isPublic: true, + adminOnly: false, position: 0, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), @@ -288,6 +292,7 @@ describe('GET /api/v1/help-center/categories/:categoryId', () => { icon: '\u{1F680}', parentId: null, isPublic: true, + adminOnly: false, position: 0, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-02'), @@ -358,6 +363,7 @@ describe('PATCH /api/v1/help-center/categories/:categoryId', () => { icon: '\u{2728}', parentId: null, isPublic: true, + adminOnly: false, position: 0, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-03'), @@ -395,6 +401,7 @@ describe('PATCH /api/v1/help-center/categories/:categoryId', () => { icon: null, parentId: 'category_01jk0000000000000000000002' as HelpCenterCategoryId, isPublic: true, + adminOnly: false, position: 0, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-03'), @@ -434,6 +441,7 @@ describe('PATCH /api/v1/help-center/categories/:categoryId', () => { icon: null, parentId: null, isPublic: true, + adminOnly: false, position: 0, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-03'), From b06c6003a0e893f77e6afb785e2aa8712d1748b9 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 14:16:47 +0700 Subject: [PATCH 20/21] test: assert admin-only category service boundary --- .../help-center/__tests__/categories.test.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/web/src/routes/api/v1/help-center/__tests__/categories.test.ts b/apps/web/src/routes/api/v1/help-center/__tests__/categories.test.ts index 11770d8f0..a71ab1e7c 100644 --- a/apps/web/src/routes/api/v1/help-center/__tests__/categories.test.ts +++ b/apps/web/src/routes/api/v1/help-center/__tests__/categories.test.ts @@ -44,7 +44,10 @@ import type { import { Route } from '../categories/index' import { Route as CategoryDetailRoute } from '../categories/$categoryId' -type MockedHandler = (ctx: { request: Request; params?: Record }) => Promise +type MockedHandler = (ctx: { + request: Request + params?: Record +}) => Promise type MockedRouteShape = { options: { server: { handlers: Record } } } // Access handlers @@ -219,7 +222,8 @@ describe('POST /api/v1/help-center/categories', () => { name: 'Billing', icon: '\u{1F4B0}', parentId: 'category_01jk0000000000000000000001', - }) + }), + { includeAdminOnly: true } ) }) @@ -263,9 +267,7 @@ describe('POST /api/v1/help-center/categories', () => { it('returns 403 when auth fails (non-admin)', async () => { vi.mocked(isFeatureEnabled).mockResolvedValue(true) - vi.mocked(withApiKeyAuth).mockRejectedValue( - new ForbiddenError('FORBIDDEN', 'Admin required') - ) + vi.mocked(withApiKeyAuth).mockRejectedValue(new ForbiddenError('FORBIDDEN', 'Admin required')) const request = createRequest('POST', 'http://localhost/api/v1/help-center/categories', { name: 'Test', @@ -385,7 +387,8 @@ describe('PATCH /api/v1/help-center/categories/:categoryId', () => { expect(json.data.icon).toBe('\u{2728}') expect(updateCategory).toHaveBeenCalledWith( 'category_01jk0000000000000000000001', - expect.objectContaining({ icon: '\u{2728}' }) + expect.objectContaining({ icon: '\u{2728}' }), + { includeAdminOnly: true } ) }) @@ -425,7 +428,8 @@ describe('PATCH /api/v1/help-center/categories/:categoryId', () => { 'category_01jk0000000000000000000001', expect.objectContaining({ parentId: 'category_01jk0000000000000000000002', - }) + }), + { includeAdminOnly: true } ) }) @@ -463,15 +467,14 @@ describe('PATCH /api/v1/help-center/categories/:categoryId', () => { expect(json.data.icon).toBeNull() expect(updateCategory).toHaveBeenCalledWith( 'category_01jk0000000000000000000001', - expect.objectContaining({ icon: null }) + expect.objectContaining({ icon: null }), + { includeAdminOnly: true } ) }) it('returns 403 when auth fails (non-admin)', async () => { vi.mocked(isFeatureEnabled).mockResolvedValue(true) - vi.mocked(withApiKeyAuth).mockRejectedValue( - new ForbiddenError('FORBIDDEN', 'Admin required') - ) + vi.mocked(withApiKeyAuth).mockRejectedValue(new ForbiddenError('FORBIDDEN', 'Admin required')) const request = createRequest( 'PATCH', @@ -503,14 +506,14 @@ describe('DELETE /api/v1/help-center/categories/:categoryId', () => { }) expect(response.status).toBe(204) - expect(deleteCategory).toHaveBeenCalledWith('category_01jk0000000000000000000001') + expect(deleteCategory).toHaveBeenCalledWith('category_01jk0000000000000000000001', { + includeAdminOnly: true, + }) }) it('returns 403 when auth fails (non-admin)', async () => { vi.mocked(isFeatureEnabled).mockResolvedValue(true) - vi.mocked(withApiKeyAuth).mockRejectedValue( - new ForbiddenError('FORBIDDEN', 'Admin required') - ) + vi.mocked(withApiKeyAuth).mockRejectedValue(new ForbiddenError('FORBIDDEN', 'Admin required')) const request = createRequest( 'DELETE', From e66a110f604ed5f495086a6c55ac83e7bc79e48b Mon Sep 17 00:00:00 2001 From: iPLAYCAFE Date: Wed, 29 Jul 2026 14:58:37 +0700 Subject: [PATCH 21/21] fix(widget): retain skewed assertion replay marker Verification accepts bounded signer clock skew, so Redis must retain the one-time marker until the signed expiry. Otherwise a valid future-issued assertion is rejected before the replay fence is written. --- .../__tests__/host-submit-assertion.test.ts | 29 +++++++++++++++++++ .../bug-reports/host-submit-assertion.ts | 5 +++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-assertion.test.ts b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-assertion.test.ts index 1d894d703..a648045e4 100644 --- a/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-assertion.test.ts +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-assertion.test.ts @@ -139,6 +139,35 @@ describe('consumeHostSubmitAssertionOnce', () => { expect(set.mock.calls[0]![0]).not.toContain(claims.jti) }) + it.each([1, 5])( + 'keeps the replay key through an accepted assertion issued %ss ahead', + async (secondsAhead) => { + const set = vi.fn().mockResolvedValue('OK') + const redis = { set } + const claims = verifyHostSubmitAssertion( + sign({ + iat: NOW_SECONDS + secondsAhead, + exp: NOW_SECONDS + secondsAhead + 30, + }), + EXPECTED, + SECRET, + NOW_SECONDS * 1000 + )! + + expect(claims).not.toBeNull() + await expect(consumeHostSubmitAssertionOnce(claims, NOW_SECONDS * 1000, redis)).resolves.toBe( + 'consumed' + ) + expect(set).toHaveBeenCalledWith( + expect.stringMatching(/^host-submit-assertion:used:[a-f0-9]{64}$/), + '1', + 'EX', + 30 + secondsAhead, + 'NX' + ) + } + ) + it('fails closed when Redis is unavailable or the assertion is no longer live', async () => { const claims = verifyHostSubmitAssertion(sign(), EXPECTED, SECRET, NOW_SECONDS * 1000)! const unavailable = { set: vi.fn().mockRejectedValue(new Error('private redis failure')) } diff --git a/apps/web/src/lib/server/domains/bug-reports/host-submit-assertion.ts b/apps/web/src/lib/server/domains/bug-reports/host-submit-assertion.ts index 6d896ce8c..a16c8b061 100644 --- a/apps/web/src/lib/server/domains/bug-reports/host-submit-assertion.ts +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-assertion.ts @@ -12,6 +12,7 @@ const JTI = /^[A-Za-z0-9_-]{16,128}$/ const REPORT_DIGEST = /^[a-f0-9]{64}$/ const TOKEN = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/ const FUTURE_IAT_SKEW_SECONDS = 5 +const MAX_REPLAY_TTL_SECONDS = HOST_SUBMIT_ASSERTION_MAX_TTL_SECONDS + FUTURE_IAT_SKEW_SECONDS const USED_KEY_DOMAIN = 'iplaycafe/quackback-host-submit/assertion-jti/v1' export interface HostSubmitAssertionExpected { @@ -204,7 +205,9 @@ export async function consumeHostSubmitAssertionOnce( redis: AssertionRedis = getRedis() ): Promise<'consumed' | 'replayed' | 'unavailable'> { const remaining = claims.exp - Math.floor(nowMs / 1000) - if (remaining <= 0 || remaining > HOST_SUBMIT_ASSERTION_MAX_TTL_SECONDS) return 'replayed' + // Verification accepts bounded issuer clock skew. Keep the NX marker through + // the signed expiry instead of expiring it before a future-issued token does. + if (remaining <= 0 || remaining > MAX_REPLAY_TTL_SECONDS) return 'replayed' const fingerprint = createHash('sha256') .update(USED_KEY_DOMAIN) .update('\0')