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