@olomonhelpdesk2/helpdesk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/helpdesk-capture.mjs +3275 -0
- package/helpdesk-seed.mjs +1373 -0
- package/helpdesk.mjs +1097 -0
- package/package.json +22 -0
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// adapters/seed-bin.ts
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
// core/cli-package.ts
|
|
7
|
+
var HELPDESK_CLI_PACKAGE = "@olomonhelpdesk2/helpdesk";
|
|
8
|
+
function helpdeskCommand(command, args = []) {
|
|
9
|
+
const parts = ["npx", "--yes", "-p", HELPDESK_CLI_PACKAGE, command, ...args];
|
|
10
|
+
return parts.join(" ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// core/seed/cassette.ts
|
|
14
|
+
var CASSETTE_SCHEMA = "olomon.seed-cassette/1";
|
|
15
|
+
var DROP_HEADERS = /* @__PURE__ */ new Set([
|
|
16
|
+
"set-cookie",
|
|
17
|
+
"authorization",
|
|
18
|
+
"proxy-authorization",
|
|
19
|
+
"www-authenticate",
|
|
20
|
+
"connection",
|
|
21
|
+
"keep-alive",
|
|
22
|
+
"transfer-encoding",
|
|
23
|
+
"content-length",
|
|
24
|
+
"content-encoding",
|
|
25
|
+
"upgrade",
|
|
26
|
+
"trailer",
|
|
27
|
+
"te",
|
|
28
|
+
"alt-svc",
|
|
29
|
+
"strict-transport-security",
|
|
30
|
+
"report-to",
|
|
31
|
+
"nel"
|
|
32
|
+
]);
|
|
33
|
+
var CACHE_BUSTERS = /* @__PURE__ */ new Set(["_", "cb", "cachebust", "cache_bust", "_t", "__ts", "nocache"]);
|
|
34
|
+
function keepHeaders(headers) {
|
|
35
|
+
const kept = {};
|
|
36
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
37
|
+
const lower = name.toLowerCase();
|
|
38
|
+
if (DROP_HEADERS.has(lower) || value === void 0) continue;
|
|
39
|
+
kept[lower] = Array.isArray(value) ? value.join(", ") : value;
|
|
40
|
+
}
|
|
41
|
+
return Object.fromEntries(Object.entries(kept).sort(([a], [b]) => a.localeCompare(b)));
|
|
42
|
+
}
|
|
43
|
+
function significantQuery(query) {
|
|
44
|
+
const kept = Object.entries(query).filter(([name]) => !CACHE_BUSTERS.has(name)).sort(([a], [b]) => a.localeCompare(b));
|
|
45
|
+
return Object.fromEntries(kept);
|
|
46
|
+
}
|
|
47
|
+
function interactionKey(args) {
|
|
48
|
+
const query = significantQuery(args.query ?? {});
|
|
49
|
+
const search = Object.entries(query).map(([name, value]) => `${name}=${value}`).join("&");
|
|
50
|
+
return `${args.method.toUpperCase()} ${args.path}${search ? `?${search}` : ""}`;
|
|
51
|
+
}
|
|
52
|
+
function looksLikeIdentifier(segment) {
|
|
53
|
+
if (!segment) return false;
|
|
54
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) return true;
|
|
55
|
+
if (/^\d+$/.test(segment)) return true;
|
|
56
|
+
if (/^[0-9a-f]{8,}$/i.test(segment)) return true;
|
|
57
|
+
if (segment.length >= 20) return true;
|
|
58
|
+
return /\d/.test(segment) && segment.length >= 3;
|
|
59
|
+
}
|
|
60
|
+
function templatise(paths) {
|
|
61
|
+
const split = paths.map((path2) => path2.split("/").filter(Boolean));
|
|
62
|
+
const depth = Math.max(0, ...split.map((segments) => segments.length));
|
|
63
|
+
const parameters = /* @__PURE__ */ new Set();
|
|
64
|
+
const prefixOf = (segments, upTo) => {
|
|
65
|
+
const parts = [];
|
|
66
|
+
for (let index = 0; index < upTo; index += 1) {
|
|
67
|
+
parts.push(parameters.has(`${parts.join("/")}\0${index}`) ? ":" : segments[index]);
|
|
68
|
+
}
|
|
69
|
+
return parts.join("/");
|
|
70
|
+
};
|
|
71
|
+
for (let index = 0; index < depth; index += 1) {
|
|
72
|
+
const groups = /* @__PURE__ */ new Map();
|
|
73
|
+
const children = /* @__PURE__ */ new Map();
|
|
74
|
+
for (const segments of split) {
|
|
75
|
+
if (segments.length <= index) continue;
|
|
76
|
+
const prefix = prefixOf(segments, index);
|
|
77
|
+
const value = segments[index];
|
|
78
|
+
(groups.get(prefix) ?? groups.set(prefix, /* @__PURE__ */ new Set()).get(prefix)).add(value);
|
|
79
|
+
const perGroup = children.get(prefix) ?? children.set(prefix, /* @__PURE__ */ new Map()).get(prefix);
|
|
80
|
+
const seen = perGroup.get(value) ?? perGroup.set(value, /* @__PURE__ */ new Set()).get(value);
|
|
81
|
+
if (segments.length > index + 1) seen.add(segments[index + 1]);
|
|
82
|
+
}
|
|
83
|
+
for (const [prefix, values] of groups) {
|
|
84
|
+
if (values.size < 2) continue;
|
|
85
|
+
const identifier = [...values].some(looksLikeIdentifier);
|
|
86
|
+
const parallel = leadsToTheSamePlaces(children.get(prefix));
|
|
87
|
+
if (identifier || parallel) parameters.add(`${prefix}\0${index}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const templates = /* @__PURE__ */ new Map();
|
|
91
|
+
for (const [index, segments] of split.entries()) {
|
|
92
|
+
const named = [];
|
|
93
|
+
let templated = false;
|
|
94
|
+
for (let position = 0; position < segments.length; position += 1) {
|
|
95
|
+
if (parameters.has(`${prefixOf(segments, position)}\0${position}`)) {
|
|
96
|
+
named.push(`:${parameterName(segments, position)}`);
|
|
97
|
+
templated = true;
|
|
98
|
+
} else {
|
|
99
|
+
named.push(segments[position]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (templated) templates.set(paths[index], `/${named.join("/")}`);
|
|
103
|
+
}
|
|
104
|
+
return templates;
|
|
105
|
+
}
|
|
106
|
+
function leadsToTheSamePlaces(children) {
|
|
107
|
+
if (!children || children.size < 2) return false;
|
|
108
|
+
const sets = [...children.values()].map((set) => [...set].sort().join("\0"));
|
|
109
|
+
return sets.every((set) => set && set === sets[0]);
|
|
110
|
+
}
|
|
111
|
+
function parameterName(segments, depth) {
|
|
112
|
+
const parent = depth > 0 ? segments[depth - 1] : "";
|
|
113
|
+
const base = parent && !parent.startsWith(":") ? singular(parent) : "id";
|
|
114
|
+
return base.replace(/[^a-z0-9]+/gi, "") || "id";
|
|
115
|
+
}
|
|
116
|
+
function singular(word) {
|
|
117
|
+
const lower = word.toLowerCase();
|
|
118
|
+
if (/(analysis|status|address|series|species)$/.test(lower)) return lower;
|
|
119
|
+
if (lower.endsWith("ies")) return `${lower.slice(0, -3)}y`;
|
|
120
|
+
if (/(ch|sh|ss|x|z)es$/.test(lower)) return lower.slice(0, -2);
|
|
121
|
+
if (lower.endsWith("s") && !lower.endsWith("ss")) return lower.slice(0, -1);
|
|
122
|
+
return lower;
|
|
123
|
+
}
|
|
124
|
+
function serialiseCassette(cassette) {
|
|
125
|
+
const interactions = [...cassette.interactions].sort(
|
|
126
|
+
(a, b) => interactionKey(a).localeCompare(interactionKey(b))
|
|
127
|
+
);
|
|
128
|
+
return `${JSON.stringify({ ...cassette, interactions }, null, 2)}
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
function parseCassette(text) {
|
|
132
|
+
const parsed = JSON.parse(text);
|
|
133
|
+
if (parsed.schema !== CASSETTE_SCHEMA) {
|
|
134
|
+
throw new Error(`Cassette schema ${JSON.stringify(parsed.schema)} is not one this understands.`);
|
|
135
|
+
}
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
var CassetteIndex = class {
|
|
139
|
+
constructor(cassette) {
|
|
140
|
+
this.cassette = cassette;
|
|
141
|
+
for (const interaction of cassette.interactions) {
|
|
142
|
+
this.exact.set(interactionKey(interaction), interaction);
|
|
143
|
+
if (interaction.template) {
|
|
144
|
+
const key = interactionKey({ ...interaction, path: interaction.template });
|
|
145
|
+
if (!this.byTemplate.has(key)) this.byTemplate.set(key, interaction);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
exact = /* @__PURE__ */ new Map();
|
|
150
|
+
byTemplate = /* @__PURE__ */ new Map();
|
|
151
|
+
get anchor() {
|
|
152
|
+
return this.cassette.anchor;
|
|
153
|
+
}
|
|
154
|
+
get size() {
|
|
155
|
+
return this.exact.size;
|
|
156
|
+
}
|
|
157
|
+
find(args) {
|
|
158
|
+
const direct = this.exact.get(interactionKey(args));
|
|
159
|
+
if (direct) return direct;
|
|
160
|
+
const templates = new Set(
|
|
161
|
+
this.cassette.interactions.map((interaction) => interaction.template).filter(Boolean)
|
|
162
|
+
);
|
|
163
|
+
for (const template of [...templates].sort()) {
|
|
164
|
+
if (!matchesTemplate(template, args.path)) continue;
|
|
165
|
+
const found = this.byTemplate.get(interactionKey({ ...args, path: template }));
|
|
166
|
+
if (found) return found;
|
|
167
|
+
}
|
|
168
|
+
return void 0;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
function matchesTemplate(template, path2) {
|
|
172
|
+
const wanted = template.split("/").filter(Boolean);
|
|
173
|
+
const actual = path2.split("/").filter(Boolean);
|
|
174
|
+
if (wanted.length !== actual.length) return false;
|
|
175
|
+
return wanted.every((segment, index) => segment.startsWith(":") || segment === actual[index]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// core/seed/gaps.ts
|
|
179
|
+
function gapsFrom(misses, cassette) {
|
|
180
|
+
const known = cassette?.interactions.map((interaction) => interaction.path) ?? [];
|
|
181
|
+
const templates = templatise([...known, ...misses.map((miss) => miss.path)]);
|
|
182
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
183
|
+
for (const miss of misses) {
|
|
184
|
+
const path2 = templates.get(miss.path) ?? miss.path;
|
|
185
|
+
const key = interactionKey({ method: miss.method, path: path2, ...miss.query ? { query: miss.query } : {} });
|
|
186
|
+
const existing = grouped.get(key);
|
|
187
|
+
if (existing) {
|
|
188
|
+
existing.asked += 1;
|
|
189
|
+
if (miss.from && !existing.broke.includes(miss.from)) existing.broke.push(miss.from);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
grouped.set(key, {
|
|
193
|
+
method: miss.method.toUpperCase(),
|
|
194
|
+
path: path2,
|
|
195
|
+
asked: 1,
|
|
196
|
+
broke: miss.from ? [miss.from] : [],
|
|
197
|
+
example: miss.path
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return [...grouped.values()].sort((a, b) => b.asked - a.asked || a.path.localeCompare(b.path));
|
|
201
|
+
}
|
|
202
|
+
function gapReport(gaps2) {
|
|
203
|
+
if (!gaps2.length) return ["The recording answered everything this run asked for."];
|
|
204
|
+
const lines = [
|
|
205
|
+
`${gaps2.length} endpoint(s) are missing from the recording. Until they are in it, the screens below have no data:`,
|
|
206
|
+
""
|
|
207
|
+
];
|
|
208
|
+
for (const gap of gaps2) {
|
|
209
|
+
const broke = gap.broke.length ? ` \u2014 breaks ${gap.broke.join(", ")}` : "";
|
|
210
|
+
lines.push(` ${gap.method} ${gap.path}${broke}`);
|
|
211
|
+
if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
|
|
212
|
+
}
|
|
213
|
+
lines.push(
|
|
214
|
+
"",
|
|
215
|
+
"Record them by running the product against the recording proxy once more and visiting those screens."
|
|
216
|
+
);
|
|
217
|
+
return lines;
|
|
218
|
+
}
|
|
219
|
+
function parametersFrom(cassette) {
|
|
220
|
+
const templates = templatise(cassette.interactions.map((interaction) => interaction.path));
|
|
221
|
+
const found = /* @__PURE__ */ new Map();
|
|
222
|
+
for (const interaction of cassette.interactions) {
|
|
223
|
+
const template = interaction.template ?? templates.get(interaction.path);
|
|
224
|
+
if (!template) continue;
|
|
225
|
+
const wanted = template.split("/").filter(Boolean);
|
|
226
|
+
const actual = interaction.path.split("/").filter(Boolean);
|
|
227
|
+
if (wanted.length !== actual.length) continue;
|
|
228
|
+
const perTemplate = found.get(template) ?? /* @__PURE__ */ new Map();
|
|
229
|
+
for (const [index, segment] of wanted.entries()) {
|
|
230
|
+
if (!segment.startsWith(":")) continue;
|
|
231
|
+
const name = segment.slice(1);
|
|
232
|
+
const values = perTemplate.get(name) ?? [];
|
|
233
|
+
const value = actual[index];
|
|
234
|
+
if (!values.includes(value)) values.push(value);
|
|
235
|
+
perTemplate.set(name, values);
|
|
236
|
+
}
|
|
237
|
+
found.set(template, perTemplate);
|
|
238
|
+
}
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
241
|
+
function exampleRoutes(cassette) {
|
|
242
|
+
const routes2 = /* @__PURE__ */ new Set();
|
|
243
|
+
for (const [template, parameters] of parametersFrom(cassette)) {
|
|
244
|
+
let filled = template;
|
|
245
|
+
for (const [name, values] of parameters) {
|
|
246
|
+
filled = filled.replace(`:${name}`, values[0]);
|
|
247
|
+
}
|
|
248
|
+
routes2.add(filled);
|
|
249
|
+
}
|
|
250
|
+
return [...routes2].sort();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// core/seed/cast.ts
|
|
254
|
+
var GIVEN = [
|
|
255
|
+
"Marion",
|
|
256
|
+
"Aurelio",
|
|
257
|
+
"Delphine",
|
|
258
|
+
"Kwabena",
|
|
259
|
+
"Ingrid",
|
|
260
|
+
"Rafael",
|
|
261
|
+
"Solveig",
|
|
262
|
+
"Tobias",
|
|
263
|
+
"Anouk",
|
|
264
|
+
"Emeka",
|
|
265
|
+
"Beatriz",
|
|
266
|
+
"Lars",
|
|
267
|
+
"Yuki",
|
|
268
|
+
"Nadia",
|
|
269
|
+
"Cormac",
|
|
270
|
+
"Priya",
|
|
271
|
+
"Halvard",
|
|
272
|
+
"Esperanza",
|
|
273
|
+
"Dimitri",
|
|
274
|
+
"Fenella",
|
|
275
|
+
"Osric",
|
|
276
|
+
"Amara",
|
|
277
|
+
"Bastien",
|
|
278
|
+
"Wilhelmina"
|
|
279
|
+
];
|
|
280
|
+
var FAMILY = [
|
|
281
|
+
"Whitfield",
|
|
282
|
+
"Aldergrove",
|
|
283
|
+
"Marchetti",
|
|
284
|
+
"Osei",
|
|
285
|
+
"Lindqvist",
|
|
286
|
+
"Barrandon",
|
|
287
|
+
"Fairweather",
|
|
288
|
+
"Nakamura",
|
|
289
|
+
"Oyelaran",
|
|
290
|
+
"Castellan",
|
|
291
|
+
"Thornbury",
|
|
292
|
+
"Vasilenko",
|
|
293
|
+
"Merriweather",
|
|
294
|
+
"Adeyemi",
|
|
295
|
+
"Blackwood",
|
|
296
|
+
"Ferreira",
|
|
297
|
+
"Kowalczyk",
|
|
298
|
+
"Ravensworth",
|
|
299
|
+
"Duguid",
|
|
300
|
+
"Sandoval",
|
|
301
|
+
"Haverford",
|
|
302
|
+
"Ingleby",
|
|
303
|
+
"Petrossian",
|
|
304
|
+
"Wrenfield"
|
|
305
|
+
];
|
|
306
|
+
var STREETS = [
|
|
307
|
+
"Calderwood Row",
|
|
308
|
+
"Harrowgate Lane",
|
|
309
|
+
"Pentland Rise",
|
|
310
|
+
"Ashcombe Way",
|
|
311
|
+
"Marlowe Crescent",
|
|
312
|
+
"Belforte Street",
|
|
313
|
+
"Kirkstall Green",
|
|
314
|
+
"Windermere Close",
|
|
315
|
+
"Thackeray Walk",
|
|
316
|
+
"Ossington Mews"
|
|
317
|
+
];
|
|
318
|
+
var CITIES = [
|
|
319
|
+
"Ashbourne",
|
|
320
|
+
"Fenwick",
|
|
321
|
+
"Marlbury",
|
|
322
|
+
"Kesterton",
|
|
323
|
+
"Ravensfield",
|
|
324
|
+
"Holbeck",
|
|
325
|
+
"Danforth",
|
|
326
|
+
"Wexbury",
|
|
327
|
+
"Alderney Green",
|
|
328
|
+
"Portstow"
|
|
329
|
+
];
|
|
330
|
+
var COMPANIES = [
|
|
331
|
+
"Ambervale",
|
|
332
|
+
"Northgate Works",
|
|
333
|
+
"Calder & Roe",
|
|
334
|
+
"Fenwick Holdings",
|
|
335
|
+
"Kestrel Union",
|
|
336
|
+
"Ossington Trading",
|
|
337
|
+
"Pentland Group",
|
|
338
|
+
"Thornbury Mills",
|
|
339
|
+
"Wexford Rail",
|
|
340
|
+
"Alderney Supply"
|
|
341
|
+
];
|
|
342
|
+
var EMAIL_DOMAIN = "example.invalid";
|
|
343
|
+
var PHONE_PREFIX = "555-01";
|
|
344
|
+
function mulberry32(seed) {
|
|
345
|
+
let state = seed >>> 0;
|
|
346
|
+
return () => {
|
|
347
|
+
state = state + 1831565813 >>> 0;
|
|
348
|
+
let t = state;
|
|
349
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
350
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
351
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
var Cast = class {
|
|
355
|
+
random;
|
|
356
|
+
assigned = /* @__PURE__ */ new Map();
|
|
357
|
+
people = /* @__PURE__ */ new Map();
|
|
358
|
+
issued = 0;
|
|
359
|
+
constructor(seed = 20240101) {
|
|
360
|
+
this.random = mulberry32(seed);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The whole person behind an anchor.
|
|
364
|
+
*
|
|
365
|
+
* The anchor is whatever real value identified them first — an email if the
|
|
366
|
+
* record had one, otherwise a name or an id. Every field of that record then
|
|
367
|
+
* comes from the person this returns, which is what makes the email match
|
|
368
|
+
* the name rather than merely coexist with it.
|
|
369
|
+
*/
|
|
370
|
+
person(anchor) {
|
|
371
|
+
const already = this.people.get(anchor);
|
|
372
|
+
if (already) return already;
|
|
373
|
+
const index = this.issued;
|
|
374
|
+
this.issued += 1;
|
|
375
|
+
const given = GIVEN[index % GIVEN.length];
|
|
376
|
+
const family = FAMILY[(index * 7 + Math.floor(index / FAMILY.length)) % FAMILY.length];
|
|
377
|
+
const suffix = index >= GIVEN.length ? String(index) : "";
|
|
378
|
+
const person = {
|
|
379
|
+
given,
|
|
380
|
+
family,
|
|
381
|
+
full: `${given} ${family}`,
|
|
382
|
+
email: `${given}.${family}${suffix}@${EMAIL_DOMAIN}`.toLowerCase(),
|
|
383
|
+
phone: `${PHONE_PREFIX}${String(index % 100).padStart(2, "0")}`,
|
|
384
|
+
street: `${1 + index % 90} ${STREETS[index % STREETS.length]}`,
|
|
385
|
+
city: CITIES[index % CITIES.length],
|
|
386
|
+
postcode: `ZZ${String(10 + index % 89)} ${String(1 + index % 9)}QQ`
|
|
387
|
+
};
|
|
388
|
+
this.people.set(anchor, person);
|
|
389
|
+
return person;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* One field, replaced consistently.
|
|
393
|
+
*
|
|
394
|
+
* `within` is the record the field belongs to, and it is what makes this a
|
|
395
|
+
* graph rather than a dictionary: two people who happen to share a first
|
|
396
|
+
* name stay two people, and one person mentioned on four screens stays one.
|
|
397
|
+
*/
|
|
398
|
+
value(kind, real, within) {
|
|
399
|
+
const key = `${kind}\0${real}`;
|
|
400
|
+
const already = this.assigned.get(key);
|
|
401
|
+
if (already) return already;
|
|
402
|
+
const synthetic = this.mint(kind, real, within ?? real);
|
|
403
|
+
this.assigned.set(key, synthetic);
|
|
404
|
+
return synthetic;
|
|
405
|
+
}
|
|
406
|
+
mint(kind, real, anchor) {
|
|
407
|
+
const person = this.person(anchor);
|
|
408
|
+
switch (kind) {
|
|
409
|
+
case "given-name":
|
|
410
|
+
return person.given;
|
|
411
|
+
case "family-name":
|
|
412
|
+
return person.family;
|
|
413
|
+
case "full-name":
|
|
414
|
+
return person.full;
|
|
415
|
+
case "email":
|
|
416
|
+
return person.email;
|
|
417
|
+
case "phone":
|
|
418
|
+
return person.phone;
|
|
419
|
+
case "street":
|
|
420
|
+
return person.street;
|
|
421
|
+
case "city":
|
|
422
|
+
return person.city;
|
|
423
|
+
case "postcode":
|
|
424
|
+
return person.postcode;
|
|
425
|
+
case "company":
|
|
426
|
+
return COMPANIES[this.assigned.size % COMPANIES.length];
|
|
427
|
+
case "note":
|
|
428
|
+
return redactedNote(real, this.random);
|
|
429
|
+
case "amount":
|
|
430
|
+
return recastAmount(real, this.random);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** What was replaced with what. Held by the caller, never written beside the cassette. */
|
|
434
|
+
mapping() {
|
|
435
|
+
return {
|
|
436
|
+
entries: [...this.assigned.entries()].map(([key, to]) => {
|
|
437
|
+
const [kind, from] = key.split("\0");
|
|
438
|
+
return { kind, from, to };
|
|
439
|
+
}).sort((a, b) => a.from.localeCompare(b.from))
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
get size() {
|
|
443
|
+
return this.assigned.size;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
var NOTES = [
|
|
447
|
+
"Reviewed the quarterly position and confirmed the settlement dates with operations.",
|
|
448
|
+
"Statement reconciled against the ledger. No further action required this period.",
|
|
449
|
+
"Contact prefers email. Documentation is complete and filed.",
|
|
450
|
+
"Awaiting countersigned paperwork before the transfer can be scheduled.",
|
|
451
|
+
"Adjustment applied after the reporting close; the balances agree.",
|
|
452
|
+
"Annual review completed on schedule and signed off internally."
|
|
453
|
+
];
|
|
454
|
+
function redactedNote(real, random) {
|
|
455
|
+
const wanted = real.trim().split(/\s+/).length;
|
|
456
|
+
const chosen = [];
|
|
457
|
+
let words = 0;
|
|
458
|
+
let index = Math.floor(random() * NOTES.length);
|
|
459
|
+
do {
|
|
460
|
+
const sentence = NOTES[index % NOTES.length];
|
|
461
|
+
chosen.push(sentence);
|
|
462
|
+
words += sentence.split(" ").length;
|
|
463
|
+
index += 1;
|
|
464
|
+
} while (words < wanted * 0.7 && chosen.length < NOTES.length);
|
|
465
|
+
return chosen.join(" ");
|
|
466
|
+
}
|
|
467
|
+
var FIELDS = [
|
|
468
|
+
{ pattern: /(^|_|\.)(given[_-]?name|first[_-]?name|forename)$/i, kind: "given-name" },
|
|
469
|
+
{ pattern: /(^|_|\.)(family[_-]?name|last[_-]?name|surname)$/i, kind: "family-name" },
|
|
470
|
+
{ pattern: /(^|_|\.)(email|email[_-]?address|e[_-]?mail)$/i, kind: "email" },
|
|
471
|
+
{ pattern: /(^|_|\.)(phone|telephone|phone[_-]?number|mobile|cell)$/i, kind: "phone" },
|
|
472
|
+
{ pattern: /(^|_|\.)(street|address|address[_-]?line[_-]?1|line1)$/i, kind: "street" },
|
|
473
|
+
{ pattern: /(^|_|\.)(city|town|locality)$/i, kind: "city" },
|
|
474
|
+
{ pattern: /(^|_|\.)(postcode|postal[_-]?code|zip|zip[_-]?code)$/i, kind: "postcode" },
|
|
475
|
+
// `held_at`, `custodian` and `provider` are the keys a real institution
|
|
476
|
+
// actually arrives under, and an institution in a published frame implies a
|
|
477
|
+
// partnership. Where the key says what the value is, replace it; where it
|
|
478
|
+
// does not, the text pass flags it and a person decides.
|
|
479
|
+
{
|
|
480
|
+
pattern: /(^|_|\.)(company|organisation|organization|employer|institution|custodian|provider|bank|broker|held[_-]?at|held[_-]?by)$/i,
|
|
481
|
+
kind: "company"
|
|
482
|
+
},
|
|
483
|
+
{ pattern: /(^|_|\.)(note|notes|comment|comments|memo|description|bio)$/i, kind: "note" },
|
|
484
|
+
{
|
|
485
|
+
// A person, whatever this product calls the part they are playing.
|
|
486
|
+
pattern: /(^|_|\.)(name|full[_-]?name|display[_-]?name|owner|contact|customer|client|member|patient|assignee|assigned[_-]?to|reporter|reviewer|approver|author|editor|manager|supervisor|clinician|practitioner|physician|doctor|nurse|therapist|teacher|student|tutor|technician|engineer|driver|courier|recipient|sender|guest|attendee|participant|applicant|candidate|tenant[_-]?name|landlord|beneficiary|guardian|next[_-]?of[_-]?kin|emergency[_-]?contact)$/i,
|
|
487
|
+
kind: "full-name"
|
|
488
|
+
}
|
|
489
|
+
];
|
|
490
|
+
function kindOfField(key, extra = []) {
|
|
491
|
+
const named = extra.find((field) => new RegExp(`(^|_|\\.)${escapeKey(field.key)}$`, "i").test(key));
|
|
492
|
+
if (named) return named.kind;
|
|
493
|
+
return FIELDS.find((field) => field.pattern.test(key))?.kind;
|
|
494
|
+
}
|
|
495
|
+
function escapeKey(key) {
|
|
496
|
+
return key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
497
|
+
}
|
|
498
|
+
function recastJson(value, cast, options = {}) {
|
|
499
|
+
const path2 = options.path ?? "";
|
|
500
|
+
if (Array.isArray(value)) {
|
|
501
|
+
return value.map((entry, index) => recastJson(entry, cast, { ...options, path: `${path2}[${index}]` }));
|
|
502
|
+
}
|
|
503
|
+
if (value === null || typeof value !== "object") return value;
|
|
504
|
+
const record2 = value;
|
|
505
|
+
const anchor = anchorOf(record2, path2);
|
|
506
|
+
const out = {};
|
|
507
|
+
for (const [key, entry] of Object.entries(record2)) {
|
|
508
|
+
const kind = kindOfField(key, options.fields ?? []);
|
|
509
|
+
if (kind && typeof entry === "string" && entry.trim()) {
|
|
510
|
+
note(options.seen, key, entry, kind);
|
|
511
|
+
out[key] = cast.value(kind, entry, anchor);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (kind === "amount" && typeof entry === "number" && Number.isFinite(entry)) {
|
|
515
|
+
note(options.seen, key, String(entry), kind);
|
|
516
|
+
out[key] = Number(cast.value(kind, String(entry), anchor));
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (entry !== null && typeof entry !== "object") note(options.seen, key, String(entry));
|
|
520
|
+
out[key] = recastJson(entry, cast, { ...options, path: path2 ? `${path2}.${key}` : key });
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
function recastAmount(real, random) {
|
|
525
|
+
const negative = real.trim().startsWith("-");
|
|
526
|
+
const [whole = "0", fraction] = real.trim().replace(/^-/, "").split(".");
|
|
527
|
+
const digits = Math.max(1, whole.replace(/\D/g, "").length);
|
|
528
|
+
const low = digits > 1 ? 10 ** (digits - 1) : 0;
|
|
529
|
+
const span = 10 ** digits - low;
|
|
530
|
+
const invented = String(low + Math.floor(random() * span));
|
|
531
|
+
const places = fraction === void 0 ? "" : `.${fraction.replace(/\d/g, () => String(Math.floor(random() * 10)))}`;
|
|
532
|
+
return `${negative ? "-" : ""}${invented}${places}`;
|
|
533
|
+
}
|
|
534
|
+
function note(seen, key, value, kind) {
|
|
535
|
+
if (!seen) return;
|
|
536
|
+
const held = seen.get(key);
|
|
537
|
+
if (held) {
|
|
538
|
+
held.count += 1;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
seen.set(key, { key, count: 1, example: value.length > 60 ? `${value.slice(0, 57)}\u2026` : value, ...kind ? { kind } : {} });
|
|
542
|
+
}
|
|
543
|
+
function anchorOf(record2, path2) {
|
|
544
|
+
for (const key of ["email", "emailAddress", "email_address"]) {
|
|
545
|
+
const value = record2[key];
|
|
546
|
+
if (typeof value === "string" && value.trim()) return `email:${value.toLowerCase()}`;
|
|
547
|
+
}
|
|
548
|
+
for (const key of ["name", "fullName", "full_name", "displayName"]) {
|
|
549
|
+
const value = record2[key];
|
|
550
|
+
if (typeof value === "string" && value.trim()) return `name:${value.toLowerCase()}`;
|
|
551
|
+
}
|
|
552
|
+
for (const key of ["id", "uuid", "userId", "user_id"]) {
|
|
553
|
+
const value = record2[key];
|
|
554
|
+
if (typeof value === "string" || typeof value === "number") return `id:${String(value)}`;
|
|
555
|
+
}
|
|
556
|
+
return `at:${path2}`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// core/seed/clock.ts
|
|
560
|
+
var DEFAULT_ANCHOR = "2024-05-14T10:37:00.000Z";
|
|
561
|
+
var ISO = /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?\b/g;
|
|
562
|
+
var DATE_ONLY = /\b\d{4}-\d{2}-\d{2}\b/g;
|
|
563
|
+
var TIME_KEYS = /(^|_|\.)(.*_at|.*At|time|timestamp|ts|date|expires|expiry|exp|iat|nbf|created|updated|modified|deleted|seen|due|start|end|since|until)$/i;
|
|
564
|
+
function instantsIn(value, key = "") {
|
|
565
|
+
if (typeof value === "number") {
|
|
566
|
+
return TIME_KEYS.test(key) ? epochToMs(value).map((ms) => ms) : [];
|
|
567
|
+
}
|
|
568
|
+
if (typeof value === "string") {
|
|
569
|
+
const found = value.match(ISO) ?? [];
|
|
570
|
+
const parsed = found.map((text) => Date.parse(text)).filter((ms) => Number.isFinite(ms));
|
|
571
|
+
if (parsed.length) return parsed;
|
|
572
|
+
if (TIME_KEYS.test(key)) {
|
|
573
|
+
const dates = (value.match(DATE_ONLY) ?? []).map((text) => Date.parse(`${text}T00:00:00.000Z`));
|
|
574
|
+
return dates.filter((ms) => Number.isFinite(ms));
|
|
575
|
+
}
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
if (Array.isArray(value)) return value.flatMap((entry) => instantsIn(entry, key));
|
|
579
|
+
if (value && typeof value === "object") {
|
|
580
|
+
return Object.entries(value).flatMap(([name, entry]) => instantsIn(entry, name));
|
|
581
|
+
}
|
|
582
|
+
return [];
|
|
583
|
+
}
|
|
584
|
+
function epochToMs(value) {
|
|
585
|
+
if (!Number.isFinite(value) || value <= 0) return [];
|
|
586
|
+
if (value > 1e12) return [value];
|
|
587
|
+
if (value > 1e9) return [value * 1e3];
|
|
588
|
+
return [];
|
|
589
|
+
}
|
|
590
|
+
function shiftFor(instants, anchor = DEFAULT_ANCHOR) {
|
|
591
|
+
const target = Date.parse(anchor);
|
|
592
|
+
if (!Number.isFinite(target)) throw new Error(`Anchor ${JSON.stringify(anchor)} is not an instant.`);
|
|
593
|
+
if (!instants.length) return { offsetMs: 0, latest: void 0, anchor };
|
|
594
|
+
const latest = Math.max(...instants);
|
|
595
|
+
return { offsetMs: target - latest, latest: new Date(latest).toISOString(), anchor };
|
|
596
|
+
}
|
|
597
|
+
function shiftTime(value, shift, key = "") {
|
|
598
|
+
if (!shift.offsetMs) return value;
|
|
599
|
+
if (typeof value === "number") {
|
|
600
|
+
if (!TIME_KEYS.test(key)) return value;
|
|
601
|
+
if (value > 1e12) return value + shift.offsetMs;
|
|
602
|
+
if (value > 1e9) return value + Math.round(shift.offsetMs / 1e3);
|
|
603
|
+
return value;
|
|
604
|
+
}
|
|
605
|
+
if (typeof value === "string") {
|
|
606
|
+
let out = value.replace(ISO, (text) => {
|
|
607
|
+
const ms = Date.parse(text);
|
|
608
|
+
if (!Number.isFinite(ms)) return text;
|
|
609
|
+
return inShapeOf(text, ms + shift.offsetMs);
|
|
610
|
+
});
|
|
611
|
+
if (out === value && TIME_KEYS.test(key)) {
|
|
612
|
+
out = value.replace(DATE_ONLY, (text) => {
|
|
613
|
+
const ms = Date.parse(`${text}T00:00:00.000Z`);
|
|
614
|
+
if (!Number.isFinite(ms)) return text;
|
|
615
|
+
return new Date(ms + shift.offsetMs).toISOString().slice(0, 10);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
if (Array.isArray(value)) return value.map((entry) => shiftTime(entry, shift, key));
|
|
621
|
+
if (value && typeof value === "object") {
|
|
622
|
+
return Object.fromEntries(
|
|
623
|
+
Object.entries(value).map(([name, entry]) => [name, shiftTime(entry, shift, name)])
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
return value;
|
|
627
|
+
}
|
|
628
|
+
function inShapeOf(original, ms) {
|
|
629
|
+
const iso = new Date(ms).toISOString();
|
|
630
|
+
const hasMillis = /\.\d{1,9}/.test(original);
|
|
631
|
+
const spaced = original.includes(" ") && !original.includes("T");
|
|
632
|
+
const offset = /[+-]\d{2}:?\d{2}$/.exec(original)?.[0];
|
|
633
|
+
let out = hasMillis ? iso : `${iso.slice(0, 19)}Z`;
|
|
634
|
+
if (offset) {
|
|
635
|
+
const minutes = offsetMinutes(offset);
|
|
636
|
+
const local = new Date(ms + minutes * 6e4).toISOString();
|
|
637
|
+
out = `${hasMillis ? local.slice(0, 23) : local.slice(0, 19)}${offset}`;
|
|
638
|
+
} else if (!original.endsWith("Z") && !offset) {
|
|
639
|
+
out = hasMillis ? iso.slice(0, 23) : iso.slice(0, 19);
|
|
640
|
+
}
|
|
641
|
+
return spaced ? out.replace("T", " ") : out;
|
|
642
|
+
}
|
|
643
|
+
function offsetMinutes(offset) {
|
|
644
|
+
const sign = offset.startsWith("-") ? -1 : 1;
|
|
645
|
+
const digits = offset.slice(1).replace(":", "");
|
|
646
|
+
return sign * (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2)));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// core/capture/scrub.ts
|
|
650
|
+
var BRANDS = [
|
|
651
|
+
"Chase",
|
|
652
|
+
"JPMorgan",
|
|
653
|
+
"J.P. Morgan",
|
|
654
|
+
"Bank of America",
|
|
655
|
+
"Wells Fargo",
|
|
656
|
+
"Citibank",
|
|
657
|
+
"Citigroup",
|
|
658
|
+
"Goldman Sachs",
|
|
659
|
+
"Morgan Stanley",
|
|
660
|
+
"Schwab",
|
|
661
|
+
"Charles Schwab",
|
|
662
|
+
"Fidelity",
|
|
663
|
+
"Vanguard",
|
|
664
|
+
"BlackRock",
|
|
665
|
+
"Merrill",
|
|
666
|
+
"Merrill Lynch",
|
|
667
|
+
"TD Ameritrade",
|
|
668
|
+
"E*TRADE",
|
|
669
|
+
"Robinhood",
|
|
670
|
+
"Coinbase",
|
|
671
|
+
"American Express",
|
|
672
|
+
"Amex",
|
|
673
|
+
"Capital One",
|
|
674
|
+
"PNC",
|
|
675
|
+
"US Bank",
|
|
676
|
+
"Truist",
|
|
677
|
+
"HSBC",
|
|
678
|
+
"Barclays",
|
|
679
|
+
"Northern Trust",
|
|
680
|
+
"State Street",
|
|
681
|
+
"Pershing",
|
|
682
|
+
"LPL Financial",
|
|
683
|
+
"Raymond James",
|
|
684
|
+
"Edward Jones",
|
|
685
|
+
"Plaid",
|
|
686
|
+
"Yodlee",
|
|
687
|
+
"Addepar",
|
|
688
|
+
"Orion",
|
|
689
|
+
"Envestnet",
|
|
690
|
+
"eMoney",
|
|
691
|
+
"MoneyGuidePro"
|
|
692
|
+
];
|
|
693
|
+
var SAFE_EMAIL_DOMAIN = "example.invalid";
|
|
694
|
+
var ALREADY_SAFE = [
|
|
695
|
+
// RFC 2606 and RFC 6761 reserve these precisely so they can never resolve.
|
|
696
|
+
{ kind: "email", regex: /@[A-Za-z0-9.-]*\.(invalid|test|example|localhost)$/i },
|
|
697
|
+
{ kind: "email", regex: /@(example\.(com|net|org))$/i },
|
|
698
|
+
// 555-01xx is reserved for fiction and cannot reach a telephone.
|
|
699
|
+
{ kind: "phone", regex: /^(\+?1[-. ]?)?\(?555\)?[-. ]?01\d{2}$/ },
|
|
700
|
+
/*
|
|
701
|
+
* This module's own replacements, which it must not report a second time.
|
|
702
|
+
*
|
|
703
|
+
* Without these it is not idempotent, and idempotence is not a nicety here:
|
|
704
|
+
* a scrubbed fixture is scrubbed AGAIN on the way into a screenshot, and a
|
|
705
|
+
* zeroed account number matching the long-digit-run rule means the gate
|
|
706
|
+
* refuses a screen this module already made safe. Nothing is wrong, nothing
|
|
707
|
+
* can be done about it, and the run stops — which is the precise way a check
|
|
708
|
+
* teaches people to ignore it.
|
|
709
|
+
*
|
|
710
|
+
* No real account, card or identification number is all zeroes.
|
|
711
|
+
*/
|
|
712
|
+
{ kind: "account-number", regex: /^0+$/ },
|
|
713
|
+
{ kind: "account-number", regex: /^XX0+$/ },
|
|
714
|
+
{ kind: "national-id", regex: /^000-00-0000$/ },
|
|
715
|
+
{ kind: "national-id", regex: /^AA0{6}A$/ },
|
|
716
|
+
{ kind: "tax-id", regex: /^00-0000000$/ },
|
|
717
|
+
{ kind: "passport", regex: /^[A-Z]{1,2}0+$/ },
|
|
718
|
+
{ kind: "date-of-birth", regex: /^1970-01-01$/ }
|
|
719
|
+
];
|
|
720
|
+
function alreadySafe(kind, value) {
|
|
721
|
+
return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
|
|
722
|
+
}
|
|
723
|
+
var PATTERNS = [
|
|
724
|
+
{
|
|
725
|
+
kind: "email",
|
|
726
|
+
regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
727
|
+
because: "a real address can receive mail from a fixture",
|
|
728
|
+
replace: (_match, index) => `person${index + 1}@${SAFE_EMAIL_DOMAIN}`
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
kind: "national-id",
|
|
732
|
+
// US SSN shape. Deliberately narrow: a looser rule matches dates and
|
|
733
|
+
// phone numbers, and a scrub that cries wolf gets click-throughed.
|
|
734
|
+
regex: /\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g,
|
|
735
|
+
because: "a national identification number",
|
|
736
|
+
replace: () => "000-00-0000"
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
kind: "tax-id",
|
|
740
|
+
regex: /\b\d{2}-\d{7}\b/g,
|
|
741
|
+
because: "an employer or tax identification number",
|
|
742
|
+
replace: () => "00-0000000"
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
kind: "national-id",
|
|
746
|
+
// UK National Insurance. Two letters, six digits, one suffix letter.
|
|
747
|
+
// Every product with a UK employee or patient holds one of these, which is
|
|
748
|
+
// why it sits beside the US shape rather than in a finance-specific list.
|
|
749
|
+
regex: /\b[A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D]\b/g,
|
|
750
|
+
because: "the shape of a UK National Insurance number",
|
|
751
|
+
replace: () => "AA000000A"
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
kind: "account-number",
|
|
755
|
+
// IBAN. Checked before the bare digit run below, because an IBAN carries
|
|
756
|
+
// its country and bank in the leading letters and zeroing only the digits
|
|
757
|
+
// would leave those standing.
|
|
758
|
+
regex: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g,
|
|
759
|
+
because: "an international bank account number",
|
|
760
|
+
replace: (match) => `XX00${"0".repeat(match.length - 4)}`
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
kind: "account-number",
|
|
764
|
+
// Long digit runs. Card numbers, account numbers, routing numbers.
|
|
765
|
+
regex: /\b\d{9,19}\b/g,
|
|
766
|
+
because: "long enough to be an account, card or routing number",
|
|
767
|
+
replace: (match) => "0".repeat(match.length)
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
kind: "date-of-birth",
|
|
771
|
+
regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
|
|
772
|
+
because: "a full date \u2014 if it is a birth date it identifies somebody",
|
|
773
|
+
replace: () => "1970-01-01"
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
kind: "passport",
|
|
777
|
+
regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
|
|
778
|
+
because: "the shape of a passport or licence number",
|
|
779
|
+
replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
kind: "phone",
|
|
783
|
+
regex: /\b(?:\+?1[-. ]?)?\(?\d{3}\)?[-. ]\d{3}[-. ]\d{4}\b/g,
|
|
784
|
+
because: "a telephone number",
|
|
785
|
+
replace: () => "555-0100"
|
|
786
|
+
}
|
|
787
|
+
];
|
|
788
|
+
function scrub(text, options = {}) {
|
|
789
|
+
const findings = [];
|
|
790
|
+
const where = options.where ?? "fixture";
|
|
791
|
+
let output = text;
|
|
792
|
+
for (const pattern of PATTERNS) {
|
|
793
|
+
const seen = /* @__PURE__ */ new Map();
|
|
794
|
+
output = output.replace(pattern.regex, (match) => {
|
|
795
|
+
if (alreadySafe(pattern.kind, match)) return match;
|
|
796
|
+
const already = seen.get(match);
|
|
797
|
+
if (already) return already;
|
|
798
|
+
const replacement = pattern.replace(match, seen.size);
|
|
799
|
+
seen.set(match, replacement);
|
|
800
|
+
findings.push({ kind: pattern.kind, found: match, replacement, where, because: pattern.because });
|
|
801
|
+
return replacement;
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
for (const brand of [...BRANDS, ...options.brands ?? []]) {
|
|
805
|
+
const regex = new RegExp(`\\b${escapeRegExp(brand)}\\b`, "gi");
|
|
806
|
+
if (regex.test(output)) {
|
|
807
|
+
findings.push({
|
|
808
|
+
kind: "brand",
|
|
809
|
+
found: brand,
|
|
810
|
+
replacement: brand,
|
|
811
|
+
where,
|
|
812
|
+
because: "a real institution in a published screenshot implies a partnership \u2014 a legal review item, not a design nit"
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return { scrubbed: output, findings, needsReview: findings.length > 0 };
|
|
817
|
+
}
|
|
818
|
+
function describeScrub(result) {
|
|
819
|
+
if (!result.findings.length) return "Nothing that looks like real data was found.";
|
|
820
|
+
const byKind = /* @__PURE__ */ new Map();
|
|
821
|
+
for (const finding of result.findings) {
|
|
822
|
+
const list = byKind.get(finding.kind) ?? [];
|
|
823
|
+
list.push(finding);
|
|
824
|
+
byKind.set(finding.kind, list);
|
|
825
|
+
}
|
|
826
|
+
const lines = [`${result.findings.length} thing(s) found. Nothing publishes until you confirm.`, ""];
|
|
827
|
+
for (const [kind, group] of byKind) {
|
|
828
|
+
lines.push(` ${kind.toUpperCase().replace(/-/g, " ")} \u2014 ${group[0].because}`);
|
|
829
|
+
for (const finding of group.slice(0, 5)) {
|
|
830
|
+
lines.push(
|
|
831
|
+
kind === "brand" ? ` ${finding.found} (left as-is: decide whether this frame can ship)` : ` ${finding.found} \u2192 ${finding.replacement}`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
if (group.length > 5) lines.push(` \u2026and ${group.length - 5} more`);
|
|
835
|
+
lines.push("");
|
|
836
|
+
}
|
|
837
|
+
return lines.join("\n").trimEnd();
|
|
838
|
+
}
|
|
839
|
+
function escapeRegExp(value) {
|
|
840
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// core/seed/seed.ts
|
|
844
|
+
function sealCassette(cassette, options = {}) {
|
|
845
|
+
const cast = new Cast(options.seed ?? 20240101);
|
|
846
|
+
const anchor = options.anchor ?? DEFAULT_ANCHOR;
|
|
847
|
+
const findings = [];
|
|
848
|
+
const seen = /* @__PURE__ */ new Map();
|
|
849
|
+
const recast = cassette.interactions.map((interaction) => {
|
|
850
|
+
if (interaction.encoding === "base64") return interaction;
|
|
851
|
+
const decoded = decodeJson(interaction.body);
|
|
852
|
+
if (decoded === void 0) return interaction;
|
|
853
|
+
return {
|
|
854
|
+
...interaction,
|
|
855
|
+
body: JSON.stringify(recastJson(decoded, cast, { seen, ...options.fields ? { fields: options.fields } : {} }))
|
|
856
|
+
};
|
|
857
|
+
});
|
|
858
|
+
const learned = knownValues(cast);
|
|
859
|
+
const swept = recast.map(
|
|
860
|
+
(interaction) => interaction.encoding === "base64" ? interaction : { ...interaction, body: sweep(interaction.body, learned) }
|
|
861
|
+
);
|
|
862
|
+
const cleaned = swept.map((interaction) => {
|
|
863
|
+
if (interaction.encoding === "base64") return interaction;
|
|
864
|
+
const reviewed = scrub(interaction.body, {
|
|
865
|
+
where: `${interaction.method} ${interaction.path}`,
|
|
866
|
+
...options.brands ? { brands: options.brands } : {}
|
|
867
|
+
});
|
|
868
|
+
findings.push(...reviewed.findings);
|
|
869
|
+
return { ...interaction, body: reviewed.scrubbed };
|
|
870
|
+
});
|
|
871
|
+
const instants = cleaned.flatMap((interaction) => {
|
|
872
|
+
const decoded = decodeJson(interaction.body);
|
|
873
|
+
return decoded === void 0 ? [] : instantsIn(decoded);
|
|
874
|
+
});
|
|
875
|
+
const shift = shiftFor(instants, anchor);
|
|
876
|
+
const frozen = cleaned.map((interaction) => {
|
|
877
|
+
const headers = { ...interaction.headers };
|
|
878
|
+
if (headers["date"]) {
|
|
879
|
+
const parsed = Date.parse(headers["date"]);
|
|
880
|
+
if (Number.isFinite(parsed)) headers["date"] = new Date(parsed + shift.offsetMs).toUTCString();
|
|
881
|
+
}
|
|
882
|
+
if (interaction.encoding === "base64") return { ...interaction, headers };
|
|
883
|
+
const decoded = decodeJson(interaction.body);
|
|
884
|
+
return {
|
|
885
|
+
...interaction,
|
|
886
|
+
headers,
|
|
887
|
+
body: decoded === void 0 ? interaction.body : JSON.stringify(shiftTime(decoded, shift))
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
const register = [...seen.values()].sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
|
|
891
|
+
return {
|
|
892
|
+
cassette: withTemplates({ ...cassette, anchor, interactions: frozen }),
|
|
893
|
+
mapping: cast.mapping(),
|
|
894
|
+
shift,
|
|
895
|
+
findings,
|
|
896
|
+
carried: register.filter((field) => !field.kind),
|
|
897
|
+
recast: register.filter((field) => field.kind)
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function knownValues(cast) {
|
|
901
|
+
return cast.mapping().entries.map((entry) => [entry.from, entry.to]).filter(([from]) => from.trim().length >= 3).sort(([a], [b]) => b.length - a.length);
|
|
902
|
+
}
|
|
903
|
+
function sweep(body, learned) {
|
|
904
|
+
let out = body;
|
|
905
|
+
for (const [from, to] of learned) {
|
|
906
|
+
if (!out.includes(from)) continue;
|
|
907
|
+
out = out.split(from).join(to);
|
|
908
|
+
}
|
|
909
|
+
return out;
|
|
910
|
+
}
|
|
911
|
+
function withTemplates(cassette) {
|
|
912
|
+
const templates = templatise(cassette.interactions.map((interaction) => interaction.path));
|
|
913
|
+
return {
|
|
914
|
+
...cassette,
|
|
915
|
+
interactions: cassette.interactions.map((interaction) => {
|
|
916
|
+
const template = templates.get(interaction.path);
|
|
917
|
+
return template ? { ...interaction, template } : interaction;
|
|
918
|
+
})
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function recordInteraction(args) {
|
|
922
|
+
const headers = keepHeaders(args.headers);
|
|
923
|
+
const text = args.body.toString("utf8");
|
|
924
|
+
const isText = Buffer.compare(Buffer.from(text, "utf8"), args.body) === 0;
|
|
925
|
+
return {
|
|
926
|
+
method: args.method.toUpperCase(),
|
|
927
|
+
path: args.path,
|
|
928
|
+
...args.query && Object.keys(args.query).length ? { query: args.query } : {},
|
|
929
|
+
status: args.status,
|
|
930
|
+
headers,
|
|
931
|
+
body: isText ? text : args.body.toString("base64"),
|
|
932
|
+
...isText ? {} : { encoding: "base64" }
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
function decodeJson(body) {
|
|
936
|
+
if (!body.trim()) return void 0;
|
|
937
|
+
try {
|
|
938
|
+
const parsed = JSON.parse(body);
|
|
939
|
+
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
940
|
+
} catch {
|
|
941
|
+
return void 0;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// adapters/seed-proxy.ts
|
|
946
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
947
|
+
import { request as httpsRequest } from "node:https";
|
|
948
|
+
|
|
949
|
+
// core/capture/contract.ts
|
|
950
|
+
var CAPTURE_CONTRACT = {
|
|
951
|
+
/** Same for every shot, so type is the same size across the whole set. */
|
|
952
|
+
viewport: { width: 1600, height: 1e3 },
|
|
953
|
+
/** Stills render at 2x → 3200x2000. */
|
|
954
|
+
stillScale: 2,
|
|
955
|
+
/** Clips at 1x; video is resampled downstream anyway. */
|
|
956
|
+
clipScale: 1,
|
|
957
|
+
/** Below this a frame is a failed shot rather than a small one. */
|
|
958
|
+
minStillWidth: 2300,
|
|
959
|
+
/**
|
|
960
|
+
* Clip length.
|
|
961
|
+
*
|
|
962
|
+
* A CAP rather than a target, and inherited from a marketing site's autoplay
|
|
963
|
+
* loop — which is not this product's constraint. A help-center clip showing a
|
|
964
|
+
* multi-step task may well want twenty seconds, so the window is the contract
|
|
965
|
+
* and the number is configurable.
|
|
966
|
+
*/
|
|
967
|
+
clipSeconds: { min: 6, target: 9, max: 10 },
|
|
968
|
+
maxClipBytes: 2 * 1024 * 1024,
|
|
969
|
+
/** Everything renders as though it were this instant. */
|
|
970
|
+
pinnedClock: "2026-01-15T09:00:00.000Z",
|
|
971
|
+
/**
|
|
972
|
+
* Injected before every shot. Necessary, not sufficient — see above.
|
|
973
|
+
*/
|
|
974
|
+
motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
|
|
975
|
+
};
|
|
976
|
+
var RECORDING_MISS_STATUS = 599;
|
|
977
|
+
|
|
978
|
+
// adapters/seed-proxy.ts
|
|
979
|
+
var MISS_STATUS = RECORDING_MISS_STATUS;
|
|
980
|
+
function bodyOf(message) {
|
|
981
|
+
return new Promise((resolve, reject) => {
|
|
982
|
+
const chunks = [];
|
|
983
|
+
message.on("data", (chunk) => chunks.push(chunk));
|
|
984
|
+
message.on("end", () => resolve(Buffer.concat(chunks)));
|
|
985
|
+
message.on("error", reject);
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
function listen(server, port) {
|
|
989
|
+
return new Promise((resolve, reject) => {
|
|
990
|
+
server.once("error", reject);
|
|
991
|
+
server.listen(port, "127.0.0.1", () => {
|
|
992
|
+
const address = server.address();
|
|
993
|
+
resolve(typeof address === "object" && address ? address.port : port);
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
function shut(server) {
|
|
998
|
+
return new Promise((resolve) => {
|
|
999
|
+
server.closeAllConnections?.();
|
|
1000
|
+
server.close(() => resolve());
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
function splitUrl(url) {
|
|
1004
|
+
const [path2, search] = url.split("?");
|
|
1005
|
+
const query = {};
|
|
1006
|
+
for (const [name, value] of new URLSearchParams(search ?? "")) query[name] = value;
|
|
1007
|
+
return { path: path2 ?? "/", query };
|
|
1008
|
+
}
|
|
1009
|
+
async function recordThrough(options) {
|
|
1010
|
+
const upstream = new URL(options.upstream);
|
|
1011
|
+
const send = upstream.protocol === "https:" ? httpsRequest : httpRequest;
|
|
1012
|
+
const interactions = [];
|
|
1013
|
+
const screens = /* @__PURE__ */ new Set();
|
|
1014
|
+
const server = createServer((incoming, outgoing) => {
|
|
1015
|
+
void (async () => {
|
|
1016
|
+
const { path: path2, query } = splitUrl(incoming.url ?? "/");
|
|
1017
|
+
const requestBody = await bodyOf(incoming);
|
|
1018
|
+
const from = incoming.headers["referer"] ?? incoming.headers["x-seed-page"];
|
|
1019
|
+
if (typeof from === "string") screens.add(pageOf(from));
|
|
1020
|
+
const forwarded = send(
|
|
1021
|
+
{
|
|
1022
|
+
protocol: upstream.protocol,
|
|
1023
|
+
hostname: upstream.hostname,
|
|
1024
|
+
port: upstream.port || (upstream.protocol === "https:" ? 443 : 80),
|
|
1025
|
+
method: incoming.method,
|
|
1026
|
+
path: incoming.url,
|
|
1027
|
+
headers: { ...incoming.headers, host: upstream.host }
|
|
1028
|
+
},
|
|
1029
|
+
(response) => {
|
|
1030
|
+
void (async () => {
|
|
1031
|
+
const body = await bodyOf(response);
|
|
1032
|
+
const interaction = recordInteraction({
|
|
1033
|
+
method: incoming.method ?? "GET",
|
|
1034
|
+
path: path2,
|
|
1035
|
+
query,
|
|
1036
|
+
status: response.statusCode ?? 200,
|
|
1037
|
+
headers: response.headers,
|
|
1038
|
+
body
|
|
1039
|
+
});
|
|
1040
|
+
interactions.push(interaction);
|
|
1041
|
+
options.onRecord?.(interaction);
|
|
1042
|
+
outgoing.writeHead(response.statusCode ?? 200, response.headers);
|
|
1043
|
+
outgoing.end(body);
|
|
1044
|
+
})();
|
|
1045
|
+
}
|
|
1046
|
+
);
|
|
1047
|
+
forwarded.on("error", (error) => {
|
|
1048
|
+
outgoing.writeHead(502, { "content-type": "text/plain" });
|
|
1049
|
+
outgoing.end(`The backend at ${options.upstream} could not be reached: ${error.message}`);
|
|
1050
|
+
});
|
|
1051
|
+
forwarded.end(requestBody);
|
|
1052
|
+
})();
|
|
1053
|
+
});
|
|
1054
|
+
const port = await listen(server, options.port ?? 0);
|
|
1055
|
+
return {
|
|
1056
|
+
url: `http://127.0.0.1:${port}`,
|
|
1057
|
+
port,
|
|
1058
|
+
cassette: () => ({
|
|
1059
|
+
schema: "olomon.seed-cassette/1",
|
|
1060
|
+
recordedAgainst: options.upstream,
|
|
1061
|
+
anchor: "",
|
|
1062
|
+
screens: [...screens].sort(),
|
|
1063
|
+
interactions: [...interactions]
|
|
1064
|
+
}),
|
|
1065
|
+
close: () => shut(server)
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
async function replayFrom(options) {
|
|
1069
|
+
const index = new CassetteIndex(options.cassette);
|
|
1070
|
+
const misses = [];
|
|
1071
|
+
const served = /* @__PURE__ */ new Set();
|
|
1072
|
+
const server = createServer((incoming, outgoing) => {
|
|
1073
|
+
void (async () => {
|
|
1074
|
+
const { path: path2, query } = splitUrl(incoming.url ?? "/");
|
|
1075
|
+
await bodyOf(incoming);
|
|
1076
|
+
const found = index.find({ method: incoming.method ?? "GET", path: path2, query });
|
|
1077
|
+
if (!found) {
|
|
1078
|
+
const from = incoming.headers["referer"] ?? incoming.headers["x-seed-page"];
|
|
1079
|
+
const miss = {
|
|
1080
|
+
method: incoming.method ?? "GET",
|
|
1081
|
+
path: path2,
|
|
1082
|
+
...Object.keys(query).length ? { query } : {},
|
|
1083
|
+
...typeof from === "string" ? { from: pageOf(from) } : {}
|
|
1084
|
+
};
|
|
1085
|
+
misses.push(miss);
|
|
1086
|
+
options.onMiss?.(miss);
|
|
1087
|
+
outgoing.writeHead(MISS_STATUS, { "content-type": "application/json" });
|
|
1088
|
+
outgoing.end(
|
|
1089
|
+
JSON.stringify({
|
|
1090
|
+
error: "not in the recording",
|
|
1091
|
+
method: miss.method,
|
|
1092
|
+
path: miss.path,
|
|
1093
|
+
hint: "Record this endpoint, or the screen that needs it has no data."
|
|
1094
|
+
})
|
|
1095
|
+
);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
served.add(`${found.method} ${found.path}`);
|
|
1099
|
+
const body = found.encoding === "base64" ? Buffer.from(found.body, "base64") : Buffer.from(found.body, "utf8");
|
|
1100
|
+
outgoing.writeHead(found.status, { ...found.headers, "content-length": String(body.byteLength) });
|
|
1101
|
+
outgoing.end(body);
|
|
1102
|
+
})();
|
|
1103
|
+
});
|
|
1104
|
+
const port = await listen(server, options.port ?? 0);
|
|
1105
|
+
return {
|
|
1106
|
+
url: `http://127.0.0.1:${port}`,
|
|
1107
|
+
port,
|
|
1108
|
+
misses,
|
|
1109
|
+
served,
|
|
1110
|
+
close: () => shut(server)
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
function pageOf(referer) {
|
|
1114
|
+
try {
|
|
1115
|
+
return new URL(referer).pathname;
|
|
1116
|
+
} catch {
|
|
1117
|
+
return referer;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// adapters/seed-bin.ts
|
|
1122
|
+
var KINDS = [
|
|
1123
|
+
"given-name",
|
|
1124
|
+
"family-name",
|
|
1125
|
+
"full-name",
|
|
1126
|
+
"email",
|
|
1127
|
+
"phone",
|
|
1128
|
+
"street",
|
|
1129
|
+
"city",
|
|
1130
|
+
"postcode",
|
|
1131
|
+
"company",
|
|
1132
|
+
"note",
|
|
1133
|
+
"amount"
|
|
1134
|
+
];
|
|
1135
|
+
function parseSensitive(value) {
|
|
1136
|
+
const [key, kind] = value.split("=");
|
|
1137
|
+
if (!key || !kind) return `--sensitive wants key=kind, as in --sensitive diagnosis=note. Got "${value}".`;
|
|
1138
|
+
if (!KINDS.includes(kind)) {
|
|
1139
|
+
return `"${kind}" is not something this can invent. Choose one of: ${KINDS.join(", ")}.`;
|
|
1140
|
+
}
|
|
1141
|
+
return { key, kind };
|
|
1142
|
+
}
|
|
1143
|
+
function parse(argv) {
|
|
1144
|
+
const args = { command: argv[0] ?? "", cassette: "./seed-cassette.json", port: 0, brands: [], fields: [] };
|
|
1145
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
1146
|
+
const flag = argv[index];
|
|
1147
|
+
const value = argv[index + 1];
|
|
1148
|
+
if (flag === "--upstream" && value) args.upstream = value, index += 1;
|
|
1149
|
+
else if (flag === "--cassette" && value) args.cassette = value, index += 1;
|
|
1150
|
+
else if (flag === "--port" && value) args.port = Number(value), index += 1;
|
|
1151
|
+
else if (flag === "--anchor" && value) args.anchor = value, index += 1;
|
|
1152
|
+
else if (flag === "--seed" && value) args.seed = Number(value), index += 1;
|
|
1153
|
+
else if (flag === "--brand" && value) args.brands.push(value), index += 1;
|
|
1154
|
+
else if (flag === "--mapping" && value) args.mapping = value, index += 1;
|
|
1155
|
+
else if (flag === "--sensitive" && value) {
|
|
1156
|
+
const field = parseSensitive(value);
|
|
1157
|
+
if (typeof field === "string") return field;
|
|
1158
|
+
args.fields.push(field);
|
|
1159
|
+
index += 1;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (!["record", "replay", "gaps", "routes"].includes(args.command)) {
|
|
1163
|
+
return [
|
|
1164
|
+
"Usage: helpdesk-seed <record|replay|gaps|routes> [--upstream <url>] [--cassette <file>] [--port <n>]",
|
|
1165
|
+
"",
|
|
1166
|
+
" --brand <name> an institution of yours that must not appear in a screenshot",
|
|
1167
|
+
" --sensitive <key=kind> a key your product uses that holds something real,",
|
|
1168
|
+
" as in --sensitive diagnosis=note --sensitive clinician=full-name"
|
|
1169
|
+
].join("\n");
|
|
1170
|
+
}
|
|
1171
|
+
if (args.command === "record" && !args.upstream) {
|
|
1172
|
+
return "record needs --upstream <the address your product calls for data>";
|
|
1173
|
+
}
|
|
1174
|
+
return args;
|
|
1175
|
+
}
|
|
1176
|
+
async function main(argv) {
|
|
1177
|
+
const parsed = parse(argv);
|
|
1178
|
+
if (typeof parsed === "string") {
|
|
1179
|
+
console.error(parsed);
|
|
1180
|
+
return 2;
|
|
1181
|
+
}
|
|
1182
|
+
if (parsed.command === "record") return record(parsed);
|
|
1183
|
+
if (parsed.command === "replay") return replay(parsed);
|
|
1184
|
+
if (parsed.command === "gaps") return gaps(parsed);
|
|
1185
|
+
return routes(parsed);
|
|
1186
|
+
}
|
|
1187
|
+
async function record(args) {
|
|
1188
|
+
const proxy = await recordThrough({
|
|
1189
|
+
upstream: args.upstream,
|
|
1190
|
+
port: args.port,
|
|
1191
|
+
onRecord: (interaction) => console.log(` ${interaction.status} ${interaction.method} ${interaction.path}`)
|
|
1192
|
+
});
|
|
1193
|
+
console.log(
|
|
1194
|
+
[
|
|
1195
|
+
`Recording everything sent to ${args.upstream}.`,
|
|
1196
|
+
"",
|
|
1197
|
+
"Point your product's backend address at this and use it \u2014 sign in, open the screens",
|
|
1198
|
+
"you want documented, and make the errors happen if you can. What you visit is what",
|
|
1199
|
+
"the recording will be able to replay.",
|
|
1200
|
+
"",
|
|
1201
|
+
` ${proxy.url}`,
|
|
1202
|
+
"",
|
|
1203
|
+
"Use a development or staging backend and a throwaway account. This is passing real",
|
|
1204
|
+
"responses through to your product, so whatever that account holds is what gets",
|
|
1205
|
+
"written down before anything is scrubbed.",
|
|
1206
|
+
"",
|
|
1207
|
+
"Ctrl-C when you are done.",
|
|
1208
|
+
""
|
|
1209
|
+
].join("\n")
|
|
1210
|
+
);
|
|
1211
|
+
await new Promise((resolve) => {
|
|
1212
|
+
process.on("SIGINT", resolve);
|
|
1213
|
+
process.on("SIGTERM", resolve);
|
|
1214
|
+
});
|
|
1215
|
+
await proxy.close();
|
|
1216
|
+
const raw = proxy.cassette();
|
|
1217
|
+
if (!raw.interactions.length) {
|
|
1218
|
+
console.log("\nNothing was recorded. Was your product actually pointed at the address above?");
|
|
1219
|
+
return 1;
|
|
1220
|
+
}
|
|
1221
|
+
const sealed = sealCassette(raw, {
|
|
1222
|
+
...args.anchor ? { anchor: args.anchor } : {},
|
|
1223
|
+
...args.seed === void 0 ? {} : { seed: args.seed },
|
|
1224
|
+
...args.brands.length ? { brands: args.brands } : {},
|
|
1225
|
+
...args.fields.length ? { fields: args.fields } : {}
|
|
1226
|
+
});
|
|
1227
|
+
await mkdir(path.dirname(path.resolve(args.cassette)), { recursive: true });
|
|
1228
|
+
await writeFile(args.cassette, serialiseCassette(sealed.cassette), "utf8");
|
|
1229
|
+
console.log(
|
|
1230
|
+
`
|
|
1231
|
+
${sealed.cassette.interactions.length} interaction(s) written to ${args.cassette}.
|
|
1232
|
+
${sealed.mapping.entries.length} real value(s) replaced with a synthetic cast.` + (sealed.shift.latest ? `
|
|
1233
|
+
Time moved from ${sealed.shift.latest} to ${sealed.cassette.anchor}, intervals intact.` : "\nNo timestamps found, so nothing was moved.")
|
|
1234
|
+
);
|
|
1235
|
+
if (args.mapping) {
|
|
1236
|
+
await writeFile(args.mapping, `${JSON.stringify(sealed.mapping, null, 2)}
|
|
1237
|
+
`, "utf8");
|
|
1238
|
+
console.log(
|
|
1239
|
+
`
|
|
1240
|
+
The real-to-synthetic mapping is in ${args.mapping}. It is the personal data you just
|
|
1241
|
+
removed, in its most convenient possible form. Do not commit it.`
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
console.log(`
|
|
1245
|
+
${carriedReport(sealed.carried, args.cassette).join("\n")}`);
|
|
1246
|
+
if (sealed.findings.length) {
|
|
1247
|
+
console.log(`
|
|
1248
|
+
${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}`);
|
|
1249
|
+
console.log(
|
|
1250
|
+
"\nThese survived the field pass, which means they were in free text or under a key nothing\nrecognised. Read them before you commit this."
|
|
1251
|
+
);
|
|
1252
|
+
return 1;
|
|
1253
|
+
}
|
|
1254
|
+
console.log("\nNothing SHAPED like real data survived into the recording.");
|
|
1255
|
+
return 0;
|
|
1256
|
+
}
|
|
1257
|
+
function carriedReport(carried, cassette) {
|
|
1258
|
+
if (!carried.length) return ["Every key in the recording was recognised and rewritten."];
|
|
1259
|
+
const ranked = [...carried].sort((a, b) => score(b) - score(a) || b.count - a.count);
|
|
1260
|
+
const width = Math.max(...ranked.map((field) => field.key.length));
|
|
1261
|
+
const worth = ranked.filter((field) => score(field) > 0);
|
|
1262
|
+
return [
|
|
1263
|
+
`${carried.length} key(s) were carried through untouched:`,
|
|
1264
|
+
"",
|
|
1265
|
+
...ranked.map(
|
|
1266
|
+
(field) => ` ${field.key.padEnd(width)} ${String(field.count).padStart(4)}\xD7 ${field.example}`
|
|
1267
|
+
),
|
|
1268
|
+
"",
|
|
1269
|
+
"That is not automatically wrong \u2014 an id, a status or a currency is meant to survive.",
|
|
1270
|
+
"But this package only knows the words every product shares: people, their contact",
|
|
1271
|
+
"details, their address, and the role each one is playing. Whatever your product holds",
|
|
1272
|
+
"that no other product does is on the list above, and it was not touched.",
|
|
1273
|
+
"",
|
|
1274
|
+
...worth.length ? [
|
|
1275
|
+
"If any of these hold something real, name them and record again:",
|
|
1276
|
+
"",
|
|
1277
|
+
` ${helpdeskCommand("helpdesk-seed", ["record", "--upstream <url>", `--cassette ${cassette}`])} \\`,
|
|
1278
|
+
...worth.slice(0, 4).map((field, index) => ` --sensitive ${field.key}=${suggest(field)}${index === Math.min(worth.length, 4) - 1 ? "" : " \\"}`),
|
|
1279
|
+
"",
|
|
1280
|
+
" kinds: given-name, family-name, full-name, email, phone, street, city, postcode,",
|
|
1281
|
+
" company, note, amount",
|
|
1282
|
+
"",
|
|
1283
|
+
" note invents a sentence of about the same length, for anything free-text.",
|
|
1284
|
+
" amount invents a different figure of the same size, for a salary or a balance \u2014",
|
|
1285
|
+
" a number is never detectable by shape, so naming it is the only way it is caught."
|
|
1286
|
+
] : []
|
|
1287
|
+
];
|
|
1288
|
+
}
|
|
1289
|
+
function score(field) {
|
|
1290
|
+
const value = field.example;
|
|
1291
|
+
if (/^\d+(\.\d+)?$/.test(value)) return 0;
|
|
1292
|
+
if (!/[A-Za-z]/.test(value)) return 0;
|
|
1293
|
+
if (value.length < 12 && value === value.toLowerCase()) return 0;
|
|
1294
|
+
const prose = value.includes(" ") ? 2 : 0;
|
|
1295
|
+
const proper = /\b[A-Z][a-z]+\b/.test(value) ? 1 : 0;
|
|
1296
|
+
return prose + proper + (value.length > 30 ? 1 : 0);
|
|
1297
|
+
}
|
|
1298
|
+
function suggest(field) {
|
|
1299
|
+
const words = field.example.trim().split(/\s+/);
|
|
1300
|
+
const name = words.length <= 3 && words.every((word) => /^[A-Z][\w'’-]*$/.test(word));
|
|
1301
|
+
return name ? "full-name" : "note";
|
|
1302
|
+
}
|
|
1303
|
+
async function replay(args) {
|
|
1304
|
+
const cassette = parseCassette(await readFile(args.cassette, "utf8"));
|
|
1305
|
+
const misses = [];
|
|
1306
|
+
const proxy = await replayFrom({
|
|
1307
|
+
cassette,
|
|
1308
|
+
port: args.port,
|
|
1309
|
+
onMiss: (miss) => console.log(` MISS ${miss.method} ${miss.path}${miss.from ? ` (from ${miss.from})` : ""}`)
|
|
1310
|
+
});
|
|
1311
|
+
console.log(
|
|
1312
|
+
[
|
|
1313
|
+
`Replaying ${cassette.interactions.length} interaction(s) from ${args.cassette}.`,
|
|
1314
|
+
`Every timestamp in here sits relative to ${cassette.anchor}.`,
|
|
1315
|
+
"",
|
|
1316
|
+
` ${proxy.url}`,
|
|
1317
|
+
"",
|
|
1318
|
+
"The real backend is not reachable from here on purpose. Anything the recording does",
|
|
1319
|
+
"not have comes back as a miss rather than quietly fetching the truth, because a",
|
|
1320
|
+
"recording that reaches past itself puts real data in a screenshot.",
|
|
1321
|
+
"",
|
|
1322
|
+
"Ctrl-C when you are done.",
|
|
1323
|
+
""
|
|
1324
|
+
].join("\n")
|
|
1325
|
+
);
|
|
1326
|
+
await new Promise((resolve) => {
|
|
1327
|
+
process.on("SIGINT", resolve);
|
|
1328
|
+
process.on("SIGTERM", resolve);
|
|
1329
|
+
});
|
|
1330
|
+
misses.push(...proxy.misses);
|
|
1331
|
+
await proxy.close();
|
|
1332
|
+
const found = gapsFrom(misses, cassette);
|
|
1333
|
+
console.log(`
|
|
1334
|
+
${gapReport(found).join("\n")}`);
|
|
1335
|
+
if (found.length) {
|
|
1336
|
+
await writeFile(missFile(args.cassette), `${JSON.stringify(misses, null, 2)}
|
|
1337
|
+
`, "utf8");
|
|
1338
|
+
console.log(`
|
|
1339
|
+
The full miss log is in ${missFile(args.cassette)}.`);
|
|
1340
|
+
}
|
|
1341
|
+
const unused = cassette.interactions.filter(
|
|
1342
|
+
(interaction) => !proxy.served.has(`${interaction.method} ${interaction.path}`)
|
|
1343
|
+
);
|
|
1344
|
+
if (unused.length) {
|
|
1345
|
+
console.log(`
|
|
1346
|
+
${unused.length} recorded interaction(s) were never asked for during this run.`);
|
|
1347
|
+
}
|
|
1348
|
+
return found.length ? 1 : 0;
|
|
1349
|
+
}
|
|
1350
|
+
async function gaps(args) {
|
|
1351
|
+
const cassette = parseCassette(await readFile(args.cassette, "utf8"));
|
|
1352
|
+
const log = await readFile(missFile(args.cassette), "utf8").catch(() => "[]");
|
|
1353
|
+
console.log(gapReport(gapsFrom(JSON.parse(log), cassette)).join("\n"));
|
|
1354
|
+
return 0;
|
|
1355
|
+
}
|
|
1356
|
+
async function routes(args) {
|
|
1357
|
+
const cassette = parseCassette(await readFile(args.cassette, "utf8"));
|
|
1358
|
+
const found = exampleRoutes(cassette);
|
|
1359
|
+
if (!found.length) {
|
|
1360
|
+
console.log("The recording holds no parameterised routes, so there are no example ids to offer.");
|
|
1361
|
+
return 0;
|
|
1362
|
+
}
|
|
1363
|
+
console.log("Routes the recording can answer, with an identifier it has already served:\n");
|
|
1364
|
+
for (const route of found) console.log(` ${route}`);
|
|
1365
|
+
return 0;
|
|
1366
|
+
}
|
|
1367
|
+
function missFile(cassette) {
|
|
1368
|
+
return cassette.replace(/\.json$/, "") + ".misses.json";
|
|
1369
|
+
}
|
|
1370
|
+
export {
|
|
1371
|
+
carriedReport,
|
|
1372
|
+
main
|
|
1373
|
+
};
|