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..ce4506e30 --- /dev/null +++ b/apps/web/src/components/widget/__tests__/widget-auth-provider-identity-race.test.tsx @@ -0,0 +1,1758 @@ +// @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, + readPersistedToken, + setWidgetToken, +} 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 { + ANONYMOUS_RESTORE_TIMEOUT_MS, + 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, + canSendPrivilegedHostMessage, + privilegedHostTransport, + sendPrivilegedHostMessage, + } = useWidgetAuth() + return ( + <> + + {user?.id ?? 'anonymous'} + + {String(canSendPrivilegedHostMessage())} + {privilegedHostTransport()} + )} 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-post-detail.tsx b/apps/web/src/components/widget/widget-post-detail.tsx index 324decc07..85fcdf686 100644 --- a/apps/web/src/components/widget/widget-post-detail.tsx +++ b/apps/web/src/components/widget/widget-post-detail.tsx @@ -14,7 +14,10 @@ import type { PublicPostDetailView } from '@/lib/client/queries/portal-detail' import { WidgetVoteButton } from './widget-vote-button' import { WidgetCommentList } from './widget-comment-list' import { useWidgetAuth } from './widget-auth-provider' -import { sendToHost } from '@/lib/client/widget-bridge' +import { + openDirectWidgetNavigation, + preopenDirectWidgetNavigation, +} from '@/lib/client/widget-navigation' import { WidgetCommentForm } from './widget-comment-form' import { WidgetPortalTitle } from './widget-portal-title' import type { TiptapContent } from '@/lib/shared/db-types' @@ -41,6 +44,9 @@ export function WidgetPostDetail({ postId, statuses }: WidgetPostDetailProps) { identifyWithEmail, emitEvent, sessionVersion, + canSendPrivilegedHostMessage, + privilegedHostTransport, + sendPrivilegedHostMessage, } = useWidgetAuth() const queryClient = useQueryClient() @@ -68,16 +74,49 @@ export function WidgetPostDetail({ postId, statuses }: WidgetPostDetailProps) { const handleViewOnPortal = useCallback(async () => { if (!post) return + const privileged = canSendPrivilegedHostMessage() + if (!privileged) { + openDirectWidgetNavigation( + buildPortalUrl({ + origin: window.location.origin, + boardSlug: post.board.slug, + postId: post.id, + isIdentified: false, + ott: null, + }) + ) + return + } + + const transport = privilegedHostTransport() + const pendingNavigation = + transport === 'local' && isIdentified ? preopenDirectWidgetNavigation() : null const ott = isIdentified ? await generateOneTimeToken() : null const url = buildPortalUrl({ origin: window.location.origin, boardSlug: post.board.slug, postId: post.id, - isIdentified, + isIdentified: privileged && isIdentified, ott, }) - sendToHost({ type: 'quackback:navigate', url }) - }, [post, isIdentified]) + if (transport !== 'local' && sendPrivilegedHostMessage({ type: 'quackback:navigate', url })) { + pendingNavigation?.close() + return + } + if (transport === 'local') { + if (pendingNavigation) { + pendingNavigation.navigate(url) + } else { + openDirectWidgetNavigation(url) + } + } + }, [ + post, + isIdentified, + canSendPrivilegedHostMessage, + privilegedHostTransport, + sendPrivilegedHostMessage, + ]) /** Submit a comment (root or reply). */ const submitComment = useCallback( diff --git a/apps/web/src/components/widget/widget-shell.tsx b/apps/web/src/components/widget/widget-shell.tsx index 809ecb7e3..70ac51edd 100644 --- a/apps/web/src/components/widget/widget-shell.tsx +++ b/apps/web/src/components/widget/widget-shell.tsx @@ -13,7 +13,10 @@ import { cn } from '@/lib/shared/utils' import { Avatar } from '@/components/ui/avatar' import { UserStatsBar } from '@/components/shared/user-stats' import { getWidgetAuthHeaders, generateOneTimeToken } from '@/lib/client/widget-auth' -import { sendToHost } from '@/lib/client/widget-bridge' +import { + openDirectWidgetNavigation, + preopenDirectWidgetNavigation, +} from '@/lib/client/widget-navigation' import { useWidgetAuth } from './widget-auth-provider' import { type WidgetTab, type EnabledTabs, visibleTabs } from './widget-nav' @@ -85,7 +88,15 @@ export function WidgetShell({ const intl = useIntl() const tabsToShow = visibleTabs(enabledTabs) const showTabBar = tabsToShow.length > 1 - const { user, isIdentified, hmacRequired, closeWidget } = useWidgetAuth() + const { + user, + isIdentified, + hmacRequired, + closeWidget, + canSendPrivilegedHostMessage, + privilegedHostTransport, + sendPrivilegedHostMessage, + } = useWidgetAuth() // Global Escape key handler — close widget from anywhere useEffect(() => { @@ -110,18 +121,47 @@ export function WidgetShell({ const [portalCtaError, setPortalCtaError] = useState(false) const handleGoToPortal = useCallback(async () => { setPortalCtaError(false) + const origin = portalOrigin || window.location.origin + if (!canSendPrivilegedHostMessage()) { + if (!openDirectWidgetNavigation(`${origin}/auth/login`)) setPortalCtaError(true) + return + } + const transport = privilegedHostTransport() + // Only channel-less local/top-level widgets need a reserved direct tab. + // Approved parent/native channels must not flash a speculative blank tab. + const pendingNavigation = transport === 'local' ? preopenDirectWidgetNavigation() : null const ott = await generateOneTimeToken() if (!ott) { + pendingNavigation?.close() setPortalCtaError(true) return } // Prefer the server-resolved portal origin so the handoff URL targets the // portal host — not the widget iframe's origin, which may differ in // self-hosted setups where the widget is served from a separate domain. - const origin = portalOrigin || window.location.origin const portalUrl = `${origin}/auth/widget-handoff?ott=${encodeURIComponent(ott)}` - sendToHost({ type: 'quackback:navigate', url: portalUrl }) - }, []) + if ( + transport !== 'local' && + sendPrivilegedHostMessage({ type: 'quackback:navigate', url: portalUrl }) + ) { + pendingNavigation?.close() + return + } + if (transport !== 'local') { + // A previously approved/native channel disappeared during OTT + // generation. Avoid a routine blank-tab flash; let the user retry. + setPortalCtaError(true) + return + } + if (!(pendingNavigation?.navigate(portalUrl) ?? false)) { + setPortalCtaError(true) + } + }, [ + canSendPrivilegedHostMessage, + portalOrigin, + privilegedHostTransport, + sendPrivilegedHostMessage, + ]) 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 new file mode 100644 index 000000000..cfadcd2f0 --- /dev/null +++ b/apps/web/src/lib/client/__tests__/bug-report-host-submit.test.ts @@ -0,0 +1,838 @@ +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, +} 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 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', + status: 'received', + createdAt: '2026-07-28T01:00:00.000Z', + 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', + data: { + contract: CONTRACT, + requestId: REQUEST_ID, + clientSubmissionId: SUBMISSION_ID, + summary: SUMMARY, + impact: IMPACT, + hostSubmitAssertion: assertion(), + ...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, + location: { origin: PROVIDER_ORIGIN }, + 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 < 24; index += 1) await Promise.resolve() +} + +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(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +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 + 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(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + 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({ + contract: CONTRACT, + requestId: REQUEST_ID, + clientSubmissionId: SUBMISSION_ID, + 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') + expect(harness.parent.postMessage.mock.calls.some((call) => call[1] === '*')).toBe(false) + + dispose() + parentBinding.dispose() + 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 }>() + 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('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 }) + 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(), + resolveBinding: (source, origin) => parentBinding.resolve(source, origin), + 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(), + resolveBinding: (source, origin) => binding.resolve(source, origin), + 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(), + resolveBinding: (source, origin) => binding.resolve(source, origin), + 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(), + resolveBinding: (source, origin) => binding.resolve(source, origin), + 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( + /currentBinding:\s*currentHostParentBinding,\s*resolveBinding:\s*resolveHostParentBinding/ + ) + 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/__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/__tests__/widget-navigation.test.ts b/apps/web/src/lib/client/__tests__/widget-navigation.test.ts new file mode 100644 index 000000000..8343e1199 --- /dev/null +++ b/apps/web/src/lib/client/__tests__/widget-navigation.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { openDirectWidgetNavigation, preopenDirectWidgetNavigation } from '../widget-navigation' + +describe('widget navigation', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reports a null reserve as a real popup failure', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + + expect(openDirectWidgetNavigation('https://portal.example.test/report')).toBe(false) + expect(open).toHaveBeenCalledWith('about:blank', '_blank') + }) + + it('reports only a thrown direct-open call as failure', () => { + vi.spyOn(window, 'open').mockImplementation(() => { + throw new Error('browser rejected the call') + }) + + expect(openDirectWidgetNavigation('https://portal.example.test/report')).toBe(false) + }) + + it('preopens about:blank, severs opener synchronously, then navigates the reserved tab', () => { + const replace = vi.fn() + const close = vi.fn() + const opened = { + opener: window, + document: { + createElement: vi.fn(() => ({ name: '', content: '' })), + head: { append: vi.fn() }, + }, + location: { replace }, + close, + } as unknown as Window + const open = vi.spyOn(window, 'open').mockReturnValue(opened) + + const pending = preopenDirectWidgetNavigation() + + expect(open).toHaveBeenCalledWith('about:blank', '_blank') + expect(opened.opener).toBeNull() + expect(opened.document.createElement).toHaveBeenCalledWith('meta') + expect(opened.document.head.append).toHaveBeenCalledWith( + expect.objectContaining({ name: 'referrer', content: 'no-referrer' }) + ) + expect(pending?.navigate('https://portal.example.test/handoff')).toBe(true) + expect(replace).toHaveBeenCalledWith('https://portal.example.test/handoff') + pending?.close() + expect(close).toHaveBeenCalledTimes(1) + }) +}) 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..48ffd4d2c --- /dev/null +++ b/apps/web/src/lib/client/bug-report-host-submit.ts @@ -0,0 +1,447 @@ +import { + computeHostSubmitReportDigest, + parseHostSubmitAssertionForProvider, + 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 BugReportHostParentChannel = Readonly<{ + generation: number + origin: string + source: Window +}> + +/** A basic channel upgraded by the server origin policy for capabilities/PII. */ +export type BugReportHostParentBinding = BugReportHostParentChannel + +export type BugReportHostParentBindingController = { + /** First exact source+origin tuple frozen for this iframe generation. */ + currentBasic(): BugReportHostParentChannel | null + basic(source: MessageEventSource | null, origin: string): BugReportHostParentChannel | null + /** Server-approved capability binding, if the one bounded upgrade passed. */ + 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 { + 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 basicChannel: BugReportHostParentChannel | null = null + let pending: PendingBugReportHostParentBinding | null = null + let epoch = 0 + let disposed = false + let approvalAttempted = false + + const clear = () => { + binding = null + basicChannel = null + pending = null + approvalAttempted = false + epoch += 1 + } + + const currentBasic = () => { + if ( + basicChannel && + (basicChannel.generation !== options.currentGeneration() || + basicChannel.source !== target.parent) + ) { + clear() + } + return basicChannel + } + + const basic = ( + source: MessageEventSource | null, + origin: string + ): BugReportHostParentChannel | null => { + const channel = currentBasic() + return channel && + channel.source === source && + channel.origin === origin && + target.parent === source + ? channel + : null + } + + const current = () => { + currentBasic() + if ( + binding && + (binding.generation !== options.currentGeneration() || binding.source !== target.parent) + ) { + clear() + } + 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 || + event.source !== target.parent || + !hasMessageType(event.data, 'quackback:identify') + ) { + return + } + + const generation = options.currentGeneration() + const origin = event.origin + const source = event.source as Window + const existingBasic = currentBasic() + if (existingBasic) { + // A navigated/reused WindowProxy cannot change the origin tuple within + // one iframe generation, even if its later origin is allowlisted. + if (existingBasic.source !== source || existingBasic.origin !== origin) return + } else { + basicChannel = Object.freeze({ generation, origin, source }) + } + + if (current() || approvalAttempted) return + approvalAttempted = true + const startingEpoch = epoch + + 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 || + basic(source, origin) === null + ) { + 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 + } + } + + target.addEventListener('message', handleMessage) + return { + currentBasic, + basic, + current, + resolve, + 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, + captured: BugReportHostParentBinding +): boolean { + const current = currentBinding() + return current === captured && target.parent === captured.source +} + +export function installBugReportHostSubmitBridge(options: { + authorizeOrigin(candidateOrigin: string): Promise<{ allowed: boolean }> + currentBinding(): BugReportHostParentBinding | null + resolveBinding( + source: MessageEventSource | null, + origin: string + ): Promise + submit(input: { + contract: typeof CONTRACT + requestId: string + clientSubmissionId: string + summary: string + impact: string + hostOrigin: string + hostSubmitAssertion: 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 + 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 || + target.parent !== binding.source + ) { + 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 + captured.source.postMessage( + resultMessage(correlation.requestId, { + accepted: false, + reason: 'invalid_request', + }), + captured.origin + ) + 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({ + 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' } + } + if (disposed || !bindingStillCurrent(target, options.currentBinding, captured)) return + captured.source.postMessage(resultMessage(request.requestId, result), captured.origin) + } + + target.addEventListener('message', handleMessage) + return () => { + if (disposed) return + disposed = true + target.removeEventListener('message', handleMessage) + } +} 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/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-auth.ts b/apps/web/src/lib/client/widget-auth.ts index 870d30644..f9c8c440a 100644 --- a/apps/web/src/lib/client/widget-auth.ts +++ b/apps/web/src/lib/client/widget-auth.ts @@ -40,9 +40,14 @@ export function getWidgetToken(): string | null { return _widgetToken } +/** Clears only the iframe-memory bearer, preserving any durable anonymous slot. */ +export function clearWidgetTokenMemory(): void { + _widgetToken = null +} + /** Clears the in-memory token AND any persisted anonymous copy. */ export function clearWidgetToken(): void { - _widgetToken = null + clearWidgetTokenMemory() clearPersistedToken() } 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/client/widget-navigation.ts b/apps/web/src/lib/client/widget-navigation.ts new file mode 100644 index 000000000..2383a69b9 --- /dev/null +++ b/apps/web/src/lib/client/widget-navigation.ts @@ -0,0 +1,63 @@ +export type PendingDirectWidgetNavigation = { + navigate(url: string): boolean + close(): void +} + +/** + * Reserve a tab synchronously while the click still has user activation. + * + * `noopener` intentionally makes `window.open()` return `null` in browsers, so + * the reserve path opens only `about:blank` and severs `opener` immediately, + * before any await or untrusted URL is involved. + */ +export function preopenDirectWidgetNavigation(): PendingDirectWidgetNavigation | null { + let opened: Window | null + try { + opened = window.open('about:blank', '_blank') + } catch { + return null + } + if (!opened) return null + + try { + opened.opener = null + const meta = opened.document.createElement('meta') + meta.name = 'referrer' + meta.content = 'no-referrer' + opened.document.head.append(meta) + } catch { + try { + opened.close() + } catch { + // Best-effort cleanup of a browser-owned window handle. + } + return null + } + + return { + navigate(url: string): boolean { + try { + opened.location.replace(url) + return true + } catch { + return false + } + }, + close() { + try { + opened.close() + } catch { + // The host may already have closed or navigated the reserved tab. + } + }, + } +} + +/** Open a non-capability fallback without giving the framed parent an opener or referrer. */ +export function openDirectWidgetNavigation(url: string): boolean { + const pending = preopenDirectWidgetNavigation() + if (!pending) return false + if (pending.navigate(url)) return true + pending.close() + return false +} 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 new file mode 100644 index 000000000..c06b75f16 --- /dev/null +++ b/apps/web/src/lib/server/auth/__tests__/anonymous-principal-fk-policy.test.ts @@ -0,0 +1,174 @@ +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 * 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[] { + const keys = new Set() + for (const candidate of Object.values(schema)) { + if (!isTable(candidate)) continue + const table = getTableConfig(candidate) + for (const foreignKey of table.foreignKeys) { + const reference = foreignKey.reference() + if (getTableConfig(reference.foreignTable).name !== 'principal') continue + for (const column of reference.columns) { + keys.add(`${table.name}.${column.name}`) + } + } + } + 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('requires every principal FK to block the truly-empty anonymous sweep', () => { + expect(Object.keys(ANONYMOUS_SWEEP_PRINCIPAL_FK_POLICY).sort()).toEqual( + schemaPrincipalForeignKeys() + ) + expect(new Set(Object.values(ANONYMOUS_SWEEP_PRINCIPAL_FK_POLICY))).toEqual( + new Set(['blocks_sweep']) + ) + }) +}) + +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('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(() => 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' + ) + }) + + 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('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('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' + ) + }) + + 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/__tests__/identify-merge.test.ts b/apps/web/src/lib/server/auth/__tests__/identify-merge.test.ts index 9a9e80165..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 @@ -1,184 +1,517 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PrincipalId, UserId } from '@quackback/ids' -/** - * Tests for resolveAndMergeAnonymousToken — the server-side logic that - * validates a previousToken from the widget and merges anonymous activity - * into the newly identified user. - */ +const TARGET_TOKEN = 'identified-target-token' +const PREVIOUS_TOKEN = 'anonymous-source-token' +const now = new Date('2026-07-29T00:00:00.000Z') -// Mock DB -const mockSessionFindFirst = vi.fn() -const mockPrincipalFindFirst = vi.fn() +type SessionRow = { + id: string + token: string + userId: UserId + expiresAt: Date + userAgent: string | null +} + +type PrincipalRow = { + id: PrincipalId + userId: UserId + type: string + role: string + displayName: string | null +} + +type TargetHintRow = Omit & { + targetSessionId: string +} + +const targetSession: SessionRow = { + id: 'target-session', + token: TARGET_TOKEN, + userId: 'user_target' as UserId, + expiresAt: new Date('2026-07-30T00:00:00.000Z'), + userAgent: null, +} +const sourceSession: SessionRow = { + id: 'source-session', + token: PREVIOUS_TOKEN, + userId: 'user_anon' as UserId, + expiresAt: new Date('2026-07-30T00:00:00.000Z'), + userAgent: null, +} +const targetPrincipal: PrincipalRow = { + id: 'principal_target' as PrincipalId, + userId: targetSession.userId, + type: 'user', + role: 'user', + displayName: 'Target', +} +const sourcePrincipal: PrincipalRow = { + id: 'principal_anon' as PrincipalId, + userId: sourceSession.userId, + type: 'anonymous', + role: 'user', + displayName: 'Anonymous', +} + +const mocks = vi.hoisted(() => { + const state = { + targetHints: [] as TargetHintRow[], + sourceHints: [] as Array<{ sourceUserId: UserId }>, + lockedUsers: [] as Array<{ lockedUserId: UserId }>, + lockedSessions: [] as SessionRow[], + provenanceRows: [] as Array<{ sessionId: string }>, + targetPrincipalHints: [] as Array<{ + targetPrincipalId: PrincipalId + type: string + role: string + }>, + principalRows: [] as PrincipalRow[], + targetHintRead: vi.fn(), + sourceHintRead: vi.fn(), + userLockWhere: vi.fn(), + sessionLockWhere: vi.fn(), + userLockForUpdate: vi.fn(), + sessionLockForUpdate: vi.fn(), + principalLockForUpdate: vi.fn(), + userOrderBy: vi.fn(), + sessionOrderBy: vi.fn(), + principalOrderBy: vi.fn(), + mergeInTransaction: vi.fn(), + transaction: vi.fn(), + sessionTable: { + id: 'session.id', + token: 'session.token', + userId: 'session.user_id', + expiresAt: 'session.expires_at', + }, + userTable: { id: 'user.id' }, + provenanceTable: { sessionId: 'widget_identified_session.session_id' }, + principalTable: { id: 'principal.id', userId: 'principal.user_id' }, + tx: {} as { select: ReturnType }, + } + state.tx = { + select: vi.fn((fields?: Record) => ({ + from: (table: unknown) => ({ + where: (condition: unknown) => { + if (table === state.sessionTable) { + if (fields && 'targetSessionId' in fields) { + return { + limit: async (count: number) => { + state.targetHintRead(count) + return state.targetHints + }, + } + } + if (fields && 'sourceUserId' in fields) { + return { + limit: async (count: number) => { + state.sourceHintRead(count) + return state.sourceHints + }, + } + } + state.sessionLockWhere(condition) + return { + orderBy: (column: unknown) => ({ + for: async (mode: string) => { + state.sessionOrderBy(column) + await state.sessionLockForUpdate(mode) + return state.lockedSessions + }, + }), + } + } + if (table === state.userTable) { + state.userLockWhere(condition) + return { + orderBy: (column: unknown) => ({ + for: async (mode: string) => { + state.userOrderBy(column) + await state.userLockForUpdate(mode) + return state.lockedUsers + }, + }), + } + } + if (table === state.provenanceTable) return Promise.resolve(state.provenanceRows) + if (table === state.principalTable) { + if (fields && 'targetPrincipalId' in fields) { + return { + limit: async () => state.targetPrincipalHints, + } + } + return { + orderBy: (column: unknown) => ({ + for: async (mode: string) => { + state.principalOrderBy(column) + await state.principalLockForUpdate(mode) + return state.principalRows + }, + }), + } + } + throw new Error('unexpected table') + }, + }), + })), + } + return state +}) vi.mock('@/lib/server/db', () => ({ db: { - query: { - session: { findFirst: (...args: unknown[]) => mockSessionFindFirst(...args) }, - principal: { findFirst: (...args: unknown[]) => mockPrincipalFindFirst(...args) }, - }, + transaction: (callback: (transaction: typeof mocks.tx) => unknown) => + mocks.transaction(callback), }, - session: { token: 'token', expiresAt: 'expiresAt', userId: 'userId' }, - principal: { userId: 'userId', id: 'id' }, - eq: vi.fn(), - and: vi.fn(), - gt: vi.fn(), + session: mocks.sessionTable, + user: mocks.userTable, + widgetIdentifiedSession: mocks.provenanceTable, + principal: mocks.principalTable, + eq: vi.fn((column, value) => ({ column, value })), + inArray: vi.fn((column, values) => ({ column, values })), + or: vi.fn((...conditions) => ({ conditions })), })) -// Mock the merge utility -const mockMerge = vi.fn() vi.mock('../merge-anonymous', () => ({ - mergeAnonymousToIdentified: (...args: unknown[]) => mockMerge(...args), + mergeAnonymousToIdentifiedInTransaction: (...args: unknown[]) => + mocks.mergeInTransaction(...args), })) import { resolveAndMergeAnonymousToken } from '../identify-merge' -describe('resolveAndMergeAnonymousToken', () => { - const TARGET_PRINCIPAL_ID = 'principal_target' as PrincipalId +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 }), + }) +} + +function arrangeSessions(rows: SessionRow[]) { + const target = rows.find((row) => row.token === TARGET_TOKEN) + const source = rows.find((row) => row.token === PREVIOUS_TOKEN) + mocks.targetHints = target + ? [ + { + targetSessionId: target.id, + token: target.token, + userId: target.userId, + expiresAt: target.expiresAt, + }, + ] + : [] + mocks.sourceHints = source ? [{ sourceUserId: source.userId }] : [] + mocks.lockedUsers = source + ? Array.from(new Set([source.userId, target?.userId].filter(Boolean) as UserId[])) + .sort() + .map((lockedUserId) => ({ lockedUserId })) + : [] + mocks.lockedSessions = source + ? rows + .filter((row) => row.userId === source.userId || row.token === TARGET_TOKEN) + .sort((left, right) => left.id.localeCompare(right.id)) + : [] +} +describe('resolveAndMergeAnonymousToken — two-phase commit boundary', () => { beforeEach(() => { vi.clearAllMocks() - mockMerge.mockResolvedValue(undefined) + arrangeSessions([targetSession, sourceSession]) + mocks.provenanceRows = [{ sessionId: targetSession.id }] + mocks.targetPrincipalHints = [ + { + targetPrincipalId: targetPrincipal.id, + type: targetPrincipal.type, + role: targetPrincipal.role, + }, + ] + mocks.principalRows = [targetPrincipal, sourcePrincipal] + mocks.transaction.mockImplementation(async (callback) => callback(mocks.tx)) + mocks.userLockForUpdate.mockResolvedValue(undefined) + mocks.sessionLockForUpdate.mockResolvedValue(undefined) + mocks.principalLockForUpdate.mockResolvedValue(undefined) + mocks.mergeInTransaction.mockResolvedValue(undefined) }) - it('does nothing when previousToken is null/undefined', async () => { - await resolveAndMergeAnonymousToken({ - previousToken: null, - targetPrincipalId: TARGET_PRINCIPAL_ID, - targetDisplayName: 'Jane', + afterEach(() => { + vi.useRealTimers() + }) + + it('locks both session tokens and atomically preserves the source as a target-owned tombstone', async () => { + await expect(commit()).resolves.toEqual({ status: 'merged' }) + + expect(mocks.transaction).toHaveBeenCalledTimes(1) + expect(mocks.targetHintRead).toHaveBeenCalledWith(1) + expect(mocks.sourceHintRead).toHaveBeenCalledWith(1) + expect(mocks.userLockForUpdate).toHaveBeenCalledWith('update') + expect(mocks.userOrderBy).toHaveBeenCalledWith(mocks.userTable.id) + expect(mocks.userLockWhere).toHaveBeenCalledWith({ + column: mocks.userTable.id, + values: [sourceSession.userId, targetSession.userId], }) + expect(mocks.sessionLockForUpdate).toHaveBeenCalledWith('update') + expect(mocks.sessionOrderBy).toHaveBeenCalledWith(mocks.sessionTable.id) + expect(mocks.sessionLockWhere).toHaveBeenCalledWith({ + conditions: [ + { column: mocks.sessionTable.userId, value: sourceSession.userId }, + { column: mocks.sessionTable.token, value: TARGET_TOKEN }, + ], + }) + expect(mocks.principalLockForUpdate).toHaveBeenCalledWith('update') + expect(mocks.principalOrderBy).toHaveBeenCalledWith(mocks.principalTable.id) + expect(mocks.mergeInTransaction).toHaveBeenCalledTimes(1) + expect(mocks.mergeInTransaction).toHaveBeenCalledWith( + mocks.tx, + { + anonPrincipalId: sourcePrincipal.id, + targetPrincipalId: targetPrincipal.id, + anonUserId: sourceSession.userId, + anonDisplayName: sourcePrincipal.displayName, + targetDisplayName: targetPrincipal.displayName, + }, + { + preservedSessionId: sourceSession.id, + targetUserId: targetSession.userId, + consumedAt: now, + } + ) + }) - expect(mockSessionFindFirst).not.toHaveBeenCalled() - expect(mockMerge).not.toHaveBeenCalled() + it('returns idempotent success for an expired source tombstone owned by the same target', async () => { + arrangeSessions([ + targetSession, + { + ...sourceSession, + userId: targetSession.userId, + expiresAt: new Date('1970-01-01T00:00:00.000Z'), + userAgent: 'quackback:identity-merge-tombstone:v1', + }, + ]) + mocks.principalRows = [targetPrincipal] + + await expect(commit()).resolves.toEqual({ status: 'already_merged' }) + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() }) - it('does nothing when previousToken is empty string', async () => { - await resolveAndMergeAnonymousToken({ - previousToken: '', - targetPrincipalId: TARGET_PRINCIPAL_ID, - targetDisplayName: 'Jane', - }) + it('does not accept an unmarked expired same-target session as an idempotent merge', async () => { + arrangeSessions([ + targetSession, + { + ...sourceSession, + userId: targetSession.userId, + expiresAt: new Date('1970-01-01T00:00:00.000Z'), + userAgent: null, + }, + ]) + mocks.principalRows = [targetPrincipal] - expect(mockSessionFindFirst).not.toHaveBeenCalled() - expect(mockMerge).not.toHaveBeenCalled() + await expect(commit()).resolves.toEqual({ status: 'conflict' }) + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() }) - it('does nothing when session is not found (expired or invalid token)', async () => { - mockSessionFindFirst.mockResolvedValue(null) + it('returns one uniform conflict for an expired tombstone owned by another target', async () => { + arrangeSessions([ + targetSession, + { + ...sourceSession, + userId: 'user_actor_a' as UserId, + expiresAt: new Date('1970-01-01T00:00:00.000Z'), + }, + ]) + mocks.principalRows = [ + targetPrincipal, + { + ...sourcePrincipal, + id: 'principal_actor_a' as PrincipalId, + userId: 'user_actor_a' as UserId, + type: 'user', + }, + ] - await resolveAndMergeAnonymousToken({ - previousToken: 'expired-token', - targetPrincipalId: TARGET_PRINCIPAL_ID, - targetDisplayName: 'Jane', + await expect(commit()).resolves.toEqual({ status: 'conflict' }) + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() + }) + + it('evaluates a cross-target tombstone against the clock after the session-lock wait', async () => { + const beforeWait = new Date('2026-07-29T00:00:00.000Z') + const afterWait = new Date('2026-07-29T00:00:01.000Z') + vi.useFakeTimers() + vi.setSystemTime(beforeWait) + mocks.sessionLockForUpdate.mockImplementationOnce(async () => { + vi.setSystemTime(afterWait) }) + arrangeSessions([ + targetSession, + { + ...sourceSession, + userId: 'user_actor_a' as UserId, + expiresAt: afterWait, + }, + ]) + mocks.principalRows = [ + targetPrincipal, + { + ...sourcePrincipal, + id: 'principal_actor_a' as PrincipalId, + userId: 'user_actor_a' as UserId, + type: 'user', + }, + ] - expect(mockMerge).not.toHaveBeenCalled() + await expect(commit(null)).resolves.toEqual({ status: 'conflict' }) + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() }) - it('does nothing when principal is not found for the session user', async () => { - mockSessionFindFirst.mockResolvedValue({ - userId: 'user_anon', - user: { id: 'user_anon', name: 'Anon' }, - }) - mockPrincipalFindFirst.mockResolvedValue(null) + it('treats an active identified source as not applicable so ordinary A→B switches can continue', async () => { + mocks.provenanceRows = [{ sessionId: targetSession.id }, { sessionId: sourceSession.id }] + mocks.principalRows = [ + targetPrincipal, + { ...sourcePrincipal, type: 'user', id: 'principal_actor_a' as PrincipalId }, + ] - await resolveAndMergeAnonymousToken({ - previousToken: 'valid-token', - targetPrincipalId: TARGET_PRINCIPAL_ID, - targetDisplayName: 'Jane', - }) + await expect(commit()).resolves.toEqual({ status: 'not_applicable' }) + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() + }) + + it('locks and revalidates a source principal upgraded while the session lock was waiting', async () => { + mocks.principalRows = [targetPrincipal, { ...sourcePrincipal, type: 'user' }] - expect(mockMerge).not.toHaveBeenCalled() + await expect(commit()).resolves.toEqual({ status: 'not_applicable' }) + expect(mocks.principalLockForUpdate).toHaveBeenCalledWith('update') + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() }) - it('does nothing when previous session belongs to a non-anonymous user', async () => { - mockSessionFindFirst.mockResolvedValue({ - userId: 'user_real', - user: { id: 'user_real', name: 'Real User' }, - }) - mockPrincipalFindFirst.mockResolvedValue({ - id: 'principal_real', - type: 'user', // NOT anonymous - displayName: 'Real User', - }) + it.each([ + ['missing target session', [sourceSession], [], [sourcePrincipal]], + [ + 'expired target session', + [{ ...targetSession, expiresAt: new Date('1970-01-01T00:00:00.000Z') }, sourceSession], + [{ sessionId: targetSession.id }], + [targetPrincipal, sourcePrincipal], + ], + [ + 'target without widget provenance', + [targetSession, sourceSession], + [], + [targetPrincipal, sourcePrincipal], + ], + [ + 'anonymous target principal', + [targetSession, sourceSession], + [{ sessionId: targetSession.id }], + [{ ...targetPrincipal, type: 'anonymous' }, sourcePrincipal], + ], + [ + 'team target principal', + [targetSession, sourceSession], + [{ sessionId: targetSession.id }], + [{ ...targetPrincipal, role: 'member' }, sourcePrincipal], + ], + ] as const)( + 'rejects a forged/invalid target: %s', + async (_label, sessions, provenance, principals) => { + arrangeSessions([...sessions]) + mocks.provenanceRows = [...provenance] + const target = principals.find((candidate) => candidate.userId === targetSession.userId) + mocks.targetPrincipalHints = target + ? [ + { + targetPrincipalId: target.id, + type: target.type, + role: target.role, + }, + ] + : [] + mocks.principalRows = [...principals] - await resolveAndMergeAnonymousToken({ - previousToken: 'real-user-token', - targetPrincipalId: TARGET_PRINCIPAL_ID, - targetDisplayName: 'Jane', - }) + await expect(commit()).resolves.toEqual({ status: 'target_invalid' }) + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() + } + ) - expect(mockMerge).not.toHaveBeenCalled() - }) + it.each([ + ['unknown source', [targetSession], [targetPrincipal]], + [ + 'expired anonymous source', + [targetSession, { ...sourceSession, expiresAt: new Date('1970-01-01T00:00:00.000Z') }], + [targetPrincipal, sourcePrincipal], + ], + ['source without principal', [targetSession, sourceSession], [targetPrincipal]], + ] as const)( + 'rejects a forged/invalid previous token uniformly: %s', + async (_label, sessions, principals) => { + arrangeSessions([...sessions]) + mocks.principalRows = [...principals] - it('does nothing when previous anonymous principal is the same as target', async () => { - mockSessionFindFirst.mockResolvedValue({ - userId: 'user_same', - user: { id: 'user_same', name: 'Same User' }, - }) - mockPrincipalFindFirst.mockResolvedValue({ - id: TARGET_PRINCIPAL_ID, // same as target - type: 'anonymous', - displayName: 'Curious Penguin', - }) + await expect(commit()).resolves.toEqual({ status: 'conflict' }) + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() + } + ) - await resolveAndMergeAnonymousToken({ - previousToken: 'same-user-token', - targetPrincipalId: TARGET_PRINCIPAL_ID, - targetDisplayName: 'Jane', - }) + it('rejects an invalid target before probing whether a previous token exists', async () => { + arrangeSessions([sourceSession]) - expect(mockMerge).not.toHaveBeenCalled() + await expect(commit()).resolves.toEqual({ status: 'target_invalid' }) + expect(mocks.sourceHintRead).not.toHaveBeenCalled() + expect(mocks.userLockForUpdate).not.toHaveBeenCalled() }) - it('calls merge when previous session is a different anonymous user', async () => { - const anonPrincipalId = 'principal_anon' as PrincipalId - const anonUserId = 'user_anon' as UserId + it('rejects a provenance-bearing team target before probing the previous token', async () => { + mocks.targetPrincipalHints = [ + { + targetPrincipalId: targetPrincipal.id, + type: 'user', + role: 'member', + }, + ] - mockSessionFindFirst.mockResolvedValue({ - userId: anonUserId, - user: { id: anonUserId, name: 'Anon User' }, - }) - mockPrincipalFindFirst.mockResolvedValue({ - id: anonPrincipalId, - type: 'anonymous', - displayName: 'Curious Penguin', - }) + await expect(commit()).resolves.toEqual({ status: 'target_invalid' }) + expect(mocks.sourceHintRead).not.toHaveBeenCalled() + expect(mocks.userLockForUpdate).not.toHaveBeenCalled() + }) - await resolveAndMergeAnonymousToken({ - previousToken: 'anon-token-123', - targetPrincipalId: TARGET_PRINCIPAL_ID, - targetDisplayName: 'Jane Doe', - }) + 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(mockMerge).toHaveBeenCalledWith({ - anonPrincipalId, - targetPrincipalId: TARGET_PRINCIPAL_ID, - anonUserId, - anonDisplayName: 'Curious Penguin', - targetDisplayName: 'Jane Doe', - }) - }) + expect(mocks.sourceHintRead).not.toHaveBeenCalled() + expect(mocks.mergeInTransaction).not.toHaveBeenCalled() + } + ) - it('does not throw when merge fails (graceful degradation)', async () => { - mockSessionFindFirst.mockResolvedValue({ - userId: 'user_anon', - user: { id: 'user_anon', name: 'Anon' }, - }) - mockPrincipalFindFirst.mockResolvedValue({ - id: 'principal_anon', - type: 'anonymous', - displayName: 'Anon', - }) - mockMerge.mockRejectedValue(new Error('DB constraint violation')) - - // Should not throw — merge failures are non-fatal - await expect( - resolveAndMergeAnonymousToken({ - previousToken: 'anon-token', - targetPrincipalId: TARGET_PRINCIPAL_ID, - targetDisplayName: 'Jane', - }) - ).resolves.toBeUndefined() + 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) + + await expect(commit()).rejects.toBe(failure) + expect(mocks.mergeInTransaction).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/web/src/lib/server/auth/__tests__/identity-link-invariants.test.ts b/apps/web/src/lib/server/auth/__tests__/identity-link-invariants.test.ts new file mode 100644 index 000000000..70e8809d1 --- /dev/null +++ b/apps/web/src/lib/server/auth/__tests__/identity-link-invariants.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { assertSignupIdentityLinkPrincipals } from '../identity-link-invariants' + +const expectation = { + anonPrincipalId: 'principal_anon', + anonUserId: 'user_anon', + newPrincipalId: 'principal_new', + newUserId: 'user_new', +} + +describe('assertSignupIdentityLinkPrincipals', () => { + it('accepts the exact locked anonymous snapshots used to choose the signup branch', () => { + expect(() => + assertSignupIdentityLinkPrincipals(expectation, [ + { id: 'principal_anon', userId: 'user_anon', type: 'anonymous' }, + { id: 'principal_new', userId: 'user_new', type: 'anonymous' }, + ]) + ).not.toThrow() + }) + + it.each([ + [ + 'source upgraded', + [ + { id: 'principal_anon', userId: 'user_anon', type: 'user' }, + { id: 'principal_new', userId: 'user_new', type: 'anonymous' }, + ], + ], + [ + 'source replaced', + [ + { id: 'principal_other', userId: 'user_anon', type: 'anonymous' }, + { id: 'principal_new', userId: 'user_new', type: 'anonymous' }, + ], + ], + [ + 'new principal upgraded', + [ + { id: 'principal_anon', userId: 'user_anon', type: 'anonymous' }, + { id: 'principal_new', userId: 'user_new', type: 'user' }, + ], + ], + ])('rejects a stale branch after lock: %s', (_label, locked) => { + expect(() => assertSignupIdentityLinkPrincipals(expectation, locked)).toThrow( + 'Identity link principal changed' + ) + }) + + it('rejects a principal created after a no-principal pre-read', () => { + expect(() => + assertSignupIdentityLinkPrincipals({ ...expectation, newPrincipalId: null }, [ + { id: 'principal_anon', userId: 'user_anon', type: 'anonymous' }, + { id: 'principal_late', userId: 'user_new', type: 'user' }, + ]) + ).toThrow('Identity link principal changed') + }) +}) 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 new file mode 100644 index 000000000..0b44711da --- /dev/null +++ b/apps/web/src/lib/server/auth/__tests__/identity-merge-rate-limit.test.ts @@ -0,0 +1,139 @@ +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' + +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 () => { + mocks.incrementBuckets.mockResolvedValue([1, 1]) + + await expect( + checkIdentityMergeRateLimit('203.0.113.8', 'private-target-token') + ).resolves.toEqual({ allowed: true }) + + 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).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 () => { + mocks.incrementBuckets.mockResolvedValue([null, null]) + + await expect(checkIdentityMergeRateLimit('unknown', 'target-token')).resolves.toEqual({ + allowed: false, + reason: 'unavailable', + retryAfter: 30, + }) + }) + + it('limits target and IP buckets with bounded retry-after', async () => { + mocks.incrementBuckets.mockResolvedValueOnce([21, 1]).mockResolvedValueOnce([1, 41]) + + await expect(checkIdentityMergeRateLimit('ip-a', 'target-a')).resolves.toEqual({ + allowed: false, + reason: 'limited', + retryAfter: 123, + }) + await expect(checkIdentityMergeRateLimit('ip-b', 'target-b')).resolves.toEqual({ + allowed: false, + reason: 'limited', + 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 new file mode 100644 index 000000000..85519bea0 --- /dev/null +++ b/apps/web/src/lib/server/auth/__tests__/identity-merge.postgres.test.ts @@ -0,0 +1,528 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { generateId, type PrincipalId, type UserId } from '@quackback/ids' +import { + boards, + createDb, + eq, + inArray, + notificationPreferences, + postSubscriptions, + posts, + principal, + segments, + session, + unsubscribeTokens, + user, + userSegments, + votes, + widgetIdentifiedSession, + sql, + type Database, +} from '@/lib/server/db' +import { resolveAndMergeAnonymousTokenWithDatabase } from '../identify-merge' +import { mergeAnonymousToIdentifiedWithDatabase } from '../merge-anonymous' +import { IDENTITY_MERGE_TOMBSTONE_USER_AGENT } from '../identity-merge-tombstone' +import { processUnsubscribeTokenWithDatabase } from '../../domains/subscriptions/subscription.service' + +const DATABASE_URL = process.env.PG18_IDENTITY_MERGE_DATABASE_URL +const database = DATABASE_URL ? createDb(DATABASE_URL, { max: 8, prepare: false }) : null + +async function closeDatabase(db: Database | null): Promise { + const client = (db as unknown as { $client?: { end?: () => Promise } } | null)?.$client + await client?.end?.() +} + +function delay(ms: number): Promise<'blocked'> { + return new Promise((resolve) => { + setTimeout(() => resolve('blocked'), ms) + }) +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Identity merge concurrency timed out')), ms) + promise.then( + (value) => { + clearTimeout(timeout) + resolve(value) + }, + (error) => { + clearTimeout(timeout) + reject(error) + } + ) + }) +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolvePromise!: () => void + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} + +// Better Auth owns the outer signup-link callback transaction, so invoking +// that callback here would require a full provider handshake. Its production +// path calls the same lockIdentityActorUsers helper exercised by both merges +// below; identity-link-invariants.test.ts separately pins its post-lock branch +// revalidation. +describe.skipIf(!database)('identity merge canonical locks (PostgreSQL 18)', () => { + beforeAll(async () => { + if (!database) return + const versionRows = await database.execute<{ version: string }>( + sql`select current_setting('server_version_num') as version` + ) + const version = Number((versionRows as unknown as Array<{ version: string }>)[0]?.version) + expect(version).toBeGreaterThanOrEqual(180_000) + await database.execute(sql`select session_id from "widget_identified_session" limit 0`) + }) + + afterAll(async () => { + await closeDatabase(database) + }) + + it('serializes generic + dedicated merges into one target without deadlock or unique conflicts', async () => { + if (!database) return + + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + const targetUserId = generateId('user') as UserId + const genericSourceUserId = generateId('user') as UserId + const dedicatedSourceUserId = generateId('user') as UserId + const targetPrincipalId = generateId('principal') as PrincipalId + const genericSourcePrincipalId = generateId('principal') as PrincipalId + const dedicatedSourcePrincipalId = generateId('principal') as PrincipalId + const boardId = generateId('board') + const postId = generateId('post') + const guardedPostId = generateId('post') + const sharedSegmentId = generateId('segment') + const manualOnlySegmentId = generateId('segment') + const staleDynamicSegmentId = generateId('segment') + const staleWidgetSegmentId = generateId('segment') + const targetSessionId = `identity-merge-target-${suffix}` + const genericSourceSessionId = `identity-merge-generic-${suffix}` + const dedicatedSourceSessionId = `identity-merge-dedicated-${suffix}` + const targetToken = `identity-merge-target-token-${suffix}` + const genericSourceToken = `identity-merge-generic-token-${suffix}` + const dedicatedSourceToken = `identity-merge-dedicated-token-${suffix}` + const unsubscribeToken = `identity-merge-unsubscribe-${suffix}` + const future = new Date(Date.now() + 60_000) + const now = new Date() + const genericMergeParams = { + anonPrincipalId: genericSourcePrincipalId, + targetPrincipalId, + anonUserId: genericSourceUserId, + anonDisplayName: 'Generic anonymous source', + targetDisplayName: 'Identity merge target', + } + + const targetLockAcquired = deferred() + const targetLockRelease = deferred() + let blocker: Promise | null = null + + try { + await database.insert(user).values([ + { + id: targetUserId, + name: 'Identity merge target', + email: `identity-merge-target-${suffix}@example.test`, + isAnonymous: false, + }, + { + id: genericSourceUserId, + name: 'Generic anonymous source', + email: `identity-merge-generic-${suffix}@example.test`, + isAnonymous: true, + }, + { + id: dedicatedSourceUserId, + name: 'Dedicated anonymous source', + email: `identity-merge-dedicated-${suffix}@example.test`, + isAnonymous: true, + }, + ]) + await database.insert(principal).values([ + { + id: targetPrincipalId, + userId: targetUserId, + role: 'user', + type: 'user', + displayName: 'Identity merge target', + createdAt: now, + }, + { + id: genericSourcePrincipalId, + userId: genericSourceUserId, + role: 'user', + type: 'anonymous', + displayName: 'Generic anonymous source', + createdAt: now, + }, + { + id: dedicatedSourcePrincipalId, + userId: dedicatedSourceUserId, + role: 'user', + type: 'anonymous', + displayName: 'Dedicated anonymous source', + createdAt: now, + }, + ]) + await database.insert(session).values([ + { + id: targetSessionId, + token: targetToken, + userId: targetUserId, + expiresAt: future, + updatedAt: now, + }, + { + id: genericSourceSessionId, + token: genericSourceToken, + userId: genericSourceUserId, + expiresAt: future, + updatedAt: now, + }, + { + id: dedicatedSourceSessionId, + token: dedicatedSourceToken, + userId: dedicatedSourceUserId, + expiresAt: future, + updatedAt: now, + }, + ]) + await database.insert(widgetIdentifiedSession).values({ + sessionId: targetSessionId, + hmacVerified: true, + }) + await database.insert(boards).values({ + id: boardId, + slug: `identity-merge-${suffix}`, + name: 'Identity merge concurrency', + }) + await database.insert(posts).values({ + id: postId, + boardId, + title: 'Identity merge concurrency', + content: 'Synthetic PostgreSQL concurrency fixture', + principalId: targetPrincipalId, + voteCount: 3, + }) + await database.insert(posts).values({ + id: guardedPostId, + boardId, + title: 'Identity merge target-only guard', + content: 'Synthetic target-only principal reference', + principalId: targetPrincipalId, + ownerPrincipalId: genericSourcePrincipalId, + }) + await database.insert(votes).values([ + { id: generateId('vote'), postId, principalId: targetPrincipalId }, + { id: generateId('vote'), postId, principalId: genericSourcePrincipalId }, + { id: generateId('vote'), postId, principalId: dedicatedSourcePrincipalId }, + ]) + await database.insert(postSubscriptions).values([ + { + id: generateId('post_sub'), + postId, + principalId: targetPrincipalId, + reason: 'manual', + notifyComments: true, + notifyStatusChanges: true, + }, + { + id: generateId('post_sub'), + postId, + principalId: genericSourcePrincipalId, + reason: 'vote', + notifyComments: false, + notifyStatusChanges: true, + }, + { + id: generateId('post_sub'), + postId, + principalId: dedicatedSourcePrincipalId, + reason: 'vote', + notifyComments: true, + notifyStatusChanges: false, + }, + ]) + await database.insert(notificationPreferences).values([ + { + id: generateId('notif_pref'), + principalId: targetPrincipalId, + emailStatusChange: true, + emailNewComment: true, + emailMuted: false, + }, + { + id: generateId('notif_pref'), + principalId: genericSourcePrincipalId, + emailStatusChange: false, + emailNewComment: true, + emailMuted: false, + }, + { + id: generateId('notif_pref'), + principalId: dedicatedSourcePrincipalId, + emailStatusChange: true, + emailNewComment: false, + emailMuted: false, + }, + ]) + await database.insert(unsubscribeTokens).values({ + id: generateId('unsub_token'), + token: unsubscribeToken, + principalId: genericSourcePrincipalId, + postId: null, + action: 'unsubscribe_all', + expiresAt: future, + }) + await database.insert(segments).values([ + { + id: sharedSegmentId, + slug: `identity-merge-shared-${suffix}`, + name: 'Identity merge shared segment', + type: 'manual', + }, + { + id: manualOnlySegmentId, + slug: `identity-merge-manual-${suffix}`, + name: 'Identity merge manual-only segment', + type: 'manual', + }, + { + id: staleDynamicSegmentId, + slug: `identity-merge-dynamic-${suffix}`, + name: 'Identity merge stale dynamic segment', + type: 'dynamic', + }, + { + id: staleWidgetSegmentId, + slug: `identity-merge-widget-${suffix}`, + name: 'Identity merge stale widget segment', + type: 'manual', + }, + ]) + await database.insert(userSegments).values([ + { + principalId: targetPrincipalId, + segmentId: sharedSegmentId, + addedBy: 'api', + }, + { + principalId: genericSourcePrincipalId, + segmentId: sharedSegmentId, + addedBy: 'manual', + }, + { + principalId: genericSourcePrincipalId, + segmentId: manualOnlySegmentId, + addedBy: 'manual', + }, + { + principalId: genericSourcePrincipalId, + segmentId: staleDynamicSegmentId, + addedBy: 'dynamic', + }, + { + principalId: dedicatedSourcePrincipalId, + segmentId: staleWidgetSegmentId, + addedBy: 'widget', + }, + ]) + + await expect( + mergeAnonymousToIdentifiedWithDatabase(database, genericMergeParams) + ).rejects.toThrow('Anonymous principal has target-only references') + expect( + await database + .select({ id: principal.id }) + .from(principal) + .where(eq(principal.id, genericSourcePrincipalId)) + ).toEqual([{ id: genericSourcePrincipalId }]) + await database + .update(posts) + .set({ ownerPrincipalId: null }) + .where(eq(posts.id, guardedPostId)) + + blocker = database.transaction(async (tx) => { + await tx.select({ id: user.id }).from(user).where(eq(user.id, targetUserId)).for('update') + targetLockAcquired.resolve() + await targetLockRelease.promise + }) + await targetLockAcquired.promise + + const genericMerge = mergeAnonymousToIdentifiedWithDatabase(database, genericMergeParams) + const dedicatedMerge = resolveAndMergeAnonymousTokenWithDatabase(database, { + previousToken: dedicatedSourceToken, + targetToken, + targetActor: { + userId: targetUserId, + principalId: targetPrincipalId, + }, + }) + const unsubscribe = processUnsubscribeTokenWithDatabase(database, unsubscribeToken) + const allMutations = Promise.all([genericMerge, dedicatedMerge, unsubscribe]) + + const [genericWhileBlocked, dedicatedWhileBlocked] = await Promise.all([ + Promise.race([genericMerge.then(() => 'settled' as const), delay(150)]), + Promise.race([dedicatedMerge.then(() => 'settled' as const), delay(150)]), + ]) + expect(genericWhileBlocked).toBe('blocked') + expect(dedicatedWhileBlocked).toBe('blocked') + + targetLockRelease.resolve() + const [, dedicatedResult, unsubscribeResult] = await withTimeout(allMutations, 10_000) + await withTimeout(blocker, 10_000) + blocker = null + + expect(dedicatedResult).toEqual({ status: 'merged' }) + expect(unsubscribeResult).toMatchObject({ + action: 'unsubscribe_all', + postId: null, + }) + + const targetVotes = await database + .select({ principalId: votes.principalId }) + .from(votes) + .where(eq(votes.postId, postId)) + expect(targetVotes).toEqual([{ principalId: targetPrincipalId }]) + + const targetSubscriptions = await database + .select({ + principalId: postSubscriptions.principalId, + notifyComments: postSubscriptions.notifyComments, + notifyStatusChanges: postSubscriptions.notifyStatusChanges, + }) + .from(postSubscriptions) + .where(eq(postSubscriptions.postId, postId)) + expect(targetSubscriptions).toEqual([ + { + principalId: targetPrincipalId, + notifyComments: false, + notifyStatusChanges: false, + }, + ]) + + const [postAfterMerge] = await database + .select({ voteCount: posts.voteCount }) + .from(posts) + .where(eq(posts.id, postId)) + expect(postAfterMerge).toEqual({ voteCount: 1 }) + + const targetPreferences = await database + .select({ + principalId: notificationPreferences.principalId, + emailStatusChange: notificationPreferences.emailStatusChange, + emailNewComment: notificationPreferences.emailNewComment, + emailMuted: notificationPreferences.emailMuted, + }) + .from(notificationPreferences) + .where(eq(notificationPreferences.principalId, targetPrincipalId)) + expect(targetPreferences).toEqual([ + { + principalId: targetPrincipalId, + emailStatusChange: false, + emailNewComment: false, + emailMuted: true, + }, + ]) + + const targetSegments = await database + .select({ + segmentId: userSegments.segmentId, + addedBy: userSegments.addedBy, + }) + .from(userSegments) + .where(eq(userSegments.principalId, targetPrincipalId)) + expect(targetSegments).toHaveLength(2) + expect(targetSegments).toEqual( + expect.arrayContaining([ + { segmentId: sharedSegmentId, addedBy: 'manual' }, + { segmentId: manualOnlySegmentId, addedBy: 'manual' }, + ]) + ) + expect(targetSegments).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ segmentId: staleDynamicSegmentId }), + expect.objectContaining({ segmentId: staleWidgetSegmentId }), + ]) + ) + + const consumedUnsubscribe = await database + .select({ + principalId: unsubscribeTokens.principalId, + usedAt: unsubscribeTokens.usedAt, + }) + .from(unsubscribeTokens) + .where(eq(unsubscribeTokens.token, unsubscribeToken)) + expect(consumedUnsubscribe).toEqual([ + { + principalId: targetPrincipalId, + usedAt: expect.any(Date), + }, + ]) + + const sourceUsers = await database + .select({ id: user.id }) + .from(user) + .where(inArray(user.id, [genericSourceUserId, dedicatedSourceUserId])) + expect(sourceUsers).toEqual([]) + + const tombstones = await database + .select({ + id: session.id, + userId: session.userId, + userAgent: session.userAgent, + }) + .from(session) + .where(eq(session.token, dedicatedSourceToken)) + expect(tombstones).toEqual([ + { + id: dedicatedSourceSessionId, + userId: targetUserId, + userAgent: IDENTITY_MERGE_TOMBSTONE_USER_AGENT, + }, + ]) + } finally { + targetLockRelease.resolve() + await blocker?.catch(() => undefined) + + await database.delete(boards).where(eq(boards.id, boardId)) + await database + .delete(segments) + .where( + inArray(segments.id, [ + sharedSegmentId, + manualOnlySegmentId, + staleDynamicSegmentId, + staleWidgetSegmentId, + ]) + ) + await database + .delete(widgetIdentifiedSession) + .where( + inArray(widgetIdentifiedSession.sessionId, [ + targetSessionId, + genericSourceSessionId, + dedicatedSourceSessionId, + ]) + ) + await database + .delete(session) + .where(inArray(session.token, [targetToken, genericSourceToken, dedicatedSourceToken])) + await database + .delete(principal) + .where( + inArray(principal.id, [ + targetPrincipalId, + genericSourcePrincipalId, + dedicatedSourcePrincipalId, + ]) + ) + await database + .delete(user) + .where(inArray(user.id, [targetUserId, genericSourceUserId, dedicatedSourceUserId])) + } + }, 30_000) +}) diff --git a/apps/web/src/lib/server/auth/__tests__/merge-anonymous.test.ts b/apps/web/src/lib/server/auth/__tests__/merge-anonymous.test.ts index 6b8c8d366..554f73925 100644 --- a/apps/web/src/lib/server/auth/__tests__/merge-anonymous.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/merge-anonymous.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { PrincipalId, UserId } from '@quackback/ids' +import { createId, type PrincipalId, type UserId } from '@quackback/ids' // ── Mock DB ──────────────────────────────────────────────────────────── // Track all operations in order so we can verify the merge sequence @@ -8,18 +8,87 @@ const operations: string[] = [] const mockSelectWhere = vi.fn() const mockSelectFrom = vi.fn(() => ({ where: mockSelectWhere })) const mockSelect = vi.fn(() => ({ from: mockSelectFrom })) +const mockUserLockForUpdate = vi.fn() +const mockSessionLockForUpdate = vi.fn() +const mockPrincipalLockForUpdate = vi.fn() +const mockTargetPrincipalHint = vi.fn() +const mockUserLockWhere = vi.fn() +const mockSessionLockWhere = vi.fn() +const mockPrincipalLockWhere = vi.fn() +const mockUserLockOrderBy = vi.fn() +const mockSessionLockOrderBy = vi.fn() +const mockPrincipalLockOrderBy = vi.fn() const mockDeleteWhere = vi.fn() const mockDelete = vi.fn((_table?: unknown) => ({ where: mockDeleteWhere })) -const mockUpdateWhere = vi.fn() +const mockPreservedSessionReturning = vi.fn() +const mockUpdateWhere = vi.fn(() => ({ returning: mockPreservedSessionReturning })) const mockUpdateSet = vi.fn((_values?: unknown) => ({ where: mockUpdateWhere })) const mockUpdate = vi.fn((_table?: unknown) => ({ set: mockUpdateSet })) +const mockExecute = vi.fn(async (_query: unknown) => { + operations.push('execute:merge-policy') + return [{ target_only_reference_present: false }] +}) + +function executedPolicySql(): string { + return mockExecute.mock.calls + .flatMap(([query]) => (query as { strings?: readonly string[] }).strings ?? []) + .join('\n') +} // The transaction function just calls the callback with itself (same API) const mockTx = { - select: (..._args: unknown[]) => { + select: (fields?: Record) => { mockSelect() + if (fields && 'targetUserId' in fields) { + return { + from: () => ({ + where: () => ({ + limit: mockTargetPrincipalHint, + }), + }), + } + } + if (fields && 'lockedUserId' in fields) { + return { + from: () => ({ + where: (condition: unknown) => ({ + orderBy: (column: unknown) => { + mockUserLockWhere(condition) + mockUserLockOrderBy(column) + return { for: mockUserLockForUpdate } + }, + }), + }), + } + } + if (fields && 'lockedSessionId' in fields) { + return { + from: () => ({ + where: (condition: unknown) => ({ + orderBy: (column: unknown) => { + mockSessionLockWhere(condition) + mockSessionLockOrderBy(column) + return { for: mockSessionLockForUpdate } + }, + }), + }), + } + } + if (fields && 'lockedPrincipalId' in fields) { + return { + from: () => ({ + where: (condition: unknown) => ({ + orderBy: (column: unknown) => { + mockPrincipalLockWhere(condition) + mockPrincipalLockOrderBy(column) + return { for: mockPrincipalLockForUpdate } + }, + }), + }), + } + } return { from: mockSelectFrom } }, delete: (table: { __name?: string }) => { @@ -32,6 +101,7 @@ const mockTx = { mockUpdate(table) return { set: mockUpdateSet } }, + execute: mockExecute, } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -42,24 +112,139 @@ vi.mock('@/lib/server/db', () => ({ transaction: (fn: unknown) => mockTransaction(fn), }, votes: { principalId: 'principalId', postId: 'postId', __name: 'votes' }, - comments: { principalId: 'principalId', id: 'id', __name: 'comments' }, - posts: { principalId: 'principalId', __name: 'posts' }, + comments: { + principalId: 'principalId', + deletedByPrincipalId: 'deletedByPrincipalId', + id: 'id', + __name: 'comments', + }, + commentReactions: { + principalId: 'principalId', + commentId: 'commentId', + emoji: 'emoji', + __name: 'commentReactions', + }, + postEditHistory: { + editorPrincipalId: 'editorPrincipalId', + __name: 'postEditHistory', + }, + commentEditHistory: { + editorPrincipalId: 'editorPrincipalId', + __name: 'commentEditHistory', + }, + posts: { + id: 'id', + principalId: 'principalId', + deletedByPrincipalId: 'deletedByPrincipalId', + mergedByPrincipalId: 'mergedByPrincipalId', + ownerPrincipalId: 'ownerPrincipalId', + trackedByPrincipalId: 'trackedByPrincipalId', + voteCount: 'voteCount', + __name: 'posts', + }, + postActivity: { principalId: 'principalId', __name: 'postActivity' }, conversations: { visitorPrincipalId: 'visitorPrincipalId', __name: 'conversations', }, - chatMessages: { principalId: 'principalId', __name: 'chatMessages' }, - postSubscriptions: { principalId: 'principalId', postId: 'postId', __name: 'postSubscriptions' }, + chatMessages: { + principalId: 'principalId', + deletedByPrincipalId: 'deletedByPrincipalId', + __name: 'chatMessages', + }, + postSubscriptions: { + principalId: 'principalId', + postId: 'postId', + notifyComments: 'notifyComments', + notifyStatusChanges: 'notifyStatusChanges', + __name: 'postSubscriptions', + }, + notificationPreferences: { + principalId: 'principalId', + emailStatusChange: 'emailStatusChange', + emailNewComment: 'emailNewComment', + emailMuted: 'emailMuted', + __name: 'notificationPreferences', + }, + unsubscribeTokens: { principalId: 'principalId', __name: 'unsubscribeTokens' }, + bugReportSubmissions: { + principalId: 'principalId', + clientSubmissionId: 'clientSubmissionId', + __name: 'bugReportSubmissions', + }, + rawFeedbackItems: { + principalId: 'principalId', + author: 'author', + __name: 'rawFeedbackItems', + }, + externalUserMappings: { + principalId: 'principalId', + __name: 'externalUserMappings', + }, + helpCenterArticles: { + id: 'id', + helpfulCount: 'helpfulCount', + notHelpfulCount: 'notHelpfulCount', + __name: 'helpCenterArticles', + }, + helpCenterArticleFeedback: { + principalId: 'principalId', + __name: 'helpCenterArticleFeedback', + }, + userSegments: { + principalId: 'principalId', + segmentId: 'segmentId', + addedBy: 'addedBy', + addedAt: 'addedAt', + __name: 'userSegments', + }, + apiKeys: { + createdById: 'createdById', + principalId: 'principalId', + __name: 'apiKeys', + }, + changelogEntries: { principalId: 'principalId', __name: 'changelogEntries' }, + chatMessageFlags: { principalId: 'principalId', __name: 'chatMessageFlags' }, + chatMessageMentions: { principalId: 'principalId', __name: 'chatMessageMentions' }, + chatMessageReactions: { principalId: 'principalId', __name: 'chatMessageReactions' }, + feedbackSuggestions: { + resolvedByPrincipalId: 'resolvedByPrincipalId', + __name: 'feedbackSuggestions', + }, + integrationPlatformCredentials: { + configuredByPrincipalId: 'configuredByPrincipalId', + __name: 'integrationPlatformCredentials', + }, + integrations: { + connectedByPrincipalId: 'connectedByPrincipalId', + principalId: 'principalId', + __name: 'integrations', + }, + mergeSuggestions: { + resolvedByPrincipalId: 'resolvedByPrincipalId', + __name: 'mergeSuggestions', + }, + postMentions: { principalId: 'principalId', __name: 'postMentions' }, + postNotes: { principalId: 'principalId', __name: 'postNotes' }, + pushDevices: { principalId: 'principalId', __name: 'pushDevices' }, + webhooks: { createdById: 'createdById', __name: 'webhooks' }, inAppNotifications: { principalId: 'principalId', commentId: 'commentId', title: 'title', __name: 'inAppNotifications', }, - principal: { id: 'id', userId: 'userId', __name: 'principal' }, - session: { userId: 'userId', __name: 'session' }, - user: { id: 'id', __name: 'user' }, + principal: { id: 'principal.id', userId: 'principal.userId', __name: 'principal' }, + session: { + id: 'session.id', + userId: 'session.userId', + expiresAt: 'expiresAt', + updatedAt: 'updatedAt', + __name: 'session', + }, + user: { id: 'user.id', __name: 'user' }, eq: vi.fn((...args: unknown[]) => ({ _type: 'eq', args })), + gt: vi.fn((...args: unknown[]) => ({ _type: 'gt', args })), and: vi.fn((...args: unknown[]) => ({ _type: 'and', args })), inArray: vi.fn((...args: unknown[]) => ({ _type: 'inArray', args })), sql: Object.assign( @@ -68,12 +253,17 @@ vi.mock('@/lib/server/db', () => ({ ), })) -import { mergeAnonymousToIdentified } from '../merge-anonymous' +import { + mergeAnonymousToIdentified, + mergeAnonymousToIdentifiedInTransaction, +} from '../merge-anonymous' +import { IDENTITY_MERGE_TOMBSTONE_USER_AGENT } from '../identity-merge-tombstone' describe('mergeAnonymousToIdentified', () => { - const ANON_PRINCIPAL_ID = 'principal_anon' as PrincipalId - const TARGET_PRINCIPAL_ID = 'principal_target' as PrincipalId - const ANON_USER_ID = 'user_anon' as UserId + const ANON_PRINCIPAL_ID = createId('principal') as PrincipalId + const TARGET_PRINCIPAL_ID = createId('principal') as PrincipalId + const ANON_USER_ID = createId('user') as UserId + const TARGET_USER_ID = createId('user') as UserId const defaultParams = { anonPrincipalId: ANON_PRINCIPAL_ID, @@ -88,8 +278,39 @@ describe('mergeAnonymousToIdentified', () => { operations.length = 0 // Default: no existing votes, no comments, no subscriptions mockSelectWhere.mockResolvedValue([]) + mockTargetPrincipalHint.mockResolvedValue([ + { + targetPrincipalId: TARGET_PRINCIPAL_ID, + targetUserId: TARGET_USER_ID, + targetPrincipalType: 'user', + }, + ]) + mockUserLockForUpdate.mockImplementation(async () => { + operations.push('lock:user') + return [{ lockedUserId: ANON_USER_ID }, { lockedUserId: TARGET_USER_ID }] + }) + mockSessionLockForUpdate.mockImplementation(async () => { + operations.push('lock:session') + return [{ lockedSessionId: 'anonymous-session-id' }] + }) + mockPrincipalLockForUpdate.mockImplementation(async () => { + operations.push('lock:principal') + return [ + { + lockedPrincipalId: ANON_PRINCIPAL_ID, + lockedPrincipalUserId: ANON_USER_ID, + lockedPrincipalType: 'anonymous', + }, + { + lockedPrincipalId: TARGET_PRINCIPAL_ID, + lockedPrincipalUserId: TARGET_USER_ID, + lockedPrincipalType: 'user', + }, + ] + }) mockDeleteWhere.mockResolvedValue(undefined) - mockUpdateWhere.mockResolvedValue(undefined) + mockUpdateWhere.mockImplementation(() => ({ returning: mockPreservedSessionReturning })) + mockPreservedSessionReturning.mockResolvedValue([{ preservedSessionId: 'source-session-id' }]) }) it('runs the merge inside a database transaction', async () => { @@ -97,6 +318,109 @@ describe('mergeAnonymousToIdentified', () => { expect(mockTransaction).toHaveBeenCalledTimes(1) }) + it('locks both actors and source sessions in canonical order before transferring activity', async () => { + await mergeAnonymousToIdentified(defaultParams) + + expect(mockUserLockForUpdate).toHaveBeenCalledWith('update') + expect(mockUserLockWhere).toHaveBeenCalledWith({ + _type: 'inArray', + args: ['user.id', [ANON_USER_ID, TARGET_USER_ID]], + }) + expect(mockUserLockOrderBy).toHaveBeenCalledWith('user.id') + expect(mockSessionLockForUpdate).toHaveBeenCalledWith('update') + expect(mockSessionLockWhere).toHaveBeenCalledWith({ + _type: 'eq', + args: ['session.userId', ANON_USER_ID], + }) + expect(mockSessionLockOrderBy).toHaveBeenCalledWith('session.id') + expect(mockPrincipalLockForUpdate).toHaveBeenCalledWith('update') + expect(mockPrincipalLockWhere).toHaveBeenCalledWith({ + _type: 'inArray', + args: ['principal.id', [ANON_PRINCIPAL_ID, TARGET_PRINCIPAL_ID]], + }) + expect(mockPrincipalLockOrderBy).toHaveBeenCalledWith('principal.id') + expect(operations.slice(0, 3)).toEqual(['lock:user', 'lock:session', 'lock:principal']) + }) + + it('does nothing when a competing merge already removed the source user sentinel', async () => { + mockUserLockForUpdate.mockResolvedValueOnce([{ lockedUserId: TARGET_USER_ID }]) + + await mergeAnonymousToIdentified(defaultParams) + + expect(operations).toEqual([]) + expect(mockSessionLockForUpdate).not.toHaveBeenCalled() + }) + + it('rejects a non-user target before entering the lock hierarchy', async () => { + mockTargetPrincipalHint.mockResolvedValueOnce([ + { + targetPrincipalId: TARGET_PRINCIPAL_ID, + targetUserId: null, + targetPrincipalType: 'service', + }, + ]) + + await mergeAnonymousToIdentified(defaultParams) + + expect(mockUserLockForUpdate).not.toHaveBeenCalled() + expect(operations).toEqual([]) + }) + + it('does nothing when a competing merge removed or reparented every source session', async () => { + mockSessionLockForUpdate.mockResolvedValueOnce([]) + + await mergeAnonymousToIdentified(defaultParams) + + expect(operations).toEqual(['lock:user']) + expect(mockSelectWhere).not.toHaveBeenCalled() + }) + + it('does nothing when the locked source principal was concurrently upgraded from anonymous', async () => { + mockPrincipalLockForUpdate.mockImplementationOnce(async () => { + operations.push('lock:principal') + return [ + { + lockedPrincipalId: ANON_PRINCIPAL_ID, + lockedPrincipalUserId: ANON_USER_ID, + lockedPrincipalType: 'user', + }, + { + lockedPrincipalId: TARGET_PRINCIPAL_ID, + lockedPrincipalUserId: TARGET_USER_ID, + lockedPrincipalType: 'user', + }, + ] + }) + + await mergeAnonymousToIdentified(defaultParams) + + expect(operations).toEqual(['lock:user', 'lock:session', 'lock:principal']) + expect(mockSelectWhere).not.toHaveBeenCalled() + }) + + it('does nothing when the locked target principal is no longer a user', async () => { + mockPrincipalLockForUpdate.mockImplementationOnce(async () => { + operations.push('lock:principal') + return [ + { + lockedPrincipalId: ANON_PRINCIPAL_ID, + lockedPrincipalUserId: ANON_USER_ID, + lockedPrincipalType: 'anonymous', + }, + { + lockedPrincipalId: TARGET_PRINCIPAL_ID, + lockedPrincipalUserId: TARGET_USER_ID, + lockedPrincipalType: 'service', + }, + ] + }) + + await mergeAnonymousToIdentified(defaultParams) + + expect(operations).toEqual(['lock:user', 'lock:session', 'lock:principal']) + expect(mockSelectWhere).not.toHaveBeenCalled() + }) + it('transfers votes from anonymous to target principal', async () => { await mergeAnonymousToIdentified(defaultParams) @@ -150,18 +474,59 @@ describe('mergeAnonymousToIdentified', () => { expect(operations.indexOf('update:chatMessages')).toBeLessThan(principalIdx) }) - it('transfers post subscriptions with conflict handling', async () => { - // Target already subscribed to post_2 - mockSelectWhere - .mockResolvedValueOnce([]) // votes query - .mockResolvedValueOnce([]) // comments query - .mockResolvedValueOnce([{ postId: 'post_2' }]) // subscriptions query - + it('keeps only the privacy-safe subscription intersection with restrictive flags', async () => { await mergeAnonymousToIdentified(defaultParams) const subOps = operations.filter((op) => op.includes('postSubscriptions')) - // Should delete conflicting subs, then update remaining - expect(subOps).toEqual(['delete:postSubscriptions', 'update:postSubscriptions']) + // Subscription absence is a negative preference. Until the schema has a + // durable negative/tombstone, merge must keep source∩target rather than + // reparenting either actor's one-sided subscription. + expect(subOps).toEqual([]) + expect(executedPolicySql()).toContain('notify_comments') + expect(executedPolicySql()).toContain('AND source.notify_comments') + expect(executedPolicySql()).toContain('notify_status_changes') + expect(executedPolicySql()).toContain('NOT EXISTS') + expect(executedPolicySql()).toContain('DELETE FROM') + }) + + it('preserves restrictive notification consent and unsubscribe tokens', async () => { + await mergeAnonymousToIdentified(defaultParams) + + expect(executedPolicySql()).toContain('email_status_change') + expect(executedPolicySql()).toContain('AND source.email_status_change') + expect(executedPolicySql()).toContain('email_muted') + expect(executedPolicySql()).toContain('OR source.email_muted') + expect(operations).toContain('update:notificationPreferences') + expect(operations).toContain('update:unsubscribeTokens') + }) + + it('deduplicates reactions, KB feedback, and segment memberships before reparenting', async () => { + await mergeAnonymousToIdentified(defaultParams) + + const policySql = executedPolicySql() + expect(policySql).toContain('source.comment_id = target.comment_id') + expect(policySql).toContain('RETURNING source.article_id, source.helpful') + expect(policySql).toContain("WHEN 'manual' THEN 3") + expect(policySql).toContain("WHEN 'api' THEN 2") + expect(policySql).toContain("NOT IN ('manual', 'api')") + expect(operations).toContain('update:commentReactions') + expect(operations).toContain('update:helpCenterArticleFeedback') + expect(operations).toContain('update:userSegments') + }) + + it('preserves report receipts, feedback identity, edit history, and deletion attribution', async () => { + await mergeAnonymousToIdentified(defaultParams) + + expect(mockUpdateSet).toHaveBeenCalledWith({ clientSubmissionId: null }) + expect(operations).toContain('update:bugReportSubmissions') + expect(operations).toContain('update:rawFeedbackItems') + expect(operations).toContain('update:externalUserMappings') + expect(operations).toContain('update:postEditHistory') + expect(operations).toContain('update:commentEditHistory') + expect(operations).toContain('update:postActivity') + expect(operations.filter((op) => op === 'update:posts')).toHaveLength(2) + expect(operations.filter((op) => op === 'update:comments')).toHaveLength(2) + expect(operations.filter((op) => op === 'update:chatMessages')).toHaveLength(2) }) it('transfers in-app notifications', async () => { @@ -216,4 +581,47 @@ describe('mergeAnonymousToIdentified', () => { expect(operations).toContain('delete:session') expect(operations).toContain('delete:user') }) + + it('reparents and expires the consumed source session before anonymous-user cleanup', async () => { + const consumedAt = new Date('2026-07-29T00:00:00.000Z') + + await mergeAnonymousToIdentifiedInTransaction(mockTx as never, defaultParams, { + preservedSessionId: 'source-session-id', + targetUserId: 'user_target' as UserId, + consumedAt, + }) + + const preserveIndex = operations.indexOf('update:session') + const deleteAnonSessionsIndex = operations.indexOf('delete:session') + const deleteAnonUserIndex = operations.indexOf('delete:user') + expect(preserveIndex).toBeGreaterThanOrEqual(0) + expect(preserveIndex).toBeLessThan(deleteAnonSessionsIndex) + expect(preserveIndex).toBeLessThan(deleteAnonUserIndex) + expect(mockUpdateSet).toHaveBeenCalledWith({ + userId: 'user_target', + expiresAt: consumedAt, + updatedAt: consumedAt, + userAgent: IDENTITY_MERGE_TOMBSTONE_USER_AGENT, + }) + expect(mockPreservedSessionReturning).toHaveBeenCalledWith({ + preservedSessionId: 'session.id', + }) + }) + + it('aborts before cleanup when the consumed source-session CAS updates no row', async () => { + const consumedAt = new Date('2026-07-29T00:00:00.000Z') + mockPreservedSessionReturning.mockResolvedValueOnce([]) + + await expect( + mergeAnonymousToIdentifiedInTransaction(mockTx as never, defaultParams, { + preservedSessionId: 'source-session-id', + targetUserId: 'user_target' as UserId, + consumedAt, + }) + ).rejects.toThrow('Identity merge source session changed') + + expect(operations).not.toContain('delete:principal') + expect(operations).not.toContain('delete:session') + expect(operations).not.toContain('delete:user') + }) }) 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 new file mode 100644 index 000000000..c9c0b045a --- /dev/null +++ b/apps/web/src/lib/server/auth/anonymous-principal-fk-policy.ts @@ -0,0 +1,170 @@ +/** + * Exhaustive contract for every database FK that targets principal.id. + * + * The schema-coverage test fails whenever a new FK is added without an + * explicit anonymous-merge decision. `target_only` means the write path + * requires a non-anonymous team/service actor; every other policy is executed + * by mergeAnonymousToIdentifiedInTransaction before source-principal deletion. + */ +export const ANONYMOUS_PRINCIPAL_FK_POLICY = { + 'api_keys.created_by_id': 'target_only', + 'api_keys.principal_id': 'target_only', + 'bug_report_submissions.principal_id': 'reparent', + 'changelog_entries.principal_id': 'target_only', + 'chat_message_flags.principal_id': 'target_only', + 'chat_message_mentions.principal_id': 'target_only', + 'chat_message_reactions.principal_id': 'target_only', + 'chat_messages.deleted_by_principal_id': 'reparent', + 'chat_messages.principal_id': 'reparent', + 'comment_edit_history.editor_principal_id': 'reparent', + 'comment_reactions.principal_id': 'dedupe', + 'comments.deleted_by_principal_id': 'reparent', + 'comments.principal_id': 'reparent', + 'conversations.assigned_agent_principal_id': 'target_only', + 'conversations.visitor_principal_id': 'reparent', + 'external_user_mappings.principal_id': 'reparent', + 'feedback_suggestions.resolved_by_principal_id': 'target_only', + 'in_app_notifications.principal_id': 'reparent', + 'integration_platform_credentials.configured_by_principal_id': 'target_only', + 'integrations.connected_by_principal_id': 'target_only', + 'integrations.principal_id': 'target_only', + 'kb_article_feedback.principal_id': 'dedupe', + 'kb_articles.principal_id': 'target_only', + 'merge_suggestions.resolved_by_principal_id': 'target_only', + 'notification_preferences.principal_id': 'conservative_merge', + 'post_activity.principal_id': 'reparent', + 'post_edit_history.editor_principal_id': 'reparent', + 'post_mentions.principal_id': 'target_only', + 'post_notes.principal_id': 'target_only', + 'post_subscriptions.principal_id': 'conservative_merge', + 'posts.deleted_by_principal_id': 'reparent', + 'posts.merged_by_principal_id': 'target_only', + 'posts.owner_principal_id': 'target_only', + 'posts.principal_id': 'reparent', + 'posts.tracked_by_principal_id': 'target_only', + 'push_devices.principal_id': 'target_only', + 'raw_feedback_items.principal_id': 'reparent', + 'unsubscribe_tokens.principal_id': 'reparent', + 'user_segments.principal_id': 'conservative_merge', + 'votes.added_by_principal_id': 'target_only', + 'votes.principal_id': 'dedupe', + 'webhooks.created_by_id': 'target_only', +} 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_TARGET_ONLY_KEYS = Object.freeze( + Object.entries(ANONYMOUS_PRINCIPAL_FK_POLICY) + .filter(([, policy]) => policy === 'target_only') + .map(([key]) => key) + .sort() +) as readonly AnonymousPrincipalFkKey[] + +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 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 { + 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 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/identify-merge.ts b/apps/web/src/lib/server/auth/identify-merge.ts index 31afec0c9..d83aba578 100644 --- a/apps/web/src/lib/server/auth/identify-merge.ts +++ b/apps/web/src/lib/server/auth/identify-merge.ts @@ -1,65 +1,359 @@ /** - * Resolve a previousToken from the widget and merge anonymous activity - * into the newly identified user. + * Atomic commit boundary for anonymous → identified widget history. * - * Called by the widget identify endpoint when the client sends a - * previousToken alongside the new identify payload. This enables - * anonymous→identified transitions to preserve votes, comments, and posts. + * `/api/widget/identify` only authenticates the target identity. The client + * calls this commit after its exact-current attempt gate, proving possession + * of both the prior anonymous token and the newly minted identified token. + * + * No schema migration is required: the locked anonymous session row becomes + * an expired, target-owned tombstone in the same transaction as the activity + * transfer. It is the bounded idempotency marker for same-target retries and + * cross-target races, then the daily marked-row sweep removes it after 7 days. */ import type { PrincipalId, UserId } from '@quackback/ids' -import { db, session, principal, eq, and, gt } from '@/lib/server/db' -import { logger } from '@/lib/server/logger' -import { mergeAnonymousToIdentified } from './merge-anonymous' +import { + db, + type Database, + session, + principal, + widgetIdentifiedSession, + eq, + and, + gt, + inArray, + or, +} from '@/lib/server/db' +import { + mergeAnonymousToIdentified, + mergeAnonymousToIdentifiedInTransaction, +} from './merge-anonymous' +import { IDENTITY_MERGE_TOMBSTONE_USER_AGENT } from './identity-merge-tombstone' +import { lockIdentityActorUsers } from './identity-merge-locks' -const log = logger.child({ component: 'identify-merge' }) +export type IdentityMergeCommitResult = + | { status: 'merged' } + | { status: 'already_merged' } + | { status: 'not_applicable' } + | { status: 'target_invalid' } + | { status: 'conflict' } interface ResolveAndMergeParams { - /** The previous widget session token (captured before re-identify) */ + /** Prior token held privately by the iframe; never log or serialize it. */ + 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 - /** The principal ID of the newly identified user */ targetPrincipalId: PrincipalId - /** Display name of the newly identified user */ targetDisplayName: string } /** - * Validates the previous token, checks that it belongs to an anonymous user, - * and merges their activity into the target principal. Non-fatal on failure. + * Compatibility path for already-shipped one-phase clients. + * + * The identify route calls this only after proving + * Authorization Bearer === body.previousToken. New correlated host attempts + * never use this path. Failure remains non-fatal so an old client can still + * authenticate even when its anonymous source was already consumed. */ -export async function resolveAndMergeAnonymousToken(params: ResolveAndMergeParams): Promise { +export async function resolveAndMergeLegacyAnonymousToken( + params: ResolveLegacyMergeParams +): Promise { const { previousToken, targetPrincipalId, targetDisplayName } = params - if (!previousToken) return try { - // Look up the session for the previous token - const prevSession = await db.query.session.findFirst({ + const previousSession = await db.query.session.findFirst({ where: and(eq(session.token, previousToken), gt(session.expiresAt, new Date())), - with: { user: true }, + columns: { userId: true }, }) - if (!prevSession) return - - const prevUserId = prevSession.userId as UserId + if (!previousSession) return - // Check that the previous session belongs to an anonymous user - const prevPrincipal = await db.query.principal.findFirst({ - where: eq(principal.userId, prevUserId), + const previousPrincipal = await db.query.principal.findFirst({ + where: eq(principal.userId, previousSession.userId), + columns: { id: true, type: true, displayName: true }, }) - if (!prevPrincipal) return - if (prevPrincipal.type !== 'anonymous') return - - // Don't merge with self - if (prevPrincipal.id === targetPrincipalId) return + if ( + !previousPrincipal || + previousPrincipal.type !== 'anonymous' || + previousPrincipal.id === targetPrincipalId + ) { + return + } await mergeAnonymousToIdentified({ - anonPrincipalId: prevPrincipal.id as PrincipalId, + anonPrincipalId: previousPrincipal.id as PrincipalId, targetPrincipalId, - anonUserId: prevUserId, - anonDisplayName: prevPrincipal.displayName || 'Anonymous', + anonUserId: previousSession.userId as UserId, + anonDisplayName: previousPrincipal.displayName || 'Anonymous', targetDisplayName, }) - } catch (error) { - // Merge failures are non-fatal — the identify should still succeed - log.error({ err: error }, 'previous token merge failed') + } catch { + // Do not log DB errors: driver exceptions may retain token query params. + // Legacy identify remains available while new clients use atomic commit. } } + +export async function resolveAndMergeAnonymousToken( + params: ResolveAndMergeParams +): Promise { + 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, targetActor } = params + + if (!targetToken || !targetActor?.userId || !targetActor.principalId) { + return { status: 'target_invalid' } + } + if (!previousToken) return { status: 'conflict' } + + return database.transaction(async (tx) => { + // Reject a forged/expired/non-widget target before touching the previous + // token. This keeps the prepare/commit route from becoming a source-token + // existence oracle for unauthenticated callers. + const preflightAt = params.now ?? new Date() + const targetHints = 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 targetHint = targetHints[0] + if ( + !targetHint || + targetHint.token !== targetToken || + targetHint.userId !== targetActor.userId || + targetHint.expiresAt <= preflightAt + ) { + return { status: 'target_invalid' } + } + const targetProvenance = await tx + .select({ sessionId: widgetIdentifiedSession.sessionId }) + .from(widgetIdentifiedSession) + .where(eq(widgetIdentifiedSession.sessionId, targetHint.targetSessionId)) + if (!targetProvenance.some((row) => row.sessionId === targetHint.targetSessionId)) { + return { status: 'target_invalid' } + } + const targetPrincipalHints = await tx + .select({ + targetPrincipalId: principal.id, + type: principal.type, + role: principal.role, + }) + .from(principal) + .where(eq(principal.userId, targetHint.userId)) + .limit(1) + const targetPrincipalHint = targetPrincipalHints[0] + if ( + !targetPrincipalHint || + targetPrincipalHint.targetPrincipalId !== targetActor.principalId || + targetPrincipalHint.type !== 'user' || + targetPrincipalHint.role !== 'user' + ) { + return { status: 'target_invalid' } + } + + // Non-locking hints only discover the two actors. The transaction then + // locks both user sentinels and the full relevant session union in stable + // primary-key order. Reversed A→B / B→A commits therefore acquire every + // overlapping row in the same order instead of deadlocking. + const sourceHints = await tx + .select({ sourceUserId: session.userId }) + .from(session) + .where(eq(session.token, previousToken)) + .limit(1) + const sourceHint = sourceHints[0] + if (!sourceHint) return { status: 'conflict' } + + const lockedActorUserIds = await lockIdentityActorUsers(tx, [ + sourceHint.sourceUserId, + targetHint.userId, + ]) + if (!lockedActorUserIds.has(targetHint.userId)) { + return { status: 'target_invalid' } + } + if (!lockedActorUserIds.has(sourceHint.sourceUserId)) { + return { status: 'conflict' } + } + + const lockedSessions = await tx + .select({ + id: session.id, + token: session.token, + userId: session.userId, + expiresAt: session.expiresAt, + userAgent: session.userAgent, + }) + .from(session) + .where(or(eq(session.userId, sourceHint.sourceUserId), eq(session.token, targetToken))) + .orderBy(session.id) + .for('update') + const sourceSession = lockedSessions.find((row) => row.token === previousToken) + const targetSession = lockedSessions.find((row) => row.token === targetToken) + + // Capture the production clock only after a contended lock returns. A + // cross-target tombstone committed while this request waited must compare + // as consumed, never as an active identified source. + const consumedAt = params.now ?? new Date() + + if ( + !targetSession || + targetSession.token !== targetToken || + targetSession.userId !== targetActor.userId || + targetSession.expiresAt <= consumedAt + ) { + return { status: 'target_invalid' } + } + + const sessionIds = sourceSession ? [targetSession.id, sourceSession.id] : [targetSession.id] + const provenanceRows = await tx + .select({ sessionId: widgetIdentifiedSession.sessionId }) + .from(widgetIdentifiedSession) + .where(inArray(widgetIdentifiedSession.sessionId, sessionIds)) + const provenance = new Set(provenanceRows.map((row) => row.sessionId)) + if (!provenance.has(targetSession.id)) return { status: 'target_invalid' } + + const userIds = sourceSession + ? Array.from(new Set([targetSession.userId, sourceSession.userId])) + : [targetSession.userId] + const principals = await tx + .select({ + id: principal.id, + userId: principal.userId, + type: principal.type, + role: principal.role, + displayName: principal.displayName, + }) + .from(principal) + .where(inArray(principal.userId, userIds)) + .orderBy(principal.id) + .for('update') + const targetPrincipal = principals.find( + (candidate) => candidate.userId === targetSession.userId + ) + if ( + !targetPrincipal || + targetPrincipal.id !== targetActor.principalId || + targetPrincipal.userId !== targetActor.userId || + targetPrincipal.type !== 'user' || + targetPrincipal.role !== 'user' + ) { + return { status: 'target_invalid' } + } + + if (!sourceSession) return { status: 'conflict' } + + // An identified source session is not anonymous history. This is the + // defensive server path for a normal identified A → identified B switch; + // the client normally avoids offering such a token as a candidate. + if (provenance.has(sourceSession.id)) return { status: 'not_applicable' } + + if (sourceSession.expiresAt <= consumedAt) { + // Same-target tombstone is an idempotent retry. A tombstone owned by a + // different target is the bounded overlap conflict and must not reparent + // another identified actor's history. + return sourceSession.userId === targetSession.userId && + sourceSession.userAgent === IDENTITY_MERGE_TOMBSTONE_USER_AGENT + ? { status: 'already_merged' } + : { status: 'conflict' } + } + + const sourcePrincipal = principals.find( + (candidate) => candidate.userId === sourceSession.userId + ) + if (!sourcePrincipal) return { status: 'conflict' } + if (sourcePrincipal.type !== 'anonymous') return { status: 'not_applicable' } + if (sourcePrincipal.id === targetPrincipal.id) return { status: 'not_applicable' } + + await mergeAnonymousToIdentifiedInTransaction( + tx, + { + anonPrincipalId: sourcePrincipal.id as PrincipalId, + targetPrincipalId: targetPrincipal.id as PrincipalId, + anonUserId: sourceSession.userId as UserId, + anonDisplayName: sourcePrincipal.displayName || 'Anonymous', + targetDisplayName: targetPrincipal.displayName || 'User', + }, + { + preservedSessionId: sourceSession.id, + targetUserId: targetSession.userId as UserId, + consumedAt, + } + ) + + return { status: 'merged' } + }) +} diff --git a/apps/web/src/lib/server/auth/identity-link-invariants.ts b/apps/web/src/lib/server/auth/identity-link-invariants.ts new file mode 100644 index 000000000..8f6960c1f --- /dev/null +++ b/apps/web/src/lib/server/auth/identity-link-invariants.ts @@ -0,0 +1,51 @@ +interface LockedPrincipalSnapshot { + id: string + userId: string | null + type: string +} + +interface SignupIdentityLinkExpectation { + anonPrincipalId: string + anonUserId: string + newPrincipalId: string | null + newUserId: string +} + +/** + * Revalidate the pre-transaction signup/link branch after canonical row locks. + * + * The Better Auth callback discovers principals before opening its transaction. + * A competing merge can change that branch while it waits for user sentinels, + * so mutations must use only the locked snapshot. + */ +export function assertSignupIdentityLinkPrincipals( + expectation: SignupIdentityLinkExpectation, + lockedPrincipals: ReadonlyArray +): void { + const lockedAnon = lockedPrincipals.filter( + (candidate) => candidate.userId === expectation.anonUserId + ) + if ( + lockedAnon.length !== 1 || + lockedAnon[0]?.id !== expectation.anonPrincipalId || + lockedAnon[0]?.type !== 'anonymous' + ) { + throw new Error('Identity link principal changed') + } + + const lockedNew = lockedPrincipals.filter( + (candidate) => candidate.userId === expectation.newUserId + ) + if (expectation.newPrincipalId === null) { + if (lockedNew.length !== 0) throw new Error('Identity link principal changed') + return + } + + if ( + lockedNew.length !== 1 || + lockedNew[0]?.id !== expectation.newPrincipalId || + lockedNew[0]?.type !== 'anonymous' + ) { + throw new Error('Identity link principal changed') + } +} diff --git a/apps/web/src/lib/server/auth/identity-merge-locks.ts b/apps/web/src/lib/server/auth/identity-merge-locks.ts new file mode 100644 index 000000000..3520c4379 --- /dev/null +++ b/apps/web/src/lib/server/auth/identity-merge-locks.ts @@ -0,0 +1,23 @@ +import type { UserId } from '@quackback/ids' +import { inArray, type Transaction, user } from '@/lib/server/db' + +/** + * Canonical first lock for every identity actor transition. + * + * Sorting both user sentinels before any session/principal child row removes + * reversed A→B / B→A cycles and serializes generic, dedicated, and Better Auth + * signup-link paths on the same actor pair. + */ +export async function lockIdentityActorUsers( + tx: Transaction, + userIds: ReadonlyArray +): Promise> { + const orderedUserIds = Array.from(new Set(userIds)).sort() + const lockedUsers = await tx + .select({ lockedUserId: user.id }) + .from(user) + .where(inArray(user.id, orderedUserIds)) + .orderBy(user.id) + .for('update') + return new Set(lockedUsers.map((row) => row.lockedUserId as UserId)) +} 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 new file mode 100644 index 000000000..b9a98d1bb --- /dev/null +++ b/apps/web/src/lib/server/auth/identity-merge-rate-limit.ts @@ -0,0 +1,145 @@ +/** + * Bounded commit limiter for anonymous→identified identity merges. + * + * Every successful commit retains one source-session tombstone. Keep the + * maximum rate below the daily bounded sweep capacity per source while also + * 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 '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 } + | { allowed: false; reason: 'limited' | 'unavailable'; retryAfter: number } + +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 + +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( + clientIp: string, + targetToken: string +): Promise { + const targetSpec: RateBucketSpec = { + key: `identity-merge:target:${fingerprint('identity-merge-target-v1', targetToken)}`, + windowSeconds: WINDOW_SECONDS, + } + const ipSpec: RateBucketSpec = { + key: `identity-merge:ip:${fingerprint('identity-merge-ip-v1', clientIp)}`, + windowSeconds: WINDOW_SECONDS, + } + const [targetCount, ipCount] = await incrementBuckets([targetSpec, ipSpec]) + + // A merge is retryable and leaves the anonymous capability intact, so + // failing closed protects the bounded tombstone backlog during Redis loss. + if (targetCount === null || ipCount === null) { + return { allowed: false, reason: 'unavailable', retryAfter: 30 } + } + if (targetCount > TARGET_LIMIT) { + return { + allowed: false, + reason: 'limited', + retryAfter: await bucketRetryAfter(targetSpec), + } + } + if (ipCount > IP_LIMIT) { + return { + allowed: false, + reason: 'limited', + retryAfter: await bucketRetryAfter(ipSpec), + } + } + 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/identity-merge-tombstone.ts b/apps/web/src/lib/server/auth/identity-merge-tombstone.ts new file mode 100644 index 000000000..87dfacf96 --- /dev/null +++ b/apps/web/src/lib/server/auth/identity-merge-tombstone.ts @@ -0,0 +1,5 @@ +/** Non-secret marker for expired source sessions retained by identity merge. */ +export const IDENTITY_MERGE_TOMBSTONE_USER_AGENT = 'quackback:identity-merge-tombstone:v1' + +/** Long enough for delayed retries; the daily sweep removes older markers. */ +export const IDENTITY_MERGE_TOMBSTONE_RETENTION_DAYS = 7 diff --git a/apps/web/src/lib/server/auth/index.ts b/apps/web/src/lib/server/auth/index.ts index 0c6d7bd02..46d974b3e 100644 --- a/apps/web/src/lib/server/auth/index.ts +++ b/apps/web/src/lib/server/auth/index.ts @@ -16,6 +16,8 @@ import { generateId } from '@quackback/ids' import { config } from '@/lib/server/config' import { logger } from '@/lib/server/logger' import type { GenericOAuthConfig } from './build-oauth-configs' +import { assertSignupIdentityLinkPrincipals } from './identity-link-invariants' +import { lockIdentityActorUsers } from './identity-merge-locks' import { isSignInMethodEnabled } from '@/lib/shared/signin-methods' const log = logger.child({ component: 'auth-config' }) @@ -83,6 +85,7 @@ async function createAuth() { oauthConsent: oauthConsentTable, twoFactor: twoFactorTable, eq, + inArray, } = await import('@/lib/server/db') const { sendPasswordResetEmail, isEmailConfigured } = await import('@quackback/email') const { getPlatformCredentials } = @@ -512,6 +515,44 @@ async function createAuth() { ((newUser.user as Record).image as string | null) ?? null await db.transaction(async (tx) => { + // Match the identity-merge hierarchy before touching child rows: + // both users → all involved sessions → known principals, each in + // stable primary-key order. This prevents the signup absorption + // path from inverting dedicated/generic merge locks. + const actorUserIds = [anonUserId, newUserId].sort() + const lockedUserIds = await lockIdentityActorUsers(tx, actorUserIds) + if (lockedUserIds.size !== new Set(actorUserIds).size) { + throw new Error('Identity link actor changed') + } + + await tx + .select({ id: sessionTable.id }) + .from(sessionTable) + .where(inArray(sessionTable.userId, actorUserIds)) + .orderBy(sessionTable.id) + .for('update') + + const lockedPrincipals = await tx + .select({ + id: principalTable.id, + userId: principalTable.userId, + type: principalTable.type, + }) + .from(principalTable) + .where(inArray(principalTable.userId, actorUserIds)) + .orderBy(principalTable.id) + .for('update') + if (!anonPrincipal) throw new Error('Identity link principal changed') + assertSignupIdentityLinkPrincipals( + { + anonPrincipalId: anonPrincipal.id, + anonUserId, + newPrincipalId: existingPrincipal?.id ?? null, + newUserId, + }, + lockedPrincipals + ) + // Move account+session refs to anon user (before deleting new user) await Promise.all([ tx diff --git a/apps/web/src/lib/server/auth/merge-anonymous.ts b/apps/web/src/lib/server/auth/merge-anonymous.ts index d7547a750..85f741f3e 100644 --- a/apps/web/src/lib/server/auth/merge-anonymous.ts +++ b/apps/web/src/lib/server/auth/merge-anonymous.ts @@ -5,25 +5,63 @@ * - Portal auth: onLinkAccount (anonymous → sign-in to existing account) * - Widget identify: previousToken merge (anonymous → SDK re-identify) * - * Transfers: votes, comments, posts, postSubscriptions, inAppNotifications, - * chat conversations + messages. + * Transfers every anonymous-capable principal FK registered in + * anonymous-principal-fk-policy.ts, with conservative conflict handling for + * notification consent, reactions, KB feedback, and segment memberships. * Cleans up: anonymous principal, sessions, user record. */ -import type { PrincipalId, UserId } from '@quackback/ids' +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_TARGET_ONLY_KEYS, + createAnonymousPrincipalFkExecutionRegistry, + type AnonymousPrincipalFkExecutableHandler, + type AnonymousPrincipalFkExecutableHandlers, + type AnonymousPrincipalFkKey, +} from './anonymous-principal-fk-policy' import { db, + type Database, + type Transaction, votes, comments, + commentReactions, + postEditHistory, + commentEditHistory, posts, + postActivity, postSubscriptions, + notificationPreferences, + unsubscribeTokens, inAppNotifications, conversations, chatMessages, + chatMessageFlags, + chatMessageMentions, + chatMessageReactions, + bugReportSubmissions, + rawFeedbackItems, + externalUserMappings, + helpCenterArticles, + helpCenterArticleFeedback, + userSegments, + apiKeys, + changelogEntries, + feedbackSuggestions, + integrationPlatformCredentials, + integrations, + mergeSuggestions, + postMentions, + postNotes, + pushDevices, + webhooks, principal, session, user, eq, and, + gt, inArray, sql, } from '@/lib/server/db' @@ -41,120 +79,969 @@ export interface MergeAnonymousParams { targetDisplayName: string } -export async function mergeAnonymousToIdentified(params: MergeAnonymousParams): Promise { - const { anonPrincipalId, targetPrincipalId, anonUserId, anonDisplayName, targetDisplayName } = - params +export interface PreserveConsumedSession { + /** + * Anonymous source session retained as an expired, target-owned tombstone. + * + * The dedicated widget merge commit uses this row as a bounded + * idempotency/CAS marker until the daily marked-row sweep removes it. Portal + * auth leaves it undefined and retains the historical cleanup behavior. + */ + preservedSessionId: string + targetUserId: UserId + consumedAt: Date +} - await db.transaction(async (tx) => { - // 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)) +type CommentRow = typeof comments.$inferSelect - if (existingVotedPostIds.length > 0) { - await tx.delete(votes).where( - and( - eq(votes.principalId, anonPrincipalId), - inArray( - votes.postId, - existingVotedPostIds.map((v) => v.postId) - ) - ) - ) - } +interface AnonymousPrincipalFkHandlerContext { + tx: Transaction + anonPrincipalId: PrincipalId + targetPrincipalId: PrincipalId + anonPrincipalUuid: ReturnType + targetPrincipalUuid: ReturnType + conservativePreferenceUuid: ReturnType + anonDisplayName: string + targetDisplayName: string + state: { + anonCommentIds: CommentRow['id'][] + } +} - // 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)) +type AnonymousPrincipalFkHandler = + AnonymousPrincipalFkExecutableHandler +type AnonymousPrincipalFkOperation = AnonymousPrincipalFkHandler['operations'][string] - // 3. Transfer votes, comments, posts, and chat history to target principal. - // Chat rows use onDelete:'restrict', so re-pointing them here is mandatory — - // otherwise the anon-principal delete in step 6 throws and breaks the merge. - await Promise.all([ - tx - .update(votes) - .set({ principalId: targetPrincipalId }) - .where(eq(votes.principalId, anonPrincipalId)), +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} + )` + ), + 'chat_message_flags.principal_id': targetOnlyGuard( + 'chat_message_flags.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${chatMessageFlags} + WHERE ${chatMessageFlags.principalId} = ${anonPrincipalUuid} + )` + ), + 'chat_message_mentions.principal_id': targetOnlyGuard( + 'chat_message_mentions.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${chatMessageMentions} + WHERE ${chatMessageMentions.principalId} = ${anonPrincipalUuid} + )` + ), + 'chat_message_reactions.principal_id': targetOnlyGuard( + 'chat_message_reactions.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${chatMessageReactions} + WHERE ${chatMessageReactions.principalId} = ${anonPrincipalUuid} + )` + ), + '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(comments) + .update(chatMessages) .set({ principalId: targetPrincipalId }) - .where(eq(comments.principalId, anonPrincipalId)), + .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(posts) + .update(commentReactions) .set({ principalId: targetPrincipalId }) - .where(eq(posts.principalId, anonPrincipalId)), + .where(eq(commentReactions.principalId, anonPrincipalId)), + }), + 'comments.deleted_by_principal_id': mergeHandler('comments.deleted_by_principal_id', 'reparent', { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => tx - .update(conversations) - .set({ visitorPrincipalId: targetPrincipalId }) - .where(eq(conversations.visitorPrincipalId, anonPrincipalId)), + .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(chatMessages) + .update(comments) .set({ principalId: targetPrincipalId }) - .where(eq(chatMessages.principalId, anonPrincipalId)), - ]) - - // 4. 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) + .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, commentIds) + inArray(inAppNotifications.commentId, state.anonCommentIds) ) ) - - // 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)) + .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} + )` + ), + '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)), } - - // 5. Handle subscription conflicts and transfer - const existingSubPostIds = await tx - .select({ postId: postSubscriptions.postId }) - .from(postSubscriptions) - .where(eq(postSubscriptions.principalId, targetPrincipalId)) - - if (existingSubPostIds.length > 0) { - await tx.delete(postSubscriptions).where( - and( - eq(postSubscriptions.principalId, anonPrincipalId), - inArray( - postSubscriptions.postId, - existingSubPostIds.map((s) => s.postId) + ), + '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} + )` + ), + '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} + )` + ), + 'integrations.connected_by_principal_id': targetOnlyGuard( + 'integrations.connected_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${integrations} + WHERE ${integrations.connectedByPrincipalId} = ${anonPrincipalUuid} + )` + ), + '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} + )` + ), + 'merge_suggestions.resolved_by_principal_id': targetOnlyGuard( + 'merge_suggestions.resolved_by_principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${mergeSuggestions} + WHERE ${mergeSuggestions.resolvedByPrincipalId} = ${anonPrincipalUuid} + )` + ), + '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)), } - - // Transfer remaining subscriptions and notifications - await Promise.all([ + ), + '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} + )` + ), + 'post_notes.principal_id': targetOnlyGuard( + 'post_notes.principal_id', + ({ anonPrincipalUuid }) => + sql`EXISTS ( + SELECT 1 FROM ${postNotes} + WHERE ${postNotes.principalId} = ${anonPrincipalUuid} + )` + ), + '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} + )` + ), + '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(postSubscriptions) + .update(posts) .set({ principalId: targetPrincipalId }) - .where(eq(postSubscriptions.principalId, anonPrincipalId)), + .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} + )` + ), + 'raw_feedback_items.principal_id': mergeHandler('raw_feedback_items.principal_id', 'reparent', { + reparent: ({ tx, anonPrincipalId, targetPrincipalId }) => tx - .update(inAppNotifications) + .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(inAppNotifications.principalId, anonPrincipalId)), - ]) + .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) + ) + ) + ) + }, + 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 = ( + targetOnlyReferences[0] as { target_only_reference_present?: unknown } | undefined + )?.target_only_reference_present + if (targetOnlyReferencePresent !== false) { + throw new Error('Anonymous principal has target-only references') + } - // 6. Clean up anonymous records: principal first, then sessions and user - await tx.delete(principal).where(eq(principal.id, anonPrincipalId)) - await Promise.all([ - tx.delete(session).where(eq(session.userId, anonUserId)), - tx.delete(user).where(eq(user.id, anonUserId)), + // Lock every conflict/evidence row before reading or folding it. Principal + // FOR UPDATE locks (held by both callers) block new FK inserts; these row + // locks serialize existing opt-outs, deletes, and dedupe state. + // 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. + 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 + await fkExecution.invoke('votes.principal_id', 'dedupe', fkContext) + + // 2. Get anonymous comment IDs before transfer (for notification cleanup) + 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 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 fkExecution.invoke('notification_preferences.principal_id', 'merge', fkContext) + + 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 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 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 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( + [ + '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 fkExecution.invoke('votes.principal_id', 'recount', fkContext) + + // 5. Fix notifications for transferred comments + 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( + [ + 'notification_preferences.principal_id', + 'unsubscribe_tokens.principal_id', + 'in_app_notifications.principal_id', + ].map((key) => fkExecution.invoke(key, 'reparent_late', fkContext)) + ) + + // 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, + // target-owned tombstone. Reparent it before deleting the anonymous user so + // the user FK cascade cannot erase the bounded idempotency marker. + if (preserve) { + const preservedSessions = await tx + .update(session) + .set({ + userId: preserve.targetUserId, + expiresAt: preserve.consumedAt, + updatedAt: preserve.consumedAt, + userAgent: IDENTITY_MERGE_TOMBSTONE_USER_AGENT, + }) + .where( + and( + eq(session.id, preserve.preservedSessionId), + eq(session.userId, anonUserId), + gt(session.expiresAt, preserve.consumedAt) + ) + ) + .returning({ preservedSessionId: session.id }) + if ( + preservedSessions.length !== 1 || + preservedSessions[0]?.preservedSessionId !== preserve.preservedSessionId + ) { + throw new Error('Identity merge source session changed') + } + } + + // 8. Clean up anonymous records: principal first, then sessions and user. + await tx.delete(principal).where(eq(principal.id, anonPrincipalId)) + await Promise.all([ + tx.delete(session).where(eq(session.userId, anonUserId)), + tx.delete(user).where(eq(user.id, anonUserId)), + ]) +} + +export async function mergeAnonymousToIdentified(params: MergeAnonymousParams): Promise { + return mergeAnonymousToIdentifiedWithDatabase(db, params) +} + +/** Database-injected entry point for PostgreSQL concurrency verification. */ +export async function mergeAnonymousToIdentifiedWithDatabase( + database: Pick, + params: MergeAnonymousParams +): Promise { + await database.transaction(async (tx) => { + // Discover the target actor without locking, then use the same canonical + // user → session → principal hierarchy as the dedicated two-phase commit. + // Locking both actor sentinels in primary-key order serializes reversed + // actor pairs and prevents target-user deletion/cascade races. + const targetPrincipalHints = await tx + .select({ + targetPrincipalId: principal.id, + targetUserId: principal.userId, + targetPrincipalType: principal.type, + }) + .from(principal) + .where(eq(principal.id, params.targetPrincipalId)) + .limit(1) + const targetPrincipalHint = targetPrincipalHints[0] + if ( + !targetPrincipalHint || + targetPrincipalHint.targetPrincipalId !== params.targetPrincipalId || + targetPrincipalHint.targetPrincipalType !== 'user' || + !targetPrincipalHint.targetUserId || + targetPrincipalHint.targetUserId === params.anonUserId || + params.targetPrincipalId === params.anonPrincipalId + ) { + return + } + + const lockedUserIds = await lockIdentityActorUsers(tx, [ + params.anonUserId, + targetPrincipalHint.targetUserId, ]) + if ( + !lockedUserIds.has(params.anonUserId) || + !lockedUserIds.has(targetPrincipalHint.targetUserId) + ) { + return + } + + // Only source sessions are mutated/deleted. The target user sentinel above + // already protects its session FK boundary, avoiding unrelated device-row + // contention while preserving the cross-class lock order. + const lockedSessions = await tx + .select({ lockedSessionId: session.id }) + .from(session) + .where(eq(session.userId, params.anonUserId)) + .orderBy(session.id) + .for('update') + if (lockedSessions.length === 0) return + + // Lock both principals in primary-key order and revalidate every discovery + // hint. The anonymous plugin can upgrade the source without replacing its + // sessions, while a concurrent target deletion can invalidate the target. + const actorPrincipalIds = [params.anonPrincipalId, params.targetPrincipalId].sort() + const lockedPrincipals = await tx + .select({ + lockedPrincipalId: principal.id, + lockedPrincipalUserId: principal.userId, + lockedPrincipalType: principal.type, + }) + .from(principal) + .where(inArray(principal.id, actorPrincipalIds)) + .orderBy(principal.id) + .for('update') + const lockedSource = lockedPrincipals.find( + (candidate) => candidate.lockedPrincipalId === params.anonPrincipalId + ) + const lockedTarget = lockedPrincipals.find( + (candidate) => candidate.lockedPrincipalId === params.targetPrincipalId + ) + if ( + !lockedSource || + lockedSource.lockedPrincipalId !== params.anonPrincipalId || + lockedSource.lockedPrincipalUserId !== params.anonUserId || + lockedSource.lockedPrincipalType !== 'anonymous' || + !lockedTarget || + lockedTarget.lockedPrincipalUserId !== targetPrincipalHint.targetUserId || + lockedTarget.lockedPrincipalType !== 'user' + ) { + return + } + + await mergeAnonymousToIdentifiedInTransaction(tx, params) }) } diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts index 72a861107..e0c87470c 100644 --- a/apps/web/src/lib/server/db.ts +++ b/apps/web/src/lib/server/db.ts @@ -66,6 +66,7 @@ export const min = _min export const max = _max // Database type - postgres.js for self-hosted +export { createDb } export type Database = PostgresDatabase export type Transaction = Parameters[0]>[0] 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..a648045e4 --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-assertion.test.ts @@ -0,0 +1,182 @@ +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.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')) } + + 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-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..3fd1ab5b5 --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/__tests__/host-submit-origin-policy.test.ts @@ -0,0 +1,106 @@ +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 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.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() + }) + + 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/__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/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/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..a16c8b061 --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-assertion.ts @@ -0,0 +1,228 @@ +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 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 { + 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) + // 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') + .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-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/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..ed96de0e6 --- /dev/null +++ b/apps/web/src/lib/server/domains/bug-reports/host-submit-origin-policy.ts @@ -0,0 +1,91 @@ +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?):\/\/[^/?#]+\/?$/ +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.replace(/^ +/, '').replace(/ +$/, '') + if ( + !value || + value.includes(',') || + value.includes('*') || + value.includes('\\') || + PERCENT_ENCODING_PATTERN.test(value) || + WHITESPACE_PATTERN.test(value) || + !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/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/__tests__/anon-sweep-tombstones.test.ts b/apps/web/src/lib/server/domains/principals/__tests__/anon-sweep-tombstones.test.ts new file mode 100644 index 000000000..46c03f6ac --- /dev/null +++ b/apps/web/src/lib/server/domains/principals/__tests__/anon-sweep-tombstones.test.ts @@ -0,0 +1,133 @@ +import { afterEach, 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(), + sql, + } +}) + +vi.mock('@/lib/server/db', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + db: { + execute: (...args: unknown[]) => mocks.execute(...args), + }, + sql: mocks.sql, + eq: vi.fn(), + } +}) + +vi.mock('@/lib/server/logger', () => ({ + logger: { + child: () => ({ warn: vi.fn() }), + }, +})) + +import { + IDENTITY_MERGE_TOMBSTONE_RETENTION_DAYS, + IDENTITY_MERGE_TOMBSTONE_USER_AGENT, +} from '@/lib/server/auth/identity-merge-tombstone' +import { + IDENTITY_MERGE_TOMBSTONE_BACKLOG_COUNT_CAP, + sweepIdentityMergeTombstones, +} from '../anon-sweep.service' + +describe('sweepIdentityMergeTombstones', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-10T00:00:00.000Z')) + mocks.execute + .mockResolvedValueOnce([{ deleted: 1 }, { deleted: 1 }]) + .mockResolvedValueOnce([{ remaining: 0 }]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('deletes only marked rows older than the indexed cutoff in bounded batches', async () => { + await expect( + sweepIdentityMergeTombstones({ olderThanDays: 3, batchSize: 25 }) + ).resolves.toEqual({ deleted: 2, batches: 1, backlog: 0, backlogCapped: false }) + + expect(mocks.execute).toHaveBeenCalledTimes(2) + const query = mocks.execute.mock.calls[0]![0] as { + strings: string[] + values: unknown[] + } + const statement = query.strings.join('?') + expect(statement).toContain('WHERE user_agent = ?') + expect(statement).toContain('AND expires_at <= now()') + expect(statement).toContain('AND updated_at < ?::timestamptz') + expect(statement).toContain('ORDER BY updated_at') + expect(statement).toContain('LIMIT ?') + expect(statement).toContain('FOR UPDATE SKIP LOCKED') + expect(statement).toContain('DELETE FROM session') + expect(statement).toContain('RETURNING 1 AS deleted') + expect(query.values).toEqual([ + IDENTITY_MERGE_TOMBSTONE_USER_AGENT, + '2026-08-07T00:00:00.000Z', + 25, + ]) + }) + + it('uses the declared retention window and reports zero without touching unmarked sessions', async () => { + mocks.execute.mockReset() + mocks.execute.mockResolvedValueOnce([]).mockResolvedValueOnce([{ remaining: 0 }]) + + await expect(sweepIdentityMergeTombstones()).resolves.toEqual({ + deleted: 0, + batches: 1, + backlog: 0, + backlogCapped: false, + }) + + const query = mocks.execute.mock.calls[0]![0] as { + strings: string[] + values: unknown[] + } + expect(query.values[0]).toBe(IDENTITY_MERGE_TOMBSTONE_USER_AGENT) + expect(query.values[1]).toBe( + new Date(Date.now() - IDENTITY_MERGE_TOMBSTONE_RETENTION_DAYS * 86_400_000).toISOString() + ) + expect(query.values[2]).toBe(500) + }) + + it('drains multiple full batches and emits only a bounded count-only backlog', async () => { + mocks.execute.mockReset() + const fullBatch = Array.from({ length: 500 }, () => ({ deleted: 1 })) + mocks.execute + .mockResolvedValueOnce(fullBatch) + .mockResolvedValueOnce(fullBatch) + .mockResolvedValueOnce(Array.from({ length: 25 }, () => ({ deleted: 1 }))) + .mockResolvedValueOnce([{ remaining: IDENTITY_MERGE_TOMBSTONE_BACKLOG_COUNT_CAP + 1 }]) + + await expect(sweepIdentityMergeTombstones()).resolves.toEqual({ + deleted: 1_025, + batches: 3, + backlog: IDENTITY_MERGE_TOMBSTONE_BACKLOG_COUNT_CAP + 1, + backlogCapped: true, + }) + + const backlogQuery = mocks.execute.mock.calls[3]![0] as { + strings: string[] + values: unknown[] + } + expect(backlogQuery.strings.join('?')).toContain('COUNT(*)::integer AS remaining') + expect(backlogQuery.strings.join('?')).not.toContain('RETURNING s.id') + expect(backlogQuery.values).toContain(IDENTITY_MERGE_TOMBSTONE_BACKLOG_COUNT_CAP + 1) + }) +}) diff --git a/apps/web/src/lib/server/domains/principals/anon-sweep-queue.ts b/apps/web/src/lib/server/domains/principals/anon-sweep-queue.ts index d2ff0483f..d682a9bfa 100644 --- a/apps/web/src/lib/server/domains/principals/anon-sweep-queue.ts +++ b/apps/web/src/lib/server/domains/principals/anon-sweep-queue.ts @@ -5,7 +5,7 @@ import { Queue, Worker } from 'bullmq' import { getQueueRedis, REDIS_READY_TIMEOUT_MS } from '@/lib/server/queue/redis-config' import { logger } from '@/lib/server/logger' -import { sweepAnonymousPrincipals } from './anon-sweep.service' +import { sweepAnonymousPrincipals, sweepIdentityMergeTombstones } from './anon-sweep.service' const log = logger.child({ component: 'anon-sweep-queue' }) @@ -36,8 +36,24 @@ async function initializeQueue() { async (job) => { if (job.data.type === 'sweep-anonymous') { const result = await sweepAnonymousPrincipals() - if (result.deleted > 0 || result.candidates > 0) { - log.debug({ candidates: result.candidates, deleted: result.deleted }, 'anon-sweep run complete') + const tombstones = await sweepIdentityMergeTombstones() + if ( + result.deleted > 0 || + result.candidates > 0 || + tombstones.deleted > 0 || + tombstones.backlog > 0 + ) { + log.debug( + { + candidates: result.candidates, + deleted: result.deleted, + merge_tombstones_deleted: tombstones.deleted, + merge_tombstone_batches: tombstones.batches, + merge_tombstone_backlog: tombstones.backlog, + merge_tombstone_backlog_capped: tombstones.backlogCapped, + }, + 'anon-sweep run complete' + ) } } }, 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 22fcc903e..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 @@ -5,22 +5,26 @@ * sessions + principals). This reclaims them. * * Only TRULY EMPTY anon principals are deleted — created beyond the retention - * window, no live session, and no content anywhere (posts/votes/comments/ - * comment_reactions/conversations/messages/subscriptions/notifications). A - * principal that authored anything is left untouched. + * window, no live session, and no row through any FK targeting principal.id. * * The NOT EXISTS list must cover every table where an anon actor can author * content, because the FKs are a mix: chat FKs are onDelete:restrict (a missed * one would throw and be caught), but content like comment_reactions is * onDelete:CASCADE — a missing guard there would NOT throw; it would silently * cascade-delete real content. So the guard, not the catch block, is the - * safety net for cascade tables. (notification_preferences / unsubscribe_tokens - * also cascade but are derived preference state, so sweeping them is intended.) + * safety net for cascade tables. Preference, receipt, mapping, feedback, and + * derived rows also block sweep: retaining an old principal is safer than + * silently discarding identity evidence. * 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, + IDENTITY_MERGE_TOMBSTONE_USER_AGENT, +} from '@/lib/server/auth/identity-merge-tombstone' +import { ANONYMOUS_SWEEP_BLOCKING_REFERENCE_GROUPS } from './anonymous-sweep-principal-fk-policy' const log = logger.child({ component: 'anon-sweep' }) @@ -31,6 +35,143 @@ export interface AnonSweepResult { deleted: number } +export interface IdentityMergeTombstoneSweepResult { + /** Marked rows deleted across bounded batches. */ + deleted: number + /** Delete batches attempted during this run. */ + batches: number + /** Count-only eligible backlog after the run (capped at 10,001). */ + backlog: number + /** True when backlog means "at least 10,001", not an exact total. */ + backlogCapped: boolean +} + +export const IDENTITY_MERGE_TOMBSTONE_SWEEP_MAX_BATCHES = 10 +export const IDENTITY_MERGE_TOMBSTONE_SWEEP_MAX_DURATION_MS = 20_000 +export const IDENTITY_MERGE_TOMBSTONE_BACKLOG_COUNT_CAP = 10_000 + +function anonymousSweepReferenceGuards() { + const outerPrincipalId = sql.raw('pr.id') + return sql.join( + ANONYMOUS_SWEEP_BLOCKING_REFERENCE_GROUPS.map((group) => { + const columns = group.columns as readonly (typeof group.columns)[number][] + return sql`NOT EXISTS ( + SELECT 1 + FROM ${group.table} + WHERE ${sql.join( + columns.map((column) => sql`${column} = ${outerPrincipalId}`), + sql` OR ` + )} + )` + }), + sql` AND ` + ) +} + +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. + * + * The fixed marker prevents this maintenance path from touching ordinary + * expired sessions. `updated_at` is indexed, the batch is bounded, and + * SKIP LOCKED keeps the daily worker from blocking an in-flight merge retry. + */ +export async function sweepIdentityMergeTombstones(opts?: { + olderThanDays?: number + batchSize?: number + maxBatches?: number + maxDurationMs?: number +}): Promise { + const olderThanDays = opts?.olderThanDays ?? IDENTITY_MERGE_TOMBSTONE_RETENTION_DAYS + const batchSize = Math.max(1, Math.min(opts?.batchSize ?? 500, 500)) + const maxBatches = Math.max( + 1, + Math.min( + opts?.maxBatches ?? IDENTITY_MERGE_TOMBSTONE_SWEEP_MAX_BATCHES, + IDENTITY_MERGE_TOMBSTONE_SWEEP_MAX_BATCHES + ) + ) + const maxDurationMs = Math.max( + 1, + Math.min( + opts?.maxDurationMs ?? IDENTITY_MERGE_TOMBSTONE_SWEEP_MAX_DURATION_MS, + IDENTITY_MERGE_TOMBSTONE_SWEEP_MAX_DURATION_MS + ) + ) + const cutoffIso = new Date(Date.now() - olderThanDays * 86_400_000).toISOString() + const startedAt = Date.now() + let deleted = 0 + let batches = 0 + + while (batches < maxBatches && (batches === 0 || Date.now() - startedAt < maxDurationMs)) { + const rows = await db.execute(sql` + WITH candidates AS ( + SELECT id + FROM session + WHERE user_agent = ${IDENTITY_MERGE_TOMBSTONE_USER_AGENT} + AND expires_at <= now() + AND updated_at < ${cutoffIso}::timestamptz + ORDER BY updated_at + LIMIT ${batchSize} + FOR UPDATE SKIP LOCKED + ) + DELETE FROM session s + USING candidates c + WHERE s.id = c.id + RETURNING 1 AS deleted + `) + const batchDeleted = (rows as unknown as Array<{ deleted: number }>).length + deleted += batchDeleted + batches += 1 + if (batchDeleted < batchSize) break + } + + const backlogRows = await db.execute(sql` + SELECT COUNT(*)::integer AS remaining + FROM ( + SELECT 1 + FROM session + WHERE user_agent = ${IDENTITY_MERGE_TOMBSTONE_USER_AGENT} + AND expires_at <= now() + AND updated_at < ${cutoffIso}::timestamptz + LIMIT ${IDENTITY_MERGE_TOMBSTONE_BACKLOG_COUNT_CAP + 1} + ) eligible + `) + const backlog = Number( + (backlogRows as unknown as Array<{ remaining?: number | string }>)[0]?.remaining ?? 0 + ) + return { + deleted, + batches, + backlog, + backlogCapped: backlog > IDENTITY_MERGE_TOMBSTONE_BACKLOG_COUNT_CAP, + } +} + export async function sweepAnonymousPrincipals(opts?: { olderThanDays?: number batchSize?: number @@ -46,14 +187,7 @@ export async function sweepAnonymousPrincipals(opts?: { AND pr.user_id IS NOT NULL AND pr.created_at < ${cutoffIso}::timestamptz AND NOT EXISTS (SELECT 1 FROM session s WHERE s.user_id = pr.user_id AND s.expires_at > now()) - AND NOT EXISTS (SELECT 1 FROM posts WHERE principal_id = pr.id) - AND NOT EXISTS (SELECT 1 FROM votes WHERE principal_id = pr.id) - AND NOT EXISTS (SELECT 1 FROM comments WHERE principal_id = pr.id) - AND NOT EXISTS (SELECT 1 FROM comment_reactions WHERE principal_id = pr.id) - AND NOT EXISTS (SELECT 1 FROM conversations WHERE visitor_principal_id = pr.id) - AND NOT EXISTS (SELECT 1 FROM chat_messages WHERE principal_id = pr.id) - AND NOT EXISTS (SELECT 1 FROM post_subscriptions WHERE principal_id = pr.id) - AND NOT EXISTS (SELECT 1 FROM in_app_notifications WHERE principal_id = pr.id) + AND ${anonymousSweepReferenceGuards()} LIMIT ${batchSize} `) @@ -61,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/domains/principals/anonymous-sweep-principal-fk-policy.ts b/apps/web/src/lib/server/domains/principals/anonymous-sweep-principal-fk-policy.ts new file mode 100644 index 000000000..44f6bf8d1 --- /dev/null +++ b/apps/web/src/lib/server/domains/principals/anonymous-sweep-principal-fk-policy.ts @@ -0,0 +1,223 @@ +import { + apiKeys, + bugReportSubmissions, + changelogEntries, + chatMessageFlags, + chatMessageMentions, + chatMessageReactions, + chatMessages, + commentEditHistory, + commentReactions, + comments, + conversations, + externalUserMappings, + feedbackSuggestions, + helpCenterArticleFeedback, + helpCenterArticles, + inAppNotifications, + integrationPlatformCredentials, + integrations, + mergeSuggestions, + notificationPreferences, + postActivity, + postEditHistory, + postMentions, + postNotes, + postSubscriptions, + posts, + pushDevices, + rawFeedbackItems, + unsubscribeTokens, + userSegments, + votes, + webhooks, +} from '@/lib/server/db' + +/** + * Sweep is intentionally stricter than merge: any principal reference means + * the actor is not "truly empty", including impossible legacy target-only + * rows and preference/derived state. Retaining an old principal is safer than + * silently CASCADE-deleting or SET-NULLing identity evidence. + */ +export const ANONYMOUS_SWEEP_BLOCKING_REFERENCE_GROUPS = [ + { + table: apiKeys, + columns: [apiKeys.createdById, apiKeys.principalId], + keys: ['api_keys.created_by_id', 'api_keys.principal_id'], + }, + { + table: bugReportSubmissions, + columns: [bugReportSubmissions.principalId], + keys: ['bug_report_submissions.principal_id'], + }, + { + table: changelogEntries, + columns: [changelogEntries.principalId], + keys: ['changelog_entries.principal_id'], + }, + { + table: chatMessageFlags, + columns: [chatMessageFlags.principalId], + keys: ['chat_message_flags.principal_id'], + }, + { + table: chatMessageMentions, + columns: [chatMessageMentions.principalId], + keys: ['chat_message_mentions.principal_id'], + }, + { + table: chatMessageReactions, + columns: [chatMessageReactions.principalId], + keys: ['chat_message_reactions.principal_id'], + }, + { + table: chatMessages, + columns: [chatMessages.deletedByPrincipalId, chatMessages.principalId], + keys: ['chat_messages.deleted_by_principal_id', 'chat_messages.principal_id'], + }, + { + table: commentEditHistory, + columns: [commentEditHistory.editorPrincipalId], + keys: ['comment_edit_history.editor_principal_id'], + }, + { + table: commentReactions, + columns: [commentReactions.principalId], + keys: ['comment_reactions.principal_id'], + }, + { + table: comments, + columns: [comments.deletedByPrincipalId, comments.principalId], + keys: ['comments.deleted_by_principal_id', 'comments.principal_id'], + }, + { + table: conversations, + columns: [conversations.assignedAgentPrincipalId, conversations.visitorPrincipalId], + keys: ['conversations.assigned_agent_principal_id', 'conversations.visitor_principal_id'], + }, + { + table: externalUserMappings, + columns: [externalUserMappings.principalId], + keys: ['external_user_mappings.principal_id'], + }, + { + table: feedbackSuggestions, + columns: [feedbackSuggestions.resolvedByPrincipalId], + keys: ['feedback_suggestions.resolved_by_principal_id'], + }, + { + table: inAppNotifications, + columns: [inAppNotifications.principalId], + keys: ['in_app_notifications.principal_id'], + }, + { + table: integrationPlatformCredentials, + columns: [integrationPlatformCredentials.configuredByPrincipalId], + keys: ['integration_platform_credentials.configured_by_principal_id'], + }, + { + table: integrations, + columns: [integrations.connectedByPrincipalId, integrations.principalId], + keys: ['integrations.connected_by_principal_id', 'integrations.principal_id'], + }, + { + table: helpCenterArticleFeedback, + columns: [helpCenterArticleFeedback.principalId], + keys: ['kb_article_feedback.principal_id'], + }, + { + table: helpCenterArticles, + columns: [helpCenterArticles.principalId], + keys: ['kb_articles.principal_id'], + }, + { + table: mergeSuggestions, + columns: [mergeSuggestions.resolvedByPrincipalId], + keys: ['merge_suggestions.resolved_by_principal_id'], + }, + { + table: notificationPreferences, + columns: [notificationPreferences.principalId], + keys: ['notification_preferences.principal_id'], + }, + { + table: postActivity, + columns: [postActivity.principalId], + keys: ['post_activity.principal_id'], + }, + { + table: postEditHistory, + columns: [postEditHistory.editorPrincipalId], + keys: ['post_edit_history.editor_principal_id'], + }, + { + table: postMentions, + columns: [postMentions.principalId], + keys: ['post_mentions.principal_id'], + }, + { + table: postNotes, + columns: [postNotes.principalId], + keys: ['post_notes.principal_id'], + }, + { + table: postSubscriptions, + columns: [postSubscriptions.principalId], + keys: ['post_subscriptions.principal_id'], + }, + { + table: posts, + columns: [ + posts.deletedByPrincipalId, + posts.mergedByPrincipalId, + posts.ownerPrincipalId, + posts.principalId, + posts.trackedByPrincipalId, + ], + keys: [ + 'posts.deleted_by_principal_id', + 'posts.merged_by_principal_id', + 'posts.owner_principal_id', + 'posts.principal_id', + 'posts.tracked_by_principal_id', + ], + }, + { + table: pushDevices, + columns: [pushDevices.principalId], + keys: ['push_devices.principal_id'], + }, + { + table: rawFeedbackItems, + columns: [rawFeedbackItems.principalId], + keys: ['raw_feedback_items.principal_id'], + }, + { + table: unsubscribeTokens, + columns: [unsubscribeTokens.principalId], + keys: ['unsubscribe_tokens.principal_id'], + }, + { + table: userSegments, + columns: [userSegments.principalId], + keys: ['user_segments.principal_id'], + }, + { + table: votes, + columns: [votes.addedByPrincipalId, votes.principalId], + keys: ['votes.added_by_principal_id', 'votes.principal_id'], + }, + { + table: webhooks, + columns: [webhooks.createdById], + keys: ['webhooks.created_by_id'], + }, +] as const + +export const ANONYMOUS_SWEEP_PRINCIPAL_FK_POLICY = Object.freeze( + Object.fromEntries( + ANONYMOUS_SWEEP_BLOCKING_REFERENCE_GROUPS.flatMap((group) => + group.keys.map((key) => [key, 'blocks_sweep'] as const) + ) + ) +) as Readonly> diff --git a/apps/web/src/lib/server/domains/subscriptions/__tests__/subscription.identity-locks.test.ts b/apps/web/src/lib/server/domains/subscriptions/__tests__/subscription.identity-locks.test.ts new file mode 100644 index 000000000..f3a9f8356 --- /dev/null +++ b/apps/web/src/lib/server/domains/subscriptions/__tests__/subscription.identity-locks.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PostId, PrincipalId } from '@quackback/ids' + +const state = vi.hoisted(() => ({ + events: [] as string[], + lockedPrincipalId: 'principal_actor' as string | null, + existingPreferences: { + emailStatusChange: true, + emailNewComment: true, + emailMuted: false, + } as Record | null, + returnedPreferences: { + emailStatusChange: false, + emailNewComment: true, + emailMuted: true, + }, + setCalls: [] as Array<{ table: string; values: Record }>, +})) + +vi.mock('@/lib/server/logger', () => ({ + logger: { + child: () => ({ debug: vi.fn() }), + }, +})) + +vi.mock('@/lib/server/db', () => { + type TestTable = { tableName: string; [column: string]: unknown } + + const principal: TestTable = { + tableName: 'principal', + id: 'principal.id', + } + const postSubscriptions: TestTable = { + tableName: 'postSubscriptions', + principalId: 'postSubscriptions.principalId', + postId: 'postSubscriptions.postId', + } + const notificationPreferences: TestTable = { + tableName: 'notificationPreferences', + principalId: 'notificationPreferences.principalId', + } + const unsubscribeTokens: TestTable = { + tableName: 'unsubscribeTokens', + id: 'unsubscribeTokens.id', + } + const posts: TestTable = { + tableName: 'posts', + id: 'posts.id', + } + const user: TestTable = { + tableName: 'user', + id: 'user.id', + } + + const createTransaction = () => ({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => ({ + for: vi.fn(async () => { + state.events.push('lock:principal') + return state.lockedPrincipalId ? [{ principalId: state.lockedPrincipalId }] : [] + }), + })), + })), + })), + })), + delete: vi.fn((table: TestTable) => ({ + where: vi.fn(async () => { + state.events.push(`delete:${table.tableName}`) + }), + })), + update: vi.fn((table: TestTable) => ({ + set: vi.fn((values: Record) => { + state.setCalls.push({ table: table.tableName, values }) + return { + where: vi.fn(() => { + state.events.push(`update:${table.tableName}`) + if (table === notificationPreferences) { + return { + returning: vi.fn(async () => [state.returnedPreferences]), + } + } + return Promise.resolve() + }), + } + }), + })), + insert: vi.fn((table: TestTable) => ({ + values: vi.fn((values: Record) => { + state.setCalls.push({ table: table.tableName, values }) + state.events.push(`insert:${table.tableName}`) + return { + returning: vi.fn(async () => [state.returnedPreferences]), + } + }), + })), + query: { + notificationPreferences: { + findFirst: vi.fn(async () => { + state.events.push('read:notificationPreferences') + return state.existingPreferences + }), + }, + }, + }) + + return { + db: { + transaction: vi.fn(async (callback: (tx: ReturnType) => unknown) => + callback(createTransaction()) + ), + }, + eq: vi.fn(() => ({})), + and: vi.fn(() => ({})), + gt: vi.fn(() => ({})), + inArray: vi.fn(() => ({})), + isNull: vi.fn(() => ({})), + isNotNull: vi.fn(() => ({})), + postSubscriptions, + notificationPreferences, + unsubscribeTokens, + posts, + principal, + user, + } +}) + +import { + IdentityScopedPreferenceChangedError, + unsubscribeFromPost, + updateNotificationPreferences, + updateSubscriptionLevel, +} from '../subscription.service' + +const ACTOR_ID = 'principal_actor' as PrincipalId +const POST_ID = 'post_report' as PostId + +describe('identity-scoped subscription writers', () => { + beforeEach(() => { + state.events.length = 0 + state.setCalls.length = 0 + state.lockedPrincipalId = ACTOR_ID + state.existingPreferences = { + emailStatusChange: true, + emailNewComment: true, + emailMuted: false, + } + }) + + it('locks the actor before unsubscribeFromPost deletes the subscription', async () => { + await unsubscribeFromPost(ACTOR_ID, POST_ID) + + expect(state.events).toEqual(['lock:principal', 'delete:postSubscriptions']) + }) + + it('locks the actor before updateSubscriptionLevel writes status-only flags', async () => { + await updateSubscriptionLevel(ACTOR_ID, POST_ID, 'status_only') + + expect(state.events).toEqual(['lock:principal', 'update:postSubscriptions']) + expect(state.setCalls).toEqual([ + { + table: 'postSubscriptions', + values: expect.objectContaining({ + notifyComments: false, + notifyStatusChanges: true, + }), + }, + ]) + }) + + it('locks the actor before updateSubscriptionLevel deletes a none subscription', async () => { + await updateSubscriptionLevel(ACTOR_ID, POST_ID, 'none') + + expect(state.events).toEqual(['lock:principal', 'delete:postSubscriptions']) + }) + + it('locks the actor before updateNotificationPreferences reads and updates', async () => { + await expect( + updateNotificationPreferences(ACTOR_ID, { + emailStatusChange: false, + emailMuted: true, + }) + ).resolves.toEqual(state.returnedPreferences) + + expect(state.events).toEqual([ + 'lock:principal', + 'read:notificationPreferences', + 'update:notificationPreferences', + ]) + }) + + it.each([ + ['unsubscribeFromPost', () => unsubscribeFromPost(ACTOR_ID, POST_ID)], + ['updateSubscriptionLevel', () => updateSubscriptionLevel(ACTOR_ID, POST_ID, 'status_only')], + [ + 'updateNotificationPreferences', + () => updateNotificationPreferences(ACTOR_ID, { emailMuted: true }), + ], + ])( + 'fails %s closed without touching child rows when an identity merge already consumed the actor', + async (_name, write) => { + state.lockedPrincipalId = null + + await expect(write()).rejects.toBeInstanceOf(IdentityScopedPreferenceChangedError) + expect(state.events).toEqual(['lock:principal']) + expect(state.setCalls).toEqual([]) + } + ) +}) diff --git a/apps/web/src/lib/server/domains/subscriptions/subscription.service.ts b/apps/web/src/lib/server/domains/subscriptions/subscription.service.ts index 06065b4fd..b53fa273d 100644 --- a/apps/web/src/lib/server/domains/subscriptions/subscription.service.ts +++ b/apps/web/src/lib/server/domains/subscriptions/subscription.service.ts @@ -20,6 +20,7 @@ import { db, eq, and, + gt, inArray, isNull, isNotNull, @@ -29,6 +30,7 @@ import { posts, principal, user, + type Database, type Transaction, } from '@/lib/server/db' import type { PrincipalId, PostId } from '@quackback/ids' @@ -61,6 +63,28 @@ interface SubscribeOptions { level?: SubscriptionLevel } +export class IdentityScopedPreferenceChangedError extends Error { + constructor() { + super('Identity changed while updating notification preferences') + this.name = 'IdentityScopedPreferenceChangedError' + } +} + +async function lockPreferencePrincipal(tx: Transaction, principalId: PrincipalId): Promise { + const locked = await tx + .select({ principalId: principal.id }) + .from(principal) + .where(eq(principal.id, principalId)) + .limit(1) + .for('update') + if (locked[0]?.principalId !== principalId) { + // A concurrent identity merge already consumed this source actor. Failing + // explicitly lets the caller re-resolve its bearer instead of reporting a + // false success against a deleted principal. + throw new IdentityScopedPreferenceChangedError() + } +} + /** * Subscribe a member to a post (idempotent - won't duplicate) * @@ -75,10 +99,7 @@ export async function subscribeToPost( reason: SubscriptionReason, options?: SubscribeOptions ): Promise { - log.debug( - { post_id: postId, principal_id: principalId, reason }, - 'subscribe to post' - ) + log.debug({ post_id: postId, principal_id: principalId, reason }, 'subscribe to post') const executor = options?.tx ?? db const level = options?.level ?? 'all' @@ -101,15 +122,15 @@ export async function subscribeToPost( * Unsubscribe a member from a post */ export async function unsubscribeFromPost(principalId: PrincipalId, postId: PostId): Promise { - log.debug( - { post_id: postId, principal_id: principalId }, - 'unsubscribe from post' - ) - await db - .delete(postSubscriptions) - .where( - and(eq(postSubscriptions.principalId, principalId), eq(postSubscriptions.postId, postId)) - ) + log.debug({ post_id: postId, principal_id: principalId }, 'unsubscribe from post') + await db.transaction(async (tx) => { + await lockPreferencePrincipal(tx, principalId) + await tx + .delete(postSubscriptions) + .where( + and(eq(postSubscriptions.principalId, principalId), eq(postSubscriptions.postId, postId)) + ) + }) } /** @@ -120,28 +141,32 @@ export async function updateSubscriptionLevel( postId: PostId, level: SubscriptionLevel ): Promise { - log.debug( - { post_id: postId, principal_id: principalId, level }, - 'update subscription level' - ) - if (level === 'none') { - await unsubscribeFromPost(principalId, postId) - return - } + log.debug({ post_id: postId, principal_id: principalId, level }, 'update subscription level') + await db.transaction(async (tx) => { + await lockPreferencePrincipal(tx, principalId) + if (level === 'none') { + await tx + .delete(postSubscriptions) + .where( + and(eq(postSubscriptions.principalId, principalId), eq(postSubscriptions.postId, postId)) + ) + return + } - const notifyComments = level === 'all' - const notifyStatusChanges = true // Both 'all' and 'status_only' get status changes + const notifyComments = level === 'all' + const notifyStatusChanges = true // Both 'all' and 'status_only' get status changes - await db - .update(postSubscriptions) - .set({ - notifyComments, - notifyStatusChanges, - updatedAt: new Date(), - }) - .where( - and(eq(postSubscriptions.principalId, principalId), eq(postSubscriptions.postId, postId)) - ) + await tx + .update(postSubscriptions) + .set({ + notifyComments, + notifyStatusChanges, + updatedAt: new Date(), + }) + .where( + and(eq(postSubscriptions.principalId, principalId), eq(postSubscriptions.postId, postId)) + ) + }) } /** @@ -157,10 +182,7 @@ export async function getSubscriptionStatus( reason: SubscriptionReason | null level: SubscriptionLevel }> { - log.debug( - { post_id: postId, principal_id: principalId }, - 'get subscription status' - ) + log.debug({ post_id: postId, principal_id: principalId }, 'get subscription status') const subscription = await db.query.postSubscriptions.findFirst({ where: and( eq(postSubscriptions.principalId, principalId), @@ -200,10 +222,7 @@ export async function getSubscribersForEvent( postId: PostId, eventType: NotificationEventType ): Promise { - log.debug( - { post_id: postId, event_type: eventType }, - 'get subscribers for event' - ) + log.debug({ post_id: postId, event_type: eventType }, 'get subscribers for event') // Determine which column to filter by const notifyColumn = eventType === 'comment' @@ -312,10 +331,7 @@ const DEFAULT_NOTIFICATION_PREFS: NotificationPreferencesData = { export async function batchGetNotificationPreferences( principalIds: PrincipalId[] ): Promise> { - log.debug( - { count: principalIds.length }, - 'batch get notification preferences' - ) + log.debug({ count: principalIds.length }, 'batch get notification preferences') if (principalIds.length === 0) return new Map() const rows = await db @@ -357,27 +373,30 @@ export async function updateNotificationPreferences( preferences: Partial ): Promise { log.debug({ principal_id: principalId }, 'update notification preferences') - const existing = await db.query.notificationPreferences.findFirst({ - where: eq(notificationPreferences.principalId, principalId), - }) - - if (existing) { - const [updated] = await db - .update(notificationPreferences) - .set({ - ...preferences, - updatedAt: new Date(), - }) - .where(eq(notificationPreferences.principalId, principalId)) - .returning() + return db.transaction(async (tx) => { + await lockPreferencePrincipal(tx, principalId) + const existing = await tx.query.notificationPreferences.findFirst({ + where: eq(notificationPreferences.principalId, principalId), + }) - return { - emailStatusChange: updated.emailStatusChange, - emailNewComment: updated.emailNewComment, - emailMuted: updated.emailMuted, + if (existing) { + const [updated] = await tx + .update(notificationPreferences) + .set({ + ...preferences, + updatedAt: new Date(), + }) + .where(eq(notificationPreferences.principalId, principalId)) + .returning() + + return { + emailStatusChange: updated.emailStatusChange, + emailNewComment: updated.emailNewComment, + emailMuted: updated.emailMuted, + } } - } else { - const [created] = await db + + const [created] = await tx .insert(notificationPreferences) .values({ principalId, @@ -392,7 +411,7 @@ export async function updateNotificationPreferences( emailNewComment: created.emailNewComment, emailMuted: created.emailMuted, } - } + }) } /** @@ -403,10 +422,7 @@ export async function generateUnsubscribeToken( postId: PostId | null, action: 'unsubscribe_post' | 'unsubscribe_all' ): Promise { - log.debug( - { principal_id: principalId, post_id: postId, action }, - 'generate unsubscribe token' - ) + log.debug({ principal_id: principalId, post_id: postId, action }, 'generate unsubscribe token') const token = randomUUID() const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days @@ -423,6 +439,17 @@ export async function generateUnsubscribeToken( export type UnsubscribeAction = 'unsubscribe_post' | 'unsubscribe_all' +type ProcessedUnsubscribe = { + action: string + principalId: PrincipalId + postId: PostId | null + post?: { title: string; boardSlug: string } +} + +type UnsubscribeAttempt = + | { kind: 'retry_owner' } + | { kind: 'done'; value: ProcessedUnsubscribe | null } + /** * Batch generate unsubscribe tokens for multiple principals. * Returns a Map of principalId -> token. @@ -452,73 +479,135 @@ export async function batchGenerateUnsubscribeTokens( * Process an unsubscribe token * Returns the action performed with post details for redirect, or null if token is invalid/expired */ -export async function processUnsubscribeToken(token: string): Promise<{ - action: string - principalId: PrincipalId - postId: PostId | null - post?: { title: string; boardSlug: string } -} | null> { - log.debug('process unsubscribe token') - const tokenRecord = await db.query.unsubscribeTokens.findFirst({ - where: eq(unsubscribeTokens.token, token), - }) - - if (!tokenRecord) { - return null - } +export async function processUnsubscribeToken(token: string): Promise { + return processUnsubscribeTokenWithDatabase(db, token) +} - if (tokenRecord.usedAt) { - return null // Already used +/** + * Database-injected implementation used by the PostgreSQL identity-race suite. + * + * Lock order is principal → unsubscribe token, matching identity merge's + * principal → child-row order. The unlocked first read is only an owner hint: + * a concurrent merge may reparent the token before either actor acquires the + * principal lock, so we re-read and retry once when ownership changes. + */ +export async function processUnsubscribeTokenWithDatabase( + database: Pick, + token: string +): Promise { + log.debug('process unsubscribe token') + const readOwnerHint = async (): Promise => { + const rows = await database + .select({ principalId: unsubscribeTokens.principalId }) + .from(unsubscribeTokens) + .where(eq(unsubscribeTokens.token, token)) + .limit(1) + return rows[0]?.principalId ?? null } - if (new Date() > tokenRecord.expiresAt) { - return null // Expired - } + let hintedPrincipalId = await readOwnerHint() + if (!hintedPrincipalId) return null + + for (let attemptNumber = 0; attemptNumber < 2; attemptNumber += 1) { + const now = new Date() + const outcome = await database.transaction(async (tx): Promise => { + const lockedPrincipals = await tx + .select({ principalId: principal.id }) + .from(principal) + .where(eq(principal.id, hintedPrincipalId!)) + .limit(1) + .for('update') + if (lockedPrincipals[0]?.principalId !== hintedPrincipalId) { + return { kind: 'retry_owner' } + } - // Mark as used - await db - .update(unsubscribeTokens) - .set({ usedAt: new Date() }) - .where(eq(unsubscribeTokens.id, tokenRecord.id)) + const tokenRows = await tx + .select({ + id: unsubscribeTokens.id, + principalId: unsubscribeTokens.principalId, + postId: unsubscribeTokens.postId, + action: unsubscribeTokens.action, + expiresAt: unsubscribeTokens.expiresAt, + usedAt: unsubscribeTokens.usedAt, + }) + .from(unsubscribeTokens) + .where(eq(unsubscribeTokens.token, token)) + .limit(1) + .for('update') + const tokenRecord = tokenRows[0] + if (!tokenRecord) return { kind: 'done', value: null } + if (tokenRecord.principalId !== hintedPrincipalId) return { kind: 'retry_owner' } + if ( + tokenRecord.usedAt || + tokenRecord.expiresAt <= now || + (tokenRecord.action !== 'unsubscribe_post' && tokenRecord.action !== 'unsubscribe_all') + ) { + return { kind: 'done', value: null } + } - // Get principal's organization for workspace context - const principalRecord = await db.query.principal.findFirst({ - where: eq(principal.id, tokenRecord.principalId), - }) + const marked = await tx + .update(unsubscribeTokens) + .set({ usedAt: now }) + .where( + and( + eq(unsubscribeTokens.id, tokenRecord.id), + eq(unsubscribeTokens.principalId, hintedPrincipalId), + isNull(unsubscribeTokens.usedAt), + gt(unsubscribeTokens.expiresAt, now) + ) + ) + .returning({ id: unsubscribeTokens.id }) + if (marked.length !== 1) return { kind: 'done', value: null } + + let postDetails: { title: string; boardSlug: string } | undefined + if (tokenRecord.postId) { + const post = await tx.query.posts.findFirst({ + where: eq(posts.id, tokenRecord.postId), + columns: { title: true }, + with: { board: { columns: { slug: true } } }, + }) + if (post) { + postDetails = { title: post.title, boardSlug: post.board.slug } + } + } - if (!principalRecord) { - return null - } + if (tokenRecord.action === 'unsubscribe_post' && tokenRecord.postId) { + await tx + .delete(postSubscriptions) + .where( + and( + eq(postSubscriptions.principalId, tokenRecord.principalId), + eq(postSubscriptions.postId, tokenRecord.postId) + ) + ) + } else if (tokenRecord.action === 'unsubscribe_all') { + await tx + .insert(notificationPreferences) + .values({ + principalId: tokenRecord.principalId, + emailMuted: true, + }) + .onConflictDoUpdate({ + target: notificationPreferences.principalId, + set: { emailMuted: true, updatedAt: now }, + }) + } - // Get post details if postId exists - let postDetails: { title: string; boardSlug: string } | undefined - if (tokenRecord.postId) { - const post = await db.query.posts.findFirst({ - where: eq(posts.id, tokenRecord.postId), - columns: { title: true }, - with: { board: { columns: { slug: true } } }, + return { + kind: 'done', + value: { + action: tokenRecord.action, + principalId: tokenRecord.principalId, + postId: tokenRecord.postId, + post: postDetails, + }, + } }) - if (post) { - postDetails = { title: post.title, boardSlug: post.board.slug } - } - } - // Perform the action - switch (tokenRecord.action) { - case 'unsubscribe_post': - if (tokenRecord.postId) { - await unsubscribeFromPost(tokenRecord.principalId, tokenRecord.postId) - } - break - case 'unsubscribe_all': - await updateNotificationPreferences(tokenRecord.principalId, { emailMuted: true }) - break + if (outcome.kind === 'done') return outcome.value + hintedPrincipalId = await readOwnerHint() + if (!hintedPrincipalId) return null } - return { - action: tokenRecord.action, - principalId: tokenRecord.principalId, - postId: tokenRecord.postId, - post: postDetails, - } + return null } 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..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 @@ -17,26 +17,63 @@ 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(), + authorizeHostSubmit: vi.fn(), receiptTransaction: vi.fn(), + receipts: new Map>(), eqCalls: [] as Array<[unknown, unknown]>, principalField: Symbol('principal_id'), clientField: Symbol('client_submission_id'), + serverOnlyHandlers: [] as unknown[], })) 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: () => { @@ -46,6 +83,10 @@ vi.mock('@tanstack/react-start', () => ({ } return chain }, + createServerOnlyFn: (handler: T): T => { + state.serverOnlyHandlers.push(handler) + return handler + }, })) vi.mock('@tanstack/react-start/server', () => ({ @@ -154,7 +195,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 +212,7 @@ vi.mock('../auth-helpers', () => ({ }), policyActorFromAuth: async () => ({ principalId: state.principal, - principalType: 'user', + principalType: state.principalType, role: 'user', segmentIds: new Set(), }), @@ -171,18 +220,14 @@ 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/host-submit-authorization', () => ({ + authorizeHostSubmitMutation: (...args: unknown[]) => state.authorizeHostSubmit(...args), })) vi.mock('@/lib/server/domains/bug-reports/receipt.store', () => ({ @@ -211,6 +256,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), @@ -237,8 +286,17 @@ vi.mock('@/lib/server/logger', () => ({ import { createBugReportReplyFn as createBugReportReplyHandler, listMyBugReportsFn as listMyBugReportsHandler, + submitConfiguredBugReportHandler, 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 +321,7 @@ describe('authenticated bug-report receipt handlers', () => { const logicHandlerContracts = [ { name: 'submitBugReportHandler', + expectedIdentifiers: 3, serverPath: sourceSlice( 'export const submitBugReportFn', 'async function listMyBugReportsHandler' @@ -270,6 +329,7 @@ describe('authenticated bug-report receipt handlers', () => { }, { name: 'listMyBugReportsHandler', + expectedIdentifiers: 2, serverPath: sourceSlice( 'export const listMyBugReportsFn', 'async function resolveOwnedBugReport' @@ -277,23 +337,32 @@ 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.authorizeHostSubmit.mockReset().mockResolvedValue(undefined) state.createComment.mockReset().mockResolvedValue({}) state.completeEffects.mockReset().mockRejectedValue(new Error('queue unavailable')) state.processEvent.mockReset() @@ -301,6 +370,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 +400,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 +579,263 @@ describe('authenticated bug-report receipt handlers', () => { expect(state.createComment).not.toHaveBeenCalled() }) }) + +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' + 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 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({ + ...parsedHostInput, + [forbidden]: 'forbidden', + }).success + ).toBe(false) + } + expect( + hostBugReportServerInputSchema.safeParse({ + clientSubmissionId: CLIENT_ID, + summary: 'Bearer alone', + impact: '', + }).success + ).toBe(false) + + const result = await submitConfiguredHostBugReport(parsedHostInput) + 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 does nothing', + 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', + }) + + 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) + 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 first = await submitConfiguredHostBugReport(hostInput()) + state.principal = 'principal_B' + 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) + 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( + hostInput({ + summary: 'Anonymous', + impact: '', + }) + ) + ).resolves.toEqual({ accepted: false, reason: 'unauthorized' }) + + state.principalType = 'user' + state.portalOutcome = { + kind: 'decision', + decision: { granted: false, reason: 'unauthenticated' }, + } + await expect( + submitConfiguredHostBugReport( + hostInput({ + summary: 'Expired', + impact: '', + }) + ) + ).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( + hostInput({ + summary: 'Disabled', + impact: '', + }) + ) + ).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + + state.getPublicBoardBySlug.mockResolvedValueOnce(null) + await expect( + submitConfiguredHostBugReport( + hostInput({ + summary: 'Missing board', + impact: '', + }) + ) + ).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + + state.canCreatePost.mockReturnValueOnce({ allowed: false, reason: 'Private policy detail' }) + await expect( + submitConfiguredHostBugReport( + hostInput({ + summary: 'Ineligible board', + impact: '', + }) + ) + ).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( + hostInput({ summary: 'Safe title', impact: '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..31e25ffb3 --- /dev/null +++ b/apps/web/src/lib/server/functions/bug-report-host-submit.ts @@ -0,0 +1,69 @@ +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 { + mapHostSubmitText, + 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({ + contract: z.literal('iplaycafe.quackback.report-submit/1'), + requestId: z.string().regex(UUID_V4), + clientSubmissionId: z.string().regex(UUID_V4), + 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() + +export async function submitConfiguredHostBugReport( + data: z.infer +): Promise { + try { + const { getRequestHeaders } = await import('@tanstack/react-start/server') + const receipt = parseHostSubmitReceipt( + await submitConfiguredBugReportHandler({ + data: { + ...data, + ...mapHostSubmitText(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 }) => { + 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 b454ab07b..16caec464 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 { @@ -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,19 @@ 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 { + 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 @@ -84,28 +95,94 @@ 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 | HostSubmitAuthorizationData headers: Headers + configuredHost?: boolean }): Promise { - const auth = await requireBugReportPrincipal() + const failures = configuredHost ? hostBugReportFailures : legacyBugReportFailures + 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) + 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 +208,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 +256,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 +282,16 @@ async function submitBugReportHandler({ return result.receipt } +export const submitConfiguredBugReportHandler = createServerOnlyFn( + async ({ + data, + headers, + }: { + data: HostSubmitAuthorizationData + headers: Headers + }): Promise => 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/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__/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/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..90a62a0f7 --- /dev/null +++ b/apps/web/src/lib/shared/bugreport/__tests__/host-submit-contract.test.ts @@ -0,0 +1,318 @@ +import contract from '../../../../../../../docs/fixtures/quackback-report-submit-contract-v1.json' +import { describe, expect, it } from 'vitest' +import { + computeHostSubmitReportDigest, + 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', + hostSubmitAssertion: `e30.e30.${'s'.repeat(43)}`, + ...data, + }, + } +} + +describe('host submit report digest', () => { + it('classifies the content-derived report digest as telemetry-forbidden', () => { + expect(contract.telemetryForbiddenFields).toContain('reportDigest') + }) + + 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) + + 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('accepts an empty optional impact', () => { + expect(parseHostSubmitRequestMessage(request({ impact: '' }))).toEqual({ + ...request().data, + impact: '', + }) + }) + + 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 + 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', + }) + expect(parseHostSubmitReceipt({ ...RECEIPT, fixedInRelease: '' })).toEqual({ + ...RECEIPT, + fixedInRelease: '', + }) + }) + + 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 \t button\n\ndoes nothing ', + impact: ' Cannot finish checkout ', + }) + ).toEqual({ + clientSubmissionId: SUBMISSION_ID, + title: 'Save button', + content: 'Save \t button\n\ndoes nothing\n\nImpact:\nCannot finish checkout', + }) + }) + + 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).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`) + }) + + 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 new file mode 100644 index 000000000..271c7e676 --- /dev/null +++ b/apps/web/src/lib/shared/bugreport/host-submit-contract.ts @@ -0,0 +1,485 @@ +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 + hostSubmitAssertion: string +} + +export type HostSubmitRequestData = { + contract: 'iplaycafe.quackback.report-submit/1' + requestId: string + clientSubmissionId: string + summary: string + impact: string + hostSubmitAssertion: 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_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', + '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 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 + + 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', + 'hostSubmitAssertion', + ]) + if ( + !record || + !isUuidV4(record.clientSubmissionId) || + !isBoundedText(record.summary, 2_000) || + typeof record.impact !== 'string' || + record.impact.length > 1_000 || + record.summary !== record.summary.trim() || + record.impact !== record.impact.trim() || + typeof record.hostSubmitAssertion !== 'string' || + record.hostSubmitAssertion.length > 4_096 || + !HOST_SUBMIT_ASSERTION.test(record.hostSubmitAssertion) + ) { + return null + } + return { + clientSubmissionId: record.clientSubmissionId, + summary: record.summary, + impact: record.impact, + hostSubmitAssertion: record.hostSubmitAssertion, + } +} + +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', + 'hostSubmitAssertion', + ]) + if (!data || data.contract !== CONTRACT || !isUuidV4(data.requestId)) return null + + const input = parseHostSubmitInput({ + clientSubmissionId: data.clientSubmissionId, + summary: data.summary, + impact: data.impact, + hostSubmitAssertion: data.hostSubmitAssertion, + }) + if (!input) return null + return { + contract: CONTRACT, + requestId: data.requestId, + ...input, + } +} + +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 + 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') + ) { + 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: Pick +): { + 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().replace(/\s+/gu, ' ')) + return { + clientSubmissionId: input.clientSubmissionId, + title, + content: impact ? `${summary}\n\nImpact:\n${impact}` : summary, + } +} diff --git a/apps/web/src/lib/shared/widget/types.ts b/apps/web/src/lib/shared/widget/types.ts index 48d20fedc..2a0bbf23c 100644 --- a/apps/web/src/lib/shared/widget/types.ts +++ b/apps/web/src/lib/shared/widget/types.ts @@ -166,6 +166,13 @@ export interface WidgetInboundMessages { } } +/** Correlation lives beside the actor payload so it can never reach identify APIs. */ +export interface WidgetIdentifyEnvelope { + type: 'quackback:identify' + data: WidgetInboundMessages['quackback:identify'] + identityRequestId?: string +} + // ---- Iframe -> SDK Messages ---- export interface WidgetOutboundMessages { @@ -176,9 +183,11 @@ export interface WidgetOutboundMessages { success: boolean user: { id: string; name: string; email: string; avatarUrl: string | null } | null error?: string + identityRequestId?: string } 'quackback:auth-change': { user: { id: string; name: string; email: string; avatarUrl: string | null } | null + identityRequestId?: string } 'quackback:event': { name: WidgetEventName 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..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 @@ -118,6 +121,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 +158,7 @@ describe('GET /api/v1/help-center/categories', () => { icon: null, parentId: null, isPublic: true, + adminOnly: false, position: 1, articleCount: 0, publishedArticleCount: 0, @@ -193,6 +198,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'), @@ -216,7 +222,8 @@ describe('POST /api/v1/help-center/categories', () => { name: 'Billing', icon: '\u{1F4B0}', parentId: 'category_01jk0000000000000000000001', - }) + }), + { includeAdminOnly: true } ) }) @@ -231,6 +238,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'), @@ -259,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', @@ -288,6 +294,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 +365,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'), @@ -379,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 } ) }) @@ -395,6 +404,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'), @@ -418,7 +428,8 @@ describe('PATCH /api/v1/help-center/categories/:categoryId', () => { 'category_01jk0000000000000000000001', expect.objectContaining({ parentId: 'category_01jk0000000000000000000002', - }) + }), + { includeAdminOnly: true } ) }) @@ -434,6 +445,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'), @@ -455,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', @@ -495,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', 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/__tests__/identify-external-id.test.ts b/apps/web/src/routes/api/widget/__tests__/identify-external-id.test.ts index 9ad1739ee..689206bd2 100644 --- a/apps/web/src/routes/api/widget/__tests__/identify-external-id.test.ts +++ b/apps/web/src/routes/api/widget/__tests__/identify-external-id.test.ts @@ -14,6 +14,7 @@ const mockSessionFindFirst = vi.fn() const insertValues = vi.fn() const updateSet = vi.fn() const mockVerifyJWT = vi.fn() +const { legacyMerge } = vi.hoisted(() => ({ legacyMerge: vi.fn() })) vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), @@ -70,6 +71,7 @@ vi.mock('@/lib/server/storage/s3', () => ({ vi.mock('@/lib/server/auth/identify-merge', () => ({ resolveAndMergeAnonymousToken: vi.fn(), + resolveAndMergeLegacyAnonymousToken: (...args: unknown[]) => legacyMerge(...args), })) vi.mock('@/lib/server/widget/identity-token', () => ({ @@ -97,11 +99,13 @@ type RouteOpts = { } const { POST } = (Route as unknown as { options: RouteOpts }).options.server.handlers -function postIdentify(body: Record): Promise { +function postIdentify(body: Record, authorization?: string): Promise { + const headers = new Headers({ 'content-type': 'application/json' }) + if (authorization) headers.set('authorization', authorization) return POST({ request: new Request('http://test/api/widget/identify', { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers, body: JSON.stringify(body), }), }) @@ -193,4 +197,42 @@ describe('POST /api/widget/identify — external_id is untrusted on the unverifi const created = userInsertValues() expect(created?.externalId ?? null).toBeNull() }) + + it('preserves the shipped one-phase merge only when Bearer exactly owns previousToken', async () => { + mockUserFindFirst.mockResolvedValue(null) + mockPrincipalFindFirst.mockResolvedValue(null) + + const owned = await postIdentify( + { + id: 'legacy-client', + email: 'legacy@acme.com', + previousToken: 'legacy-anonymous-token', + }, + 'Bearer legacy-anonymous-token' + ) + + expect(owned.status).toBe(200) + expect(legacyMerge).toHaveBeenCalledWith({ + previousToken: 'legacy-anonymous-token', + targetPrincipalId: 'inserted', + targetDisplayName: 'User', + }) + }) + + it('never merges an unowned legacy body token', async () => { + mockUserFindFirst.mockResolvedValue(null) + mockPrincipalFindFirst.mockResolvedValue(null) + + const unowned = await postIdentify( + { + id: 'legacy-client', + email: 'legacy@acme.com', + previousToken: 'legacy-anonymous-token', + }, + 'Bearer different-token' + ) + + expect(unowned.status).toBe(200) + expect(legacyMerge).not.toHaveBeenCalled() + }) }) 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 new file mode 100644 index 000000000..70d8c2d63 --- /dev/null +++ b/apps/web/src/routes/api/widget/__tests__/identify-merge-route.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { resolveMerge, resolveTargetActor, checkMergeRate, checkActorCapacity } = vi.hoisted(() => ({ + resolveMerge: vi.fn(), + resolveTargetActor: vi.fn(), + checkMergeRate: vi.fn(), + checkActorCapacity: vi.fn(), +})) + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (options: unknown) => ({ options })), +})) + +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' + +type RouteOptions = { + server: { + handlers: { + POST: (args: { request: Request }) => Promise + } + } +} + +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, + authorization: string | null = 'Bearer identified-target-token' +): Promise { + const headers = new Headers({ 'content-type': 'application/json' }) + if (authorization !== null) headers.set('authorization', authorization) + return POST({ + request: new Request('https://feedback.example.test/api/widget/identify/merge', { + method: 'POST', + headers, + body: JSON.stringify(body), + }), + }) +} + +function streamingRequest( + chunks: string[], + contentLength?: string +): { response: Promise; cancel: ReturnType } { + const headers = new Headers({ + authorization: 'Bearer identified-target-token', + 'content-type': 'application/json', + }) + if (contentLength !== undefined) headers.set('content-length', contentLength) + const encoder = new TextEncoder() + let index = 0 + const cancel = vi.fn() + const body = new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index++]!)) + } else { + controller.close() + } + }, + cancel, + }) + return { + response: POST({ request: { headers, body } as Request }), + cancel, + } +} + +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)( + 'returns a bounded success for %s without reflecting either token', + async (status) => { + resolveMerge.mockResolvedValueOnce({ status }) + + const response = await request({ previousToken: 'anonymous-source-token' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status }) + 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) + } + ) + + it('rejects an invalid target bearer without calling the commit service', async () => { + const response = await request({ previousToken: 'anonymous-source-token' }, 'Basic forged') + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + error: { code: 'IDENTITY_MERGE_REJECTED' }, + }) + 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 () => { + checkMergeRate.mockResolvedValueOnce({ + allowed: false, + reason: 'unavailable', + retryAfter: 30, + }) + const unavailable = await request({ previousToken: 'anonymous-source-token' }) + expect(unavailable.status).toBe(503) + expect(unavailable.headers.get('retry-after')).toBe('30') + expect(await unavailable.json()).toEqual({ + error: { code: 'IDENTITY_MERGE_RETRYABLE' }, + }) + + checkMergeRate.mockResolvedValueOnce({ + allowed: false, + reason: 'limited', + retryAfter: 90, + }) + const limited = await request({ previousToken: 'anonymous-source-token' }) + expect(limited.status).toBe(429) + expect(limited.headers.get('retry-after')).toBe('90') + expect(await limited.json()).toEqual({ + 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([ + [{}, 400], + [{ previousToken: '' }, 400], + [{ previousToken: 'x'.repeat(513) }, 400], + [{ previousToken: 'valid', extra: true }, 400], + ] as const)('rejects malformed exact bodies', async (body, status) => { + const response = await request(body) + + expect(response.status).toBe(status) + expect(await response.json()).toEqual({ + error: { code: 'IDENTITY_MERGE_REJECTED' }, + }) + expect(resolveMerge).not.toHaveBeenCalled() + }) + + it('maps invalid target ownership and source conflict to bounded responses', async () => { + resolveMerge.mockResolvedValueOnce({ status: 'target_invalid' }) + const targetResponse = await request({ previousToken: 'anonymous-source-token' }) + expect(targetResponse.status).toBe(401) + expect(await targetResponse.json()).toEqual({ + error: { code: 'IDENTITY_MERGE_REJECTED' }, + }) + + resolveMerge.mockResolvedValueOnce({ status: 'conflict' }) + const conflictResponse = await request({ previousToken: 'anonymous-source-token' }) + expect(conflictResponse.status).toBe(409) + expect(await conflictResponse.json()).toEqual({ + error: { code: 'IDENTITY_MERGE_CONFLICT' }, + }) + }) + + it('fails closed and retryably without serializing database or token material', async () => { + resolveMerge.mockRejectedValueOnce( + new Error('query failed with anonymous-source-token and identified-target-token') + ) + + const response = await request({ previousToken: 'anonymous-source-token' }) + const serialized = JSON.stringify(await response.json()) + + expect(response.status).toBe(503) + expect(serialized).toBe('{"error":{"code":"IDENTITY_MERGE_RETRYABLE"}}') + expect(serialized).not.toContain('anonymous-source-token') + expect(serialized).not.toContain('identified-target-token') + expect(serialized).not.toContain('query failed') + }) + + it('rejects an oversized declared Content-Length before reading the body', async () => { + const streamed = streamingRequest( + ['{"previousToken":"never-read"}'], + String(IDENTITY_MERGE_BODY_MAX_BYTES + 1) + ) + + const response = await streamed.response + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: { code: 'IDENTITY_MERGE_REJECTED' }, + }) + expect(streamed.cancel).toHaveBeenCalledTimes(1) + expect(resolveMerge).not.toHaveBeenCalled() + }) + + it('cancels a chunked body as soon as the streamed byte cap is exceeded', async () => { + const streamed = streamingRequest([ + 'x'.repeat(IDENTITY_MERGE_BODY_MAX_BYTES), + 'x', + 'never-read', + ]) + + const response = await streamed.response + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: { code: 'IDENTITY_MERGE_REJECTED' }, + }) + expect(streamed.cancel).toHaveBeenCalledTimes(1) + expect(resolveMerge).not.toHaveBeenCalled() + }) + + it('enforces the streamed byte cap when Content-Length lies short', async () => { + const streamed = streamingRequest(['x'.repeat(IDENTITY_MERGE_BODY_MAX_BYTES), 'x'], '16') + + const response = await streamed.response + + expect(response.status).toBe(413) + expect(streamed.cancel).toHaveBeenCalledTimes(1) + expect(resolveMerge).not.toHaveBeenCalled() + }) +}) 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 29448ac98..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 @@ -86,6 +86,7 @@ vi.mock('@/lib/server/storage/s3', () => ({ vi.mock('@/lib/server/auth/identify-merge', () => ({ resolveAndMergeAnonymousToken: vi.fn(), + resolveAndMergeLegacyAnonymousToken: vi.fn(), })) vi.mock('@/lib/server/widget/identity-token', () => ({ @@ -102,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: { @@ -127,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() @@ -306,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/__tests__/widget-identify-attributes.test.ts b/apps/web/src/routes/api/widget/__tests__/widget-identify-attributes.test.ts index 150c108c1..72fca95f8 100644 --- a/apps/web/src/routes/api/widget/__tests__/widget-identify-attributes.test.ts +++ b/apps/web/src/routes/api/widget/__tests__/widget-identify-attributes.test.ts @@ -32,6 +32,7 @@ vi.mock('@/lib/server/storage/s3', () => ({ })) vi.mock('@/lib/server/auth/identify-merge', () => ({ resolveAndMergeAnonymousToken: vi.fn(), + resolveAndMergeLegacyAnonymousToken: vi.fn(), })) vi.mock('@quackback/ids', () => ({ generateId: vi.fn(() => 'mock_id'), 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/apps/web/src/routes/api/widget/identify.merge.ts b/apps/web/src/routes/api/widget/identify.merge.ts new file mode 100644 index 000000000..cce0acccf --- /dev/null +++ b/apps/web/src/routes/api/widget/identify.merge.ts @@ -0,0 +1,95 @@ +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' +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 +const mergeSchema = z + .object({ + previousToken: z.string().min(1).max(TOKEN_MAX_LENGTH), + }) + .strict() + +function error(code: string, status: number, retryAfter?: number): Response { + return Response.json( + { error: { code } }, + { + status, + headers: retryAfter ? { 'Retry-After': String(retryAfter) } : undefined, + } + ) +} + +function readTargetBearer(request: Request): string | null { + const header = request.headers.get('authorization') + if (!header?.startsWith('Bearer ')) return null + const token = header.slice(7) + if (!token || token.length > TOKEN_MAX_LENGTH || /\s/.test(token)) return null + return token +} + +export const Route = createFileRoute('/api/widget/identify/merge')({ + server: { + handlers: { + POST: async ({ request }) => { + const targetToken = readTargetBearer(request) + if (!targetToken) return error('IDENTITY_MERGE_REJECTED', 401) + + 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) + if (!parsed.success) return error('IDENTITY_MERGE_REJECTED', 400) + + const rate = await checkIdentityMergeRateLimit(getClientIp(request), targetToken) + if (!rate.allowed) { + return rate.reason === 'limited' + ? error('IDENTITY_MERGE_RATE_LIMITED', 429, rate.retryAfter) + : error('IDENTITY_MERGE_RETRYABLE', 503, rate.retryAfter) + } + + 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': + case 'already_merged': + case 'not_applicable': + return Response.json({ status: result.status }) + case 'target_invalid': + return error('IDENTITY_MERGE_REJECTED', 401) + case 'conflict': + return error('IDENTITY_MERGE_CONFLICT', 409) + } + } catch { + // Never log the request, tokens, or driver error: DB exceptions may + // retain bound query parameters. The client receives one bounded, + // retryable failure and keeps its private candidate. + return error('IDENTITY_MERGE_RETRYABLE', 503) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/widget/identify.ts b/apps/web/src/routes/api/widget/identify.ts index d6caeeddc..f95214c30 100644 --- a/apps/web/src/routes/api/widget/identify.ts +++ b/apps/web/src/routes/api/widget/identify.ts @@ -18,7 +18,7 @@ import { import { getWidgetConfig, getWidgetSecret } from '@/lib/server/domains/settings/settings.widget' import { getAllUserVotedPostIds } from '@/lib/server/domains/posts/post.public' import { getPublicUrlOrNull } from '@/lib/server/storage/s3' -import { resolveAndMergeAnonymousToken } from '@/lib/server/auth/identify-merge' +import { resolveAndMergeLegacyAnonymousToken } from '@/lib/server/auth/identify-merge' import { verifyHS256JWT } from '@/lib/server/widget/identity-token' import { validateAndCoerceAttributes, @@ -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 @@ -39,7 +41,8 @@ const identifySchema = z name: z.string().optional(), avatarURL: z.string().optional(), avatarUrl: z.string().optional(), - // Anonymous→identified merge: previous widget session token + // Reserved for already-shipped one-phase clients. New clients omit this + // field during authentication and use the dedicated merge commit route. previousToken: z.string().optional(), }) .passthrough() @@ -59,6 +62,7 @@ export const RESERVED_JWT_CLAIMS = new Set([ 'iss', 'aud', 'jti', + 'previousToken', ]) /** Extract non-reserved claims from a verified JWT payload for attribute processing */ @@ -162,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 @@ -447,14 +456,16 @@ export const Route = createFileRoute('/api/widget/identify')({ desiredSegmentIds: resolvedSegmentIds, }) - // If the widget had a previous anonymous session, merge its activity. - // Ownership check: the caller must send the previousToken as both a body - // field AND the Authorization Bearer header to prove they own the session. + // Compatibility for already-shipped one-phase clients. Ownership is + // proven only when the old token is present in both body and Bearer. + // New correlated host attempts omit both and use the dedicated atomic + // commit after their exact-current client gate. if (body.previousToken) { - const authHeader = request.headers.get('authorization') ?? '' - const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null - if (bearerToken && bearerToken === body.previousToken) { - await resolveAndMergeAnonymousToken({ + const authorization = request.headers.get('authorization') + const bearer = + authorization?.startsWith('Bearer ') === true ? authorization.slice(7) : null + if (bearer && bearer === body.previousToken) { + await resolveAndMergeLegacyAnonymousToken({ previousToken: body.previousToken, targetPrincipalId: principalId, targetDisplayName: userRecord.name || 'User', @@ -462,8 +473,7 @@ export const Route = createFileRoute('/api/widget/identify')({ } } - // Find/create session and fetch voted posts in parallel - // (voted posts include any merged anonymous votes) + // Find/create the target session and fetch votes after any legacy merge. const [sessionInfo, votedPostIdSet] = await Promise.all([ findOrCreateSession(userId, request), getAllUserVotedPostIds(principalId), diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index e71ad6e01..31d4710b5 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -29,7 +29,7 @@ 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' import { portalQueries } from '@/lib/client/queries/portal' @@ -39,6 +39,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). */ @@ -212,7 +216,17 @@ function WidgetPage() { portalAccess, portalOrigin, } = Route.useLoaderData() - const { ensureSession, sessionVersion, emitEvent, isIdentified, hmacRequired } = useWidgetAuth() + const { + ensureSession, + sessionVersion, + emitEvent, + isIdentified, + hmacRequired, + hostIdentityVersion, + currentHostParentBinding, + resolveHostParentBinding, + sendPrivilegedHostMessage, + } = useWidgetAuth() // Bug-report capture wiring: `undefined` means the user has not requested a // screenshot, `null` means a requested capture is pending, and an object is @@ -358,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 @@ -432,6 +449,17 @@ function WidgetPage() { return () => window.clearTimeout(timer) }, [view, activeBugReportFlowId, captureData, captureTimedOut, emitEvent]) + const observedHostIdentityVersionRef = useRef(hostIdentityVersion) + useEffect(() => { + if (observedHostIdentityVersionRef.current === hostIdentityVersion) return + observedHostIdentityVersionRef.current = hostIdentityVersion + const flowId = activeBugReportFlowIdRef.current + updatePendingBugReportOpen(null) + if (flowId) clearBugReportFlow(flowId) + setActiveTab(resolveInitialTab(tabs)) + setView(resolveInitialView(tabs)) + }, [hostIdentityVersion, tabs, clearBugReportFlow, updatePendingBugReportOpen]) + // Listen for quackback:open + quackback:capture-result messages from the SDK useEffect(() => { function handleMessage(event: MessageEvent) { @@ -439,9 +467,10 @@ function WidgetPage() { const msg = event.data if (!msg || typeof msg !== 'object') return - // Every host-driven actor change invalidates the current report before - // the auth provider can install the new token. This also protects older - // SDKs that do not send the explicit reset message first. + // 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) @@ -521,6 +550,28 @@ function WidgetPage() { return () => window.removeEventListener('message', handleMessage) }, [tabs, openChat, clearBugReportFlow, updatePendingBugReportOpen]) + useEffect(() => { + const authorizeOrigin = (candidateOrigin: string) => + authorizeBugReportHostOriginFn({ + data: { candidateOrigin }, + }) + + const disposeHostSubmit = installBugReportHostSubmitBridge({ + authorizeOrigin, + currentBinding: currentHostParentBinding, + resolveBinding: resolveHostParentBinding, + submit: (input) => + submitHostBugReportFn({ + data: input, + headers: getWidgetAuthHeaders(), + }), + }) + + return () => { + disposeHostSubmit() + } + }, [currentHostParentBinding, resolveHostParentBinding]) + const handlePostCreated = useCallback((post: SuccessPost) => { setCreatedPosts((prev) => [ { @@ -689,7 +740,7 @@ function WidgetPage() { isFlowActive={() => activeBugReportFlowIdRef.current === activeBugReportFlowId} onSubmitStarted={() => { if (activeBugReportFlowIdRef.current !== activeBugReportFlowId) return - sendToHost({ + sendPrivilegedHostMessage({ type: 'quackback:bug-report-submit-started', flowId: activeBugReportFlowId, }) @@ -706,9 +757,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={ 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..c792ac51d --- /dev/null +++ b/docs/fixtures/quackback-report-submit-contract-v1.json @@ -0,0 +1,124 @@ +{ + "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 + }, + "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, + "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}$", + "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", "hostSubmitAssertion"], + "hostAdapterInput": ["clientSubmissionId", "summary", "impact"], + "signerRequest": ["clientSubmissionId", "reportDigest"], + "signerResponse": ["hostSubmitAssertion"], + "request": ["type", "data"], + "requestData": [ + "contract", + "requestId", + "clientSubmissionId", + "summary", + "impact", + "hostSubmitAssertion" + ], + "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", + "reportDigest", + "hostSubmitAssertion", + "clientSubmissionId", + "requestId", + "reportRef", + "boardId", + "postId", + "principalId", + "email", + "url", + "rawError", + "evidenceId" + ] +} 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", diff --git a/packages/widget/__tests__/postmessage.test.ts b/packages/widget/__tests__/postmessage.test.ts index 5a4846494..1be806ba3 100644 --- a/packages/widget/__tests__/postmessage.test.ts +++ b/packages/widget/__tests__/postmessage.test.ts @@ -18,9 +18,13 @@ describe('postmessage bridge', () => { getIframe: () => fakeIframe as unknown as HTMLIFrameElement, origin: 'https://feedback.acme.com', }) - bridge.send('quackback:identify', { anonymous: true }) + bridge.send('quackback:identify', { anonymous: true }, 'qbi_000000000001') expect(postMessage).toHaveBeenCalledWith( - { type: 'quackback:identify', data: { anonymous: true } }, + { + type: 'quackback:identify', + data: { anonymous: true }, + identityRequestId: 'qbi_000000000001', + }, 'https://feedback.acme.com' ) }) diff --git a/packages/widget/__tests__/sdk.test.ts b/packages/widget/__tests__/sdk.test.ts index e600694ff..75055610a 100644 --- a/packages/widget/__tests__/sdk.test.ts +++ b/packages/widget/__tests__/sdk.test.ts @@ -24,6 +24,14 @@ function fireReady() { ) } +function latestIdentityRequestId(postMessage: ReturnType): string | undefined { + const messages = postMessage.mock.calls + .map(([message]) => message as Record) + .filter((message) => message.type === 'quackback:identify') + const requestId = messages[messages.length - 1]?.identityRequestId + return typeof requestId === 'string' ? requestId : undefined +} + describe('sdk', () => { beforeEach(() => { document.body.innerHTML = '' @@ -109,6 +117,7 @@ describe('sdk', () => { launcher.click() await Promise.resolve() fireReady() + const identityRequestId = latestIdentityRequestId(postMessage) window.dispatchEvent( new MessageEvent('message', { origin: ORIGIN, @@ -117,7 +126,7 @@ describe('sdk', () => { type: 'quackback:identify-result', success: true, user: null, - anonymous: true, + identityRequestId, }, }) ) @@ -270,6 +279,7 @@ describe('sdk', () => { { type: 'quackback:identify', data: { ssoToken: 'NEW_SIGNED_IDENTITY' }, + identityRequestId: expect.stringMatching(/^[A-Za-z0-9_-]{8,64}$/), }, ORIGIN ) @@ -380,7 +390,11 @@ describe('sdk', () => { sdk.dispatch('init', { instanceUrl: ORIGIN }) fireReady() expect(postMessage).toHaveBeenCalledWith( - { type: 'quackback:identify', data: { anonymous: true } }, + { + type: 'quackback:identify', + data: { anonymous: true }, + identityRequestId: expect.stringMatching(/^[A-Za-z0-9_-]{8,64}$/), + }, ORIGIN ) spy.mockRestore() @@ -395,7 +409,11 @@ describe('sdk', () => { }) fireReady() expect(postMessage).toHaveBeenCalledWith( - { type: 'quackback:identify', data: { id: 'u1', email: 'a@b.c', name: 'Ada' } }, + { + type: 'quackback:identify', + data: { id: 'u1', email: 'a@b.c', name: 'Ada' }, + identityRequestId: expect.stringMatching(/^[A-Za-z0-9_-]{8,64}$/), + }, ORIGIN ) spy.mockRestore() @@ -408,7 +426,11 @@ describe('sdk', () => { fireReady() sdk.dispatch('identify', { id: 'u2', email: 'b@c.d' }) expect(postMessage).toHaveBeenLastCalledWith( - { type: 'quackback:identify', data: { id: 'u2', email: 'b@c.d' } }, + { + type: 'quackback:identify', + data: { id: 'u2', email: 'b@c.d' }, + identityRequestId: expect.stringMatching(/^[A-Za-z0-9_-]{8,64}$/), + }, ORIGIN ) spy.mockRestore() 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..ef2f45981 --- /dev/null +++ b/packages/widget/src/__tests__/browser-queue.test.ts @@ -0,0 +1,118 @@ +// @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) +} + +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() + 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 () => { + 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: sourceQueue, + } + ) + 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') + expect(copiedQueue).toEqual([]) + + 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' }) + }) + + 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 374d98648..64f2fe35a 100644 --- a/packages/widget/src/browser-queue.ts +++ b/packages/widget/src/browser-queue.ts @@ -46,10 +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[] - 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__/postmessage.test.ts b/packages/widget/src/core/__tests__/postmessage.test.ts index dc3d620c3..030fb8ef4 100644 --- a/packages/widget/src/core/__tests__/postmessage.test.ts +++ b/packages/widget/src/core/__tests__/postmessage.test.ts @@ -45,4 +45,100 @@ 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() + }) + + it('sends identity correlation only as a top-level field to the exact iframe origin', () => { + const trustedSource = { postMessage: vi.fn() } as unknown as Window + const bridge = createBridge({ + getIframe: () => ({ contentWindow: trustedSource }) as HTMLIFrameElement, + origin: ORIGIN, + }) + const identity = { id: 'actor_1', email: 'private@example.test' } + + ;( + bridge.send as (type: 'quackback:identify', data: unknown, identityRequestId: string) => void + )('quackback:identify', identity, 'qbi_000000000001') + + expect(trustedSource.postMessage).toHaveBeenCalledWith( + { + type: 'quackback:identify', + data: identity, + identityRequestId: 'qbi_000000000001', + }, + ORIGIN + ) + expect(JSON.stringify(identity)).not.toContain('qbi_000000000001') + bridge.dispose() + }) + + it('rejects accessor-bearing replies without invoking them', () => { + const trustedSource = {} as Window + const bridge = createBridge({ + getIframe: () => ({ contentWindow: trustedSource }) as HTMLIFrameElement, + origin: ORIGIN, + }) + const handler = vi.fn() + bridge.onMessage(handler) + let accessed = false + const message = { type: 'quackback:identify-result', success: true, user: null } + Object.defineProperty(message, 'identityRequestId', { + enumerable: true, + get() { + accessed = true + return 'qbi_accessor' + }, + }) + + window.dispatchEvent( + new MessageEvent('message', { + origin: ORIGIN, + source: trustedSource, + data: message, + }) + ) + + expect(accessed).toBe(false) + expect(handler).not.toHaveBeenCalled() + 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..28d1954a8 --- /dev/null +++ b/packages/widget/src/core/__tests__/report-submit.test.ts @@ -0,0 +1,408 @@ +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, + parseHostSubmitAssertionForTransport, + 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 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', + 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: '', + 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() + }) + + 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: '', + 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() + } + }) + + 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: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, + } + 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: '', + hostSubmitAssertion: HOST_SUBMIT_ASSERTION, + }) + ).toEqual({ + type: contract.requestType, + data: { + 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', () => { + 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' }, + }) + 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', () => { + 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' }), + 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..140a70fb6 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 @@ -22,6 +23,7 @@ const ORIGIN = 'https://feedback.acme.com' const WIDGET_FLOW_ID = 'widget_flow_00000001' let iframeSource: Window let iframePost: ReturnType +let lastIdentityRequestId: string | undefined function stubIframe() { const postMessage = vi.fn() @@ -29,10 +31,36 @@ function stubIframe() { iframeSource = { postMessage, } as unknown as Window + lastIdentityRequestId = undefined vi.spyOn(HTMLIFrameElement.prototype, 'contentWindow', 'get').mockReturnValue(iframeSource) 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 currentIframePost(): ReturnType { + const candidate = (iframeSource as unknown as { postMessage?: ReturnType }) + ?.postMessage + return candidate?.mock ? candidate : iframePost +} + +function identifyMessages(post = currentIframePost()) { + return post.mock.calls + .map(([message]) => message as Record) + .filter((message) => message.type === 'quackback:identify') +} + +function latestIdentityRequestId(post = currentIframePost()): string | undefined { + const messages = identifyMessages(post) + const requestId = messages[messages.length - 1]?.identityRequestId + if (typeof requestId === 'string') lastIdentityRequestId = requestId + return lastIdentityRequestId +} + function fireWidgetReady() { window.dispatchEvent( new MessageEvent('message', { @@ -41,14 +69,45 @@ function fireWidgetReady() { data: { type: 'quackback:ready' }, }) ) + latestIdentityRequestId() } -function fireIdentifyResult(user?: { id: string; name: string; email: string }) { +function fireIdentifyResult( + user?: { id: string; name: string; email: string }, + options: { + identityRequestId?: unknown + legacy?: boolean + success?: boolean + error?: string + source?: Window + origin?: string + } = {} +) { + const data: Record = { + type: 'quackback:identify-result', + success: options.success ?? true, + ...(options.error ? { error: options.error } : {}), + } + if (user !== undefined) data.user = user + if (!options.legacy) { + const identityRequestId = options.identityRequestId ?? latestIdentityRequestId() + if (identityRequestId !== undefined) data.identityRequestId = identityRequestId + } window.dispatchEvent( new MessageEvent('message', { - origin: ORIGIN, - source: iframeSource, - data: { type: 'quackback:identify-result', success: true, user }, + origin: options.origin ?? ORIGIN, + source: options.source ?? iframeSource, + data, + }) + ) +} + +function fireRawIdentifyResult(data: object, source: Window = iframeSource, origin = ORIGIN) { + window.dispatchEvent( + new MessageEvent('message', { + origin, + source, + data, }) ) } @@ -58,6 +117,24 @@ function fireReady(user?: { id: string; name: string; email: string }) { fireIdentifyResult(user) } +function fireAuthChange( + user: { id: string; name: string; email: string } | null, + options: { identityRequestId?: unknown; legacy?: boolean } = {} +) { + const data: Record = { type: 'quackback:auth-change', user } + if (!options.legacy) { + const identityRequestId = options.identityRequestId ?? latestIdentityRequestId() + if (identityRequestId !== undefined) data.identityRequestId = identityRequestId + } + window.dispatchEvent( + new MessageEvent('message', { + origin: ORIGIN, + source: iframeSource, + data, + }) + ) +} + function latestRequestedFlowId(): string | null { const calls = iframePost?.mock.calls ?? [] for (let i = calls.length - 1; i >= 0; i -= 1) { @@ -121,11 +198,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 }), + })) ) } @@ -162,7 +242,526 @@ describe('sdk — bug-report capture wiring', () => { window.history.replaceState(null, '', '/') }) - it('emits the exact deeply-frozen feedback/6 readiness contract', async () => { + 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 }, + identityRequestId: expect.stringMatching(/^[A-Za-z0-9_-]{8,64}$/), + }, + 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('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() + 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], + ['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() const sdk = createSDK() @@ -182,22 +781,12 @@ 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'], - features: [ - 'direct-report', - 'screenshot', - 'text-only', - 'lifecycle-events', - 'media-lifecycle-events', - 'private-evidence-attach', - 'private-report-receipts', - 'broad-media-upload', - 'launcher-activation', - ], + features: [...reportSubmitContract.readiness.features], }) const payload = seen[0] as { locales: unknown; features: unknown } expect(Object.isFrozen(payload)).toBe(true) @@ -235,13 +824,21 @@ describe('sdk — bug-report capture wiring', () => { const seen: unknown[] = [] sdk.dispatch('on', 'ready', (payload: unknown) => seen.push(payload)) sdk.dispatch('init', { instanceUrl: ORIGIN }) + // Let the iframe's initial load establish the final transport generation + // while config.json remains deliberately unresolved. Otherwise the + // synthetic ready below can race ahead of happy-dom's load event and be + // correctly revoked as stale. + await flush() 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/6') + await vi.waitFor(() => expect(seen).toHaveLength(1)) + expect((seen[0] as { feedbackContract?: string }).feedbackContract).toBe('iplaycafe.feedback/7') }) it('forwards only sanitized media lifecycle buckets for the active report', async () => { @@ -344,12 +941,14 @@ describe('sdk — bug-report capture wiring', () => { fireReady() await flush() expect(opened).not.toHaveBeenCalled() - const sent = post.mock.calls.map((c) => c[0] as { type: string; data?: unknown }) - expect( - sent.some( - (m) => m.type === 'quackback:open' && (m.data as { view?: string })?.view === 'bug-report' - ) - ).toBe(true) + await vi.waitFor(() => { + const sent = post.mock.calls.map((c) => c[0] as { type: string; data?: unknown }) + expect( + sent.some( + (m) => m.type === 'quackback:open' && (m.data as { view?: string })?.view === 'bug-report' + ) + ).toBe(true) + }) fireLifecycle('bug-report:opened', { entrypoint: 'sdk', authenticated: false }) expect(opened).toHaveBeenCalledWith({ entrypoint: 'sdk', authenticated: false }) }) @@ -1176,3 +1775,1098 @@ describe('sdk — bug-report capture wiring', () => { ).toBe(false) }) }) + +const HOST_SUBMISSION_ID = '22222222-2222-4222-8222-222222222222' +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', + 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() + 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 +} + +describe('sdk — bounded public host report submit', () => { + beforeEach(() => { + HOST_SUBMIT_INPUT = makeHostSubmitInput() + 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('uses a bounded top-level request ID for explicit headless SSO authorization', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + const originalConsoleError = console.error + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + }) + await flush() + expect(document.querySelectorAll('.quackback-widget-iframe')).toHaveLength(1) + fireWidgetReady() + post.mockClear() + + const ssoToken = 'canary-private-sso-token' + sdk.dispatch('identify', { ssoToken }) + expect(document.querySelectorAll('.quackback-widget-iframe')).toHaveLength(1) + expect(sdk.isOpen()).toBe(false) + expect(console.error).toBe(originalConsoleError) + const [message] = identifyMessages(post) + expect(message).toEqual({ + type: 'quackback:identify', + data: { ssoToken }, + identityRequestId: expect.stringMatching(/^[A-Za-z0-9_-]{8,64}$/), + }) + expect(message?.identityRequestId).not.toContain(ssoToken) + }) + + it('keeps B authoritative when B settles before A and ignores duplicate terminal A/B results', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireReady() + post.mockClear() + + sdk.dispatch('identify', { id: 'actor_a', email: 'a-private@example.test' }) + const requestA = latestIdentityRequestId(post) ?? 'qbi_red_actor_a' + sdk.dispatch('identify', { id: 'actor_b', email: 'b-private@example.test' }) + const requestB = latestIdentityRequestId(post) ?? 'qbi_red_actor_b' + expect(requestB).not.toBe(requestA) + + const actorB = { id: 'actor_b', name: 'Actor B', email: 'b-private@example.test' } + fireIdentifyResult(actorB, { identityRequestId: requestB }) + expect(sdk.getUser()).toEqual(actorB) + + fireIdentifyResult( + { id: 'actor_a', name: 'Actor A', email: 'a-private@example.test' }, + { identityRequestId: requestA } + ) + expect(sdk.getUser()).toEqual(actorB) + + fireIdentifyResult( + { id: 'actor_duplicate', name: 'Duplicate', email: 'duplicate-private@example.test' }, + { identityRequestId: requestB } + ) + expect(sdk.getUser()).toEqual(actorB) + }) + + it('ignores malformed exact-current identity terminals until a later exact valid result settles once', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + const identified = vi.fn() + sdk.dispatch('on', 'identify', identified) + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireWidgetReady() + post.mockClear() + + sdk.dispatch('identify', { id: 'actor_b', email: 'b-private@example.test' }) + const requestId = latestIdentityRequestId(post) ?? 'qbi_malformed_actor_b' + sdk.dispatch('reportBug') + post.mockClear() + + const validUser = { + id: 'actor_b', + name: 'Actor B', + email: 'b-private@example.test', + } + const accessorEnvelope = { + type: 'quackback:identify-result', + identityRequestId: requestId, + user: validUser, + } + const successGetter = vi.fn(() => true) + Object.defineProperty(accessorEnvelope, 'success', { + enumerable: true, + get: successGetter, + }) + const symbolEnvelope = { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: validUser, + [Symbol('hidden')]: 'forbidden', + } + const nonEnumerableEnvelope = { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: validUser, + } + Object.defineProperty(nonEnumerableEnvelope, 'hidden', { + enumerable: false, + value: 'forbidden', + }) + const userAccessor = { id: validUser.id, name: validUser.name } + const emailGetter = vi.fn(() => validUser.email) + Object.defineProperty(userAccessor, 'email', { + enumerable: true, + get: emailGetter, + }) + + const malformed: object[] = [ + { type: 'quackback:identify-result', identityRequestId: requestId }, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: 'true', + }, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: undefined, + }, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: validUser, + error: 'SHOULD_NOT_COEXIST', + }, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: { id: validUser.id, name: validUser.name }, + }, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: { ...validUser, extra: 'forbidden' }, + }, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: false, + user: validUser, + error: 'IDENTIFY_FAILED', + }, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: false, + error: 'X'.repeat(65), + }, + { + type: 'quackback:identify-result', + identityRequestID: requestId, + success: true, + user: validUser, + }, + accessorEnvelope, + symbolEnvelope, + nonEnumerableEnvelope, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: userAccessor, + }, + ] + + for (const envelope of malformed) { + fireRawIdentifyResult(envelope) + expect(sdk.getUser()).toBeNull() + expect(identified).not.toHaveBeenCalled() + expect( + post.mock.calls.some( + ([message]) => + (message as { type?: unknown; data?: { view?: unknown } }).type === 'quackback:open' && + (message as { data?: { view?: unknown } }).data?.view === 'bug-report' + ) + ).toBe(false) + } + expect(successGetter).not.toHaveBeenCalled() + expect(emailGetter).not.toHaveBeenCalled() + + fireRawIdentifyResult({ + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: validUser, + }) + expect(sdk.getUser()).toEqual(validUser) + expect(identified).toHaveBeenCalledTimes(1) + expect( + post.mock.calls.filter( + ([message]) => + (message as { type?: unknown; data?: { view?: unknown } }).type === 'quackback:open' && + (message as { data?: { view?: unknown } }).data?.view === 'bug-report' + ) + ).toHaveLength(1) + + fireRawIdentifyResult({ + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: { + id: 'actor_duplicate', + name: 'Duplicate', + email: 'duplicate-private@example.test', + }, + }) + expect(sdk.getUser()).toEqual(validUser) + expect(identified).toHaveBeenCalledTimes(1) + }) + + it('never cross-mixes a stateful envelope snapshot with a later correlation snapshot', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + const identified = vi.fn() + sdk.dispatch('on', 'identify', identified) + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireWidgetReady() + + sdk.dispatch('identify', { id: 'actor_b', email: 'b-private@example.test' }) + const requestId = latestIdentityRequestId(post) ?? 'qbi_snapshot_actor_b' + const forgedUser = { + id: 'actor_forged', + name: 'Forged', + email: 'forged-private@example.test', + } + const snapshots: Array> = [ + { + type: 'quackback:identify-result', + identityRequestId: 'qbi_stale_snapshot', + success: true, + user: forgedUser, + }, + { + type: 'quackback:identify-result', + identityRequestId: requestId, + success: true, + user: forgedUser, + }, + ] + let ownKeysCalls = 0 + let snapshot = 0 + const driftingEnvelope = new Proxy( + {}, + { + getPrototypeOf: () => Object.prototype, + ownKeys: () => { + // readDataRecord takes two own-key snapshots. Only a second, + // forbidden top-level read may advance to the matching correlation. + snapshot = ownKeysCalls < 2 ? 0 : 1 + ownKeysCalls += 1 + return Reflect.ownKeys(snapshots[snapshot]!) + }, + getOwnPropertyDescriptor: (_target, key) => ({ + configurable: true, + enumerable: true, + writable: true, + value: snapshots[snapshot]![key as string], + }), + } + ) + + const driftingEvent = new Event('message') + Object.defineProperties(driftingEvent, { + origin: { value: ORIGIN }, + source: { value: iframeSource }, + data: { value: driftingEnvelope }, + }) + window.dispatchEvent(driftingEvent) + expect(sdk.getUser()).toBeNull() + expect(identified).not.toHaveBeenCalled() + + const validUser = { + id: 'actor_b', + name: 'Actor B', + email: 'b-private@example.test', + } + fireIdentifyResult(validUser, { identityRequestId: requestId }) + expect(sdk.getUser()).toEqual(validUser) + expect(identified).toHaveBeenCalledTimes(1) + }) + + it('accepts exact anonymous success with an absent or null user and bounds exact failures', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + const identified = vi.fn() + sdk.dispatch('on', 'identify', identified) + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireWidgetReady() + + const absentUserRequest = latestIdentityRequestId(post) ?? 'qbi_absent_user' + fireRawIdentifyResult({ + type: 'quackback:identify-result', + identityRequestId: absentUserRequest, + success: true, + }) + expect(sdk.getUser()).toBeNull() + expect(identified).toHaveBeenLastCalledWith({ + success: true, + user: null, + anonymous: true, + error: undefined, + }) + + sdk.dispatch('logout') + const nullUserRequest = latestIdentityRequestId(post) ?? 'qbi_null_user' + fireRawIdentifyResult({ + type: 'quackback:identify-result', + identityRequestId: nullUserRequest, + success: true, + user: null, + }) + expect(identified).toHaveBeenLastCalledWith({ + success: true, + user: null, + anonymous: true, + error: undefined, + }) + + sdk.dispatch('identify', { id: 'actor_failure', email: 'failure-private@example.test' }) + const failureRequest = latestIdentityRequestId(post) ?? 'qbi_bounded_failure' + fireRawIdentifyResult({ + type: 'quackback:identify-result', + identityRequestId: failureRequest, + success: false, + error: 'IDENTIFY_FAILED', + }) + expect(identified).toHaveBeenLastCalledWith({ + success: false, + user: null, + anonymous: false, + error: 'IDENTIFY_FAILED', + }) + await expect(sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)).resolves.toEqual({ + accepted: false, + reason: 'unavailable', + }) + }) + + it('retires A and its pending report intent across logout before ignoring late A', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireReady() + post.mockClear() + + sdk.dispatch('identify', { id: 'actor_a', email: 'a-private@example.test' }) + const requestA = latestIdentityRequestId(post) ?? 'qbi_red_actor_a' + sdk.dispatch('reportBug') + sdk.dispatch('logout') + const logoutRequest = latestIdentityRequestId(post) ?? 'qbi_red_logout' + fireIdentifyResult(undefined, { identityRequestId: logoutRequest }) + fireAuthChange(null, { identityRequestId: logoutRequest }) + fireIdentifyResult( + { id: 'actor_a', name: 'Actor A', email: 'a-private@example.test' }, + { identityRequestId: requestA } + ) + + expect(sdk.getUser()).toBeNull() + expect( + post.mock.calls.filter( + ([message]) => + (message as { type?: unknown; data?: { view?: unknown } }).type === 'quackback:open' && + (message as { data?: { view?: unknown } }).data?.view === 'bug-report' + ) + ).toHaveLength(0) + }) + + it.each([ + ['legacy', { legacy: true }], + ['wrong', { identityRequestId: 'qbi_wrong_request' }], + ['malformed', { identityRequestId: 'bad id with spaces' }], + ] as const)( + 'never lets a %s signed result authorize V7 or revive its correlated pending report', + async (_label, resultOptions) => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireWidgetReady() + sdk.dispatch('reportBug') + post.mockClear() + + fireIdentifyResult( + { id: 'actor_forged', name: 'Forged', email: 'forged-private@example.test' }, + resultOptions + ) + const pending = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + + expect(hostSubmitRequests(post)).toHaveLength(0) + expect( + post.mock.calls.some( + ([message]) => + (message as { type?: unknown; data?: { view?: unknown } }).type === 'quackback:open' && + (message as { data?: { view?: unknown } }).data?.view === 'bug-report' + ) + ).toBe(false) + sdk.dispatch('destroy') + await expect(pending).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + } + ) + + it('revokes a correlated V7 acknowledgement on a later legacy actor result', async () => { + const sdk = createSDK() + const post = await initializeHostSubmit(sdk) + + fireIdentifyResult( + { id: 'legacy_actor', name: 'Legacy', email: 'legacy-private@example.test' }, + { legacy: true } + ) + const pending = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + + expect(hostSubmitRequests(post)).toHaveLength(0) + await expect(pending).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + }) + + it('cannot unlock prepared host submit from an uncorrelated signed result received before ready', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + sdk.dispatch('init', { + instanceUrl: ORIGIN, + launcher: { deferWidgetUntilActivate: true }, + prepareHostReportSubmit: true, + }) + await flush() + + fireIdentifyResult( + { id: 'actor_early', name: 'Early', email: 'early-private@example.test' }, + { legacy: true } + ) + fireWidgetReady() + post.mockClear() + const pending = sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT) as Promise + + expect(hostSubmitRequests(post)).toHaveLength(0) + sdk.dispatch('destroy') + await expect(pending).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + }) + + it('retires request IDs on reload, replacement generation, re-init, and destroy', async () => { + mockConfig(true) + const post = stubIframe() + const sdk = createSDK() + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireWidgetReady() + const firstRequest = latestIdentityRequestId(post) ?? 'qbi_red_first' + + const iframe = document.querySelector('.quackback-widget-iframe') as HTMLIFrameElement + iframe.dispatchEvent(new Event('load')) + fireWidgetReady() + const replayRequest = latestIdentityRequestId(post) ?? 'qbi_red_replay' + expect(replayRequest).not.toBe(firstRequest) + + fireIdentifyResult( + { id: 'actor_stale', name: 'Stale', email: 'stale-private@example.test' }, + { identityRequestId: firstRequest } + ) + expect(sdk.getUser()).toBeNull() + + const actorCurrent = { + id: 'actor_current', + name: 'Current', + email: 'current-private@example.test', + } + fireIdentifyResult(actorCurrent, { identityRequestId: replayRequest }) + expect(sdk.getUser()).toEqual(actorCurrent) + + sdk.dispatch('init', { instanceUrl: ORIGIN }) + await flush() + fireWidgetReady() + const reinitRequest = latestIdentityRequestId(post) ?? 'qbi_red_reinit' + expect(reinitRequest).not.toBe(replayRequest) + fireIdentifyResult( + { id: 'actor_old', name: 'Old', email: 'old-private@example.test' }, + { identityRequestId: replayRequest } + ) + expect(sdk.getUser()).toBeNull() + + sdk.dispatch('destroy') + fireIdentifyResult( + { id: 'actor_destroyed', name: 'Destroyed', email: 'destroyed-private@example.test' }, + { identityRequestId: reinitRequest } + ) + expect(sdk.getUser()).toBeNull() + }) + + 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('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, error: 'IDENTIFY_FAILED' }, + { success: true, user: malformedUser }, + ] + const results: unknown[] = [] + + for (const acknowledgement of acknowledgements) { + sdk.dispatch('identify', { id: 'actor_1', email: 'actor@example.test' }) + const identityRequestId = latestIdentityRequestId(post) + fireRawIdentifyResult({ + type: 'quackback:identify-result', + identityRequestId, + ...acknowledgement, + }) + results.push(await sdk.dispatch('submitBugReport', HOST_SUBMIT_INPUT)) + } + + expect(hostSubmitRequests(post)).toHaveLength(0) + expect(emailGetter).not.toHaveBeenCalled() + expect(results).toEqual([ + { accepted: false, reason: 'unavailable' }, + { accepted: false, reason: 'unavailable' }, + ]) + }) + + 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 + const requests = hostSubmitRequests(post) + expect(requests).toHaveLength(1) + expect(requests[0]?.data).toEqual({ + contract: 'iplaycafe.quackback.report-submit/1', + requestId: HOST_REQUEST_ID, + ...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 [index, reason] of [ + 'aborted', + 'invalid_request', + 'unavailable', + 'unauthorized', + 'retryable_failure', + ].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) + 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', + 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) + + 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(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/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/postmessage.ts b/packages/widget/src/core/postmessage.ts index 59d4abd7d..6246bc281 100644 --- a/packages/widget/src/core/postmessage.ts +++ b/packages/widget/src/core/postmessage.ts @@ -1,6 +1,8 @@ +import type { HostSubmitRequestMessage } from './report-submit' + export type InboundMessage = | { type: 'quackback:init'; data?: unknown } - | { type: 'quackback:identify'; data: unknown } + | { type: 'quackback:identify'; data: unknown; identityRequestId: string } | { type: 'quackback:metadata'; data: Record } | { type: 'quackback:open'; data?: unknown } | { type: 'quackback:locale'; data: string } @@ -10,18 +12,26 @@ 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' } | { type: 'quackback:close' } | { type: 'quackback:navigate'; url: string } - | { type: 'quackback:identify-result'; success: boolean; user?: unknown; error?: string } - | { type: 'quackback:auth-change'; user: unknown } + | { + type: 'quackback:identify-result' + success: boolean + user?: unknown + error?: string + identityRequestId?: unknown + } + | { type: 'quackback:auth-change'; user: unknown; identityRequestId?: unknown } | { type: 'quackback:event'; name: string; payload: unknown } // Bug-report capture: the widget asks the host to capture the page (the // 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 } @@ -32,11 +42,32 @@ export interface BridgeOptions { } export interface Bridge { - send(type: InboundMessage['type'], data?: unknown): void + send(type: 'quackback:identify', data: unknown, identityRequestId: string): void + send(type: Exclude, data?: unknown): void onMessage(handler: (msg: OutboundMessage) => void): () => void dispose(): void } +function readOutboundMessage(value: unknown): OutboundMessage | 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) + if (keys.some((key) => typeof key !== 'string')) return null + const descriptors = Object.getOwnPropertyDescriptors(value) + const record = Object.create(null) as Record + for (const key of keys as string[]) { + const descriptor = descriptors[key] + if (!descriptor?.enumerable || !('value' in descriptor)) return null + record[key] = descriptor.value + } + return typeof record.type === 'string' ? (record as OutboundMessage) : null + } catch { + return null + } +} + export function createBridge(opts: BridgeOptions): Bridge { const handlers = new Set<(msg: OutboundMessage) => void>() @@ -44,12 +75,11 @@ export function createBridge(opts: BridgeOptions): Bridge { if (event.origin !== opts.origin) return const iframeWindow = opts.getIframe()?.contentWindow if (!iframeWindow || event.source !== iframeWindow) return - const msg = event.data - if (!msg || typeof msg !== 'object' || typeof (msg as { type?: unknown }).type !== 'string') - return + const msg = readOutboundMessage(event.data) + if (!msg) return for (const h of handlers) { try { - h(msg as OutboundMessage) + h(msg) } catch { /* swallow */ } @@ -58,9 +88,14 @@ export function createBridge(opts: BridgeOptions): Bridge { window.addEventListener('message', listener) return { - send(type, data) { + send(type: InboundMessage['type'], data?: unknown, identityRequestId?: string) { const iframe = opts.getIframe() if (!iframe?.contentWindow) return + if (type === 'quackback:identify') { + if (typeof identityRequestId !== 'string') return + iframe.contentWindow.postMessage({ type, data, identityRequestId }, opts.origin) + return + } iframe.contentWindow.postMessage({ type, data }, opts.origin) }, onMessage(handler) { diff --git a/packages/widget/src/core/report-submit.ts b/packages/widget/src/core/report-submit.ts new file mode 100644 index 000000000..a09cdb0d2 --- /dev/null +++ b/packages/widget/src/core/report-submit.ts @@ -0,0 +1,367 @@ +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_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', + '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 + hostSubmitAssertion: string + } +} + +export 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') + ) { + 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', + 'hostSubmitAssertion', + ]) + if ( + !record || + !isUuidV4(record.clientSubmissionId) || + typeof record.summary !== 'string' || + record.summary.length === 0 || + record.summary.length > 2_000 || + record.summary !== record.summary.trim() || + typeof record.impact !== 'string' || + record.impact.length > 1_000 || + 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 + } + return { + clientSubmissionId: record.clientSubmissionId, + summary: record.summary, + impact: record.impact, + hostSubmitAssertion: record.hostSubmitAssertion, + } +} + +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, + 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 +): 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..58bda04b9 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,23 @@ import { type BugReportCaptureMode, } from './bug-report-events' import { removeStyles } from './style' +import { + HOST_REPORT_SUBMIT_CONTRACT, + HOST_REPORT_SUBMIT_TIMEOUT_MS, + createHostSubmitRequest, + parseHostSubmitAssertionForTransport, + parseHostSubmitResultForRequest, + parseSubmitBugReportContext, + parseSubmitBugReportInput, + readDataRecord, +} 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), @@ -48,6 +59,7 @@ const FEEDBACK_READY = Object.freeze({ 'media-lifecycle-events', 'private-evidence-attach', 'private-report-receipts', + 'host-report-submit', 'broad-media-upload', 'launcher-activation', ] as const), @@ -56,6 +68,9 @@ 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']) +const IDENTITY_REQUEST_ID = /^[A-Za-z0-9_-]{8,64}$/ +const IDENTITY_ERROR_CODE = /^[A-Z][A-Z0-9_]{0,63}$/ type Command = | 'init' @@ -70,6 +85,7 @@ type Command = | 'on' | 'off' | 'reportBug' + | 'submitBugReport' export interface SDK { dispatch(command: Command, arg1?: unknown, arg2?: unknown): unknown @@ -87,6 +103,122 @@ 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 } + : {}), + } +} + +type IdentityCorrelation = + | { kind: 'legacy' } + | { kind: 'valid'; requestId: string } + | { kind: 'invalid' } + +function readIdentityCorrelation(record: Record): IdentityCorrelation { + if (!Object.prototype.hasOwnProperty.call(record, 'identityRequestId')) { + return { kind: 'legacy' } + } + return typeof record.identityRequestId === 'string' && + IDENTITY_REQUEST_ID.test(record.identityRequestId) + ? { kind: 'valid', requestId: record.identityRequestId } + : { kind: 'invalid' } +} + +function readWidgetUser(value: unknown): WidgetUser | null { + const user = readDataRecord(value) + 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 } + : {}), + } +} + +type ParsedIdentityResult = { + success: boolean + user: WidgetUser | null + error: string | undefined +} + +function parseIdentityResult(record: Record): ParsedIdentityResult | null { + if (record.type !== 'quackback:identify-result') return null + if (record.success !== true && record.success !== false) return null + + const hasCorrelation = Object.prototype.hasOwnProperty.call(record, 'identityRequestId') + const keys = Object.keys(record) + const allowed = new Set( + record.success + ? ['type', 'success', 'user', ...(hasCorrelation ? ['identityRequestId'] : [])] + : ['type', 'success', 'error', ...(hasCorrelation ? ['identityRequestId'] : [])] + ) + if (!keys.every((key) => allowed.has(key))) return null + + if (record.success) { + if (Object.prototype.hasOwnProperty.call(record, 'error')) return null + if (!Object.prototype.hasOwnProperty.call(record, 'user') || record.user === null) { + return { success: true, user: null, error: undefined } + } + const user = readWidgetUser(record.user) + return user ? { success: true, user, error: undefined } : null + } + + if (Object.prototype.hasOwnProperty.call(record, 'user')) return null + if ( + Object.prototype.hasOwnProperty.call(record, 'error') && + (typeof record.error !== 'string' || !IDENTITY_ERROR_CODE.test(record.error)) + ) { + return null + } + return { + success: false, + user: null, + error: typeof record.error === 'string' ? record.error : undefined, + } +} + export function createSDK(): SDK { let config: InitOptions | null = null let launcher: LauncherHandle | null = null @@ -94,14 +226,27 @@ export function createSDK(): SDK { let bridge: Bridge | null = null let ready = false let metadata: Record | null = null - // `pendingIdentify` can be an Identity, `{anonymous: true}`, or `null` (logout). - // `pendingIdentifyPresent` distinguishes "nothing queued" from "logout queued". - let pendingIdentify: unknown = null - let pendingIdentifyPresent = false + type IdentityAttempt = { + requestId: string + generation: number + sent: boolean + settled: boolean + } + type PendingIdentity = { + data: unknown + attempt: IdentityAttempt + } + // Deliberately survives destroy/re-init for the lifetime of this SDK object: + // an old browsing context must never share an identity correlation ID with + // its replacement. + let identityRequestSequence = 0 + let activeIdentityAttempt: IdentityAttempt | null = null + let pendingIdentity: PendingIdentity | null = null let pendingOpen: unknown = null 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. @@ -109,18 +254,44 @@ 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 + // 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 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() + // 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 - let pendingReportBugEntrypoint: 'sdk' | 'shortcut' | null = null + let pendingReportBug: { + entrypoint: 'sdk' | 'shortcut' + identityRequestId: string + generation: number + } | null = null type BugReportFlow = { flowId: string startedAt: number @@ -143,7 +314,11 @@ 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 = + serverConfig?.bugReportCapture === true && + serverConfig.bugReportHostSubmit === true && + widgetTransportStarted + emitter.emit('ready', hostSubmitV7Eligible ? FEEDBACK_READY : GENERIC_READY) } function sendMobileState(): void { @@ -154,6 +329,174 @@ 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( + result: SubmitBugReportResultV1 = { + accepted: false, + reason: 'retryable_failure', + } + ): void { + for (const [requestId, pending] of pendingHostSubmits) { + settlePendingHostSubmit(requestId, pending, result) + } + } + + function nextIdentityRequestId(): string { + identityRequestSequence += 1 + return `qbi_${identityRequestSequence.toString(36).padStart(12, '0')}` + } + + function createIdentityAttempt(): IdentityAttempt { + return { + requestId: nextIdentityRequestId(), + generation: transportGeneration, + sent: false, + settled: false, + } + } + + function queueIdentity(data: unknown): IdentityAttempt { + const attempt = createIdentityAttempt() + activeIdentityAttempt = attempt + pendingIdentity = { data, attempt } + return attempt + } + + function isCurrentIdentityAttempt(attempt: IdentityAttempt | null): attempt is IdentityAttempt { + return ( + attempt !== null && + activeIdentityAttempt === attempt && + attempt.generation === transportGeneration + ) + } + + function isActiveIdentitySettled(): boolean { + return ( + isCurrentIdentityAttempt(activeIdentityAttempt) && + activeIdentityAttempt.sent && + activeIdentityAttempt.settled + ) + } + + function invalidateHostSubmitTransport(preservePendingReportIntent = false): void { + const revokeExactReadiness = hostReadyEmitted && hostSubmitV7Eligible + const pendingReportEntrypoint = preservePendingReportIntent + ? pendingReportBug?.entrypoint + : undefined + transportGeneration += 1 + ready = false + settleAllHostSubmits({ accepted: false, reason: 'retryable_failure' }) + usedHostSubmitRequestIds.clear() + identityResolved = false + currentUser = null + hostSubmitAcknowledgedUser = null + hostReadyEmitted = false + hostSubmitV7Eligible = false + activeIdentityAttempt = null + pendingIdentity = null + pendingReportBug = null + if (revokeExactReadiness) emitter.emit('ready', GENERIC_READY) + if (config && widgetTransportStarted) { + // Replacing a still-hidden prepared iframe must not smuggle its retained + // init identity across the transport boundary. + const attempt = queueIdentity(pendingPreparedIdentity ? ANONYMOUS_IDENTITY : deferredIdentity) + // A report command carries only user intent, not diagnostics or actor + // data. Rebind that intent to the replacement transport's exact identity + // attempt so an init→reportBug call survives the iframe's first load + // without ever crossing actor generations. + if (pendingReportEntrypoint) { + pendingReportBug = { + entrypoint: pendingReportEntrypoint, + identityRequestId: attempt.requestId, + generation: attempt.generation, + } + } + } + removeIframeLoad?.() + removeIframeLoad = null + bridge?.dispose() + bridge = null + currentIframe = null + currentIframeWindow = null + } + + 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(true) + installHostSubmitTransport(iframe) + } + iframe.addEventListener('load', onLoad) + removeIframeLoad = () => iframe.removeEventListener('load', onLoad) + } + + function replaceHostSubmitTransport(iframe: HTMLIFrameElement | null): void { + invalidateHostSubmitTransport(true) + 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) { @@ -249,11 +592,22 @@ export function createSDK(): SDK { } function flushPendingReportBug(): void { - const entrypoint = pendingReportBugEntrypoint - if (!entrypoint || captureAvailable === null || !identityResolved) return - pendingReportBugEntrypoint = null + const pending = pendingReportBug + const attempt = activeIdentityAttempt + if ( + !pending || + !attempt || + captureAvailable === null || + !identityResolved || + !isActiveIdentitySettled() || + pending.identityRequestId !== attempt.requestId || + pending.generation !== attempt.generation + ) { + return + } + pendingReportBug = null if (captureAvailable) { - startBugReport(entrypoint) + startBugReport(pending.entrypoint) return } emitter.emit('bug-report:failed', { @@ -264,14 +618,20 @@ 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 - if (pendingIdentifyPresent) { - bridge!.send('quackback:identify', pendingIdentify) - pendingIdentifyPresent = false - pendingIdentify = null + if ( + pendingIdentity && + isCurrentIdentityAttempt(pendingIdentity.attempt) && + !pendingIdentity.attempt.sent + ) { + const queued = pendingIdentity + pendingIdentity = null + queued.attempt.sent = true + bridge!.send('quackback:identify', queued.data, queued.attempt.requestId) } if (config?.locale) bridge!.send('quackback:locale', config.locale) if (metadata) bridge!.send('quackback:metadata', metadata) @@ -292,28 +652,96 @@ export function createSDK(): SDK { dispatch('close') break case 'quackback:identify-result': { - const m = msg as { - success?: boolean - user?: WidgetUser - error?: string + // One authoritative shallow snapshot supplies both terminal validity + // and correlation. Never re-read a stateful descriptor/proxy envelope + // and cross-mix values observed at different moments. + const record = readDataRecord(msg) + if (!record) break + const result = parseIdentityResult(record) + const correlation = readIdentityCorrelation(record) + if (!result || correlation.kind === 'invalid') break + + const { success, user: nextUser, error } = result + if (correlation.kind === 'legacy') { + // V3-V6 display compatibility only. An uncorrelated result may update + // generic SDK state, but can never settle the active actor attempt, + // authorize V7 submission, or flush a correlated report intent. + currentUser = nextUser + identityResolved = true + hostSubmitAcknowledgedUser = null + emitter.emit('identify', { + success, + user: currentUser, + anonymous: success && !nextUser, + error, + }) + break } - currentUser = m.user ?? null + + const attempt = activeIdentityAttempt + if ( + !ready || + !isCurrentIdentityAttempt(attempt) || + !attempt.sent || + attempt.settled || + correlation.requestId !== attempt.requestId + ) { + break + } + // Settle before emitting user callbacks. A re-entrant identify/logout + // therefore retires this terminal before any later mutation can occur. + attempt.settled = true + currentUser = nextUser identityResolved = true + hostSubmitAcknowledgedUser = success ? nextUser : null emitter.emit('identify', { - success: !!m.success, + success, user: currentUser, - anonymous: !!m.success && !m.user, - error: m.error, + anonymous: success && !nextUser, + error, }) - flushPendingReportBug() + if (isCurrentIdentityAttempt(attempt)) flushPendingReportBug() break } case 'quackback:auth-change': { - const m = msg as { user?: WidgetUser } - currentUser = m.user ?? null + const record = readDataRecord(msg) + if (!record) break + const correlation = readIdentityCorrelation(record) + if (correlation.kind === 'invalid') break + const nextUser = readWidgetUser(record.user) + const previousAcknowledgedUser = hostSubmitAcknowledgedUser + const nextAcknowledgedUser = + nextUser && + previousAcknowledgedUser && + readAcknowledgedSignedInUser({ + type: 'quackback:identify-result', + success: true, + user: nextUser, + }) + + if (correlation.kind === 'valid') { + const attempt = activeIdentityAttempt + if ( + !ready || + !isCurrentIdentityAttempt(attempt) || + !attempt.sent || + !attempt.settled || + correlation.requestId !== attempt.requestId + ) { + break + } + } + + // Legacy auth-change remains a display update. It may retain an + // already-correlated acknowledgement only for the same actor; it can + // never create one. + hostSubmitAcknowledgedUser = + nextAcknowledgedUser?.id === previousAcknowledgedUser?.id ? nextAcknowledgedUser : null + currentUser = nextUser 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) @@ -399,6 +827,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 @@ -412,12 +841,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. @@ -427,6 +858,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 + } } } @@ -491,7 +932,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 @@ -509,7 +950,7 @@ export function createSDK(): SDK { } pendingCaptureResult = null captureAvailable = null - pendingReportBugEntrypoint = null + pendingReportBug = null activeBugReport = null requestedBugReport = null detachedSubmittingReports.clear() @@ -518,6 +959,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 +971,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) @@ -541,27 +984,52 @@ 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) } + 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 // draft/screenshot can never be posted with B's eventual session token. resetActiveBugReport() + pendingReportBug = null + settleAllHostSubmits({ accepted: false, reason: 'retryable_failure' }) identityResolved = false - if (ready && bridge) bridge.send('quackback:identify', data) - else { - pendingIdentify = data - pendingIdentifyPresent = true + currentUser = null + hostSubmitAcknowledgedUser = null + const attempt = queueIdentity(data) + if (ready && bridge) { + pendingIdentity = null + attempt.sent = true + bridge.send('quackback:identify', data, attempt.requestId) } } @@ -621,7 +1089,9 @@ export function createSDK(): SDK { let activationTimer: number | null = null let activationAbortHandler: (() => void) | null = null try { - ensureWidgetSession() + 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. @@ -669,6 +1139,103 @@ 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 || + !isActiveIdentitySettled() || + !currentUser || + !hostSubmitAcknowledgedUser || + currentUser.id !== hostSubmitAcknowledgedUser.id + ) { + return Promise.resolve({ accepted: false, reason: 'unavailable' }) + } + + 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 + + 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': { @@ -684,12 +1251,18 @@ 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 + 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) void fetchServerConfig(config.instanceUrl) @@ -697,8 +1270,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() }) @@ -715,32 +1290,55 @@ export function createSDK(): SDK { } case 'identify': { const nextIdentity = (a as Identity | undefined) ?? ANONYMOUS_IDENTITY + pendingPreparedIdentity = null deferredIdentity = nextIdentity - if (!widgetSessionStarted && launcherConfig()?.deferWidgetUntilActivate === true) { + if (!widgetTransportStarted && launcherConfig()?.deferWidgetUntilActivate === true) { + resetActiveBugReport() + pendingReportBug = null + activeIdentityAttempt = null + pendingIdentity = null currentUser = null identityResolved = false + hostSubmitAcknowledgedUser = null return } - ensureWidgetSession(nextIdentity, true) + // A prepared deferred transport makes explicit identify a headless + // authentication boundary without opening UI or installing capture. + ensureWidgetTransport(nextIdentity, true) return } case 'logout': + pendingPreparedIdentity = null deferredIdentity = ANONYMOUS_IDENTITY - currentUser = null - identityResolved = false - if (!widgetSessionStarted && launcherConfig()?.deferWidgetUntilActivate === true) return - resetActiveBugReport() + if (!config) { + resetActiveBugReport() + pendingReportBug = null + activeIdentityAttempt = null + pendingIdentity = null + currentUser = null + identityResolved = false + hostSubmitAcknowledgedUser = null + return + } + if (!widgetTransportStarted && launcherConfig()?.deferWidgetUntilActivate === true) { + resetActiveBugReport() + pendingReportBug = null + activeIdentityAttempt = null + pendingIdentity = null + currentUser = null + identityResolved = false + hostSubmitAcknowledgedUser = null + return + } + ensureWidgetTransport(null, true) panel?.hide({ resetLayoutAfterClose: true }) launcher?.setOpen(false) panelOpen = false - if (ready && bridge) bridge.send('quackback:identify', null as unknown as undefined) - else { - pendingIdentify = null - pendingIdentifyPresent = true - } return case 'open': { - ensureWidgetSession() + authorizeInteractiveCapture() + ensureWidgetTransport() + promotePreparedIdentity() const opts = (a as OpenOptions) ?? {} const view = (opts as { view?: string }).view let wireOpts: unknown = opts @@ -809,13 +1407,22 @@ export function createSDK(): SDK { } case 'reportBug': { if (!config) return - ensureWidgetSession() + authorizeInteractiveCapture() + ensureWidgetTransport() + promotePreparedIdentity() const requestedEntrypoint = (a as { entrypoint?: unknown } | undefined)?.entrypoint === 'shortcut' ? 'shortcut' : 'sdk' - if (captureAvailable === null || !identityResolved) { - pendingReportBugEntrypoint ??= requestedEntrypoint + if (captureAvailable === null || !identityResolved || !isActiveIdentitySettled()) { + const attempt = activeIdentityAttempt + if (isCurrentIdentityAttempt(attempt)) { + pendingReportBug ??= { + entrypoint: requestedEntrypoint, + identityRequestId: attempt.requestId, + generation: attempt.generation, + } + } return } if (!captureAvailable || !captureEnabled) { @@ -830,14 +1437,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 @@ -848,17 +1460,21 @@ export function createSDK(): SDK { bridge = null ready = false metadata = null - pendingIdentify = null - pendingIdentifyPresent = false + activeIdentityAttempt = null + pendingIdentity = null pendingOpen = null panelOpen = false currentUser = null identityResolved = false + hostSubmitAcknowledgedUser = null serverConfigSettled = false hostReadyEmitted = false + hostSubmitV7Eligible = false serverConfig = null - widgetSessionStarted = false + widgetTransportStarted = false + interactiveCaptureAuthorized = false deferredIdentity = ANONYMOUS_IDENTITY + pendingPreparedIdentity = null launcherActivationInFlight = false config = null return diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index b14e7980b..4352756cf 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -12,6 +12,13 @@ import type { LauncherActivationContext, LauncherActivationSource, LauncherConfig, + SubmitBugReportInputV1, + SubmitBugReportContextV1, + SubmitBugReportResultV1, + BugReportReceiptV1, + BugReportStatusV1, + HostSubmitFailureReason, + FeedbackReady, } from './types' export type { @@ -27,6 +34,13 @@ export type { LauncherActivationContext, LauncherActivationSource, LauncherConfig, + SubmitBugReportInputV1, + SubmitBugReportContextV1, + SubmitBugReportResultV1, + BugReportReceiptV1, + BugReportStatusV1, + HostSubmitFailureReason, + FeedbackReady, } const sdk = createSDK() @@ -55,6 +69,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..6dfbb988a 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 } /** @@ -113,9 +118,53 @@ 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 + /** + * 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 { + 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'] @@ -127,6 +176,7 @@ export interface FeedbackReady { 'media-lifecycle-events', 'private-evidence-attach', 'private-report-receipts', + 'host-report-submit', 'broad-media-upload', 'launcher-activation', ] diff --git a/packages/widget/vitest.config.ts b/packages/widget/vitest.config.ts index 56feb5b7e..84c2d85cd 100644 --- a/packages/widget/vitest.config.ts +++ b/packages/widget/vitest.config.ts @@ -3,5 +3,18 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'happy-dom', + environmentOptions: { + happyDOM: { + settings: { + // SDK tests install an explicit contentWindow transport and dispatch + // load/message events themselves. Real child-frame navigation only + // creates external network work that can outlive a test generation + // and make transport-race assertions time out under full-suite load. + navigation: { + disableChildFrameNavigation: true, + }, + }, + }, + }, }, })