@fourier-labs/harbour 0.1.29 → 0.1.30

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.
@@ -154,11 +154,12 @@ The person you are working with may not be a developer. They say what they want
154
154
 
155
155
  ## What they say → what you do
156
156
 
157
- - "run it", "show me", "let me try it" → start \`harbour dev --app-root .\` in the background (it keeps running; the first start pulls images and takes a minute or two). Wait for the line \`Harbour dev is running: http://127.0.0.1:<port>\` and give them that link. Locally they are a fixture user; no company sign-in is needed.
157
+ - "run it", "show me", "let me try it" → start \`harbour dev --app-root .\` in the background (it keeps running; the first start pulls images and takes a minute or two). Wait for the line \`Harbour dev is running: http://127.0.0.1:<port>\` and give them that link. 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.
158
158
  - "check it", "is it ok?", "is it ready?" → with dev running, \`harbour check --app-root . --json\`, then read \`.harbour/local/check-report.json\`. Failures in the app's code are yours to fix — fix, then check again until it is clean. Run the checks yourself after every change and before every ship, without being asked and without offering them as a choice.
159
- - "I need Slack / Gmail / the warehouse / company data" → a fresh \`harbour init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. Declare the connection and only the operations the app really calls in \`.harbour/integrations.json\` (the closed set is in the per-app block), then \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\`. READY means use it now. PENDING means IT has to approve it: say "IT has to approve this; the app works without it until then", and check later with \`harbour integrations status --app-root . --json\`. A refusal with \`RESOURCE_NOT_APPROVED\` means the channel, view or mailbox is not on the connection yet: IT adds it in the Harbour console under Controls & integrations API integrations → the provider tile Configure → Channels (a warehouse view: Controls & integrations → Databases → the source → Views for the environment), and then you run the same request command again. Say "IT has to add <name> to the Slack connection first; the app works without it until then" and nothing more. Never declare a connection the app does not call every declared one blocks shipping until IT approves it.
160
- - "summarise", "draft", "explain", "AI" one \`harbour.ai.chat\` call (through \`ai()\` in \`src/harbour.client.ts\`) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`harbour check\` writes its journey. A refusal with \`AI_NOT_ENABLED\` means IT has to enable an AI provider: say so in one line and keep the app working without it.
161
- - "ship it", "put it online", "let my team try it" → run \`harbour check --app-root . --json\` first and fix everything it finds, every time, unasked: the same gates run again in the cloud, where each failed attempt costs minutes instead of the seconds it costs here. Then each declared connection needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`), and \`harbour productionise --app-root . --wait --json\` gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they name, open after company sign-in. Keep \`operationRef\`; \`harbour setup --operation <ref> --json\` lists what is still missing (name, audience, secrets) and \`harbour profile\` / \`harbour audience\` / \`harbour secrets set\` fill it in.
159
+ - "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.
160
+ - "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. 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.
161
+ - "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.
162
+ - "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, \`harbour status --operation <ref> --wait --json\` 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.
162
163
  - "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.
163
164
  - "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.
164
165
 
@@ -170,11 +171,13 @@ When a command refuses, the refusal names its own reason and its own fix: change
170
171
  - Authentication is Harbour SSO: no login forms, no roles or ids trusted from the browser; row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\`. Every route needs a signed-in person; no public routes.
171
172
  - Schema changes are SQL files in \`migrations/\` with row-level security and GRANTs to \`harbour_app_gateway\`; \`harbour dev\` and \`harbour check\` apply them.
172
173
  - 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.
173
- - A Slack message or an email (\`slack.message.post\`, \`gmail.message.send\`) is sent only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press (reused only to retry that press). Never send from an effect, a timer, a queue or during checks. Consent is a user action (\`harbour.integrations.connect\`); missing consent never falls back to another account.
174
+ - 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.
175
+ - 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 mode is declared in \`.harbour/integrations.json\`, not chosen in code; 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".
174
176
  - 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.
175
177
 
176
178
  ## Talking to the person
177
179
 
178
180
  - Plain words, short: "Your app is running at <link>.", "All 6 checks passed.", "One check failed: votes were not being saved — fixed, checking again.", "IT has to approve Slack; the app works without it until then."
179
181
  - Say what happens next and roughly how long it takes. Report only what you observed; if something is unknown, say so.
182
+ - End every report with three short lines: what works now; what does not yet, and who unblocks it; what you decided for them (a fixed list instead of AI, a schedule the platform cannot run, a feature you left out). A decision of yours never passes as theirs, or as done.
180
183
  - End a turn with at most one question, and only when a decision is genuinely theirs to make and you cannot go on without it. Never offer to do something this guide already tells you to do unasked — do it and report what happened.`;
