From 6d5a8859b8aac393638ddc9ef1f562b56bca8316 Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 15:06:42 +0100 Subject: [PATCH 1/3] feat(widget): two-step install with a lazy signing secret Cloud and self-host users were stuck looking for QUACKBACK_WIDGET_SECRET. Mint the secret on first admin fetch, put the launcher first, and keep regenerate on the install page. Co-authored-by: Cursor --- .../__tests__/widget-signing-secret.test.tsx | 72 ++++++++++++ .../settings/widget/widget-signing-secret.tsx | 96 ++++++++++++++++ .../settings/__tests__/settings-cache.test.ts | 38 ++++++- .../settings/__tests__/widget-config.test.ts | 8 ++ .../domains/settings/settings.widget.ts | 32 ++++++ apps/web/src/lib/server/functions/settings.ts | 4 +- .../widget/__tests__/install-prompt.test.ts | 60 ++++++---- .../src/lib/shared/widget/install-prompt.ts | 57 +++++++--- .../settings.widget-install-page.test.tsx | 70 ++++++++++++ .../routes/admin/settings.widget.install.tsx | 103 ++++++++---------- 10 files changed, 443 insertions(+), 97 deletions(-) create mode 100644 apps/web/src/components/admin/settings/widget/__tests__/widget-signing-secret.test.tsx create mode 100644 apps/web/src/components/admin/settings/widget/widget-signing-secret.tsx create mode 100644 apps/web/src/routes/admin/__tests__/settings.widget-install-page.test.tsx diff --git a/apps/web/src/components/admin/settings/widget/__tests__/widget-signing-secret.test.tsx b/apps/web/src/components/admin/settings/widget/__tests__/widget-signing-secret.test.tsx new file mode 100644 index 0000000000..5a2a1654ac --- /dev/null +++ b/apps/web/src/components/admin/settings/widget/__tests__/widget-signing-secret.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { maskSigningSecret } from '../widget-signing-secret' + +const mutateAsync = vi.fn() +const copyWithFallback = vi.fn() + +vi.mock('@/lib/client/mutations/settings', () => ({ + useRegenerateWidgetSecret: () => ({ + mutateAsync, + isPending: false, + }), +})) + +vi.mock('@/components/admin/activation-action-button', () => ({ + copyWithFallback: (...args: unknown[]) => copyWithFallback(...args), +})) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +describe('maskSigningSecret', () => { + it('keeps the prefix and masks the rest', () => { + expect(maskSigningSecret('wgt_abc123secret')).toBe('wgt_abc1••••••••') + }) +}) + +describe('WidgetSigningSecret', () => { + beforeEach(() => { + mutateAsync.mockReset() + copyWithFallback.mockReset() + copyWithFallback.mockResolvedValue(undefined) + mutateAsync.mockResolvedValue('wgt_new') + }) + + it('masks the secret until reveal', async () => { + const { WidgetSigningSecret } = await import('../widget-signing-secret') + const secret = 'wgt_abc123secret' + render() + + expect(screen.getByTestId('signing-secret')).toHaveTextContent(maskSigningSecret(secret)) + expect(screen.queryByText(secret)).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Reveal signing secret' })) + expect(screen.getByTestId('signing-secret')).toHaveTextContent(secret) + }) + + it('copies the full secret', async () => { + const { WidgetSigningSecret } = await import('../widget-signing-secret') + render() + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + await waitFor(() => { + expect(copyWithFallback).toHaveBeenCalledWith('wgt_abc123secret') + }) + }) + + it('regenerates after confirm', async () => { + const { WidgetSigningSecret } = await import('../widget-signing-secret') + render() + + fireEvent.click(screen.getByRole('button', { name: 'Regenerate…' })) + expect(screen.getByText('Regenerate signing secret?')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Regenerate secret' })) + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/apps/web/src/components/admin/settings/widget/widget-signing-secret.tsx b/apps/web/src/components/admin/settings/widget/widget-signing-secret.tsx new file mode 100644 index 0000000000..c8e91bb101 --- /dev/null +++ b/apps/web/src/components/admin/settings/widget/widget-signing-secret.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/shared/confirm-dialog' +import { copyWithFallback } from '@/components/admin/activation-action-button' +import { useRegenerateWidgetSecret } from '@/lib/client/mutations/settings' + +export function maskSigningSecret(secret: string): string { + return `${secret.slice(0, 8)}${'•'.repeat(8)}` +} + +export function WidgetSigningSecret({ secret }: { secret: string }) { + const [revealed, setRevealed] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + const [copying, setCopying] = useState(false) + const regenerate = useRegenerateWidgetSecret() + + async function copySecret() { + setCopying(true) + try { + await copyWithFallback(secret) + toast.success('Copied') + } catch { + toast.error('Copy failed. Select the text and copy it manually.') + } finally { + setCopying(false) + } + } + + return ( +
+
+

Signing secret

+

+ Store this in your product's server-side secret store. It is not a Quackback Cloud or + self-host setting. +

+
+
+ + {revealed ? secret : maskSigningSecret(secret)} + +
+ + +
+
+ + { + try { + await regenerate.mutateAsync() + toast.success('Signing secret regenerated') + setRevealed(false) + setConfirmOpen(false) + } catch { + toast.error('Could not regenerate the signing secret') + } + }} + /> +
+ ) +} diff --git a/apps/web/src/lib/server/domains/settings/__tests__/settings-cache.test.ts b/apps/web/src/lib/server/domains/settings/__tests__/settings-cache.test.ts index 6d07bb23f4..9c9c45af9c 100644 --- a/apps/web/src/lib/server/domains/settings/__tests__/settings-cache.test.ts +++ b/apps/web/src/lib/server/domains/settings/__tests__/settings-cache.test.ts @@ -144,7 +144,8 @@ const { saveHeaderLogoKey, deleteHeaderLogoKey, } = await import('../settings.media') -const { updateWidgetConfig, regenerateWidgetSecret } = await import('../settings.widget') +const { updateWidgetConfig, regenerateWidgetSecret, ensureWidgetSecret } = + await import('../settings.widget') beforeEach(() => { vi.clearAllMocks() @@ -404,6 +405,41 @@ describe('settings write functions invalidate cache', () => { }) }) +describe('ensureWidgetSecret', () => { + it('returns an existing secret without writing or invalidating', async () => { + mockFindFirst.mockResolvedValue(makeSettingsRow({ widgetSecret: 'wgt_existing' })) + await expect(ensureWidgetSecret()).resolves.toBe('wgt_existing') + expect(mockUpdate).not.toHaveBeenCalled() + expect(mockCacheDel).not.toHaveBeenCalled() + }) + + it('mints when missing and invalidates cache', async () => { + mockFindFirst.mockResolvedValue(makeSettingsRow({ widgetSecret: null })) + let stored: string | undefined + mockSet.mockImplementation((payload: { widgetSecret?: string }) => { + stored = payload.widgetSecret + return { where: mockWhere } + }) + mockReturning.mockImplementation(() => Promise.resolve([{ widgetSecret: stored }])) + + const secret = await ensureWidgetSecret() + expect(secret).toMatch(/^wgt_[a-f0-9]{64}$/) + expect(secret).toBe(stored) + expect(mockCacheDel).toHaveBeenCalledWith('settings:workspace', 'auth:registered-providers') + }) + + it('returns the winner when the insert loses the race', async () => { + const existing = `wgt_${'b'.repeat(64)}` + mockFindFirst + .mockResolvedValueOnce(makeSettingsRow({ widgetSecret: null })) + .mockResolvedValueOnce(makeSettingsRow({ widgetSecret: existing })) + mockReturning.mockResolvedValue([]) + + await expect(ensureWidgetSecret()).resolves.toBe(existing) + expect(mockCacheDel).not.toHaveBeenCalled() + }) +}) + describe('updateFeatureFlags', () => { beforeEach(() => { mockFindFirst.mockResolvedValue( diff --git a/apps/web/src/lib/server/domains/settings/__tests__/widget-config.test.ts b/apps/web/src/lib/server/domains/settings/__tests__/widget-config.test.ts index 61341034bb..b7f8f222d8 100644 --- a/apps/web/src/lib/server/domains/settings/__tests__/widget-config.test.ts +++ b/apps/web/src/lib/server/domains/settings/__tests__/widget-config.test.ts @@ -20,6 +20,7 @@ vi.mock('../settings.helpers', async (importOriginal) => ({ import { generateWidgetSecret, + ensureWidgetSecret, publicMessengerConfig, getPublicWidgetConfig, } from '../settings.widget' @@ -363,3 +364,10 @@ describe('generateWidgetSecret', () => { expect(secret1).not.toBe(secret2) }) }) + +describe('ensureWidgetSecret', () => { + it('returns the existing secret without writing', async () => { + settingsRow.current = { id: 'settings_1', widgetSecret: 'wgt_existing' } + await expect(ensureWidgetSecret()).resolves.toBe('wgt_existing') + }) +}) diff --git a/apps/web/src/lib/server/domains/settings/settings.widget.ts b/apps/web/src/lib/server/domains/settings/settings.widget.ts index e2e999da1f..767e0af2ac 100644 --- a/apps/web/src/lib/server/domains/settings/settings.widget.ts +++ b/apps/web/src/lib/server/domains/settings/settings.widget.ts @@ -433,6 +433,38 @@ export async function getWidgetSecret(): Promise { } } +/** + * Admin-only: return the workspace signing secret, minting one if missing. + * Identify and other public paths must keep using {@link getWidgetSecret}. + */ +export async function ensureWidgetSecret(): Promise { + log.info('ensure widget secret') + try { + const org = await requireSettings() + if (org.widgetSecret) return org.widgetSecret + + const secret = generateWidgetSecret() + const [updated] = await db + .update(settings) + .set({ widgetSecret: secret }) + .where(and(eq(settings.id, org.id), isNull(settings.widgetSecret))) + .returning({ widgetSecret: settings.widgetSecret }) + if (updated?.widgetSecret) { + await invalidateSettingsCache() + return updated.widgetSecret + } + + const again = await requireSettings() + if (!again.widgetSecret) { + throw new Error('widget secret missing after ensure') + } + return again.widgetSecret + } catch (error) { + log.error({ err: error }, 'ensure widget secret failed') + wrapDbError('ensure widget secret', error) + } +} + /** Regenerate the widget secret. Returns the new secret once. */ export async function regenerateWidgetSecret(): Promise { log.info('regenerate widget secret') diff --git a/apps/web/src/lib/server/functions/settings.ts b/apps/web/src/lib/server/functions/settings.ts index ca4b252832..8c3c1f3212 100644 --- a/apps/web/src/lib/server/functions/settings.ts +++ b/apps/web/src/lib/server/functions/settings.ts @@ -740,8 +740,8 @@ export const fetchWidgetConfig = createServerFn({ method: 'GET' }).handler(async export const fetchWidgetSecret = createServerFn({ method: 'GET' }).handler(async () => { log.debug('fetch widget secret') await requireAuth({ permission: PERMISSIONS.SETTINGS_MANAGE }) - const { getWidgetSecret } = await import('@/lib/server/domains/settings/settings.widget') - return await getWidgetSecret() + const { ensureWidgetSecret } = await import('@/lib/server/domains/settings/settings.widget') + return await ensureWidgetSecret() }) const messengerConfigInputSchema = z.object({ diff --git a/apps/web/src/lib/shared/widget/__tests__/install-prompt.test.ts b/apps/web/src/lib/shared/widget/__tests__/install-prompt.test.ts index 09198c8841..09219cde36 100644 --- a/apps/web/src/lib/shared/widget/__tests__/install-prompt.test.ts +++ b/apps/web/src/lib/shared/widget/__tests__/install-prompt.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from 'vitest' import { - WIDGET_SECRET_ENV, - WIDGET_SECRET_PLACEHOLDER, WIDGET_SKILL_RAW, buildWidgetInstallPrompt, buildWidgetInstallSnippet, @@ -9,7 +7,7 @@ import { } from '../install-prompt' describe('buildWidgetInstallPrompt', () => { - it('points the agent at the public skill and includes verified identify rules', () => { + it('installs the launcher only by default', () => { const prompt = buildWidgetInstallPrompt({ instanceUrl: 'https://feedback.example.com/', widgetSecret: 'wgt_abc123secret', @@ -17,54 +15,71 @@ describe('buildWidgetInstallPrompt', () => { expect(prompt).toContain('Instance URL: https://feedback.example.com') expect(prompt).toContain('https://feedback.example.com/api/widget/sdk.js') - expect(prompt).toContain('wgt_abc123secret') - expect(prompt).toContain(WIDGET_SECRET_ENV) expect(prompt).toContain(WIDGET_SKILL_RAW) + expect(prompt).toContain('Install the launcher only') + expect(prompt).not.toContain('wgt_abc123secret') + expect(prompt).not.toContain('QUACKBACK_WIDGET_SECRET') + expect(prompt).not.toContain('Do not skip identify') + expect(prompt).not.toContain('No widget secret has been generated yet') + }) + + it('includes the signing secret and identify steps when identify is on', () => { + const prompt = buildWidgetInstallPrompt({ + instanceUrl: 'https://feedback.example.com/', + widgetSecret: 'wgt_abc123secret', + identify: true, + }) + + expect(prompt).toContain('wgt_abc123secret') + expect(prompt).toContain('host app server-side secret store') expect(prompt).toContain('ssoToken') expect(prompt).toContain('Once per session') expect(prompt).toContain('Never pass raw id/email from the client') + expect(prompt).not.toContain('QUACKBACK_WIDGET_SECRET') }) - it('uses a placeholder when no secret has been generated', () => { + it('does not invent a placeholder secret when identify is on but the secret is missing', () => { const prompt = buildWidgetInstallPrompt({ instanceUrl: 'https://feedback.example.com', widgetSecret: null, + identify: true, }) - expect(prompt).toContain(WIDGET_SECRET_PLACEHOLDER) - expect(prompt).toContain('No widget secret has been generated yet') + expect(prompt).toContain('Do not invent one') + expect(prompt).not.toContain('wgt_YOUR_WIDGET_SECRET') + expect(prompt).not.toContain('after they regenerate it') }) }) describe('buildWidgetInstallSnippet', () => { - it('documents identify primitives without assuming a host session API', () => { + it('omits identify by default', () => { const snippet = buildWidgetInstallSnippet({ instanceUrl: 'https://feedback.example.com/', }) expect(snippet).toContain('https://feedback.example.com/api/widget/sdk.js') expect(snippet).toContain('Quackback("init")') + expect(snippet).not.toContain('ssoToken') + expect(snippet).not.toContain('QUACKBACK_WIDGET_SECRET') + }) + + it('documents identify primitives without assuming a host session API', () => { + const snippet = buildWidgetInstallSnippet({ + instanceUrl: 'https://feedback.example.com/', + identify: true, + }) + expect(snippet).toContain('ssoToken') expect(snippet).toContain('Quackback("identify", { ssoToken })') expect(snippet).toContain('Quackback("logout")') - expect(snippet).toContain(WIDGET_SECRET_ENV) + expect(snippet).toContain('Admin → Settings → Widget → Install') expect(snippet).toContain('stable unique user id') + expect(snippet).not.toContain('QUACKBACK_WIDGET_SECRET') expect(snippet).not.toContain('Quackback("identify", { id') expect(snippet).not.toContain('fetch(') expect(snippet).not.toContain('/api/quackback') expect(snippet).not.toContain('user.id') }) - - it('omits identify when the switch is off', () => { - const snippet = buildWidgetInstallSnippet({ - instanceUrl: 'https://feedback.example.com', - identify: false, - }) - - expect(snippet).toContain('Quackback("init")') - expect(snippet).not.toContain('ssoToken') - expect(snippet).not.toContain('user.id') - }) }) describe('maskWidgetSecretInPrompt', () => { @@ -73,6 +88,7 @@ describe('maskWidgetSecretInPrompt', () => { const prompt = buildWidgetInstallPrompt({ instanceUrl: 'https://feedback.example.com', widgetSecret: secret, + identify: true, }) const masked = maskWidgetSecretInPrompt(prompt, secret) @@ -80,7 +96,7 @@ describe('maskWidgetSecretInPrompt', () => { expect(masked).toContain('wgt_abc1••••••••') }) - it('leaves placeholder prompts unchanged', () => { + it('leaves launcher-only prompts unchanged', () => { const prompt = buildWidgetInstallPrompt({ instanceUrl: 'https://feedback.example.com', widgetSecret: null, diff --git a/apps/web/src/lib/shared/widget/install-prompt.ts b/apps/web/src/lib/shared/widget/install-prompt.ts index 540eef001a..c12748c410 100644 --- a/apps/web/src/lib/shared/widget/install-prompt.ts +++ b/apps/web/src/lib/shared/widget/install-prompt.ts @@ -1,10 +1,10 @@ export interface WidgetInstallPromptInput { instanceUrl: string widgetSecret: string | null + /** When true, the prompt includes identify steps and the signing secret. */ + identify?: boolean } -export const WIDGET_SECRET_ENV = 'QUACKBACK_WIDGET_SECRET' -export const WIDGET_SECRET_PLACEHOLDER = 'wgt_YOUR_WIDGET_SECRET' export const WIDGET_SKILL_REPO = 'https://github.com/QuackbackIO/skills' export const WIDGET_SKILL_RAW = 'https://raw.githubusercontent.com/QuackbackIO/skills/main/skills/quackback/install-widget/SKILL.md' @@ -18,38 +18,62 @@ function trimTrailingSlash(url: string): string { /** Short prompt an agent pastes: install the public skill, then use these credentials. */ export function buildWidgetInstallPrompt(input: WidgetInstallPromptInput): string { const instanceUrl = trimTrailingSlash(input.instanceUrl) - const secret = input.widgetSecret ?? WIDGET_SECRET_PLACEHOLDER - const secretNote = input.widgetSecret - ? 'A widget secret is included below. Store it in a server-only env var. Never ship it to the browser, commit it, or log it.' - : 'No widget secret has been generated yet. Use the placeholder below and ask the user to paste the real secret from Admin → Settings → Widget after they regenerate it.' + const identify = input.identify === true + const secret = identify ? input.widgetSecret : null + + if (!identify) { + return `# Install the Quackback widget + +Add the launcher so anonymous visitors can use the widget after init. Identifying signed-in users is optional — skip it unless the user asked. + +## Workspace +- Instance URL: ${instanceUrl} +- SDK script: ${instanceUrl}/api/widget/sdk.js + +## What to do +1. Fetch and follow the \`install-widget\` skill: + - ${WIDGET_SKILL_RAW} +2. Install the launcher only. Do not invent a signing secret. Do not implement identify unless the user provided the signing secret from Admin → Settings → Widget → Install. +3. Use the credentials above. Do not invent APIs. + +Repo: ${WIDGET_SKILL_REPO} +` + } + + const secretLine = secret + ? `- Widget signing secret (host app server only): ${secret}` + : '- Widget signing secret: ask the user to copy it from Admin → Settings → Widget → Install. Do not invent one.' return `# Install the Quackback widget -${secretNote} +${ + secret + ? 'A signing secret is included below. Store it in the host app server-side secret store — not in Quackback Cloud or self-host env. Never ship it to the browser, commit it, or log it.' + : 'The user wants identify. Copy the signing secret from Admin → Settings → Widget → Install. Do not invent one.' +} ## Workspace - Instance URL: ${instanceUrl} - SDK script: ${instanceUrl}/api/widget/sdk.js -- Widget secret (server-only): ${secret} -- Env var name: ${WIDGET_SECRET_ENV} +${secretLine} ## What to do 1. Fetch and follow the \`install-widget\` skill: - ${WIDGET_SKILL_RAW} - ${WIDGET_IDENTIFY_RAW} -2. Follow every step in order. Do not skip identify. +2. Install the launcher, then identify signed-in users with a backend-signed ssoToken. 3. Use the credentials above. Do not invent APIs. Repo: ${WIDGET_SKILL_REPO} -## Identify (required for signed-in users) -The widget appears after init for anonymous visitors. Call identify as soon as you know who the user is: when the app first loads if they are already signed in, and immediately after login or signup. Once per session — not on every navigation. Mint a fresh HS256 JWT at that moment and call \`Quackback("identify", { ssoToken })\`. \`sub\` is a unique stable host user id, not email. Call \`Quackback("logout")\` on logout. Never pass raw id/email from the client. +## Identify (signed-in users) +The widget appears after init for anonymous visitors. Call identify as soon as you know who the user is: when the app first loads if they are already signed in, and immediately after login or signup. Once per session — not on every navigation. Mint a fresh HS256 JWT at that moment with the signing secret from Admin → Settings → Widget → Install and call \`Quackback("identify", { ssoToken })\`. \`sub\` is a unique stable host user id, not email. Call \`Quackback("logout")\` on logout. Never pass raw id/email from the client. ` } export interface WidgetInstallSnippetInput { instanceUrl: string - /** Recommended. When true, the snippet identifies signed-in users. Default true. */ + /** When true, the snippet documents identify. Default false. */ identify?: boolean } @@ -62,10 +86,10 @@ function widgetLoader(instanceUrl: string): string { d.head.appendChild(s)})(window,document);` } -/** Script-tag snippet for hand install. Identify-on is the recommended default. */ +/** Script-tag snippet for hand install. Launcher-only is the default. */ export function buildWidgetInstallSnippet(input: WidgetInstallSnippetInput): string { const loader = widgetLoader(input.instanceUrl) - if (input.identify === false) { + if (input.identify !== true) { return `