@fourier-labs/harbour 0.1.47 → 0.1.48

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/README.md CHANGED
@@ -33,7 +33,7 @@ harbour dev --app-root <path> [--reset] run the app locally o
33
33
  harbour stop --app-root <path> stop local services, keep data
34
34
  harbour check --app-root <path> [--integrations] [--json] declaration, types, build, migrations, journeys; report in .harbour/local/check-report.json
35
35
  harbour integrations catalog --app-root <path> [--json] the company's connections as .harbour/integrations.json names them, with approved channels/views per environment
36
- harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>]
36
+ harbour integrations request <connection> --app-root <path> [--reason <text>] one request per connection; IT approves it once for every environment (`harbour dev` and `harbour productionise` file it for you)
37
37
  harbour integrations status --app-root <path> [--json]
38
38
  harbour connect <work-email-or-start-url> once per company; then harbour login | logout
39
39
  harbour productionise --app-root <path> [--wait] [--json] save + preview deployment, prints the protected link
@@ -195,9 +195,9 @@ The person you are working with may not be a developer. They say what they want
195
195
  - "run it", "show me", "let me try it" → start \`harbour dev --app-root .\` in the background (it keeps running; the first start downloads the native runtime 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. Do this unasked as soon as the first check is green — they should always have the link. Locally they are a fixture user; no company sign-in is needed.
196
196
  - "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.
197
197
  - "does it work?", and before you report anything as working → open the dev link in your own browser when you have one, press the control you built or changed, and read what the app shows. A green \`harbour check\` is not that proof: it answers governed AI and company systems from fixtures, so the refusals that matter (a field the company's AI route does not accept, a consent the operation does not need, a channel that is not approved) appear only when the control is really pressed. Test rendering, navigation, fixtures and approved reads automatically. In development a Send sends a real email or Slack message: reuse explicit authorization for that bounded test, or ask once if none exists; IT access approval alone is not permission to send. Never ask again for the same authorized test. Before a real integration test, run \`harbour integrations status --app-root . --json\`; explain pending IT approval or missing personal consent before pressing Send, and test the ready parts independently. If you have no browser, say that the button itself is untested.
198
- - "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. Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Before writing a warehouse query or mapping response fields, run \`harbour integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly. If the required object or column is absent, report that catalog gap instead of substituting a plausible name. A warehouse resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`; inspect the returned row keys before mapping them and do not invent a friendlier schema or ask the person to get IT to confirm one. Declare only connections and operations the app really calls, then submit both development and preview requests yourself in the same turn with \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` and the same command with \`--environment preview\`. Never tell the person to ask IT before you have submitted the request. 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 named resource is not on the connection yet: IT adds it in the Harbour console under Controls & integrations, and then you run the same request again. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
198
+ - "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. Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Before writing a warehouse query or mapping response fields, run \`harbour integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly. If the required object or column is absent, report that catalog gap instead of substituting a plausible name. A warehouse resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`; inspect the returned row keys before mapping them and do not invent a friendlier schema or ask the person to get IT to confirm one. Declare only connections and operations the app really calls, then submit the request yourself in the same turn with \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` — one request per connection; IT approves it once for every environment (development, preview and production together), and \`harbour dev\` and \`harbour productionise\` file it for you as well, so there is never a second request to make before shipping. Never tell the person to ask IT before you have submitted the request. 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\` (development may already read ready while preview and production wait: that is the company's development preapproval, and the one approval covers the rest). A refusal with \`RESOURCE_NOT_APPROVED\` means the named resource is not on the connection yet: IT adds it in the Harbour console under Controls & integrations, and then you run the same request again. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
199
199
  - "summarise", "draft", "explain", "AI" → one \`harbour.ai.chat\` call (on the \`harbour\` client from \`src/harbour.client.ts\`, never through a wrapper function) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`harbour check\` writes its journey. Send \`messages\` and \`maxTokens\` and nothing else: a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. 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.
200
- - "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. Before sharing or deploying the private preview, show the person the exact proposed app name, description and audience that will be passed to Harbour (say "only you" when the audience is empty) and ask for one confirmation or correction covering all three; do not run \`productionise\` until they confirm. Pass that confirmed setup in the same command: \`harbour productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --wait --json\`; omit \`--emails\` for "only you". The command records the confirmed setup before uploading or deploying the app. Then each declared connection needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`); when the app calls governed AI, \`productionise\` also asks whether the company's AI setup is ready and refuses with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without AI until then. The command gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they confirmed, open after company sign-in. If your tool cuts the command off before it finishes, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened. For kit apps, describe \`TRANSFORMING\` as building and checking the app; it does not mean a transformation AI is running. The CLI saves \`operationRef\` in \`.harbour/local/productionise.json\`; repeating \`productionise\` continues that operation. 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.
200
+ - "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. Before sharing or deploying the private preview, show the person the exact proposed app name, description and audience that will be passed to Harbour (say "only you" when the audience is empty) and ask for one confirmation or correction covering all three; do not run \`productionise\` until they confirm. Pass that confirmed setup in the same command: \`harbour productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --wait --json\`; omit \`--emails\` for "only you". The command records the confirmed setup before uploading or deploying the app. It files the access request for each declared connection itself and refuses with \`INTEGRATIONS_NOT_READY\` naming what IT still has to approve — say that line and nothing more, and ship again once IT has approved it (one approval covers preview and production); when the app calls governed AI, \`productionise\` also asks whether the company's AI setup is ready and refuses with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without AI until then. The command gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they confirmed, open after company sign-in. If your tool cuts the command off before it finishes, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened. For kit apps, describe \`TRANSFORMING\` as building and checking the app; it does not mean a transformation AI is running. The CLI saves \`operationRef\` in \`.harbour/local/productionise.json\`; repeating \`productionise\` continues that operation. 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.
201
201
  - "make it live for everyone", "go to production" → only after they have tried the preview. Before \`harbour promote --operation <ref> --json\`, show the exact app name, description and production audience again and ask for one confirmation or correction covering all three; never promote a profile or audience the person has not just seen and confirmed. Then promote with the operation reference from productionise. Report the production link, or that an operator approval is pending.
202
202
  - "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.
203
203
  - "every day at 2pm", "run this on a schedule", "send this automatically" → create one \`jobs/<name>.ts\` file with a literal UTC cron and one default async handler. Start \`harbour dev\`, then run it immediately with \`harbour jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\`; this checks the job against local data and safe fixtures. If the person explicitly asks to test the company action now, use \`--real\` only after the exact action has development approval. Tell them: "Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved." Never use \`setInterval\`, an effect or a browser timer as a scheduler; Harbour runs the same declaration automatically after deployment.
@@ -50,7 +50,7 @@ export async function runChecks(root, options) {
50
50
  const lock = await readKitLock(root).catch(() => undefined);
51
51
  const { declaration, errors } = await readDeclaration(root);
52
52
  if (!lock?.appId)
53
- skipped("the app is not linked yet; `harbour integrations request` links it");
53
+ skipped("the app is not linked yet; `harbour dev` (signed in) or `harbour integrations request` links it");
54
54
  else if (errors.length)
55
55
  skipped(`.harbour/integrations.json is invalid: ${errors[0]}`);
56
56
  else {
@@ -7,13 +7,13 @@ 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
9
  import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
10
- import { appRoot, readKitLock } from "./kit.js";
10
+ import { appRoot, readKitLock, requestResourceName } from "./kit.js";
11
11
  import { initKit } from "./starter.js";
12
12
  import { agentPaths, agentSetup, cliVersionLines } from "./agent-setup.js";
13
13
  import { startDev } from "./dev.js";
14
14
  import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
15
15
  import { runChecks } from "./check.js";
16
- import { assertAiReady, GovernanceClient, integrationsCatalog, integrationsStatus, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
16
+ import { assertAiReady, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
17
17
  import { runJob } from "./jobs.js";
18
18
  const args = process.argv.slice(2);
19
19
  const command = args[0];
@@ -34,9 +34,7 @@ const reset = args.includes("--reset");
34
34
  const upgrade = args.includes("--upgrade");
35
35
  const testIntegrations = args.includes("--integrations");
36
36
  const reason = optionValue("--reason");
37
- const environment = optionValue("--environment");
38
37
  const operations = optionValue("--operations");
39
- const expiresAt = optionValue("--expires-at");
40
38
  const scheduledAt = optionValue("--scheduled-at");
41
39
  const realJob = args.includes("--real");
42
40
  /** Seconds `productionise`, `status --wait`, `retry` and `promote` follow the operation before reporting it as still running (default: 30 minutes). */
@@ -80,13 +78,13 @@ const usage = [
80
78
  " harbour stop --app-root <path> stop this app's local services, keeping its database and files",
81
79
  " 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)",
82
80
  " harbour jobs run <name> --app-root <path> [--scheduled-at <UTC>] [--real] [--json] run one scheduled job now (local fixtures; --real uses approved company access)",
83
- " harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>] [--operations a,b] [--expires-at <UTC>] [--json]",
81
+ " harbour integrations request <connection> --app-root <path> [--reason <text>] [--operations a,b] [--json] one request per connection, for every environment at once: IT approves it once (harbour dev and productionise file it for you)",
84
82
  " harbour integrations status --app-root <path> [--json]",
85
83
  " harbour integrations catalog --app-root <path> [--json] the company's connections as .harbour/integrations.json names them: identifiers, allowed operations, approved channels/tables/views/mailboxes per environment (no app needed)",
86
84
  "Run `harbour connect <work-email-or-start-url>` once, then sign in when Harbour asks.",
87
85
  "productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
88
86
  `--max-wait bounds how long productionise, status --wait, retry and promote follow the deployment (default 30 minutes). When it passes the command exits 0 with status RUNNING, the last known deployment state, and the \`${continueCommand("<reference>")}\` that continues the same deployment: a bounded wait is not a failure and never means start another one.`,