@@ -289,7 +289,7 @@ function renderIntegrationsStatus(status) {
289
289
  return lines.join("\n");
290
290
  }
291
291
  function renderRequest(result) {
292
- return [`App ${result.appId}: ${result.connection} (${result.environment})`, ...result.requests.map(item => ` ${item.identityMode} identity — ${item.operations.join(", ")} on ${item.resources.join(", ")}: ${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");
292
+ 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");
293
293
  }
294
294
  function optionValue(flag) {
295
295
  const index = args.indexOf(flag);
@@ -62,20 +62,22 @@ export async function requestIntegrations(root, client, tenantId, bundle, option
62
62
  throw new CliError("USAGE", "--expires-at must be an ISO-8601 UTC timestamp.");
63
63
  const scope = requestScope(declaration, options.connection, options.operations);
64
64
  const appId = await ensureLinkedApp(root, client, tenantId, bundle);
65
- const submitted = await Promise.all(scope.map(async (part) => ({ ...part, ...await client.request(appId, { connection: options.connection, environment, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), reason: options.reason }).catch((error) => { throw unregisteredResourceGuidance(error, declaration, options.connection, environment, options.reason, root); }) })));
65
+ const submitted = await Promise.all(scope.map(async (part) => ({ ...part, ...await client.request(appId, { connection: options.connection, environment, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(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); }) })));
66
66
  const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
67
67
  const pollMs = options.pollMs ?? 3_000;
68
68
  let grants = [];
69
69
  for (let waited = 0; submitted.some(item => item.state === "PENDING") && waited < 30_000; waited += pollMs) {
70
70
  await sleep(pollMs);
71
71
  grants = (await client.list(appId)).grants;
72
+ // A grant that already serves an older scope reads "ready" while this request (an expansion, a renewal,
73
+ // a changed per-app name) is still attached to it as pending: READY only once the grant no longer waits on it.
72
74
  for (const item of submitted) {
73
75
  const grant = grants.find(candidate => candidate.grantId === item.grantId);
74
- if (grant?.readiness === "ready")
76
+ if (grant?.readiness === "ready" && grant.pendingRequestId !== item.requestId)
75
77
  item.state = "READY";
76
78
  }
77
79
  }
78
- return { appId, connection: options.connection, environment, requests: submitted.map(item => ({ identityMode: item.identityMode, operations: item.operations, resources: item.resources, requestId: item.requestId, grantId: item.grantId, state: item.state, readiness: grants.find(grant => grant.grantId === item.grantId)?.readiness ?? (item.state === "READY" ? "ready" : item.state === "PENDING" ? "pending" : "denied") })) };
80
+ 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") })) };
79
81
  }
80
82
  const UNREGISTERED_RESOURCE = /^resource "([^"]+)" is not registered for \S+ on "([^"]+)"$/;
81
83
  /**
@@ -147,7 +149,13 @@ export async function assertPreviewIntegrationsReady(root, client, tenantId, bun
147
149
  output(` harbour integrations request ${connection} --environment preview --reason "<why>" --app-root ${root}`);
148
150
  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.`);
149
151
  }
150
- /** Splits the declared operations of one connection by identity mode. */
152
+ /**
153
+ * Splits the declared operations of one connection by identity mode — the
154
+ * declaration's own `identity` (the gate has checked it against the closed
155
+ * set; an operation with one mode is read as that mode). The app-mode part
156
+ * that carries `slack.message.post` also carries the connection's declared
157
+ * presentation, so IT sees "posts as" on the request.
158
+ */
151
159
  export function requestScope(declaration, connection, only) {
152
160
  const declared = declaration.connections[connection];
153
161
  if (!declared)
@@ -158,14 +166,19 @@ export function requestScope(declaration, connection, only) {
158
166
  throw new CliError("OPERATION_NOT_DECLARED", `${unknown.join(", ")} not declared for ${connection}.`);
159
167
  const byMode = new Map();
160
168
  for (const name of names) {
161
- const mode = OPERATIONS[name].identity;
169
+ const allowed = OPERATIONS[name]?.identities ?? [];
170
+ const declaredMode = declared.operations[name].identity;
171
+ const mode = allowed.includes(declaredMode) ? declaredMode : allowed[0];
162
172
  const entry = byMode.get(mode) ?? { operations: [], resources: new Map() };
163
173
  entry.operations.push(name);
164
174
  for (const resource of requestResources(declared.operations[name]))
165
175
  entry.resources.set(typeof resource === "string" ? resource : resource.name, resource);
166
176
  byMode.set(mode, entry);
167
177
  }
168
- return [...byMode.entries()].map(([identityMode, entry]) => ({ identityMode, operations: entry.operations, resources: [...entry.resources.keys()].sort().map(key => entry.resources.get(key)) }));
178
+ return [...byMode.entries()].map(([identityMode, entry]) => ({
179
+ identityMode, operations: entry.operations, resources: [...entry.resources.keys()].sort().map(key => entry.resources.get(key)),
180
+ ...(identityMode === "app" && entry.operations.includes("slack.message.post") && declared.presentation ? { presentation: declared.presentation } : {})
181
+ }));
169
182
  }
170
183
  export async function integrationsStatus(root, client) {
171
184
  const lock = await readKitLock(root);
@@ -1,18 +1,18 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "harbour.kit-bundle/1.0",
3
- "kitVersion": "0.1.29",
3
+ "kitVersion": "0.1.30",
4
4
  "sdk": {
5
5
  "package": "@harbour/app-sdk",
6
6
  "version": "1.1.0",
7
- "tarballSha256": "998102545ba9b0eec3687d8ded2609a16dbb1d3254b006401155106bf8b80355",
8
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:998102545ba9b0eec3687d8ded2609a16dbb1d3254b006401155106bf8b80355"
7
+ "tarballSha256": "88e00a9dac87f5281285ca1d881c7766f8fa04fcee00376f1c60e10e848bf16f",
8
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:88e00a9dac87f5281285ca1d881c7766f8fa04fcee00376f1c60e10e848bf16f"
9
9
  },
10
10
  "images": {
11
- "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:0fae5eda4278d1a13e3575efc24aa279e601f932bc8d3b3ac41e65fda147d4df",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:5d09b312cedea249e642d64931fa7cd41ad517b05dc0412a4845f7abe6da3a11"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:11a9ceadcb3b997163f61cf8249ad9302ad073af28ac7d2110c6cafac0a6d16e",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:72caccadc12cd5f604bdebec20f8b831242b6d93924a16128f4434aac2497e52"
13
13
  },
14
14
  "brief": {
15
- "fingerprint": "14e3cd99236da47263e430084e470d75f648223320f16a0d9917054d7dbc296d"
15
+ "fingerprint": "4b3c61acdce47995c15e5ce8c38d5e636a624cd79ef7bebb9401b6228d129d11"
16
16
  },
17
17
  "declarationSchema": "harbour.app-integrations/2.0"
18
18
  };
@@ -5,14 +5,20 @@ import { dirname, join, parse, resolve } from "node:path";
5
5
  import { scanWorkspace } from "../../../src/analyzer.js";
6
6
  import { isKitBundle } from "./kit-bundle.js";
7
7
  import { CliError } from "./output.js";
8
- /** Closed set: each operation has exactly one identity mode and one connection kind. Gmail is user-mode only (two reads and one send as the signed-in person). */
8
+ /**
9
+ * Closed set: each operation has one connection kind and the identity modes it
10
+ * may run under — exactly one, except slack.message.post, which the app
11
+ * declares (`app` posts as the company's Slack app under its IT-approved name,
12
+ * `user` posts as the signed-in person after their consent). Gmail is
13
+ * user-mode only (two reads and one send as the signed-in person).
14
+ */
9
15
  export const OPERATIONS = {
10
- "slack.channel.history": { identity: "user", kind: "saas" },
11
- "slack.message.post": { identity: "app", kind: "saas" },
12
- "gmail.thread.list": { identity: "user", kind: "saas" },
13
- "gmail.message.read": { identity: "user", kind: "saas" },
14
- "gmail.message.send": { identity: "user", kind: "saas" },
15
- "warehouse.view.read": { identity: "app", kind: "database" }
16
+ "slack.channel.history": { identities: ["user"], kind: "saas" },
17
+ "slack.message.post": { identities: ["app", "user"], kind: "saas" },
18
+ "gmail.thread.list": { identities: ["user"], kind: "saas" },
19
+ "gmail.message.read": { identities: ["user"], kind: "saas" },
20
+ "gmail.message.send": { identities: ["user"], kind: "saas" },
21
+ "warehouse.view.read": { identities: ["app"], kind: "database" }
16
22
  };
17
23
  /** Explicit reads `harbour check --integrations` may run; every other operation is a send and never runs during checks. */
18
24
  export const READ_OPERATIONS = ["slack.channel.history", "gmail.thread.list", "warehouse.view.read"];
@@ -91,10 +91,11 @@ export function managedBlock() {
91
91
  "This app runs on Harbour. Keep these rules; `harbour check` and the deployment pipeline enforce them.",
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
- "- 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` (app identity), `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.",
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 with only the operations the app calls, 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
- "- Consent is a user action: call `harbour.integrations.connect(connection)` and, when it returns `consent_required`, open `authorizationUrl`. Missing consent never falls back to another account.",
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.",
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.",
98
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.",
99
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.",
100
101
  "- Authentication is owned by Harbour SSO. Do not add login forms, JWT handling, or trust a role, owner id or tenant id supplied by the browser. Row ownership is decided in SQL through `current_setting('harbour.user_id', true)` and `current_setting('harbour.user_email', true)`.",
@@ -227,9 +228,11 @@ Add one in two steps, when the app really calls it:
227
228
  1. Declare the connection and only the operations the app calls, under \`connections\`. Slack:
228
229
 
229
230
  \`\`\`json
230
- "company-slack": { "kind": "saas", "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
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"] } } }
231
232
  \`\`\`
232
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 declared here, not chosen in code. 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
+
233
236
  A warehouse view:
234
237
 
235
238
  \`\`\`json
@@ -246,7 +249,7 @@ const report = await integrations().execute<{ rows: Array<{ week: string; total:
246
249
  });
247
250
  \`\`\`
248
251
 
249
- A send (\`slack.message.post\`, \`gmail.message.send\`) runs only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a timer or a check. Consent is a user action: \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit. A check that calls \`integrations().execute\` is answered by the gate's fixture — the contract's result shape for the declared connection, operation and resource, nothing sent or read, and the report says so; \`harbour check --integrations\` is what exercises real access.
252
+ A send (\`slack.message.post\`, \`gmail.message.send\`) runs only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a timer or a check. Consent is only for user-identity operations (\`slack.channel.history\`, \`gmail.*\`, and \`slack.message.post\` declared \`"identity": "user"\`): \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`; app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit. A check that calls \`integrations().execute\` is answered by the gate's fixture — the contract's result shape for the declared connection, operation and resource, nothing sent or read, and the report says so; \`harbour check --integrations\` is what exercises real access.
250
253
  `;
251
254
  const VITE_CONFIG = `import { defineConfig } from "vite";
252
255
  import react from "@vitejs/plugin-react";
@@ -363,7 +366,9 @@ export function App() {
363
366
  // "connections" in .harbour/integrations.json — every declared connection blocks
364
367
  // the deploy until IT grants it:
365
368
  // "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
366
- // "company-slack": { "kind": "saas", "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
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
+ // (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.)
367
372
  // 2. Request access (harbour integrations request sales-warehouse --reason "<why>" --app-root .,
368
373
  // and again with --environment preview before harbour productionise), then import
369
374
  // { integrations } from "./harbour.client" and uncomment:
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.29";
1
+ export const CLI_VERSION = "0.1.30";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
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.29"
37
+ "version": "0.1.30"
38
38
  }
39
39
  }
40
40
  }