@forklaunch/core 2.1.1 → 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.
- package/bin/gateway-mock/routes/email.mjs +233 -0
- package/bin/gateway-mock/routes/models.mjs +192 -0
- package/bin/gateway-mock/routes/payments.mjs +541 -0
- package/bin/gateway-mock/routes/sms.mjs +209 -0
- package/bin/gateway-mock/routes/voice.mjs +237 -0
- package/bin/gateway-mock/routes/whatsapp.mjs +227 -0
- package/bin/gateway-mock/server.mjs +321 -0
- package/bin/migrate-compliant-fields.mjs +312 -0
- package/bin/model-gateway-mock.mjs +13 -0
- package/lib/http/emailClient.d.ts +102 -0
- package/lib/http/emailClient.d.ts.map +1 -0
- package/lib/http/index.d.ts +7 -0
- package/lib/http/index.d.ts.map +1 -1
- package/lib/http/index.js +850 -4
- package/lib/http/index.js.map +1 -1
- package/lib/http/index.mjs +809 -1
- package/lib/http/index.mjs.map +1 -1
- package/lib/http/instanceGateway.d.ts +113 -0
- package/lib/http/instanceGateway.d.ts.map +1 -0
- package/lib/http/modelGatewayClient.d.ts +126 -0
- package/lib/http/modelGatewayClient.d.ts.map +1 -0
- package/lib/http/paymentsClient.d.ts +123 -0
- package/lib/http/paymentsClient.d.ts.map +1 -0
- package/lib/http/smsClient.d.ts +123 -0
- package/lib/http/smsClient.d.ts.map +1 -0
- package/lib/http/voiceClient.d.ts +105 -0
- package/lib/http/voiceClient.d.ts.map +1 -0
- package/lib/http/whatsappClient.d.ts +118 -0
- package/lib/http/whatsappClient.d.ts.map +1 -0
- package/lib/objectstore/interfaces/objectstore.interface.d.ts +45 -0
- package/lib/objectstore/interfaces/objectstore.interface.d.ts.map +1 -1
- package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
- package/lib/persistence/complianceTypes.d.ts +10 -1
- package/lib/persistence/complianceTypes.d.ts.map +1 -1
- package/lib/persistence/compliantField.d.ts +203 -0
- package/lib/persistence/compliantField.d.ts.map +1 -0
- package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
- package/lib/persistence/encryptedType.d.ts +8 -1
- package/lib/persistence/encryptedType.d.ts.map +1 -1
- package/lib/persistence/fieldEncryptor.d.ts +47 -6
- package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
- package/lib/persistence/index.d.ts +3 -2
- package/lib/persistence/index.d.ts.map +1 -1
- package/lib/persistence/index.js +987 -259
- package/lib/persistence/index.js.map +1 -1
- package/lib/persistence/index.mjs +976 -257
- package/lib/persistence/index.mjs.map +1 -1
- package/lib/persistence/keyRotation.d.ts +5 -0
- package/lib/persistence/keyRotation.d.ts.map +1 -1
- package/lib/services/index.js.map +1 -1
- package/lib/services/index.mjs.map +1 -1
- package/package.json +25 -9
|
@@ -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
|
+
}
|