@fourier-labs/harbour 0.1.20 → 0.1.21

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.
@@ -4,7 +4,7 @@ import { loadDatabaseGate } from "./database-gate.js";
4
4
  import { CoverageLedger, createCoverageProxy, declaredCapabilities, missingFlowOperations, sourceTableVerbs } from "./flow-coverage.js";
5
5
  import { CliError } from "./output.js";
6
6
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
7
- import { LOCAL, LocalRuntime, runCommand, runningOrigin } from "./local-runtime.js";
7
+ import { LOCAL, LocalRuntime, identityEnvironment, runCommand, runningOrigin } from "./local-runtime.js";
8
8
  import { CLI_VERSION } from "./version.js";
9
9
  /** Runs the kit checks and writes `.harbour/local/check-report.json`; a source edit changes sourceDigest and so invalidates the previous report. */
10
10
  export async function runChecks(root, options) {
@@ -38,7 +38,7 @@ export async function runChecks(root, options) {
38
38
  else {
39
39
  const gate = await loadDatabaseGate(bundle, run);
40
40
  const violations = await throwaway.databaseGate(gate.sql);
41
- 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 ${gate.source === "fixture" ? "the pinned session fixture" : "this CLI release"})`);
41
+ 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)})`);
42
42
  }
43
43
  }
44
44
  catch (error) {
@@ -61,13 +61,18 @@ export async function runChecks(root, options) {
61
61
  else {
62
62
  // The journeys run through a recording proxy so this reports what the
63
63
  // pipeline reports: which converted operations and declared capabilities
64
- // the checks actually exercised at the gateway.
64
+ // the checks actually exercised at the gateway. They get the pipeline's
65
+ // three identity names from the running session (both signed users), so
66
+ // the SDK attaches the first identity itself and a cross-user check can
67
+ // act as the second — the same environment the retained-check runner
68
+ // gives them in CodeBuild.
69
+ const session = await new LocalRuntime(root, run).sessionEnv(1).catch(() => ({}));
65
70
  const ledger = new CoverageLedger();
66
71
  const proxy = createCoverageProxy(origin, ledger);
67
72
  const appUrl = await proxy.listen();
68
73
  try {
69
74
  for (const name of journeys) {
70
- 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") } });
75
+ 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) } });
71
76
  record(`journey:${name}`, result.code === 0 ? "pass" : "fail", result.code === 0 ? undefined : lastLines(result.stderr || result.stdout));
72
77
  }
73
78
  }
