@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.
@@ -156,7 +156,8 @@ The person you are working with may not be a developer. They say what they want
156
156
 
157
157
  - "run it", "show me", "let me try it" → start \`harbour dev --app-root .\` in the background (it keeps running; the first start pulls images and takes a minute or two). Wait for the line \`Harbour dev is running: http://127.0.0.1:<port>\` and give them that link. Locally they are a fixture user; no company sign-in is needed.
158
158
  - "check it", "is it ok?", "is it ready?" → with dev running, \`harbour check --app-root . --json\`, then read \`.harbour/local/check-report.json\`. Failures in the app's code are yours to fix — fix, then check again until it is clean. Run the checks yourself after every change and before every ship, without being asked and without offering them as a choice.
159
- - "I need Slack / Gmail / the warehouse / company data" → a fresh \`harbour init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. Declare the connection and only the operations the app really calls in \`.harbour/integrations.json\` (the closed set is in the per-app block), then \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\`. READY means use it now. PENDING means IT has to approve it: say "IT has to approve this; the app works without it until then", and check later with \`harbour integrations status --app-root . --json\`. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
159
+ - "I need Slack / Gmail / the warehouse / company data" → a fresh \`harbour init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. Declare the connection and only the operations the app really calls in \`.harbour/integrations.json\` (the closed set is in the per-app block), then \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\`. READY means use it now. PENDING means IT has to approve it: say "IT has to approve this; the app works without it until then", and check later with \`harbour integrations status --app-root . --json\`. A refusal with \`RESOURCE_NOT_APPROVED\` means the channel, view or mailbox is not on the connection yet: IT adds it in the Harbour console under Controls & integrations → API integrations → the provider tile → Configure → Channels (a warehouse view: Controls & integrations → Databases → the source → Views for the environment), and then you run the same request command again. Say "IT has to add <name> to the Slack connection first; the app works without it until then" and nothing more. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
160
+ - "summarise", "draft", "explain", "AI" → one \`harbour.ai.chat\` call (through \`ai()\` in \`src/harbour.client.ts\`) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`harbour check\` writes its journey. A refusal with \`AI_NOT_ENABLED\` means IT has to enable an AI provider: say so in one line and keep the app working without it.
160
161
  - "ship it", "put it online", "let my team try it" → run \`harbour check --app-root . --json\` first and fix everything it finds, every time, unasked: the same gates run again in the cloud, where each failed attempt costs minutes instead of the seconds it costs here. Then each declared connection needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`), and \`harbour productionise --app-root . --wait --json\` gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they name, open after company sign-in. Keep \`operationRef\`; \`harbour setup --operation <ref> --json\` lists what is still missing (name, audience, secrets) and \`harbour profile\` / \`harbour audience\` / \`harbour secrets set\` fill it in.
161
162
  - "make it live for everyone", "go to production" → only after they have tried the preview: \`harbour promote --operation <ref> --json\` with the operation reference from productionise. Report the production link, or that an operator approval is pending.
162
163
  - "stop it" → \`harbour stop --app-root .\` (local data kept). \`harbour dev --reset --app-root .\` deletes local data — only when they explicitly ask to start over.
@@ -165,9 +166,10 @@ When a command refuses, the refusal names its own reason and its own fix: change
165
166
 
166
167
  ## Building the app
167
168
 
168
- - Identity, data and files go through \`@harbour/app-sdk\` only: \`harbour.identity.current()\`, \`harbour.data.from(table)\`, \`harbour.files.*\`. Company systems go only through \`harbour.integrations.execute\` with declared operations. Never open a database, bucket or company URL from browser code, and never add another backend, auth library or deployment config: the kit is the whole path.
169
+ - Identity, data and files go through \`@harbour/app-sdk\` only: \`harbour.identity.current()\`, \`harbour.data.from(table)\`, \`harbour.files.*\`. Company systems go only through \`harbour.integrations.execute\` with declared operations. AI goes through \`harbour.ai\`; never add an OpenAI/Anthropic key or SDK. Never open a database, bucket or company URL from browser code, and never add another backend, auth library or deployment config: the kit is the whole path.
169
170
  - Authentication is Harbour SSO: no login forms, no roles or ids trusted from the browser; row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\`. Every route needs a signed-in person; no public routes.
170
171
  - Schema changes are SQL files in \`migrations/\` with row-level security and GRANTs to \`harbour_app_gateway\`; \`harbour dev\` and \`harbour check\` apply them.
172
+ - Know the operation's input bounds before writing a call: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.list\` \`input.limit\` 1..15, \`warehouse.view.read\` \`input.limit\` 1..1000 (the connector's \`VIEW_READ_MAX_LIMIT\`); anything larger is refused with \`INPUT_INVALID\`, so page instead of asking for more.
171
173
  - A Slack message or an email (\`slack.message.post\`, \`gmail.message.send\`) is sent only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press (reused only to retry that press). Never send from an effect, a timer, a queue or during checks. Consent is a user action (\`harbour.integrations.connect\`); missing consent never falls back to another account.
172
174
  - No secrets, tokens, \`.env\` values or fetched company content in source. \`.harbour/local/\` is never committed; \`.harbour/integrations.json\` and \`.harbour/kit.lock.json\` are.
173
175
 
@@ -1,56 +1,18 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- /**
4
- * One row per application table, as one JSON document. The scoping is the
5
- * database gate's own (`relkind` r/p outside pg_catalog, information_schema and
6
- * the platform's harbour_runtime), so "an application table" means the same
7
- * thing to the generator as it does to the gate that refuses the app.
8
- */
9
- const CATALOG_SQL = String.raw `
10
- SELECT coalesce(json_agg(t ORDER BY t.name), '[]'::json)::text FROM (
11
- SELECT c.relname AS name,
12
- (SELECT coalesce(json_agg(json_build_object(
13
- 'name', a.attname,
14
- 'type', format_type(a.atttypid, a.atttypmod),
15
- 'base', y.typname,
16
- 'category', y.typcategory,
17
- 'notnull', a.attnotnull,
18
- 'default', pg_get_expr(d.adbin, d.adrelid),
19
- 'generated', a.attidentity <> '' OR a.attgenerated <> '' OR coalesce(pg_get_expr(d.adbin, d.adrelid), '') LIKE 'nextval(%',
20
- 'fk', EXISTS (SELECT 1 FROM pg_constraint f WHERE f.conrelid = a.attrelid AND f.contype = 'f' AND a.attnum = ANY (f.conkey)),
21
- 'checks', (SELECT coalesce(json_agg(pg_get_constraintdef(k.oid)), '[]'::json) FROM pg_constraint k WHERE k.conrelid = a.attrelid AND k.contype = 'c' AND a.attnum = ANY (k.conkey))
22
- ) ORDER BY a.attnum), '[]'::json)
23
- FROM pg_attribute a
24
- JOIN pg_type y ON y.oid = a.atttypid
25
- LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
26
- WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns,
27
- (SELECT coalesce(json_agg(k.attname ORDER BY u.ord), '[]'::json)
28
- FROM pg_constraint p
29
- CROSS JOIN LATERAL unnest(p.conkey) WITH ORDINALITY AS u(num, ord)
30
- JOIN pg_attribute k ON k.attrelid = p.conrelid AND k.attnum = u.num
31
- WHERE p.conrelid = c.oid AND p.contype = 'p') AS "primaryKey",
32
- (SELECT coalesce(json_agg(concat_ws(' ', pg_get_expr(pol.polqual, pol.polrelid), pg_get_expr(pol.polwithcheck, pol.polrelid))), '[]'::json)
33
- FROM pg_policy pol WHERE pol.polrelid = c.oid) AS policies
34
- FROM pg_class c
35
- JOIN pg_namespace n ON n.oid = c.relnamespace
36
- WHERE c.relkind IN ('r', 'p')
37
- AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')
38
- AND n.nspname NOT LIKE 'pg\_toast%' AND n.nspname NOT LIKE 'pg\_temp%'
39
- ) t;
40
- `;
41
3
  /** A policy that decides rows by the signed-in identity, and the column it decides them on. */
42
4
  const OWNER_SCOPE = /([A-Za-z_][A-Za-z0-9_]*)\s*(?:\)|::[a-z ]+)*\s*=\s*current_setting\('harbour\.user_(?:id|email)'/i;
43
5
  const IDENTITY = /current_setting\('harbour\.user_(?:id|email)'/i;
44
6
  /**
45
- * The app's tables as the database holds them, or `undefined` when the database
46
- * did not answer. `undefined` is not "no tables": nothing is generated from, or
7
+ * The app's tables as the database holds them, from the catalog the kit gate
8
+ * read after replaying the migrations, or `undefined` when that text is not a
9
+ * catalog. `undefined` is not "no tables": nothing is generated from, or
47
10
  * removed because of, a schema Harbour could not read — deleting an app's
48
11
  * retained checks on a failed query would be the worst possible reading of it.
49
12
  */
50
- export async function readAppSchema(source, root) {
51
- const result = await source.psql(CATALOG_SQL, ["-At"]);
52
- const text = result.stdout.trim();
53
- if (result.code !== 0 || !text.startsWith("["))
13
+ export async function readAppSchema(catalogText, root) {
14
+ const text = catalogText.trim();
15
+ if (!text.startsWith("["))
54
16
  return undefined;
55
17
  let catalog;
56
18
  try {
@@ -59,6 +21,8 @@ export async function readAppSchema(source, root) {
59
21
  catch {
60
22
  return undefined;
61
23
  }
24
+ if (!Array.isArray(catalog))
25
+ return undefined;
62
26
  const migrations = await migrationTexts(root);
63
27
  const schema = new Map();
64
28
  for (const table of catalog) {
@@ -1,123 +1,44 @@
1
1
  import { readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { readAppSchema } from "./app-schema.js";
4
- import { loadDatabaseGate } from "./database-gate.js";
5
- import { CoverageLedger, capabilityCallSurface, createCoverageProxy, declaredCapabilities, flowDetail, missingFlowOperations, orphanedChecks, sourceTableVerbs } from "./flow-coverage.js";
6
4
  import { describeRetainedChecks, syncRetainedChecks } from "./retained-checks.js";
7
5
  import { CliError } from "./output.js";
8
6
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
9
- import { LOCAL, LocalRuntime, identityEnvironment, runCommand, runningOrigin } from "./local-runtime.js";
7
+ import { LocalRuntime, allocatePorts, ensureSdk, freePort, readDevLock, runCommand, runningOrigin } from "./local-runtime.js";
10
8
  import { CLI_VERSION } from "./version.js";
11
- /** Runs the kit checks and writes `.harbour/local/check-report.json`; a source edit changes sourceDigest and so invalidates the previous report. */
9
+ /** The name the gate reports operation coverage under the pipeline's rule for it. */
10
+ export const FLOW_CHECK = "flow.check-failed";
11
+ /**
12
+ * Runs the kit checks and writes `.harbour/local/check-report.json`; a source
13
+ * edit changes sourceDigest and so invalidates the previous report. The app's
14
+ * own toolchain first (typecheck, build), then the kit gate — the pipeline's
15
+ * gate, run by the pinned gateway image in this app's local session — whose
16
+ * checks are reported verbatim. The retained journeys are generated inside
17
+ * that run, from the app's tables as the gate's replayed database holds
18
+ * them, before the gate enumerates and runs them.
19
+ */
12
20
  export async function runChecks(root, options) {
13
21
  const { output, run, bundle } = options;
14
22
  const checks = [];
15
- const record = (name, status, detail) => { checks.push({ name, status, ...(detail ? { detail } : {}) }); output(`${status === "pass" ? "ok " : status === "fail" ? "FAIL" : "skip"} ${name}${detail ? ` — ${indentLines(detail)}` : ""}`); };
16
- const declaration = await readDeclaration(root);
17
- record("declaration", declaration.errors.length ? "fail" : "pass", declaration.errors[0]);
18
- let lockDetail;
19
- const lock = await readKitLock(root).catch(error => { lockDetail = error instanceof Error ? error.message : String(error); return undefined; });
20
- record("kit_lock", lock ? "pass" : "fail", lockDetail ?? (lock ? undefined : ".harbour/kit.lock.json is missing (run `harbour init`)"));
21
- const typecheck = await run("npx", ["tsc", "--noEmit"], { cwd: root, quiet: true });
22
- record("typecheck", typecheck.code === 0 ? "pass" : "fail", typecheck.code === 0 ? undefined : lastLines(typecheck.stdout || typecheck.stderr));
23
- const build = await run("npm", ["run", "build"], { cwd: root, quiet: true });
24
- record("build", build.code === 0 ? "pass" : "fail", build.code === 0 ? undefined : lastLines(build.stderr || build.stdout));
25
- const throwaway = LocalRuntime.forCheck(root, run);
26
- let schema;
27
- try {
28
- await throwaway.writeCheckFiles(bundle);
29
- await throwaway.up();
30
- const applied = await throwaway.migrate();
31
- // The app's own tables, as the database holds them once those files have
32
- // replayed. This is the only window in which that database exists, and
33
- // reading it here is what lets `.harbour/checks/` be generated from the
34
- // schema the app will really run against rather than from the SQL text.
35
- schema = await readAppSchema(throwaway, root);
36
- record("migrations", "pass", `${applied.length} file(s) applied to a disposable database`);
37
- // The pipeline's database gate, on the database those files just built:
38
- // the same script the in-loop probe runs after its replay, so the defect
39
- // CodeBuild would name is named here, in seconds.
40
- if (!applied.length)
41
- record("database_gate", "not_run", "no migrations/*.sql to gate");
42
- else {
43
- const gate = await loadDatabaseGate(bundle, run);
44
- const violations = await throwaway.databaseGate(gate.sql);
45
- record("database_gate", violations.length ? "fail" : "pass", violations.length ? violations.join("\n") : `row-level security, policy verbs and grants verified for ${LOCAL.gatewayRole} (gate from ${gateSourceLabel(gate.source)})`);
46
- }
47
- }
48
- catch (error) {
49
- record("migrations", "fail", error instanceof Error ? error.message : String(error));
50
- record("database_gate", "not_run", "the migrations did not replay");
51
- }
52
- finally {
53
- await throwaway.down();
54
- }
55
- // `.harbour/checks/` is generated from what the app declares — the tables the
56
- // database above holds, the SDK namespaces its own code calls — before
57
- // anything reads it, so a check for a feature the app dropped cannot survive a
58
- // single `harbour check`, and a feature it added arrives with its journey.
59
- // This is not one of the gates: it writes files and reports what it did, and
60
- // `flow` below still decides. Files Harbour did not write, and generated files
61
- // the builder has edited, are never touched — and when the database did not
62
- // answer, nothing is touched at all.
63
- if (schema)
64
- for (const line of describeRetainedChecks(await syncRetainedChecks(root, schema)))
65
- output(line);
66
- else
67
- output(".harbour/checks/ was left as it is: the app's tables could not be read from the disposable database, so there was nothing to generate from.");
68
- // After generation, because `.harbour/checks/*.mjs` is part of the deployed
69
- // tree (kit.ts `sourceDigest`): the report has to pin the tree the journeys
70
- // below actually run against, or `harbour productionise` would refuse the very
71
- // tree this check just passed as CHECKS_STALE.
72
- const source = await sourceDigest(root);
23
+ const record = (check) => { checks.push(check); output(renderCheck(check)); };
73
24
  const previous = await readReport(root);
74
- if (previous && previous.sourceDigest !== source.digest)
25
+ if (previous && previous.sourceDigest !== (await sourceDigest(root)).digest)
75
26
  output("Source changed since the last report; the previous report is no longer valid.");
76
- const origin = await (options.localOrigin ?? (() => runningOrigin(root)))();
77
- const journeys = (await readdir(kitPaths(root).checks).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
78
- if (!journeys.length) {
79
- record("journeys", "not_run", "no checks under .harbour/checks/");
80
- record("flow", "not_run", "no checks under .harbour/checks/ to exercise the app's operations");
81
- }
82
- else if (!origin) {
83
- record("journeys", "not_run", "harbour dev is not running; start it to exercise the journey checks");
84
- record("flow", "not_run", "harbour dev is not running; the pipeline's operation-coverage gate needs the checks to run");
85
- }
86
- else {
87
- // The journeys run through a recording proxy so this reports what the
88
- // pipeline reports: which converted operations and declared capabilities
89
- // the checks actually exercised at the gateway. They get the pipeline's
90
- // three identity names from the running session (both signed users), so
91
- // the SDK attaches the first identity itself and a cross-user check can
92
- // act as the second — the same environment the retained-check runner
93
- // gives them in CodeBuild.
94
- const session = await new LocalRuntime(root, run).sessionEnv(1).catch(() => ({}));
95
- const ledger = new CoverageLedger();
96
- const proxy = createCoverageProxy(origin, ledger);
97
- const appUrl = await proxy.listen();
98
- try {
99
- for (const name of journeys) {
100
- // The journeys run one at a time, so naming the running check makes the
101
- // gateway traffic that follows attributable to it — which is what lets
102
- // an orphaned check be named as the file to delete.
103
- ledger.nowRunning(name);
104
- const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { HARBOUR_APP_URL: appUrl, HARBOUR_SDK_MODULE: join(root, "node_modules", "@harbour", "app-sdk", "dist", "index.js"), ...identityEnvironment(session) } });
105
- ledger.nowRunning(undefined);
106
- record(`journey:${name}`, result.code === 0 ? "pass" : "fail", result.code === 0 ? undefined : lastLines(result.stderr || result.stdout));
107
- }
108
- }
109
- finally {
110
- ledger.nowRunning(undefined);
111
- await proxy.close();
112
- }
113
- // Both directions of the pipeline's gate: an operation or capability the
114
- // app has with no check exercising it, and a check exercising a capability
115
- // the app does not have.
116
- const missing = missingFlowOperations(await sourceTableVerbs(root), await declaredCapabilities(root), ledger);
117
- const orphaned = orphanedChecks(await capabilityCallSurface(root), ledger);
118
- record("flow", missing.length || orphaned.length ? "fail" : "pass", flowDetail(missing, orphaned));
119
- }
27
+ const typecheck = await run("npx", ["tsc", "--noEmit"], { cwd: root, quiet: true });
28
+ record({ name: "typecheck", status: typecheck.code === 0 ? "pass" : "fail", ...(typecheck.code === 0 ? {} : { detail: lastLines(typecheck.stdout || typecheck.stderr) }) });
29
+ const build = await run("npm", ["run", "build"], { cwd: root, quiet: true });
30
+ record({ name: "build", status: build.code === 0 ? "pass" : "fail", ...(build.code === 0 ? {} : { detail: lastLines(build.stderr || build.stdout) }) });
31
+ // The journeys run here with the app's own node and SDK: the SDK the bundle
32
+ // pins, installed now if node_modules holds an older copy.
33
+ const sdk = await ensureSdk(root, bundle, options.env ?? process.env, run, output).catch(error => { output(error instanceof Error ? error.message : String(error)); return "missing"; });
34
+ if (sdk === "missing")
35
+ output("The kit SDK is not installed: set HARBOUR_KIT_SDK_TARBALL to the bundle's @harbour/app-sdk tarball (or use a bundle with sdk.url).");
36
+ const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}) });
37
+ for (const check of gate.checks)
38
+ record(check);
120
39
  let integrations = "not tested";
40
+ const lock = await readKitLock(root).catch(() => undefined);
41
+ const declaration = await readDeclaration(root);
121
42
  if (options.governance && lock?.appId && !declaration.errors.length) {
122
43
  integrations = await testIntegrationReads(lock.appId, options.governance, declaration.declaration, output);
123
44
  if (!integrations.length)
@@ -125,18 +46,175 @@ export async function runChecks(root, options) {
125
46
  }
126
47
  else if (options.governance)
127
48
  output("Integrations were not tested: the app is not linked yet or the declaration is invalid.");
49
+ // After the gate, because `.harbour/checks/*.mjs` is part of the deployed
50
+ // tree (kit.ts `sourceDigest`) and the gate may have regenerated it: the
51
+ // report has to pin the tree the journeys actually ran against, or
52
+ // `harbour productionise` would refuse the very tree this check just passed
53
+ // as CHECKS_STALE.
54
+ const source = await sourceDigest(root);
128
55
  const report = {
129
56
  schema: "harbour.check-report/1.0",
130
57
  createdAt: new Date().toISOString(),
131
58
  sourceDigest: source.digest,
132
59
  toolchain: { node: process.version, cliVersion: CLI_VERSION, bundle: { kitVersion: bundle.kitVersion, sdkTarballSha256: bundle.sdk.tarballSha256, appGateway: bundle.images.appGateway, sessionFixture: bundle.images.sessionFixture, briefFingerprint: bundle.brief.fingerprint } },
133
60
  checks,
61
+ gate,
134
62
  integrations,
63
+ ai: "not tested",
135
64
  passed: checks.every(check => check.status !== "fail") && (integrations === "not tested" || integrations.every(item => item.status !== "fail"))
136
65
  };
137
66
  await writeFile(kitPaths(root).report, `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined);
138
67
  return report;
139
68
  }
69
+ function renderCheck(check) {
70
+ const mark = check.status === "pass" ? "ok " : check.status === "fail" ? "FAIL" : "skip";
71
+ return `${mark} ${check.name}${check.detail ? ` — ${indentLines(check.detail)}` : ""}`;
72
+ }
73
+ /**
74
+ * Runs the pipeline's kit gate in this app's local session: the pinned
75
+ * gateway image's `gate` subcommand as a one-shot service of the Compose
76
+ * project (the app mounted read-only, a scratch database on the session
77
+ * PostgreSQL, the session bucket). The gate's control API drives two things
78
+ * from here: the `checks` phase, where the gate hands over the app's tables
79
+ * as its replayed database holds them and `.harbour/checks/` is regenerated
80
+ * from them (retained-checks.ts) and handed back as the set to run; and the
81
+ * journeys, run here — the app's own node and SDK — one at a time as the
82
+ * gate asks for them. The session services are started when `harbour dev`
83
+ * is not running and stopped again afterwards.
84
+ */
85
+ export async function runKitGate(root, options) {
86
+ const { run, bundle, output } = options;
87
+ const fetchImpl = options.fetch ?? fetch;
88
+ const runtime = new LocalRuntime(root, run);
89
+ const started = await ensureSession(root, runtime, bundle, output);
90
+ const port = await freePort();
91
+ const base = `http://127.0.0.1:${port}`;
92
+ const deadline = Date.now() + (options.timeoutMs ?? 10 * 60_000);
93
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
94
+ const request = async (path, init) => { try {
95
+ return await fetchImpl(`${base}${path}`, init);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ } };
100
+ const post = (path, body) => request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
101
+ // Everything after the session is up runs under one `finally`: a gate
102
+ // container that fails to start is exactly the case that used to leave a
103
+ // session this run had started still running.
104
+ let container;
105
+ try {
106
+ container = await runtime.startGate(bundle, port, base);
107
+ let announced = false;
108
+ while (Date.now() < deadline) {
109
+ const done = await request("/_gate/report");
110
+ if (done?.status === 200) {
111
+ const result = await done.json();
112
+ if (result.report)
113
+ return result.report;
114
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate could not run: ${result.error ?? "no report"}.`);
115
+ }
116
+ if (!(await runtime.gateRunning(container)))
117
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate stopped before reporting: ${await runtime.gateLogs(container)}`);
118
+ const session = await request("/_gate/session");
119
+ const state = session?.status === 200 ? await session.json() : undefined;
120
+ if (state?.status === "checks") {
121
+ const files = await regenerateRetainedChecks(root, state.catalog, state.capabilities, output);
122
+ const posted = await post("/_gate/checks", { files });
123
+ if (posted?.status !== 200)
124
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The kit gate did not accept the retained checks.");
125
+ continue;
126
+ }
127
+ if (state?.status === "journeys" && state.current && state.environment) {
128
+ if (!announced) {
129
+ announced = true;
130
+ output("Running the retained journeys against the gate's App Gateway.");
131
+ }
132
+ const name = state.current;
133
+ const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { ...state.environment, HARBOUR_SDK_MODULE: join(root, "node_modules", "@harbour", "app-sdk", "dist", "index.js"), NODE_NO_WARNINGS: "1" } });
134
+ const posted = await post(`/_gate/journeys/${encodeURIComponent(name)}`, { code: result.code, output: `${result.stdout}${result.stderr}`.slice(-4000) });
135
+ if (posted?.status !== 200)
136
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate did not accept the result of ${name}.`);
137
+ continue;
138
+ }
139
+ await sleep(options.pollMs ?? 500);
140
+ }
141
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The kit gate did not finish within its budget.");
142
+ }
143
+ finally {
144
+ if (container)
145
+ await runtime.stopGate(container);
146
+ if (started)
147
+ await runtime.down();
148
+ }
149
+ }
150
+ /**
151
+ * `.harbour/checks/` is generated from what the app declares — the tables the
152
+ * gate's replayed database holds, the capabilities the gate derived from the
153
+ * source (the set its coverage gate will demand evidence for) —
154
+ * before the gate reads it, so a check for a feature the app dropped cannot
155
+ * survive a single `harbour check`, and a feature it added arrives with its
156
+ * journey. This is not one of the gates: it writes files and reports what it
157
+ * did, and the gate's coverage check still decides. Files Harbour did not
158
+ * write, and generated files the builder has edited, are never touched — and
159
+ * when the catalog is not one, nothing is touched at all. Resolves to the
160
+ * complete set the gate is to run.
161
+ */
162
+ async function regenerateRetainedChecks(root, catalog, capabilities, output) {
163
+ const schema = await readAppSchema(typeof catalog === "string" ? catalog : JSON.stringify(catalog ?? null), root);
164
+ if (schema)
165
+ for (const line of describeRetainedChecks(await syncRetainedChecks(root, schema, Array.isArray(capabilities) ? capabilities : undefined)))
166
+ output(line);
167
+ else
168
+ output(".harbour/checks/ was left as it is: the app's tables could not be read from the gate's database, so there was nothing to generate from.");
169
+ const directory = kitPaths(root).checks;
170
+ const names = (await readdir(directory).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
171
+ const files = {};
172
+ for (const name of names)
173
+ files[name] = await readFile(join(directory, name), "utf8");
174
+ return files;
175
+ }
176
+ /** Brings the session services up when `harbour dev` is not running; resolves to whether they were started here (and so are taken down again after the gate, data kept). */
177
+ async function ensureSession(root, runtime, bundle, output) {
178
+ if (await runningOrigin(root))
179
+ return false;
180
+ const lock = await readDevLock(root);
181
+ const ports = lock?.ports ?? await allocatePorts();
182
+ await runtime.writeFiles(bundle, ports);
183
+ output("harbour dev is not running; starting the local Harbour services for the gate (stopped again afterwards).");
184
+ await runtime.pull(bundle);
185
+ await runtime.up();
186
+ await runtime.sessionEnv();
187
+ return true;
188
+ }
189
+ /**
190
+ * `productionise` pre-flight for a kit app: the same gate the pipeline runs,
191
+ * here, before any operation exists. A failed check is refused with the
192
+ * pipeline's own wording (kit.check-failed: <check>); a local runtime that
193
+ * cannot start (no Docker) is reported and skipped — the pipeline's gate
194
+ * still runs.
195
+ */
196
+ export async function preflightKitGate(root, bundle, output, run = runCommand, fetchImpl) {
197
+ const lock = await readKitLock(root).catch(() => undefined);
198
+ if (!lock)
199
+ return "skipped";
200
+ let report;
201
+ try {
202
+ report = await runKitGate(root, { run, bundle, output, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
203
+ }
204
+ catch (error) {
205
+ if (error instanceof CliError && ["LOCAL_RUNTIME_FAILED", "KIT_IMAGES_UNAVAILABLE"].includes(error.code)) {
206
+ output(`The kit gate was not run locally (${error.message}); the pipeline's gate will run in CodeBuild.`);
207
+ return "skipped";
208
+ }
209
+ throw error;
210
+ }
211
+ const failed = report.checks.filter(check => check.status === "fail");
212
+ if (failed.length)
213
+ throw new CliError("KIT_GATE_FAILED", `The kit gate refused this app (${failed.length} check(s) failed):\n${failed.map(check => ` - ${check.name}: ${check.detail ?? ""}`).join("\n")}\nFix the app and run \`harbour check\`; the pipeline would refuse this deployment as kit.check-failed: ${failed[0].name}.`);
214
+ output(`The kit gate passed locally (${report.checks.length} checks; ${report.inventory.journeys.length} journey(s), ${report.inventory.migrations} migration(s)).`);
215
+ return "passed";
216
+ }
217
+ // ---- Real integrations ----------------------------------------------------------------
140
218
  /** Explicit read operations only (READ_OPERATIONS) against READY development grants; sends never run. A dependent read (gmail.message.read) is exercised through its list: the newest message of the first thread, when there is one. */
