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..63d767c1e3 --- /dev/null +++ b/apps/web/src/components/admin/settings/widget/__tests__/widget-signing-secret.test.tsx @@ -0,0 +1,92 @@ +// @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, copyWithFallback, toast } = vi.hoisted(() => ({ + mutateAsync: vi.fn(), + copyWithFallback: vi.fn(), + toast: { success: vi.fn(), error: 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, +})) + +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') + toast.success.mockReset() + toast.error.mockReset() + }) + + 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() + }) + expect(toast.success).toHaveBeenCalledWith('Signing secret regenerated') + }) + + it('keeps the dialog open and toasts when regenerate fails', async () => { + mutateAsync.mockRejectedValue(new Error('nope')) + const { WidgetSigningSecret } = await import('../widget-signing-secret') + render() + + fireEvent.click(screen.getByRole('button', { name: 'Regenerate…' })) + fireEvent.click(screen.getByRole('button', { name: 'Regenerate secret' })) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Could not regenerate the signing secret') + }) + expect(screen.getByText('Regenerate signing secret?')).toBeInTheDocument() + }) +}) 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..12b86830c0 --- /dev/null +++ b/apps/web/src/components/admin/settings/widget/widget-signing-secret.tsx @@ -0,0 +1,102 @@ +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

+

+ Paste this into your app's server env (any name). Do not add it to Quackback Cloud or + your Quackback host. Some older notes called it QUACKBACK_WIDGET_SECRET — same value. +

