@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.
- package/dist/packages/harbour-cli/src/check.js +13 -5
- package/dist/packages/harbour-cli/src/cli.js +4 -4
- package/dist/packages/harbour-cli/src/database-gate.js +21 -13
- package/dist/packages/harbour-cli/src/dev.js +9 -14
- package/dist/packages/harbour-cli/src/forwarder.js +7 -2
- package/dist/packages/harbour-cli/src/kit-bundle.js +15 -43
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +18 -0
- package/dist/packages/harbour-cli/src/local-runtime.js +58 -72
- package/dist/packages/harbour-cli/src/starter.js +60 -9
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +31 -6
- package/dist/packages/harbour-cli/src/realtime-stream.js +0 -79
|
@@ -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
|
|
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
|
|
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 {
|
|
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 =
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
25
|
-
|
|
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
|
|
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
|
|
42
|
+
return undefined;
|
|
35
43
|
const sql = await readFile(target, "utf8").catch(() => undefined);
|
|
36
|
-
return sql && sql.includes("harbour database gate") ?
|
|
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,
|
|
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
|
|
38
|
-
// migration together, so waiting for the session also
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
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
|
|
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,
|
|
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/") ?
|
|
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 {
|
|
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
|
|
4
|
-
* harbour-deployment-data-plane `publish-kit-bundle`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 }
|
|
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
|
-
/**
|
|
126
|
-
|
|
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.
|
|
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
|
-
//
|
|
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,
|
|
164
|
-
return { postgres: postgres, minio: minio,
|
|
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
|
|
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}
|
|
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
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
299
|
+
if (attempts > 1)
|
|
300
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
323
301
|
}
|
|
324
|
-
throw new CliError("LOCAL_RUNTIME_FAILED", "The local
|
|
302
|
+
throw new CliError("LOCAL_RUNTIME_FAILED", "The local Harbour gateway did not publish a session.");
|
|
325
303
|
}
|
|
326
304
|
}
|
|
327
|
-
/** The
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
const harbour = sdk.createClient({ baseUrl: appUrl
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
const harbour = sdk.createClient({ baseUrl: appUrl
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "Harbour productionisation helper",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
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
|
-
}
|