141
219
  async function testIntegrationReads(appId, client, declaration, output) {
142
220
  const results = [];
@@ -180,44 +258,6 @@ async function testIntegrationReads(appId, client, declaration, output) {
180
258
  }
181
259
  return results;
182
260
  }
183
- /**
184
- * `productionise` pre-flight for a kit app with migrations: replays them on a
185
- * throwaway database and runs the pipeline's database gate before any
186
- * operation exists. A gate violation or a migration that will not replay is
187
- * refused here — the pipeline would refuse it minutes later in CodeBuild. A
188
- * local runtime that cannot start (no Docker) is reported and skipped: the
189
- * pipeline gate still runs.
190
- */
191
- export async function preflightDatabaseGate(root, bundle, output, run = runCommand) {
192
- const lock = await readKitLock(root).catch(() => undefined);
193
- const migrations = (await readdir(join(root, "migrations")).catch(() => [])).filter(name => name.endsWith(".sql"));
194
- if (!lock || !migrations.length)
195
- return "skipped";
196
- const throwaway = LocalRuntime.forCheck(root, run);
197
- try {
198
- await throwaway.writeCheckFiles(bundle);
199
- try {
200
- await throwaway.up();
201
- }
202
- catch (error) {
203
- output(`The migrations were not replayed locally (${error instanceof Error ? error.message : String(error)}); the pipeline's database gate will run in CodeBuild.`);
204
- return "skipped";
205
- }
206
- await throwaway.migrate();
207
- const gate = await loadDatabaseGate(bundle, run);
208
- const violations = await throwaway.databaseGate(gate.sql);
209
- if (violations.length)
210
- throw new CliError("DATABASE_GATE_FAILED", `The migrations fail the pipeline's database gate (${violations.length} violation(s)):\n${violations.map(line => ` - ${line}`).join("\n")}\nFix migrations/ and run \`harbour check\`; the pipeline would refuse this deployment as kit.check-failed: aurora.database-gate.`);
211
- output(`Migrations replayed and the database gate passed locally (${migrations.length} file(s), gate from ${gateSourceLabel(gate.source)}).`);
212
- return "passed";
213
- }
214
- finally {
215
- await throwaway.down();
216
- }
217
- }
218
- function gateSourceLabel(source) {
219
- return source === "gateway" ? "the pinned app gateway image" : source === "fixture" ? "the pinned session fixture" : "this CLI release";
220
- }
221
261
  export async function readReport(root) {
222
262
  try {
223
263
  return JSON.parse(await readFile(kitPaths(root).report, "utf8"));
@@ -68,12 +68,12 @@ const usage = [
68
68
  " harbour init --app-root <path> [--upgrade] create the starter or add the kit files; --upgrade re-pins the kit bundle (also runs agent-setup)",
69
69
  " harbour dev --app-root <path> [--reset] run the app locally on one loopback origin (--reset deletes this app's local data)",
70
70
  " harbour stop --app-root <path> stop this app's local services, keeping data",
71
- " harbour check --app-root <path> [--integrations] [--json] declaration, types, build, migrations + the pipeline's database gate (RLS, policy verbs, grants to harbour_app_gateway), journeys (+ authorised real reads)",
71
+ " harbour check --app-root <path> [--integrations] [--json] types, build, then the pipeline's kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, journeys, operation coverage (+ authorised real reads)",
72
72
  " harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>] [--operations a,b] [--expires-at <UTC>] [--json]",
73
73
  " harbour integrations status --app-root <path> [--json]",
74
74
  "Run `harbour connect <company-start-url>` once, then sign in when Harbour asks.",
75
75
  "productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
76
- "For kit apps, productionise first checks that every connection in .harbour/integrations.json has a preview grant and exits 2 (INTEGRATIONS_NOT_READY) with the requests to make, then replays migrations/ on a throwaway database and refuses (DATABASE_GATE_FAILED) what the pipeline's database gate would refuse.",
76
+ "For kit apps, productionise first checks that every connection in .harbour/integrations.json has a preview grant and exits 2 (INTEGRATIONS_NOT_READY) with the requests to make, then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse.",
77
77
  "secrets set reads the value from your terminal with echo off (or from stdin with --value-stdin); it is never printed or passed to any other program.",
78
78
  ""
79
79
  ].join("\n");
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createForwarder } from "./forwarder.js";
3
3
  import { readKitLock } from "./kit.js";
4
+ import { GovernanceClient, ensureLinkedApp } from "./integrations.js";
4
5
  import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, releaseDevLock, runCommand } from "./local-runtime.js";
