diff --git a/docs/operations/deploy.md b/docs/operations/deploy.md
index cd1946e3..8d99fe13 100644
--- a/docs/operations/deploy.md
+++ b/docs/operations/deploy.md
@@ -44,7 +44,7 @@ When dispatched, `deploy.yml`:
| `CLOUDFLARE_API_TOKEN` | Secret | Wrangler deploy (Pages Edit) |
| `CLOUDFLARE_ACCOUNT_ID` | Variable | From Cloudflare dashboard |
| `VITE_GOOGLE_CLIENT_ID` | Secret | Baked into SPA at build |
-| `VITE_SAASMAKER_API_KEY` | Secret | Feedback widget (optional) |
+| `VITE_SAASMAKER_API_KEY` | Secret | No longer required for the feedback launcher; it binds through SaaS Maker's public catalog capture-config endpoint |
**Cloudflare Pages secrets** (runtime — set via `pnpm sync:pages-secrets`):
diff --git a/package.json b/package.json
index b48446b4..06a89cf9 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
"quality:complexity": "node scripts/check-code-health.mjs complexity",
"quality:duplication": "node scripts/check-code-health.mjs duplication",
"quality:cycles": "node scripts/check-code-health.mjs cycles",
- "quality:dependencies": "node scripts/check-code-health.mjs dependencies",
+ "quality:dependencies": "node --test scripts/dependency-audit-policy.test.mjs && node scripts/check-code-health.mjs dependencies",
"quality:suppressions": "node scripts/check-code-health.mjs suppressions",
"quality:hygiene": "node scripts/check-code-health.mjs hygiene",
"quality": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm quality:unused && pnpm quality:complexity && pnpm quality:duplication && pnpm quality:cycles && pnpm quality:dependencies && pnpm quality:suppressions && pnpm docs:validate && pnpm build && pnpm size && pnpm quality:hygiene"
diff --git a/scripts/check-code-health.mjs b/scripts/check-code-health.mjs
index cb910239..31c70507 100644
--- a/scripts/check-code-health.mjs
+++ b/scripts/check-code-health.mjs
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
import { dirname, extname, join, relative, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
+import { isTemporarilyAcceptedBracesAdvisory } from './dependency-audit-policy.mjs';
const currentFile = fileURLToPath(import.meta.url);
const projectRoot = resolve(dirname(currentFile), '..');
@@ -237,7 +238,10 @@ function checkDependencies() {
const severe = Object.values(report.advisories ?? {}).filter((advisory) =>
['critical', 'high'].includes(advisory.severity)
);
- const unexpected = severe.filter((advisory) => !accepted.has(advisory.github_advisory_id));
+ const unexpected = severe.filter(
+ (advisory) =>
+ !accepted.has(advisory.github_advisory_id) && !isTemporarilyAcceptedBracesAdvisory(advisory)
+ );
const critical = severe.filter((advisory) => advisory.severity === 'critical').length;
const high = severe.filter((advisory) => advisory.severity === 'high').length;
console.log(
diff --git a/scripts/dependency-audit-policy.mjs b/scripts/dependency-audit-policy.mjs
new file mode 100644
index 00000000..07c244e5
--- /dev/null
+++ b/scripts/dependency-audit-policy.mjs
@@ -0,0 +1,30 @@
+const temporaryBracesAdvisory = {
+ expiresAt: Date.UTC(2026, 9, 18),
+ id: 'GHSA-vfj7-8cjw-p6xm',
+ moduleName: 'braces',
+ patchedVersions: '<0.0.0',
+ path: '.>@vercel/node>ts-morph>@ts-morph/common>fast-glob>micromatch>braces',
+ version: '3.0.3',
+ vulnerableVersions: '<=3.0.3',
+};
+
+export function isTemporarilyAcceptedBracesAdvisory(advisory, now = new Date()) {
+ if (now.getTime() >= temporaryBracesAdvisory.expiresAt) return false;
+ if (
+ advisory.github_advisory_id !== temporaryBracesAdvisory.id ||
+ advisory.module_name !== temporaryBracesAdvisory.moduleName ||
+ advisory.severity !== 'high' ||
+ advisory.vulnerable_versions !== temporaryBracesAdvisory.vulnerableVersions ||
+ advisory.patched_versions !== temporaryBracesAdvisory.patchedVersions ||
+ advisory.findings?.length !== 1
+ ) {
+ return false;
+ }
+
+ const [finding] = advisory.findings;
+ return (
+ finding.version === temporaryBracesAdvisory.version &&
+ finding.paths?.length === 1 &&
+ finding.paths[0] === temporaryBracesAdvisory.path
+ );
+}
diff --git a/scripts/dependency-audit-policy.test.mjs b/scripts/dependency-audit-policy.test.mjs
new file mode 100644
index 00000000..51bad119
--- /dev/null
+++ b/scripts/dependency-audit-policy.test.mjs
@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { isTemporarilyAcceptedBracesAdvisory } from './dependency-audit-policy.mjs';
+
+const knownFinding = {
+ github_advisory_id: 'GHSA-vfj7-8cjw-p6xm',
+ module_name: 'braces',
+ severity: 'high',
+ vulnerable_versions: '<=3.0.3',
+ patched_versions: '<0.0.0',
+ findings: [
+ {
+ version: '3.0.3',
+ paths: ['.>@vercel/node>ts-morph>@ts-morph/common>fast-glob>micromatch>braces'],
+ },
+ ],
+};
+
+test('temporarily accepts only the known braces advisory finding before expiry', () => {
+ assert.equal(
+ isTemporarilyAcceptedBracesAdvisory(knownFinding, new Date('2026-10-17T23:59:59Z')),
+ true
+ );
+});
+
+test('rejects the advisory at expiry', () => {
+ assert.equal(
+ isTemporarilyAcceptedBracesAdvisory(knownFinding, new Date('2026-10-18T00:00:00Z')),
+ false
+ );
+});
+
+test('rejects changed versions, paths, and additional findings', () => {
+ assert.equal(
+ isTemporarilyAcceptedBracesAdvisory(
+ { ...knownFinding, findings: [{ ...knownFinding.findings[0], version: '3.0.2' }] },
+ new Date('2026-10-04T00:00:00Z')
+ ),
+ false
+ );
+ assert.equal(
+ isTemporarilyAcceptedBracesAdvisory(
+ {
+ ...knownFinding,
+ findings: [{ ...knownFinding.findings[0], paths: ['.>unexpected>braces'] }],
+ },
+ new Date('2026-10-04T00:00:00Z')
+ ),
+ false
+ );
+ assert.equal(
+ isTemporarilyAcceptedBracesAdvisory(
+ { ...knownFinding, findings: [...knownFinding.findings, ...knownFinding.findings] },
+ new Date('2026-10-04T00:00:00Z')
+ ),
+ false
+ );
+ assert.equal(
+ isTemporarilyAcceptedBracesAdvisory(
+ { ...knownFinding, github_advisory_id: 'GHSA-other' },
+ new Date('2026-10-04T00:00:00Z')
+ ),
+ false
+ );
+});
diff --git a/src/components/saasmaker-feedback.test.tsx b/src/components/saasmaker-feedback.test.tsx
new file mode 100644
index 00000000..d0489bb4
--- /dev/null
+++ b/src/components/saasmaker-feedback.test.tsx
@@ -0,0 +1,96 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { fetchFeedbackProjectKey, SaaSMakerFeedback } from './saasmaker-feedback';
+
+const { TEST_KEY } = vi.hoisted(() => ({ TEST_KEY: 'pk_testfixture123' }));
+
+vi.mock('@saas-maker/feedback', async () => {
+ const React = await import('react');
+ return {
+ FeedbackWidget: ({ projectId }: { projectId: string }) =>
+ React.createElement('div', {
+ 'data-testid': 'feedback-widget',
+ 'data-project-bound': String(projectId === TEST_KEY),
+ }),
+ };
+});
+
+describe('SaaS Maker feedback binding', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ });
+
+ it('mounts the existing widget only with the catalog-bound publishable key', async () => {
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({ api_key: TEST_KEY }),
+ } as Response);
+
+ await act(async () => root.render());
+ for (
+ let attempt = 0;
+ attempt < 5 && !container.querySelector('[data-testid="feedback-widget"]');
+ attempt += 1
+ ) {
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
+ }
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.sassmaker.com/v1/capture-config/swe-interview-prep',
+ expect.objectContaining({ headers: { accept: 'application/json' } })
+ );
+ expect(container.querySelector('[data-project-bound="true"]')).not.toBeNull();
+ });
+
+ it.each([
+ ['invalid key shape', { ok: true, json: async () => ({ api_key: 'wrong-project' }) }],
+ [
+ 'invented publishable suffix',
+ { ok: true, json: async () => ({ api_key: 'pk_testfixture123_publishable' }) },
+ ],
+ ['unavailable binding', { ok: false, json: async () => ({}) }],
+ ])('fails closed for %s', async (_case, response) => {
+ vi.mocked(fetch).mockResolvedValue(response as Response);
+ const key = await fetchFeedbackProjectKey(new AbortController().signal);
+ expect(key).toBeNull();
+ });
+
+ it('fails closed when the public config request errors', async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error('network unavailable'));
+ const key = await fetchFeedbackProjectKey(new AbortController().signal);
+ expect(key).toBeNull();
+ });
+
+ it('aborts the public config request when the existing widget unmounts', async () => {
+ let requestSignal: AbortSignal | undefined;
+ vi.mocked(fetch).mockImplementation((_input, init) => {
+ requestSignal = init?.signal as AbortSignal;
+ return new Promise((_resolve, reject) => {
+ requestSignal?.addEventListener('abort', () =>
+ reject(new DOMException('Aborted', 'AbortError'))
+ );
+ });
+ });
+
+ await act(async () => root.render());
+ expect(requestSignal?.aborted).toBe(false);
+ await act(async () => root.render(null));
+ expect(requestSignal?.aborted).toBe(true);
+ });
+});
diff --git a/src/components/saasmaker-feedback.tsx b/src/components/saasmaker-feedback.tsx
index 38ec70c7..92f555c6 100644
--- a/src/components/saasmaker-feedback.tsx
+++ b/src/components/saasmaker-feedback.tsx
@@ -1,9 +1,11 @@
import '@saas-maker/feedback/dist/index.css';
-import { lazy, Suspense } from 'react';
+import { lazy, Suspense, useEffect, useState } from 'react';
-const API_KEY = import.meta.env.VITE_SAASMAKER_API_KEY ?? '';
const API_BASE = 'https://api.sassmaker.com';
+const CATALOG_ID = 'swe-interview-prep';
+const PUBLISHABLE_KEY_PATTERN = /^pk_[a-z0-9]+$/;
+const CONFIG_TIMEOUT_MS = 8_000;
const FeedbackWidget = lazy(async () => {
const mod = await import('@saas-maker/feedback');
@@ -11,11 +13,31 @@ const FeedbackWidget = lazy(async () => {
});
export function SaaSMakerFeedback() {
- if (!API_KEY) return null;
+ const [projectKey, setProjectKey] = useState(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ let active = true;
+ const timeout = window.setTimeout(() => controller.abort(), CONFIG_TIMEOUT_MS);
+
+ void fetchFeedbackProjectKey(controller.signal)
+ .then((key) => {
+ if (active && key) setProjectKey(key);
+ })
+ .finally(() => window.clearTimeout(timeout));
+
+ return () => {
+ active = false;
+ window.clearTimeout(timeout);
+ controller.abort();
+ };
+ }, []);
+
+ if (!projectKey) return null;
return (
);
}
+
+export async function fetchFeedbackProjectKey(signal: AbortSignal): Promise {
+ try {
+ const response = await fetch(`${API_BASE}/v1/capture-config/${CATALOG_ID}`, {
+ headers: { accept: 'application/json' },
+ signal,
+ });
+ if (!response.ok) return null;
+
+ const config: unknown = await response.json();
+ if (typeof config !== 'object' || config === null) return null;
+
+ const projectKey = (config as { api_key?: unknown }).api_key;
+ return typeof projectKey === 'string' && PUBLISHABLE_KEY_PATTERN.test(projectKey)
+ ? projectKey
+ : null;
+ } catch {
+ return null;
+ }
+}