@forklaunch/core 2.1.0 → 3.0.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.
Files changed (52) hide show
  1. package/bin/gateway-mock/routes/email.mjs +233 -0
  2. package/bin/gateway-mock/routes/models.mjs +192 -0
  3. package/bin/gateway-mock/routes/payments.mjs +541 -0
  4. package/bin/gateway-mock/routes/sms.mjs +209 -0
  5. package/bin/gateway-mock/routes/voice.mjs +237 -0
  6. package/bin/gateway-mock/routes/whatsapp.mjs +227 -0
  7. package/bin/gateway-mock/server.mjs +321 -0
  8. package/bin/migrate-compliant-fields.mjs +312 -0
  9. package/bin/model-gateway-mock.mjs +13 -0
  10. package/lib/http/emailClient.d.ts +102 -0
  11. package/lib/http/emailClient.d.ts.map +1 -0
  12. package/lib/http/index.d.ts +7 -0
  13. package/lib/http/index.d.ts.map +1 -1
  14. package/lib/http/index.js +850 -4
  15. package/lib/http/index.js.map +1 -1
  16. package/lib/http/index.mjs +809 -1
  17. package/lib/http/index.mjs.map +1 -1
  18. package/lib/http/instanceGateway.d.ts +113 -0
  19. package/lib/http/instanceGateway.d.ts.map +1 -0
  20. package/lib/http/modelGatewayClient.d.ts +126 -0
  21. package/lib/http/modelGatewayClient.d.ts.map +1 -0
  22. package/lib/http/paymentsClient.d.ts +123 -0
  23. package/lib/http/paymentsClient.d.ts.map +1 -0
  24. package/lib/http/smsClient.d.ts +123 -0
  25. package/lib/http/smsClient.d.ts.map +1 -0
  26. package/lib/http/voiceClient.d.ts +105 -0
  27. package/lib/http/voiceClient.d.ts.map +1 -0
  28. package/lib/http/whatsappClient.d.ts +118 -0
  29. package/lib/http/whatsappClient.d.ts.map +1 -0
  30. package/lib/objectstore/interfaces/objectstore.interface.d.ts +45 -0
  31. package/lib/objectstore/interfaces/objectstore.interface.d.ts.map +1 -1
  32. package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
  33. package/lib/persistence/complianceTypes.d.ts +10 -1
  34. package/lib/persistence/complianceTypes.d.ts.map +1 -1
  35. package/lib/persistence/compliantField.d.ts +203 -0
  36. package/lib/persistence/compliantField.d.ts.map +1 -0
  37. package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
  38. package/lib/persistence/encryptedType.d.ts +8 -1
  39. package/lib/persistence/encryptedType.d.ts.map +1 -1
  40. package/lib/persistence/fieldEncryptor.d.ts +47 -6
  41. package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
  42. package/lib/persistence/index.d.ts +3 -2
  43. package/lib/persistence/index.d.ts.map +1 -1
  44. package/lib/persistence/index.js +987 -259
  45. package/lib/persistence/index.js.map +1 -1
  46. package/lib/persistence/index.mjs +976 -257
  47. package/lib/persistence/index.mjs.map +1 -1
  48. package/lib/persistence/keyRotation.d.ts +5 -0
  49. package/lib/persistence/keyRotation.d.ts.map +1 -1
  50. package/lib/services/index.js.map +1 -1
  51. package/lib/services/index.mjs.map +1 -1
  52. package/package.json +29 -13
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A local stand-in for the ForkLaunch instance gateway — and for the vendors
4
+ * behind it — so managed features run on a laptop and in CI with no accounts,
5
+ * no keys and no network. No dependencies; deterministic answers.
6
+ *
7
+ * Each feature (models, payments, email, sms, whatsapp, voice, …) is one file
8
+ * in ./routes, discovered at start-up. A route file exports:
9
+ *
10
+ * export default {
11
+ * feature: 'payments',
12
+ * // Instance-gateway routes, router-relative (the part after
13
+ * // /instance-gateway). Requests are HMAC-verified exactly as the platform
14
+ * // verifies them before the handler runs.
15
+ * routes: [{ method: 'POST', path: '/payments/checkout-sessions', handler }],
16
+ * // Optional vendor emulation (Foundry, Entra, …), unauthenticated here.
17
+ * vendorRoutes: [{ method: 'POST', path: /\/v1\/chat\/completions$/, handler }],
18
+ * // Optional: reset per-feature state (POST /__mock/reset).
19
+ * reset() {}
20
+ * }
21
+ *
22
+ * A handler gets `ctx` = { req, res, body, raw, path, params, instanceId, env,
23
+ * send(status, body, headers), stream(chunks, headers), emit(type, data),
24
+ * state, record } and answers through `send`/`stream`. `record` is this
25
+ * call's entry in /__mock/requests; a handler may annotate it (e.g. with what
26
+ * it forwarded upstream). `state` is a per-feature object
27
+ * that survives between requests until a reset. `path` may be a string or a
28
+ * RegExp; a RegExp's named groups arrive in `params`.
29
+ *
30
+ * Signatures cover the router-relative path plus its query string, and the
31
+ * body: JSON as parsed, a form-encoded body (the Stripe SDK's) verbatim — in
32
+ * which case `body` is {} and the handler reads `raw`.
33
+ *
34
+ * Events: `ctx.emit(type, data)` — or POST /__mock/events/<feature> with
35
+ * { type, data } — delivers a signed platform event to the app at
36
+ * `${MOCK_EVENTS_URL}/platform-events/<feature>`, signed with the instance
37
+ * key under key id `platform`, exactly as the platform does. The app verifies
38
+ * it with `verifyPlatformEvent` from @forklaunch/core/http.
39
+ *
40
+ * Control endpoints (for tests):
41
+ * GET /health
42
+ * GET /__mock/requests[?feature=x] gateway calls received, in order
43
+ * GET /__mock/events[?feature=x] events delivered and their outcomes
44
+ * POST /__mock/events/<feature> deliver an event now
45
+ * POST /__mock/reset forget requests, events and state
46
+ *
47
+ * Environment:
48
+ * PORT default 8080
49
+ * MOCK_INSTANCE_HMAC_KEY verify instance signatures and sign events
50
+ * (unset: accept any signature; events unsigned)
51
+ * MOCK_EVENTS_URL the app's base URL for event delivery
52
+ * MOCK_EVENTS_URL_<FEATURE> per-feature override (the service that declared it)
53
+ * (plus each feature's own MOCK_* settings; see its route file)
54
+ *
55
+ * Usage: npx -p @forklaunch/core forklaunch-gateway-mock
56
+ */
57
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
58
+ import { readdirSync } from 'node:fs';
59
+ import { createServer } from 'node:http';
60
+ import { dirname, join } from 'node:path';
61
+ import { fileURLToPath, pathToFileURL } from 'node:url';
62
+
63
+ const HERE = dirname(fileURLToPath(import.meta.url));
64
+ const MAX_SKEW_MS = 5 * 60 * 1000;
65
+ const GATEWAY_MOUNT = '/instance-gateway';
66
+
67
+ /** createHmacToken's canonical string, reimplemented without the framework. */
68
+ function sign(key, method, path, bodyPart, timestamp, nonce) {
69
+ return createHmac('sha256', key)
70
+ .update(`${method}\n${path}\n${bodyPart}${timestamp}\n${nonce}`)
71
+ .digest('base64');
72
+ }
73
+
74
+ export async function loadFeatures(dir = join(HERE, 'routes')) {
75
+ const files = readdirSync(dir)
76
+ .filter((f) => f.endsWith('.mjs'))
77
+ .sort();
78
+ const features = [];
79
+ for (const file of files) {
80
+ const mod = await import(pathToFileURL(join(dir, file)).href);
81
+ if (mod.default?.feature) features.push(mod.default);
82
+ }
83
+ return features;
84
+ }
85
+
86
+ function matchPath(pattern, path) {
87
+ if (typeof pattern === 'string') return pattern === path ? {} : null;
88
+ const m = pattern.exec(path);
89
+ return m ? { ...(m.groups ?? {}) } : null;
90
+ }
91
+
92
+ export async function startGatewayMock(env = process.env) {
93
+ const port = Number(env.PORT ?? 8080);
94
+ const hmacKey = env.MOCK_INSTANCE_HMAC_KEY || '';
95
+ const eventsUrl = env.MOCK_EVENTS_URL || '';
96
+ const features = await loadFeatures();
97
+ const state = new Map(features.map((f) => [f.feature, {}]));
98
+ const seenNonces = new Map();
99
+ let requests = [];
100
+ let events = [];
101
+
102
+ function verifyHmac(req, raw, routerPath, verbatim = false) {
103
+ if (!hmacKey) return { ok: true, instanceId: 'local-instance' };
104
+ const header = req.headers.authorization ?? '';
105
+ const m = /^HMAC keyId=(\S+) ts=(\S+) nonce=(\S+) signature=(\S+)$/.exec(
106
+ header
107
+ );
108
+ if (!m) return { ok: false, message: 'Missing or malformed HMAC authorization' };
109
+ const [, keyId, ts, nonce, signature] = m;
110
+ const timestamp = new Date(ts);
111
+ if (
112
+ Number.isNaN(timestamp.getTime()) ||
113
+ Math.abs(Date.now() - timestamp.getTime()) > MAX_SKEW_MS
114
+ ) {
115
+ return { ok: false, message: 'Stale or invalid timestamp' };
116
+ }
117
+ // createHmacToken: an absent body contributes the string "undefined",
118
+ // a present one its JSON text plus a newline.
119
+ const bodyPart = raw
120
+ ? `${verbatim ? raw : JSON.stringify(JSON.parse(raw))}\n`
121
+ : 'undefined';
122
+ const expected = Buffer.from(
123
+ sign(hmacKey, req.method, routerPath, bodyPart, timestamp.toISOString(), nonce)
124
+ );
125
+ const received = Buffer.from(signature);
126
+ if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
127
+ return { ok: false, message: 'Invalid signature' };
128
+ }
129
+ const now = Date.now();
130
+ for (const [seen, at] of seenNonces) if (now - at > MAX_SKEW_MS) seenNonces.delete(seen);
131
+ if (seenNonces.has(nonce)) return { ok: false, message: 'Replayed request' };
132
+ seenNonces.set(nonce, now);
133
+ return { ok: true, instanceId: keyId };
134
+ }
135
+
136
+ async function emit(feature, type, data) {
137
+ const event = {
138
+ id: `evt_mock_${randomUUID()}`,
139
+ feature,
140
+ type,
141
+ occurredAt: new Date().toISOString(),
142
+ data: data ?? {}
143
+ };
144
+ const record = { event, status: null, error: null };
145
+ events.push(record);
146
+ // A feature's events can go to its own service (MOCK_EVENTS_URL_<FEATURE>).
147
+ const target =
148
+ env[`MOCK_EVENTS_URL_${feature.toUpperCase().replace(/-/g, '_')}`] ||
149
+ eventsUrl;
150
+ if (!target) {
151
+ record.error = 'MOCK_EVENTS_URL is not set; event recorded, not delivered';
152
+ return record;
153
+ }
154
+ const path = `/platform-events/${feature}`;
155
+ const body = JSON.stringify(event);
156
+ const headers = { 'content-type': 'application/json' };
157
+ if (hmacKey) {
158
+ const ts = new Date().toISOString();
159
+ const nonce = randomUUID();
160
+ headers.authorization = `HMAC keyId=platform ts=${ts} nonce=${nonce} signature=${sign(hmacKey, 'POST', path, `${body}\n`, ts, nonce)}`;
161
+ }
162
+ try {
163
+ const response = await fetch(`${target.replace(/\/$/, '')}${path}`, {
164
+ method: 'POST',
165
+ headers,
166
+ body
167
+ });
168
+ record.status = response.status;
169
+ } catch (error) {
170
+ record.error = String(error?.message ?? error);
171
+ }
172
+ return record;
173
+ }
174
+
175
+ function send(res, status, body, headers = {}) {
176
+ const text = typeof body === 'string' ? body : JSON.stringify(body);
177
+ res.writeHead(status, {
178
+ 'content-type': typeof body === 'string' ? 'text/plain' : 'application/json',
179
+ ...headers
180
+ });
181
+ res.end(text);
182
+ }
183
+
184
+ function stream(res, chunks, headers = {}, withIds = false) {
185
+ res.writeHead(200, {
186
+ 'content-type': 'text/event-stream',
187
+ 'cache-control': 'no-cache',
188
+ ...headers
189
+ });
190
+ let id = 0;
191
+ for (const chunk of chunks) {
192
+ res.write(`${withIds ? `id: ${id++}\n` : ''}data: ${typeof chunk === 'string' ? chunk : JSON.stringify(chunk)}\n\n`);
193
+ }
194
+ res.end();
195
+ }
196
+
197
+ const server = createServer((req, res) => {
198
+ let raw = '';
199
+ req.setEncoding('utf8');
200
+ req.on('data', (chunk) => {
201
+ raw += chunk;
202
+ });
203
+ req.on('end', async () => {
204
+ try {
205
+ const url = new URL(req.url ?? '/', 'http://mock');
206
+ const path = url.pathname;
207
+ let body = {};
208
+ let parsed = true;
209
+ if (raw) {
210
+ try {
211
+ body = JSON.parse(raw);
212
+ } catch {
213
+ parsed = false;
214
+ }
215
+ }
216
+ const reply = (status, b, h) => send(res, status, b, h);
217
+
218
+ if (req.method === 'GET' && path === '/health') return reply(200, { ok: true });
219
+ if (req.method === 'GET' && path === '/__mock/requests') {
220
+ const f = url.searchParams.get('feature');
221
+ return reply(200, f ? requests.filter((r) => r.feature === f) : requests);
222
+ }
223
+ if (req.method === 'GET' && path === '/__mock/events') {
224
+ const f = url.searchParams.get('feature');
225
+ return reply(200, f ? events.filter((e) => e.event.feature === f) : events);
226
+ }
227
+ if (req.method === 'POST' && path === '/__mock/reset') {
228
+ requests = [];
229
+ events = [];
230
+ for (const f of features) {
231
+ state.set(f.feature, {});
232
+ f.reset?.();
233
+ }
234
+ return reply(200, { ok: true });
235
+ }
236
+ const eventMatch = /^\/__mock\/events\/([a-z-]+)$/.exec(path);
237
+ if (req.method === 'POST' && eventMatch) {
238
+ return reply(200, await emit(eventMatch[1], body.type, body.data));
239
+ }
240
+
241
+ const ctxBase = (feature, params, instanceId) => ({
242
+ req,
243
+ res,
244
+ body,
245
+ raw,
246
+ path,
247
+ params,
248
+ instanceId,
249
+ env,
250
+ state: state.get(feature),
251
+ send: (status, b, h) => send(res, status, b, h),
252
+ stream: (chunks, h, withIds) => stream(res, chunks, h, withIds),
253
+ emit: (type, data) => emit(feature, type, data)
254
+ });
255
+
256
+ if (path.startsWith(`${GATEWAY_MOUNT}/`)) {
257
+ const form = /x-www-form-urlencoded/.test(req.headers['content-type'] ?? '');
258
+ if (!parsed && !form) return reply(400, 'Body is not JSON');
259
+ if (form) body = {};
260
+ const routerPath = path.slice(GATEWAY_MOUNT.length);
261
+ const verified = verifyHmac(req, raw, routerPath + url.search, form);
262
+ if (!verified.ok) return reply(401, verified.message);
263
+ for (const feature of features) {
264
+ for (const route of feature.routes ?? []) {
265
+ if (route.method !== req.method) continue;
266
+ const params = matchPath(route.path, routerPath);
267
+ if (!params) continue;
268
+ const record = {
269
+ feature: feature.feature,
270
+ method: req.method,
271
+ path: routerPath,
272
+ instanceId: verified.instanceId,
273
+ body: raw ? (form ? raw : body) : undefined,
274
+ at: new Date().toISOString()
275
+ };
276
+ requests.push(record);
277
+ return await route.handler({
278
+ ...ctxBase(feature.feature, params, verified.instanceId),
279
+ record
280
+ });
281
+ }
282
+ }
283
+ return reply(404, 'No such gateway route');
284
+ }
285
+
286
+ for (const feature of features) {
287
+ for (const route of feature.vendorRoutes ?? []) {
288
+ if (route.method !== req.method) continue;
289
+ const params = matchPath(route.path, path);
290
+ if (!params) continue;
291
+ return await route.handler(ctxBase(feature.feature, params, undefined));
292
+ }
293
+ }
294
+ return reply(404, 'Not found');
295
+ } catch (error) {
296
+ if (!res.headersSent) send(res, 500, `Mock error: ${error?.message ?? error}`);
297
+ }
298
+ });
299
+ });
300
+
301
+ await new Promise((resolve) => server.listen(port, resolve));
302
+ console.log(
303
+ `gateway mock on :${port} — features ${features.map((f) => f.feature).join(', ')}; ` +
304
+ (hmacKey
305
+ ? 'verifying instance signatures'
306
+ : 'NOT verifying signatures (set MOCK_INSTANCE_HMAC_KEY)') +
307
+ (eventsUrl ? `; events to ${eventsUrl}` : '')
308
+ );
309
+ return server;
310
+ }
311
+
312
+ const isMain =
313
+ process.argv[1] &&
314
+ (fileURLToPath(import.meta.url) === process.argv[1] ||
315
+ process.argv[1].endsWith('forklaunch-gateway-mock'));
316
+ if (isMain) {
317
+ const server = await startGatewayMock();
318
+ for (const signal of ['SIGINT', 'SIGTERM']) {
319
+ process.on(signal, () => server.close(() => process.exit(0)));
320
+ }
321
+ }
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codemod for @forklaunch/core compliant fields.
4
+ *
5
+ * pii, phi and pci properties now load as CompliantField: the value is read
6
+ * with `.deanon` (plaintext) or `.anon` (de-identified). This rewrites every
7
+ * place a project used such a property as its value, by asking the
8
+ * project's own TypeScript program where a CompliantField meets something
9
+ * that is not one:
10
+ *
11
+ * const s: string = user.email -> user.email.deanon
12
+ * return { to: record.to } -> record.to.deanon
13
+ * send(record.body) -> send(record.body.deanon)
14
+ * `Hi ${user.name}` -> `Hi ${user.name.deanon}`
15
+ * user.email.toLowerCase() -> user.email.deanon.toLowerCase()
16
+ * user.email === input -> user.email.deanon === input
17
+ * ({ ...entity }) -> ({ ...deanon(entity) })
18
+ *
19
+ * Nullable fields get `?.deanon`. Entity data (em.create, em.assign) and
20
+ * `where` clauses are left alone: they accept plain values already.
21
+ *
22
+ * What it cannot decide is reported, not guessed: chiefly `where` clauses on
23
+ * fields that are not queryable, which need `.compliance(level, { queryable:
24
+ * true })` on the property (and a migration adding its `<column>_idx`).
25
+ *
26
+ * It runs on TypeScript 7's API (`typescript/unstable/sync`, the native
27
+ * compiler's type checker), from the project's own TypeScript when it has
28
+ * one, else the one installed with @forklaunch/core.
29
+ *
30
+ * Usage: npx -y -p @forklaunch/core forklaunch-migrate-compliant-fields
31
+ * [path/to/tsconfig.json] [--dry-run] [--import-from <module exporting deanon>]
32
+ */
33
+ import { readFileSync, writeFileSync } from 'node:fs';
34
+ import { createRequire } from 'node:module';
35
+ import path from 'node:path';
36
+ import { pathToFileURL } from 'node:url';
37
+
38
+ const args = process.argv.slice(2);
39
+ const dryRun = args.includes('--dry-run');
40
+ const positional = args.filter(
41
+ (a, i) => !a.startsWith('--') && args[i - 1] !== '--import-from'
42
+ );
43
+ const tsconfigPath = path.resolve(positional[0] ?? 'tsconfig.json');
44
+ const importFrom = args.includes('--import-from')
45
+ ? args[args.indexOf('--import-from') + 1]
46
+ : '@forklaunch/core/persistence';
47
+
48
+ /** A TypeScript 7 module: the project's own first, then ours. */
49
+ async function loadTypeScript(subpath) {
50
+ for (const from of [tsconfigPath, import.meta.url]) {
51
+ try {
52
+ const resolved = createRequire(from).resolve(`typescript/${subpath}`);
53
+ return await import(pathToFileURL(resolved).href);
54
+ } catch {
55
+ // try the next one
56
+ }
57
+ }
58
+ console.error(
59
+ `This codemod needs TypeScript 7 (typescript/${subpath}). Install typescript@7 in the project and run it again.`
60
+ );
61
+ process.exit(1);
62
+ }
63
+ const { API, TypeFlags } = await loadTypeScript('unstable/sync');
64
+ const ast = await loadTypeScript('unstable/ast');
65
+ const { SyntaxKind } = ast;
66
+
67
+ const READ_MEMBERS = new Set(['anon', 'deanon', 'level']);
68
+
69
+ /** The kinds TypeScript counts as declarations (ts.isDeclarationKind). */
70
+ const DECLARATION_KINDS = new Set(
71
+ [
72
+ 'ArrowFunction', 'BindingElement', 'ClassDeclaration', 'ClassExpression',
73
+ 'ClassStaticBlockDeclaration', 'Constructor', 'EnumDeclaration', 'EnumMember',
74
+ 'ExportSpecifier', 'FunctionDeclaration', 'FunctionExpression', 'GetAccessor',
75
+ 'ImportClause', 'ImportEqualsDeclaration', 'ImportSpecifier', 'InterfaceDeclaration',
76
+ 'JsxAttribute', 'MethodDeclaration', 'MethodSignature', 'ModuleDeclaration',
77
+ 'NamespaceExportDeclaration', 'NamespaceImport', 'NamespaceExport', 'Parameter',
78
+ 'PropertyAssignment', 'PropertyDeclaration', 'PropertySignature', 'SetAccessor',
79
+ 'ShorthandPropertyAssignment', 'TypeAliasDeclaration', 'TypeParameter',
80
+ 'VariableDeclaration'
81
+ ]
82
+ .map((name) => SyntaxKind[name])
83
+ .filter((kind) => kind !== undefined)
84
+ );
85
+ const isDeclaration = (node) => Boolean(node) && DECLARATION_KINDS.has(node.kind);
86
+
87
+ /** Open the project; a fresh API per load so edits on disk are re-read. */
88
+ function loadProject() {
89
+ const api = new API({ cwd: path.dirname(tsconfigPath) });
90
+ const snapshot = api.updateSnapshot({ openProjects: [tsconfigPath] });
91
+ const project = snapshot.getProject(tsconfigPath) ?? snapshot.getProjects()[0];
92
+ if (!project) {
93
+ console.error(`Could not open ${tsconfigPath}`);
94
+ api.close();
95
+ process.exit(1);
96
+ }
97
+ return { api, project };
98
+ }
99
+
100
+ const isUnion = (type) => Boolean(type && type.flags & TypeFlags.Union);
101
+
102
+ function isCompliantType(type) {
103
+ if (!type) return false;
104
+ if (isUnion(type)) return type.getTypes().some(isCompliantType);
105
+ const symbol = type.getAliasSymbol() ?? type.getSymbol();
106
+ return symbol?.name === 'CompliantField';
107
+ }
108
+
109
+ function isNullableCompliant(type) {
110
+ return (
111
+ isUnion(type) &&
112
+ type.getTypes().some((t) => t.flags & (TypeFlags.Null | TypeFlags.Undefined))
113
+ );
114
+ }
115
+
116
+ /** Does `target` accept a CompliantField as it is (entity data, where, any)? */
117
+ function acceptsField(checker, source, target) {
118
+ if (!target) return true;
119
+ if (target.flags & (TypeFlags.Any | TypeFlags.Unknown)) return true;
120
+ return checker.isTypeAssignableTo(source, target);
121
+ }
122
+
123
+ /** Why this expression, typed CompliantField, needs its value. */
124
+ function needsValue(checker, node, type) {
125
+ const parent = node.parent;
126
+ if (!parent) return false;
127
+
128
+ // user.email.toLowerCase() (anything but .anon / .deanon / .level)
129
+ if (
130
+ ast.isPropertyAccessExpression(parent) &&
131
+ parent.expression === node &&
132
+ !READ_MEMBERS.has(parent.name.text)
133
+ ) {
134
+ return true;
135
+ }
136
+ // `${user.email}`
137
+ if (ast.isTemplateSpan(parent) && parent.expression === node) return true;
138
+ // user.email === input, user.email + '!'
139
+ if (ast.isBinaryExpression(parent)) {
140
+ const op = parent.operatorToken.kind;
141
+ const other = parent.left === node ? parent.right : parent.left;
142
+ const comparison = [
143
+ SyntaxKind.EqualsEqualsEqualsToken,
144
+ SyntaxKind.ExclamationEqualsEqualsToken,
145
+ SyntaxKind.EqualsEqualsToken,
146
+ SyntaxKind.ExclamationEqualsToken,
147
+ SyntaxKind.PlusToken
148
+ ].includes(op);
149
+ if (comparison) {
150
+ const otherType = checker.getTypeAtLocation(other);
151
+ const otherIsNullish =
152
+ otherType && otherType.flags & (TypeFlags.Null | TypeFlags.Undefined);
153
+ return !isCompliantType(otherType) && !otherIsNullish;
154
+ }
155
+ if (op === SyntaxKind.EqualsToken && parent.left === node) return false;
156
+ }
157
+ // Assignments, returns, arguments, properties: compare with the
158
+ // contextual type.
159
+ const contextual = checker.getContextualType(node);
160
+ if (contextual) return !acceptsField(checker, type, contextual);
161
+ return false;
162
+ }
163
+
164
+ function sourceFiles(program) {
165
+ return program
166
+ .getSourceFileNames()
167
+ .filter((fileName) => !fileName.includes('/node_modules/'))
168
+ .map((fileName) => program.getSourceFile(fileName))
169
+ .filter((sourceFile) => sourceFile && !sourceFile.isDeclarationFile);
170
+ }
171
+
172
+ function collectEdits(project) {
173
+ const { checker, program } = project;
174
+ const edits = new Map(); // fileName -> [{ pos, text }]
175
+ let count = 0;
176
+
177
+ for (const sourceFile of sourceFiles(program)) {
178
+ const fileEdits = [];
179
+ let needsImport = false;
180
+
181
+ const visit = (node) => {
182
+ // { ...entity }: compliant properties live on the prototype, so a
183
+ // spread drops them; deanon(entity) copies them as values.
184
+ if (ast.isSpreadAssignment(node)) {
185
+ const spreadType = checker.getTypeAtLocation(node.expression);
186
+ const carriesField =
187
+ spreadType &&
188
+ checker
189
+ .getPropertiesOfType(spreadType)
190
+ .some((property) =>
191
+ isCompliantType(
192
+ checker.getTypeOfSymbolAtLocation(property, node.expression)
193
+ )
194
+ );
195
+ if (carriesField) {
196
+ fileEdits.push({
197
+ pos: ast.getTokenPosOfNode(node.expression, sourceFile),
198
+ text: 'deanon('
199
+ });
200
+ fileEdits.push({ pos: node.expression.end, text: ')' });
201
+ needsImport = true;
202
+ count += 1;
203
+ return undefined;
204
+ }
205
+ }
206
+ const isCandidate =
207
+ ast.isPropertyAccessExpression(node) ||
208
+ ast.isElementAccessExpression(node) ||
209
+ (ast.isIdentifier(node) &&
210
+ !ast.isPropertyAccessExpression(node.parent) &&
211
+ !isDeclaration(node.parent));
212
+ if (isCandidate) {
213
+ const type = checker.getTypeAtLocation(node);
214
+ if (isCompliantType(type) && needsValue(checker, node, type)) {
215
+ fileEdits.push({
216
+ pos: node.end,
217
+ text: isNullableCompliant(type) ? '?.deanon' : '.deanon'
218
+ });
219
+ count += 1;
220
+ return undefined; // do not descend into a rewritten expression
221
+ }
222
+ }
223
+ node.forEachChild(visit);
224
+ return undefined; // a truthy return would stop forEachChild
225
+ };
226
+ visit(sourceFile);
227
+
228
+ if (needsImport && !/\bdeanon\b[^;]*from/.test(sourceFile.text)) {
229
+ const imports = sourceFile.statements.filter(ast.isImportDeclaration);
230
+ const pos = imports.length ? imports[imports.length - 1].end : 0;
231
+ fileEdits.push({
232
+ pos,
233
+ text: `${pos ? '\n' : ''}import { deanon } from '${importFrom}';${pos ? '' : '\n'}`
234
+ });
235
+ }
236
+ if (fileEdits.length) edits.set(sourceFile.fileName, fileEdits);
237
+ }
238
+ return { edits, count };
239
+ }
240
+
241
+ function applyEdits(edits) {
242
+ for (const [fileName, fileEdits] of edits) {
243
+ let text = readFileSync(fileName, 'utf8');
244
+ // Apply back to front; at one position, later-pushed edits go first so
245
+ // pushes read left to right in the result.
246
+ const ordered = fileEdits
247
+ .map((edit, index) => ({ ...edit, index }))
248
+ .sort((a, b) => b.pos - a.pos || b.index - a.index);
249
+ for (const edit of ordered) {
250
+ text = text.slice(0, edit.pos) + edit.text + text.slice(edit.pos);
251
+ }
252
+ if (!dryRun) writeFileSync(fileName, text);
253
+ console.log(
254
+ `${dryRun ? 'would edit' : 'edited'} ${path.relative(process.cwd(), fileName)} (${fileEdits.length})`
255
+ );
256
+ }
257
+ }
258
+
259
+ /** A diagnostic's message and its chain, flattened. */
260
+ function flatten(diagnostic, separator) {
261
+ return [diagnostic.text, ...(diagnostic.messageChain ?? []).map((d) => flatten(d, separator))]
262
+ .filter(Boolean)
263
+ .join(separator);
264
+ }
265
+
266
+ function reportRemaining(project) {
267
+ const { program } = project;
268
+ const remaining = sourceFiles(program)
269
+ .flatMap((sourceFile) => [
270
+ ...program.getSyntacticDiagnostics(sourceFile.fileName),
271
+ ...program.getSemanticDiagnostics(sourceFile.fileName)
272
+ ])
273
+ .filter((d) => flatten(d, '\n').includes('CompliantField'));
274
+ if (remaining.length === 0) {
275
+ console.log('No compliant-field type errors remain.');
276
+ return 0;
277
+ }
278
+ console.log(`\n${remaining.length} compliant-field error(s) need a decision:`);
279
+ for (const d of remaining) {
280
+ const sourceFile = d.fileName ? program.getSourceFile(d.fileName) : undefined;
281
+ const where = sourceFile
282
+ ? (() => {
283
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(d.pos ?? 0);
284
+ return `${path.relative(process.cwd(), d.fileName)}:${line + 1}:${character + 1}`;
285
+ })()
286
+ : '';
287
+ const message = flatten(d, ' ');
288
+ const hint = /FilterQuery|FilterValue|ExpandScalar|OperatorMap/.test(message)
289
+ ? ' -> a `where` on a field that is not queryable: add `{ queryable: true }` to its .compliance() call and generate a migration'
290
+ : '';
291
+ console.log(` ${where} ${message.slice(0, 240)}${hint ? `\n${hint}` : ''}`);
292
+ }
293
+ return remaining.length;
294
+ }
295
+
296
+ let loaded = loadProject();
297
+ const { edits, count } = collectEdits(loaded.project);
298
+ applyEdits(edits);
299
+ console.log(
300
+ `${count} read(s) ${dryRun ? 'would be' : ''} rewritten to .deanon across ${edits.size} file(s).`
301
+ );
302
+ if (!dryRun && count > 0) {
303
+ loaded.api.close();
304
+ loaded = loadProject();
305
+ }
306
+ const left = reportRemaining(loaded.project);
307
+ loaded.api.close();
308
+ console.log(
309
+ '\nNext: generate a migration (queryable fields add <column>_idx), deploy, then run ' +
310
+ 'reencryptEncryptedColumns() once to seal existing rows into v4 and write blind indexes.'
311
+ );
312
+ process.exit(left > 0 ? 2 : 0);
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The ForkLaunch gateway mock, under its original name. Every feature's mock
4
+ * (models, payments, email, sms, whatsapp, voice) now lives in
5
+ * ./gateway-mock; see gateway-mock/server.mjs for settings and control
6
+ * endpoints. Kept so existing docker-compose files and scripts keep working.
7
+ */
8
+ import { startGatewayMock } from './gateway-mock/server.mjs';
9
+
10
+ const server = await startGatewayMock();
11
+ for (const signal of ['SIGINT', 'SIGTERM']) {
12
+ process.on(signal, () => server.close(() => process.exit(0)));
13
+ }