@fourier-labs/harbour 0.1.27 → 0.1.28

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.
@@ -1,490 +0,0 @@
1
- import { createServer, request as httpRequest } from "node:http";
2
- import { connect } from "node:net";
3
- import { readdir, readFile } from "node:fs/promises";
4
- import { extname, join, relative, sep } from "node:path";
5
- /**
6
- * The pipeline's operation-coverage gate, run locally.
7
- *
8
- * The deployment pipeline replays `.harbour/checks/` against a real App Gateway
9
- * and then refuses the deployment (`flow.check-failed`) unless every database
10
- * operation the browser code performs, and every capability the kit lane
11
- * derives from that code, was actually exercised while those checks ran. Before
12
- * this module `harbour check` ran the same journey scripts but asserted nothing
13
- * about what they exercised, so an app whose checks never touched a converted
14
- * operation passed locally and failed in the pipeline — the parity gap this
15
- * closes.
16
- *
17
- * The inventory and the wording below deliberately mirror the data plane's
18
- * packages/toolkit/transformbuild (write_probe.go `inventorySDKTableVerbs`,
19
- * local_operation_coverage.go `requireOperationCoverage`) so a local failure
20
- * reads exactly like the pipeline's.
21
- */
22
- /** Capabilities the coverage gate demands evidence for, in the pipeline's order. */
23
- export const COVERED_CAPABILITIES = ["data", "files", "actions", "telemetry", "realtime"];
24
- /** Every capability the kit lane derives from the browser SDK surface. */
25
- const SDK_CAPABILITIES = ["data", "files", "actions", "realtime", "telemetry", "integrations"];
26
- const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
27
- const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".harbour"]);
28
- /**
29
- * TypeScript call sites carry explicit type arguments between a method name and
30
- * its call — `harbour.data.from<Note>("notes")`, `.select<Row>(` — and the
31
- * starter's own list is one of them. Matching only the bare `.from(` made every
32
- * typed call site invisible, so a freshly generated starter inventoried
33
- * `notes: delete, insert, update` with no SELECT: the gate was reading the
34
- * starter's own capabilities wrong. This is the pipeline's own
35
- * `appSDKTypeArguments` (transformbuild/source_inspection.go), one nesting
36
- * level of generics, so both scanners see the same call sites.
37
- */
38
- const TYPE_ARGUMENTS = String.raw `(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\s*`;
39
- const TABLE_CHAIN_SOURCE = String.raw `\.from${TYPE_ARGUMENTS}\(\s*["'\`]([A-Za-z0-9_]+)["'\`]\s*\)`;
40
- const TABLE_CHAIN = new RegExp(TABLE_CHAIN_SOURCE, "g");
41
- /** The same chain, unanchored and non-global: where THIS chain's verbs stop. */
42
- const NEXT_TABLE_CHAIN = new RegExp(TABLE_CHAIN_SOURCE);
43
- const VERB_CALL = new RegExp(String.raw `\.(select|insert|update|delete|upsert)\s*${TYPE_ARGUMENTS}\(`, "g");
44
- /** `createClient()` bindings name the identifier a capability call must be made on. */
45
- const CLIENT_BINDING = /(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:await\s+)?createClient\s*\(/g;
46
- /** `<anything>.<namespace>.<method>(` — the receiver is deliberately not constrained; see capabilityCallSurface. */
47
- const CAPABILITY_CALL = new RegExp(String.raw `\.\s*(${SDK_CAPABILITIES.join("|")})\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*\s*${TYPE_ARGUMENTS}\(`, "g");
48
- /** Records the gateway operations observed while the retained checks ran. */
49
- export class CoverageLedger {
50
- seen = new Set();
51
- /** capability -> the retained checks that asked the gateway for it. */
52
- asked = new Map();
53
- running;
54
- add(key) { this.seen.add(key); }
55
- has(key) { return this.seen.has(key); }
56
- keys() { return [...this.seen].sort(); }
57
- /**
58
- * Names the retained check whose gateway traffic follows. The journeys run
59
- * one at a time, so a request the proxy sees between this call and the next
60
- * belongs to this check — which is how a refusal can name the file to delete.
61
- */
62
- nowRunning(check) { this.running = check; }
63
- /**
64
- * One request the running check sent at a capability-gated path, recorded
65
- * whatever the answer was. The deployed gateway resolves the capability
66
- * BEFORE any handler runs and answers 404 when the app did not declare it
67
- * (appgateway/resolver.go "capability is not declared"), so the request
68
- * itself — not its local success — is what decides the check's fate there.
69
- */
70
- askedFor(path) {
71
- const capability = capabilityForRequest(path);
72
- if (!capability || !this.running)
73
- return;
74
- const checks = this.asked.get(capability) ?? new Set();
75
- checks.add(this.running);
76
- this.asked.set(capability, checks);
77
- }
78
- /** capability -> the checks that asked for it, for the reverse coverage question. */
79
- capabilitiesAsked() { return this.asked; }
80
- /**
81
- * The gateway's data boundary. A write that changed no row proves nothing, so
82
- * only a select — or a mutation that returned rows — counts (observeData in
83
- * local_operation_coverage.go).
84
- */
85
- observeData(table, operation, rows) {
86
- if (operation !== "select" && rows === 0)
87
- return;
88
- this.add("data");
89
- this.add(`data:${table}:${operation}`);
90
- }
91
- }
92
- async function sourceFiles(root) {
93
- const found = [];
94
- const walk = async (directory) => {
95
- const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
96
- for (const entry of entries) {
97
- const path = join(directory, entry.name);
98
- if (entry.isDirectory()) {
99
- if (!SKIPPED_DIRECTORIES.has(entry.name))
100
- await walk(path);
101
- continue;
102
- }
103
- if (SOURCE_EXTENSIONS.has(extname(entry.name)))
104
- found.push(path);
105
- }
106
- };
107
- await walk(root);
108
- return found.sort();
109
- }
110
- /**
111
- * Every `<table> <verb>` the browser code performs, with the files performing
112
- * it. The verb chain is what follows THIS `.from(...)` up to the end of its
113
- * statement or the next `.from(` — never a later chain's verbs.
114
- *
115
- * This reading is what the gate demands evidence for, so `retained-checks.ts`
116
- * generates from exactly it: a generated journey can then only ever cover what
117
- * the gate asks about, in both directions, with no template in between.
118
- */
119
- export async function sourceTableUsage(root) {
120
- const inventory = new Map();
121
- for (const path of await sourceFiles(root)) {
122
- const text = await readFile(path, "utf8");
123
- for (const match of text.matchAll(TABLE_CHAIN)) {
124
- const start = match.index ?? 0;
125
- if (text.slice(0, start).trimEnd().endsWith(".storage"))
126
- continue;
127
- let tail = text.slice(start + match[0].length);
128
- const semicolon = tail.indexOf(";");
129
- if (/[;\n]/.test(tail) && semicolon >= 0)
130
- tail = tail.slice(0, semicolon);
131
- const next = tail.search(NEXT_TABLE_CHAIN);
132
- if (next > 0)
133
- tail = tail.slice(0, next);
134
- tail = tail.slice(0, 200);
135
- const table = match[1];
136
- const usage = inventory.get(table) ?? { verbs: new Set(), files: [] };
137
- for (const verb of tail.matchAll(VERB_CALL))
138
- usage.verbs.add(verb[1]);
139
- const file = appPath(root, path);
140
- if (!usage.files.includes(file))
141
- usage.files.push(file);
142
- inventory.set(table, usage);
143
- }
144
- }
145
- return inventory;
146
- }
147
- /** `<table> <verb>` alone, as the coverage gate compares it. */
148
- export async function sourceTableVerbs(root) {
149
- return new Map([...await sourceTableUsage(root)].map(([table, usage]) => [table, usage.verbs]));
150
- }
151
- /**
152
- * The capabilities the kit lane declares for this app, with the files declaring
153
- * them: a namespace called as `<client>.<namespace>.<method>(` on an identifier
154
- * bound to `createClient()`. TypeScript type arguments may sit between the
155
- * method and its call.
156
- */
157
- export async function capabilityUsage(root) {
158
- const files = await sourceFiles(root);
159
- const texts = await Promise.all(files.map(path => readFile(path, "utf8").catch(() => "")));
160
- const clients = new Set();
161
- for (const text of texts)
162
- for (const match of text.matchAll(CLIENT_BINDING))
163
- clients.add(match[1]);
164
- const used = new Map();
165
- if (!clients.size)
166
- return used;
167
- const namespaces = SDK_CAPABILITIES.join("|");
168
- for (const client of clients) {
169
- const call = new RegExp(`\\b${client}\\s*\\.\\s*(${namespaces})\\s*\\.\\s*[A-Za-z_$][A-Za-z0-9_$]*\\s*(?:<[^<>()]*>\\s*)?\\(`, "g");
170
- for (const [index, text] of texts.entries()) {
171
- const file = appPath(root, files[index]);
172
- for (const match of text.matchAll(call)) {
173
- const seen = used.get(match[1]) ?? [];
174
- if (!seen.includes(file))
175
- seen.push(file);
176
- used.set(match[1], seen);
177
- }
178
- }
179
- }
180
- return used;
181
- }
182
- /** The capability names alone, as the coverage gate compares them. */
183
- export async function declaredCapabilities(root) {
184
- return new Set((await capabilityUsage(root)).keys());
185
- }
186
- /** A source file as the app itself names it: relative to the root, `/`-separated on every platform. */
187
- const appPath = (root, path) => relative(root, path).split(sep).join("/");
188
- /**
189
- * Every capability namespace the app's own source calls a method on, whatever
190
- * object the call is made on: `harbour.files.list(`, `client().files.list(`,
191
- * `(harbour as {files: F}).files.list(`.
192
- *
193
- * This is deliberately looser than `declaredCapabilities`, and the two answer
194
- * different questions. The forward gate asks "was this capability exercised?"
195
- * and must be precise about what the app really uses. The reverse gate below
196
- * asks "is this check exercising something the app no longer has?" — a
197
- * question whose wrong answer refuses a good app — so it needs a lower bound on
198
- * what is unused, never an upper bound on what is used.
199
- *
200
- * The pipeline's own evidence for a capability is `<client>.<namespace>.<method>(`
201
- * with the client traced through the module graph (transformbuild
202
- * analyzeAppSDKClientCalls / analyzeAppSDKNamespaceWrappers). Dropping the
203
- * receiver makes this a superset of that set — every call site the pipeline can
204
- * trace, plus ones it cannot — so a capability this cannot see is one the kit
205
- * lane cannot declare either, and the reverse gate refuses only trees the
206
- * pipeline already refuses. Where it errs it errs by staying quiet: a
207
- * commented-out call still counts here, and the pipeline masks comments.
208
- */
209
- export async function capabilityCallSurface(root) {
210
- const surface = new Set();
211
- for (const path of await sourceFiles(root))
212
- for (const capability of capabilitiesCalled(await readFile(path, "utf8").catch(() => "")))
213
- surface.add(capability);
214
- return surface;
215
- }
216
- /** The same reading of one piece of text, so a retained check is judged by exactly the rule the app's own source is read with (retained-checks.ts). */
217
- export function capabilitiesCalled(text) {
218
- return new Set([...text.matchAll(CAPABILITY_CALL)].map(match => match[1]));
219
- }
220
- /**
221
- * The gateway's own path -> capability map (appgateway/gateway.go
222
- * `capabilityForRequest`). Every one of these paths is resolved against the
223
- * app's declared capability set before it is served.
224
- */
225
- export function capabilityForRequest(path) {
226
- if (path === "/_harbour/realtime")
227
- return "realtime";
228
- if (path.startsWith("/_harbour/files"))
229
- return "files";
230
- if (path.startsWith("/_harbour/actions/"))
231
- return "actions";
232
- if (path === "/_harbour/telemetry")
233
- return "telemetry";
234
- if (path.startsWith("/_harbour/integrations/"))
235
- return "integrations";
236
- if (path.startsWith("/_harbour/data/"))
237
- return "data";
238
- return undefined;
239
- }
240
- /**
241
- * The other direction, and the one that let a real deploy fail twice: a
242
- * retained check exercising a capability the app does not have any more.
243
- *
244
- * A builder's agent replaced the starter's notes schema and deleted the
245
- * "Private files" section but left `.harbour/checks/files-journey.mjs` behind.
246
- * The kit lane derives `.harbour/app-capabilities.json` from the browser SDK
247
- * surface, the cell's App Gateway serves only what that file declares, and the
248
- * journey's first `harbour.files.*` call came back 404 — `flow.check-failed …
249
- * files-journey.mjs: exit status 1`, twice, 2m23s and 2m12s. Locally the check
250
- * was green: `harbour dev` hard-codes data/files/realtime/telemetry
251
- * (local-runtime.ts `gatewayConfig`) and always provisions MinIO, so the
252
- * orphaned journey passed on its own terms.
253
- *
254
- * This reproduces the pipeline's refusal rather than inventing a second
255
- * opinion, at the pipeline's own granularity — the capability, not the table.
256
- * The pipeline does have a table-level reverse check
257
- * (`rejectServerTierOperationsDrivenFromTheBrowser`), but it is scoped to apps
258
- * that declare a workload directory; a kit app has none, so a check that reads
259
- * a table the browser no longer reads is served there and must be served here.
260
- */
261
- export function orphanedChecks(surface, ledger) {
262
- const orphaned = [];
263
- for (const [capability, checks] of ledger.capabilitiesAsked()) {
264
- if (surface.has(capability))
265
- continue;
266
- for (const check of checks) {
267
- orphaned.push(`${check}: exercises the ${capability} capability, which no code in this app calls any more. ` +
268
- `The deployed gateway serves only the capabilities the kit lane derives from the source, so it refuses this check as ` +
269
- `flow.check-failed: ${check}: exit status 1. Delete .harbour/checks/${check}, or restore the harbour.${capability}.* feature it was written for.`);
270
- }
271
- }
272
- return orphaned.sort();
273
- }
274
- /** The `flow` check's detail, in the pipeline's words, for both directions. */
275
- export function flowDetail(missing, orphaned) {
276
- const parts = [];
277
- if (missing.length)
278
- parts.push(`${missing.join("; ")}. Exercise the converted application operations with source-valid inputs and assertions; an unrelated passing check is insufficient.`);
279
- if (orphaned.length)
280
- parts.push(orphaned.join("; "));
281
- if (!parts.length)
282
- return "every operation the app performs and every capability it declares was exercised by a retained check, and no retained check exercises a capability the app no longer has";
283
- return `${parts.join(" ")} The pipeline refuses this deployment as kit.check-failed: flow.check-failed.`;
284
- }
285
- /**
286
- * What the pipeline would refuse, in its own words: an inventoried operation or
287
- * a declared capability with no successful observation while the checks ran.
288
- */
289
- export function missingFlowOperations(inventory, capabilities, ledger) {
290
- const missing = [];
291
- for (const [table, verbs] of inventory) {
292
- for (const verb of verbs) {
293
- if (!ledger.has(`data:${table}:${verb}`))
294
- missing.push(`${table} ${verb.toUpperCase()}: no successful converted application operation observed`);
295
- }
296
- }
297
- for (const capability of COVERED_CAPABILITIES) {
298
- if (capabilities.has(capability) && !ledger.has(capability))
299
- missing.push(`${capability}: retained checks did not exercise this declared component`);
300
- }
301
- return missing.sort();
302
- }
303
- const HOP_HEADERS = new Set(["connection", "keep-alive", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"]);
304
- /**
305
- * A loopback reverse proxy in front of the running `harbour dev` origin that
306
- * records what the retained checks exercise. The pipeline observes the same
307
- * boundary from inside its own gateway; observing it here needs no second
308
- * gateway and no change to the checks, which keep using HARBOUR_APP_URL.
309
- */
310
- export function createCoverageProxy(origin, ledger) {
311
- const target = new URL(origin);
312
- const port = Number(target.port || 80);
313
- const host = target.hostname;
314
- const upstreamOrigin = target.origin;
315
- const server = createServer((incoming, response) => {
316
- const path = (incoming.url ?? "/").split("?")[0];
317
- // Before the answer: the deployed gateway refuses an undeclared capability
318
- // at this path whatever the handler would have said, so what the check
319
- // asked for is the evidence the reverse gate needs.
320
- ledger.askedFor(path);
321
- const chunks = [];
322
- incoming.on("data", chunk => { if (chunks.length < 64)
323
- chunks.push(chunk); });
324
- const upstream = httpRequest({ host, port, method: incoming.method, path: incoming.url, headers: forwardable(incoming.headers, upstreamOrigin) }, upstreamResponse => {
325
- const body = [];
326
- upstreamResponse.on("data", chunk => { if (body.length < 64)
327
- body.push(chunk); });
328
- upstreamResponse.on("end", () => {
329
- const status = upstreamResponse.statusCode ?? 0;
330
- if (status >= 200 && status < 300)
331
- record(ledger, path, incoming.method ?? "GET", Buffer.concat(chunks).toString("utf8"), Buffer.concat(body).toString("utf8"));
332
- });
333
- response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
334
- upstreamResponse.pipe(response);
335
- });
336
- upstream.on("error", () => { if (!response.headersSent)
337
- response.writeHead(502); response.end(); });
338
- incoming.pipe(upstream);
339
- });
340
- // Realtime is a WebSocket: the proxy tunnels it untouched and reads the
341
- // server's frames as they pass. Only a delivered event counts — the gateway
342
- // fires OnRealtimeDelivery for postgres_changes, broadcast and presence, and
343
- // never for the `subscribed` acknowledgement, so a check that subscribes and
344
- // receives nothing does not clear the capability here either.
345
- server.on("upgrade", (incoming, socket, head) => {
346
- const path = (incoming.url ?? "/").split("?")[0];
347
- ledger.askedFor(path);
348
- const upstream = connect(port, host, () => {
349
- const headers = Object.entries({ ...forwardable(incoming.headers, upstreamOrigin), host: `${host}:${port}`, connection: "Upgrade", upgrade: "websocket" })
350
- .map(([key, value]) => `${key}: ${value}`);
351
- upstream.write(`${incoming.method} ${incoming.url} HTTP/1.1\r\n${headers.join("\r\n")}\r\n\r\n`);
352
- if (head?.length)
353
- upstream.write(head);
354
- if (path === "/_harbour/realtime") {
355
- const frames = websocketTextFrames();
356
- upstream.on("data", (chunk) => { for (const message of frames(chunk))
357
- if (isRealtimeDelivery(message))
358
- ledger.add("realtime"); });
359
- }
360
- upstream.pipe(socket);
361
- socket.pipe(upstream);
362
- });
363
- upstream.on("error", () => socket.destroy());
364
- socket.on("error", () => upstream.destroy());
365
- });
366
- return {
367
- listen: () => new Promise((resolve, reject) => {
368
- server.on("error", reject);
369
- server.listen(0, "127.0.0.1", () => resolve(`http://127.0.0.1:${server.address().port}`));
370
- }),
371
- close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(() => resolve()); })
372
- };
373
- }
374
- /**
375
- * `harbour dev` serves one origin and refuses anything that does not carry it,
376
- * so the proxy presents the upstream's own Host and Origin. Forwarding its own
377
- * address instead is what made the realtime upgrade fail before this: the
378
- * forwarder dropped the socket and the channel never subscribed.
379
- */
380
- function forwardable(headers, origin) {
381
- const result = {};
382
- for (const [key, value] of Object.entries(headers)) {
383
- if (HOP_HEADERS.has(key.toLowerCase()) || value === undefined)
384
- continue;
385
- result[key] = key.toLowerCase() === "origin" ? origin : Array.isArray(value) ? value.join(", ") : value;
386
- }
387
- return result;
388
- }
389
- /** One successful gateway request, classified exactly as the pipeline's coverage handler does. */
390
- export function record(ledger, path, method, requestBody, responseBody) {
391
- const data = /^\/_harbour\/data\/([A-Za-z0-9_]+)\/query$/.exec(path);
392
- if (data) {
393
- const operation = String(readJson(requestBody)?.operation ?? "");
394
- if (!operation)
395
- return;
396
- const envelope = readJson(responseBody)?.data;
397
- const rows = Array.isArray(envelope?.data) ? envelope.data.length : envelope?.count ?? 0;
398
- if (operation === "rpc") {
399
- ledger.add("data");
400
- ledger.add(`rpc:${data[1]}`);
401
- return;
402
- }
403
- ledger.observeData(data[1], operation, rows);
404
- return;
405
- }
406
- if (path.startsWith("/_harbour/actions/")) {
407
- ledger.add("actions");
408
- ledger.add(`action:${path.slice("/_harbour/actions/".length)}`);
409
- return;
410
- }
411
- if (path === "/_harbour/files/uploads/complete") {
412
- ledger.add("files");
413
- ledger.add("files:upload");
414
- return;
415
- }
416
- if (path === "/_harbour/files" && method === "GET") {
417
- ledger.add("files");
418
- ledger.add("files:list");
419
- return;
420
- }
421
- if (path === "/_harbour/files/remove") {
422
- ledger.add("files");
423
- ledger.add("files:remove");
424
- return;
425
- }
426
- if (path === "/_harbour/telemetry")
427
- ledger.add("telemetry");
428
- }
429
- /** The event types the gateway counts as a delivery (appgateway/realtime.go). */
430
- export function isRealtimeDelivery(message) {
431
- const type = readJson(message)?.type;
432
- return type === "postgres_changes" || type === "broadcast" || type === "presence";
433
- }
434
- /**
435
- * Reads complete text frames out of a server-to-client WebSocket byte stream.
436
- * Server frames are never masked, so the payload is the bytes themselves; the
437
- * head of the stream is the HTTP 101 response, which carries no frame.
438
- */
439
- export function websocketTextFrames() {
440
- let buffer = Buffer.alloc(0);
441
- let started = false;
442
- return chunk => {
443
- buffer = Buffer.concat([buffer, chunk]);
444
- if (!started) {
445
- const end = buffer.indexOf("\r\n\r\n");
446
- if (end < 0)
447
- return [];
448
- buffer = buffer.subarray(end + 4);
449
- started = true;
450
- }
451
- const messages = [];
452
- for (;;) {
453
- if (buffer.length < 2)
454
- break;
455
- const opcode = buffer[0] & 0x0f;
456
- const masked = (buffer[1] & 0x80) !== 0;
457
- let length = buffer[1] & 0x7f;
458
- let offset = 2;
459
- if (length === 126) {
460
- if (buffer.length < 4)
461
- break;
462
- length = buffer.readUInt16BE(2);
463
- offset = 4;
464
- }
465
- else if (length === 127) {
466
- if (buffer.length < 10)
467
- break;
468
- length = Number(buffer.readBigUInt64BE(2));
469
- offset = 10;
470
- }
471
- if (masked)
472
- offset += 4;
473
- if (buffer.length < offset + length)
474
- break;
475
- if (opcode === 1 && !masked)
476
- messages.push(buffer.subarray(offset, offset + length).toString("utf8"));
477
- buffer = buffer.subarray(offset + length);
478
- }
479
- return messages;
480
- };
481
- }
482
- function readJson(body) {
483
- try {
484
- const value = JSON.parse(body);
485
- return value && typeof value === "object" ? value : undefined;
486
- }
487
- catch {
488
- return undefined;
489
- }
490
- }