@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,8 +1,14 @@
1
1
  import { PUBLISHED_KIT_BUNDLE } from "./kit-bundle.manifest.js";
2
- /** The local stack's supporting services. Not part of the published bundle: they run only under `harbour dev`. */
3
- const LOCAL_SERVICE_IMAGES = {
2
+ /**
3
+ * The local stack's supporting services. Not part of the published bundle: they run only under `harbour dev`.
4
+ * MinIO is the exact image the pipeline's validation cell runs (data plane `transformbuild.validationMinIOImage`),
5
+ * from MinIO's own registry by digest: Docker Hub's `minio/minio` repository disappeared on 2026-09-11
6
+ * (404, "pull access denied"), and `harbour dev`/`check` pull before every start, so a tag there took the
7
+ * whole local kit down. A digest cannot be moved or removed from under an app.
8
+ */
9
+ export const LOCAL_SERVICE_IMAGES = {
4
10
  postgres: "postgres:16-alpine",
5
- minio: "minio/minio:RELEASE.2025-07-23T15-54-02Z"
11
+ minio: "quay.io/minio/minio@sha256:d249d1fb6966de4d8ad26c04754b545205ff15a62e4fd19ebd0f26fa5baacbc0"
6
12
  };
7
13
  /**
8
14
  * The bundle this CLI was built with: `manifest.json` of the
@@ -1,18 +1,18 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "harbour.kit-bundle/1.0",
3
- "kitVersion": "0.1.21",
3
+ "kitVersion": "0.1.28",
4
4
  "sdk": {
5
5
  "package": "@harbour/app-sdk",
6
- "version": "1.0.0",
7
- "tarballSha256": "561297368f61eb3a81ca64d8d360a353746d059547a2d01cfcc3ad6e3ff7dd84",
8
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:561297368f61eb3a81ca64d8d360a353746d059547a2d01cfcc3ad6e3ff7dd84"
6
+ "version": "1.1.0",
7
+ "tarballSha256": "998102545ba9b0eec3687d8ded2609a16dbb1d3254b006401155106bf8b80355",
8
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:998102545ba9b0eec3687d8ded2609a16dbb1d3254b006401155106bf8b80355"
9
9
  },
10
10
  "images": {
11
- "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:8a838e05694da93a84def78898807dea177b0cd7bab60697b365151be511f47c",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:6f7142d34ba3f8d7879a71658555c38eaf2f416bb9580461cb74b47a627d16e9"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:1effa54091b76c5668193618f1a781be7157fdd5ad49da831ded354b9e4162ac",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:47b305a2a3234c66702aebccfe53d3929d91556dde29b3c27128e2d18e3f1977"
13
13
  },
14
14
  "brief": {
15
- "fingerprint": "3b9d305bf6eedad1f48f704d3f29c33fcbc3e746bae395b41a3f71b3a809ab8f"
15
+ "fingerprint": "2efee82148238282a9aae255d071395dfdba69b68b398208cc0b8ea1d5b9a3b0"
16
16
  },
17
17
  "declarationSchema": "harbour.app-integrations/2.0"
18
18
  };
@@ -18,61 +18,12 @@ export const OPERATIONS = {
18
18
  export const READ_OPERATIONS = ["slack.channel.history", "gmail.thread.list", "warehouse.view.read"];
19
19
  /** Reads whose input needs an identifier from a prior read (a message id): listed as reads, exercised only through the list. */
20
20
  export const DEPENDENT_READ_OPERATIONS = ["gmail.message.read"];
21
- const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
22
- /** Validates the committed declaration; every error names the path that broke the closed rules. */
23
- export function validateDeclaration(value) {
24
- const errors = [];
25
- const record = (value ?? {});
26
- if (!value || typeof value !== "object" || Array.isArray(value))
27
- return { declaration: emptyDeclaration(), errors: ["declaration must be a JSON object"] };
28
- if (record.schema !== "harbour.app-integrations/2.0")
29
- errors.push("schema must be harbour.app-integrations/2.0");
30
- const connections = record.connections && typeof record.connections === "object" && !Array.isArray(record.connections) ? record.connections : undefined;
31
- if (!connections)
32
- errors.push("connections must be an object keyed by connection alias");
33
- for (const [alias, connection] of Object.entries(connections ?? {})) {
34
- if (!LOGICAL_NAME.test(alias))
35
- errors.push(`connections.${alias}: alias must be a lowercase logical name`);
36
- if (!connection || typeof connection !== "object") {
37
- errors.push(`connections.${alias}: must be an object`);
38
- continue;
39
- }
40
- if (connection.kind !== "saas" && connection.kind !== "database")
41
- errors.push(`connections.${alias}.kind: must be saas or database`);
42
- const operations = connection.operations && typeof connection.operations === "object" ? connection.operations : undefined;
43
- if (!operations || !Object.keys(operations).length) {
44
- errors.push(`connections.${alias}.operations: at least one operation is required`);
45
- continue;
46
- }
47
- for (const [name, operation] of Object.entries(operations)) {
48
- const path = `connections.${alias}.operations.${name}`;
49
- const rule = OPERATIONS[name];
50
- if (!rule) {
51
- errors.push(`${path}: unsupported operation (allowed: ${Object.keys(OPERATIONS).join(", ")})`);
52
- continue;
53
- }
54
- if (rule.kind !== connection.kind)
55
- errors.push(`${path}: ${name} belongs to a ${rule.kind} connection`);
56
- if (operation?.identity !== rule.identity)
57
- errors.push(`${path}.identity: ${name} is always ${rule.identity}`);
58
- const resources = operation?.resources;
59
- if (name === "warehouse.view.read") {
60
- if (!resources || Array.isArray(resources) || typeof resources !== "object" || !Object.keys(resources).length)
61
- errors.push(`${path}.resources: must map view names to {columns}`);
62
- else
63
- for (const [view, spec] of Object.entries(resources)) {
64
- if (!LOGICAL_NAME.test(view))
65
- errors.push(`${path}.resources.${view}: view name must be a logical name`);
66
- if (!spec || !Array.isArray(spec.columns) || !spec.columns.length || spec.columns.length > 32 || spec.columns.some(column => typeof column !== "string" || !LOGICAL_NAME.test(column)))
67
- errors.push(`${path}.resources.${view}.columns: 1..32 column names are required`);
68
- }
69
- }
70
- else if (!Array.isArray(resources) || !resources.length || resources.some(resource => typeof resource !== "string" || !LOGICAL_NAME.test(resource)))
71
- errors.push(`${path}.resources: must be a non-empty list of logical resource names (no IDs, tokens or URLs)`);
72
- }
73
- }
74
- return { declaration: record, errors };
75
- }
21
+ /**
22
+ * Reads the committed declaration as JSON. The closed rules (schema, connection
23
+ * kinds, the operation set, identity modes, logical resource names) are the
24
+ * kit gate's `declaration` check — the pipeline's own — so a defect is named
25
+ * once, in the same words, by `harbour check` and by CodeBuild.
26
+ */
76
27
  export function emptyDeclaration() { return { schema: "harbour.app-integrations/2.0", connections: {} }; }
77
28
  export function resourceNames(operation) { return Array.isArray(operation.resources) ? operation.resources : Object.keys(operation.resources); }
78
29
  export function requestResources(operation) {
@@ -86,12 +37,18 @@ export async function readDeclaration(root) {
86
37
  catch {
87
38
  return { declaration: emptyDeclaration(), errors: [".harbour/integrations.json is missing (run `harbour init`)"] };
88
39
  }
40
+ let parsed;
89
41
  try {
90
- return validateDeclaration(JSON.parse(raw));
42
+ parsed = JSON.parse(raw);
91
43
  }
92
44
  catch {
93
45
  return { declaration: emptyDeclaration(), errors: [".harbour/integrations.json is not valid JSON"] };
94
46
  }
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
48
+ return { declaration: emptyDeclaration(), errors: [".harbour/integrations.json must be a JSON object"] };
49
+ const record = parsed;
50
+ const connections = record.connections && typeof record.connections === "object" && !Array.isArray(record.connections) ? record.connections : {};
51
+ return { declaration: { schema: record.schema, connections }, errors: [] };
95
52
  }
96
53
  export function isKitLock(value) {
97
54
  const record = value;
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:net";
5
5
  import { join, relative } from "node:path";
6
- import { gateViolations } from "./database-gate.js";
6
+ import { LOCAL_SERVICE_IMAGES } from "./kit-bundle.js";
7
7
  import { kitPaths, projectName } from "./kit.js";
8
8
  import { CliError } from "./output.js";
9
9
  export const runCommand = (command, args, options = {}) => new Promise(resolve => {
@@ -54,13 +54,13 @@ export function composeFile(project, bundle, ports, stateDir) {
54
54
  `name: ${project}`,
55
55
  "services:",
56
56
  " postgres:",
57
- ` image: ${images.postgres ?? "postgres:16-alpine"}`,
57
+ ` image: ${images.postgres ?? LOCAL_SERVICE_IMAGES.postgres}`,
58
58
  ` environment: { POSTGRES_USER: ${LOCAL.dbUser}, POSTGRES_PASSWORD: ${LOCAL.dbPassword}, POSTGRES_DB: ${LOCAL.database} }`,
59
59
  ` ports: ["127.0.0.1:${ports.postgres}:5432"]`,
60
60
  " volumes: [postgres-data:/var/lib/postgresql/data]",
61
61
  ` healthcheck: { test: ["CMD-SHELL", "pg_isready -U ${LOCAL.dbUser} -d ${LOCAL.database}"], interval: 2s, timeout: 3s, retries: 30 }`,
62
62
  " minio:",
63
- ` image: ${images.minio ?? "minio/minio:RELEASE.2025-07-23T15-54-02Z"}`,
63
+ ` image: ${images.minio ?? LOCAL_SERVICE_IMAGES.minio}`,
64
64
  " command: server /data",
65
65
  ` environment: { MINIO_ROOT_USER: ${LOCAL.s3Key}, MINIO_ROOT_PASSWORD: ${LOCAL.s3Secret} }`,
66
66
  ` ports: ["127.0.0.1:${ports.minio}:9000"]`,
@@ -123,7 +123,7 @@ export function gatewayConfig(project, ports, stateDir) {
123
123
  async function migrationNames(root) {
124
124
  return (await readdir(join(root, "migrations")).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
125
125
  }
126
- function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@postgres:5432/${LOCAL.database}?sslmode=disable`; }
126
+ export function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@postgres:5432/${LOCAL.database}?sslmode=disable`; }
127
127
  function uploadKey(project) { return createHash("sha256").update(`upload-key:${project}`).digest("base64"); }
128
128
  // ---- Ports and lock ------------------------------------------------------------------
129
129
  export async function freePort() {
@@ -180,19 +180,9 @@ export class LocalRuntime {
180
180
  this.project = project;
181
181
  this.composePath = composePath;
182
182
  }
183
- /** Disposable postgres-only project for `harbour check` migrations: separate name, separate volume, torn down after. */
184
- static forCheck(root, run = runCommand) {
185
- return new LocalRuntime(root, run, projectName(root, "-check"), join(kitPaths(root).local, "check-compose.yml"));
186
- }
187
183
  compose(args, options = {}) {
188
184
  return this.run("docker", ["compose", "-p", this.project, "-f", this.composePath, ...args], { cwd: this.root, ...options });
189
185
  }
190
- async writeCheckFiles(bundle) {
191
- await mkdir(kitPaths(this.root).local, { recursive: true });
192
- await writeFile(this.composePath, [`name: ${this.project}`, "services:", " postgres:", ` image: ${bundle.images.postgres ?? "postgres:16-alpine"}`, ` environment: { POSTGRES_USER: ${LOCAL.dbUser}, POSTGRES_PASSWORD: ${LOCAL.dbPassword}, POSTGRES_DB: ${LOCAL.database} }`, " volumes: [postgres-data:/var/lib/postgresql/data]", ` healthcheck: { test: ["CMD-SHELL", "pg_isready -U ${LOCAL.dbUser} -d ${LOCAL.database}"], interval: 1s, timeout: 3s, retries: 30 }`, "volumes:", ` postgres-data: { name: ${this.project}-postgres }`, ""].join("\n"));
193
- }
194
- /** Removes this project's containers and volumes without the reset banner (check teardown). */
195
- async down() { await this.compose(["down", "-v", "--remove-orphans"], { quiet: true }); }
196
186
  async writeFiles(bundle, ports) {
197
187
  const paths = kitPaths(this.root);
198
188
  await mkdir(paths.state, { recursive: true });
@@ -229,6 +219,14 @@ export class LocalRuntime {
229
219
  const result = await this.compose(["stop"], { quiet: true });
230
220
  return { stopped: result.code === 0 };
231
221
  }
222
+ /**
223
+ * Removes this project's containers and network, keeping its named volumes
224
+ * (the app's local data). What the kit gate uses for a session it started
225
+ * itself: `stop` would leave one Docker network per checked app behind,
226
+ * and Docker's address pools run out after a dozen or so ("all predefined
227
+ * address pools have been fully subnetted").
228
+ */
229
+ async down() { await this.compose(["down", "--remove-orphans"], { quiet: true }); }
232
230
  /** Removes this project's containers and named volumes only. */
233
231
  async reset(output) {
234
232
  output(`Deleting local data for this app only: compose project ${this.project}, volumes ${this.project}-postgres and ${this.project}-minio.`);
@@ -252,18 +250,40 @@ export class LocalRuntime {
252
250
  }
253
251
  return names;
254
252
  }
253
+ psql(sql) {
254
+ return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
255
+ }
255
256
  /**
256
- * Runs the kit database gate (one psql script, see database-gate.ts) against
257
- * this project's database after `migrate()`. Resolves to the violation lines,
258
- * empty when the schema satisfies the App Gateway role's contract.
257
+ * Starts the kit gate: the pinned gateway image's `gate` subcommand as a
258
+ * one-shot service of this project (`docker compose run`), the app mounted
259
+ * read-only at /workspace, its control listener published on `port`, the
260
+ * session's PostgreSQL and bucket named for its scratch database and files.
261
+ * Resolves to the container id; the caller drives the control API
262
+ * (check.ts) and stops it with stopGate.
259
263
  */
260
- async databaseGate(sql) {
261
- const result = await this.psql(sql);
262
- return result.code === 0 ? [] : gateViolations(result.stderr);
264
+ async startGate(bundle, port, publicUrl) {
265
+ const result = await this.compose([
266
+ "run", "--detach", "--no-deps", "--rm", "--publish", `127.0.0.1:${port}:${port}`,
267
+ "--volume", `${this.root}:/workspace:ro`,
268
+ "--env", "HARBOUR_GATE_WORKSPACE=/workspace", "--env", `HARBOUR_GATE_LISTEN=:${port}`, "--env", `HARBOUR_GATE_PUBLIC_URL=${publicUrl}`,
269
+ "--env", `HARBOUR_GATE_DATABASE_URL=${internalDatabaseUrl()}`, "--env", `HARBOUR_GATE_S3_BUCKET=${LOCAL.bucket}`,
270
+ "gateway", "gate"
271
+ ], { quiet: true });
272
+ const id = result.code === 0 ? result.stdout.trim().split("\n").at(-1)?.trim() ?? "" : "";
273
+ if (!id)
274
+ throw new CliError("LOCAL_RUNTIME_FAILED", `Docker could not start the kit gate (${bundle.images.appGateway}): ${result.stderr.trim().split("\n").at(-1) ?? "docker error"}.`);
275
+ return id;
276
+ }
277
+ async gateRunning(container) {
278
+ const result = await this.run("docker", ["inspect", "--format", "{{.State.Running}}", container], { quiet: true });
279
+ return result.code === 0 && result.stdout.trim() === "true";
280
+ }
281
+ async gateLogs(container) {
282
+ const result = await this.run("docker", ["logs", "--tail", "20", container], { quiet: true });
283
+ return `${result.stdout}${result.stderr}`.trim().split("\n").slice(-5).join(" | ").slice(0, 800);
263
284
  }
264
- /** One psql run inside this project's postgres container; `flags` is how a caller asks for machine-readable output (`-At`). */
265
- psql(sql, flags = []) {
266
- return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", ...flags, "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
285
+ async stopGate(container) {
286
+ await this.run("docker", ["rm", "-f", container], { quiet: true });
267
287
  }
268
288
  /**
269
289
  * Applies the platform's realtime outbox migration the gateway generated
@@ -11,7 +11,7 @@ import { CLI_VERSION } from "./version.js";
11
11
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
12
  import { assertPreviewIntegrationsReady } from "./integrations.js";
13
13
  import { kitPaths, recordKitAppId, sourceDigest } from "./kit.js";
14
- import { preflightDatabaseGate, readReport } from "./check.js";
14
+ import { FLOW_CHECK, preflightKitGate, readReport } from "./check.js";
15
15
  export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
16
16
  const root = resolve(rootArg);
17
17
  output(`Harbour is checking ${basename(root)}.`);
@@ -35,12 +35,12 @@ export async function productionise(rootArg, client, output, tenantId, includePa
35
35
  // A declared connection without its preview grant would only park the
36
36
  // deployment after the save; refuse here, before any operation exists.
37
37
  const kitAppId = options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId;
38
- // A kit migration set the pipeline's database gate would refuse (a table
39
- // without RLS, a policy verb never granted, a grant to a role the platform
40
- // never creates) is refused here, before the save, with the same wording.
41
- const gate = options.databaseGate === false ? undefined : options.databaseGate ?? (options.integrations ? { bundle: options.integrations.bundle } : undefined);
38
+ // A kit app the pipeline's gate would refuse (a table without RLS, an
39
+ // operation no journey exercises, a cross-user leak) is refused here,
40
+ // before the save, by the same gate with the same wording.
41
+ const gate = options.kitGate === false ? undefined : options.kitGate ?? (options.integrations ? { bundle: options.integrations.bundle } : undefined);
42
42
  if (gate)
43
- await preflightDatabaseGate(root, gate.bundle, output, gate.run);
43
+ await preflightKitGate(root, gate.bundle, output, gate.run);
44
44
  try {
45
45
  await client.initialize();
46
46
  }
@@ -140,18 +140,17 @@ export async function productionise(rootArg, client, output, tenantId, includePa
140
140
  * against the running app, so both commands are named: the app has to be up
141
141
  * before the checks that matter can run at all.
142
142
  */
143
- const RUN_THE_CHECKS = "Start the app with `harbour dev --app-root .`, then run `harbour check --app-root .` in a second terminal. Fix whatever it reports, then run this again.";
144
- const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .harbour/local/check-report.json), then run `harbour check --app-root .` again with `harbour dev --app-root .` running.";
143
+ const RUN_THE_CHECKS = "Run `harbour check --app-root .` (it starts the local Harbour services itself when `harbour dev` is not running). Fix whatever it reports, then run this again.";
144
+ const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .harbour/local/check-report.json), then run `harbour check --app-root .` again.";
145
145
  /**
146
- * The checks the deployment pipeline replays in the cloud, and the only ones
147
- * `harbour check` cannot run without `harbour dev` up. They are recorded as
148
- * `not_run` not as a pass when the app was not running, which is exactly
149
- * the state that used to reach the pipeline and fail there. `journeys` is the
150
- * placeholder name the report uses when none of them ran; each journey that did
151
- * run is recorded under its own `journey:<file>` name.
146
+ * The checks the deployment pipeline replays in the cloud: each retained
147
+ * journey under its own `journey:<file>` name and the operation-coverage gate
148
+ * under the pipeline's rule for it. The kit gate records them as `not_run`
149
+ * not as a pass when an earlier check stopped the run, which is exactly the
150
+ * state that used to reach the pipeline and fail there.
152
151
  */
153
152
  function isReplayedGate(name) {
154
- return name === "flow" || name === "journeys" || name.startsWith("journey:");
153
+ return name === FLOW_CHECK || name.startsWith("journey:");
155
154
  }
156
155
  /** The retained checks this app keeps, enumerated exactly as `harbour check` and the pipeline enumerate them. */
157
156
  async function retainedChecks(root) {
@@ -193,18 +192,18 @@ async function assertChecksPassedForTree(root, output) {
193
192
  if (report.sourceDigest !== tree.digest)
194
193
  throw new CliError("CHECKS_STALE", "The app's code has changed since its checks last ran, so that result no longer describes what would be deployed. Harbour does not deploy an app whose own checks have not passed for this exact code.", undefined, RUN_THE_CHECKS);
195
194
  const checks = Array.isArray(report.checks) ? report.checks : [];
195
+ const failed = checks.filter(check => check.status === "fail").map(check => check.name);
196
+ if (failed.length || report.passed !== true)
197
+ throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${nameList(failed)})` : ""}. Harbour does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS);
196
198
  // "Skipped" is not "passed". A report whose journeys and coverage gate never
197
199
  // ran says nothing about whether the app works, and accepting it would hand
198
200
  // the discovery straight back to the pipeline.
199
- const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass" && check.status !== "fail").map(check => check.name);
200
- const flowRan = checks.some(check => check.name === "flow" && (check.status === "pass" || check.status === "fail"));
201
+ const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass").map(check => check.name);
202
+ const flowRan = checks.some(check => check.name === FLOW_CHECK && check.status === "pass");
201
203
  if (notRun.length || !flowRan) {
202
- const names = notRun.length ? notRun : ["flow"];
203
- throw new CliError("CHECKS_NOT_RUN", `The last check run skipped ${nameList(names)} instead of passing ${names.length === 1 ? "it" : "them"}: ${names.length === 1 ? "that check needs" : "those checks need"} the app running. The deployment pipeline runs the same ones in the cloud, so skipping them here only moves the failure.`, undefined, RUN_THE_CHECKS);
204
+ const names = notRun.length ? notRun : [FLOW_CHECK];
205
+ throw new CliError("CHECKS_NOT_RUN", `The last check run skipped ${nameList(names)} instead of passing ${names.length === 1 ? "it" : "them"}. The deployment pipeline runs the same ones in the cloud, so skipping them here only moves the failure.`, undefined, RUN_THE_CHECKS);
204
206
  }
205
- const failed = checks.filter(check => check.status === "fail").map(check => check.name);
206
- if (failed.length || report.passed !== true)
207
- throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${nameList(failed)})` : ""}. Harbour does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS);
208
207
  output(`The app's own checks passed for this exact code (${retained.length} retained check${plural}, last run ${report.createdAt}).`);
209
208
  }
210
209
  async function waitForIntake(client, operationRef) {
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { allowedLiterals, numericMinimum, textLengthBounds } from "./app-schema.js";
5
- import { COVERED_CAPABILITIES, capabilitiesCalled, capabilityCallSurface, capabilityUsage, sourceTableUsage } from "./flow-coverage.js";
5
+ import { COVERED_CAPABILITIES, capabilitiesCalled, capabilityCallSurface, capabilityUsage, sourceTableUsage } from "./source-inventory.js";
6
6
  import { kitPaths } from "./kit.js";
7
7
  /**
8
8
  * Retained checks, generated from what the app declares.
@@ -43,12 +43,21 @@ import { kitPaths } from "./kit.js";
43
43
  /** First line of every file Harbour wrote, carrying the digest of the body below it. */
44
44
  const GENERATED_PREFIX = "// harbour:generated sha256:";
45
45
  /** Capability names that own a fixed file name, so a table of the same name does not shadow one. */
46
- const RESERVED_NAMES = new Set(["files", "telemetry", "actions", "realtime"]);
47
- /** The data verbs a generated journey performs, in the order it performs them — the set `flow-coverage.ts` inventories. */
46
+ const RESERVED_NAMES = new Set(["files", "telemetry", "actions", "realtime", "ai"]);
47
+ /** The data verbs a generated journey performs, in the order it performs them — the set `source-inventory.ts` inventories. */
48
48
  const VERB_ORDER = ["insert", "upsert", "select", "update", "delete"];
49
- /** Regenerates `.harbour/checks/` in place from the app's code and its own database, and reports every decision. */
50
- export async function syncRetainedChecks(root, schema) {
51
- const plan = await planRetainedChecks(root, schema);
49
+ /**
50
+ * Regenerates `.harbour/checks/` in place from the app's code and its own
51
+ * database, and reports every decision. `declared`, when given, is the
52
+ * capability set the kit gate derived from the source — the set its coverage
53
+ * gate will demand evidence for — and it is the authority: the kit lane traces
54
+ * a namespace wrapper (`export function ai() { return harbour.ai }`) to the
55
+ * capability it wraps whether or not anything calls it, which a call-site
56
+ * reading of the source never sees. Without it (tests, no gate) the source
57
+ * reading decides.
58
+ */
59
+ export async function syncRetainedChecks(root, schema, declared) {
60
+ const plan = await planRetainedChecks(root, schema, declared);
52
61
  const created = [];
53
62
  const updated = [];
54
63
  for (const check of plan.write) {
@@ -75,14 +84,26 @@ export function describeRetainedChecks(result) {
75
84
  ];
76
85
  }
77
86
  /** Reads the app and decides what `.harbour/checks/` should hold, writing nothing. */
78
- export async function planRetainedChecks(root, schema) {
79
- const [tables, capabilities, surface, existing] = await Promise.all([sourceTableUsage(root), capabilityUsage(root), capabilityCallSurface(root), readChecks(root)]);
87
+ export async function planRetainedChecks(root, schema, declared) {
88
+ const [tables, used, surface, existing] = await Promise.all([sourceTableUsage(root), capabilityUsage(root), capabilityCallSurface(root), readChecks(root)]);
89
+ // The gate's derivation, when it spoke, names the capabilities; the source
90
+ // reading keeps the file attribution where it has one.
91
+ const capabilities = declared ? new Map(declared.map(capability => [capability, used.get(capability) ?? ["the app's source, as the kit lane derives it"]])) : used;
92
+ const gateDeclared = (capability) => declared !== undefined && declared.includes(capability);
80
93
  const adopted = existing.filter(check => !check.pristine);
81
94
  const plan = { write: [], remove: [], adopted: adopted.map(check => check.path), blocked: [], orphaned: [] };
82
95
  const planned = new Set();
83
- /** A planned check, named by the one capability it exercises: outside the `flow` gate's own surface, Harbour writes nothing. */
96
+ /**
97
+ * A planned check, named by the one capability it exercises: outside the
98
+ * `flow` gate's own surface, Harbour writes nothing. And never over a file
99
+ * the builder owns: an adopted check under the name Harbour would use is
100
+ * theirs, however little of the table it still exercises — the gate says
101
+ * what it misses, and only its author decides.
102
+ */
84
103
  const add = (capability, name, content) => {
85
- if (!surface.has(capability))
104
+ if (!surface.has(capability) && !gateDeclared(capability))
105
+ return;
106
+ if (adopted.some(check => check.name === name))
86
107
  return;
87
108
  planned.add(name);
88
109
  plan.write.push({ path: `.harbour/checks/${name}`, content });
@@ -94,7 +115,7 @@ export async function planRetainedChecks(root, schema) {
94
115
  // second person is the cross-user coverage. The two are decided separately: editing a
95
116
  // journey must not take its table's cross-user denial down with it.
96
117
  const journeyCovered = verbs.length > 0 && verbs.every(verb => adopted.some(check => coversTableVerb(check.text, table, verb)));
97
- const crossUserCovered = adopted.some(check => check.text.includes("HARBOUR_IDENTITY_CONTEXT_SECOND_USER") && coversTableVerb(check.text, table, "select"));
118
+ const crossUserCovered = adopted.some(check => actsAsSecondUser(check.text) && coversTableVerb(check.text, table, "select"));
98
119
  if (journeyCovered && (crossUserCovered || !declared?.ownerScoped))
99
120
  continue;
100
121
  const reason = (detail) => plan.blocked.push(`\`${table}\` (${verbs.join(", ")}), which ${usage.files.join(", ")} queries: ${detail}`);
@@ -118,9 +139,9 @@ export async function planRetainedChecks(root, schema) {
118
139
  continue;
119
140
  if (adopted.some(check => capabilitiesCalled(check.text).has(capability)))
120
141
  continue;
121
- const generic = capability === "files" ? filesJourney(files) : capability === "telemetry" ? telemetryJourney(files) : undefined;
142
+ const generic = capability === "files" ? filesJourney(files) : capability === "telemetry" ? telemetryJourney(files) : capability === "ai" ? aiJourney(files) : undefined;
122
143
  if (!generic) {
123
- plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Harbour writes journeys for data, files and telemetry only — write \`.harbour/checks/${capability}-journey.mjs\` yourself`);
144
+ plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Harbour writes journeys for data, files, telemetry and ai only — write \`.harbour/checks/${capability}-journey.mjs\` yourself`);
124
145
  continue;
125
146
  }
126
147
  add(capability, `${capability}-journey.mjs`, generic);
@@ -163,8 +184,8 @@ function stewardship(sources, uuid = false) {
163
184
  }
164
185
  /** The single signed-in identity every journey but the cross-user one runs as. */
165
186
  const SIGNED_IN = [
166
- "// The signed-in identity is attached by the SDK itself (HARBOUR_IDENTITY_CONTEXT",
167
- "// from `harbour check` and from the pipeline's runner): one injector per request.",
187
+ "// The signed-in identity is attached by the SDK itself, from the kit gate's",
188
+ "// runner (the same under `harbour check` and in the pipeline): one injector per request.",
168
189
  "const harbour = sdk.createClient({ baseUrl: appUrl });",
169
190
  "",
170
191
  "const user = await harbour.identity.current();",
@@ -216,14 +237,11 @@ function crossUserCheck(table, schema, row, sources) {
216
237
  `// with current_setting('harbour.user_id')${schema.ownerColumn ? ` on \`${schema.ownerColumn}\`` : ""}. Running it here means user isolation can`,
217
238
  "// no longer be green under `harbour check` and red in CodeBuild.",
218
239
  ...stewardship([schema.file, ...sources], row.uuid),
219
- "const secondUser = process.env.HARBOUR_IDENTITY_CONTEXT_SECOND_USER;",
220
- "assert.ok(secondUser, \"HARBOUR_IDENTITY_CONTEXT_SECOND_USER is required (set by harbour check and by the pipeline)\");",
221
- "// The first person: the SDK attaches HARBOUR_IDENTITY_CONTEXT itself.",
240
+ "// Both people come from the SDK itself: the kit gate's runner signs two",
241
+ "// identities and the SDK attaches the first to every request by default and",
242
+ "// the second when asked. A check never reads a token name.",
222
243
  "const owner = sdk.createClient({ baseUrl: appUrl });",
223
- "// The second person: the same SDK, with that person's signed identity replacing the first on every request.",
224
- "const header = (process.env.HARBOUR_IDENTITY_CONTEXT_HEADER ?? \"X-Harbour-Identity-Context\").toLowerCase();",
225
- "const asSecondUser = (input, init = {}) => fetch(input, { ...init, headers: { ...(init.headers ?? {}), [header]: secondUser } });",
226
- "const other = sdk.createClient({ baseUrl: appUrl, fetch: asSecondUser });",
244
+ "const other = sdk.createClient({ baseUrl: appUrl, identity: \"second-user\" });",
227
245
  "",
228
246
  "const me = await owner.identity.current();",
229
247
  "const them = await other.identity.current();",
@@ -289,6 +307,24 @@ function telemetryJourney(sources) {
289
307
  ];
290
308
  return sign(`${body.join("\n")}\n`);
291
309
  }
310
+ function aiJourney(sources) {
311
+ const body = [
312
+ "// Retained journey for the app's governed AI: one chat completion reaches the gateway",
313
+ `// through the running app (HARBOUR_APP_URL) — the capability ${sources.join(", ")} uses`,
314
+ "// through `harbour.ai.chat`. Locally this is a real governed call through the Harbour",
315
+ "// development route (your `harbour login`; IT must have enabled an AI provider). In the",
316
+ "// deployment pipeline it is answered by a canned completion and reported as not tested.",
317
+ "// The pipeline refuses an app whose checks never exercise a capability its code uses.",
318
+ ...stewardship(sources),
319
+ ...SIGNED_IN,
320
+ "",
321
+ "const reply = await harbour.ai.chat({ messages: [{ role: \"user\", content: \"Reply with one word: ready\" }], maxTokens: 16 });",
322
+ "assert.equal(typeof reply.content, \"string\", \"ai.chat must answer with content\");",
323
+ "assert.ok(reply.content.length > 0, \"the governed completion is not empty\");",
324
+ "assert.equal(typeof reply.model, \"string\", \"the answer names the governed model alias\");"
325
+ ];
326
+ return sign(`${body.join("\n")}\n`);
327
+ }
292
328
  // ---- Reading the app ------------------------------------------------------------
293
329
  /** How a generated journey writes one row: the values to insert, the column it finds the row by, and the column it changes. */
294
330
  function rowShape(schema, needsUpdate) {
@@ -381,6 +417,8 @@ function staleSurface(text, tables, capabilities) {
381
417
  }
382
418
  /** `insert, update and delete`, for a generated check's opening line. */
383
419
  const sentence = (verbs) => verbs.length > 1 ? `${verbs.slice(0, -1).join(", ")} and ${verbs.at(-1)}` : verbs.join("");
420
+ /** A check that acts as the runner's second person, in the SDK's form or the older header form. */
421
+ const actsAsSecondUser = (text) => /identity:\s*["'`]second-user["'`]/.test(text) || text.includes("HARBOUR_IDENTITY_CONTEXT_SECOND_USER");
384
422
  const coversTableVerb = (text, table, verb) => new RegExp(`\\.from\\(\\s*["'\`]${table}["'\`]\\s*\\)`).test(text) && new RegExp(`\\.${verb}\\s*\\(`).test(text);
385
423
  /** `notes` gets `notes-journey.mjs`; a table sharing a capability's name does not shadow it. */
386
424
  const fileName = (table, kind) => `${table}${RESERVED_NAMES.has(table) ? "-table" : ""}-${kind}.mjs`;