@fourier-labs/harbour 0.1.13 → 0.1.14

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.
@@ -0,0 +1,151 @@
1
+ import { basename } from "node:path";
2
+ import { linkIdempotencyKey, OPERATIONS, readDeclaration, readKitLock, requestResources, writeKitLock, newKitLock } from "./kit.js";
3
+ import { CliError } from "./output.js";
4
+ export class GovernanceClient {
5
+ apiUrl;
6
+ token;
7
+ tenantId;
8
+ fetchImpl;
9
+ constructor(apiUrl, token, tenantId, fetchImpl = fetch) {
10
+ this.apiUrl = apiUrl;
11
+ this.token = token;
12
+ this.tenantId = tenantId;
13
+ this.fetchImpl = fetchImpl;
14
+ }
15
+ link(input) {
16
+ return this.call("POST", "/v1/development/apps/link", { tenantId: this.tenantId, ...input });
17
+ }
18
+ request(appId, input) {
19
+ return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integration-requests`, { tenantId: this.tenantId, ...input });
20
+ }
21
+ list(appId) {
22
+ return this.call("GET", `/v1/development/apps/${encodeURIComponent(appId)}/integrations?tenantId=${encodeURIComponent(this.tenantId)}`);
23
+ }
24
+ execute(appId, body) {
25
+ return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integrations/execute`, body);
26
+ }
27
+ async call(method, path, body) {
28
+ const response = await this.fetchImpl(`${this.apiUrl.replace(/\/$/, "")}${path}`, { method, headers: { authorization: `Bearer ${this.token}`, "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" });
29
+ const parsed = await response.json().catch(() => ({}));
30
+ if (response.status === 401)
31
+ throw new CliError("AUTH_REQUIRED", "Please sign in to Harbour with `harbour login`.");
32
+ if (!response.ok)
33
+ throw new CliError(parsed.error?.details?.code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message ?? "Harbour governance rejected the request.");
34
+ return (parsed.data ?? parsed);
35
+ }
36
+ }
37
+ /** Links the app once (stable idempotency key) and records the appId in kit.lock. */
38
+ export async function ensureLinkedApp(root, client, tenantId, bundle) {
39
+ const lock = (await readKitLock(root)) ?? newKitLock(bundle, tenantId);
40
+ if (lock.appId)
41
+ return lock.appId;
42
+ const linked = await client.link({ displayName: basename(root), idempotencyKey: linkIdempotencyKey(tenantId, root) });
43
+ if (!linked.appId)
44
+ throw new CliError("LINK_FAILED", "Harbour did not return an app identity for this app.");
45
+ await writeKitLock(root, { ...lock, appId: linked.appId, tenantId });
46
+ return linked.appId;
47
+ }
48
+ /**
49
+ * One request per identity mode from the declaration's scope (or the named subset).
50
+ * Polls the grant list for up to 30 s; PENDING is reported as pending, never as ready.
51
+ */
52
+ export async function requestIntegrations(root, client, tenantId, bundle, options) {
53
+ const { declaration, errors } = await readDeclaration(root);
54
+ if (errors.length)
55
+ throw new CliError("DECLARATION_INVALID", `.harbour/integrations.json is invalid: ${errors[0]}`);
56
+ const environment = options.environment ?? "development";
57
+ if (!["development", "preview", "production"].includes(environment))
58
+ throw new CliError("USAGE", "--environment must be development, preview or production.");
59
+ if (!options.reason.trim())
60
+ throw new CliError("USAGE", "--reason <text> is required.");
61
+ if (options.expiresAt && Number.isNaN(Date.parse(options.expiresAt)))
62
+ throw new CliError("USAGE", "--expires-at must be an ISO-8601 UTC timestamp.");
63
+ const scope = requestScope(declaration, options.connection, options.operations);
64
+ const appId = await ensureLinkedApp(root, client, tenantId, bundle);
65
+ const submitted = await Promise.all(scope.map(async (part) => ({ ...part, ...await client.request(appId, { connection: options.connection, environment, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), reason: options.reason }) })));
66
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
67
+ const pollMs = options.pollMs ?? 3_000;
68
+ let grants = [];
69
+ for (let waited = 0; submitted.some(item => item.state === "PENDING") && waited < 30_000; waited += pollMs) {
70
+ await sleep(pollMs);
71
+ grants = (await client.list(appId)).grants;
72
+ for (const item of submitted) {
73
+ const grant = grants.find(candidate => candidate.grantId === item.grantId);
74
+ if (grant?.readiness === "ready")
75
+ item.state = "READY";
76
+ }
77
+ }
78
+ return { appId, connection: options.connection, environment, requests: submitted.map(item => ({ identityMode: item.identityMode, operations: item.operations, resources: item.resources, requestId: item.requestId, grantId: item.grantId, state: item.state, readiness: grants.find(grant => grant.grantId === item.grantId)?.readiness ?? (item.state === "READY" ? "ready" : item.state === "PENDING" ? "pending" : "denied") })) };
79
+ }
80
+ /**
81
+ * `productionise` pre-check: the preview deploy holds until every declared
82
+ * connection has a GRANTED, unexpired preview grant per identity mode, so the
83
+ * CLI checks first and names the exact request to make. Links the app when
84
+ * needed (same idempotency key as `integrations request`) so both commands
85
+ * share one appId. Returns the kit appId, or undefined for a non-kit app.
86
+ */
87
+ export async function assertPreviewIntegrationsReady(root, client, tenantId, bundle, output) {
88
+ const lock = await readKitLock(root);
89
+ const { declaration, errors } = await readDeclaration(root);
90
+ if (errors.length) {
91
+ if (errors[0].startsWith(".harbour/integrations.json is missing"))
92
+ return lock?.appId || undefined;
93
+ throw new CliError("DECLARATION_INVALID", `.harbour/integrations.json is invalid: ${errors[0]}`);
94
+ }
95
+ const connections = Object.keys(declaration.connections);
96
+ if (!connections.length)
97
+ return lock?.appId || undefined;
98
+ const appId = await ensureLinkedApp(root, client, tenantId, bundle);
99
+ const grants = (await client.list(appId)).grants;
100
+ const nowMs = Date.now();
101
+ const missing = [];
102
+ for (const connection of connections) {
103
+ for (const { identityMode } of requestScope(declaration, connection)) {
104
+ const grant = grants.find(candidate => candidate.connection === connection && candidate.environment === "preview" && candidate.identityMode === identityMode);
105
+ const why = !grant ? "no preview grant requested"
106
+ : grant.status !== "GRANTED" ? `preview grant is ${grant.status}${grant.readiness === "pending" ? " (waiting for IT)" : ""}`
107
+ : grant.expiresAt && Date.parse(grant.expiresAt) <= nowMs ? `preview grant expired ${grant.expiresAt}`
108
+ : grant.readiness === "pending" || grant.readiness === "failed" ? `preview grant is ${grant.readiness}`
109
+ : undefined;
110
+ if (why)
111
+ missing.push({ connection, identityMode, why });
112
+ }
113
+ }
114
+ if (!missing.length)
115
+ return appId;
116
+ const unresolved = [...new Set(missing.map(item => item.connection))];
117
+ output(`Harbour cannot deploy the preview yet: ${missing.length} integration grant${missing.length === 1 ? " is" : "s are"} missing.`);
118
+ for (const item of missing)
119
+ output(` ${item.connection} (${item.identityMode} identity): ${item.why}`);
120
+ output("Request each one, then rerun productionise:");
121
+ for (const connection of unresolved)
122
+ output(` harbour integrations request ${connection} --environment preview --reason "<why>" --app-root ${root}`);
123
+ throw new CliError("INTEGRATIONS_NOT_READY", `Preview grants are missing for ${unresolved.join(", ")}. Run the \`harbour integrations request … --environment preview\` commands above, then rerun productionise.`);
124
+ }
125
+ /** Splits the declared operations of one connection by identity mode. */
126
+ export function requestScope(declaration, connection, only) {
127
+ const declared = declaration.connections[connection];
128
+ if (!declared)
129
+ throw new CliError("CONNECTION_NOT_DECLARED", `${connection} is not declared in .harbour/integrations.json.`);
130
+ const names = (only?.length ? only : Object.keys(declared.operations));
131
+ const unknown = names.filter(name => !declared.operations[name]);
132
+ if (unknown.length)
133
+ throw new CliError("OPERATION_NOT_DECLARED", `${unknown.join(", ")} not declared for ${connection}.`);
134
+ const byMode = new Map();
135
+ for (const name of names) {
136
+ const mode = OPERATIONS[name].identity;
137
+ const entry = byMode.get(mode) ?? { operations: [], resources: new Map() };
138
+ entry.operations.push(name);
139
+ for (const resource of requestResources(declared.operations[name]))
140
+ entry.resources.set(typeof resource === "string" ? resource : resource.name, resource);
141
+ byMode.set(mode, entry);
142
+ }
143
+ return [...byMode.entries()].map(([identityMode, entry]) => ({ identityMode, operations: entry.operations, resources: [...entry.resources.keys()].sort().map(key => entry.resources.get(key)) }));
144
+ }
145
+ export async function integrationsStatus(root, client) {
146
+ const lock = await readKitLock(root);
147
+ if (!lock?.appId)
148
+ return { linked: false, grants: [], requests: [] };
149
+ const listed = await client.list(lock.appId);
150
+ return { appId: lock.appId, linked: true, grants: listed.grants.map(grant => ({ connection: grant.connection, environment: grant.environment, identityMode: grant.identityMode, status: grant.status, operations: grant.operations, resources: grant.resources, expiresAt: grant.expiresAt, readiness: grant.readiness, pendingRequestId: grant.pendingRequestId })), requests: listed.requests };
151
+ }
@@ -0,0 +1,63 @@
1
+ import { readFile } from "node:fs/promises";
2
+ /**
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.
10
+ */
11
+ export const EMBEDDED_KIT_BUNDLE = {
12
+ schema: "harbour.kit-bundle/1.0",
13
+ kitVersion: "0.1.14",
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:387f51c7a824fc839044a2582fc3271acf2ff2b704d9ca6bb6d1edb06a845def",
22
+ sessionFixture: "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:a64025be110e592927b5c2a1ef5312e24624208e9427e74deb3873453512b0ca",
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
+ }
46
+ export function isKitBundle(value) {
47
+ const record = value;
48
+ return Boolean(record && typeof record === "object" && record.schema === "harbour.kit-bundle/1.0" && typeof record.kitVersion === "string"
49
+ && record.sdk && typeof record.sdk.package === "string" && typeof record.sdk.tarballSha256 === "string"
50
+ && record.images && typeof record.images.appGateway === "string" && typeof record.images.sessionFixture === "string"
51
+ && record.brief && typeof record.brief.fingerprint === "string" && record.declarationSchema === "harbour.app-integrations/2.0");
52
+ }
53
+ /** Lines naming every digest that differs between two manifests, for `init --upgrade`. */
54
+ export function bundleDiff(before, after) {
55
+ const fields = [
56
+ ["kitVersion", before.kitVersion, after.kitVersion],
57
+ ["sdk.tarballSha256", before.sdk.tarballSha256, after.sdk.tarballSha256],
58
+ ["images.appGateway", before.images.appGateway, after.images.appGateway],
59
+ ["images.sessionFixture", before.images.sessionFixture, after.images.sessionFixture],
60
+ ["brief.fingerprint", before.brief.fingerprint, after.brief.fingerprint]
61
+ ];
62
+ return fields.filter(([, a, b]) => a !== b).map(([name, a, b]) => `${name}: ${a ?? "(none)"} -> ${b ?? "(none)"}`);
63
+ }
@@ -0,0 +1,167 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, parse, resolve } from "node:path";
5
+ import { scanWorkspace } from "../../../src/analyzer.js";
6
+ import { isKitBundle } from "./kit-bundle.js";
7
+ import { CliError } from "./output.js";
8
+ /** Closed set: each operation has exactly one identity mode and one connection kind. Gmail is read-only and user-mode only. */
9
+ export const OPERATIONS = {
10
+ "slack.channel.history": { identity: "user", kind: "saas" },
11
+ "slack.message.post": { identity: "app", kind: "saas" },
12
+ "gmail.thread.list": { identity: "user", kind: "saas" },
13
+ "gmail.message.read": { identity: "user", kind: "saas" },
14
+ "warehouse.view.read": { identity: "app", kind: "database" }
15
+ };
16
+ /** Explicit reads `harbour check --integrations` may run; every other operation is a send and never runs during checks. */
17
+ export const READ_OPERATIONS = ["slack.channel.history", "gmail.thread.list", "warehouse.view.read"];
18
+ /** Reads whose input needs an identifier from a prior read (a message id): listed as reads, exercised only through the list. */
19
+ export const DEPENDENT_READ_OPERATIONS = ["gmail.message.read"];
20
+ const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
21
+ /** Validates the committed declaration; every error names the path that broke the closed rules. */
22
+ export function validateDeclaration(value) {
23
+ const errors = [];
24
+ const record = (value ?? {});
25
+ if (!value || typeof value !== "object" || Array.isArray(value))
26
+ return { declaration: emptyDeclaration(), errors: ["declaration must be a JSON object"] };
27
+ if (record.schema !== "harbour.app-integrations/2.0")
28
+ errors.push("schema must be harbour.app-integrations/2.0");
29
+ const connections = record.connections && typeof record.connections === "object" && !Array.isArray(record.connections) ? record.connections : undefined;
30
+ if (!connections)
31
+ errors.push("connections must be an object keyed by connection alias");
32
+ for (const [alias, connection] of Object.entries(connections ?? {})) {
33
+ if (!LOGICAL_NAME.test(alias))
34
+ errors.push(`connections.${alias}: alias must be a lowercase logical name`);
35
+ if (!connection || typeof connection !== "object") {
36
+ errors.push(`connections.${alias}: must be an object`);
37
+ continue;
38
+ }
39
+ if (connection.kind !== "saas" && connection.kind !== "database")
40
+ errors.push(`connections.${alias}.kind: must be saas or database`);
41
+ const operations = connection.operations && typeof connection.operations === "object" ? connection.operations : undefined;
42
+ if (!operations || !Object.keys(operations).length) {
43
+ errors.push(`connections.${alias}.operations: at least one operation is required`);
44
+ continue;
45
+ }
46
+ for (const [name, operation] of Object.entries(operations)) {
47
+ const path = `connections.${alias}.operations.${name}`;
48
+ const rule = OPERATIONS[name];
49
+ if (!rule) {
50
+ errors.push(`${path}: unsupported operation (allowed: ${Object.keys(OPERATIONS).join(", ")})`);
51
+ continue;
52
+ }
53
+ if (rule.kind !== connection.kind)
54
+ errors.push(`${path}: ${name} belongs to a ${rule.kind} connection`);
55
+ if (operation?.identity !== rule.identity)
56
+ errors.push(`${path}.identity: ${name} is always ${rule.identity}`);
57
+ const resources = operation?.resources;
58
+ if (name === "warehouse.view.read") {
59
+ if (!resources || Array.isArray(resources) || typeof resources !== "object" || !Object.keys(resources).length)
60
+ errors.push(`${path}.resources: must map view names to {columns}`);
61
+ else
62
+ for (const [view, spec] of Object.entries(resources)) {
63
+ if (!LOGICAL_NAME.test(view))
64
+ errors.push(`${path}.resources.${view}: view name must be a logical name`);
65
+ if (!spec || !Array.isArray(spec.columns) || !spec.columns.length || spec.columns.length > 32 || spec.columns.some(column => typeof column !== "string" || !LOGICAL_NAME.test(column)))
66
+ errors.push(`${path}.resources.${view}.columns: 1..32 column names are required`);
67
+ }
68
+ }
69
+ else if (!Array.isArray(resources) || !resources.length || resources.some(resource => typeof resource !== "string" || !LOGICAL_NAME.test(resource)))
70
+ errors.push(`${path}.resources: must be a non-empty list of logical resource names (no IDs, tokens or URLs)`);
71
+ }
72
+ }
73
+ return { declaration: record, errors };
74
+ }
75
+ export function emptyDeclaration() { return { schema: "harbour.app-integrations/2.0", connections: {} }; }
76
+ export function resourceNames(operation) { return Array.isArray(operation.resources) ? operation.resources : Object.keys(operation.resources); }
77
+ export function requestResources(operation) {
78
+ return Array.isArray(operation.resources) ? operation.resources : Object.entries(operation.resources).map(([name, spec]) => ({ name, columns: [...spec.columns] }));
79
+ }
80
+ export async function readDeclaration(root) {
81
+ let raw;
82
+ try {
83
+ raw = await readFile(kitPaths(root).declaration, "utf8");
84
+ }
85
+ catch {
86
+ return { declaration: emptyDeclaration(), errors: [".harbour/integrations.json is missing (run `harbour init`)"] };
87
+ }
88
+ try {
89
+ return validateDeclaration(JSON.parse(raw));
90
+ }
91
+ catch {
92
+ return { declaration: emptyDeclaration(), errors: [".harbour/integrations.json is not valid JSON"] };
93
+ }
94
+ }
95
+ export function isKitLock(value) {
96
+ const record = value;
97
+ return Boolean(record && typeof record === "object" && record.schema === "harbour.kit-lock/1.0" && typeof record.appId === "string" && typeof record.tenantId === "string" && isKitBundle(record.bundle) && typeof record.createdAt === "string" && typeof record.updatedAt === "string");
98
+ }
99
+ export async function readKitLock(root) {
100
+ let raw;
101
+ try {
102
+ raw = await readFile(kitPaths(root).lock, "utf8");
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(raw);
110
+ }
111
+ catch {
112
+ throw new CliError("KIT_LOCK_INVALID", ".harbour/kit.lock.json is not valid JSON.");
113
+ }
114
+ if (!isKitLock(parsed))
115
+ throw new CliError("KIT_LOCK_INVALID", ".harbour/kit.lock.json does not match harbour.kit-lock/1.0.");
116
+ return parsed;
117
+ }
118
+ export async function writeKitLock(root, lock) {
119
+ const next = { ...lock, updatedAt: new Date().toISOString() };
120
+ await mkdir(dirname(kitPaths(root).lock), { recursive: true });
121
+ await writeFile(kitPaths(root).lock, `${JSON.stringify(next, null, 2)}\n`);
122
+ return next;
123
+ }
124
+ /** Records the app identity Harbour settled on; a non-kit app (no kit.lock) is left alone. */
125
+ export async function recordKitAppId(root, appId, tenantId) {
126
+ const lock = await readKitLock(root);
127
+ if (!lock || (lock.appId === appId && lock.tenantId === tenantId))
128
+ return;
129
+ await writeKitLock(root, { ...lock, appId, tenantId });
130
+ }
131
+ export function newKitLock(bundle, tenantId = "", appId = "") {
132
+ const now = new Date().toISOString();
133
+ return { schema: "harbour.kit-lock/1.0", appId, tenantId, bundle, createdAt: now, updatedAt: now };
134
+ }
135
+ // ---- Paths and identity --------------------------------------------------------
136
+ export function appRoot(rootArg) {
137
+ if (!rootArg)
138
+ throw new CliError("USAGE", "--app-root <path> is required.");
139
+ const root = resolve(rootArg);
140
+ if (root === parse(root).root || root === resolve(homedir()))
141
+ throw new CliError("PREFLIGHT_APP_ROOT", "The selected app root is unsafe.");
142
+ return root;
143
+ }
144
+ export function kitPaths(root) {
145
+ const harbour = join(root, ".harbour");
146
+ const local = join(harbour, "local");
147
+ return { harbour, local, declaration: join(harbour, "integrations.json"), lock: join(harbour, "kit.lock.json"), checks: join(harbour, "checks"), compose: join(local, "compose.yml"), gatewayConfig: join(local, "app-gateway.json"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json") };
148
+ }
149
+ /** Stable per-project identity for Compose projects and volumes: `harbour-<12 hex of sha256(abs root)>`. */
150
+ export function projectName(root, suffix = "") {
151
+ return `harbour-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 12)}${suffix}`;
152
+ }
153
+ /** Governance link idempotency key: sha256(tenantId + absolute app root). */
154
+ export function linkIdempotencyKey(tenantId, root) {
155
+ return createHash("sha256").update(`${tenantId}${resolve(root)}`).digest("hex");
156
+ }
157
+ /** sha256 over the tracked app files (same boundary as productionise), so any edit changes it. */
158
+ export async function sourceDigest(root) {
159
+ const graph = await scanWorkspace(root, { sourceBoundary: "root" });
160
+ const files = [...graph.deploymentScope.includedFiles].sort();
161
+ const hash = createHash("sha256");
162
+ for (const path of files) {
163
+ hash.update(path).update("\0");
164
+ hash.update(await readFile(join(root, path))).update("\0");
165
+ }
166
+ return { digest: `sha256:${hash.digest("hex")}`, files };
167
+ }