@isomorph.ai/cli 0.2.0

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.
Files changed (30) hide show
  1. package/README.md +55 -0
  2. package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
  3. package/dist/packages/harbour-cli/src/app-schema.js +142 -0
  4. package/dist/packages/harbour-cli/src/auth.js +197 -0
  5. package/dist/packages/harbour-cli/src/check.js +317 -0
  6. package/dist/packages/harbour-cli/src/cli.js +321 -0
  7. package/dist/packages/harbour-cli/src/config.js +97 -0
  8. package/dist/packages/harbour-cli/src/dev.js +125 -0
  9. package/dist/packages/harbour-cli/src/forwarder.js +168 -0
  10. package/dist/packages/harbour-cli/src/integrations.js +385 -0
  11. package/dist/packages/harbour-cli/src/jobs.js +53 -0
  12. package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
  13. package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
  14. package/dist/packages/harbour-cli/src/kit.js +149 -0
  15. package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
  16. package/dist/packages/harbour-cli/src/operations.js +382 -0
  17. package/dist/packages/harbour-cli/src/output.js +273 -0
  18. package/dist/packages/harbour-cli/src/productionise.js +508 -0
  19. package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
  20. package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
  21. package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
  22. package/dist/packages/harbour-cli/src/starter.js +557 -0
  23. package/dist/packages/harbour-cli/src/upload.js +163 -0
  24. package/dist/packages/harbour-cli/src/version.js +1 -0
  25. package/dist/src/analyzer.js +685 -0
  26. package/dist/src/contracts.js +82 -0
  27. package/dist/src/digest.js +26 -0
  28. package/dist/src/secret-paths.js +38 -0
  29. package/dist/src/source-intake.js +125 -0
  30. package/package.json +43 -0
