Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/operations/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion scripts/check-code-health.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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), '..');
Expand Down Expand Up @@ -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(
Expand Down
30 changes: 30 additions & 0 deletions scripts/dependency-audit-policy.mjs
Original file line number Diff line number Diff line change
@@ -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
);
}
65 changes: 65 additions & 0 deletions scripts/dependency-audit-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -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
);
});
96 changes: 96 additions & 0 deletions src/components/saasmaker-feedback.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SaaSMakerFeedback />));
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(<SaaSMakerFeedback />));
expect(requestSignal?.aborted).toBe(false);
await act(async () => root.render(null));
expect(requestSignal?.aborted).toBe(true);
});
});
50 changes: 46 additions & 4 deletions src/components/saasmaker-feedback.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,43 @@
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');
return { default: mod.FeedbackWidget };
});

export function SaaSMakerFeedback() {
if (!API_KEY) return null;
const [projectKey, setProjectKey] = useState<string | null>(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 (
<Suspense fallback={null}>
<FeedbackWidget
projectId={API_KEY}
projectId={projectKey}
apiBaseUrl={API_BASE}
position="bottom-right"
theme="dark"
Expand All @@ -25,3 +47,23 @@ export function SaaSMakerFeedback() {
</Suspense>
);
}

export async function fetchFeedbackProjectKey(signal: AbortSignal): Promise<string | null> {
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;
}
}
Loading