89
- "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, asks governance whether the company's AI setup is ready when the app calls governed AI (exit 2, AI_NOT_READY, with the IT step), then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse. promote asks the same AI question for production when --app-root names the app.",
87
+ "For kit apps, productionise first files the access request for every connection in .harbour/integrations.json (one per connection; IT approves it once for every environment) and exits 2 (INTEGRATIONS_NOT_READY) naming what IT still has to approve, asks governance whether the company's AI setup is ready when the app calls governed AI (exit 2, AI_NOT_READY, with the IT step), then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse. promote asks the same AI question for production when --app-root names the app.",
90
88
  "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.",
91
89
  ""
92
90
  ].join("\n");
@@ -123,7 +121,7 @@ else if (!["connect", "login", "logout", "productionise", "integrations", ...LOC
123
121
  || (command === "productionise" && !root)
124
122
  || (LOCAL_COMMANDS.includes(command) && !root)
125
123
  || (command === "jobs" && (subcommand !== "run" || !args[2] || args[2].startsWith("--")))
126
- || (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--") || !reason))))
124
+ || (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--")))))
127
125
  || (OPERATION_COMMANDS.includes(command) && !operationRef)
128
126
  || (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
129
127
  if (optionError)
@@ -202,7 +200,7 @@ else {
202
200
  emit(summaryEnvelope(result));
203
201
  }
204
202
  else {
205
- const started = await startDev(target, { bundle, output: progress, reset, company: config ? { apiUrl: config.apiUrl, tenantId: config.tenantId, accessToken: companyToken, account: async () => { const token = await companyToken(); return token ? connectedAccount(config.mcpUrl, config.tenantId, token) : undefined; } } : undefined });
203
+ const started = await startDev(target, { bundle, output: progress, reset, company: config ? { apiUrl: config.apiUrl, tenantId: config.tenantId, accessToken: companyToken, account: token => connectedAccount(config.mcpUrl, config.tenantId, token) } : undefined });
206
204
  let stopping = false;
207
205
  const shutdown = () => { if (stopping)
208
206
  return; stopping = true; progress("Stopping local Harbour services (data kept)."); void started.stop().finally(() => process.exit(0)); };
@@ -242,13 +240,13 @@ else {
242
240
  ? await integrationsStatus(target, governance)
243
241
  : subcommand === "catalog"
244
242
  ? await integrationsCatalog(governance)
245
- : await requestIntegrations(target, governance, tenant, EMBEDDED_KIT_BUNDLE, { connection: args[2], reason: reason, environment, operations: operations?.split(",").map(value => value.trim()).filter(Boolean), expiresAt });
243
+ : await requestIntegrations(target, governance, tenant, EMBEDDED_KIT_BUNDLE, { connection: args[2], ...(reason ? { reason } : {}), operations: operations?.split(",").map(value => value.trim()).filter(Boolean) });
246
244
  envelope = summaryEnvelope(result);
247
245
  if (!json)
248
246
  progress(subcommand === "status" ? renderIntegrationsStatus(result) : subcommand === "catalog" ? renderIntegrationsCatalog(result) : renderRequest(result));
249
247
  }
250
248
  else if (command === "productionise") {
251
- // 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.
249
+ // Kit apps: the access requests are filed and the preview lane checked (and the app linked) before any operation starts, so productionise never mints a second app for the same root.
252
250
  const confirmedAppSetup = profileName && profileDescription
253
251
  ? { displayName: profileName, description: profileDescription, audienceEmails: audienceEmails ? audienceEmails.split(",").map(value => value.trim()).filter(Boolean) : [] }
254
252
  : undefined;
@@ -297,23 +295,19 @@ else {
297
295
  process.exit(envelope.error?.code === "INTEGRATIONS_NOT_READY" || envelope.error?.code === "AI_NOT_READY" ? 2 : 1);
298
296
  }
299
297
  }
298
+ /** One line per (connection, identity): the three lanes are one approval, so they are read together. */
300
299
  function renderIntegrationsStatus(status) {
301
300
  if (!status.linked)
302
- return "This app is not linked yet; `harbour integrations request` links it.";
303
- const lines = [`App ${status.appId}`];
304
- for (const grant of status.grants) {
305
- lines.push(` ${grant.connection} [${grant.environment}, ${grant.identityMode}] ${grant.status} — ${grant.readiness}${grant.expiresAt ? ` until ${grant.expiresAt}` : ""}: ${grant.operations.join(", ")}`);
306
- if (grant.failure)
307
- lines.push(` ${grant.failure.message} ${grant.failure.remediation}`);
308
- }
301
+ return "This app is not linked yet; `harbour dev` (signed in) or `harbour integrations request` links it.";
302
+ const lines = [`App ${status.appId}`, ...groupGrants(status.grants).flatMap(group => renderGrantGroup(group).map(line => ` ${line}`))];
309
303
  for (const request of status.requests)
310
- lines.push(` request ${request.requestId}: ${request.state} (${request.reason})`);
304
+ lines.push(` request ${request.requestId}: ${request.state}${request.reason ? ` (${request.reason})` : ""}`);
311
305
  if (!status.grants.length && !status.requests.length)
312
306
  lines.push(" no grants or requests yet");
313
307
  return lines.join("\n");
314
308
  }
315
309
  function renderRequest(result) {
316
- return [`App ${result.appId}: ${result.connection} (${result.environment})`, ...result.requests.map(item => ` ${item.identityMode} identity — ${item.operations.join(", ")} on ${item.resources.join(", ")}${item.presentation ? ` (posts as "${item.presentation.displayName}")` : ""}: ${item.state === "READY" ? "READY" : item.state === "PENDING" ? "PENDING (not ready; IT approval or provider setup is outstanding)" : item.state}${item.readiness && item.readiness !== "ready" && item.readiness !== "pending" ? ` (${item.readiness})` : ""}`)].join("\n");
310
+ return [`App ${result.appId}: ${result.connection}`, ...result.requests.map(item => ` ${IDENTITY_WORDS[item.identityMode]} — ${item.operations.join(", ")} on ${item.resources.map(requestResourceName).join(", ")}${item.presentation ? ` (posts as "${item.presentation.displayName}")` : ""}: ${item.state} — ${item.summary}`)].join("\n");
317
311
  }
318
312
  function optionValue(flag) {
319
313
  const index = args.indexOf(flag);
@@ -1,9 +1,33 @@
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
+ import { GovernanceClient, ensureLinkedApp, fileDeclaredRequests, IDENTITY_WORDS, renderGrantGroup } from "./integrations.js";
5
5
  import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, nodePackageCommand, releaseDevLock, runCommand } from "./local-runtime.js";
6
- import { CliError } from "./output.js";
6
+ import { CliError, safeError } from "./output.js";
7
+ /**
8
+ * Signed in at startup: link the app and file the access request for every
9
+ * declared connection — one per connection, approved by IT once for every
10
+ * environment; the same filing `productionise` does — so IT's queue holds the
11
+ * ask from the first run and no command stands between the builder and the
12
+ * approval. Returns the banner lines saying where each connection stands.
13
+ * Never throws: `harbour dev` runs without company access.
14
+ */
15
+ export async function companyAccessAtStartup(root, client, tenantId, bundle) {
16
+ try {
17
+ const filed = await fileDeclaredRequests(root, client, tenantId, bundle);
18
+ // Nothing declared: still link, so governed AI has the app's identity from its first call.
19
+ if (!filed)
20
+ return { appId: await ensureLinkedApp(root, client, tenantId, bundle), lines: [] };
21
+ const lines = filed.groups.flatMap(group => renderGrantGroup(group));
22
+ for (const item of filed.requests)
23
+ if (item.error)
24
+ lines.push(`${item.connection} [${IDENTITY_WORDS[item.identityMode]}] not requested: ${item.error.message}`);
25
+ return { appId: filed.appId, lines };
26
+ }
27
+ catch (error) {
28
+ return { lines: [`Company access could not be checked (${safeError(error).message}); the app runs without it until the next start.`] };
29
+ }
30
+ }
7
31
  /**
8
32
  * Starts the kit-managed Postgres and App Gateway, applies migrations, then
9
33
  * starts Vite and the loopback origin. Stopping retains the app's local data.
@@ -71,16 +95,27 @@ export async function startDev(root, options) {
71
95
  const refreshLock = setInterval(() => { void readKitLock(root).then(lock => { lockAppId = lock?.appId || undefined; }).catch(() => undefined); }, 5_000);
72
96
  refreshLock.unref();
73
97
  await new Promise((resolve, reject) => { forwarder.on("error", reject); forwarder.listen(ports.origin, "127.0.0.1", resolve); });
74
- const account = company ? await company.account().catch(() => undefined) : undefined;
98
+ const token = company ? await company.accessToken().catch(() => undefined) : undefined;
99
+ const governance = company && token ? new GovernanceClient(company.apiUrl, token, company.tenantId) : undefined;
100
+ const account = company && token ? await company.account(token).catch(() => undefined) : undefined;
101
+ const linkedAtStart = lockAppId;
75
102
  options.output([
76
103
  "",
77
104
  `Harbour dev is running: ${origin}`,
78
105
  ` 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"}`,
79
106
  ` Company account for integrations: ${account ?? (company ? "not signed in — run `harbour login`" : "not connected — run `harbour connect`")}`,
80
- lockAppId ? ` Linked app: ${lockAppId}` : " App not linked yet (harbour integrations request links it).",
107
+ ...(linkedAtStart ? [` Linked app: ${linkedAtStart}`] : governance ? [] : [" App not linked yet: the next `harbour dev` after `harbour login` links it and files its access requests (so does `harbour integrations request`)."]),
81
108
  " Ctrl-C or `harbour stop` stops the services and keeps local data; `harbour dev --reset` deletes it.",
82
109
  ""
83
110
  ].join("\n"));
111
+ // Signed in: link now and file the app-wide requests — after the link is out, so governance's round-trips never hold it — and say where each connection stands.
112
+ if (governance && company) {
113
+ const access = await companyAccessAtStartup(root, governance, company.tenantId, options.bundle);
114
+ lockAppId = access.appId ?? lockAppId;
115
+ const lines = [...(access.appId && !linkedAtStart ? [`Linked app: ${access.appId}`] : []), ...access.lines];
116
+ if (lines.length)
117
+ options.output(lines.map(line => ` ${line}`).join("\n"));
118
+ }
84
119
  return { origin, stop };
85
120
  }
86
121
  catch (error) {
@@ -71,7 +71,7 @@ async function answerLocally(request, response, path, host, origin, options) {
71
71
  // Governed AI needs the app's identity for its trace; a signed-in builder's app is linked on first use.
72
72
  const appId = options.appId() ?? (ai && options.linkApp ? await options.linkApp().catch(() => undefined) : undefined);
73
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");
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: restart `harbour dev` now that you are signed in, or run `harbour integrations request <connection> --app-root .` once.", "APP_NOT_LINKED");
75
75
  return;
76
76
  }
77
77
  if (body === undefined) {
@@ -1,5 +1,5 @@
1
1
  import { basename } from "node:path";
2
- import { linkIdempotencyKey, OPERATIONS, readDeclaration, readKitLock, requestResources, writeKitLock, newKitLock } from "./kit.js";
2
+ import { linkIdempotencyKey, OPERATIONS, readDeclaration, readKitLock, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
3
3
  import { CliError } from "./output.js";
4
4
  export class GovernanceClient {
5
5
  apiUrl;
@@ -15,6 +15,7 @@ export class GovernanceClient {
15
15
  link(input) {
16
16
  return this.call("POST", "/v1/development/apps/link", { tenantId: this.tenantId, ...input });
17
17
  }
18
+ /** One app-wide request per (connection, identity): no environment and no expiry — IT approves once for every lane and sets any expiry itself. */
18
19
  request(appId, input) {
19
20
  return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integration-requests`, { tenantId: this.tenantId, ...input });
20
21
  }
@@ -72,41 +73,92 @@ export async function ensureLinkedApp(root, client, tenantId, bundle) {
72
73
  return linked.appId;
73
74
  }
74
75
  export const ENVIRONMENTS = ["development", "preview", "production"];
76
+ export const IDENTITY_WORDS = { app: "as the company bot", user: "as the signed-in person" };
77
+ /** A refusal about the leg rather than the ask — signed out, unreachable, an answer with no code — is nobody's ask and stops the run. */
78
+ const legFailure = (code) => code === "AUTH_REQUIRED" || code === "GOVERNANCE_UNREACHABLE" || code.startsWith("HTTP_");
79
+ /** One request per identity mode of the connection. A refusal of an ask is returned beside the others, not thrown, so a caller can report every connection at once. */
80
+ async function submitRequests(client, appId, declaration, root, connection, reason, only) {
81
+ return Promise.all(requestScope(declaration, connection, only).map(async (part) => {
82
+ const ask = { connection, ...part };
83
+ try {
84
+ return { ...ask, outcome: await client.request(appId, { connection, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(part.presentation ? { presentation: part.presentation } : {}), ...(reason?.trim() ? { reason: reason.trim() } : {}) }) };
85
+ }
86
+ catch (error) {
87
+ const refusal = unregisteredResourceGuidance(error, declaration, connection, root);
88
+ if (!(refusal instanceof CliError) || legFailure(refusal.code))
89
+ throw refusal;
90
+ return { ...ask, error: refusal };
91
+ }
92
+ }));
93
+ }
94
+ /** The filed asks with every refusal thrown, for the commands that cannot go on past one. */
95
+ function settled(requests) {
96
+ return requests.map(item => { if (item.error)
97
+ throw item.error; return item; });
98
+ }
75
99
  /**
76
- * One request per identity mode from the declaration's scope (or the named subset).
77
- * Polls the grant list for up to 30 s; PENDING is reported as pending, never as ready.
100
+ * Files the app-wide request for every connection the app declares, one per
101
+ * identity mode — what `harbour dev` (signed in) and `productionise` do before
102
+ * anything else, so IT's queue holds the ask from the first run and no command
103
+ * stands between a builder and an approval. Idempotent: governance answers
104
+ * READY for a scope IT has approved and PENDING with the existing request for
105
+ * one it is still deciding, so a repeat opens nothing. Links the app first
106
+ * (the same key as `integrations request`) and reads the lanes back, grouped.
107
+ * An app with no declaration or no connections is left alone: nothing to ask,
108
+ * so governance is not called.
109
+ */
110
+ export async function fileDeclaredRequests(root, client, tenantId, bundle) {
111
+ const { declaration, errors } = await readDeclaration(root);
112
+ if (errors.length) {
113
+ if (errors[0].startsWith(".harbour/integrations.json is missing"))
114
+ return undefined;
115
+ throw new CliError("DECLARATION_INVALID", `.harbour/integrations.json is invalid: ${errors[0]}`);
116
+ }
117
+ const connections = Object.keys(declaration.connections);
118
+ if (!connections.length)
119
+ return undefined;
120
+ const appId = await ensureLinkedApp(root, client, tenantId, bundle);
121
+ const requests = (await Promise.all(connections.map(connection => submitRequests(client, appId, declaration, root, connection)))).flat();
122
+ return { appId, requests, groups: groupGrants((await client.list(appId)).grants) };
123
+ }
124
+ /**
125
+ * `harbour integrations request`: the request for one connection (or the named
126
+ * subset of its operations) now, one per identity mode. Polls the grant list
127
+ * for up to 30 s; PENDING is reported as pending, never as ready.
78
128
  */
79
129
  export async function requestIntegrations(root, client, tenantId, bundle, options) {
80
130
  const { declaration, errors } = await readDeclaration(root);
81
131
  if (errors.length)
82
132
  throw new CliError("DECLARATION_INVALID", `.harbour/integrations.json is invalid: ${errors[0]}`);
83
- const environment = options.environment ?? "development";
84
- if (!ENVIRONMENTS.includes(environment))
85
- throw new CliError("USAGE", "--environment must be development, preview or production.");
86
- if (!options.reason.trim())
87
- throw new CliError("USAGE", "--reason <text> is required.");
88
- if (options.expiresAt && Number.isNaN(Date.parse(options.expiresAt)))
89
- throw new CliError("USAGE", "--expires-at must be an ISO-8601 UTC timestamp.");
90
- const scope = requestScope(declaration, options.connection, options.operations);
91
133
  const appId = await ensureLinkedApp(root, client, tenantId, bundle);
92
- 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, ...(part.presentation ? { presentation: part.presentation } : {}), ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), reason: options.reason }).catch((error) => { throw unregisteredResourceGuidance(error, declaration, options.connection, environment, options.reason, root); }) })));
134
+ const submitted = settled(await submitRequests(client, appId, declaration, root, options.connection, options.reason, options.operations)).map(item => ({ ...item, state: item.outcome.state }));
93
135
  const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
94
136
  const pollMs = options.pollMs ?? 3_000;
95
137
  let grants = [];
96
- for (let waited = 0; submitted.some(item => item.state === "PENDING") && waited < 30_000; waited += pollMs) {
97
- await sleep(pollMs);
138
+ for (let waited = 0;; waited += pollMs) {
98
139
  grants = (await client.list(appId)).grants;
99
140
  // A grant that already serves an older scope reads "ready" while this request (an expansion, a renewal,
100
141
  // a changed per-app name) is still attached to it as pending: READY only once the grant no longer waits on it.
101
142
  for (const item of submitted) {
102
- const grant = grants.find(candidate => candidate.grantId === item.grantId);
103
- if (grant?.failure)
104
- throw new CliError(grant.failure.code, `${grant.failure.message} ${grant.failure.remediation}`);
105
- if (grant?.readiness === "ready" && grant.pendingRequestId !== item.requestId)
143
+ const grant = grants.find(candidate => candidate.grantId === item.outcome.grantId);
144
+ // Guidance also rides on a grant that is still being retried ("attempt 1 of 3 …"); only a failed readiness ends the wait.
145
+ if (grant?.failure && grant.readiness === "failed")
146
+ throw failureError(grant.failure);
147
+ if (grant?.readiness === "ready" && grant.pendingRequestId !== item.outcome.requestId)
106
148
  item.state = "READY";
107
149
  }
150
+ if (!submitted.some(item => item.state === "PENDING") || waited >= 30_000)
151
+ break;
152
+ await sleep(pollMs);
108
153
  }
109
- return { appId, connection: options.connection, environment, requests: submitted.map(item => ({ identityMode: item.identityMode, operations: item.operations, resources: item.resources, ...(item.presentation ? { presentation: item.presentation } : {}), requestId: item.requestId, grantId: item.grantId, state: item.state, readiness: item.state === "PENDING" ? "pending" : grants.find(grant => grant.grantId === item.grantId)?.readiness ?? (item.state === "READY" ? "ready" : "denied") })) };
154
+ const groups = groupGrants(grants);
155
+ return { appId, connection: options.connection, requests: submitted.map(item => ({
156
+ identityMode: item.identityMode, operations: item.operations, resources: item.resources, ...(item.presentation ? { presentation: item.presentation } : {}),
157
+ ...(item.outcome.requestId ? { requestId: item.outcome.requestId } : {}), grantId: item.outcome.grantId, state: item.state,
158
+ readiness: item.state === "PENDING" ? "pending" : grants.find(grant => grant.grantId === item.outcome.grantId)?.readiness ?? (item.state === "READY" ? "ready" : "denied"),
159
+ // The three lanes in one sentence, as `integrations status` prints them (the request itself names the anchor lane's grant).
160
+ summary: laneSummary(findGroup(groups, options.connection, item.identityMode), item.outcome.requestId)
161
+ })) };
110
162
  }
111
163
  const UNREGISTERED_RESOURCE = /^resource "([^"]+)" is not registered for \S+ on "([^"]+)"$/;
112
164
  /**
@@ -117,7 +169,7 @@ const UNREGISTERED_RESOURCE = /^resource "([^"]+)" is not registered for \S+ on
117
169
  * up. The code is kept for `--json`; the sentence names the resource, the one
118
170
  * console place where IT adds it, and the command to run again afterwards.
119
171
  */
120
- export function unregisteredResourceGuidance(error, declaration, connection, environment, reason, root) {
172
+ export function unregisteredResourceGuidance(error, declaration, connection, root) {
121
173
  if (!(error instanceof CliError) || error.code !== "RESOURCE_NOT_APPROVED")
122
174
  return error;
123
175
  const match = UNREGISTERED_RESOURCE.exec(error.message);
@@ -128,55 +180,123 @@ export function unregisteredResourceGuidance(error, declaration, connection, env
128
180
  const operations = Object.keys(declared?.operations ?? {});
129
181
  const provider = operations.some(name => name.startsWith("gmail.")) ? "Gmail" : operations.some(name => name.startsWith("slack.")) ? "Slack" : connection;
130
182
  const place = declared?.kind === "database"
131
- ? `Controls & integrations → Databases → ${connection} → Data resources for ${environment}`
183
+ ? `Controls & integrations → Databases → ${connection} → Data resources`
132
184
  : `Controls & integrations → API integrations → ${provider} → Configure → ${provider === "Gmail" ? "the mailbox" : "Channels"}`;
133
- const command = `harbour integrations request ${connection} --reason "${reason}" --app-root ${root}${environment === "development" ? "" : ` --environment ${environment}`}`;
134
- 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);
185
+ 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 \`harbour integrations request ${connection} --app-root ${root}\` again (\`harbour dev\` and \`harbour productionise\` file the request too).`, error.operationRef);
186
+ }
187
+ export function groupGrants(grants) {
188
+ const groups = new Map();
189
+ for (const grant of grants) {
190
+ const key = `${grant.identityMode}:${grant.connection}`;
191
+ const group = groups.get(key) ?? { connection: grant.connection, identityMode: grant.identityMode, lanes: {} };
192
+ if (ENVIRONMENTS.includes(grant.environment))
193
+ group.lanes[grant.environment] = grant;
194
+ groups.set(key, group);
195
+ }
196
+ return [...groups.values()].sort((a, b) => a.connection.localeCompare(b.connection) || a.identityMode.localeCompare(b.identityMode));
135
197
  }
198
+ /** The group of one ask, or an empty one (no lane on file yet). */
199
+ export function findGroup(groups, connection, identityMode) {
200
+ return groups.find(group => group.connection === connection && group.identityMode === identityMode) ?? { connection, identityMode, lanes: {} };
201
+ }
202
+ /** The open request of a group: it sits on the production lane (the anchor), so whichever lane record carries one names it. */
203
+ export function openRequestId(lanes) {
204
+ return ENVIRONMENTS.map(lane => lanes[lane]?.pendingRequestId).find(Boolean);
205
+ }
206
+ /** A granted lane past IT's expiry (the server reads it as failed; the sentence says why). */
207
+ const expired = (grant, nowMs) => grant.status === "GRANTED" && Boolean(grant.expiresAt && Date.parse(grant.expiresAt) <= nowMs);
208
+ /** A lane a deploy can run on: granted, unexpired, not still provisioning or failed (a person's consent is asked in the app, at runtime). */
209
+ const laneReady = (grant, nowMs) => grant.status === "GRANTED" && !expired(grant, nowMs) && grant.readiness !== "pending" && grant.readiness !== "failed";
210
+ /** Builder-safe failure guidance as one sentence: what failed, then what to do. */
211
+ export const failureSentence = (failure) => `${failure.message} ${failure.remediation}`;
212
+ const failureError = (failure) => new CliError(failure.code, failureSentence(failure));
136
213
  /**
137
- * `productionise` pre-check: the preview deploy holds until every declared
138
- * connection has a GRANTED, unexpired preview grant per identity mode, so the
139
- * CLI checks first and names the exact request to make. Links the app when
140
- * needed (same idempotency key as `integrations request`) so both commands
141
- * share one appId. Returns the kit appId, or undefined for a non-kit app.
214
+ * One sentence for the three lanes: `ready in development, preview, production`
215
+ * when they agree, otherwise the lanes that differ — `development ready
216
+ * (preapproved until <ts>); preview, production waiting for IT (request <id>)`.
217
+ * A lane with no record yet is waiting on the group's open request (the list
218
+ * read right after filing may not carry the anchor yet, so the filing's own
219
+ * request id is the fallback). Development ready while another lane waits is
220
+ * the connection's development preapproval: the server writes that lane at
221
+ * filing time and keeps the request open for IT.
142
222
  */
143
- export async function assertPreviewIntegrationsReady(root, client, tenantId, bundle, output) {
144
- const lock = await readKitLock(root);
145
- const { declaration, errors } = await readDeclaration(root);
146
- if (errors.length) {
147
- if (errors[0].startsWith(".harbour/integrations.json is missing"))
148
- return lock?.appId || undefined;
149
- throw new CliError("DECLARATION_INVALID", `.harbour/integrations.json is invalid: ${errors[0]}`);
223
+ export function laneSummary(group, requestHint, nowMs = Date.now()) {
224
+ const { lanes } = group;
225
+ const open = openRequestId(lanes) ?? requestHint;
226
+ const waitingOn = (lane) => { const grant = lanes[lane]; return grant ? (grant.readiness === "pending" ? grant.pendingRequestId ?? requestHint : undefined) : open; };
227
+ const word = (lane) => {
228
+ const grant = lanes[lane];
229
+ const request = waitingOn(lane);
230
+ if (!grant)
231
+ return { base: request ? `waiting for IT (request ${request})` : "not requested", suffix: "" };
232
+ if (expired(grant, nowMs))
233
+ return { base: `expired ${grant.expiresAt}`, suffix: "" };
234
+ const until = grant.expiresAt ? ` until ${grant.expiresAt}` : "";
235
+ switch (grant.readiness) {
236
+ case "ready": return { base: "ready", suffix: lane === "development" && ENVIRONMENTS.some(other => other !== lane && waitingOn(other)) ? ` (preapproved${until})` : until };
237
+ case "consent_required": return { base: "approved", suffix: " (connect your account in the app)" };
238
+ case "reconnect_required": return { base: "approved", suffix: " (reconnect your account in the app)" };
239
+ case "pending": return { base: request ? `waiting for IT (request ${request})` : "being set up", suffix: "" };
240
+ case "failed": return { base: grant.status === "DENIED" ? "denied" : grant.status === "REVOKED" ? "revoked" : "failed", suffix: "" };
241
+ }
242
+ };
243
+ const segments = [];
244
+ for (const lane of ENVIRONMENTS) {
245
+ const { base, suffix } = word(lane);
246
+ const segment = segments.find(candidate => candidate.base === base && candidate.suffix === suffix);
247
+ if (segment)
248
+ segment.lanes.push(lane);
249
+ else
250
+ segments.push({ base, suffix, lanes: [lane] });
150
251
  }
151
- const connections = Object.keys(declaration.connections);
152
- if (!connections.length)
153
- return lock?.appId || undefined;
154
- const appId = await ensureLinkedApp(root, client, tenantId, bundle);
155
- const grants = (await client.list(appId)).grants;
156
- const nowMs = Date.now();
157
- const missing = [];
158
- for (const connection of connections) {
159
- for (const { identityMode } of requestScope(declaration, connection)) {
160
- const grant = grants.find(candidate => candidate.connection === connection && candidate.environment === "preview" && candidate.identityMode === identityMode);
161
- const why = !grant ? "no preview grant requested"
162
- : grant.status !== "GRANTED" ? `preview grant is ${grant.status}${grant.readiness === "pending" ? " (waiting for IT)" : ""}`
163
- : grant.expiresAt && Date.parse(grant.expiresAt) <= nowMs ? `preview grant expired ${grant.expiresAt}`
164
- : grant.readiness === "pending" || grant.readiness === "failed" ? `preview grant is ${grant.readiness}`
165
- : undefined;
166
- if (why)
167
- missing.push({ connection, identityMode, why });
252
+ return segments.length === 1 ? `${segments[0].base} in ${ENVIRONMENTS.join(", ")}${segments[0].suffix}` : segments.map(segment => `${segment.lanes.join(", ")} ${segment.base}${segment.suffix}`).join("; ");
253
+ }
254
+ /** The group's line — `company-slack [as the company bot] ready in development, preview, production` — plus the failure guidance of any lane, once per distinct failure. */
255
+ export function renderGrantGroup(group, nowMs = Date.now()) {
256
+ const lines = [`${group.connection} [${IDENTITY_WORDS[group.identityMode]}] ${laneSummary(group, undefined, nowMs)}`];
257
+ const seen = new Set();
258
+ for (const lane of ENVIRONMENTS) {
259
+ const failure = group.lanes[lane]?.failure;
260
+ if (failure && !seen.has(failure.message)) {
261
+ seen.add(failure.message);
262
+ lines.push(` ${failureSentence(failure)}`);
168
263
  }
169
264
  }
170
- if (!missing.length)
171
- return appId;
172
- const unresolved = [...new Set(missing.map(item => item.connection))];
173
- output(`Harbour cannot deploy the preview yet: ${missing.length} integration grant${missing.length === 1 ? " is" : "s are"} missing.`);
174
- for (const item of missing)
175
- output(` ${item.connection} (${item.identityMode} identity): ${item.why}`);
176
- output("Request each one, then rerun productionise:");
177
- for (const connection of unresolved)
178
- output(` harbour integrations request ${connection} --environment preview --reason "<why>" --app-root ${root}`);
179
- 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.`);
265
+ return lines;
266
+ }
267
+ /**
268
+ * `productionise` pre-check. Files the app-wide request for every declared
269
+ * connection (idempotent: READY comes straight back once IT has approved), then
270
+ * reads the preview lane — the one this deploy runs in — and refuses before any
271
+ * operation exists, naming exactly what is still waiting. Links the app when
272
+ * needed (the same key as `integrations request`) so both share one appId.
273
+ * Returns the kit appId, or undefined for a non-kit app.
274
+ */
275
+ export async function assertPreviewIntegrationsReady(root, client, tenantId, bundle, output) {
276
+ const filed = await fileDeclaredRequests(root, client, tenantId, bundle);
277
+ if (!filed)
278
+ return (await readKitLock(root))?.appId || undefined;
279
+ const nowMs = Date.now();
280
+ const waiting = [];
281
+ for (const item of settled(filed.requests)) {
282
+ const group = findGroup(filed.groups, item.connection, item.identityMode);
283
+ const preview = group.lanes.preview;
284
+ // Guidance also rides on a lane that is still being retried; only a failed readiness is a refusal in its own words.
285
+ if (preview?.failure && preview.readiness === "failed")
286
+ throw failureError(preview.failure);
287
+ if (preview && laneReady(preview, nowMs))
288
+ continue;
289
+ const requestId = item.outcome.requestId ?? openRequestId(group.lanes);
290
+ waiting.push(requestId
291
+ ? `Waiting for IT: ${item.connection} ${IDENTITY_WORDS[item.identityMode]} (request ${requestId}).`
292
+ : `${item.connection} ${IDENTITY_WORDS[item.identityMode]} is not ready for the preview (${laneSummary(group, undefined, nowMs)}); ask IT to review this app's access in the Harbour console.`);
293
+ }
294
+ if (!waiting.length)
295
+ return filed.appId;
296
+ output("Harbour cannot deploy the preview yet:");
297
+ for (const line of waiting)
298
+ output(` ${line}`);
299
+ throw new CliError("INTEGRATIONS_NOT_READY", `${waiting.join(" ")} IT approves a request once, for every environment; rerun productionise when it is approved.`);
180
300
  }
181
301
  /**
182
302
  * `productionise` / `promote` pre-check for an app that calls governed AI
@@ -219,7 +339,7 @@ export function requestScope(declaration, connection, only) {
219
339
  const entry = byMode.get(mode) ?? { operations: [], resources: new Map() };
220
340
  entry.operations.push(name);
221
341
  for (const resource of requestResources(declared.operations[name]))
222
- entry.resources.set(typeof resource === "string" ? resource : resource.name, resource);
342
+ entry.resources.set(requestResourceName(resource), resource);
223
343
  byMode.set(mode, entry);
224
344
  }
225
345
  return [...byMode.entries()].map(([identityMode, entry]) => ({
@@ -32,6 +32,7 @@ export const DEPENDENT_READ_OPERATIONS = ["gmail.message.read"];
32
32
  */
33
33
  export function emptyDeclaration() { return { schema: "harbour.app-integrations/2.0", connections: {} }; }
34
34
  export function resourceNames(operation) { return Array.isArray(operation.resources) ? operation.resources : Object.keys(operation.resources); }
35
+ export const requestResourceName = (resource) => typeof resource === "string" ? resource : resource.name;
35
36
  export function requestResources(operation) {
36
37
  return Array.isArray(operation.resources) ? operation.resources : Object.entries(operation.resources).map(([name, spec]) => ({ name, columns: [...spec.columns] }));
37
38
  }
@@ -72,8 +72,8 @@ export async function productionise(rootArg, client, output, tenantId, includePa
72
72
  await assertChecksPassedForTree(root, output);
73
73
  const files = pending?.uploaded ? [] : await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
74
74
  output(`Harbour found ${graph.deploymentScope.includedFiles.length} app files.`);
75
- // A declared connection without its preview grant would only park the
76
- // deployment after the save; refuse here, before any operation exists.
75
+ // A declared connection IT has not approved yet would only park the
76
+ // deployment after the save; file the request and refuse here, before any operation exists.
77
77
  const kitAppId = pending?.appId ?? (options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId);
78
78
  // An app that calls governed AI needs the company's AI setup to pass the
79
79
  // deployment PLAN; ask governance now rather than discover it on the deployed button.
@@ -92,7 +92,7 @@ export function managedBlock() {
92
92
  "",
93
93
  "- Identity, data and files go through `@harbour/app-sdk` only: `harbour.identity.current()`, `harbour.data.from(table)`, `harbour.files.*`. Never open a database, storage bucket or company system from browser code, and never `fetch` a company URL directly.",
94
94
  "- Company systems (Slack, Gmail, warehouse views) are reached only through `harbour.integrations.execute(connection, {operation, resource, input})` with the connection and operation declared in `.harbour/integrations.json`. Operations are a closed set: `slack.channel.history` (user identity), `slack.message.post` (declared per app: `app` posts as the company's Slack bot, Isomorph AI, under the name IT approved; `user` posts as the signed-in person after their consent), `gmail.thread.list`, `gmail.message.read` and `gmail.message.send` (user identity — reads and one plain-text send as the signed-in person, resource `inbox`), `warehouse.view.read` (app identity). Resources are logical names, never IDs, URLs or tokens.",
95
- "- `.harbour/integrations.json` starts with `\"connections\": {}` and stays that way until the app really calls a company system. Adding one is two steps: declare the connection — by the identifier `harbour integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — then `harbour integrations request <connection> --reason \"<why>\" --app-root .` (and the same command with `--environment preview` before shipping). Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. Before a real integration test, read `harbour integrations status --app-root . --json`: show pending IT approval separately from personal consent, and test ready destinations independently. After a partial send, retry only the failed destination with its original idempotency key; do not regenerate or resend a successful destination. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
95
+ "- `.harbour/integrations.json` starts with `\"connections\": {}` and stays that way until the app really calls a company system. Adding one is two steps: declare the connection — by the identifier `harbour integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — then ask for access — one request per connection, which IT approves once for every environment (development, preview and production together): `harbour dev` and `harbour productionise` file it for you, and `harbour integrations request <connection> --reason \"<why>\" --app-root .` files it now. Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. Before a real integration test, read `harbour integrations status --app-root . --json`: show pending IT approval separately from personal consent, and test ready destinations independently. After a partial send, retry only the failed destination with its original idempotency key; do not regenerate or resend a successful destination. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
96
96
  "- Scheduled work lives only in `jobs/<name>.ts`: export one literal UTC cron as `schedule` and one default async handler. Test it immediately with `harbour jobs run <name> --app-root . --scheduled-at <UTC> --json`; this uses local data and safe fixtures. If the person explicitly asks for a company-action test, use `--real` only after the exact action has development approval. Tell them: \"Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved.\" Harbour runs the same declaration automatically after deployment; never use `setInterval`, an effect or a browser timer as a scheduler.",
97
97
  "- In browser code, a Slack message is sent only after the person presses an explicit Send control; pass a fresh UUID `idempotencyKey` per send action and reuse the same key when retrying that action. Never send from an effect or browser timer, and never send during checks. A `jobs/*.ts` handler is the only scheduled-send path and acts as the app: only an app-mode operation may run there, after the person explicitly requested it and IT granted its exact destination for that environment. Use a deterministic idempotency key for the business period and destination; local job runs and checks use fixtures and send nothing.",
98
98
  "- A Slack message is posted either as the app (the company's Slack bot, Isomorph AI, under the IT-approved name: declare `\"presentation\": { \"displayName\": \"<app name>\" }` on the Slack connection, optional `iconEmoji`; every app-mode post ends with \"Posted by <app> on Isomorph\") or as the person (`\"identity\": \"user\"` on `slack.message.post`, their own consent; an older consent answers `USER_RECONNECT_REQUIRED` — offer Connect again) — never pretend one is the other. The declaration is the mode you request access for; when the app is approved for both, every post says which with `mode: \"app\"` or `mode: \"user\"` on the call (`MODE_REQUIRED` otherwise), and a mode IT has not approved is refused with `MODE_NOT_GRANTED`, never swapped. A post needs the bot in the channel: `/invite @Isomorph AI` there first.",
@@ -121,7 +121,7 @@ Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
121
121
  2. Edit \`src/\` and \`migrations/\`. Use the SDK only. \`.harbour/integrations.json\` starts with no connections and gains one only when the app really calls a company system: declare it with only the operations the app calls, then request access (step 5). A declared connection the app does not call blocks every deploy until IT grants it; an undeclared one cannot be requested, so it never gets a grant. README.md has the worked Slack and warehouse examples — the file itself is strict JSON and cannot hold comments.
122
122
  3. \`.harbour/checks/\` is generated, not written by hand: \`harbour check\` reads \`migrations/\` and \`src/\` and writes one retained journey per capability the app's own code uses (plus a cross-user denial per owner-scoped table), deleting the ones the app no longer needs — so run it in the same edit that changes the app instead of adding or deleting these files yourself. It reports anything it cannot generate (\`actions\`, \`realtime\`, a table with no migration) for you to write. Edit a generated check and it becomes yours: Harbour keeps it, stops managing it and never removes it, so deleting it when the feature goes is then your job. \`harbour check\` and the deployment pipeline replay these checks against a real App Gateway and refuse the app (\`flow.check-failed\`) in both directions — a capability no check exercises, and a check that exercises something the code no longer does.
123
123
  4. \`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); a retained check that calls \`harbour.integrations.execute\` is answered by the gate's fixture (the contract's shape for what the app declared, nothing sent or read) and the passed check says so. A governed AI call (\`harbour.ai.chat\`) is exercised for real through the development route while \`harbour dev\` is up; the pipeline answers it with a canned completion and reports it as not tested.
124
- 5. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
124
+ 5. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for access now — one request per connection, approved once for every environment; \`harbour dev\` (signed in) and \`harbour productionise\` file it for you. Pending is not ready.
125
125
  6. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
126
126
  `;
127
127
  /**
@@ -134,8 +134,8 @@ function kitFiles() {
134
134
  return {
135
135
  // No connection. The starter calls no company system, and a connection the
136
136
  // app does not call is a deploy that never happens: `harbour productionise`
137
- // refuses to start until every declared connection holds a preview grant
138
- // from IT. One is added when the app really calls it — the two steps and the
137
+ // files the access request for every declared connection and refuses to
138
+ // start until IT has approved it. One is added when the app really calls it — the two steps and the
139
139
  // worked Slack and warehouse examples are in README.md, in the "Harbour
140
140
  // development kit" block of AGENTS.md / CLAUDE.md and in src/App.tsx. They
141
141
  // are not in this file: it is read with JSON.parse here and again by the
@@ -240,7 +240,7 @@ Add one in two steps, when the app really calls it:
240
240
  "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
241
241
  \`\`\`
242
242
 
243
- 2. Ask IT for access: \`harbour integrations request <connection> --reason "<why>" --app-root .\` for local development, and the same command with \`--environment preview\` for the preview grant every declared connection needs before \`harbour productionise\` will deploy. \`harbour integrations status --app-root .\` says where each request stands; PENDING is not ready.
243
+ 2. Ask IT for access: one request per connection, and IT approves it once for every environment (development, preview and production together). \`harbour dev\` (signed in) and \`harbour productionise\` file it for you; \`harbour integrations request <connection> --reason "<why>" --app-root .\` files it now. \`harbour integrations status --app-root .\` says where each connection stands in every environment; PENDING is not ready, and \`harbour productionise\` will not deploy until IT has approved every declared connection.
244
244
 
245
245
  Then call it from the app on the \`harbour\` client from \`src/harbour.client.ts\` — always this exact shape, never a wrapper function, because the kit gate derives what the app uses from it:
246
246
 
@@ -348,8 +348,9 @@ export function App() {
348
348
  // (slack.message.post "identity": "app" posts as the company's Slack bot, Isomorph AI, under the
349
349
  // IT-approved presentation name; "identity": "user" posts as the signed-in person after their consent.
350
350
  // A post may name the approved mode it runs under — { ..., mode: "app" } — and must once IT approved both.)
351
- // 2. Request access (harbour integrations request sales-warehouse --reason "<why>" --app-root .,
352
- // and again with --environment preview before harbour productionise), then uncomment
351
+ // 2. Request access — one request per connection, which IT approves once for every
352
+ // environment; harbour dev and harbour productionise file it for you, or file it now with
353
+ // harbour integrations request sales-warehouse --reason "<why>" --app-root . — then uncomment
353
354
  // (the harbour client is already imported from "./harbour.client"):
354
355
  // const report = await harbour.integrations.execute("sales-warehouse", {
355
356
  // operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.47";
1
+ export const CLI_VERSION = "0.1.48";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": {