+
+
+ + {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/components/shared/__tests__/confirm-dialog.test.tsx b/apps/web/src/components/shared/__tests__/confirm-dialog.test.tsx new file mode 100644 index 0000000000..fa6afe967a --- /dev/null +++ b/apps/web/src/components/shared/__tests__/confirm-dialog.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ConfirmDialog } from '../confirm-dialog' + +describe('ConfirmDialog', () => { + it('keeps the dialog open for an async confirm and ignores a second click', async () => { + let resolveConfirm: (() => void) | undefined + const onConfirm = vi.fn( + () => + new Promise((resolve) => { + resolveConfirm = resolve + }) + ) + const onOpenChange = vi.fn() + + render( + + ) + + const confirm = screen.getByRole('button', { name: 'Rotate now' }) + fireEvent.click(confirm) + fireEvent.click(confirm) + + expect(onConfirm).toHaveBeenCalledOnce() + expect(onOpenChange).not.toHaveBeenCalled() + expect(confirm).toBeDisabled() + + resolveConfirm?.() + await waitFor(() => { + expect(confirm).not.toBeDisabled() + }) + }) + + it('does not reject when the async confirm fails', async () => { + const onConfirm = vi.fn(() => Promise.reject(new Error('nope'))) + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Rotate now' })) + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledOnce() + }) + expect(screen.getByText('Rotate?')).toBeInTheDocument() + }) + + it('lets a sync confirm close the dialog', () => { + const onConfirm = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(onConfirm).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/web/src/components/shared/confirm-dialog.tsx b/apps/web/src/components/shared/confirm-dialog.tsx index 64356413a6..ed7d39431f 100644 --- a/apps/web/src/components/shared/confirm-dialog.tsx +++ b/apps/web/src/components/shared/confirm-dialog.tsx @@ -1,3 +1,4 @@ +import { useRef, useState } from 'react' import { AlertDialog, AlertDialogAction, @@ -39,8 +40,23 @@ export function ConfirmDialog({ onConfirm, children, }: ConfirmDialogProps) { + const startedRef = useRef(false) + const [started, setStarted] = useState(false) + const busy = Boolean(isPending) || started + + function resetStarted() { + startedRef.current = false + setStarted(false) + } + return ( - + { + if (!next) resetStarted() + onOpenChange(next) + }} + > {title} @@ -65,10 +81,27 @@ export function ConfirmDialog({ )} - {cancelLabel} + {cancelLabel} { + if (busy || startedRef.current) { + event.preventDefault() + return + } + let result: void | Promise + try { + result = onConfirm() + } catch { + return + } + if (result && typeof result.then === 'function') { + event.preventDefault() + startedRef.current = true + setStarted(true) + void result.catch(() => undefined).finally(resetStarted) + } + }} + disabled={busy} className={cn(variant === 'destructive' && buttonVariants({ variant: 'destructive' }))} > {confirmLabel} diff --git a/apps/web/src/lib/client/mutations/__tests__/settings.test.ts b/apps/web/src/lib/client/mutations/__tests__/settings.test.ts index 657a8d97a0..ca3955219a 100644 --- a/apps/web/src/lib/client/mutations/__tests__/settings.test.ts +++ b/apps/web/src/lib/client/mutations/__tests__/settings.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const invalidateQueries = vi.fn() +const setQueryData = vi.fn() const updateThemeFn = vi.fn(async () => ({ ok: true })) const updateCustomCssFn = vi.fn(async () => ({ ok: true })) @@ -10,7 +11,7 @@ vi.mock('@tanstack/react-query', async () => { return { ...actual, useMutation: vi.fn((options: unknown) => options), - useQueryClient: vi.fn(() => ({ invalidateQueries })), + useQueryClient: vi.fn(() => ({ invalidateQueries, setQueryData })), } }) @@ -59,12 +60,13 @@ describe('settings config mutations cache invalidation', () => { expect(result).toBeInstanceOf(Promise) }) - it('useRegenerateWidgetSecret.onSuccess awaits invalidation of the widgetSecret query', async () => { + it('useRegenerateWidgetSecret.onSuccess writes the new secret then awaits invalidation', async () => { const { useRegenerateWidgetSecret } = await import('../settings') - const mutation = useRegenerateWidgetSecret() as { onSuccess?: () => unknown } + const mutation = useRegenerateWidgetSecret() as { onSuccess?: (secret: string) => unknown } - const result = mutation.onSuccess?.() + const result = mutation.onSuccess?.('wgt_new') + expect(setQueryData).toHaveBeenCalledWith(['settings', 'widgetSecret'], 'wgt_new') expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['settings', 'widgetSecret'] }) expect(result).toBeInstanceOf(Promise) }) diff --git a/apps/web/src/lib/client/mutations/settings.ts b/apps/web/src/lib/client/mutations/settings.ts index 67b568026c..3f0509a097 100644 --- a/apps/web/src/lib/client/mutations/settings.ts +++ b/apps/web/src/lib/client/mutations/settings.ts @@ -302,8 +302,10 @@ export function useRegenerateWidgetSecret() { return useMutation({ mutationFn: () => regenerateWidgetSecretFn(), - onSuccess: () => - queryClient.invalidateQueries({ queryKey: settingsQueries.widgetSecret().queryKey }), + onSuccess: (secret) => { + queryClient.setQueryData(settingsQueries.widgetSecret().queryKey, secret) + return queryClient.invalidateQueries({ queryKey: settingsQueries.widgetSecret().queryKey }) + }, }) } 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..a765be8f03 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,39 @@ 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.debug('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) { + log.info('minted widget secret') + 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..d828416f8b 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,72 @@ 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('Do not ask the user for QUACKBACK_WIDGET_SECRET') + expect(prompt).toContain('Do not implement identify') + expect(prompt).toContain('Show on your website') + expect(prompt).not.toContain('wgt_abc123secret') + 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 +89,7 @@ describe('maskWidgetSecretInPrompt', () => { const prompt = buildWidgetInstallPrompt({ instanceUrl: 'https://feedback.example.com', widgetSecret: secret, + identify: true, }) const masked = maskWidgetSecretInPrompt(prompt, secret) @@ -80,7 +97,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..b6375725e3 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,65 @@ 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 + +Launcher only. Anonymous visitors should see the widget after init. + +Do not ask the user for QUACKBACK_WIDGET_SECRET. Do not invent a signing secret. Do not implement identify. Quackback Cloud and self-host do not define a widget secret env var. + +## Workspace +- Instance URL: ${instanceUrl} +- SDK script: ${instanceUrl}/api/widget/sdk.js + +## What to do +1. Add the snippet or npm package and call init. Use the URL above. +2. Remind the user to turn on Show on your website in Admin → Settings → Widget → Install. +3. Stop. If they later want identify, they will copy the signing secret from Admin → Settings → Widget → Install. + +Optional skill (launcher steps only): ${WIDGET_SKILL_RAW} + +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 +89,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 `