@@ -0,0 +1,557 @@
1
+ import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { bundleDiff } from "./kit-bundle.js";
4
+ import { agentSetup, MANAGED_END, MANAGED_START, upsertManagedBlock } from "./agent-setup.js";
5
+ import { assertKitCurrent, emptyDeclaration, KIT_DIRECTORY, kitPaths, LEGACY_KIT_DIRECTORY, newKitLock, readKitLock, writeKitLock } from "./kit.js";
6
+ import { CliError } from "./output.js";
7
+ import { isPristine } from "./retained-checks.js";
8
+ export { MANAGED_END, MANAGED_START };
9
+ /**
10
+ * Creates the starter in an empty directory, or adds the missing kit files to
11
+ * an existing Vite + React app. User files are never overwritten: a path that
12
+ * exists is reported as kept. Instruction files get a managed block appended, and
13
+ * the user-level agent guide is installed so the agents know the kit from any folder.
14
+ */
15
+ export async function initKit(root, bundle, options = {}) {
16
+ await mkdir(root, { recursive: true });
17
+ const existingPackage = await readJson(join(root, "package.json"));
18
+ const emptyDir = !existingPackage && !(await exists(join(root, "src")));
19
+ if (existingPackage && !isSupportedApp(existingPackage))
20
+ throw new CliError("APP_UNSUPPORTED", "isomorph init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).");
21
+ // An app from a CLI older than 0.2.0 is moved to this layout by `--upgrade`
22
+ // and by nothing else: a plain `init` over it would write a second, empty kit
23
+ // beside the one it has. Refused before anything, the user-level skill included, is written.
24
+ if (!options.upgrade)
25
+ await assertKitCurrent(root);
26
+ const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [], removed: [] } };
27
+ if (options.upgrade) {
28
+ await migrateLegacyKit(root, bundle, result);
29
+ await rewriteSdkReferences(root, bundle.sdk.package, result);
30
+ }
31
+ const write = async (path, content) => {
32
+ const absolute = join(root, path);
33
+ if (await exists(absolute)) {
34
+ result.kept.push(path);
35
+ return;
36
+ }
37
+ await mkdir(dirname(absolute), { recursive: true });
38
+ await writeFile(absolute, content);
39
+ result.created.push(path);
40
+ };
41
+ // The starter (its source and schema) is created only when there is no app here
42
+ // yet; kit infrastructure is written on every path, including `--upgrade`.
43
+ const appFiles = emptyDir ? starterFiles(bundle) : {};
44
+ for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles() }))
45
+ await write(path, content);
46
+ await appendManaged(root, ".gitignore", GITIGNORE_LINES, result, "\n");
47
+ for (const file of ["CLAUDE.md", "AGENTS.md"])
48
+ await appendManaged(root, file, managedBlock(), result, "\n\n", MANAGED_START, MANAGED_END);
49
+ await write(".claude/skills/isomorph-kit/SKILL.md", SKILL_FILE);
50
+ const previous = await readKitLock(root);
51
+ if (!previous) {
52
+ await writeKitLock(root, newKitLock(bundle, options.tenantId ?? ""));
53
+ result.created.push(".isomorph/kit.lock.json");
54
+ }
55
+ else if (options.upgrade) {
56
+ // Already re-pinned when the migration moved the lock: then the diff is the migration's and the file is kept here.
57
+ const changes = bundleDiff(previous.bundle, bundle);
58
+ result.bundleChanges.push(...changes);
59
+ if (changes.length) {
60
+ await writeKitLock(root, { ...previous, bundle });
61
+ result.updated.push(".isomorph/kit.lock.json");
62
+ }
63
+ else
64
+ result.kept.push(".isomorph/kit.lock.json");
65
+ }
66
+ else
67
+ result.kept.push(".isomorph/kit.lock.json");
68
+ // A file the migration rewrote is reported once, as updated, not again as kept by the pass after it.
69
+ result.kept = result.kept.filter(path => !result.updated.includes(path));
70
+ return result;
71
+ }
72
+ /** The kit layout of CLI releases before 0.2.0, as `init --upgrade` finds it: everything here is what that layout wrote, nothing an app author chose. */
73
+ const LEGACY = {
74
+ start: "<!-- harbour:kit:start -->",
75
+ end: "<!-- harbour:kit:end -->",
76
+ generatedPrefix: "// harbour:generated sha256:",
77
+ gitignoreLine: ".harbour/local/",
78
+ skill: ".claude/skills/harbour-kit/SKILL.md",
79
+ sdkPackage: "@harbour/app-sdk",
80
+ /** The SDK's exported type names, renamed with the package. */
81
+ typeNames: /\bHarbour(User|ErrorCategory|Error|Client|Result|CompatSource)\b/g
82
+ };
83
+ /**
84
+ * Moves an app set up by a CLI older than 0.2.0 to this kit, once, and only under
85
+ * `init --upgrade`: the kit directory and its five entries are renamed (git records
86
+ * renames), the two committed schema ids move with it, the `.gitignore` line, the
87
+ * managed block in CLAUDE.md / AGENTS.md and the project skill are rewritten, and
88
+ * the checks the old kit generated are deleted for the next `check` to regenerate.
89
+ * A check the author wrote or edited is kept, as everywhere else. Nothing happens
90
+ * when there is no old layout, or when the current one already exists beside it.
91
+ */
92
+ async function migrateLegacyKit(root, bundle, result) {
93
+ const legacy = join(root, LEGACY_KIT_DIRECTORY);
94
+ const paths = kitPaths(root);
95
+ if (!(await exists(legacy)) || await exists(paths.kit))
96
+ return;
97
+ const touched = (path) => result.updated.push(path);
98
+ await mkdir(paths.kit, { recursive: true });
99
+ for (const entry of ["kit.lock.json", "integrations.json", "checks", "ai-inventory.json", "local"]) {
100
+ if (!(await exists(join(legacy, entry))))
101
+ continue;
102
+ await rename(join(legacy, entry), join(paths.kit, entry));
103
+ touched(`${KIT_DIRECTORY}/${entry}`);
104
+ }
105
+ await rmdir(legacy).catch(() => undefined); // anything else in it was never the kit's
106
+ const declaration = await readJson(paths.declaration);
107
+ if (declaration)
108
+ await writeFile(paths.declaration, `${JSON.stringify({ ...declaration, schema: emptyDeclaration().schema }, null, 2)}\n`);
109
+ const lock = await readJson(paths.lock);
110
+ if (lock) {
111
+ result.bundleChanges = bundleDiff(lock.bundle, bundle);
112
+ await writeKitLock(root, { ...newKitLock(bundle, lock.tenantId, lock.appId), createdAt: lock.createdAt });
113
+ }
114
+ const ignore = join(root, ".gitignore");
115
+ const ignored = (await readFile(ignore, "utf8").catch(() => undefined))?.split("\n");
116
+ if (ignored?.includes(LEGACY.gitignoreLine)) {
117
+ await writeFile(ignore, ignored.map(line => line === LEGACY.gitignoreLine ? `${KIT_DIRECTORY}/local/` : line).join("\n"));
118
+ touched(".gitignore");
119
+ }
120
+ for (const file of ["CLAUDE.md", "AGENTS.md"]) {
121
+ const current = await readFile(join(root, file), "utf8").catch(() => "");
122
+ if (current.includes(LEGACY.start) && current.includes(LEGACY.end)) {
123
+ await upsertManagedBlock(join(root, file), managedBlock(), { start: LEGACY.start, end: LEGACY.end });
124
+ touched(file);
125
+ }
126
+ }
127
+ const skill = join(root, LEGACY.skill);
128
+ if ((await readFile(skill, "utf8").catch(() => undefined)) === LEGACY_SKILL_FILE) {
129
+ await rm(skill);
130
+ await rmdir(dirname(skill)).catch(() => undefined);
131
+ touched(LEGACY.skill);
132
+ }
133
+ for (const name of await readdir(paths.checks).catch(() => [])) {
134
+ if (!isPristine(await readFile(join(paths.checks, name), "utf8").catch(() => ""), LEGACY.generatedPrefix))
135
+ continue;
136
+ await rm(join(paths.checks, name));
137
+ touched(`${KIT_DIRECTORY}/checks/${name}`);
138
+ }
139
+ }
140
+ /**
141
+ * Rewrites the SDK's previous package name (and, in `package.json`, the tarball
142
+ * path the old kit staged it under) and its six exported type names in the app's
143
+ * own files: `package.json`, `src/`, `jobs/` and the retained checks. Whole words
144
+ * only, so an author's own `HarbourUserProfile` is theirs. Idempotent: an app that
145
+ * names none of them is left untouched and unreported.
146
+ */
147
+ async function rewriteSdkReferences(root, sdkPackage, result) {
148
+ const rewrite = (text) => text.replaceAll(`"${LEGACY.sdkPackage}"`, `"${sdkPackage}"`).replaceAll(`'${LEGACY.sdkPackage}'`, `'${sdkPackage}'`).replace(LEGACY.typeNames, "Isomorph$1");
149
+ for (const path of ["package.json", ...(await appSourceFiles(root))]) {
150
+ const current = await readFile(join(root, path), "utf8").catch(() => undefined);
151
+ if (current === undefined)
152
+ continue;
153
+ const next = path === "package.json" ? rewrite(current).replaceAll(`file:${LEGACY_KIT_DIRECTORY}/local/sdk/`, `file:${KIT_DIRECTORY}/local/sdk/`) : rewrite(current);
154
+ if (next !== current) {
155
+ await writeFile(join(root, path), next);
156
+ result.updated.push(path);
157
+ }
158
+ }
159
+ }
160
+ /** `src/**`, `jobs/**` and the retained checks, relative to the app root. */
161
+ async function appSourceFiles(root) {
162
+ const files = [];
163
+ const walk = async (directory) => {
164
+ for (const entry of await readdir(join(root, directory), { withFileTypes: true }).catch(() => [])) {
165
+ const path = `${directory}/${entry.name}`;
166
+ if (entry.isDirectory())
167
+ await walk(path);
168
+ else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name))
169
+ files.push(path);
170
+ }
171
+ };
172
+ for (const directory of ["src", "jobs", `${KIT_DIRECTORY}/checks`])
173
+ await walk(directory);
174
+ return files.sort();
175
+ }
176
+ function isSupportedApp(pkg) {
177
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
178
+ return Boolean(deps.vite && deps.react);
179
+ }
180
+ async function appendManaged(root, file, block, result, separator, start, end) {
181
+ const absolute = join(root, file);
182
+ if (start && end) {
183
+ result[await upsertManagedBlock(absolute, block, { start, end, separator })].push(file);
184
+ return;
185
+ }
186
+ const current = await readFile(absolute, "utf8").catch(() => undefined);
187
+ if (current === undefined) {
188
+ await writeFile(absolute, `${block}\n`);
189
+ result.created.push(file);
190
+ return;
191
+ }
192
+ if (block.split("\n").every(line => current.split("\n").includes(line))) {
193
+ result.kept.push(file);
194
+ return;
195
+ }
196
+ await writeFile(absolute, `${current.replace(/\n*$/, "")}${separator}${block}\n`);
197
+ result.updated.push(file);
198
+ }
199
+ const exists = (path) => stat(path).then(() => true, () => false);
200
+ const readJson = (path) => readFile(path, "utf8").then(text => JSON.parse(text), () => undefined);
201
+ // ---- Templates -----------------------------------------------------------------
202
+ const GITIGNORE_LINES = ["node_modules/", "dist/", ".isomorph/local/"].join("\n");
203
+ /** The per-app rules, condensed from the pipeline's own briefs (browser SDK, route auth policy, company SSO) for a kit app. */
204
+ export function managedBlock() {
205
+ return [
206
+ MANAGED_START,
207
+ "## Isomorph development kit",
208
+ "",
209
+ "This app runs on Isomorph. Keep these rules; `isomorph check` and the deployment pipeline enforce them.",
210
+ "",
211
+ "- Identity, data and files go through `@isomorph.ai/app-sdk` only: `isomorph.identity.current()`, `isomorph.data.from(table)`, `isomorph.files.*`. Never open a database, storage bucket or company system from browser code, and never `fetch` a company URL directly.",
212
+ "- Company systems (Slack, Gmail, warehouse views) are reached only through `isomorph.integrations.execute(connection, {operation, resource, input})` with the connection and operation declared in `.isomorph/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.",
213
+ "- `.isomorph/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 `isomorph integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — then ask for access — one request per connection, which IT approves once for every environment (development, preview and production together): `isomorph dev` and `isomorph productionise` file it for you, and `isomorph integrations request <connection> --reason \"<why>\" --app-root .` files it now. Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. Before a real integration test, read `isomorph integrations status --app-root . --json`: show pending IT approval separately from personal consent, and test ready destinations independently. After a partial send, retry only the failed destination with its original idempotency key; do not regenerate or resend a successful destination. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
214
+ "- Scheduled work lives only in `jobs/<name>.ts`: export one literal UTC cron as `schedule` and one default async handler. Test it immediately with `isomorph jobs run <name> --app-root . --scheduled-at <UTC> --json`; this uses local data and safe fixtures. If the person explicitly asks for a company-action test, use `--real` only after the exact action has development approval. Tell them: \"Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved.\" Isomorph runs the same declaration automatically after deployment; never use `setInterval`, an effect or a browser timer as a scheduler.",
215
+ "- In browser code, a Slack message is sent only after the person presses an explicit Send control; pass a fresh UUID `idempotencyKey` per send action and reuse the same key when retrying that action. Never send from an effect or browser timer, and never send during checks. A `jobs/*.ts` handler is the only scheduled-send path and acts as the app: only an app-mode operation may run there, after the person explicitly requested it and IT granted its exact destination for that environment. Use a deterministic idempotency key for the business period and destination; local job runs and checks use fixtures and send nothing.",
216
+ "- 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.",
217
+ "- Consent is only for user-identity operations (`slack.channel.history`, `gmail.*`, and `slack.message.post` declared `\"identity\": \"user\"`): call `isomorph.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.",
218
+ "- A retained check that calls `isomorph.integrations.execute` is answered, under `isomorph 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 `isomorph check --integrations` (reads) and the preview's own smoke test.",
219
+ "- AI goes through `isomorph.ai` only — `isomorph.ai.chat({ messages, maxTokens })` on the client exported by `src/isomorph.client.ts`, never through a wrapper function (the gate reads only the direct call) — 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 `isomorph check` writes `.isomorph/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.",
220
+ "- Authentication is owned by Isomorph 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)`.",
221
+ "- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
222
+ "- No secrets, tokens, `.env` values or fetched company content in source. `.isomorph/local/` is ignored and never committed; `.isomorph/integrations.json` and `.isomorph/kit.lock.json` are committed.",
223
+ "- Schema changes are SQL files in `migrations/`, applied by `isomorph dev` and `isomorph check`. Every table: ENABLE ROW LEVEL SECURITY + a policy; GRANT every verb a policy allows to `harbour_app_gateway` and to no other role — `isomorph check` runs the pipeline's database gate and names any table/policy/grant that breaks this, with the fix.",
224
+ "- `.isomorph/checks/` holds the app's retained journeys: one per capability the app's own code uses (`isomorph.data.*`, `isomorph.files.*`, actions, realtime, telemetry), plus a cross-user denial per owner-scoped table. `isomorph 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: `isomorph 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: `isomorph check` deletes the ones it generated, and one you wrote or edited is yours to delete, because `isomorph check` and the deployment pipeline replay `.isomorph/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.",
225
+ "- A generated check starts with a `// isomorph:generated` line carrying a digest of its own body; that is how `isomorph check` knows the file is still its to rewrite and remove. Edit one and it becomes yours: Isomorph 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 `isomorph.files.*`. Replace the notes table with the app's own, or remove the \"Private files\" section, and the next `isomorph check` rewrites and deletes to match — `.isomorph/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.",
226
+ "- Commands: `isomorph dev --app-root .` (local runtime), `isomorph 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), `isomorph integrations catalog --app-root .` (the company's connections and approved names, before declaring one), `isomorph integrations request <connection> --reason <text> --app-root .`, `isomorph integrations status --app-root .`, `isomorph productionise --app-root .`. Company calls in `dev` use the account from `isomorph login`; the local fixture user is only the app's identity.",
227
+ "- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/isomorph-kit/SKILL.md`. The plain-English workflow (what to run when the person says \"run it\", \"check it\", \"ship it\") is the user-level `isomorph` skill installed by `isomorph agent-setup` (Claude Code: `~/.claude/skills/isomorph`, Codex: `~/.codex/skills/isomorph`); use it for every request about this app.",
228
+ MANAGED_END
229
+ ].join("\n");
230
+ }
231
+ const SKILL_FILE = `---
232
+ name: isomorph-kit
233
+ description: Build, run and check this Isomorph app with the Isomorph CLI (dev, check, integrations, productionise).
234
+ ---
235
+
236
+ Follow the "Isomorph development kit" block in CLAUDE.md / AGENTS.md. Workflow:
237
+
238
+ 1. \`isomorph dev --app-root .\` starts Postgres, storage and one Isomorph gateway (session identities, fixtures, realtime) plus Vite behind one loopback origin printed in the banner.
239
+ 2. Edit \`src/\` and \`migrations/\`. Use the SDK only. \`.isomorph/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.
240
+ 3. \`.isomorph/checks/\` is generated, not written by hand: \`isomorph 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: Isomorph keeps it, stops managing it and never removes it, so deleting it when the feature goes is then your job. \`isomorph 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.
241
+ 4. \`isomorph check --app-root .\` before every hand-off; read \`.isomorph/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only); a retained check that calls \`isomorph.integrations.execute\` is answered by the gate's fixture (the contract's shape for what the app declared, nothing sent or read) and the passed check says so. A governed AI call (\`isomorph.ai.chat\`) is exercised for real through the development route while \`isomorph dev\` is up; the pipeline answers it with a canned completion and reports it as not tested.
242
+ 5. \`isomorph integrations request <connection> --reason "<why>" --app-root .\` asks IT for access now — one request per connection, approved once for every environment; \`isomorph dev\` (signed in) and \`isomorph productionise\` file it for you. Pending is not ready.
243
+ 6. \`isomorph productionise --app-root .\` saves and deploys the preview; \`isomorph promote\` after the person has tested it.
244
+ `;
245
+ /** The project skill CLI releases before 0.2.0 wrote, byte for byte: `init --upgrade` deletes the old file only when it is still exactly this. */
246
+ export const LEGACY_SKILL_FILE = `---
247
+ name: harbour-kit
248
+ description: Build, run and check this Harbour app with the Harbour CLI (dev, check, integrations, productionise).
249
+ ---
250
+
251
+ Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
252
+
253
+ 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.
254
+ 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.
255
+ 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.
256
+ 4. \`harbour check --app-root .\` before every hand-off; read \`.harbour/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only); a retained check that calls \`harbour.integrations.execute\` is answered by the gate's fixture (the contract's shape for what the app declared, nothing sent or read) and the passed check says so. A governed AI call (\`harbour.ai.chat\`) is exercised for real through the development route while \`harbour dev\` is up; the pipeline answers it with a canned completion and reports it as not tested.
257
+ 5. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for access now — one request per connection, approved once for every environment; \`harbour dev\` (signed in) and \`harbour productionise\` file it for you. Pending is not ready.
258
+ 6. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
259
+ `;
260
+ /**
261
+ * Kit infrastructure: structural files every kit app needs, on every path —
262
+ * `init` in an empty directory, `init` over an existing Vite + React app, and
263
+ * `--upgrade`. Nothing here is example content, because `write` only fills a
264
+ * gap: an app that deleted a file it does not want must not have it recreated.
265
+ */
266
+ function kitFiles() {
267
+ return {
268
+ // No connection. The starter calls no company system, and a connection the
269
+ // app does not call is a deploy that never happens: `isomorph productionise`
270
+ // files the access request for every declared connection and refuses to
271
+ // start until IT has approved it. One is added when the app really calls it — the two steps and the
272
+ // worked Slack and warehouse examples are in README.md, in the "Isomorph
273
+ // development kit" block of AGENTS.md / CLAUDE.md and in src/App.tsx. They
274
+ // are not in this file: it is read with JSON.parse here and again by the
275
+ // pipeline's source intake, so it cannot carry comments.
276
+ ".isomorph/integrations.json": `${JSON.stringify(emptyDeclaration(), null, 2)}\n`
277
+ };
278
+ }
279
+ /**
280
+ * The starter app itself: written only when `init` is creating one in an empty
281
+ * directory. This includes the notes migration — the starter's own schema, not
282
+ * kit infrastructure. Writing it into an app that has its own schema recreates a
283
+ * `notes` table it never had, and a check that queries it then fails `flow` on
284
+ * the next deploy (#456).
285
+ *
286
+ * The retained checks are deliberately not here, and not written by `init` at
287
+ * all. `isomorph check` generates them from this schema — read back out of the
288
+ * database it replays the migrations into — and from this source, so "one
289
+ * journey per capability the UI declares, plus the cross-user denial per
290
+ * owner-scoped table" is a property of the generator rather than three files
291
+ * that have to be kept in step with `src/App.tsx` by hand. `init` has no
292
+ * database and so invents nothing: `.isomorph/checks/` arrives with the first
293
+ * `isomorph check`, which is also the command that keeps it right afterwards.
294
+ */
295
+ function starterFiles(bundle) {
296
+ return {
297
+ "migrations/0001_notes.sql": MIGRATION,
298
+ "package.json": `${JSON.stringify({
299
+ name: "isomorph-app", private: true, version: "0.1.0", type: "module",
300
+ scripts: { dev: "vite", build: "tsc --noEmit && vite build", preview: "vite preview" },
301
+ // The SDK is distributed as a tarball in the kit bundle, not from the public registry:
302
+ // `isomorph dev` installs it from ISOMORPH_KIT_SDK_TARBALL or the manifest's sdk.url and
303
+ // verifies its sha256 against .isomorph/kit.lock.json before use.
304
+ dependencies: { [bundle.sdk.package]: bundle.sdk.version ?? "1.0.0", react: "^18.3.1", "react-dom": "^18.3.1" },
305
+ devDependencies: { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", typescript: "^5.6.3", vite: "^5.4.11" }
306
+ }, null, 2)}\n`,
307
+ "tsconfig.json": `${JSON.stringify({ compilerOptions: { target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: true, isolatedModules: true }, include: ["src"] }, null, 2)}\n`,
308
+ "vite.config.ts": VITE_CONFIG,
309
+ "index.html": `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>Isomorph app</title>\n </head>\n <body>\n <div id="root"></div>\n <script type="module" src="/src/main.tsx"></script>\n </body>\n</html>\n`,
310
+ "src/vite-env.d.ts": "/// <reference types=\"vite/client\" />\n",
311
+ "src/main.tsx": `import { StrictMode } from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\n\ncreateRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);\n`,
312
+ "src/isomorph.client.ts": ISOMORPH_CLIENT,
313
+ "src/App.tsx": APP,
314
+ "README.md": STARTER_README
315
+ };
316
+ }
317
+ const STARTER_README = `# Isomorph app
318
+
319
+ Created by \`isomorph init\`. Run \`isomorph dev --app-root .\` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.
320
+
321
+ \`.isomorph/checks/\` holds one journey per capability the app uses (\`notes-journey.mjs\` for data, \`files-journey.mjs\` for files) and \`notes-cross-user.mjs\`, which proves a second signed-in person cannot read, update or delete another person's note — the same denial the deployment pipeline's write probe asserts.
322
+
323
+ The directory is empty until the first \`isomorph check\`, which writes those three: it replays \`migrations/\` into a disposable database, reads this app's own tables back out of it, and generates a journey for each table and capability \`src/App.tsx\` uses. Every later \`isomorph check\` keeps them in step — adding a journey when the app starts using a table or capability, removing one when the app stops. Each begins with a \`// isomorph:generated\` line carrying a digest of its own body — edit a check and Isomorph keeps your version, stops updating it and never removes it, and it is yours to maintain from then on.
324
+
325
+ The checks and the code are one pair, and \`isomorph check\` and the deployment pipeline's \`flow\` gate enforce the pair in both directions against a real App Gateway. Both directions refuse the deploy:
326
+
327
+ - **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.
328
+ - **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 \`isomorph 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 \`isomorph.files.*\` — and it deletes \`.isomorph/checks/files-journey.mjs\` for you. A check you have edited is not Isomorph's to remove, so \`rm .isomorph/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\`.
329
+
330
+ ## Adding AI ("Summarise my notes")
331
+
332
+ The starter calls no AI. When the person asks for it, add one call — \`isomorph.ai.chat(...)\` on the client exported by \`src/isomorph.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.
333
+
334
+ \`\`\`tsx
335
+ import { isomorph, errorCode } from "./isomorph.client";
336
+
337
+ const summarise = async () => {
338
+ try {
339
+ const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
340
+ setSummary(reply.content);
341
+ } catch (error) {
342
+ 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.");
343
+ }
344
+ };
345
+ // <button type="button" onClick={() => void summarise()}>Summarise my notes</button>
346
+ \`\`\`
347
+
348
+ That one call is the declaration: \`isomorph check\` writes \`.isomorph/checks/ai-journey.mjs\` for it and the deployment registers the \`ai\` capability. Locally the journey makes a real governed call through the Isomorph development route with your \`isomorph 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 \`isomorph check\` deletes the journey.
349
+
350
+ ## Adding a company system (Slack, Gmail, a warehouse view)
351
+
352
+ \`.isomorph/integrations.json\` ships with no connections:
353
+
354
+ \`\`\`json
355
+ { "schema": "isomorph.app-integrations/2.0", "connections": {} }
356
+ \`\`\`
357
+
358
+ That is deliberate. Every declared connection blocks the deploy until IT grants it — \`isomorph productionise\` refuses to start while one is missing — so a connection the app does not call is a deploy that never happens. The file is strict JSON and cannot hold comments, which is why this note lives here.
359
+
360
+ Add one in two steps, when the app really calls it:
361
+
362
+ 1. Declare the connection and only the operations the app calls, under \`connections\`. Slack:
363
+
364
+ \`\`\`json
365
+ "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"] } } }
366
+ \`\`\`
367
+
368
+ \`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\`.
369
+
370
+ A warehouse view:
371
+
372
+ \`\`\`json
373
+ "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
374
+ \`\`\`
375
+
376
+ 2. Ask IT for access: one request per connection, and IT approves it once for every environment (development, preview and production together). \`isomorph dev\` (signed in) and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<why>" --app-root .\` files it now. \`isomorph integrations status --app-root .\` says where each connection stands in every environment; PENDING is not ready, and \`isomorph productionise\` will not deploy until IT has approved every declared connection.
377
+
378
+ Then call it from the app on the client exported by \`src/isomorph.client.ts\` — always this exact shape, never a wrapper function, because the kit gate derives what the app uses from it:
379
+
380
+ \`\`\`ts
381
+ const report = await isomorph.integrations.execute("sales-warehouse", {
382
+ operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
383
+ });
384
+ \`\`\`
385
+
386
+ In browser code, a send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a browser timer or a check. Scheduled work lives in \`jobs/<name>.ts\`; its handler acts as the app, so only app-mode sends may run there, with an approved destination and a deterministic idempotency key for the business period and destination. Test the schedule immediately with \`isomorph jobs run <name> --app-root . --scheduled-at <UTC> --json\`; local job runs and checks use fixtures and send nothing, and the deployed Kubernetes schedule owns the real clock. Consent is only for user-identity operations (\`slack.channel.history\`, \`gmail.*\`, and \`slack.message.post\` declared \`"identity": "user"\`): \`isomorph.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 \`.isomorph/checks/\`, and anything it stops calling loses its check in the same edit. Keep request construction in an app function that both the button and its retained check call, so the check exercises the actual payload. Do not bypass the SDK types with a generic string/unknown wrapper. A check that calls \`isomorph.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; \`isomorph check --integrations\` is what exercises real access.
387
+ `;
388
+ const VITE_CONFIG = `import { defineConfig } from "vite";
389
+ import react from "@vitejs/plugin-react";
390
+
391
+ // \`isomorph dev\` fronts Vite with one loopback origin and sets ISOMORPH_LOCAL_ORIGIN;
392
+ // the proxy below keeps SDK calls working if Vite is opened directly.
393
+ const isomorphOrigin = process.env.ISOMORPH_LOCAL_ORIGIN ?? "http://127.0.0.1:4180";
394
+
395
+ export default defineConfig({
396
+ plugins: [react()],
397
+ server: {
398
+ host: "127.0.0.1",
399
+ port: Number(process.env.ISOMORPH_VITE_PORT ?? 5173),
400
+ strictPort: true,
401
+ proxy: { "/_harbour": { target: isomorphOrigin, changeOrigin: false } }
402
+ }
403
+ });
404
+ `;
405
+ const ISOMORPH_CLIENT = `import { createClient } from "@isomorph.ai/app-sdk";
406
+
407
+ // Single client for the whole app. Identity, data and files come from Isomorph;
408
+ // the same calls work locally (isomorph dev) and in preview/production.
409
+ export const isomorph = createClient();
410
+
411
+ // One call shape, everywhere: the SDK's own namespaces on this client —
412
+ // isomorph.integrations.execute(...), isomorph.ai.chat(...), isomorph.data.from(...),
413
+ // isomorph.files.* — and no wrapper functions around them. The kit gate derives
414
+ // what the app uses from exactly these calls; a wrapper would hide them.
415
+ //
416
+ // Gmail sends plain text as the signed-in person, after IT approval and personal consent.
417
+ // Inside an authorized Send handler (use the catalog's actual connection and mailbox):
418
+ // await isomorph.integrations.execute("company-gmail", {
419
+ // operation: "gmail.message.send", resource: "inbox",
420
+ // input: { to: ["recipient@example.com"], subject: "Update", text: "Your message" },
421
+ // idempotencyKey: crypto.randomUUID()
422
+ // });
423
+ //
424
+ // Governed AI through the platform's LLM gateway: the app never holds a provider key and
425
+ // IT sees every call. The starter calls no AI; when the person asks for it, add the one
426
+ // call — isomorph.ai.chat({ messages, maxTokens }) — behind an explicit control they press,
427
+ // never on load. README.md has the "Summarise my notes" example. An IsomorphError whose
428
+ // errorCode() is AI_NOT_ENABLED means IT has not enabled a provider yet: keep the app working.
429
+
430
+ export function errorCode(error: unknown): string {
431
+ const details = (error as { details?: { code?: string } } | undefined)?.details;
432
+ return typeof details?.code === "string" ? details.code : (error as { category?: string })?.category ?? "UNKNOWN";
433
+ }
434
+ `;
435
+ const APP = `import { useCallback, useEffect, useState } from "react";
436
+ import type { IsomorphUser } from "@isomorph.ai/app-sdk";
437
+ import { isomorph } from "./isomorph.client";
438
+
439
+ type Note = { id: number; title: string; done: boolean; created_at: string };
440
+ type StoredFile = { path?: string; name?: string; size?: number };
441
+
442
+ export function App() {
443
+ const [user, setUser] = useState<IsomorphUser | null>(null);
444
+ const [notes, setNotes] = useState<Note[]>([]);
445
+ const [title, setTitle] = useState("");
446
+ const [files, setFiles] = useState<StoredFile[]>([]);
447
+ const [error, setError] = useState<string | null>(null);
448
+
449
+ const loadNotes = useCallback(async () => {
450
+ const { data } = await isomorph.data.from<Note>("notes").select("*").order("created_at", { ascending: false });
451
+ setNotes(data);
452
+ }, []);
453
+ // files capability — this call and \`upload\` below are what .isomorph/checks/files-journey.mjs exercises.
454
+ const loadFiles = useCallback(async () => { setFiles((await isomorph.files.list("private/")) as StoredFile[]); }, []);
455
+
456
+ useEffect(() => {
457
+ isomorph.identity.current().then(setUser).catch(err => setError(String(err)));
458
+ loadNotes().catch(err => setError(String(err)));
459
+ loadFiles().catch(err => setError(String(err)));
460
+ return isomorph.identity.onChange(setUser);
461
+ }, [loadNotes, loadFiles]);
462
+
463
+ const addNote = async () => {
464
+ if (!title.trim()) return;
465
+ await isomorph.data.from("notes").insert({ title: title.trim() });
466
+ setTitle("");
467
+ await loadNotes();
468
+ };
469
+ const toggle = async (note: Note) => { await isomorph.data.from("notes").update({ done: !note.done }).eq("id", note.id); await loadNotes(); };
470
+ const remove = async (note: Note) => { await isomorph.data.from("notes").delete().eq("id", note.id); await loadNotes(); };
471
+ const upload = async (file: File) => { await isomorph.files.upload(\`private/\${file.name}\`, file); await loadFiles(); }; // .isomorph/checks/files-journey.mjs
472
+
473
+ // Company systems are not part of the starter: .isomorph/integrations.json declares
474
+ // nothing, so nothing about this app waits on IT. Add one only when the app really
475
+ // calls it, in two steps (README.md has the same two steps in full).
476
+ // 1. Declare the connection and only the operations this app calls, under
477
+ // "connections" in .isomorph/integrations.json — every declared connection blocks
478
+ // the deploy until IT grants it:
479
+ // "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
480
+ // "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"] } } }
481
+ // (slack.message.post "identity": "app" posts as the company's Slack bot, Isomorph AI, under the
482
+ // IT-approved presentation name; "identity": "user" posts as the signed-in person after their consent.
483
+ // A post may name the approved mode it runs under — { ..., mode: "app" } — and must once IT approved both.)
484
+ // 2. Request access — one request per connection, which IT approves once for every
485
+ // environment; isomorph dev and isomorph productionise file it for you, or file it now with
486
+ // isomorph integrations request sales-warehouse --reason "<why>" --app-root . — then uncomment
487
+ // (the isomorph client is already imported from "./isomorph.client"):
488
+ // const report = await isomorph.integrations.execute("sales-warehouse", {
489
+ // operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
490
+ // });
491
+ // A Slack send runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a
492
+ // fresh UUID idempotencyKey per press — never from an effect, a timer or a check.
493
+ //
494
+ // Governed AI is not part of the starter either. When the person asks for it, add ONE
495
+ // call isomorph.ai.chat on the client from "./isomorph.client" behind a control they press (README.md
496
+ // "Adding AI"), never an OpenAI/Anthropic key or SDK; isomorph check then writes
497
+ // .isomorph/checks/ai-journey.mjs for it, and deletes it again if the call goes:
498
+ // const summary = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
499
+
500
+ return (
501
+ <main style={{ fontFamily: "system-ui", maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
502
+ <h1>Isomorph starter</h1>
503
+ <p>Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Isomorph; locally the fixture user).</p>
504
+ {error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
505
+
506
+ {/* Notes — paired with .isomorph/checks/notes-journey.mjs (data) and
507
+ .isomorph/checks/notes-cross-user.mjs, both generated from this section and
508
+ migrations/0001_notes.sql. Replace this table with the app's own and run
509
+ \`isomorph check\`: it rewrites both checks for the new table in that same edit. */}
510
+ <section>
511
+ <h2>Notes</h2>
512
+ <form onSubmit={event => { event.preventDefault(); void addNote(); }}>
513
+ <input value={title} onChange={event => setTitle(event.target.value)} placeholder="New note" aria-label="New note" />
514
+ <button type="submit">Add</button>
515
+ </form>
516
+ <ul>
517
+ {notes.map(note => (
518
+ <li key={note.id}>
519
+ <label><input type="checkbox" checked={note.done} onChange={() => void toggle(note)} /> {note.title}</label>
520
+ <button type="button" onClick={() => void remove(note)} aria-label={\`Delete \${note.title}\`}>×</button>
521
+ </li>
522
+ ))}
523
+ </ul>
524
+ </section>
525
+
526
+ {/* Private files — the only code in this app that calls isomorph.files.*, and so the
527
+ only reason .isomorph/checks/files-journey.mjs exists. Delete this section and the next
528
+ \`isomorph check\` deletes that check with it; delete it by hand in the same edit if you
529
+ have edited it, because the deploy's flow gate replays .isomorph/checks/ against a real
530
+ App Gateway and refuses an app whose checks exercise a capability its code no longer
531
+ has ("flow.check-failed: files-journey.mjs: exit status 1"). */}
532
+ <section>
533
+ <h2>Private files</h2>
534
+ <input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
535
+ <ul>{files.map((file, index) => <li key={index}>{file.path ?? file.name}{file.size !== undefined ? \` (\${file.size} bytes)\` : ""}</li>)}</ul>
536
+ </section>
537
+ </main>
538
+ );
539
+ }
540
+ `;
541
+ const MIGRATION = `-- Notes table for the starter. Ownership comes from the Isomorph gateway's transaction-local
542
+ -- settings (harbour.user_id / harbour.user_email); the browser never supplies it.
543
+ CREATE TABLE IF NOT EXISTS notes (
544
+ id BIGSERIAL PRIMARY KEY,
545
+ owner_subject TEXT NOT NULL DEFAULT current_setting('harbour.user_id', true),
546
+ title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 500),
547
+ done BOOLEAN NOT NULL DEFAULT FALSE,
548
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
549
+ );
550
+ ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
551
+ DROP POLICY IF EXISTS notes_owner ON notes;
552
+ CREATE POLICY notes_owner ON notes
553
+ USING (owner_subject = current_setting('harbour.user_id', true))
554
+ WITH CHECK (owner_subject = current_setting('harbour.user_id', true));
555
+ GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO harbour_app_gateway;
556
+ GRANT USAGE, SELECT ON SEQUENCE notes_id_seq TO harbour_app_gateway;
557
+ `;