Skip to content

Commit a1abd7e

Browse files
committed
feat(qc): add browser privacy verification
1 parent f6306a6 commit a1abd7e

8 files changed

Lines changed: 384 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ pnpm build
4444
pnpm bench:browser
4545
pnpm bench:worker
4646
pnpm audit:lighthouse
47+
pnpm audit:privacy
4748
pnpm --filter @csvshape/web smoke:duckdb
4849
pnpm --filter @csvshape/worker smoke:mlr
4950
docker compose up --build

docs/qc/APPENDIX_B_REPORT.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ Blocked:
4848
Worker-native DuckDB is packaged via `@duckdb/node-api`, reports `duckdbNative: true` on `/health`, and executes inline CSV/JSONL SQL plus Parquet export in local tests.
4949
Worker-native Miller now has a repeatable smoke run via `pnpm --filter @csvshape/worker smoke:mlr`, which returns `engine=mlr-native`, `rowCount=3`, and CSV output for the paid-order ecommerce subset in `docs/qc/benchmarks/native-mlr-smoke.json`.
5050
- 21.12 Browser auto mode now routes simple single-source chains to the faster TypeScript preview path, and the benchmark now runs against a production preview build instead of the dev server, but the browser p95 is still above target.
51-
- 21.13 No explicit privacy verification log yet.
51+
- 21.13 Privacy evidence is now local-only rather than hosted.
52+
`pnpm audit:privacy` produces `docs/qc/benchmarks/browser-privacy.json`, which currently shows no worker calls, no cross-origin calls, and no browser storage writes during a standard browser-side sample transform.
53+
Worker `/health` and `/v1/run` responses continue to expose `artifactTtlSeconds=900` for retention handling.
5254
- 21.14 Miller-reference parity is only partial so far.
5355
`packages/core/test/miller-reference.test.ts` now passes against a real local `mlr` binary for `cat`, `filter`, `put`, `cut`, `join`, `sort`, `stats1`, `reorder`, and `unsparsify`.
5456
Remaining verb gaps are `stats2`, `nest`, and `unnest`.

