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)}
+
+
+ setRevealed((value) => !value)}
+ aria-label={revealed ? 'Hide signing secret' : 'Reveal signing secret'}
+ >
+ {revealed ? : }
+ {revealed ? 'Hide' : 'Reveal'}
+
+ void copySecret()}
+ disabled={copying}
+ >
+
+ {copying ? 'Copying…' : 'Copy'}
+
+
+
+
setConfirmOpen(true)}
+ >
+ Regenerate…
+
+
{
+ 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 `