@@ -170,13 +175,16 @@ export async function preflightDatabaseGate(root, bundle, output, run = runComma
170
175
  const violations = await throwaway.databaseGate(gate.sql);
171
176
  if (violations.length)
172
177
  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.`);
173
- output(`Migrations replayed and the database gate passed locally (${migrations.length} file(s), gate from ${gate.source === "fixture" ? "the pinned session fixture" : "this CLI release"}).`);
178
+ output(`Migrations replayed and the database gate passed locally (${migrations.length} file(s), gate from ${gateSourceLabel(gate.source)}).`);
174
179
  return "passed";
175
180
  }
176
181
  finally {
177
182
  await throwaway.down();
178
183
  }
179
184
  }
185
+ function gateSourceLabel(source) {
186
+ return source === "gateway" ? "the pinned app gateway image" : source === "fixture" ? "the pinned session fixture" : "this CLI release";
187
+ }
180
188
  export async function readReport(root) {
181
189
  try {
182
190
  return JSON.parse(await readFile(kitPaths(root).report, "utf8"));
@@ -6,7 +6,7 @@ import { safeError, CliError, renderSummary } from "./output.js";
6
6
  import { CLI_VERSION } from "./version.js";
7
7
  import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
8
8
  import { connect, loadConfig, resolveConfig } from "./config.js";
9
- import { loadKitBundle } from "./kit-bundle.js";
9
+ import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
10
10
  import { appRoot } from "./kit.js";
11
11
  import { initKit } from "./starter.js";
12
12
  import { agentPaths, agentSetup } from "./agent-setup.js";
@@ -118,7 +118,7 @@ else {
118
118
  }
119
119
  else if (LOCAL_COMMANDS.includes(command)) {
120
120
  // Local commands run before the company config/login requirement: the base app needs neither.
121
- const bundle = await loadKitBundle();
121
+ const bundle = EMBEDDED_KIT_BUNDLE;
122
122
  const target = appRoot(root);
123
123
  const config = resolveConfig(process.env, await loadConfig());
124
124
  const companyToken = async () => config ? (explicitToken || await refreshStoredToken(config.mcpUrl, config.tenantId)) : undefined;
@@ -208,14 +208,14 @@ else {
208
208
  const governance = new GovernanceClient(config.apiUrl, token, tenant);
209
209
  const result = subcommand === "status"
210
210
  ? await integrationsStatus(target, governance)
211
- : await requestIntegrations(target, governance, tenant, await loadKitBundle(), { connection: args[2], reason: reason, environment, operations: operations?.split(",").map(value => value.trim()).filter(Boolean), expiresAt });
211
+ : await requestIntegrations(target, governance, tenant, EMBEDDED_KIT_BUNDLE, { connection: args[2], reason: reason, environment, operations: operations?.split(",").map(value => value.trim()).filter(Boolean), expiresAt });
212
212
  envelope = summaryEnvelope(result);
213
213
  if (!json)
214
214
  progress(subcommand === "status" ? renderIntegrationsStatus(result) : renderRequest(result));
215
215
  }
216
216
  else if (command === "productionise") {
217
217
  // Kit apps: preview grants are checked (and the app linked) before any operation starts, so productionise never mints a second app for the same root.
218
- const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: await loadKitBundle() } });
218
+ const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE } });
219
219
  envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: result.operationRef, result: result.result };
220
220
  }
221
221
  else {
@@ -1,39 +1,47 @@
1
1
  import { mkdtemp, readFile, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- /** Where the session fixture image carries the gate (data plane build/session-fixture-image/Dockerfile). */
4
+ /** Where the app gateway and session fixture images carry the gate (data plane build/*-image/Dockerfile). */
5
5
  export const DATABASE_GATE_IMAGE_PATH = "/harbour/kit/database-gate.sql";
6
6
  /**
7
7
  * The kit database gate: the pipeline's own assertions over a replayed
8
8
  * migration set, run by the in-loop probe (harbour-deployment-data-plane
9
9
  * packages/toolkit/transformbuild/kit_database_gate.sql, embedded by
10
10
  * containerprobe.go) right after the migrations replay. `harbour check`
11
- * runs the copy the pinned session fixture image ships at
12
- * DATABASE_GATE_IMAGE_PATH; this is the verbatim copy from data plane
13
- * 0.73.1.0 for a fixture that predates the file. It raises one exception
14
- * naming every violation (table / policy / grant) on its own line with the fix.
11
+ * runs the copy the pinned kit images ship at DATABASE_GATE_IMAGE_PATH; this
12
+ * is the verbatim copy from data plane 0.73.1.0 for images that predate the
13
+ * file. It raises one exception naming every violation (table / policy /
14
+ * grant) on its own line with the fix.
15
15
  */
16
16
  export const DATABASE_GATE_SQL = "-- Harbour kit database gate.\n--\n-- Runs after a kit app's migrations replayed on a scratch PostgreSQL that\n-- carries the platform role harbour_app_gateway (NOLOGIN, NOBYPASSRLS): the\n-- role the App Gateway executes every application statement as. It asserts,\n-- against the catalog rather than the migration text, the contract that role\n-- lives under:\n--\n-- 1. every application table has row-level security enabled;\n-- 2. an RLS-enabled table has at least one policy that applies to the\n-- gateway role (RLS with no policy is default-deny: the app sees no rows);\n-- 3. every verb a policy allows is GRANTed to harbour_app_gateway — a policy\n-- without its grant is a feature that fails with permission-denied only\n-- for real users (`poll_votes`, 2026-09-10);\n-- 4. tables, views and sequences are granted only to their owner and to\n-- harbour_app_gateway; the platform creates no other role, so any other\n-- grantee (PUBLIC included) is a silent production no-op at best.\n--\n-- The platform's own schema harbour_runtime (the realtime outbox) is not the\n-- app's and is skipped. One script, executed by the pipeline's in-loop probe\n-- (packages/toolkit/transformbuild/containerprobe.go) and, byte for byte, by\n-- `harbour check` from /harbour/kit/database-gate.sql in the session fixture\n-- image. It raises one exception naming every violation on its own line, with\n-- the fix; a clean database returns silently.\n\\set ON_ERROR_STOP 1\n\\set VERBOSITY terse\nDO $harbour_gate$\nDECLARE\n gateway CONSTANT text := 'harbour_app_gateway';\n violations text[] := ARRAY[]::text[];\n rec record;\n verb text;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = gateway) THEN\n RAISE EXCEPTION 'harbour database gate: the platform role % does not exist on this database; the probe bootstrap must create it before the migrations replay', gateway;\n END IF;\n\n -- 1. Row-level security on every application table.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS table_name, c.relrowsecurity AS rls\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind IN ('r', 'p')\n AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n AND n.nspname NOT LIKE 'pg\\_toast%' AND n.nspname NOT LIKE 'pg\\_temp%'\n ORDER BY 1, 2\n LOOP\n IF NOT rec.rls THEN\n violations := violations || format(\n 'table %I.%I: row-level security is not enabled, so every signed-in person would see every row — fix: ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY; then CREATE POLICY ... ON %I.%I USING (...) WITH CHECK (...)',\n rec.schema_name, rec.table_name, rec.schema_name, rec.table_name, rec.schema_name, rec.table_name);\n -- 2. A policy the gateway role is subject to.\n ELSIF NOT EXISTS (\n SELECT 1 FROM pg_policy p\n JOIN pg_class c ON c.oid = p.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname = rec.schema_name AND c.relname = rec.table_name\n AND (p.polroles = '{0}'::oid[] OR (SELECT oid FROM pg_roles WHERE rolname = gateway) = ANY (p.polroles))\n ) THEN\n violations := violations || format(\n 'table %I.%I: row-level security is enabled but no policy applies to %s, so the app sees no rows and every write is refused — fix: CREATE POLICY %I ON %I.%I USING (owner_subject = current_setting(''harbour.user_id'', true)) WITH CHECK (owner_subject = current_setting(''harbour.user_id'', true))',\n rec.schema_name, rec.table_name, gateway, rec.table_name || '_owner', rec.schema_name, rec.table_name);\n END IF;\n END LOOP;\n\n -- 3. Every verb a policy allows is granted to the gateway role.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS table_name, c.oid AS table_oid, p.polname AS policy_name,\n CASE p.polcmd WHEN 'r' THEN ARRAY['SELECT'] WHEN 'a' THEN ARRAY['INSERT'] WHEN 'w' THEN ARRAY['UPDATE'] WHEN 'd' THEN ARRAY['DELETE']\n ELSE ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE'] END AS verbs,\n p.polroles = '{0}'::oid[] OR (SELECT oid FROM pg_roles WHERE rolname = gateway) = ANY (p.polroles) AS applies,\n (SELECT string_agg(r.rolname, ', ' ORDER BY r.rolname) FROM pg_roles r WHERE r.oid = ANY (p.polroles)) AS role_names\n FROM pg_policy p\n JOIN pg_class c ON c.oid = p.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n ORDER BY 1, 2, 4\n LOOP\n IF NOT rec.applies THEN\n violations := violations || format(\n 'policy %I on %I.%I: applies to role(s) %s, not to %s, so the App Gateway never satisfies it — fix: recreate the policy without a TO clause (or add TO %s)',\n rec.policy_name, rec.schema_name, rec.table_name, coalesce(rec.role_names, '(none)'), gateway, gateway);\n CONTINUE;\n END IF;\n FOREACH verb IN ARRAY rec.verbs LOOP\n IF NOT has_table_privilege(gateway, rec.table_oid, verb) THEN\n violations := violations || format(\n 'policy %I on %I.%I: allows %s but %s is never GRANTed %s on it, so the feature fails with permission-denied for real users — fix: GRANT %s ON %I.%I TO %s',\n rec.policy_name, rec.schema_name, rec.table_name, verb, gateway, verb, verb, rec.schema_name, rec.table_name, gateway);\n END IF;\n END LOOP;\n END LOOP;\n\n -- 4. Grants go only to the owner and the gateway role.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS object_name,\n CASE c.relkind WHEN 'S' THEN 'SEQUENCE' WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'VIEW' ELSE 'TABLE' END AS object_kind,\n coalesce(g.rolname, 'PUBLIC') AS grantee,\n string_agg(a.privilege_type, ', ' ORDER BY a.privilege_type) AS privileges\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n CROSS JOIN LATERAL aclexplode(c.relacl) a\n LEFT JOIN pg_roles g ON g.oid = a.grantee\n WHERE c.relkind IN ('r', 'p', 'v', 'm', 'S')\n AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n AND n.nspname NOT LIKE 'pg\\_toast%' AND n.nspname NOT LIKE 'pg\\_temp%'\n AND a.grantee <> c.relowner\n AND coalesce(g.rolname, '') <> gateway\n GROUP BY 1, 2, 3, 4\n ORDER BY 1, 2, 4\n LOOP\n violations := violations || format(\n 'grant %s on %s %I.%I to %s: only the platform role %s may be granted, the platform never creates %s — fix: REVOKE %s ON %s %I.%I FROM %s',\n rec.privileges, lower(rec.object_kind), rec.schema_name, rec.object_name, rec.grantee, gateway, rec.grantee,\n rec.privileges, rec.object_kind, rec.schema_name, rec.object_name, rec.grantee);\n END LOOP;\n\n IF coalesce(array_length(violations, 1), 0) > 0 THEN\n RAISE EXCEPTION 'harbour database gate: % violation(s)%', array_length(violations, 1), E'\\n' || array_to_string(violations, E'\\n');\n END IF;\nEND\n$harbour_gate$;\n";
17
17
  /**
18
- * The gate script from the pinned session fixture image when the image is
19
- * present locally and carries it (`docker create --pull never` + `docker cp`;
20
- * the image is distroless, so there is no shell to cat it), else the vendored
18
+ * The gate script from a pinned kit image present locally that carries it
19
+ * (`docker create --pull never` + `docker cp`; the images are distroless, so
20
+ * there is no shell to cat it): the app gateway image first — the one
21
+ * `harbour dev` pulls — then the session fixture image, else the vendored
21
22
  * copy. Never pulls: `harbour check` must stay a local, seconds-long step.
22
23
  */
23
24
  export async function loadDatabaseGate(bundle, run) {
24
- const vendored = { sql: DATABASE_GATE_SQL, source: "vendored" };
25
- const created = await run("docker", ["create", "--pull", "never", bundle.images.sessionFixture], { quiet: true });
25
+ for (const [source, image] of [["gateway", bundle.images.appGateway], ["fixture", bundle.images.sessionFixture]]) {
26
+ const sql = await copyGateFromImage(image, run);
27
+ if (sql)
28
+ return { sql, source };
29
+ }
30
+ return { sql: DATABASE_GATE_SQL, source: "vendored" };
31
+ }
32
+ async function copyGateFromImage(image, run) {
33
+ const created = await run("docker", ["create", "--pull", "never", image], { quiet: true });
26
34
  const id = created.code === 0 ? created.stdout.trim().split("\n").at(-1)?.trim() ?? "" : "";
27
35
  if (!id)
28
- return vendored;
36
+ return undefined;
29
37
  const dir = await mkdtemp(join(tmpdir(), "harbour-database-gate-"));
30
38
  try {
31
39
  const target = join(dir, "database-gate.sql");
32
40
  const copied = await run("docker", ["cp", `${id}:${DATABASE_GATE_IMAGE_PATH}`, target], { quiet: true });
33
41
  if (copied.code !== 0)
34
- return vendored;
42
+ return undefined;
35
43
  const sql = await readFile(target, "utf8").catch(() => undefined);
36
- return sql && sql.includes("harbour database gate") ? { sql, source: "fixture" } : vendored;
44
+ return sql && sql.includes("harbour database gate") ? sql : undefined;
37
45
  }
38
46
  finally {
39
47
  await rm(dir, { recursive: true, force: true });
@@ -3,7 +3,6 @@ import { createForwarder } from "./forwarder.js";
3
3
  import { readKitLock } from "./kit.js";
4
4
  import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, releaseDevLock, runCommand } from "./local-runtime.js";
5
5
  import { CliError } from "./output.js";
6
- import { ensureRealtimeStream } from "./realtime-stream.js";
7
6
  /**
8
7
  * Starts the project's Compose services, applies migrations, starts Vite and the
9
8
  * loopback origin. Resolves when the runtime is up; the returned `stop` runs
@@ -32,23 +31,19 @@ export async function startDev(root, options) {
32
31
  await runtime.writeFiles(options.bundle, ports);
33
32
  options.output("Pulling the kit images by digest (public registry, no login).");
34
33
  await runtime.pull(options.bundle);
35
- options.output("Starting local Harbour services (postgres, storage, realtime, identity fixture, app gateway).");
34
+ options.output("Starting local Harbour services (postgres, storage, one Harbour gateway with the session identities, fixtures and realtime relay).");
36
35
  await runtime.up();
37
- // The fixture writes the session env and the app's realtime outbox
38
- // migration together, so waiting for the session also waits for the SQL.
36
+ // The gateway writes the session env and the app's realtime outbox
37
+ // migration together once it is serving, so waiting for the session also
38
+ // waits for the SQL and for the gateway.
39
39
  const session = await runtime.sessionEnv();
40
40
  const applied = await runtime.migrate();
41
41
  options.output(`Applied ${applied.length} migration file(s).`);
42
42
  const watched = await runtime.installRealtimeOutbox();
43
- if (watched > 0) {
44
- // The outbox relay publishes with JetStream, which refuses a subject no
45
- // stream captures; without the stream harbour.realtime delivers nothing.
46
- // The relay has already given up by now, so it is restarted against the
47
- // stream this just created.
48
- await ensureRealtimeStream(ports.nats);
49
- await runtime.restartService("outbox");
43
+ // The gateway's in-process relay starts publishing as soon as the outbox
44
+ // table exists; nothing to create or restart.
45
+ if (watched > 0)
50
46
  options.output(`Installed the Harbour realtime outbox for ${watched} table(s) (harbour.realtime change events).`);
51
- }
52
47
  const origin = `http://127.0.0.1:${ports.origin}`;
53
48
  vite = spawn("npm", ["run", "dev"], { cwd: root, env: { ...env, HARBOUR_LOCAL_ORIGIN: origin, HARBOUR_VITE_PORT: String(ports.vite) }, stdio: ["ignore", "inherit", "inherit"] });
54
49
  vite.on("error", () => options.output("Vite could not be started; is npm installed and `npm install` done?"));
@@ -56,7 +51,7 @@ export async function startDev(root, options) {
56
51
  let warnedAuth = false;
57
52
  let lockAppId = (await readKitLock(root))?.appId || undefined;
58
53
  forwarder = createForwarder({
59
- port: ports.origin, vitePort: ports.vite, gatewayPort: ports.gateway, identityToken: session.HARBOUR_IDENTITY_CONTEXT_TOKEN,
54
+ port: ports.origin, vitePort: ports.vite, gatewayPort: ports.gateway, identityToken: session.HARBOUR_IDENTITY_CONTEXT,
60
55
  apiUrl: company?.apiUrl ?? "", tenantId: company?.tenantId ?? "",
61
56
  appId: () => lockAppId,
62
57
  accessToken: async () => company ? company.accessToken() : undefined,
@@ -72,7 +67,7 @@ export async function startDev(root, options) {
72
67
  options.output([
73
68
  "",
74
69
  `Harbour dev is running: ${origin}`,
75
- ` App identity (local fixture): ${session.HARBOUR_LOCAL_USER_EMAIL ?? "local-user@example.test"}`,
70
+ ` App identity (local session): ${session.HARBOUR_LOCAL_USER_EMAIL ?? "local-user@example.test"}; second user for cross-user checks: ${session.HARBOUR_LOCAL_SECOND_USER_EMAIL ?? "teammate@example.test"}`,
76
71
  ` Company account for integrations: ${account ?? (company ? "not signed in — run `harbour login`" : "not connected — run `harbour connect`")}`,
77
72
  lockAppId ? ` Linked app: ${lockAppId}` : " App not linked yet (harbour integrations request links it).",
78
73
  " Ctrl-C or `harbour stop` stops the services and keeps local data; `harbour dev --reset` deletes it.",
@@ -2,6 +2,11 @@ import { createServer, request as httpRequest } from "node:http";
2
2
  import { connect } from "node:net";
3
3
  const INTEGRATION_ROUTES = { "/_harbour/integrations/execute": ["POST"], "/_harbour/integrations/connect": ["POST", "DELETE"] };
4
4
  const HOP_HEADERS = new Set(["connection", "keep-alive", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"]);
5
+ const IDENTITY_HEADER = "x-harbour-identity-context";
6
+ /** The session identity for a gateway leg, unless the request already names one. */
7
+ function identityHeaders(request, token) {
8
+ return request.headers[IDENTITY_HEADER] ? {} : { [IDENTITY_HEADER]: token };
9
+ }
5
10
  export function createForwarder(options) {
6
11
  const origin = `http://127.0.0.1:${options.port}`;
7
12
  const host = `127.0.0.1:${options.port}`;
@@ -12,7 +17,7 @@ export function createForwarder(options) {
12
17
  return;
13
18
  }
14
19
  if (path.startsWith("/_harbour/")) {
15
- pipe(request, response, options.gatewayPort, { "x-harbour-identity-context": options.identityToken });
20
+ pipe(request, response, options.gatewayPort, identityHeaders(request, options.identityToken));
16
21
  return;
17
22
  }
18
23
  pipe(request, response, options.vitePort);
@@ -22,7 +27,7 @@ export function createForwarder(options) {
22
27
  socket.destroy();
23
28
  return;
24
29
  }
25
- tunnel(request, socket, head, (request.url ?? "/").startsWith("/_harbour/") ? options.gatewayPort : options.vitePort, (request.url ?? "/").startsWith("/_harbour/") ? { "x-harbour-identity-context": options.identityToken } : {});
30
+ tunnel(request, socket, head, (request.url ?? "/").startsWith("/_harbour/") ? options.gatewayPort : options.vitePort, (request.url ?? "/").startsWith("/_harbour/") ? identityHeaders(request, options.identityToken) : {});
26
31
  });
27
32
  return server;
28
33
  }
@@ -1,48 +1,20 @@
1
- import { readFile } from "node:fs/promises";
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 = {
4
+ postgres: "postgres:16-alpine",
5
+ minio: "minio/minio:RELEASE.2025-07-23T15-54-02Z"
6
+ };
2
7
  /**
3
- * The bundle this CLI was released with: `manifest.json` from the
4
- * harbour-deployment-data-plane `publish-kit-bundle` run for this kit version,
5
- * copied verbatim (the images and the tarball live in the kit's public
6
- * registry, `public.ecr.aws/<alias>/`, pinned by digest so no login and no
7
- * tag can substitute other bytes). `harbour init` records it in the app's
8
- * kit.lock; `harbour init --upgrade` shows the digest diff against it; the
9
- * hosted pipeline compares `sdk.tarballSha256` with the SDK it bakes.
8
+ * The bundle this CLI was built with: `manifest.json` of the
9
+ * harbour-deployment-data-plane `publish-kit-bundle` artifact for the kit
10
+ * version pinned in package.json `harbour.kitBundle`, fetched at build time
11
+ * into `kit-bundle.manifest.ts` (the images and the tarball live in the kit's
12
+ * public registry, `public.ecr.aws/<alias>/`, pinned by digest so no login
13
+ * and no tag can substitute other bytes). `harbour init` records it in the
14
+ * app's kit.lock; `harbour init --upgrade` shows the digest diff against it;
15
+ * the hosted pipeline compares `sdk.tarballSha256` with the SDK it bakes.
10
16
  */
11
- export const EMBEDDED_KIT_BUNDLE = {
12
- schema: "harbour.kit-bundle/1.0",
13
- kitVersion: "0.1.16",
14
- sdk: {
15
- package: "@harbour/app-sdk",
16
- version: "1.0.0",
17
- tarballSha256: "62954161ec29148c20223316d83367b781dd9291665ea16f74a003b16e304a3b",
18
- url: "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:62954161ec29148c20223316d83367b781dd9291665ea16f74a003b16e304a3b"
19
- },
20
- images: {
21
- appGateway: "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:4c8d359ee18f1fa6f96dde80379fa30a3c5a7675b6e9c2150e56d5ec41ba3e7c",
22
- sessionFixture: "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:32cbb4f1820fd84b9e790b8559db5aae632b3deb77a7de87b916f75a4ca37ddc",
23
- postgres: "postgres:16-alpine",
24
- minio: "minio/minio:RELEASE.2025-07-23T15-54-02Z",
25
- nats: "nats:2.11.17-alpine"
26
- },
27
- brief: { fingerprint: "ebc897c539526c5537017b49b99dc970053ba9999e346029883e96d61b98ef13" },
28
- declarationSchema: "harbour.app-integrations/2.0"
29
- };
30
- /** The manifest the CLI ships with, or the one named by HARBOUR_KIT_BUNDLE for local testing against unreleased images. */
31
- export async function loadKitBundle(env = process.env) {
32
- const override = env.HARBOUR_KIT_BUNDLE?.trim();
33
- if (!override)
34
- return EMBEDDED_KIT_BUNDLE;
35
- let parsed;
36
- try {
37
- parsed = JSON.parse(await readFile(override, "utf8"));
38
- }
39
- catch {
40
- throw new Error(`HARBOUR_KIT_BUNDLE does not point at a readable manifest: ${override}`);
41
- }
42
- if (!isKitBundle(parsed))
43
- throw new Error("HARBOUR_KIT_BUNDLE is not a harbour.kit-bundle/1.0 manifest.");
44
- return { ...EMBEDDED_KIT_BUNDLE, ...parsed, images: { ...EMBEDDED_KIT_BUNDLE.images, ...parsed.images } };
45
- }
17
+ export const EMBEDDED_KIT_BUNDLE = { ...PUBLISHED_KIT_BUNDLE, images: { ...LOCAL_SERVICE_IMAGES, ...PUBLISHED_KIT_BUNDLE.images } };
46
18
  export function isKitBundle(value) {
47
19
  const record = value;
48
20
  return Boolean(record && typeof record === "object" && record.schema === "harbour.kit-bundle/1.0" && typeof record.kitVersion === "string"
@@ -0,0 +1,18 @@
1
+ export const PUBLISHED_KIT_BUNDLE = {
2
+ "schema": "harbour.kit-bundle/1.0",
3
+ "kitVersion": "0.1.21",
4
+ "sdk": {
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"
9
+ },
10
+ "images": {
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:8a838e05694da93a84def78898807dea177b0cd7bab60697b365151be511f47c",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:6f7142d34ba3f8d7879a71658555c38eaf2f416bb9580461cb74b47a627d16e9"
13
+ },
14
+ "brief": {
15
+ "fingerprint": "3b9d305bf6eedad1f48f704d3f29c33fcbc3e746bae395b41a3f71b3a809ab8f"
16
+ },
17
+ "declarationSchema": "harbour.app-integrations/2.0"
18
+ };
@@ -24,6 +24,9 @@ export const LOCAL = {
24
24
  app: "local-app",
25
25
  userId: "local-user",
26
26
  email: "local-user@example.test",
27
+ /** The second signed-in person of every local session: what a cross-user check runs as. */
28
+ secondUserId: "local-teammate",
29
+ secondEmail: "teammate@example.test",
27
30
  database: "harbour",
28
31
  dbUser: "harbour",
29
32
  dbPassword: "harbour-local",
@@ -34,11 +37,15 @@ export const LOCAL = {
34
37
  };
35
38
  // ---- Compose ----------------------------------------------------------------------
36
39
  /**
37
- * Compose file for one project: every port bound to 127.0.0.1, named volumes
38
- * prefixed with the project name, images pinned from the bundle manifest.
39
- * The fixture writes the session env (identity token) and the platform's
40
- * realtime outbox migration (generated from the app's migrations, staged under
41
- * state/migrations) to the shared state dir.
40
+ * Compose file for one project three containers: Postgres, MinIO and one
41
+ * Harbour process, the app gateway in local-session mode. Every port bound to
42
+ * 127.0.0.1, named volumes prefixed with the project name, images pinned from
43
+ * the bundle manifest. The gateway mints the session identities and serves
44
+ * their JWKS itself, serves the provider fixtures beside its routes, relays
45
+ * the realtime outbox in-process, and writes the session env (both identity
46
+ * tokens) and the platform's realtime outbox migration (generated from the
47
+ * app's migrations, staged under state/migrations) to the shared state dir
48
+ * (data plane docs/local-kit.md).
42
49
  */
43
50
  export function composeFile(project, bundle, ports, stateDir) {
44
51
  const images = bundle.images;
@@ -59,27 +66,6 @@ export function composeFile(project, bundle, ports, stateDir) {
59
66
  ` ports: ["127.0.0.1:${ports.minio}:9000"]`,
60
67
  " volumes: [minio-data:/data]",
61
68
  " healthcheck: { test: [\"CMD-SHELL\", \"curl -sf http://127.0.0.1:9000/minio/health/ready || wget -qO- http://127.0.0.1:9000/minio/health/ready\"], interval: 2s, timeout: 3s, retries: 30 }",
62
- " nats:",
63
- ` image: ${images.nats ?? "nats:2.11.17-alpine"}`,
64
- " command: [\"-js\", \"-m\", \"8222\"]",
65
- ` ports: ["127.0.0.1:${ports.nats}:4222"]`,
66
- " fixture:",
67
- ` image: ${images.sessionFixture}`,
68
- " command: [\"--listen\", \"0.0.0.0:8080\", \"--session\", \"" + project + "\", \"--base-url\", \"http://127.0.0.1:" + ports.fixture + "\", \"--tenant\", \"" + LOCAL.tenant + "\", \"--app\", \"" + LOCAL.app + "\", \"--user-id\", \"" + LOCAL.userId + "\", \"--email\", \"" + LOCAL.email + "\", \"--metadata-file\", \"/state/session.json\", \"--env-file\", \"/state/session.env\", \"--display-directory\", \"" + stateDir + "\", \"--migrations-dir\", \"/state/migrations\", \"--realtime-outbox-file\", \"/state/realtime-outbox.sql\"]",
69
- " environment:",
70
- ` HARBOUR_LOCAL_COMPOSE_PROJECT: ${project}`,
71
- ` HARBOUR_LOCAL_S3_INTERNAL_ENDPOINT: http://minio:9000`,
72
- ` HARBOUR_LOCAL_S3_ENDPOINT: http://127.0.0.1:${ports.minio}`,
73
- ` HARBOUR_LOCAL_S3_BUCKET: ${LOCAL.bucket}`,
74
- ` HARBOUR_LOCAL_S3_ACCESS_KEY: ${LOCAL.s3Key}`,
75
- ` HARBOUR_LOCAL_S3_SECRET_KEY: ${LOCAL.s3Secret}`,
76
- ` HARBOUR_LOCAL_POSTGRES_INTERNAL_URL: ${internalDatabaseUrl()}`,
77
- ` HARBOUR_LOCAL_POSTGRES_ADDRESS: 127.0.0.1:${ports.postgres}`,
78
- ` HARBOUR_LOCAL_NATS_URL: nats://127.0.0.1:${ports.nats}`,
79
- ` HARBOUR_LOCAL_NATS_INTERNAL_URL: nats://nats:4222`,
80
- ` ports: ["127.0.0.1:${ports.fixture}:8080"]`,
81
- ` volumes: ["${stateDir}:/state"]`,
82
- " depends_on: { postgres: { condition: service_healthy }, minio: { condition: service_healthy } }",
83
69
  " gateway:",
84
70
  ` image: ${images.appGateway}`,
85
71
  " environment:",
@@ -87,34 +73,13 @@ export function composeFile(project, bundle, ports, stateDir) {
87
73
  " HARBOUR_APP_GATEWAY_UPLOAD_KEY: " + uploadKey(project),
88
74
  " HARBOUR_S3_ENDPOINT: http://minio:9000",
89
75
  ` HARBOUR_S3_PUBLIC_ENDPOINT: http://127.0.0.1:${ports.minio}`,
90
- " HARBOUR_NATS_URL: nats://nats:4222",
91
76
  ` AWS_ACCESS_KEY_ID: ${LOCAL.s3Key}`,
92
77
  ` AWS_SECRET_ACCESS_KEY: ${LOCAL.s3Secret}`,
93
78
  " AWS_REGION: us-east-1",
94
79
  " AWS_EC2_METADATA_DISABLED: \"true\"",
95
80
  ` ports: ["127.0.0.1:${ports.gateway}:8080"]`,
96
- ` volumes: ["${stateDir}/app-gateway.json:/config/app-gateway.json:ro"]`,
97
- " depends_on: { postgres: { condition: service_healthy }, minio: { condition: service_healthy }, fixture: { condition: service_started } }",
98
- // The relay that moves committed rows from harbour_runtime.event_outbox to
99
- // JetStream (docs/local-kit.md "Outbox relay container"): the hosted
100
- // gateway starts it per app, the static local configuration does not.
101
- // It shares the gateway's configuration and so fetches the fixture's JWKS
102
- // before it reaches its own mode, even though a relay verifies no token:
103
- // it therefore waits for the fixture like the gateway does, and restarts
104
- // when it still wins the race (the fixture opens its listener only after
105
- // its bucket is ready, so `service_started` is not `serving`).
106
- " outbox:",
107
- ` image: ${images.appGateway}`,
108
- " environment:",
109
- " HARBOUR_APP_GATEWAY_MODE: outbox",
110
- " HARBOUR_APP_GATEWAY_CONFIG_FILE: /config/app-gateway.json",
111
- " HARBOUR_APP_GATEWAY_UPLOAD_KEY: " + uploadKey(project),
112
- " HARBOUR_NATS_URL: nats://nats:4222",
113
- " AWS_REGION: us-east-1",
114
- " AWS_EC2_METADATA_DISABLED: \"true\"",
115
- ` volumes: ["${stateDir}/app-gateway.json:/config/app-gateway.json:ro"]`,
116
- " restart: on-failure",
117
- " depends_on: { postgres: { condition: service_healthy }, nats: { condition: service_started }, fixture: { condition: service_started } }",
81
+ ` volumes: ["${stateDir}/app-gateway.json:/config/app-gateway.json:ro", "${stateDir}:/state"]`,
82
+ " depends_on: { postgres: { condition: service_healthy }, minio: { condition: service_healthy } }",
118
83
  "volumes:",
119
84
  ` postgres-data: { name: ${project}-postgres }`,
120
85
  ` minio-data: { name: ${project}-minio }`,
@@ -122,16 +87,18 @@ export function composeFile(project, bundle, ports, stateDir) {
122
87
  ""
123
88
  ].join("\n");
124
89
  }
125
- /** App Gateway configuration in the shape cmd/appgateway/main.go decodes (unknown fields are rejected there). */
126
- export function gatewayConfig(ports) {
90
+ /**
91
+ * App Gateway configuration in the shape cmd/appgateway/main.go decodes (unknown fields are rejected there).
92
+ * `localSession` is what makes the gateway the whole local runtime: issuer = its own published origin (the
93
+ * tokens' `iss`), no jwksUrl (the keys are in-process), the session's bucket, users and state files.
94
+ */
95
+ export function gatewayConfig(project, ports, stateDir) {
127
96
  return {
128
97
  listen: ":8080",
129
- issuer: `http://127.0.0.1:${ports.fixture}`,
130
- jwksUrl: "http://fixture:8080/.well-known/jwks.json",
98
+ issuer: `http://127.0.0.1:${ports.gateway}`,
131
99
  environment: "development",
132
100
  publicBaseUrl: `http://127.0.0.1:${ports.origin}`,
133
- // Verified against the local images (docs/local-kit.md): the fixture's `iss` equals its --base-url, so `issuer`
134
- // verifies; `unrestrictedFiles` with no filePolicies is the local cell's file policy.
101
+ // `unrestrictedFiles` with no filePolicies is the local cell's file policy.
135
102
  bindings: [{
136
103
  tenant: LOCAL.tenant,
137
104
  app: LOCAL.app,
@@ -142,7 +109,14 @@ export function gatewayConfig(ports) {
142
109
  capabilities: ["data", "files", "realtime", "telemetry"],
143
110
  filePolicies: [],
144
111
  unrestrictedFiles: true
145
- }]
112
+ }],
113
+ localSession: {
114
+ session: project, tenant: LOCAL.tenant, app: LOCAL.app, userId: LOCAL.userId, email: LOCAL.email,
115
+ secondUserId: LOCAL.secondUserId, secondEmail: LOCAL.secondEmail,
116
+ metadataFile: "/state/session.json", envFile: "/state/session.env", displayDirectory: stateDir,
117
+ migrationsDir: "/state/migrations", realtimeOutboxFile: "/state/realtime-outbox.sql",
118
+ bucket: LOCAL.bucket
119
+ }
146
120
  };
147
121
  }
148
122
  /** `migrations/*.sql` in name order — what `harbour dev` applies and what the fixture derives the outbox from. */
@@ -160,8 +134,8 @@ export async function freePort() {
160
134
  });
161
135
  }
162
136
  export async function allocatePorts() {
163
- const [postgres, minio, nats, fixture, gateway, vite, origin] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort(), freePort(), freePort()]);
164
- return { postgres: postgres, minio: minio, nats: nats, fixture: fixture, gateway: gateway, vite: vite, origin: origin };
137
+ const [postgres, minio, gateway, vite, origin] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort()]);
138
+ return { postgres: postgres, minio: minio, gateway: gateway, vite: vite, origin: origin };
165
139
  }
166
140
  /** Acquires `.harbour/local/dev.lock`; a lock whose process is gone is stale and replaced. */
167
141
  export async function acquireDevLock(root, ports, isAlive = pidAlive) {
@@ -223,8 +197,8 @@ export class LocalRuntime {
223
197
  const paths = kitPaths(this.root);
224
198
  await mkdir(paths.state, { recursive: true });
225
199
  await writeFile(paths.compose, composeFile(this.project, bundle, ports, paths.state));
226
- await writeFile(join(paths.state, "app-gateway.json"), `${JSON.stringify(gatewayConfig(ports), null, 2)}\n`);
227
- // The fixture derives the realtime outbox from the app's migrations; they
200
+ await writeFile(join(paths.state, "app-gateway.json"), `${JSON.stringify(gatewayConfig(this.project, ports, paths.state), null, 2)}\n`);
201
+ // The gateway derives the realtime outbox from the app's migrations; they
228
202
  // are staged into the state dir (already mounted at /state) rather than
229
203
  // bind-mounting migrations/, which Docker would create as root if absent.
230
204
  const staged = join(paths.state, "migrations");
@@ -243,15 +217,13 @@ export class LocalRuntime {
243
217
  async pull(bundle) {
244
218
  const result = await this.compose(["pull", "--quiet"]);
245
219
  if (result.code !== 0)
246
- throw new CliError("KIT_IMAGES_UNAVAILABLE", `Docker could not pull the kit images (${bundle.images.appGateway}, ${bundle.images.sessionFixture}): ${result.stderr.trim().split("\n").at(-1) ?? "docker error"}. They are public and pinned by digest; \`docker compose\` (Compose v2) must be installed and Docker running with access to the registry.`);
220
+ throw new CliError("KIT_IMAGES_UNAVAILABLE", `Docker could not pull the kit images (${bundle.images.appGateway}): ${result.stderr.trim().split("\n").at(-1) ?? "docker error"}. They are public and pinned by digest; \`docker compose\` (Compose v2) must be installed and Docker running with access to the registry.`);
247
221
  }
248
222
  async up() {
249
223
  const result = await this.compose(["up", "-d", "--wait"]);
250
224
  if (result.code !== 0)
251
225
  throw new CliError("LOCAL_RUNTIME_FAILED", "Docker could not start the local Harbour services. Is Docker running and are the kit images available (see .harbour/kit.lock.json)?");
252
226
  }
253
- /** Restarts one service; the outbox relay needs it once its JetStream stream exists. */
254
- async restartService(service) { await this.compose(["restart", service], { quiet: true }); }
255
227
  /** Stops containers and keeps volumes; safe to repeat. */
256
228
  async stop() {
257
229
  const result = await this.compose(["stop"], { quiet: true });
@@ -293,10 +265,11 @@ export class LocalRuntime {
293
265
  return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
294
266
  }
295
267
  /**
296
- * Applies the platform's realtime outbox migration the session fixture
297
- * generated for this app's tables (state/realtime-outbox.sql: the data
298
- * plane's one generator, the same text the hosted pipeline appends to the
299
- * app's bundle) — after the app's migrations, idempotent on every start.
268
+ * Applies the platform's realtime outbox migration the gateway generated
269
+ * for this app's tables (state/realtime-outbox.sql: the data plane's one
270
+ * generator, the same text the hosted pipeline appends to the app's
271
+ * bundle) — after the app's migrations, idempotent on every start. The
272
+ * gateway's in-process relay starts publishing once the table exists.
300
273
  * Returns the number of watched tables; 0 when the app has no tables.
301
274
  */
302
275
  async installRealtimeOutbox() {
@@ -309,22 +282,35 @@ export class LocalRuntime {
309
282
  throw new CliError("MIGRATION_FAILED", `The realtime outbox migration failed: ${result.stderr.trim().split("\n").at(-1) ?? "psql error"}`);
310
283
  return (sql.match(/harbour_runtime\.watch_table\(/g) ?? []).length;
311
284
  }
312
- /** The fixture's session env (identity token for the local app user) once the fixture has written it. */
285
+ /**
286
+ * The gateway's session env — both signed identities under the pipeline's
287
+ * names (HARBOUR_IDENTITY_CONTEXT, _HEADER, _SECOND_USER) — once the
288
+ * gateway has bound its listener and written it.
289
+ */
313
290
  async sessionEnv(attempts = 60) {
314
291
  const path = join(kitPaths(this.root).state, "session.env");
315
292
  for (let attempt = 0; attempt < attempts; attempt += 1) {
316
293
  try {
317
294
  const env = parseSessionEnv(await readFile(path, "utf8"));
318
- if (env.HARBOUR_IDENTITY_CONTEXT_TOKEN)
295
+ if (env.HARBOUR_IDENTITY_CONTEXT && env.HARBOUR_IDENTITY_CONTEXT_SECOND_USER)
319
296
  return env;
320
297
  }
321
298
  catch { /* not written yet */ }
322
- await new Promise(resolve => setTimeout(resolve, 500));
299
+ if (attempts > 1)
300
+ await new Promise(resolve => setTimeout(resolve, 500));
323
301
  }
324
- throw new CliError("LOCAL_RUNTIME_FAILED", "The local identity fixture did not publish a session.");
302
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The local Harbour gateway did not publish a session.");
325
303
  }
326
304
  }
327
- /** The fixture writes a shell-sourceable file: `export KEY='value'`, a literal quote written as `'"'"'`. */
305
+ /** The three names the pipeline's retained-check runner exports for the signed identities, from a session env. */
306
+ export function identityEnvironment(session) {
307
+ return {
308
+ HARBOUR_IDENTITY_CONTEXT: session.HARBOUR_IDENTITY_CONTEXT ?? "",
309
+ HARBOUR_IDENTITY_CONTEXT_HEADER: session.HARBOUR_IDENTITY_CONTEXT_HEADER ?? "X-Harbour-Identity-Context",
310
+ HARBOUR_IDENTITY_CONTEXT_SECOND_USER: session.HARBOUR_IDENTITY_CONTEXT_SECOND_USER ?? ""
311
+ };
312
+ }
313
+ /** The gateway writes a shell-sourceable file: `export KEY='value'`, a literal quote written as `'"'"'`. */
328
314
  export function parseSessionEnv(text) {
329
315
  const env = {};
330
316
  for (const line of text.split("\n")) {
@@ -108,7 +108,7 @@ description: Build, run and check this Harbour app with the Harbour CLI (dev, ch
108
108
 
109
109
  Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
110
110
 
111
- 1. \`harbour dev --app-root .\` starts Postgres, storage, the identity fixture, the app gateway and Vite behind one loopback origin printed in the banner.
111
+ 1. \`harbour dev --app-root .\` starts Postgres, storage and one Harbour gateway (session identities, fixtures, realtime) plus Vite behind one loopback origin printed in the banner.
112
112
  2. Edit \`src/\`, \`migrations/\` and \`.harbour/integrations.json\`. Use the SDK only; declare integrations before calling them.
113
113
  3. \`harbour check --app-root .\` before every hand-off; read \`.harbour/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only).
114
114
  4. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
@@ -128,7 +128,10 @@ function kitFiles() {
128
128
  // One retained check per capability the starter's UI uses: the pipeline's
129
129
  // flow gate refuses an app whose checks never exercise a declared capability.
130
130
  ".harbour/checks/notes-journey.mjs": JOURNEY_CHECK,
131
- ".harbour/checks/files-journey.mjs": FILES_JOURNEY_CHECK
131
+ ".harbour/checks/files-journey.mjs": FILES_JOURNEY_CHECK,
132
+ // The pipeline's cross-user denial, run locally: the last gate class that
133
+ // used to exist only in CodeBuild.
134
+ ".harbour/checks/notes-cross-user.mjs": CROSS_USER_CHECK
132
135
  };
133
136
  }
134
137
  function starterFiles(bundle) {
@@ -150,7 +153,7 @@ function starterFiles(bundle) {
150
153
  "src/harbour.client.ts": HARBOUR_CLIENT,
151
154
  "src/App.tsx": APP,
152
155
  "src/SlackPanel.tsx": SLACK_PANEL,
153
- "README.md": "# Harbour app\n\nCreated by `harbour init`. Run `harbour dev --app-root .` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.\n\n`.harbour/checks/` holds one journey per capability the app uses (`notes-journey.mjs` for data, `files-journey.mjs` for files); `harbour check` and the deployment pipeline refuse an app whose checks never exercise an operation its own code performs, so a new feature needs its own retained check.\n\n`.harbour/integrations.json` declares `company-slack` only. Declare a connection only when the app calls it: `harbour productionise` refuses to deploy until every declared connection has a preview grant (`harbour integrations request <connection> --environment preview --reason \"<why>\" --app-root .`). The warehouse example (`sales-warehouse` / `warehouse.view.read`) is in a comment in `src/App.tsx`.\n"
156
+ "README.md": "# Harbour app\n\nCreated by `harbour init`. Run `harbour dev --app-root .` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.\n\n`.harbour/checks/` holds one journey per capability the app uses (`notes-journey.mjs` for data, `files-journey.mjs` for files) and `notes-cross-user.mjs`, which proves a second signed-in person cannot read, update or delete another person's note — the same denial the deployment pipeline's write probe asserts; `harbour check` and the deployment pipeline refuse an app whose checks never exercise an operation its own code performs, so a new feature needs its own retained check.\n\n`.harbour/integrations.json` declares `company-slack` only. Declare a connection only when the app calls it: `harbour productionise` refuses to deploy until every declared connection has a preview grant (`harbour integrations request <connection> --environment preview --reason \"<why>\" --app-root .`). The warehouse example (`sales-warehouse` / `warehouse.view.read`) is in a comment in `src/App.tsx`.\n"
154
157
  };
155
158
  }
156
159
  const VITE_CONFIG = `import { defineConfig } from "vite";
@@ -371,9 +374,9 @@ import assert from "node:assert/strict";
371
374
  const appUrl = process.env.HARBOUR_APP_URL;
372
375
  assert.ok(appUrl, "HARBOUR_APP_URL is required");
373
376
  const sdk = await import(process.env.HARBOUR_SDK_MODULE ?? "@harbour/app-sdk");
374
- const token = process.env.HARBOUR_IDENTITY_CONTEXT_TOKEN;
375
- const fetchWithIdentity = (input, init = {}) => fetch(input, { ...init, headers: { ...(init.headers ?? {}), ...(token ? { "x-harbour-identity-context": token } : {}) } });
376
- const harbour = sdk.createClient({ baseUrl: appUrl, fetch: fetchWithIdentity });
377
+ // The signed-in identity is attached by the SDK itself (HARBOUR_IDENTITY_CONTEXT
378
+ // from \`harbour check\` and from the pipeline's runner): one injector per request.
379
+ const harbour = sdk.createClient({ baseUrl: appUrl });
377
380
 
378
381
  const user = await harbour.identity.current();
379
382
  assert.ok(user && user.email, "identity/current must return the signed-in user");
@@ -391,6 +394,54 @@ await harbour.data.from("notes").delete().eq("id", notes[0].id);
391
394
  const { data: remaining } = await harbour.data.from("notes").select("id").eq("title", title);
392
395
  assert.equal(remaining.length, 0, "the note is deleted");
393
396
  `;
397
+ const CROSS_USER_CHECK = `// Cross-user check: a second signed-in person cannot read, update or delete the first
398
+ // person's note through the running app (HARBOUR_APP_URL). The deployment pipeline's
399
+ // write probe makes exactly these three assertions, with a second identity, against every
400
+ // owner-scoped table; this is the same check run locally, so user isolation can no longer
401
+ // be green under \`harbour check\` and red in CodeBuild. Runs while \`harbour dev\` is up,
402
+ // and in the pipeline's retained-check container.
403
+ import assert from "node:assert/strict";
404
+
405
+ const appUrl = process.env.HARBOUR_APP_URL;
406
+ assert.ok(appUrl, "HARBOUR_APP_URL is required");
407
+ const secondUser = process.env.HARBOUR_IDENTITY_CONTEXT_SECOND_USER;
408
+ assert.ok(secondUser, "HARBOUR_IDENTITY_CONTEXT_SECOND_USER is required (set by harbour check and by the pipeline)");
409
+ const sdk = await import(process.env.HARBOUR_SDK_MODULE ?? "@harbour/app-sdk");
410
+ // The first person: the SDK attaches HARBOUR_IDENTITY_CONTEXT itself.
411
+ const owner = sdk.createClient({ baseUrl: appUrl });
412
+ // The second person: the same SDK, with that person's signed identity replacing the first on every request.
413
+ const header = (process.env.HARBOUR_IDENTITY_CONTEXT_HEADER ?? "X-Harbour-Identity-Context").toLowerCase();
414
+ const asSecondUser = (input, init = {}) => fetch(input, { ...init, headers: { ...(init.headers ?? {}), [header]: secondUser } });
415
+ const other = sdk.createClient({ baseUrl: appUrl, fetch: asSecondUser });
416
+
417
+ const me = await owner.identity.current();
418
+ const them = await other.identity.current();
419
+ assert.ok(me && them && me.id !== them.id, "the two signed-in identities must be different people");
420
+
421
+ const title = \`cross-user \${Date.now()}\`;
422
+ await owner.data.from("notes").insert({ title });
423
+ const { data: mine } = await owner.data.from("notes").select("*").eq("title", title);
424
+ assert.equal(mine.length, 1, "the owner reads the note back");
425
+ const id = mine[0].id;
426
+
427
+ // 1. SELECT by id as the second person: no row.
428
+ const { data: seen } = await other.data.from("notes").select("*").eq("id", id);
429
+ assert.equal(seen.length, 0, "notes: a second signed-in user can read another user's row — the owner policy does not isolate users");
430
+
431
+ // 2. UPDATE and 3. DELETE by id as the second person: refused (403) or no row affected.
432
+ const denied = async (verb, operation) => {
433
+ let affected;
434
+ try { affected = (await operation()).data ?? []; }
435
+ catch (error) { if (error?.category === "FORBIDDEN") return; throw error; }
436
+ assert.equal(affected.length, 0, \`notes \${verb}: a second user mutated another user's row despite an owner-scoped source policy\`);
437
+ };
438
+ await denied("UPDATE", () => other.data.from("notes").update({ done: true }).eq("id", id));
439
+ await denied("DELETE", () => other.data.from("notes").delete().eq("id", id));
440
+ const { data: after } = await owner.data.from("notes").select("*").eq("id", id);
441
+ assert.equal(after.length, 1, "the owner's note still exists after the second user's attempts");
442
+ assert.equal(after[0].done, false, "the owner's note is unchanged after the second user's attempts");
443
+ await owner.data.from("notes").delete().eq("id", id);
444
+ `;
394
445
  const FILES_JOURNEY_CHECK = `// Journey check: the signed-in identity can upload a private file and list it back
395
446
  // through the running app (HARBOUR_APP_URL) — the two operations the "Private
396
447
  // files" section of src/App.tsx performs (\`harbour.files.upload\` / \`harbour.files.list\`).
@@ -402,9 +453,9 @@ import assert from "node:assert/strict";
402
453
  const appUrl = process.env.HARBOUR_APP_URL;
403
454
  assert.ok(appUrl, "HARBOUR_APP_URL is required");
404
455
  const sdk = await import(process.env.HARBOUR_SDK_MODULE ?? "@harbour/app-sdk");
405
- const token = process.env.HARBOUR_IDENTITY_CONTEXT_TOKEN;
406
- const fetchWithIdentity = (input, init = {}) => fetch(input, { ...init, headers: { ...(init.headers ?? {}), ...(token ? { "x-harbour-identity-context": token } : {}) } });
407
- const harbour = sdk.createClient({ baseUrl: appUrl, fetch: fetchWithIdentity });
456
+ // The signed-in identity is attached by the SDK itself (HARBOUR_IDENTITY_CONTEXT
457
+ // from \`harbour check\` and from the pipeline's runner): one injector per request.
458
+ const harbour = sdk.createClient({ baseUrl: appUrl });
408
459
 
409
460
  const user = await harbour.identity.current();
410
461
  assert.ok(user && user.email, "identity/current must return the signed-in user");
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.20";
1
+ export const CLI_VERSION = "0.1.21";
package/package.json CHANGED
@@ -1,15 +1,40 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
- "bin": { "harbour": "./dist/packages/harbour-cli/src/cli.js" },
7
- "repository": { "type": "git", "url": "https://github.com/Fourier-Labs-AI/harbour-governance-control-plane.git" },
8
- "publishConfig": { "access": "public" },
9
- "files": ["dist/packages/harbour-cli/src", "dist/src/analyzer.js", "dist/src/contracts.js", "dist/src/digest.js", "dist/src/secret-paths.js", "dist/src/source-intake.js", "package.json"],
10
- "engines": { "node": ">=22.13.0" },
6
+ "bin": {
7
+ "harbour": "./dist/packages/harbour-cli/src/cli.js"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/Fourier-Labs-AI/harbour-governance-control-plane.git"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "dist/packages/harbour-cli/src",
18
+ "dist/src/analyzer.js",
19
+ "dist/src/contracts.js",
20
+ "dist/src/digest.js",
21
+ "dist/src/secret-paths.js",
22
+ "dist/src/source-intake.js",
23
+ "package.json"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22.13.0"
27
+ },
11
28
  "scripts": {
29
+ "prebuild": "node scripts/kit-bundle.mjs verify",
12
30
  "build": "tsc -p tsconfig.json",
31
+ "kit-bundle:sync": "node scripts/kit-bundle.mjs sync",
13
32
  "pack:check": "npm pack --dry-run"
33
+ },
34
+ "harbour": {
35
+ "kitBundle": {
36
+ "repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
37
+ "version": "0.1.21"
38
+ }
14
39
  }
15
40
  }
@@ -1,79 +0,0 @@
1
- import { connect } from "node:net";
2
- import { randomUUID } from "node:crypto";
3
- import { LOCAL } from "./local-runtime.js";
4
- /**
5
- * The JetStream stream the local realtime path needs.
6
- *
7
- * The App Gateway's outbox relay publishes committed row changes with a
8
- * JetStream publish (appgateway/outbox.go); a JetStream publish to a subject no
9
- * stream captures is refused with "no response from stream", so without this
10
- * the relay retries forever and `harbour.realtime` never delivers anything
11
- * locally — while the deployment pipeline, which creates the same stream for
12
- * its probe (toolkit/transformbuild/local_component_runtime.go), delivers
13
- * normally. A realtime app therefore could not be exercised locally at all.
14
- *
15
- * Creating it needs three NATS protocol lines, so the CLI speaks them itself
16
- * rather than taking a client dependency.
17
- */
18
- export const REALTIME_STREAM = "HARBOUR_LOCAL";
19
- export const REALTIME_SUBJECTS = `harbour.app.${LOCAL.tenant}.${LOCAL.app}.>`;
20
- /** JetStream's "stream name already in use": the stream from an earlier `harbour dev`. */
21
- const ALREADY_EXISTS = 10058;
22
- export async function ensureRealtimeStream(port, host = "127.0.0.1", timeoutMs = 10_000) {
23
- const inbox = `_INBOX.${randomUUID()}`;
24
- const request = JSON.stringify({ name: REALTIME_STREAM, subjects: [REALTIME_SUBJECTS], storage: "memory", retention: "limits", num_replicas: 1, discard: "old" });
25
- const reply = await natsRequest(host, port, `$JS.API.STREAM.CREATE.${REALTIME_STREAM}`, inbox, request, timeoutMs);
26
- const answer = JSON.parse(reply);
27
- if (!answer.error)
28
- return "created";
29
- if (answer.error.err_code === ALREADY_EXISTS)
30
- return "present";
31
- throw new Error(`the local realtime stream could not be created: ${answer.error.description ?? reply}`);
32
- }
33
- /** CONNECT, SUB the inbox, PUB the request, and return the first reply. */
34
- function natsRequest(host, port, subject, inbox, payload, timeoutMs) {
35
- return new Promise((resolve, reject) => {
36
- const socket = connect(port, host);
37
- const finish = (error, value) => { clearTimeout(timer); socket.destroy(); error ? reject(error) : resolve(value); };
38
- const timer = setTimeout(() => finish(new Error("the local NATS server did not answer the JetStream request")), timeoutMs);
39
- timer.unref?.();
40
- let buffer = "";
41
- let greeted = false;
42
- socket.on("error", error => finish(error));
43
- socket.setEncoding("utf8");
44
- socket.on("data", chunk => {
45
- buffer += chunk;
46
- if (!greeted && buffer.includes("\r\n")) {
47
- greeted = true;
48
- socket.write(`CONNECT {"verbose":false,"pedantic":false,"tls_required":false,"name":"harbour-cli","lang":"node","version":"1"}\r\n`);
49
- socket.write(`SUB ${inbox} 1\r\n`);
50
- socket.write(`PUB ${subject} ${inbox} ${Buffer.byteLength(payload)}\r\n${payload}\r\n`);
51
- buffer = buffer.slice(buffer.indexOf("\r\n") + 2);
52
- }
53
- if (buffer.startsWith("PING\r\n")) {
54
- socket.write("PONG\r\n");
55
- buffer = buffer.slice(6);
56
- }
57
- const message = readMessage(buffer);
58
- if (message)
59
- finish(undefined, message);
60
- });
61
- });
62
- }
63
- /** One `MSG <subject> <sid> <bytes>\r\n<payload>\r\n`, once its payload has arrived. */
64
- export function readMessage(buffer) {
65
- const start = buffer.indexOf("MSG ");
66
- if (start < 0)
67
- return undefined;
68
- const headerEnd = buffer.indexOf("\r\n", start);
69
- if (headerEnd < 0)
70
- return undefined;
71
- const parts = buffer.slice(start + 4, headerEnd).trim().split(/\s+/);
72
- const bytes = Number(parts.at(-1));
73
- if (!Number.isFinite(bytes))
74
- return undefined;
75
- const payloadEnd = headerEnd + 2 + bytes;
76
- if (buffer.length < payloadEnd)
77
- return undefined;
78
- return buffer.slice(headerEnd + 2, payloadEnd);
79
- }