@fourier-labs/harbour 0.1.27 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/harbour-cli/src/agent-setup.js +4 -2
- package/dist/packages/harbour-cli/src/app-schema.js +8 -44
- package/dist/packages/harbour-cli/src/check.js +185 -145
- package/dist/packages/harbour-cli/src/cli.js +2 -2
- package/dist/packages/harbour-cli/src/dev.js +8 -0
- package/dist/packages/harbour-cli/src/forwarder.js +15 -11
- package/dist/packages/harbour-cli/src/integrations.js +26 -1
- package/dist/packages/harbour-cli/src/kit-bundle.js +9 -3
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +7 -7
- package/dist/packages/harbour-cli/src/kit.js +13 -56
- package/dist/packages/harbour-cli/src/local-runtime.js +43 -23
- package/dist/packages/harbour-cli/src/productionise.js +21 -22
- package/dist/packages/harbour-cli/src/retained-checks.js +60 -22
- package/dist/packages/harbour-cli/src/source-inventory.js +171 -0
- package/dist/packages/harbour-cli/src/starter.js +53 -4
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
- package/dist/packages/harbour-cli/src/database-gate.js +0 -61
- package/dist/packages/harbour-cli/src/flow-coverage.js +0 -490
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { extname, join, relative, sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* What the app's own source performs, read the way the pipeline reads it.
|
|
5
|
+
*
|
|
6
|
+
* Two inventories: every `<table> <verb>` the browser code performs
|
|
7
|
+
* (write_probe.go `inventorySDKTableVerbs`) and every SDK capability it calls
|
|
8
|
+
* (the kit lane's derivation). They are what the kit gate demands evidence
|
|
9
|
+
* for, so `retained-checks.ts` generates the retained journeys from exactly
|
|
10
|
+
* them — a generated journey can then only ever cover what the gate asks
|
|
11
|
+
* about, with no template in between.
|
|
12
|
+
*
|
|
13
|
+
* The gate itself — operation coverage in both directions, the cross-user
|
|
14
|
+
* denial, the database gate — is not here. It is the data plane's
|
|
15
|
+
* `transformbuild.RunKitGate`, and `harbour check` runs it (check.ts) in the
|
|
16
|
+
* app's local session through the pinned gateway image's `gate` subcommand.
|
|
17
|
+
*/
|
|
18
|
+
/** Capabilities the kit gate demands evidence for, in the pipeline's order. */
|
|
19
|
+
export const COVERED_CAPABILITIES = ["data", "files", "actions", "telemetry", "realtime", "ai"];
|
|
20
|
+
/** Every capability the kit lane derives from the browser SDK surface. */
|
|
21
|
+
const SDK_CAPABILITIES = ["data", "files", "actions", "realtime", "telemetry", "integrations", "ai"];
|
|
22
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
|
|
23
|
+
const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".harbour"]);
|
|
24
|
+
/**
|
|
25
|
+
* TypeScript call sites carry explicit type arguments between a method name and
|
|
26
|
+
* its call — `harbour.data.from<Note>("notes")`, `.select<Row>(` — and the
|
|
27
|
+
* starter's own list is one of them. Matching only the bare `.from(` made every
|
|
28
|
+
* typed call site invisible, so a freshly generated starter inventoried
|
|
29
|
+
* `notes: delete, insert, update` with no SELECT: the gate was reading the
|
|
30
|
+
* starter's own capabilities wrong. This is the pipeline's own
|
|
31
|
+
* `appSDKTypeArguments` (transformbuild/source_inspection.go), one nesting
|
|
32
|
+
* level of generics, so both scanners see the same call sites.
|
|
33
|
+
*/
|
|
34
|
+
const TYPE_ARGUMENTS = String.raw `(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\s*`;
|
|
35
|
+
const TABLE_CHAIN_SOURCE = String.raw `\.from${TYPE_ARGUMENTS}\(\s*["'\`]([A-Za-z0-9_]+)["'\`]\s*\)`;
|
|
36
|
+
const TABLE_CHAIN = new RegExp(TABLE_CHAIN_SOURCE, "g");
|
|
37
|
+
/** The same chain, unanchored and non-global: where THIS chain's verbs stop. */
|
|
38
|
+
const NEXT_TABLE_CHAIN = new RegExp(TABLE_CHAIN_SOURCE);
|
|
39
|
+
const VERB_CALL = new RegExp(String.raw `\.(select|insert|update|delete|upsert)\s*${TYPE_ARGUMENTS}\(`, "g");
|
|
40
|
+
/** `createClient()` bindings name the identifier a capability call must be made on. */
|
|
41
|
+
const CLIENT_BINDING = /(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:await\s+)?createClient\s*\(/g;
|
|
42
|
+
/** `<anything>.<namespace>.<method>(` — the receiver is deliberately not constrained; see capabilityCallSurface. */
|
|
43
|
+
const CAPABILITY_CALL = new RegExp(String.raw `\.\s*(${SDK_CAPABILITIES.join("|")})\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*\s*${TYPE_ARGUMENTS}\(`, "g");
|
|
44
|
+
async function sourceFiles(root) {
|
|
45
|
+
const found = [];
|
|
46
|
+
const walk = async (directory) => {
|
|
47
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const path = join(directory, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name))
|
|
52
|
+
await walk(path);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (SOURCE_EXTENSIONS.has(extname(entry.name)))
|
|
56
|
+
found.push(path);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
await walk(root);
|
|
60
|
+
return found.sort();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Every `<table> <verb>` the browser code performs, with the files performing
|
|
64
|
+
* it. The verb chain is what follows THIS `.from(...)` up to the end of its
|
|
65
|
+
* statement or the next `.from(` — never a later chain's verbs.
|
|
66
|
+
*
|
|
67
|
+
* This reading is what the gate demands evidence for, so `retained-checks.ts`
|
|
68
|
+
* generates from exactly it: a generated journey can then only ever cover what
|
|
69
|
+
* the gate asks about, in both directions, with no template in between.
|
|
70
|
+
*/
|
|
71
|
+
export async function sourceTableUsage(root) {
|
|
72
|
+
const inventory = new Map();
|
|
73
|
+
for (const path of await sourceFiles(root)) {
|
|
74
|
+
const text = await readFile(path, "utf8");
|
|
75
|
+
for (const match of text.matchAll(TABLE_CHAIN)) {
|
|
76
|
+
const start = match.index ?? 0;
|
|
77
|
+
if (text.slice(0, start).trimEnd().endsWith(".storage"))
|
|
78
|
+
continue;
|
|
79
|
+
let tail = text.slice(start + match[0].length);
|
|
80
|
+
const semicolon = tail.indexOf(";");
|
|
81
|
+
if (/[;\n]/.test(tail) && semicolon >= 0)
|
|
82
|
+
tail = tail.slice(0, semicolon);
|
|
83
|
+
const next = tail.search(NEXT_TABLE_CHAIN);
|
|
84
|
+
if (next > 0)
|
|
85
|
+
tail = tail.slice(0, next);
|
|
86
|
+
tail = tail.slice(0, 200);
|
|
87
|
+
const table = match[1];
|
|
88
|
+
const usage = inventory.get(table) ?? { verbs: new Set(), files: [] };
|
|
89
|
+
for (const verb of tail.matchAll(VERB_CALL))
|
|
90
|
+
usage.verbs.add(verb[1]);
|
|
91
|
+
const file = appPath(root, path);
|
|
92
|
+
if (!usage.files.includes(file))
|
|
93
|
+
usage.files.push(file);
|
|
94
|
+
inventory.set(table, usage);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return inventory;
|
|
98
|
+
}
|
|
99
|
+
/** `<table> <verb>` alone, as the coverage gate compares it. */
|
|
100
|
+
export async function sourceTableVerbs(root) {
|
|
101
|
+
return new Map([...await sourceTableUsage(root)].map(([table, usage]) => [table, usage.verbs]));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The capabilities the kit lane declares for this app, with the files declaring
|
|
105
|
+
* them: a namespace called as `<client>.<namespace>.<method>(` on an identifier
|
|
106
|
+
* bound to `createClient()`. TypeScript type arguments may sit between the
|
|
107
|
+
* method and its call.
|
|
108
|
+
*/
|
|
109
|
+
export async function capabilityUsage(root) {
|
|
110
|
+
const files = await sourceFiles(root);
|
|
111
|
+
const texts = await Promise.all(files.map(path => readFile(path, "utf8").catch(() => "")));
|
|
112
|
+
const clients = new Set();
|
|
113
|
+
for (const text of texts)
|
|
114
|
+
for (const match of text.matchAll(CLIENT_BINDING))
|
|
115
|
+
clients.add(match[1]);
|
|
116
|
+
const used = new Map();
|
|
117
|
+
if (!clients.size)
|
|
118
|
+
return used;
|
|
119
|
+
const namespaces = SDK_CAPABILITIES.join("|");
|
|
120
|
+
for (const client of clients) {
|
|
121
|
+
const call = new RegExp(`\\b${client}\\s*\\.\\s*(${namespaces})\\s*\\.\\s*[A-Za-z_$][A-Za-z0-9_$]*\\s*(?:<[^<>()]*>\\s*)?\\(`, "g");
|
|
122
|
+
for (const [index, text] of texts.entries()) {
|
|
123
|
+
const file = appPath(root, files[index]);
|
|
124
|
+
for (const match of text.matchAll(call)) {
|
|
125
|
+
const seen = used.get(match[1]) ?? [];
|
|
126
|
+
if (!seen.includes(file))
|
|
127
|
+
seen.push(file);
|
|
128
|
+
used.set(match[1], seen);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return used;
|
|
133
|
+
}
|
|
134
|
+
/** The capability names alone, as the coverage gate compares them. */
|
|
135
|
+
export async function declaredCapabilities(root) {
|
|
136
|
+
return new Set((await capabilityUsage(root)).keys());
|
|
137
|
+
}
|
|
138
|
+
/** A source file as the app itself names it: relative to the root, `/`-separated on every platform. */
|
|
139
|
+
const appPath = (root, path) => relative(root, path).split(sep).join("/");
|
|
140
|
+
/**
|
|
141
|
+
* Every capability namespace the app's own source calls a method on, whatever
|
|
142
|
+
* object the call is made on: `harbour.files.list(`, `client().files.list(`,
|
|
143
|
+
* `(harbour as {files: F}).files.list(`.
|
|
144
|
+
*
|
|
145
|
+
* This is deliberately looser than `declaredCapabilities`, and the two answer
|
|
146
|
+
* different questions. The forward gate asks "was this capability exercised?"
|
|
147
|
+
* and must be precise about what the app really uses. The reverse gate below
|
|
148
|
+
* asks "is this check exercising something the app no longer has?" — a
|
|
149
|
+
* question whose wrong answer refuses a good app — so it needs a lower bound on
|
|
150
|
+
* what is unused, never an upper bound on what is used.
|
|
151
|
+
*
|
|
152
|
+
* The pipeline's own evidence for a capability is `<client>.<namespace>.<method>(`
|
|
153
|
+
* with the client traced through the module graph (transformbuild
|
|
154
|
+
* analyzeAppSDKClientCalls / analyzeAppSDKNamespaceWrappers). Dropping the
|
|
155
|
+
* receiver makes this a superset of that set — every call site the pipeline can
|
|
156
|
+
* trace, plus ones it cannot — so a capability this cannot see is one the kit
|
|
157
|
+
* lane cannot declare either, and the reverse gate refuses only trees the
|
|
158
|
+
* pipeline already refuses. Where it errs it errs by staying quiet: a
|
|
159
|
+
* commented-out call still counts here, and the pipeline masks comments.
|
|
160
|
+
*/
|
|
161
|
+
export async function capabilityCallSurface(root) {
|
|
162
|
+
const surface = new Set();
|
|
163
|
+
for (const path of await sourceFiles(root))
|
|
164
|
+
for (const capability of capabilitiesCalled(await readFile(path, "utf8").catch(() => "")))
|
|
165
|
+
surface.add(capability);
|
|
166
|
+
return surface;
|
|
167
|
+
}
|
|
168
|
+
/** The same reading of one piece of text, so a retained check is judged by exactly the rule the app's own source is read with (retained-checks.ts). */
|
|
169
|
+
export function capabilitiesCalled(text) {
|
|
170
|
+
return new Set([...text.matchAll(CAPABILITY_CALL)].map(match => match[1]));
|
|
171
|
+
}
|
|
@@ -95,13 +95,14 @@ export function managedBlock() {
|
|
|
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
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.",
|
|
98
|
+
"- 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.",
|
|
98
99
|
"- 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)`.",
|
|
99
100
|
"- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
|
|
100
101
|
"- No secrets, tokens, `.env` values or fetched company content in source. `.harbour/local/` is ignored and never committed; `.harbour/integrations.json` and `.harbour/kit.lock.json` are committed.",
|
|
101
102
|
"- Schema changes are SQL files in `migrations/`, applied by `harbour dev` and `harbour check`. Every table: ENABLE ROW LEVEL SECURITY + a policy; GRANT every verb a policy allows to `harbour_app_gateway` and to no other role — `harbour check` runs the pipeline's database gate and names any table/policy/grant that breaks this, with the fix.",
|
|
102
103
|
"- `.harbour/checks/` holds the app's retained journeys: one per capability the app's own code uses (`harbour.data.*`, `harbour.files.*`, actions, realtime, telemetry), plus a cross-user denial per owner-scoped table. `harbour check` generates them from `migrations/` and the app's own source and deletes the ones the app no longer needs, so the way to keep them right is to run it in the same edit that changes the app — not to write or remove these files by hand. The pairing is two-way and the `flow` gate refuses the deploy in both directions. Start using a capability and it needs its own retained check: `harbour check` writes it, except for the ones it reports it cannot generate (`actions`, `realtime`), which you write yourself. Stop using one — a deleted section, a dropped table, a feature the app no longer has — and its retained check must be deleted in that same edit: `harbour check` deletes the ones it generated, and one you wrote or edited is yours to delete, because `harbour check` and the deployment pipeline replay `.harbour/checks/` against a real App Gateway and refuse the app (`flow.check-failed: the candidate's own retained checks no longer pass`) when a check exercises something the code no longer does.",
|
|
103
104
|
"- A generated check starts with a `// harbour:generated` line carrying a digest of its own body; that is how `harbour check` knows the file is still its to rewrite and remove. Edit one and it becomes yours: Harbour keeps your version, stops updating it and never deletes it, and keeping it honest is then your job. The starter's pairing is: `notes-journey.mjs` + `notes-cross-user.mjs` with the `notes` table and the Notes section of `src/App.tsx`; `files-journey.mjs` with the \"Private files\" section, the only code that calls `harbour.files.*`. Replace the notes table with the app's own, or remove the \"Private files\" section, and the next `harbour check` rewrites and deletes to match — `.harbour/checks/files-journey.mjs` goes with that section, and you delete it by hand in that same edit only if you have edited it. An inherited check for a feature the app replaced or dropped is the most common reason a first deploy is refused.",
|
|
104
|
-
"- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (
|
|
105
|
+
"- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (types, build, then the pipeline's own kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, the generated journeys, operation coverage), `harbour integrations request <connection> --reason <text> --app-root .`, `harbour integrations status --app-root .`, `harbour productionise --app-root .`. Company calls in `dev` use the account from `harbour login`; the local fixture user is only the app's identity.",
|
|
105
106
|
"- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/harbour-kit/SKILL.md`. The plain-English workflow (what to run when the person says \"run it\", \"check it\", \"ship it\") is in the user-level `isomorph` skill / `~/.codex/AGENTS.md` block installed by `harbour agent-setup`.",
|
|
106
107
|
MANAGED_END
|
|
107
108
|
].join("\n");
|
|
@@ -116,7 +117,7 @@ Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
|
|
|
116
117
|
1. \`harbour dev --app-root .\` starts Postgres, storage and one Harbour gateway (session identities, fixtures, realtime) plus Vite behind one loopback origin printed in the banner.
|
|
117
118
|
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.
|
|
118
119
|
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.
|
|
119
|
-
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).
|
|
120
|
+
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 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.
|
|
120
121
|
5. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
|
|
121
122
|
6. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
|
|
122
123
|
`;
|
|
@@ -190,6 +191,26 @@ The checks and the code are one pair, and \`harbour check\` and the deployment p
|
|
|
190
191
|
- **A capability with no check.** They refuse an app whose checks never exercise an operation its own code performs, so a new feature needs its own retained check.
|
|
191
192
|
- **A check with no capability.** They refuse an app whose retained checks no longer pass against its own code, so a feature you delete or replace means deleting or rewriting its check in the same edit. Running \`harbour check\` is that edit for a generated check: replace the \`notes\` table with your own and it rewrites both notes checks for the new table; remove the "Private files" section — the only code here that calls \`harbour.files.*\` — and it deletes \`.harbour/checks/files-journey.mjs\` for you. A check you have edited is not Harbour's to remove, so \`rm .harbour/checks/files-journey.mjs\` right then yourself; left behind, it exercises a capability the app no longer has and the deploy is refused with \`flow.check-failed: files-journey.mjs: exit status 1\`.
|
|
192
193
|
|
|
194
|
+
## Adding AI ("Summarise my notes")
|
|
195
|
+
|
|
196
|
+
The starter calls no AI. When the person asks for it, add one call through \`ai()\` from \`src/harbour.client.ts\`, behind a control they press — never on load, in an effect or a timer — and never an OpenAI/Anthropic/Gemini key, SDK or URL: the platform's governed AI gateway holds the key, IT enables the provider and sees every call.
|
|
197
|
+
|
|
198
|
+
\`\`\`tsx
|
|
199
|
+
import { ai, errorCode } from "./harbour.client";
|
|
200
|
+
|
|
201
|
+
const summarise = async () => {
|
|
202
|
+
try {
|
|
203
|
+
const reply = await ai().chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
|
|
204
|
+
setSummary(reply.content);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
setError(errorCode(error) === "AI_NOT_ENABLED" ? "AI is not enabled for this company yet; ask IT to connect a provider." : "The summary could not be produced.");
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
// <button type="button" onClick={() => void summarise()}>Summarise my notes</button>
|
|
210
|
+
\`\`\`
|
|
211
|
+
|
|
212
|
+
That one call is the declaration: \`harbour check\` writes \`.harbour/checks/ai-journey.mjs\` for it and the deployment registers the \`ai\` capability. Locally the journey makes a real governed call through the Harbour development route with your \`harbour login\` (\`AI_NOT_ENABLED\` means IT has not enabled a provider yet); in the pipeline it is answered by a canned completion and reported as not tested. Remove the call and the next \`harbour check\` deletes the journey.
|
|
213
|
+
|
|
193
214
|
## Adding a company system (Slack, Gmail, a warehouse view)
|
|
194
215
|
|
|
195
216
|
\`.harbour/integrations.json\` ships with no connections:
|
|
@@ -243,14 +264,16 @@ export default defineConfig({
|
|
|
243
264
|
}
|
|
244
265
|
});
|
|
245
266
|
`;
|
|
246
|
-
const HARBOUR_CLIENT = `import { createClient } from "@harbour/app-sdk";
|
|
267
|
+
const HARBOUR_CLIENT = `import { createClient, type IntegrationRequest } from "@harbour/app-sdk";
|
|
247
268
|
|
|
248
269
|
// Single client for the whole app. Identity, data and files come from Harbour;
|
|
249
270
|
// the same calls work locally (harbour dev) and in preview/production.
|
|
250
271
|
export const harbour = createClient();
|
|
251
272
|
|
|
252
273
|
export type ConnectResult = { status: "connected"; accountLabel: string } | { status: "consent_required"; authorizationUrl: string };
|
|
253
|
-
|
|
274
|
+
// The SDK's per-operation request types carry each input's bound (e.g. slack.channel.history limit: 1 to 15);
|
|
275
|
+
// the second member keeps an operation the SDK has not typed yet (gmail.*) callable until it is.
|
|
276
|
+
export type IntegrationCall = IntegrationRequest | { operation: string; resource: string; input: unknown; idempotencyKey?: string };
|
|
254
277
|
export type Integrations = {
|
|
255
278
|
connect(connection: string): Promise<ConnectResult>;
|
|
256
279
|
disconnect(connection: string): Promise<{ status: "disconnected" }>;
|
|
@@ -269,6 +292,26 @@ export function integrations(): Integrations {
|
|
|
269
292
|
return surface;
|
|
270
293
|
}
|
|
271
294
|
|
|
295
|
+
export type AiMessage = { role: "system" | "user" | "assistant"; content: string };
|
|
296
|
+
export type AiChatResult = { content: string; model: string; finishReason: string; usage: { inputTokens: number; outputTokens: number }; traceId?: string };
|
|
297
|
+
export type Ai = {
|
|
298
|
+
chat(request: { messages: AiMessage[]; model?: string; maxTokens?: number; temperature?: number }): Promise<AiChatResult>;
|
|
299
|
+
embed(request: { input: string | string[]; model?: string }): Promise<{ embeddings: number[][]; model: string }>;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Governed AI through the platform's LLM gateway (kit bundle SDK): the app never holds a
|
|
304
|
+
* provider key and IT governs every call. The starter calls no AI; this is the entry point
|
|
305
|
+
* for the one call you add when the person asks for it — behind an explicit control they
|
|
306
|
+
* press, never on load. README.md has the "Summarise my notes" example. A HarbourError whose
|
|
307
|
+
* errorCode() is AI_NOT_ENABLED means IT has not enabled a provider yet: keep the app working.
|
|
308
|
+
*/
|
|
309
|
+
export function ai(): Ai {
|
|
310
|
+
const surface = (harbour as { ai?: Ai }).ai;
|
|
311
|
+
if (!surface) throw new Error("This @harbour/app-sdk build has no ai surface; run harbour init --upgrade.");
|
|
312
|
+
return surface;
|
|
313
|
+
}
|
|
314
|
+
|
|
272
315
|
export function errorCode(error: unknown): string {
|
|
273
316
|
const details = (error as { details?: { code?: string } } | undefined)?.details;
|
|
274
317
|
return typeof details?.code === "string" ? details.code : (error as { category?: string })?.category ?? "UNKNOWN";
|
|
@@ -328,6 +371,12 @@ export function App() {
|
|
|
328
371
|
// });
|
|
329
372
|
// A Slack send runs only when the person presses an explicit Send control, with a
|
|
330
373
|
// fresh UUID idempotencyKey per press — never from an effect, a timer or a check.
|
|
374
|
+
//
|
|
375
|
+
// Governed AI is not part of the starter either. When the person asks for it, add ONE
|
|
376
|
+
// call through ai() from "./harbour.client" behind a control they press (README.md
|
|
377
|
+
// "Adding AI"), never an OpenAI/Anthropic key or SDK; harbour check then writes
|
|
378
|
+
// .harbour/checks/ai-journey.mjs for it, and deletes it again if the call goes:
|
|
379
|
+
// const summary = await ai().chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
|
|
331
380
|
|
|
332
381
|
return (
|
|
333
382
|
<main style={{ fontFamily: "system-ui", maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.28";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fourier-labs/harbour",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
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.28"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
/** Where the app gateway and session fixture images carry the gate (data plane build/*-image/Dockerfile). */
|
|
5
|
-
export const DATABASE_GATE_IMAGE_PATH = "/harbour/kit/database-gate.sql";
|
|
6
|
-
/**
|
|
7
|
-
* The kit database gate: the pipeline's own assertions over a replayed
|
|
8
|
-
* migration set, run by the in-loop probe (harbour-deployment-data-plane
|
|
9
|
-
* packages/toolkit/transformbuild/kit_database_gate.sql, embedded by
|
|
10
|
-
* containerprobe.go) right after the migrations replay. `harbour check`
|
|
11
|
-
* runs the copy the pinned kit images ship at DATABASE_GATE_IMAGE_PATH; this
|
|
12
|
-
* is the verbatim copy from data plane 0.73.1.0 for images that predate the
|
|
13
|
-
* file. It raises one exception naming every violation (table / policy /
|
|
14
|
-
* grant) on its own line with the fix.
|
|
15
|
-
*/
|
|
16
|
-
export const DATABASE_GATE_SQL = "-- Harbour kit database gate.\n--\n-- Runs after a kit app's migrations replayed on a scratch PostgreSQL that\n-- carries the platform role harbour_app_gateway (NOLOGIN, NOBYPASSRLS): the\n-- role the App Gateway executes every application statement as. It asserts,\n-- against the catalog rather than the migration text, the contract that role\n-- lives under:\n--\n-- 1. every application table has row-level security enabled;\n-- 2. an RLS-enabled table has at least one policy that applies to the\n-- gateway role (RLS with no policy is default-deny: the app sees no rows);\n-- 3. every verb a policy allows is GRANTed to harbour_app_gateway — a policy\n-- without its grant is a feature that fails with permission-denied only\n-- for real users (`poll_votes`, 2026-09-10);\n-- 4. tables, views and sequences are granted only to their owner and to\n-- harbour_app_gateway; the platform creates no other role, so any other\n-- grantee (PUBLIC included) is a silent production no-op at best.\n--\n-- The platform's own schema harbour_runtime (the realtime outbox) is not the\n-- app's and is skipped. One script, executed by the pipeline's in-loop probe\n-- (packages/toolkit/transformbuild/containerprobe.go) and, byte for byte, by\n-- `harbour check` from /harbour/kit/database-gate.sql in the session fixture\n-- image. It raises one exception naming every violation on its own line, with\n-- the fix; a clean database returns silently.\n\\set ON_ERROR_STOP 1\n\\set VERBOSITY terse\nDO $harbour_gate$\nDECLARE\n gateway CONSTANT text := 'harbour_app_gateway';\n violations text[] := ARRAY[]::text[];\n rec record;\n verb text;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = gateway) THEN\n RAISE EXCEPTION 'harbour database gate: the platform role % does not exist on this database; the probe bootstrap must create it before the migrations replay', gateway;\n END IF;\n\n -- 1. Row-level security on every application table.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS table_name, c.relrowsecurity AS rls\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind IN ('r', 'p')\n AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n AND n.nspname NOT LIKE 'pg\\_toast%' AND n.nspname NOT LIKE 'pg\\_temp%'\n ORDER BY 1, 2\n LOOP\n IF NOT rec.rls THEN\n violations := violations || format(\n 'table %I.%I: row-level security is not enabled, so every signed-in person would see every row — fix: ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY; then CREATE POLICY ... ON %I.%I USING (...) WITH CHECK (...)',\n rec.schema_name, rec.table_name, rec.schema_name, rec.table_name, rec.schema_name, rec.table_name);\n -- 2. A policy the gateway role is subject to.\n ELSIF NOT EXISTS (\n SELECT 1 FROM pg_policy p\n JOIN pg_class c ON c.oid = p.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname = rec.schema_name AND c.relname = rec.table_name\n AND (p.polroles = '{0}'::oid[] OR (SELECT oid FROM pg_roles WHERE rolname = gateway) = ANY (p.polroles))\n ) THEN\n violations := violations || format(\n 'table %I.%I: row-level security is enabled but no policy applies to %s, so the app sees no rows and every write is refused — fix: CREATE POLICY %I ON %I.%I USING (owner_subject = current_setting(''harbour.user_id'', true)) WITH CHECK (owner_subject = current_setting(''harbour.user_id'', true))',\n rec.schema_name, rec.table_name, gateway, rec.table_name || '_owner', rec.schema_name, rec.table_name);\n END IF;\n END LOOP;\n\n -- 3. Every verb a policy allows is granted to the gateway role.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS table_name, c.oid AS table_oid, p.polname AS policy_name,\n CASE p.polcmd WHEN 'r' THEN ARRAY['SELECT'] WHEN 'a' THEN ARRAY['INSERT'] WHEN 'w' THEN ARRAY['UPDATE'] WHEN 'd' THEN ARRAY['DELETE']\n ELSE ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE'] END AS verbs,\n p.polroles = '{0}'::oid[] OR (SELECT oid FROM pg_roles WHERE rolname = gateway) = ANY (p.polroles) AS applies,\n (SELECT string_agg(r.rolname, ', ' ORDER BY r.rolname) FROM pg_roles r WHERE r.oid = ANY (p.polroles)) AS role_names\n FROM pg_policy p\n JOIN pg_class c ON c.oid = p.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n ORDER BY 1, 2, 4\n LOOP\n IF NOT rec.applies THEN\n violations := violations || format(\n 'policy %I on %I.%I: applies to role(s) %s, not to %s, so the App Gateway never satisfies it — fix: recreate the policy without a TO clause (or add TO %s)',\n rec.policy_name, rec.schema_name, rec.table_name, coalesce(rec.role_names, '(none)'), gateway, gateway);\n CONTINUE;\n END IF;\n FOREACH verb IN ARRAY rec.verbs LOOP\n IF NOT has_table_privilege(gateway, rec.table_oid, verb) THEN\n violations := violations || format(\n 'policy %I on %I.%I: allows %s but %s is never GRANTed %s on it, so the feature fails with permission-denied for real users — fix: GRANT %s ON %I.%I TO %s',\n rec.policy_name, rec.schema_name, rec.table_name, verb, gateway, verb, verb, rec.schema_name, rec.table_name, gateway);\n END IF;\n END LOOP;\n END LOOP;\n\n -- 4. Grants go only to the owner and the gateway role.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS object_name,\n CASE c.relkind WHEN 'S' THEN 'SEQUENCE' WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'VIEW' ELSE 'TABLE' END AS object_kind,\n coalesce(g.rolname, 'PUBLIC') AS grantee,\n string_agg(a.privilege_type, ', ' ORDER BY a.privilege_type) AS privileges\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n CROSS JOIN LATERAL aclexplode(c.relacl) a\n LEFT JOIN pg_roles g ON g.oid = a.grantee\n WHERE c.relkind IN ('r', 'p', 'v', 'm', 'S')\n AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n AND n.nspname NOT LIKE 'pg\\_toast%' AND n.nspname NOT LIKE 'pg\\_temp%'\n AND a.grantee <> c.relowner\n AND coalesce(g.rolname, '') <> gateway\n GROUP BY 1, 2, 3, 4\n ORDER BY 1, 2, 4\n LOOP\n violations := violations || format(\n 'grant %s on %s %I.%I to %s: only the platform role %s may be granted, the platform never creates %s — fix: REVOKE %s ON %s %I.%I FROM %s',\n rec.privileges, lower(rec.object_kind), rec.schema_name, rec.object_name, rec.grantee, gateway, rec.grantee,\n rec.privileges, rec.object_kind, rec.schema_name, rec.object_name, rec.grantee);\n END LOOP;\n\n IF coalesce(array_length(violations, 1), 0) > 0 THEN\n RAISE EXCEPTION 'harbour database gate: % violation(s)%', array_length(violations, 1), E'\\n' || array_to_string(violations, E'\\n');\n END IF;\nEND\n$harbour_gate$;\n";
|
|
17
|
-
/**
|
|
18
|
-
* The gate script from a pinned kit image present locally that carries it
|
|
19
|
-
* (`docker create --pull never` + `docker cp`; the images are distroless, so
|
|
20
|
-
* there is no shell to cat it): the app gateway image first — the one
|
|
21
|
-
* `harbour dev` pulls — then the session fixture image, else the vendored
|
|
22
|
-
* copy. Never pulls: `harbour check` must stay a local, seconds-long step.
|
|
23
|
-
*/
|
|
24
|
-
export async function loadDatabaseGate(bundle, run) {
|
|
25
|
-
for (const [source, image] of [["gateway", bundle.images.appGateway], ["fixture", bundle.images.sessionFixture]]) {
|
|
26
|
-
const sql = await copyGateFromImage(image, run);
|
|
27
|
-
if (sql)
|
|
28
|
-
return { sql, source };
|
|
29
|
-
}
|
|
30
|
-
return { sql: DATABASE_GATE_SQL, source: "vendored" };
|
|
31
|
-
}
|
|
32
|
-
async function copyGateFromImage(image, run) {
|
|
33
|
-
const created = await run("docker", ["create", "--pull", "never", image], { quiet: true });
|
|
34
|
-
const id = created.code === 0 ? created.stdout.trim().split("\n").at(-1)?.trim() ?? "" : "";
|
|
35
|
-
if (!id)
|
|
36
|
-
return undefined;
|
|
37
|
-
const dir = await mkdtemp(join(tmpdir(), "harbour-database-gate-"));
|
|
38
|
-
try {
|
|
39
|
-
const target = join(dir, "database-gate.sql");
|
|
40
|
-
const copied = await run("docker", ["cp", `${id}:${DATABASE_GATE_IMAGE_PATH}`, target], { quiet: true });
|
|
41
|
-
if (copied.code !== 0)
|
|
42
|
-
return undefined;
|
|
43
|
-
const sql = await readFile(target, "utf8").catch(() => undefined);
|
|
44
|
-
return sql && sql.includes("harbour database gate") ? sql : undefined;
|
|
45
|
-
}
|
|
46
|
-
finally {
|
|
47
|
-
await rm(dir, { recursive: true, force: true });
|
|
48
|
-
await run("docker", ["rm", "-f", id], { quiet: true });
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
/** The violation lines of a failed gate (`<table|policy|grant>: <problem> — fix: <statement>`), psql's framing dropped. */
|
|
52
|
-
export function gateViolations(stderr) {
|
|
53
|
-
const lines = stderr.split("\n").map(line => line.trim()).filter(Boolean);
|
|
54
|
-
const start = lines.findIndex(line => line.includes("harbour database gate:"));
|
|
55
|
-
const body = (start >= 0 ? lines.slice(start + 1) : lines).filter(line => !/^(CONTEXT:|NOTICE:|DO$)/.test(line));
|
|
56
|
-
if (body.length)
|
|
57
|
-
return body;
|
|
58
|
-
if (start >= 0)
|
|
59
|
-
return [lines[start].replace(/^.*?ERROR:\s*/, "")];
|
|
60
|
-
return [lines.at(-1) ?? "psql error"];
|
|
61
|
-
}
|