@fourier-labs/harbour 0.1.31 → 0.1.32
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 +2 -2
- package/dist/packages/harbour-cli/src/cli.js +14 -7
- package/dist/packages/harbour-cli/src/integrations.js +22 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +5 -5
- package/dist/packages/harbour-cli/src/productionise.js +11 -1
- package/dist/packages/harbour-cli/src/starter.js +4 -3
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
|
@@ -160,7 +160,7 @@ The person you are working with may not be a developer. They say what they want
|
|
|
160
160
|
- "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. In development a Send posts for real to the approved channel — press it once to verify and tell them you did. If you have no browser, say that the button itself is untested.
|
|
161
161
|
- "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, view or mailbox name — a guessed one is refused before IT's queue ever sees it, so nothing appears for IT to approve; use the exact names the person or IT gave you, and if you have none, that is the one question to ask before declaring anything. Before asking, run \`harbour integrations catalog --app-root . --json\` — it lists the company's connections by the exact identifier \`.harbour/integrations.json\` uses, the operations IT allows on each and the approved channels, views and mailboxes per environment — and declare only identifiers and approved names it lists. 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\`. Request the preview grant in the same turn (\`--environment preview\`) so shipping does not wait on a second IT decision. 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.
|
|
162
162
|
- "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. 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.
|
|
163
|
-
- "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. 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. 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.
|
|
163
|
+
- "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\`); 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. \`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. 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. 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.
|
|
164
164
|
- "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.
|
|
165
165
|
- "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.
|
|
166
166
|
|
|
@@ -173,7 +173,7 @@ When a command refuses, the refusal names its own reason and its own fix: change
|
|
|
173
173
|
- Schema changes are SQL files in \`migrations/\` with row-level security and GRANTs to \`harbour_app_gateway\`; \`harbour dev\` and \`harbour check\` apply them.
|
|
174
174
|
- 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.
|
|
175
175
|
- 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 (\`harbour.integrations.connect\`) exists only for user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`); app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call it — a connect for them is refused as unapproved user access and puts nothing in IT's queue. Missing consent never falls back to another account.
|
|
176
|
-
- A Slack message is posted either as the app (\`"identity": "app"\` — the company's one Slack bot, Isomorph AI, under the name IT approved: declare \`"presentation": { "displayName": "<app name>", "iconEmoji": ":sandwich:" }\` on the connection and IT sees "posts as" before approving; leave it out to post as Isomorph AI itself) or as the person (\`"identity": "user"\` — their own Slack account, after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\` "reconnect Slack to allow posting as you", so offer Connect again) — never pretend one is the other. The
|
|
176
|
+
- A Slack message is posted either as the app (\`"identity": "app"\` — the company's one Slack bot, Isomorph AI, under the name IT approved: declare \`"presentation": { "displayName": "<app name>", "iconEmoji": ":sandwich:" }\` on the connection and IT sees "posts as" before approving; leave it out to post as Isomorph AI itself) or as the person (\`"identity": "user"\` — their own Slack account, after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\` "reconnect Slack to allow posting as you", so offer Connect again) — never pretend one is the other. The declaration in \`.harbour/integrations.json\` is the mode the app requests access for; a post names the approved mode it runs under with \`mode: "app"\` or \`mode: "user"\` on the execute call — optional while the app is approved for one mode, required once IT approved both (\`MODE_REQUIRED\`), and a mode IT has not approved is refused with \`MODE_NOT_GRANTED\`, never swapped for the other. An app-mode post always ends with "Posted by <app> on Isomorph". A post is refused with \`RESOURCE_NOT_APPROVED\` until the bot is in the channel: say "IT (or anyone in the channel) has to run \`/invite @Isomorph AI\` in #<channel> first".
|
|
177
177
|
- 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.
|
|
178
178
|
|
|
179
179
|
## Talking to the person
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { RemoteMcpClient } from "./remote-mcp-client.js";
|
|
3
|
-
import { productionise } from "./productionise.js";
|
|
3
|
+
import { appCallsAi, productionise } from "./productionise.js";
|
|
4
4
|
import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchStatus, follow, getAppSetup, listSecrets, promoteToProduction, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize } from "./operations.js";
|
|
5
5
|
import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
|
|
6
6
|
import { CLI_VERSION } from "./version.js";
|
|
7
7
|
import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
|
|
8
8
|
import { connect, loadConfig, resolveConfig } from "./config.js";
|
|
9
9
|
import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
|
|
10
|
-
import { appRoot } from "./kit.js";
|
|
10
|
+
import { appRoot, readKitLock } 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 { stopAllHarbourProjects } from "./docker-networks.js";
|
|
16
16
|
import { runChecks } from "./check.js";
|
|
17
|
-
import { GovernanceClient, integrationsCatalog, integrationsStatus, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
|
|
17
|
+
import { assertAiReady, GovernanceClient, integrationsCatalog, integrationsStatus, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
|
|
18
18
|
const args = process.argv.slice(2);
|
|
19
19
|
const command = args[0];
|
|
20
20
|
const connectUrl = args[1];
|
|
@@ -65,7 +65,7 @@ const usage = [
|
|
|
65
65
|
" harbour productionise --app-root <path> [--include <relative-path>]... [--no-wait] [--max-wait <seconds>] [--json]",
|
|
66
66
|
" harbour status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
|
|
67
67
|
" harbour retry --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
|
|
68
|
-
" harbour promote --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
|
|
68
|
+
" harbour promote --operation <reference> [--app-root <path>] [--no-wait] [--max-wait <seconds>] [--json] (--app-root: also confirm the company's AI setup for production when the app calls governed AI)",
|
|
69
69
|
" harbour setup --operation <reference> [--json] what the app still needs (name, audience, secrets)",
|
|
70
70
|
" harbour profile --operation <reference> [--name <text>] [--description <text>] [--json]",
|
|
71
71
|
" harbour audience --operation <reference> [--emails a@co,b@co] [--json] (no --emails = only you)",
|
|
@@ -84,7 +84,7 @@ const usage = [
|
|
|
84
84
|
"Run `harbour connect <company-start-url>` once, then sign in when Harbour asks.",
|
|
85
85
|
"productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
|
|
86
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.`,
|
|
87
|
-
"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.",
|
|
87
|
+
"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.",
|
|
88
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.",
|
|
89
89
|
""
|
|
90
90
|
].join("\n");
|
|
@@ -257,8 +257,15 @@ else {
|
|
|
257
257
|
summary = wait ? await follow(client, operationRef, progress, waitOptions) : summarize(await fetchStatus(client, operationRef));
|
|
258
258
|
else if (command === "retry")
|
|
259
259
|
summary = await retryDeployment(client, operationRef, progress, { wait: !noWait, waitOptions });
|
|
260
|
-
else if (command === "promote")
|
|
260
|
+
else if (command === "promote") {
|
|
261
|
+
// The production PLAN blocks on the same AI setup plus enforce mode; with the app root in hand, ask before promoting.
|
|
262
|
+
if (root && config.apiUrl && await appCallsAi(appRoot(root))) {
|
|
263
|
+
const lock = await readKitLock(appRoot(root));
|
|
264
|
+
if (lock?.appId)
|
|
265
|
+
await assertAiReady(lock.appId, "production", new GovernanceClient(config.apiUrl, token, tenant), progress);
|
|
266
|
+
}
|
|
261
267
|
summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions });
|
|
268
|
+
}
|
|
262
269
|
else if (command === "setup")
|
|
263
270
|
summary = { setup: await getAppSetup(client, operationRef) };
|
|
264
271
|
else if (command === "profile")
|
|
@@ -283,7 +290,7 @@ else {
|
|
|
283
290
|
if (json || command === "productionise")
|
|
284
291
|
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
285
292
|
// Exit 2, like a usage error: nothing started and the fix is a command the maker runs.
|
|
286
|
-
process.exitCode = envelope.error?.code === "INTEGRATIONS_NOT_READY" ? 2 : 1;
|
|
293
|
+
process.exitCode = envelope.error?.code === "INTEGRATIONS_NOT_READY" || envelope.error?.code === "AI_NOT_READY" ? 2 : 1;
|
|
287
294
|
}
|
|
288
295
|
}
|
|
289
296
|
function renderIntegrationsStatus(status) {
|
|
@@ -28,6 +28,10 @@ export class GovernanceClient {
|
|
|
28
28
|
execute(appId, body) {
|
|
29
29
|
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integrations/execute`, body);
|
|
30
30
|
}
|
|
31
|
+
/** Would this app's deployment PLAN pass for the environment? Read-only; governance answers from the company's AI setup. */
|
|
32
|
+
aiReadiness(appId, environment) {
|
|
33
|
+
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/ai/readiness`, { environment });
|
|
34
|
+
}
|
|
31
35
|
async call(method, path, body) {
|
|
32
36
|
const url = `${this.apiUrl.replace(/\/$/, "")}${path}`;
|
|
33
37
|
const response = await this.fetchImpl(url, { method, headers: { authorization: `Bearer ${this.token}`, "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" })
|
|
@@ -172,6 +176,24 @@ export async function assertPreviewIntegrationsReady(root, client, tenantId, bun
|
|
|
172
176
|
output(` harbour integrations request ${connection} --environment preview --reason "<why>" --app-root ${root}`);
|
|
173
177
|
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.`);
|
|
174
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* `productionise` / `promote` pre-check for an app that calls governed AI
|
|
181
|
+
* (the local gate's inventory lists `ai` among its capabilities): the
|
|
182
|
+
* pipeline will ask governance for a PLAN over the inventory the kit shipped,
|
|
183
|
+
* and that PLAN blocks on the company's AI setup — no provider, no route, no
|
|
184
|
+
* budget, observe mode before production. Ask the same question here, before
|
|
185
|
+
* any build, and refuse with the one IT step, the way a missing grant is
|
|
186
|
+
* refused. Observed without it (fourier app-f578bfc2, 2026-09-12): a green
|
|
187
|
+
* check, a live preview, and a button that answered AI_UNAVAILABLE.
|
|
188
|
+
*/
|
|
189
|
+
export async function assertAiReady(appId, environment, client, output) {
|
|
190
|
+
const readiness = await client.aiReadiness(appId, environment);
|
|
191
|
+
if (readiness.ready) {
|
|
192
|
+
output(`Harbour confirmed the company's AI setup for ${environment}.`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
throw new CliError("AI_NOT_READY", readiness.plainEnglish);
|
|
196
|
+
}
|
|
175
197
|
/**
|
|
176
198
|
* Splits the declared operations of one connection by identity mode — the
|
|
177
199
|
* declaration's own `identity` (the gate has checked it against the closed
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "harbour.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.1.
|
|
3
|
+
"kitVersion": "0.1.32",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@harbour/app-sdk",
|
|
6
6
|
"version": "1.1.0",
|
|
7
|
-
"tarballSha256": "
|
|
8
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
7
|
+
"tarballSha256": "3f7ee9d3f7161e8fdb1f7eb89e045c6288a919a72902dbc5bf68ec73e6e578ce",
|
|
8
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:3f7ee9d3f7161e8fdb1f7eb89e045c6288a919a72902dbc5bf68ec73e6e578ce"
|
|
9
9
|
},
|
|
10
10
|
"images": {
|
|
11
|
-
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
11
|
+
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:516f56cf83d9b270bd1163914781a216dac6f0dfe1e794150fafaefdd76feaf6",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:ac894b7490fd97ade2e646d2255bbc5ae8d386e800042ee25f8fc2049e51f5c8"
|
|
13
13
|
},
|
|
14
14
|
"brief": {
|
|
15
15
|
"fingerprint": "4b3c61acdce47995c15e5ce8c38d5e636a624cd79ef7bebb9401b6228d129d11"
|
|
@@ -9,7 +9,7 @@ import { CliError } from "./output.js";
|
|
|
9
9
|
import { follow, getAppSetup, pickSourceFailure, readLine } from "./operations.js";
|
|
10
10
|
import { CLI_VERSION } from "./version.js";
|
|
11
11
|
import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
|
|
12
|
-
import { assertPreviewIntegrationsReady } from "./integrations.js";
|
|
12
|
+
import { assertAiReady, assertPreviewIntegrationsReady } from "./integrations.js";
|
|
13
13
|
import { kitPaths, recordKitAppId, sourceDigest } from "./kit.js";
|
|
14
14
|
import { FLOW_CHECK, preflightKitGate, readReport } from "./check.js";
|
|
15
15
|
export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
|
|
@@ -35,6 +35,10 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
35
35
|
// A declared connection without its preview grant would only park the
|
|
36
36
|
// deployment after the save; refuse here, before any operation exists.
|
|
37
37
|
const kitAppId = options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId;
|
|
38
|
+
// An app that calls governed AI needs the company's AI setup to pass the
|
|
39
|
+
// deployment PLAN; ask governance now rather than discover it on the deployed button.
|
|
40
|
+
if (options.integrations && kitAppId && await appCallsAi(root))
|
|
41
|
+
await assertAiReady(kitAppId, "preview", options.integrations.governance, output);
|
|
38
42
|
// A kit app the pipeline's gate would refuse (a table without RLS, an
|
|
39
43
|
// operation no journey exercises, a cross-user leak) is refused here,
|
|
40
44
|
// before the save, by the same gate with the same wording.
|
|
@@ -388,3 +392,9 @@ function outcomeLine(summary) {
|
|
|
388
392
|
}
|
|
389
393
|
}
|
|
390
394
|
const readApproval = readLine;
|
|
395
|
+
/** The local gate's inventory says whether the app calls governed AI (`.harbour/local/check-report.json`, `gate.inventory.capabilities`). */
|
|
396
|
+
export async function appCallsAi(root) {
|
|
397
|
+
const report = await readReport(root);
|
|
398
|
+
const capabilities = report?.gate?.inventory?.capabilities;
|
|
399
|
+
return Array.isArray(capabilities) && capabilities.includes("ai");
|
|
400
|
+
}
|
|
@@ -94,7 +94,7 @@ export function managedBlock() {
|
|
|
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
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. 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
|
"- 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, a timer or a background queue, and never send during checks.",
|
|
97
|
-
"- 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. A post needs the bot in the channel: `/invite @Isomorph AI` there first.",
|
|
97
|
+
"- 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.",
|
|
98
98
|
"- Consent is only for user-identity operations (`slack.channel.history`, `gmail.*`, and `slack.message.post` declared `\"identity\": \"user\"`): call `harbour.integrations.connect(connection)` and, when it returns `consent_required`, open `authorizationUrl`. App-identity operations (`slack.message.post` declared `\"identity\": \"app\"`, `warehouse.view.read`) never call connect — it is refused as unapproved user access. Missing consent never falls back to another account.",
|
|
99
99
|
"- A retained check that calls `harbour.integrations.execute` is answered, under `harbour check` and in the deployment pipeline alike, by the gate's fixture: the contract's result shape for a connection, operation and resource the app declared (one canned Slack message, one canned Gmail thread, a warehouse view with no rows), a refusal with the platform's own code for anything undeclared, and nothing is ever sent or read. The passed check says so in the report; real access is exercised only by `harbour check --integrations` (reads) and the preview's own smoke test.",
|
|
100
100
|
"- AI goes through `harbour.ai` only — `ai().chat({ messages, maxTokens })` from `src/harbour.client.ts` — behind an explicit control the person presses (never on load, in an effect or a timer). Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the platform's governed AI gateway holds the key and IT sees every call. The starter calls no AI; add the one call when the person asks for it (README.md has the \"Summarise my notes\" example) and `harbour check` writes `.harbour/checks/ai-journey.mjs` for it. A refusal with code `AI_NOT_ENABLED` means IT has not enabled an AI provider yet; the app must still work without AI.",
|
|
@@ -231,7 +231,7 @@ Add one in two steps, when the app really calls it:
|
|
|
231
231
|
"company-slack": { "kind": "saas", "presentation": { "displayName": "Lunch Vote", "iconEmoji": ":sandwich:" }, "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
|
|
232
232
|
\`\`\`
|
|
233
233
|
|
|
234
|
-
\`slack.message.post\` is posted either as the app (\`"identity": "app"\`: the company's Slack bot, Isomorph AI, under the \`presentation\` name IT approves — leave \`presentation\` out to post as Isomorph AI itself; every app-mode post ends with "Posted by <app> on Isomorph") or as the person (\`"identity": "user"\`: their own Slack account, after their consent — an older consent answers \`USER_RECONNECT_REQUIRED\` "reconnect Slack to allow posting as you", so offer Connect again). Never pretend one is the other; the mode is
|
|
234
|
+
\`slack.message.post\` is posted either as the app (\`"identity": "app"\`: the company's Slack bot, Isomorph AI, under the \`presentation\` name IT approves — leave \`presentation\` out to post as Isomorph AI itself; every app-mode post ends with "Posted by <app> on Isomorph") or as the person (\`"identity": "user"\`: their own Slack account, after their consent — an older consent answers \`USER_RECONNECT_REQUIRED\` "reconnect Slack to allow posting as you", so offer Connect again). Never pretend one is the other. The declaration is the mode the app requests access for; a post names the approved mode it runs under with \`mode: "app"\` or \`mode: "user"\` on the call — optional while the app is approved for one mode, required once IT approved both (\`MODE_REQUIRED\`), and a mode IT has not approved is refused with \`MODE_NOT_GRANTED\`, never swapped for the other. Either way the bot must be in the channel: someone runs \`/invite @Isomorph AI\` there once, or the post is refused with \`RESOURCE_NOT_APPROVED\`.
|
|
235
235
|
|
|
236
236
|
A warehouse view:
|
|
237
237
|
|
|
@@ -368,7 +368,8 @@ export function App() {
|
|
|
368
368
|
// "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
369
369
|
// "company-slack": { "kind": "saas", "presentation": { "displayName": "Lunch Vote" }, "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
|
|
370
370
|
// (slack.message.post "identity": "app" posts as the company's Slack bot, Isomorph AI, under the
|
|
371
|
-
// IT-approved presentation name; "identity": "user" posts as the signed-in person after their consent.
|
|
371
|
+
// IT-approved presentation name; "identity": "user" posts as the signed-in person after their consent.
|
|
372
|
+
// A post may name the approved mode it runs under — { ..., mode: "app" } — and must once IT approved both.)
|
|
372
373
|
// 2. Request access (harbour integrations request sales-warehouse --reason "<why>" --app-root .,
|
|
373
374
|
// and again with --environment preview before harbour productionise), then import
|
|
374
375
|
// { integrations } from "./harbour.client" and uncomment:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.32";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fourier-labs/harbour",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"description": "Harbour productionisation helper",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"harbour": {
|
|
35
35
|
"kitBundle": {
|
|
36
36
|
"repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
|
|
37
|
-
"version": "0.1.
|
|
37
|
+
"version": "0.1.32"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|