docs/qc/SECTION_21_STATUS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ It is not yet a qualified release record.
3838
Worker native DuckDB clears the 100M-row threshold at `1057.87 ms` via `docs/qc/benchmarks/worker-duckdb.json`.
3939
Local Lighthouse preview scores are `performance=100`, `accessibility=100`, `best-practices=100`, and `seo=100` via `docs/qc/benchmarks/lighthouse-summary.json`.
4040
- [ ] 21.13 Privacy proof for browser-first processing and worker retention TTL handling.
41+
`pnpm audit:privacy` now records `docs/qc/benchmarks/browser-privacy.json`, showing no worker requests, no cross-origin requests, and no IndexedDB/localStorage/sessionStorage writes during a normal browser-side sample transform.
42+
Worker responses and `/health` continue to document `artifactTtlSeconds=900` for native fallback paths.
4143
- [ ] 21.14 Full per-verb correctness vs Miller reference.
4244
`packages/core/test/miller-reference.test.ts` now verifies `cat`, `filter`, `put`, `cut`, `join`, `sort`, `stats1`, `reorder`, and `unsparsify` against a real local `mlr` binary when available.
4345
Remaining parity gaps are `stats2`, `nest`, and `unnest`.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
{
2+
"baseUrl": "http://127.0.0.1:4175",
3+
"externalRequests": [],
4+
"requestCount": 32,
5+
"requests": [
6+
{
7+
"method": "GET",
8+
"resourceType": "document",
9+
"url": "http://127.0.0.1:4175/"
10+
},
11+
{
12+
"method": "GET",
13+
"resourceType": "script",
14+
"url": "http://127.0.0.1:4175/@vite/client"
15+
},
16+
{
17+
"method": "GET",
18+
"resourceType": "script",
19+
"url": "http://127.0.0.1:4175/src/main.tsx"
20+
},
21+
{
22+
"method": "GET",
23+
"resourceType": "script",
24+
"url": "http://127.0.0.1:4175/@react-refresh"
25+
},
26+
{
27+
"method": "GET",
28+
"resourceType": "script",
29+
"url": "http://127.0.0.1:4175/@fs/C:/Users/chait/OneDrive/Desktop/tools-for-github-push/csv-jsonl-miller/node_modules/.pnpm/vite@7.3.3_@types+node@24.1_1509e18805081f9fa7566c567d9c230c/node_modules/vite/dist/client/env.mjs"
30+
},
31+
{
32+
"method": "GET",
33+
"resourceType": "script",
34+
"url": "http://127.0.0.1:4175/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=b1e6b5be"
35+
},
36+
{
37+
"method": "GET",
38+
"resourceType": "script",
39+
"url": "http://127.0.0.1:4175/node_modules/.vite/deps/react.js?v=b1e6b5be"
40+
},
41+
{
42+
"method": "GET",
43+
"resourceType": "script",
44+
"url": "http://127.0.0.1:4175/node_modules/.vite/deps/react-dom_client.js?v=b1e6b5be"
45+
},
46+
{
47+
"method": "GET",
48+
"resourceType": "script",
49+
"url": "http://127.0.0.1:4175/src/App.tsx"
50+
},
51+
{
52+
"method": "GET",
53+
"resourceType": "script",
54+
"url": "http://127.0.0.1:4175/src/styles.css"
55+
},
56+
{
57+
"method": "GET",
58+
"resourceType": "script",
59+
"url": "http://127.0.0.1:4175/node_modules/.vite/deps/chunk-5SUI2ERJ.js?v=b1e6b5be"
60+
},
61+
{
62+
"method": "GET",
63+
"resourceType": "script",
64+
"url": "http://127.0.0.1:4175/node_modules/.vite/deps/chunk-G3PMV62Z.js?v=b1e6b5be"
65+
},
66+
{
67+
"method": "GET",
68+
"resourceType": "script",
69+
"url": "http://127.0.0.1:4175/node_modules/.vite/deps/chunk-ADAK3L2Y.js?v=b1e6b5be"
70+
},
71+
{
72+
"method": "GET",
73+
"resourceType": "script",
74+
"url": "http://127.0.0.1:4175/@fs/C:/Users/chait/OneDrive/Desktop/tools-for-github-push/csv-jsonl-miller/packages/core/src/index.ts"
75+
},
76+
{
77+
"method": "GET",
78+
"resourceType": "script",
79+
"url": "http://127.0.0.1:4175/node_modules/.vite/deps/lucide-react.js?v=b1e6b5be"
80+
},
81+
{
82+
"method": "GET",
83+
"resourceType": "script",
84+
"url": "http://127.0.0.1:4175/src/catalog.ts"
85+
},
86+
{
87+
"method": "GET",
88+
"resourceType": "script",
89+
"url": "http://127.0.0.1:4175/src/engine.ts"
90+
},
91+
{
92+
"method": "GET",
93+
"resourceType": "script",
94+
"url": "http://127.0.0.1:4175/src/escalation.ts"
95+
},
96+
{
97+
"method": "GET",
98+
"resourceType": "script",
99+
"url": "http://127.0.0.1:4175/src/seo.ts"
100+
},
101+
{
102+
"method": "GET",
103+
"resourceType": "script",
104+
"url": "http://127.0.0.1:4175/src/verb-definitions.ts"
105+
},
106+
{
107+
"method": "GET",
108+
"resourceType": "script",
109+
"url": "http://127.0.0.1:4175/@fs/C:/Users/chait/OneDrive/Desktop/tools-for-github-push/csv-jsonl-miller/packages/core/src/input.ts"
110+
},
111+
{
112+
"method": "GET",
113+
"resourceType": "script",
114+
"url": "http://127.0.0.1:4175/@fs/C:/Users/chait/OneDrive/Desktop/tools-for-github-push/csv-jsonl-miller/packages/core/src/execution.ts"
115+
},
116+
{
117+
"method": "GET",
118+
"resourceType": "script",
119+
"url": "http://127.0.0.1:4175/@fs/C:/Users/chait/OneDrive/Desktop/tools-for-github-push/csv-jsonl-miller/packages/core/src/json-query.ts"
120+
},
121+
{
122+
"method": "GET",
123+
"resourceType": "script",
124+
"url": "http://127.0.0.1:4175/@fs/C:/Users/chait/OneDrive/Desktop/tools-for-github-push/csv-jsonl-miller/packages/core/src/reshape.ts"
125+
},
126+
{
127+
"method": "GET",
128+
"resourceType": "script",
129+
"url": "http://127.0.0.1:4175/@fs/C:/Users/chait/OneDrive/Desktop/tools-for-github-push/csv-jsonl-miller/packages/core/src/export.ts"
130+
},
131+
{
132+
"method": "GET",
133+
"resourceType": "script",
134+
"url": "http://127.0.0.1:4175/@fs/C:/Users/chait/OneDrive/Desktop/tools-for-github-push/csv-jsonl-miller/packages/core/src/replay.ts"
135+
},
136+
{
137+
"method": "GET",
138+
"resourceType": "other",
139+
"url": "http://127.0.0.1:4175/favicon.svg"
140+
},
141+
{
142+
"method": "GET",
143+
"resourceType": "other",
144+
"url": "http://127.0.0.1:4175/favicon.svg"
145+
},
146+
{
147+
"method": "GET",
148+
"resourceType": "fetch",
149+
"url": "http://127.0.0.1:4175/samples/ecommerce-events.csv"
150+
},
151+
{
152+
"method": "GET",
153+
"resourceType": "other",
154+
"url": "http://127.0.0.1:4175/favicon.svg"
155+
},
156+
{
157+
"method": "GET",
158+
"resourceType": "other",
159+
"url": "http://127.0.0.1:4175/favicon.svg"
160+
},
161+
{
162+
"method": "GET",
163+
"resourceType": "other",
164+
"url": "http://127.0.0.1:4175/favicon.svg"
165+
}
166+
],
167+
"storageState": {
168+
"indexedDbNames": [],
169+
"localStorageKeys": [],
170+
"sessionStorageKeys": []
171+
},
172+
"workerBaseUrl": "http://localhost:8797",
173+
"workerRequests": []
174+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"build": "pnpm -r --filter ./packages/* --filter ./apps/* build",
1616
"dev": "pnpm --filter @csvshape/web dev",
1717
"dev:worker": "pnpm --filter @csvshape/worker dev",
18+
"audit:privacy": "node scripts/run-privacy-check.mjs",
1819
"lint": "pnpm -r --filter ./packages/* --filter ./apps/* lint",
1920
"test": "pnpm -r --filter ./packages/* --filter ./apps/* test",
2021
"typecheck": "pnpm -r --filter ./packages/* --filter ./apps/* typecheck",

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"build": "vite build",
99
"dev": "vite",
1010
"lint": "eslint src --ext .ts,.tsx",
11+
"privacy:smoke": "node scripts/privacy-smoke.mjs",
1112
"smoke:duckdb": "node scripts/smoke-duckdb.mjs",
1213
"test": "vitest run",
1314
"typecheck": "tsc -p tsconfig.json"
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { mkdir, writeFile } from 'node:fs/promises';
2+
import { existsSync } from 'node:fs';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
import { chromium } from 'playwright-core';
7+
8+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
9+
const repoRoot = path.resolve(scriptDir, '..', '..', '..');
10+
const baseUrl = process.env.CSVSHAPE_BASE_URL ?? 'http://127.0.0.1:4173';
11+
const workerBaseUrl = process.env.CSVSHAPE_WORKER_BASE_URL ?? 'http://localhost:8797';
12+
const outputPath =
13+
process.env.CSVSHAPE_PRIVACY_OUT ??
14+
path.resolve(repoRoot, 'docs', 'qc', 'benchmarks', 'browser-privacy.json');
15+
16+
const edgeCandidates = [
17+
process.env.CSVSHAPE_EDGE_PATH,
18+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
19+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
20+
].filter(Boolean);
21+
22+
const edgeExecutable = edgeCandidates.find((candidate) => existsSync(candidate));
23+
24+
if (!edgeExecutable) {
25+
throw new Error('Microsoft Edge executable was not found for the privacy smoke test.');
26+
}
27+
28+
const browser = await chromium.launch({
29+
executablePath: edgeExecutable,
30+
headless: true,
31+
});
32+
33+
try {
34+
const page = await browser.newPage({
35+
viewport: {
36+
height: 1400,
37+
width: 1440,
38+
},
39+
});
40+
page.setDefaultTimeout(20_000);
41+
42+
const requests = [];
43+
page.on('request', (request) => {
44+
requests.push({
45+
method: request.method(),
46+
resourceType: request.resourceType(),
47+
url: request.url(),
48+
});
49+
});
50+
51+
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
52+
await page.waitForSelector('text=CSVShape');
53+
await page.getByRole('button', { name: /Ecommerce events CSV/i }).click();
54+
await page.waitForFunction(() => document.body.innerText.includes('ecommerce-events.csv'));
55+
56+
const verbPalette = page.locator('.verb-grid');
57+
await verbPalette.locator('button.verb-chip').nth(1).click({ force: true });
58+
await page.locator('.chain-card').nth(0).locator('label.field input').fill('$status == "paid"');
59+
await page.waitForFunction(() => document.body.innerText.includes('1001'));
60+
61+
const storageState = await page.evaluate(async () => {
62+
const databases =
63+
typeof indexedDB.databases === 'function' ? await indexedDB.databases() : [];
64+
65+
return {
66+
indexedDbNames: databases.map((db) => db.name).filter(Boolean),
67+
localStorageKeys: Object.keys(localStorage),
68+
sessionStorageKeys: Object.keys(sessionStorage),
69+
};
70+
});
71+
72+
const externalRequests = requests.filter((request) => {
73+
if (
74+
request.url.startsWith('data:') ||
75+
request.url.startsWith('about:') ||
76+
request.url.startsWith('blob:')
77+
) {
78+
return false;
79+
}
80+
81+
return !request.url.startsWith(baseUrl);
82+
});
83+
const workerRequests = requests.filter((request) => request.url.startsWith(workerBaseUrl));
84+
const result = {
85+
baseUrl,
86+
externalRequests,
87+
requestCount: requests.length,
88+
requests,
89+
storageState,
90+
workerBaseUrl,
91+
workerRequests,
92+
};
93+
94+
if (workerRequests.length > 0) {
95+
throw new Error(`Unexpected worker requests were observed: ${workerRequests.map((request) => request.url).join(', ')}`);
96+
}
97+
98+
if (externalRequests.length > 0) {
99+
throw new Error(`Unexpected external requests were observed: ${externalRequests.map((request) => request.url).join(', ')}`);
100+
}
101+
102+
await mkdir(path.dirname(outputPath), { recursive: true });
103+
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
104+
console.log(JSON.stringify(result, null, 2));
105+
} finally {
106+
await browser.close();
107+
}

0 commit comments

Comments
 (0)