5
6
  import { CliError } from "./output.js";
6
7
  /**
@@ -55,6 +56,13 @@ export async function startDev(root, options) {
55
56
  apiUrl: company?.apiUrl ?? "", tenantId: company?.tenantId ?? "",
56
57
  appId: () => lockAppId,
57
58
  accessToken: async () => company ? company.accessToken() : undefined,
59
+ linkApp: async () => {
60
+ const token = company ? await company.accessToken() : undefined;
61
+ if (!company || !token)
62
+ return undefined;
63
+ lockAppId = await ensureLinkedApp(root, new GovernanceClient(company.apiUrl, token, company.tenantId), company.tenantId, options.bundle);
64
+ return lockAppId;
65
+ },
58
66
  onAuthRequired: () => { if (!warnedAuth) {
59
67
  warnedAuth = true;
60
68
  options.output("Company integrations need a Harbour sign-in: run `harbour login` (in another terminal) and retry in the app.");
@@ -1,6 +1,8 @@
1
1
  import { createServer, request as httpRequest } from "node:http";
2
2
  import { connect } from "node:net";
3
- const INTEGRATION_ROUTES = { "/_harbour/integrations/execute": ["POST"], "/_harbour/integrations/connect": ["POST", "DELETE"] };
3
+ const INTEGRATION_ROUTES = { "/_harbour/integrations/execute": ["POST"], "/_harbour/integrations/connect": ["POST", "DELETE"], "/_harbour/ai/chat": ["POST"], "/_harbour/ai/embed": ["POST"] };
4
+ /** The governance development route family a local path forwards to, relative to /v1/development/apps/{appId}/. */
5
+ function developmentTarget(path) { return path.startsWith("/_harbour/ai/") ? `ai/${path.slice("/_harbour/ai/".length)}` : `integrations/${path.slice("/_harbour/integrations/".length)}`; }
4
6
  const HOP_HEADERS = new Set(["connection", "keep-alive", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"]);
5
7
  const IDENTITY_HEADER = "x-harbour-identity-context";
6
8
  /** The session identity for a gateway leg, unless the request already names one. */
@@ -12,7 +14,7 @@ export function createForwarder(options) {
12
14
  const host = `127.0.0.1:${options.port}`;
13
15
  const server = createServer((request, response) => {
14
16
  const path = (request.url ?? "/").split("?")[0];
15
- if (!sameOrigin(request, host, origin) || path.startsWith("/_harbour/integrations/")) {
17
+ if (!sameOrigin(request, host, origin) || path.startsWith("/_harbour/integrations/") || path.startsWith("/_harbour/ai/")) {
16
18
  void answerLocally(request, response, path, host, origin, options);
17
19
  return;
18
20
  }
@@ -51,7 +53,7 @@ async function answerLocally(request, response, path, host, origin, options) {
51
53
  }
52
54
  const methods = INTEGRATION_ROUTES[path];
53
55
  if (!methods) {
54
- reject(response, 404, "NOT_FOUND", "Unknown integration route.");
56
+ reject(response, 404, "NOT_FOUND", path.startsWith("/_harbour/ai/") ? "Unknown AI route." : "Unknown integration route.");
55
57
  return;
56
58
  }
57
59
  const method = request.method ?? "GET";
@@ -59,26 +61,28 @@ async function answerLocally(request, response, path, host, origin, options) {
59
61
  reject(response, 405, "VALIDATION_FAILED", "Method not allowed.");
60
62
  return;
61
63
  }
62
- const appId = options.appId();
63
- if (!appId) {
64
- reject(response, 409, "CONFLICT", "This app is not linked yet. Run `harbour integrations request <connection> --reason <text>` once.", "APP_NOT_LINKED");
65
- return;
66
- }
64
+ const ai = path.startsWith("/_harbour/ai/");
67
65
  const token = await options.accessToken().catch(() => undefined);
68
66
  if (!token) {
69
67
  options.onAuthRequired?.();
70
- reject(response, 401, "AUTH_REQUIRED", "Sign in to Harbour with `harbour login` to use company integrations locally.", "CLI_LOGIN_REQUIRED");
68
+ reject(response, 401, "AUTH_REQUIRED", ai ? "Sign in to Harbour with `harbour login` to use governed AI locally." : "Sign in to Harbour with `harbour login` to use company integrations locally.", "CLI_LOGIN_REQUIRED");
69
+ return;
70
+ }
71
+ // Governed AI needs the app's identity for its trace; a signed-in builder's app is linked on first use.
72
+ const appId = options.appId() ?? (ai && options.linkApp ? await options.linkApp().catch(() => undefined) : undefined);
73
+ if (!appId) {
74
+ reject(response, 409, "CONFLICT", ai ? "This app could not be linked with Harbour yet; check `harbour login` and try again." : "This app is not linked yet. Run `harbour integrations request <connection> --reason <text>` once.", "APP_NOT_LINKED");
71
75
  return;
72
76
  }
73
77
  if (body === undefined) {
74
78
  reject(response, 413, "VALIDATION_FAILED", "Request too large.", "REQUEST_TOO_LARGE");
75
79
  return;
76
80
  }
77
- const target = `${options.apiUrl.replace(/\/$/, "")}/v1/development/apps/${encodeURIComponent(appId)}/integrations/${path.slice("/_harbour/integrations/".length)}`;
81
+ const target = `${options.apiUrl.replace(/\/$/, "")}/v1/development/apps/${encodeURIComponent(appId)}/${developmentTarget(path)}`;
78
82
  const payload = path.endsWith("/connect") && method === "POST" ? withReturnUrl(body, `${origin}/_harbour/integrations/oauth/complete`) : body.length ? Buffer.from(body).toString("utf8") : undefined;
79
83
  let upstream;
80
84
  try {
81
- upstream = await (options.fetch ?? fetch)(target, { method, headers: { authorization: `Bearer ${token}`, "x-harbour-tenant": options.tenantId, "content-type": "application/json", accept: "application/json" }, ...(payload === undefined ? {} : { body: payload }), redirect: "error", signal: AbortSignal.timeout(12_000) });
85
+ upstream = await (options.fetch ?? fetch)(target, { method, headers: { authorization: `Bearer ${token}`, "x-harbour-tenant": options.tenantId, "content-type": "application/json", accept: "application/json" }, ...(payload === undefined ? {} : { body: payload }), redirect: "error", signal: AbortSignal.timeout(ai ? 45_000 : 12_000) });
82
86
  }
83
87
  catch {
84
88
  reject(response, 503, "UNAVAILABLE", "Harbour governance could not be reached.", "PROVIDER_UNAVAILABLE");
@@ -62,7 +62,7 @@ export async function requestIntegrations(root, client, tenantId, bundle, option
62
62
  throw new CliError("USAGE", "--expires-at must be an ISO-8601 UTC timestamp.");
63
63
  const scope = requestScope(declaration, options.connection, options.operations);
64
64
  const appId = await ensureLinkedApp(root, client, tenantId, bundle);
65
- const submitted = await Promise.all(scope.map(async (part) => ({ ...part, ...await client.request(appId, { connection: options.connection, environment, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), reason: options.reason }) })));
65
+ const submitted = await Promise.all(scope.map(async (part) => ({ ...part, ...await client.request(appId, { connection: options.connection, environment, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), reason: options.reason }).catch((error) => { throw unregisteredResourceGuidance(error, declaration, options.connection, environment, options.reason, root); }) })));
66
66
  const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
67
67
  const pollMs = options.pollMs ?? 3_000;
68
68
  let grants = [];
@@ -77,6 +77,31 @@ export async function requestIntegrations(root, client, tenantId, bundle, option
77
77
  }
78
78
  return { appId, connection: options.connection, environment, requests: submitted.map(item => ({ identityMode: item.identityMode, operations: item.operations, resources: item.resources, requestId: item.requestId, grantId: item.grantId, state: item.state, readiness: grants.find(grant => grant.grantId === item.grantId)?.readiness ?? (item.state === "READY" ? "ready" : item.state === "PENDING" ? "pending" : "denied") })) };
79
79
  }
80
+ const UNREGISTERED_RESOURCE = /^resource "([^"]+)" is not registered for \S+ on "([^"]+)"$/;
81
+ /**
82
+ * Governance refuses a request that names a channel, view or mailbox IT has not
83
+ * registered on the connection (404 RESOURCE_NOT_APPROVED) before the Review
84
+ * queue ever sees it, so a builder cannot self-serve it. Observed: an agent
85
+ * read the raw refusal and told the person to email an administrator, who gave
86
+ * up. The code is kept for `--json`; the sentence names the resource, the one
87
+ * console place where IT adds it, and the command to run again afterwards.
88
+ */
89
+ export function unregisteredResourceGuidance(error, declaration, connection, environment, reason, root) {
90
+ if (!(error instanceof CliError) || error.code !== "RESOURCE_NOT_APPROVED")
91
+ return error;
92
+ const match = UNREGISTERED_RESOURCE.exec(error.message);
93
+ if (!match)
94
+ return error;
95
+ const [, resource] = match;
96
+ const declared = declaration.connections[connection];
97
+ const operations = Object.keys(declared?.operations ?? {});
98
+ const provider = operations.some(name => name.startsWith("gmail.")) ? "Gmail" : operations.some(name => name.startsWith("slack.")) ? "Slack" : connection;
99
+ const place = declared?.kind === "database"
100
+ ? `Controls & integrations → Databases → ${connection} → Views for ${environment}`
101
+ : `Controls & integrations → API integrations → ${provider} → Configure → ${provider === "Gmail" ? "the mailbox" : "Channels"}`;
102
+ const command = `harbour integrations request ${connection} --reason "${reason}" --app-root ${root}${environment === "development" ? "" : ` --environment ${environment}`}`;
103
+ return new CliError(error.code, `IT has to add ${resource} to the ${connection} connection first (in the Harbour console: ${place}); the app works without it until then, and once it is added run \`${command}\` again.`, error.operationRef);
104
+ }
80
105
  /**
81
106
  * `productionise` pre-check: the preview deploy holds until every declared
82
107
  * connection has a GRANTED, unexpired preview grant per identity mode, so the