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; + } +}