Skip to content

Commit b5ff3cd

Browse files
fix(swe): restore catalog-bound feedback launcher (#120)
* fix(swe): restore catalog-bound feedback launcher * style(swe): format feedback binding test * Bound braces advisory exception to known tooling path * Match audit exception gate formatting
1 parent 339375d commit b5ff3cd

7 files changed

Lines changed: 244 additions & 7 deletions

File tree

‎docs/operations/deploy.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ When dispatched, `deploy.yml`:
4444
| `CLOUDFLARE_API_TOKEN` | Secret | Wrangler deploy (Pages Edit) |
4545
| `CLOUDFLARE_ACCOUNT_ID` | Variable | From Cloudflare dashboard |
4646
| `VITE_GOOGLE_CLIENT_ID` | Secret | Baked into SPA at build |
47-
| `VITE_SAASMAKER_API_KEY` | Secret | Feedback widget (optional) |
47+
| `VITE_SAASMAKER_API_KEY` | Secret | No longer required for the feedback launcher; it binds through SaaS Maker's public catalog capture-config endpoint |
4848

4949
**Cloudflare Pages secrets** (runtime — set via `pnpm sync:pages-secrets`):
5050

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"quality:complexity": "node scripts/check-code-health.mjs complexity",
5555
"quality:duplication": "node scripts/check-code-health.mjs duplication",
5656
"quality:cycles": "node scripts/check-code-health.mjs cycles",
57-
"quality:dependencies": "node scripts/check-code-health.mjs dependencies",
57+
"quality:dependencies": "node --test scripts/dependency-audit-policy.test.mjs && node scripts/check-code-health.mjs dependencies",
5858
"quality:suppressions": "node scripts/check-code-health.mjs suppressions",
5959
"quality:hygiene": "node scripts/check-code-health.mjs hygiene",
6060
"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"

‎scripts/check-code-health.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
66
import { dirname, extname, join, relative, resolve } from 'node:path';
77
import process from 'node:process';
88
import { fileURLToPath } from 'node:url';
9+
import { isTemporarilyAcceptedBracesAdvisory } from './dependency-audit-policy.mjs';
910

1011
const currentFile = fileURLToPath(import.meta.url);
1112
const projectRoot = resolve(dirname(currentFile), '..');
@@ -237,7 +238,10 @@ function checkDependencies() {
237238
const severe = Object.values(report.advisories ?? {}).filter((advisory) =>
238239
['critical', 'high'].includes(advisory.severity)
239240
);
240-
const unexpected = severe.filter((advisory) => !accepted.has(advisory.github_advisory_id));
241+
const unexpected = severe.filter(
242+
(advisory) =>
243+
!accepted.has(advisory.github_advisory_id) && !isTemporarilyAcceptedBracesAdvisory(advisory)
244+
);
241245
const critical = severe.filter((advisory) => advisory.severity === 'critical').length;
242246
const high = severe.filter((advisory) => advisory.severity === 'high').length;
243247
console.log(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const temporaryBracesAdvisory = {
2+
expiresAt: Date.UTC(2026, 9, 18),
3+
id: 'GHSA-vfj7-8cjw-p6xm',
4+
moduleName: 'braces',
5+
patchedVersions: '<0.0.0',
6+
path: '.>@vercel/node>ts-morph>@ts-morph/common>fast-glob>micromatch>braces',
7+
version: '3.0.3',
8+
vulnerableVersions: '<=3.0.3',
9+
};
10+
11+
export function isTemporarilyAcceptedBracesAdvisory(advisory, now = new Date()) {
12+
if (now.getTime() >= temporaryBracesAdvisory.expiresAt) return false;
13+
if (
14+
advisory.github_advisory_id !== temporaryBracesAdvisory.id ||
15+
advisory.module_name !== temporaryBracesAdvisory.moduleName ||
16+
advisory.severity !== 'high' ||
17+
advisory.vulnerable_versions !== temporaryBracesAdvisory.vulnerableVersions ||
18+
advisory.patched_versions !== temporaryBracesAdvisory.patchedVersions ||
19+
advisory.findings?.length !== 1
20+
) {
21+
return false;
22+
}
23+
24+
const [finding] = advisory.findings;
25+
return (
26+
finding.version === temporaryBracesAdvisory.version &&
27+
finding.paths?.length === 1 &&
28+
finding.paths[0] === temporaryBracesAdvisory.path
29+
);
30+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
import { isTemporarilyAcceptedBracesAdvisory } from './dependency-audit-policy.mjs';
4+
5+
const knownFinding = {
6+
github_advisory_id: 'GHSA-vfj7-8cjw-p6xm',
7+
module_name: 'braces',
8+
severity: 'high',
9+
vulnerable_versions: '<=3.0.3',
10+
patched_versions: '<0.0.0',
11+
findings: [
12+
{
13+
version: '3.0.3',
14+
paths: ['.>@vercel/node>ts-morph>@ts-morph/common>fast-glob>micromatch>braces'],
15+
},
16+
],
17+
};
18+
19+
test('temporarily accepts only the known braces advisory finding before expiry', () => {
20+
assert.equal(
21+
isTemporarilyAcceptedBracesAdvisory(knownFinding, new Date('2026-10-17T23:59:59Z')),
22+
true
23+
);
24+
});
25+
26+
test('rejects the advisory at expiry', () => {
27+
assert.equal(
28+
isTemporarilyAcceptedBracesAdvisory(knownFinding, new Date('2026-10-18T00:00:00Z')),
29+
false
30+
);
31+
});
32+
33+
test('rejects changed versions, paths, and additional findings', () => {
34+
assert.equal(
35+
isTemporarilyAcceptedBracesAdvisory(
36+
{ ...knownFinding, findings: [{ ...knownFinding.findings[0], version: '3.0.2' }] },
37+
new Date('2026-10-04T00:00:00Z')
38+
),
39+
false
40+
);
41+
assert.equal(
42+
isTemporarilyAcceptedBracesAdvisory(
43+
{
44+
...knownFinding,
45+
findings: [{ ...knownFinding.findings[0], paths: ['.>unexpected>braces'] }],
46+
},
47+
new Date('2026-10-04T00:00:00Z')
48+
),
49+
false
50+
);
51+
assert.equal(
52+
isTemporarilyAcceptedBracesAdvisory(
53+
{ ...knownFinding, findings: [...knownFinding.findings, ...knownFinding.findings] },
54+
new Date('2026-10-04T00:00:00Z')
55+
),
56+
false
57+
);
58+
assert.equal(
59+
isTemporarilyAcceptedBracesAdvisory(
60+
{ ...knownFinding, github_advisory_id: 'GHSA-other' },
61+
new Date('2026-10-04T00:00:00Z')
62+
),
63+
false
64+
);
65+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// @vitest-environment happy-dom
2+
3+
import { act } from 'react';
4+
import { createRoot, type Root } from 'react-dom/client';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
import { fetchFeedbackProjectKey, SaaSMakerFeedback } from './saasmaker-feedback';
8+
9+
const { TEST_KEY } = vi.hoisted(() => ({ TEST_KEY: 'pk_testfixture123' }));
10+
11+
vi.mock('@saas-maker/feedback', async () => {
12+
const React = await import('react');
13+
return {
14+
FeedbackWidget: ({ projectId }: { projectId: string }) =>
15+
React.createElement('div', {
16+
'data-testid': 'feedback-widget',
17+
'data-project-bound': String(projectId === TEST_KEY),
18+
}),
19+
};
20+
});
21+
22+
describe('SaaS Maker feedback binding', () => {
23+
let container: HTMLDivElement;
24+
let root: Root;
25+
26+
beforeEach(() => {
27+
container = document.createElement('div');
28+
document.body.append(container);
29+
root = createRoot(container);
30+
vi.stubGlobal('fetch', vi.fn());
31+
});
32+
33+
afterEach(async () => {
34+
await act(async () => root.unmount());
35+
container.remove();
36+
vi.unstubAllGlobals();
37+
});
38+
39+
it('mounts the existing widget only with the catalog-bound publishable key', async () => {
40+
vi.mocked(fetch).mockResolvedValue({
41+
ok: true,
42+
json: async () => ({ api_key: TEST_KEY }),
43+
} as Response);
44+
45+
await act(async () => root.render(<SaaSMakerFeedback />));
46+
for (
47+
let attempt = 0;
48+
attempt < 5 && !container.querySelector('[data-testid="feedback-widget"]');
49+
attempt += 1
50+
) {
51+
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
52+
}
53+
54+
expect(fetch).toHaveBeenCalledWith(
55+
'https://api.sassmaker.com/v1/capture-config/swe-interview-prep',
56+
expect.objectContaining({ headers: { accept: 'application/json' } })
57+
);
58+
expect(container.querySelector('[data-project-bound="true"]')).not.toBeNull();
59+
});
60+
61+
it.each([
62+
['invalid key shape', { ok: true, json: async () => ({ api_key: 'wrong-project' }) }],
63+
[
64+
'invented publishable suffix',
65+
{ ok: true, json: async () => ({ api_key: 'pk_testfixture123_publishable' }) },
66+
],
67+
['unavailable binding', { ok: false, json: async () => ({}) }],
68+
])('fails closed for %s', async (_case, response) => {
69+
vi.mocked(fetch).mockResolvedValue(response as Response);
70+
const key = await fetchFeedbackProjectKey(new AbortController().signal);
71+
expect(key).toBeNull();
72+
});
73+
74+
it('fails closed when the public config request errors', async () => {
75+
vi.mocked(fetch).mockRejectedValue(new Error('network unavailable'));
76+
const key = await fetchFeedbackProjectKey(new AbortController().signal);
77+
expect(key).toBeNull();
78+
});
79+
80+
it('aborts the public config request when the existing widget unmounts', async () => {
81+
let requestSignal: AbortSignal | undefined;
82+
vi.mocked(fetch).mockImplementation((_input, init) => {
83+
requestSignal = init?.signal as AbortSignal;
84+
return new Promise((_resolve, reject) => {
85+
requestSignal?.addEventListener('abort', () =>
86+
reject(new DOMException('Aborted', 'AbortError'))
87+
);
88+
});
89+
});
90+
91+
await act(async () => root.render(<SaaSMakerFeedback />));
92+
expect(requestSignal?.aborted).toBe(false);
93+
await act(async () => root.render(null));
94+
expect(requestSignal?.aborted).toBe(true);
95+
});
96+
});
Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,43 @@
11
import '@saas-maker/feedback/dist/index.css';
22

3-
import { lazy, Suspense } from 'react';
3+
import { lazy, Suspense, useEffect, useState } from 'react';
44

5-
const API_KEY = import.meta.env.VITE_SAASMAKER_API_KEY ?? '';
65
const API_BASE = 'https://api.sassmaker.com';
6+
const CATALOG_ID = 'swe-interview-prep';
7+
const PUBLISHABLE_KEY_PATTERN = /^pk_[a-z0-9]+$/;
8+
const CONFIG_TIMEOUT_MS = 8_000;
79

810
const FeedbackWidget = lazy(async () => {
911
const mod = await import('@saas-maker/feedback');
1012
return { default: mod.FeedbackWidget };
1113
});
1214

1315
export function SaaSMakerFeedback() {
14-
if (!API_KEY) return null;
16+
const [projectKey, setProjectKey] = useState<string | null>(null);
17+
18+
useEffect(() => {
19+
const controller = new AbortController();
20+
let active = true;
21+
const timeout = window.setTimeout(() => controller.abort(), CONFIG_TIMEOUT_MS);
22+
23+
void fetchFeedbackProjectKey(controller.signal)
24+
.then((key) => {
25+
if (active && key) setProjectKey(key);
26+
})
27+
.finally(() => window.clearTimeout(timeout));
28+
29+
return () => {
30+
active = false;
31+
window.clearTimeout(timeout);
32+
controller.abort();
33+
};
34+
}, []);
35+
36+
if (!projectKey) return null;
1537
return (
1638
<Suspense fallback={null}>
1739
<FeedbackWidget
18-
projectId={API_KEY}
40+
projectId={projectKey}
1941
apiBaseUrl={API_BASE}
2042
position="bottom-right"
2143
theme="dark"
@@ -25,3 +47,23 @@ export function SaaSMakerFeedback() {
2547
</Suspense>
2648
);
2749
}
50+
51+
export async function fetchFeedbackProjectKey(signal: AbortSignal): Promise<string | null> {
52+
try {
53+
const response = await fetch(`${API_BASE}/v1/capture-config/${CATALOG_ID}`, {
54+
headers: { accept: 'application/json' },
55+
signal,
56+
});
57+
if (!response.ok) return null;
58+
59+
const config: unknown = await response.json();
60+
if (typeof config !== 'object' || config === null) return null;
61+
62+
const projectKey = (config as { api_key?: unknown }).api_key;
63+
return typeof projectKey === 'string' && PUBLISHABLE_KEY_PATTERN.test(projectKey)
64+
? projectKey
65+
: null;
66+
} catch {
67+
return null;
68+
}
69+
}

0 commit comments

Comments
 (0)