@fourier-labs/harbour 0.1.25 → 0.1.26

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.
@@ -0,0 +1,178 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * One row per application table, as one JSON document. The scoping is the
5
+ * database gate's own (`relkind` r/p outside pg_catalog, information_schema and
6
+ * the platform's harbour_runtime), so "an application table" means the same
7
+ * thing to the generator as it does to the gate that refuses the app.
8
+ */
9
+ const CATALOG_SQL = String.raw `
10
+ SELECT coalesce(json_agg(t ORDER BY t.name), '[]'::json)::text FROM (
11
+ SELECT c.relname AS name,
12
+ (SELECT coalesce(json_agg(json_build_object(
13
+ 'name', a.attname,
14
+ 'type', format_type(a.atttypid, a.atttypmod),
15
+ 'base', y.typname,
16
+ 'category', y.typcategory,
17
+ 'notnull', a.attnotnull,
18
+ 'default', pg_get_expr(d.adbin, d.adrelid),
19
+ 'generated', a.attidentity <> '' OR a.attgenerated <> '' OR coalesce(pg_get_expr(d.adbin, d.adrelid), '') LIKE 'nextval(%',
20
+ 'fk', EXISTS (SELECT 1 FROM pg_constraint f WHERE f.conrelid = a.attrelid AND f.contype = 'f' AND a.attnum = ANY (f.conkey)),
21
+ 'checks', (SELECT coalesce(json_agg(pg_get_constraintdef(k.oid)), '[]'::json) FROM pg_constraint k WHERE k.conrelid = a.attrelid AND k.contype = 'c' AND a.attnum = ANY (k.conkey))
22
+ ) ORDER BY a.attnum), '[]'::json)
23
+ FROM pg_attribute a
24
+ JOIN pg_type y ON y.oid = a.atttypid
25
+ LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
26
+ WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns,
27
+ (SELECT coalesce(json_agg(k.attname ORDER BY u.ord), '[]'::json)
28
+ FROM pg_constraint p
29
+ CROSS JOIN LATERAL unnest(p.conkey) WITH ORDINALITY AS u(num, ord)
30
+ JOIN pg_attribute k ON k.attrelid = p.conrelid AND k.attnum = u.num
31
+ WHERE p.conrelid = c.oid AND p.contype = 'p') AS "primaryKey",
32
+ (SELECT coalesce(json_agg(concat_ws(' ', pg_get_expr(pol.polqual, pol.polrelid), pg_get_expr(pol.polwithcheck, pol.polrelid))), '[]'::json)
33
+ FROM pg_policy pol WHERE pol.polrelid = c.oid) AS policies
34
+ FROM pg_class c
35
+ JOIN pg_namespace n ON n.oid = c.relnamespace
36
+ WHERE c.relkind IN ('r', 'p')
37
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')
38
+ AND n.nspname NOT LIKE 'pg\_toast%' AND n.nspname NOT LIKE 'pg\_temp%'
39
+ ) t;
40
+ `;
41
+ /** A policy that decides rows by the signed-in identity, and the column it decides them on. */
42
+ const OWNER_SCOPE = /([A-Za-z_][A-Za-z0-9_]*)\s*(?:\)|::[a-z ]+)*\s*=\s*current_setting\('harbour\.user_(?:id|email)'/i;
43
+ const IDENTITY = /current_setting\('harbour\.user_(?:id|email)'/i;
44
+ /**
45
+ * The app's tables as the database holds them, or `undefined` when the database
46
+ * did not answer. `undefined` is not "no tables": nothing is generated from, or
47
+ * removed because of, a schema Harbour could not read — deleting an app's
48
+ * retained checks on a failed query would be the worst possible reading of it.
49
+ */
50
+ export async function readAppSchema(source, root) {
51
+ const result = await source.psql(CATALOG_SQL, ["-At"]);
52
+ const text = result.stdout.trim();
53
+ if (result.code !== 0 || !text.startsWith("["))
54
+ return undefined;
55
+ let catalog;
56
+ try {
57
+ catalog = JSON.parse(text);
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ const migrations = await migrationTexts(root);
63
+ const schema = new Map();
64
+ for (const table of catalog) {
65
+ schema.set(table.name, {
66
+ columns: table.columns.map(column => ({
67
+ name: column.name,
68
+ type: columnType(column.base, column.category),
69
+ declaredType: column.type,
70
+ notNull: column.notnull,
71
+ hasDefault: column.default !== null,
72
+ generated: column.generated,
73
+ identityDefault: IDENTITY.test(column.default ?? ""),
74
+ references: column.fk,
75
+ checks: column.checks
76
+ })),
77
+ primaryKey: table.primaryKey,
78
+ ownerScoped: table.policies.some(policy => IDENTITY.test(policy)),
79
+ ownerColumn: table.policies.map(policy => OWNER_SCOPE.exec(policy)?.[1]).find(Boolean),
80
+ file: createdIn(table.name, migrations)
81
+ });
82
+ }
83
+ return schema;
84
+ }
85
+ /**
86
+ * Postgres has already reduced every declared spelling to one base type name
87
+ * (`character varying` to `varchar`, `double precision` to `float8`, `BIGSERIAL`
88
+ * to `int8` with a nextval default), so this is a lookup rather than a parse.
89
+ */
90
+ function columnType(base, category) {
91
+ if (category === "A")
92
+ return "array";
93
+ switch (base) {
94
+ case "text":
95
+ case "varchar":
96
+ case "bpchar":
97
+ case "char":
98
+ case "citext":
99
+ case "name": return "text";
100
+ case "uuid": return "uuid";
101
+ case "bool": return "boolean";
102
+ case "int2":
103
+ case "int4":
104
+ case "int8": return "integer";
105
+ case "numeric":
106
+ case "float4":
107
+ case "float8":
108
+ case "money": return "number";
109
+ case "timestamptz":
110
+ case "timestamp": return "timestamptz";
111
+ case "date": return "date";
112
+ case "time":
113
+ case "timetz": return "time";
114
+ case "json":
115
+ case "jsonb": return "json";
116
+ default: return "unknown";
117
+ }
118
+ }
119
+ async function migrationTexts(root) {
120
+ const directory = join(root, "migrations");
121
+ const names = (await readdir(directory).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
122
+ return Promise.all(names.map(async (name) => ({ name: `migrations/${name}`, text: await readFile(join(directory, name), "utf8").catch(() => "") })));
123
+ }
124
+ /** Which file created a table: the only question the catalog cannot answer, so the only one still asked of the SQL text. */
125
+ function createdIn(table, migrations) {
126
+ const pattern = new RegExp(String.raw `create\s+table\b[^;]*\b${table}\b`, "i");
127
+ return migrations.find(migration => pattern.test(migration.text))?.name ?? "migrations/";
128
+ }
129
+ // ---- CHECK constraints ----------------------------------------------------------
130
+ //
131
+ // `pg_get_constraintdef` deparses, so these read one normal form rather than
132
+ // everything an author might have typed: `BETWEEN a AND b` has already become
133
+ // `>= a AND <= b`, `IN (…)` has become `= ANY (ARRAY[…])`, and each operand
134
+ // carries its cast. Only the shapes a value has to satisfy to be accepted are
135
+ // read; anything else is left alone, and a value the database rejects surfaces
136
+ // as a failing journey under `harbour check` in seconds rather than in the
137
+ // pipeline minutes later.
138
+ /** `char_length(col) >= n`, `length(col) <= n` — including the `((col)::text)` spelling a deparse gives a varchar. */
139
+ export function textLengthBounds(checks, column) {
140
+ let min = 1;
141
+ let max = 500;
142
+ const pattern = new RegExp(String.raw `(?:char_length|length|octet_length)\([^<>=]*\b${column}\b[^<>=]*(<=|<|>=|>|=)\s*(\d+)`, "gi");
143
+ for (const check of checks)
144
+ for (const match of check.matchAll(pattern)) {
145
+ const value = Number(match[2]);
146
+ if (match[1] === "<=" || match[1] === "=")
147
+ max = Math.min(max, value);
148
+ else if (match[1] === "<")
149
+ max = Math.min(max, value - 1);
150
+ else if (match[1] === ">=")
151
+ min = Math.max(min, value);
152
+ else
153
+ min = Math.max(min, value + 1);
154
+ }
155
+ return { min, max: Math.max(max, min) };
156
+ }
157
+ /** `col = ANY (ARRAY['a'::text, 'b'::text])`: the closed set a value must come from. */
158
+ export function allowedLiterals(checks, column) {
159
+ for (const check of checks) {
160
+ const list = new RegExp(String.raw `\b${column}\b[^=]*=\s*ANY\s*[\s(]*ARRAY\[([^\]]*)\]`, "i").exec(check);
161
+ const values = [...(list?.[1] ?? "").matchAll(/'((?:[^']|'')*)'/g)].map(match => match[1].replace(/''/g, "'"));
162
+ if (values.length)
163
+ return values;
164
+ }
165
+ return [];
166
+ }
167
+ /** The smallest number `col > n` / `col >= n` accepts. */
168
+ export function numericMinimum(checks, column) {
169
+ let minimum = 1;
170
+ for (const check of checks) {
171
+ const comparison = new RegExp(String.raw `\b${column}\b[^<>=]*(>=|>)\s*(-?\d+(?:\.\d+)?)`, "i").exec(check);
172
+ if (!comparison)
173
+ continue;
174
+ const value = Number(comparison[2]);
175
+ minimum = Math.max(minimum, comparison[1] === ">" ? value + 1 : value);
176
+ }
177
+ return minimum;
178
+ }
@@ -1,7 +1,9 @@
1
1
  import { readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { readAppSchema } from "./app-schema.js";
3
4
  import { loadDatabaseGate } from "./database-gate.js";
4
5
  import { CoverageLedger, capabilityCallSurface, createCoverageProxy, declaredCapabilities, flowDetail, missingFlowOperations, orphanedChecks, sourceTableVerbs } from "./flow-coverage.js";
6
+ import { describeRetainedChecks, syncRetainedChecks } from "./retained-checks.js";
5
7
  import { CliError } from "./output.js";
6
8
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
7
9
  import { LOCAL, LocalRuntime, identityEnvironment, runCommand, runningOrigin } from "./local-runtime.js";
@@ -11,10 +13,6 @@ export async function runChecks(root, options) {
11
13
  const { output, run, bundle } = options;
12
14
  const checks = [];
13
15
  const record = (name, status, detail) => { checks.push({ name, status, ...(detail ? { detail } : {}) }); output(`${status === "pass" ? "ok " : status === "fail" ? "FAIL" : "skip"} ${name}${detail ? ` — ${indentLines(detail)}` : ""}`); };
14
- const source = await sourceDigest(root);
15
- const previous = await readReport(root);
16
- if (previous && previous.sourceDigest !== source.digest)
17
- output("Source changed since the last report; the previous report is no longer valid.");
18
16
  const declaration = await readDeclaration(root);
19
17
  record("declaration", declaration.errors.length ? "fail" : "pass", declaration.errors[0]);
20
18
  let lockDetail;
@@ -25,10 +23,16 @@ export async function runChecks(root, options) {
25
23
  const build = await run("npm", ["run", "build"], { cwd: root, quiet: true });
26
24
  record("build", build.code === 0 ? "pass" : "fail", build.code === 0 ? undefined : lastLines(build.stderr || build.stdout));
27
25
  const throwaway = LocalRuntime.forCheck(root, run);
26
+ let schema;
28
27
  try {
29
28
  await throwaway.writeCheckFiles(bundle);
30
29
  await throwaway.up();
31
30
  const applied = await throwaway.migrate();
31
+ // The app's own tables, as the database holds them once those files have
32
+ // replayed. This is the only window in which that database exists, and
33
+ // reading it here is what lets `.harbour/checks/` be generated from the
34
+ // schema the app will really run against rather than from the SQL text.
35
+ schema = await readAppSchema(throwaway, root);
32
36
  record("migrations", "pass", `${applied.length} file(s) applied to a disposable database`);
33
37
  // The pipeline's database gate, on the database those files just built:
34
38
  // the same script the in-loop probe runs after its replay, so the defect
@@ -48,6 +52,27 @@ export async function runChecks(root, options) {
48
52
  finally {
49
53
  await throwaway.down();
50
54
  }
55
+ // `.harbour/checks/` is generated from what the app declares — the tables the
56
+ // database above holds, the SDK namespaces its own code calls — before
57
+ // anything reads it, so a check for a feature the app dropped cannot survive a
58
+ // single `harbour check`, and a feature it added arrives with its journey.
59
+ // This is not one of the gates: it writes files and reports what it did, and
60
+ // `flow` below still decides. Files Harbour did not write, and generated files
61
+ // the builder has edited, are never touched — and when the database did not
62
+ // answer, nothing is touched at all.
63
+ if (schema)
64
+ for (const line of describeRetainedChecks(await syncRetainedChecks(root, schema)))
65
+ output(line);
66
+ else
67
+ output(".harbour/checks/ was left as it is: the app's tables could not be read from the disposable database, so there was nothing to generate from.");
68
+ // After generation, because `.harbour/checks/*.mjs` is part of the deployed
69
+ // tree (kit.ts `sourceDigest`): the report has to pin the tree the journeys
70
+ // below actually run against, or `harbour productionise` would refuse the very
71
+ // tree this check just passed as CHECKS_STALE.
72
+ const source = await sourceDigest(root);
73
+ const previous = await readReport(root);
74
+ if (previous && previous.sourceDigest !== source.digest)
75
+ output("Source changed since the last report; the previous report is no longer valid.");
51
76
  const origin = await (options.localOrigin ?? (() => runningOrigin(root)))();
52
77
  const journeys = (await readdir(kitPaths(root).checks).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
53
78
  if (!journeys.length) {
@@ -1,7 +1,7 @@
1
1
  import { createServer, request as httpRequest } from "node:http";
2
2
  import { connect } from "node:net";
3
3
  import { readdir, readFile } from "node:fs/promises";
4
- import { extname, join } from "node:path";
4
+ import { extname, join, relative, sep } from "node:path";
5
5
  /**
6
6
  * The pipeline's operation-coverage gate, run locally.
7
7
  *
@@ -108,11 +108,15 @@ async function sourceFiles(root) {
108
108
  return found.sort();
109
109
  }
110
110
  /**
111
- * Every `<table> <verb>` the browser code performs. The verb chain is what
112
- * follows THIS `.from(...)` up to the end of its statement or the next
113
- * `.from(` — never a later chain's verbs.
111
+ * Every `<table> <verb>` the browser code performs, with the files performing
112
+ * it. The verb chain is what follows THIS `.from(...)` up to the end of its
113
+ * statement or the next `.from(` — never a later chain's verbs.
114
+ *
115
+ * This reading is what the gate demands evidence for, so `retained-checks.ts`
116
+ * generates from exactly it: a generated journey can then only ever cover what
117
+ * the gate asks about, in both directions, with no template in between.
114
118
  */
115
- export async function sourceTableVerbs(root) {
119
+ export async function sourceTableUsage(root) {
116
120
  const inventory = new Map();
117
121
  for (const path of await sourceFiles(root)) {
118
122
  const text = await readFile(path, "utf8");
@@ -129,38 +133,58 @@ export async function sourceTableVerbs(root) {
129
133
  tail = tail.slice(0, next);
130
134
  tail = tail.slice(0, 200);
131
135
  const table = match[1];
132
- const verbs = inventory.get(table) ?? new Set();
136
+ const usage = inventory.get(table) ?? { verbs: new Set(), files: [] };
133
137
  for (const verb of tail.matchAll(VERB_CALL))
134
- verbs.add(verb[1]);
135
- inventory.set(table, verbs);
138
+ usage.verbs.add(verb[1]);
139
+ const file = appPath(root, path);
140
+ if (!usage.files.includes(file))
141
+ usage.files.push(file);
142
+ inventory.set(table, usage);
136
143
  }
137
144
  }
138
145
  return inventory;
139
146
  }
147
+ /** `<table> <verb>` alone, as the coverage gate compares it. */
148
+ export async function sourceTableVerbs(root) {
149
+ return new Map([...await sourceTableUsage(root)].map(([table, usage]) => [table, usage.verbs]));
150
+ }
140
151
  /**
141
- * The capabilities the kit lane declares for this app: a namespace called as
142
- * `<client>.<namespace>.<method>(` on an identifier bound to `createClient()`.
143
- * TypeScript type arguments may sit between the method and its call.
152
+ * The capabilities the kit lane declares for this app, with the files declaring
153
+ * them: a namespace called as `<client>.<namespace>.<method>(` on an identifier
154
+ * bound to `createClient()`. TypeScript type arguments may sit between the
155
+ * method and its call.
144
156
  */
145
- export async function declaredCapabilities(root) {
157
+ export async function capabilityUsage(root) {
146
158
  const files = await sourceFiles(root);
147
159
  const texts = await Promise.all(files.map(path => readFile(path, "utf8").catch(() => "")));
148
160
  const clients = new Set();
149
161
  for (const text of texts)
150
162
  for (const match of text.matchAll(CLIENT_BINDING))
151
163
  clients.add(match[1]);
152
- const used = new Set();
164
+ const used = new Map();
153
165
  if (!clients.size)
154
166
  return used;
155
167
  const namespaces = SDK_CAPABILITIES.join("|");
156
168
  for (const client of clients) {
157
169
  const call = new RegExp(`\\b${client}\\s*\\.\\s*(${namespaces})\\s*\\.\\s*[A-Za-z_$][A-Za-z0-9_$]*\\s*(?:<[^<>()]*>\\s*)?\\(`, "g");
158
- for (const text of texts)
159
- for (const match of text.matchAll(call))
160
- used.add(match[1]);
170
+ for (const [index, text] of texts.entries()) {
171
+ const file = appPath(root, files[index]);
172
+ for (const match of text.matchAll(call)) {
173
+ const seen = used.get(match[1]) ?? [];
174
+ if (!seen.includes(file))
175
+ seen.push(file);
176
+ used.set(match[1], seen);
177
+ }
178
+ }
161
179
  }
162
180
  return used;
163
181
  }
182
+ /** The capability names alone, as the coverage gate compares them. */
183
+ export async function declaredCapabilities(root) {
184
+ return new Set((await capabilityUsage(root)).keys());
185
+ }
186
+ /** A source file as the app itself names it: relative to the root, `/`-separated on every platform. */
187
+ const appPath = (root, path) => relative(root, path).split(sep).join("/");
164
188
  /**
165
189
  * Every capability namespace the app's own source calls a method on, whatever
166
190
  * object the call is made on: `harbour.files.list(`, `client().files.list(`,
@@ -184,13 +208,15 @@ export async function declaredCapabilities(root) {
184
208
  */
185
209
  export async function capabilityCallSurface(root) {
186
210
  const surface = new Set();
187
- for (const path of await sourceFiles(root)) {
188
- const text = await readFile(path, "utf8").catch(() => "");
189
- for (const match of text.matchAll(CAPABILITY_CALL))
190
- surface.add(match[1]);
191
- }
211
+ for (const path of await sourceFiles(root))
212
+ for (const capability of capabilitiesCalled(await readFile(path, "utf8").catch(() => "")))
213
+ surface.add(capability);
192
214
  return surface;
193
215
  }
216
+ /** 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). */
217
+ export function capabilitiesCalled(text) {
218
+ return new Set([...text.matchAll(CAPABILITY_CALL)].map(match => match[1]));
219
+ }
194
220
  /**
195
221
  * The gateway's own path -> capability map (appgateway/gateway.go
196
222
  * `capabilityForRequest`). Every one of these paths is resolved against the
@@ -261,8 +261,9 @@ export class LocalRuntime {
261
261
  const result = await this.psql(sql);
262
262
  return result.code === 0 ? [] : gateViolations(result.stderr);
263
263
  }
264
- psql(sql) {
265
- return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
264
+ /** One psql run inside this project's postgres container; `flags` is how a caller asks for machine-readable output (`-At`). */
265
+ psql(sql, flags = []) {
266
+ return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", ...flags, "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
266
267
  }
267
268
  /**
268
269
  * Applies the platform's realtime outbox migration the gateway generated
@@ -0,0 +1,396 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { allowedLiterals, numericMinimum, textLengthBounds } from "./app-schema.js";
5
+ import { COVERED_CAPABILITIES, capabilitiesCalled, capabilityCallSurface, capabilityUsage, sourceTableUsage } from "./flow-coverage.js";
6
+ import { kitPaths } from "./kit.js";
7
+ /**
8
+ * Retained checks, generated from what the app declares.
9
+ *
10
+ * `.harbour/checks/` used to be shipped by `harbour init` as three fixed files
11
+ * written for the starter's own notes/files example. The first thing a builder
12
+ * does is replace that example, and the checks stayed — exercising tables and a
13
+ * capability the app no longer had, which is exactly what the pipeline's `flow`
14
+ * gate refuses (`flow.check-failed`). It cost two real builder runs on one day:
15
+ * a todo app that dropped the files UI and renamed notes to todos failed on
16
+ * `files-journey.mjs` ("application capability is not enabled") and on
17
+ * `notes-cross-user.mjs` ("relation … does not exist").
18
+ *
19
+ * So there are no templates. The two readings the `flow` gate already makes of
20
+ * the app's own code — `sourceTableUsage` (every `<table> <verb>` the browser
21
+ * code performs) and `capabilityUsage` (every SDK namespace it calls) — decide
22
+ * which checks exist, and the app's own database, the one `harbour check` has
23
+ * just replayed `migrations/*.sql` into, supplies the row a journey writes. A
24
+ * check exists for each capability the app actually has and for none it does
25
+ * not, by construction rather than by guidance.
26
+ *
27
+ * Three rules keep that safe:
28
+ *
29
+ * - **Harbour only manages what Harbour wrote.** A generated file carries a
30
+ * digest of its own body on its first line. `harbour check` rewrites and
31
+ * deletes a file whose digest still matches; the moment a builder edits one
32
+ * — or writes their own — it is theirs, and Harbour reports it instead of
33
+ * touching it. That was #456's lesson: the remediation we hand out must
34
+ * never overwrite the app.
35
+ * - **Harbour never writes a check its own gate would refuse.** The `flow`
36
+ * gate refuses a retained check exercising a capability no code in the app
37
+ * calls (#469), reading that surface with `capabilityCallSurface`; nothing
38
+ * is generated for a capability outside it.
39
+ * - **The `flow` gate is untouched.** Generation removes the drift; the gate
40
+ * still decides. Surface Harbour cannot write a journey for is named with
41
+ * the reason, and `flow` fails exactly as it did before.
42
+ */
43
+ /** First line of every file Harbour wrote, carrying the digest of the body below it. */
44
+ const GENERATED_PREFIX = "// harbour:generated sha256:";
45
+ /** Capability names that own a fixed file name, so a table of the same name does not shadow one. */
46
+ const RESERVED_NAMES = new Set(["files", "telemetry", "actions", "realtime"]);
47
+ /** The data verbs a generated journey performs, in the order it performs them — the set `flow-coverage.ts` inventories. */
48
+ const VERB_ORDER = ["insert", "upsert", "select", "update", "delete"];
49
+ /** Regenerates `.harbour/checks/` in place from the app's code and its own database, and reports every decision. */
50
+ export async function syncRetainedChecks(root, schema) {
51
+ const plan = await planRetainedChecks(root, schema);
52
+ const created = [];
53
+ const updated = [];
54
+ for (const check of plan.write) {
55
+ const absolute = join(root, check.path);
56
+ const current = await readFile(absolute, "utf8").catch(() => undefined);
57
+ if (current === check.content)
58
+ continue;
59
+ await mkdir(kitPaths(root).checks, { recursive: true });
60
+ await writeFile(absolute, check.content);
61
+ (current === undefined ? created : updated).push(check.path);
62
+ }
63
+ for (const path of plan.remove)
64
+ await rm(join(root, path), { force: true });
65
+ return { created, updated, removed: [...plan.remove], adopted: plan.adopted, blocked: plan.blocked, orphaned: plan.orphaned };
66
+ }
67
+ /** One line per decision, for `harbour check`'s output. */
68
+ export function describeRetainedChecks(result) {
69
+ return [
70
+ ...result.created.map(path => `generated ${path}`),
71
+ ...result.updated.map(path => `regenerated ${path}`),
72
+ ...result.removed.map(path => `removed ${path} — the app's code no longer does what it exercised, and the pipeline's flow gate refuses a check that does`),
73
+ ...result.orphaned.map(detail => `keeping ${detail}`),
74
+ ...result.blocked.map(detail => `no check generated for ${detail}`)
75
+ ];
76
+ }
77
+ /** Reads the app and decides what `.harbour/checks/` should hold, writing nothing. */
78
+ export async function planRetainedChecks(root, schema) {
79
+ const [tables, capabilities, surface, existing] = await Promise.all([sourceTableUsage(root), capabilityUsage(root), capabilityCallSurface(root), readChecks(root)]);
80
+ const adopted = existing.filter(check => !check.pristine);
81
+ const plan = { write: [], remove: [], adopted: adopted.map(check => check.path), blocked: [], orphaned: [] };
82
+ const planned = new Set();
83
+ /** A planned check, named by the one capability it exercises: outside the `flow` gate's own surface, Harbour writes nothing. */
84
+ const add = (capability, name, content) => {
85
+ if (!surface.has(capability))
86
+ return;
87
+ planned.add(name);
88
+ plan.write.push({ path: `.harbour/checks/${name}`, content });
89
+ };
90
+ for (const [table, usage] of [...tables].sort(([first], [second]) => first.localeCompare(second))) {
91
+ const verbs = VERB_ORDER.filter(verb => usage.verbs.has(verb));
92
+ const declared = schema.get(table);
93
+ // A check the builder owns covering every verb is the coverage, and one acting as the
94
+ // second person is the cross-user coverage. The two are decided separately: editing a
95
+ // journey must not take its table's cross-user denial down with it.
96
+ const journeyCovered = verbs.length > 0 && verbs.every(verb => adopted.some(check => coversTableVerb(check.text, table, verb)));
97
+ const crossUserCovered = adopted.some(check => check.text.includes("HARBOUR_IDENTITY_CONTEXT_SECOND_USER") && coversTableVerb(check.text, table, "select"));
98
+ if (journeyCovered && (crossUserCovered || !declared?.ownerScoped))
99
+ continue;
100
+ const reason = (detail) => plan.blocked.push(`\`${table}\` (${verbs.join(", ")}), which ${usage.files.join(", ")} queries: ${detail}`);
101
+ if (!declared) {
102
+ reason("no table of that name exists once the app's migrations have replayed, so Harbour cannot know what a row of it looks like");
103
+ continue;
104
+ }
105
+ const shape = verbs.some(verb => verb !== "select") ? rowShape(declared, verbs.includes("update")) : undefined;
106
+ if (shape && "blocked" in shape) {
107
+ reason(shape.blocked);
108
+ continue;
109
+ }
110
+ if (!journeyCovered)
111
+ add("data", fileName(table, "journey"), dataJourney(table, verbs, declared, usage.files, shape));
112
+ if (declared.ownerScoped && shape && !crossUserCovered)
113
+ add("data", fileName(table, "cross-user"), crossUserCheck(table, declared, shape, usage.files));
114
+ }
115
+ for (const capability of COVERED_CAPABILITIES) {
116
+ const files = capabilities.get(capability);
117
+ if (!files || capability === "data")
118
+ continue;
119
+ if (adopted.some(check => capabilitiesCalled(check.text).has(capability)))
120
+ continue;
121
+ const generic = capability === "files" ? filesJourney(files) : capability === "telemetry" ? telemetryJourney(files) : undefined;
122
+ if (!generic) {
123
+ plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Harbour writes journeys for data, files and telemetry only — write \`.harbour/checks/${capability}-journey.mjs\` yourself`);
124
+ continue;
125
+ }
126
+ add(capability, `${capability}-journey.mjs`, generic);
127
+ }
128
+ for (const check of existing) {
129
+ if (check.pristine) {
130
+ if (!planned.has(check.name))
131
+ plan.remove.push(check.path);
132
+ continue;
133
+ }
134
+ const stale = staleSurface(check.text, tables, capabilities);
135
+ if (stale)
136
+ plan.orphaned.push(`${check.path} — Harbour did not write this file, so it is left alone, but it exercises ${stale}; delete it or restore the feature, or the pipeline's flow gate refuses the app`);
137
+ }
138
+ plan.write.sort((first, second) => first.path.localeCompare(second.path));
139
+ plan.remove.sort();
140
+ return plan;
141
+ }
142
+ // ---- Generated files ------------------------------------------------------------
143
+ /** The paragraph every generated check carries: what wrote it, what maintains it, and what an edit means. */
144
+ function stewardship(sources, uuid = false) {
145
+ return [
146
+ "//",
147
+ `// Generated by Harbour from ${sources.join(" and ")}, and kept in step by`,
148
+ "// `harbour check`: rewritten when the app's schema or the operations it performs",
149
+ "// change, and deleted when the app stops performing them. Edit this file and",
150
+ "// Harbour stops managing it — your version is kept from then on, and keeping it",
151
+ "// honest becomes yours: the deployment pipeline replays `.harbour/checks/` against",
152
+ "// a real App Gateway and refuses the app (`flow.check-failed`) when a check",
153
+ "// exercises something the code no longer does, so DELETE THIS FILE in the same",
154
+ "// edit that removes the feature it covers.",
155
+ "",
156
+ ...(uuid ? ["import { randomUUID } from \"node:crypto\";"] : []),
157
+ "import assert from \"node:assert/strict\";",
158
+ "",
159
+ "const appUrl = process.env.HARBOUR_APP_URL;",
160
+ "assert.ok(appUrl, \"HARBOUR_APP_URL is required\");",
161
+ "const sdk = await import(process.env.HARBOUR_SDK_MODULE ?? \"@harbour/app-sdk\");"
162
+ ];
163
+ }
164
+ /** The single signed-in identity every journey but the cross-user one runs as. */
165
+ const SIGNED_IN = [
166
+ "// The signed-in identity is attached by the SDK itself (HARBOUR_IDENTITY_CONTEXT",
167
+ "// from `harbour check` and from the pipeline's runner): one injector per request.",
168
+ "const harbour = sdk.createClient({ baseUrl: appUrl });",
169
+ "",
170
+ "const user = await harbour.identity.current();",
171
+ "assert.ok(user && user.email, \"identity/current must return the signed-in user\");"
172
+ ];
173
+ /** A table's journey: the write steps when the app writes to it, a plain read when it only reads. */
174
+ function dataJourney(table, verbs, schema, sources, row) {
175
+ const body = [
176
+ `// Retained journey for the \`${table}\` table: the signed-in identity can`,
177
+ `// ${sentence(verbs)} a row through the running app (HARBOUR_APP_URL) — the operations`,
178
+ `// ${sources.join(", ")} performs on this table. Runs under \`harbour check\` while`,
179
+ "// `harbour dev` is up, and in the deployment pipeline's retained-check container.",
180
+ ...stewardship([schema.file, ...sources], row?.uuid ?? false),
181
+ ...SIGNED_IN,
182
+ ...(row ? writeSteps(table, verbs, row) : [
183
+ "",
184
+ `const { data: rows } = await harbour.data.from("${table}").select("*").limit(1);`,
185
+ `assert.ok(Array.isArray(rows), "${table}: the signed-in identity can read the table this app reads");`
186
+ ])
187
+ ];
188
+ return sign(`${body.join("\n")}\n`);
189
+ }
190
+ function writeSteps(table, verbs, row) {
191
+ const from = `harbour.data.from("${table}")`;
192
+ const at = `.eq("${row.locator}", row.${row.locator})`;
193
+ const steps = [
194
+ "",
195
+ `const markerValue = ${row.markerExpression};`,
196
+ `await ${from}.insert({ ${row.values.join(", ")} });`,
197
+ `const { data: rows } = await ${from}.select("*").eq("${row.marker.name}", markerValue);`,
198
+ `assert.equal(rows.length, 1, "the inserted ${table} row is readable by its owner");`,
199
+ "const row = rows[0];"
200
+ ];
201
+ if (verbs.includes("upsert"))
202
+ steps.push("", `await ${from}.upsert({ ...row, ${row.marker.name}: markerValue });`, `const { data: upserted } = await ${from}.select("*").eq("${row.marker.name}", markerValue);`, `assert.ok(upserted.length >= 1, "the upserted ${table} row is readable by its owner");`);
203
+ if (verbs.includes("update") && row.update)
204
+ steps.push("", `await ${from}.update({ ${row.update.column.name}: ${row.update.expression} })${at};`, `const { data: updated } = await ${from}.select("*")${at};`, `assert.deepEqual(updated[0].${row.update.column.name}, ${row.update.expression}, "the ${table} row is updated for its owner");`);
205
+ if (verbs.includes("delete"))
206
+ steps.push("", `await ${from}.delete()${at};`, `const { data: remaining } = await ${from}.select("*")${at};`, `assert.equal(remaining.length, 0, "the ${table} row is deleted");`);
207
+ return steps;
208
+ }
209
+ function crossUserCheck(table, schema, row, sources) {
210
+ const mutated = row.update ? { column: row.update.column.name, value: row.update.expression } : { column: row.marker.name, value: row.markerExpression };
211
+ const body = [
212
+ "// Cross-user check: a second signed-in person cannot read, update or delete the first",
213
+ `// person's \`${table}\` row through the running app (HARBOUR_APP_URL). The deployment`,
214
+ "// pipeline's write probe makes exactly these three assertions, with a second identity,",
215
+ `// against every owner-scoped table, and \`${table}\` is one: ${schema.file} scopes its rows`,
216
+ `// with current_setting('harbour.user_id')${schema.ownerColumn ? ` on \`${schema.ownerColumn}\`` : ""}. Running it here means user isolation can`,
217
+ "// no longer be green under `harbour check` and red in CodeBuild.",
218
+ ...stewardship([schema.file, ...sources], row.uuid),
219
+ "const secondUser = process.env.HARBOUR_IDENTITY_CONTEXT_SECOND_USER;",
220
+ "assert.ok(secondUser, \"HARBOUR_IDENTITY_CONTEXT_SECOND_USER is required (set by harbour check and by the pipeline)\");",
221
+ "// The first person: the SDK attaches HARBOUR_IDENTITY_CONTEXT itself.",
222
+ "const owner = sdk.createClient({ baseUrl: appUrl });",
223
+ "// The second person: the same SDK, with that person's signed identity replacing the first on every request.",
224
+ "const header = (process.env.HARBOUR_IDENTITY_CONTEXT_HEADER ?? \"X-Harbour-Identity-Context\").toLowerCase();",
225
+ "const asSecondUser = (input, init = {}) => fetch(input, { ...init, headers: { ...(init.headers ?? {}), [header]: secondUser } });",
226
+ "const other = sdk.createClient({ baseUrl: appUrl, fetch: asSecondUser });",
227
+ "",
228
+ "const me = await owner.identity.current();",
229
+ "const them = await other.identity.current();",
230
+ "assert.ok(me && them && me.id !== them.id, \"the two signed-in identities must be different people\");",
231
+ "",
232
+ `const markerValue = ${row.markerExpression};`,
233
+ `await owner.data.from("${table}").insert({ ${row.values.join(", ")} });`,
234
+ `const { data: mine } = await owner.data.from("${table}").select("*").eq("${row.marker.name}", markerValue);`,
235
+ `assert.equal(mine.length, 1, "the owner reads the ${table} row back");`,
236
+ "const row = mine[0];",
237
+ "",
238
+ "// 1. SELECT by key as the second person: no row.",
239
+ `const { data: seen } = await other.data.from("${table}").select("*").eq("${row.locator}", row.${row.locator});`,
240
+ `assert.equal(seen.length, 0, "${table}: a second signed-in user can read another user's row — the owner policy does not isolate users");`,
241
+ "",
242
+ "// 2. UPDATE and 3. DELETE by key as the second person: refused (403) or no row affected.",
243
+ "const denied = async (verb, operation) => {",
244
+ " let affected;",
245
+ " try { affected = (await operation()).data ?? []; }",
246
+ " catch (error) { if (error?.category === \"FORBIDDEN\") return; throw error; }",
247
+ ` assert.equal(affected.length, 0, \`${table} \${verb}: a second user mutated another user's row despite an owner-scoped source policy\`);`,
248
+ "};",
249
+ `await denied("UPDATE", () => other.data.from("${table}").update({ ${mutated.column}: ${mutated.value} }).eq("${row.locator}", row.${row.locator}));`,
250
+ `await denied("DELETE", () => other.data.from("${table}").delete().eq("${row.locator}", row.${row.locator}));`,
251
+ `const { data: after } = await owner.data.from("${table}").select("*").eq("${row.locator}", row.${row.locator});`,
252
+ `assert.equal(after.length, 1, "the owner's ${table} row still exists after the second user's attempts");`,
253
+ `assert.deepEqual(after[0].${mutated.column}, row.${mutated.column}, "the owner's ${table} row is unchanged after the second user's attempts");`,
254
+ `await owner.data.from("${table}").delete().eq("${row.locator}", row.${row.locator});`
255
+ ];
256
+ return sign(`${body.join("\n")}\n`);
257
+ }
258
+ function filesJourney(sources) {
259
+ const body = [
260
+ "// Retained journey for the app's private files: the signed-in identity can upload a",
261
+ "// file and list it back through the running app (HARBOUR_APP_URL) — the operations",
262
+ `// ${sources.join(", ")} performs through \`harbour.files.*\`. The deployment pipeline`,
263
+ "// refuses an app whose checks never exercise a capability its own code uses, so this",
264
+ "// runs under `harbour check` while `harbour dev` is up, and in the pipeline's container.",
265
+ ...stewardship(sources),
266
+ ...SIGNED_IN,
267
+ "",
268
+ "const name = `harbour-journey-${Date.now()}.txt`;",
269
+ "await harbour.files.upload(`private/${name}`, new Blob([\"harbour journey check\\n\"], { type: \"text/plain\" }));",
270
+ "const listed = await harbour.files.list(\"private/\");",
271
+ "assert.ok(Array.isArray(listed), \"files.list must return a list\");",
272
+ "const found = listed.some(entry => String(typeof entry === \"string\" ? entry : entry?.path ?? entry?.name ?? \"\").endsWith(name));",
273
+ "assert.ok(found, `the uploaded file is listed back to its owner (looking for ${name})`);"
274
+ ];
275
+ return sign(`${body.join("\n")}\n`);
276
+ }
277
+ function telemetryJourney(sources) {
278
+ const body = [
279
+ "// Retained journey for the app's telemetry: one event reaches the gateway through the",
280
+ `// running app (HARBOUR_APP_URL) — the capability ${sources.join(", ")} uses through`,
281
+ "// `harbour.telemetry.*`. The deployment pipeline refuses an app whose checks never",
282
+ "// exercise a capability its own code uses, so this runs under `harbour check` while",
283
+ "// `harbour dev` is up, and in the pipeline's retained-check container.",
284
+ ...stewardship(sources),
285
+ ...SIGNED_IN,
286
+ "",
287
+ "// `track` resolves with nothing and throws on a refusal, so reaching the next line is the assertion.",
288
+ "await harbour.telemetry.track(\"harbour_retained_check\", { source: \"telemetry-journey\" });"
289
+ ];
290
+ return sign(`${body.join("\n")}\n`);
291
+ }
292
+ // ---- Reading the app ------------------------------------------------------------
293
+ /** How a generated journey writes one row: the values to insert, the column it finds the row by, and the column it changes. */
294
+ function rowShape(schema, needsUpdate) {
295
+ const writable = schema.columns.filter(column => !column.generated && !column.identityDefault);
296
+ const required = writable.filter(column => column.notNull && !column.hasDefault);
297
+ const unwritable = required.find(column => column.references) ?? required.find(column => !fillExpression(column));
298
+ if (unwritable)
299
+ return { blocked: `its \`${unwritable.name} ${unwritable.declaredType}\` column ${unwritable.references ? "references another table, so a row cannot be written without inventing its parent" : "has a type Harbour cannot invent a value for"}` };
300
+ const marker = writable.find(column => isMarker(column) && column.notNull && !column.hasDefault) ?? writable.find(isMarker);
301
+ if (!marker)
302
+ return { blocked: "it has no free text or number column a check could write a unique value into and find the row by" };
303
+ const markerExpression = marker.type === "text" ? textMarker(marker) : "Date.now() % 2000000000";
304
+ if (!markerExpression)
305
+ return { blocked: `\`${marker.name}\` is the only column a check could find a row by, and its CHECK constraint leaves no room for a unique value` };
306
+ const locator = schema.primaryKey.length === 1 && schema.primaryKey[0] !== marker.name ? schema.primaryKey[0] : marker.name;
307
+ const fill = required.filter(column => column.name !== marker.name).map(column => `${column.name}: ${fillExpression(column)}`);
308
+ const update = needsUpdate ? updateStep(writable, marker, locator) : undefined;
309
+ if (needsUpdate && !update)
310
+ return { blocked: "the app updates it, but every column Harbour could change is the one the check finds the row by" };
311
+ return { values: [`${marker.name}: markerValue`, ...fill], marker, markerExpression, locator, uuid: fill.some(value => value.includes("randomUUID(")), update };
312
+ }
313
+ /** The column a journey changes, and what it changes it to: a boolean is flipped, a closed set moves to its other value, free text gets a fixed one. */
314
+ function updateStep(writable, marker, locator) {
315
+ const candidates = writable.filter(column => column.name !== locator && column.name !== marker.name);
316
+ const flag = candidates.find(column => column.type === "boolean");
317
+ if (flag)
318
+ return { column: flag, expression: `!row.${flag.name}` };
319
+ for (const column of candidates) {
320
+ const literals = allowedLiterals(column.checks, column.name);
321
+ if (literals.length >= 2)
322
+ return { column, expression: JSON.stringify(literals[1]) };
323
+ if (column.type !== "text" || literals.length)
324
+ continue;
325
+ const { min, max } = textLengthBounds(column.checks, column.name);
326
+ if (max < 21 || min > 21)
327
+ continue;
328
+ return { column, expression: "\"harbour check updated\"" };
329
+ }
330
+ return undefined;
331
+ }
332
+ /** A column a check can write a unique value into and then filter on. */
333
+ const isMarker = (column) => (column.type === "text" || column.type === "integer" || column.type === "number") && !column.references && !allowedLiterals(column.checks, column.name).length;
334
+ /** A unique marker satisfying the column's own length constraints, or `undefined` when they leave no room for one. */
335
+ function textMarker(column) {
336
+ const { min, max } = textLengthBounds(column.checks, column.name);
337
+ const [expression, length] = max >= 27 ? ["`harbour check ${Date.now()}`", 27] : max >= 13 ? ["String(Date.now())", 13] : max >= 6 ? [`String(Date.now()).slice(-${max})`, max] : [undefined, 0];
338
+ if (!expression)
339
+ return undefined;
340
+ return min > length ? `${expression}.padEnd(${min}, "x")` : expression;
341
+ }
342
+ /** A value for a column a write must supply, or `undefined` when nothing can be invented for its type. */
343
+ function fillExpression(column) {
344
+ const literals = allowedLiterals(column.checks, column.name);
345
+ if (literals.length)
346
+ return JSON.stringify(literals[0]);
347
+ switch (column.type) {
348
+ case "text": {
349
+ const { min, max } = textLengthBounds(column.checks, column.name);
350
+ return JSON.stringify("harbour check".slice(0, max).padEnd(Math.min(min, max), "x"));
351
+ }
352
+ case "uuid": return "randomUUID()";
353
+ case "integer": return String(Math.ceil(numericMinimum(column.checks, column.name)));
354
+ case "number": return String(numericMinimum(column.checks, column.name));
355
+ case "boolean": return "false";
356
+ case "timestamptz": return "new Date().toISOString()";
357
+ case "date": return "new Date().toISOString().slice(0, 10)";
358
+ case "time": return "\"12:00:00\"";
359
+ case "json": return "{}";
360
+ case "array": return "[]";
361
+ default: return undefined;
362
+ }
363
+ }
364
+ async function readChecks(root) {
365
+ const directory = kitPaths(root).checks;
366
+ const names = (await readdir(directory).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
367
+ return Promise.all(names.map(async (name) => {
368
+ const text = await readFile(join(directory, name), "utf8").catch(() => "");
369
+ return { name, path: `.harbour/checks/${name}`, text, pristine: isPristine(text) };
370
+ }));
371
+ }
372
+ /** What an adopted check exercises that the app's own code no longer does, in the `flow` gate's terms. */
373
+ function staleSurface(text, tables, capabilities) {
374
+ for (const match of text.matchAll(/\.from\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/g))
375
+ if (!tables.has(match[1]))
376
+ return `the \`${match[1]}\` table, which no source file queries`;
377
+ for (const capability of capabilitiesCalled(text))
378
+ if (!capabilities.has(capability))
379
+ return `the \`${capability}\` capability, which no source file uses`;
380
+ return undefined;
381
+ }
382
+ /** `insert, update and delete`, for a generated check's opening line. */
383
+ const sentence = (verbs) => verbs.length > 1 ? `${verbs.slice(0, -1).join(", ")} and ${verbs.at(-1)}` : verbs.join("");
384
+ const coversTableVerb = (text, table, verb) => new RegExp(`\\.from\\(\\s*["'\`]${table}["'\`]\\s*\\)`).test(text) && new RegExp(`\\.${verb}\\s*\\(`).test(text);
385
+ /** `notes` gets `notes-journey.mjs`; a table sharing a capability's name does not shadow it. */
386
+ const fileName = (table, kind) => `${table}${RESERVED_NAMES.has(table) ? "-table" : ""}-${kind}.mjs`;
387
+ // ---- Ownership ------------------------------------------------------------------
388
+ const digest = (body) => createHash("sha256").update(body).digest("hex").slice(0, 16);
389
+ const sign = (body) => `${GENERATED_PREFIX}${digest(body)}\n${body}`;
390
+ /** True only for a file Harbour wrote and nobody has edited since. */
391
+ export function isPristine(text) {
392
+ if (!text.startsWith(GENERATED_PREFIX))
393
+ return false;
394
+ const end = text.indexOf("\n");
395
+ return end > 0 && text.slice(0, end).trim() === `${GENERATED_PREFIX}${digest(text.slice(end + 1))}`;
396
+ }
@@ -28,8 +28,8 @@ export async function initKit(root, bundle, options = {}) {
28
28
  await writeFile(absolute, content);
29
29
  result.created.push(path);
30
30
  };
31
- // The starter (its source, schema and retained checks) is created only when there is
32
- // no app here yet; kit infrastructure is written on every path, including `--upgrade`.
31
+ // The starter (its source and schema) is created only when there is no app here
32
+ // yet; kit infrastructure is written on every path, including `--upgrade`.
33
33
  const appFiles = emptyDir ? starterFiles(bundle) : {};
34
34
  for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles() }))
35
35
  await write(path, content);
@@ -99,8 +99,8 @@ export function managedBlock() {
99
99
  "- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
100
100
  "- 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
101
  "- 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
- "- `.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. The pairing is two-way and the `flow` gate refuses the deploy in both directions, so an edit that changes what the app does changes its checks in the same edit. Start using a capability and it needs its own retained check, written in that edit. 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, 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
- "- 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.*`. Replacing the notes table with the app's own means rewriting both notes checks for that table; removing the \"Private files\" section means deleting `.harbour/checks/files-journey.mjs` in that same edit. An inherited check for a feature the app replaced or dropped is the most common reason a first deploy is refused.",
102
+ "- `.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
+ "- 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
104
  "- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (declaration, types, build, migrations + the pipeline's database gate, journeys), `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
105
  "- 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
106
  MANAGED_END
@@ -115,7 +115,7 @@ Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
115
115
 
116
116
  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
117
  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
- 3. Keep \`.harbour/checks/\` paired with the code, in the same edit that changes it: one retained journey per capability the app's own code uses, and none for a capability it no longer uses. A new feature needs a new check; a feature you delete or replace (a dropped table, a removed section) means deleting or rewriting its check right then. \`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.
118
+ 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
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
120
  5. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
121
121
  6. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
@@ -141,28 +141,23 @@ function kitFiles() {
141
141
  }
142
142
  /**
143
143
  * The starter app itself: written only when `init` is creating one in an empty
144
- * directory. This includes the notes migration and the three retained checks
145
- * they are the starter's own schema and journeys, not kit infrastructure, and
146
- * they exist so a fresh starter passes the pipeline's `flow` gate (one journey
147
- * per capability its UI declares, plus the cross-user denial). Writing them into
148
- * an app that has its own schema recreates a `notes` table it never had, and the
149
- * checks that query it then fail `flow` on the next deploy.
144
+ * directory. This includes the notes migration the starter's own schema, not
145
+ * kit infrastructure. Writing it into an app that has its own schema recreates a
146
+ * `notes` table it never had, and a check that queries it then fails `flow` on
147
+ * the next deploy (#456).
148
+ *
149
+ * The retained checks are deliberately not here, and not written by `init` at
150
+ * all. `harbour check` generates them from this schema — read back out of the
151
+ * database it replays the migrations into — and from this source, so "one
152
+ * journey per capability the UI declares, plus the cross-user denial per
153
+ * owner-scoped table" is a property of the generator rather than three files
154
+ * that have to be kept in step with `src/App.tsx` by hand. `init` has no
155
+ * database and so invents nothing: `.harbour/checks/` arrives with the first
156
+ * `harbour check`, which is also the command that keeps it right afterwards.
150
157
  */
151
158
  function starterFiles(bundle) {
152
159
  return {
153
160
  "migrations/0001_notes.sql": MIGRATION,
154
- // Exactly one retained check per capability the starter's UI uses, and none for a
155
- // capability it does not: the flow gate runs both ways — it refuses an app whose
156
- // checks never exercise a declared capability, and it refuses an app whose checks
157
- // no longer pass because the code behind them is gone. The starter's own UI still
158
- // calls `harbour.files.*` in its "Private files" section, so `files-journey.mjs`
159
- // belongs here; an app that drops that section deletes the check with it (the
160
- // pairing is stated in the generated App.tsx, README.md and the CLAUDE.md block).
161
- ".harbour/checks/notes-journey.mjs": JOURNEY_CHECK,
162
- ".harbour/checks/files-journey.mjs": FILES_JOURNEY_CHECK,
163
- // The pipeline's cross-user denial, run locally: the last gate class that
164
- // used to exist only in CodeBuild.
165
- ".harbour/checks/notes-cross-user.mjs": CROSS_USER_CHECK,
166
161
  "package.json": `${JSON.stringify({
167
162
  name: "harbour-app", private: true, version: "0.1.0", type: "module",
168
163
  scripts: { dev: "vite", build: "tsc --noEmit && vite build", preview: "vite preview" },
@@ -188,10 +183,12 @@ Created by \`harbour init\`. Run \`harbour dev --app-root .\` and open the print
188
183
 
189
184
  \`.harbour/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.
190
185
 
186
+ The directory is empty until the first \`harbour 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 \`harbour 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 \`// harbour:generated\` line carrying a digest of its own body — edit a check and Harbour keeps your version, stops updating it and never removes it, and it is yours to maintain from then on.
187
+
191
188
  The checks and the code are one pair, and \`harbour check\` and the deployment pipeline's \`flow\` gate enforce the pair in both directions against a real App Gateway. Both directions refuse the deploy:
192
189
 
193
190
  - **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.
194
- - **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. Replacing the starter's \`notes\` table with your own means rewriting \`notes-journey.mjs\` and \`notes-cross-user.mjs\` for that table. Removing the "Private files" section — the only code here that calls \`harbour.files.*\` — means \`rm .harbour/checks/files-journey.mjs\` right then; 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\`.
191
+ - **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\`.
195
192
 
196
193
  ## Adding a company system (Slack, Gmail, a warehouse view)
197
194
 
@@ -339,8 +336,9 @@ export function App() {
339
336
  {error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
340
337
 
341
338
  {/* Notes — paired with .harbour/checks/notes-journey.mjs (data) and
342
- .harbour/checks/notes-cross-user.mjs. Replacing this table with the app's own
343
- means rewriting both checks for that table in the same edit. */}
339
+ .harbour/checks/notes-cross-user.mjs, both generated from this section and
340
+ migrations/0001_notes.sql. Replace this table with the app's own and run
341
+ \`harbour check\`: it rewrites both checks for the new table in that same edit. */}
344
342
  <section>
345
343
  <h2>Notes</h2>
346
344
  <form onSubmit={event => { event.preventDefault(); void addNote(); }}>
@@ -358,10 +356,11 @@ export function App() {
358
356
  </section>
359
357
 
360
358
  {/* Private files — the only code in this app that calls harbour.files.*, and so the
361
- only reason .harbour/checks/files-journey.mjs exists. Delete this section and delete
362
- that check in the same edit: the deploy's flow gate replays .harbour/checks/ against a
363
- real App Gateway and refuses an app whose checks exercise a capability its code no
364
- longer has ("flow.check-failed: files-journey.mjs: exit status 1"). */}
359
+ only reason .harbour/checks/files-journey.mjs exists. Delete this section and the next
360
+ \`harbour check\` deletes that check with it; delete it by hand in the same edit if you
361
+ have edited it, because the deploy's flow gate replays .harbour/checks/ against a real
362
+ App Gateway and refuses an app whose checks exercise a capability its code no longer
363
+ has ("flow.check-failed: files-journey.mjs: exit status 1"). */}
365
364
  <section>
366
365
  <h2>Private files</h2>
367
366
  <input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
@@ -388,118 +387,3 @@ CREATE POLICY notes_owner ON notes
388
387
  GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO harbour_app_gateway;
389
388
  GRANT USAGE, SELECT ON SEQUENCE notes_id_seq TO harbour_app_gateway;
390
389
  `;
391
- const JOURNEY_CHECK = `// Journey check: the signed-in identity can create, read, tick and delete a note through
392
- // the running app (HARBOUR_APP_URL). Runs under \`harbour check\` while \`harbour dev\`
393
- // is up, and in the deployment pipeline's retained-check container.
394
- //
395
- // Paired with the \`notes\` table (migrations/0001_notes.sql) and the Notes section of
396
- // src/App.tsx. Replace that table with the app's own and this check is rewritten for the
397
- // new table in the same edit; drop the feature entirely and this file is deleted. A check
398
- // left behind for code the app no longer has fails the deploy's flow gate.
399
- import assert from "node:assert/strict";
400
-
401
- const appUrl = process.env.HARBOUR_APP_URL;
402
- assert.ok(appUrl, "HARBOUR_APP_URL is required");
403
- const sdk = await import(process.env.HARBOUR_SDK_MODULE ?? "@harbour/app-sdk");
404
- // The signed-in identity is attached by the SDK itself (HARBOUR_IDENTITY_CONTEXT
405
- // from \`harbour check\` and from the pipeline's runner): one injector per request.
406
- const harbour = sdk.createClient({ baseUrl: appUrl });
407
-
408
- const user = await harbour.identity.current();
409
- assert.ok(user && user.email, "identity/current must return the signed-in user");
410
- const title = \`journey \${Date.now()}\`;
411
- await harbour.data.from("notes").insert({ title });
412
- const { data: notes } = await harbour.data.from("notes").select("*").eq("title", title);
413
- assert.equal(notes.length, 1, "the inserted note is readable by its owner");
414
- // The tick box in src/App.tsx is an UPDATE. The deployment pipeline refuses an
415
- // app whose checks never exercise an operation its own code performs, so the
416
- // journey toggles the note and reads the new value back.
417
- await harbour.data.from("notes").update({ done: true }).eq("id", notes[0].id);
418
- const { data: toggled } = await harbour.data.from("notes").select("*").eq("id", notes[0].id);
419
- assert.equal(toggled[0].done, true, "the note is marked done for its owner");
420
- await harbour.data.from("notes").delete().eq("id", notes[0].id);
421
- const { data: remaining } = await harbour.data.from("notes").select("id").eq("title", title);
422
- assert.equal(remaining.length, 0, "the note is deleted");
423
- `;
424
- const CROSS_USER_CHECK = `// Cross-user check: a second signed-in person cannot read, update or delete the first
425
- // person's note through the running app (HARBOUR_APP_URL). The deployment pipeline's
426
- // write probe makes exactly these three assertions, with a second identity, against every
427
- // owner-scoped table; this is the same check run locally, so user isolation can no longer
428
- // be green under \`harbour check\` and red in CodeBuild. Runs while \`harbour dev\` is up,
429
- // and in the pipeline's retained-check container.
430
- //
431
- // Paired with the \`notes\` table: an app that replaces \`notes\` with its own owner-scoped
432
- // table rewrites this check for that table in the same edit, and one that drops the table
433
- // deletes this file. A check left behind for code the app no longer has fails the flow gate.
434
- import assert from "node:assert/strict";
435
-
436
- const appUrl = process.env.HARBOUR_APP_URL;
437
- assert.ok(appUrl, "HARBOUR_APP_URL is required");
438
- const secondUser = process.env.HARBOUR_IDENTITY_CONTEXT_SECOND_USER;
439
- assert.ok(secondUser, "HARBOUR_IDENTITY_CONTEXT_SECOND_USER is required (set by harbour check and by the pipeline)");
440
- const sdk = await import(process.env.HARBOUR_SDK_MODULE ?? "@harbour/app-sdk");
441
- // The first person: the SDK attaches HARBOUR_IDENTITY_CONTEXT itself.
442
- const owner = sdk.createClient({ baseUrl: appUrl });
443
- // The second person: the same SDK, with that person's signed identity replacing the first on every request.
444
- const header = (process.env.HARBOUR_IDENTITY_CONTEXT_HEADER ?? "X-Harbour-Identity-Context").toLowerCase();
445
- const asSecondUser = (input, init = {}) => fetch(input, { ...init, headers: { ...(init.headers ?? {}), [header]: secondUser } });
446
- const other = sdk.createClient({ baseUrl: appUrl, fetch: asSecondUser });
447
-
448
- const me = await owner.identity.current();
449
- const them = await other.identity.current();
450
- assert.ok(me && them && me.id !== them.id, "the two signed-in identities must be different people");
451
-
452
- const title = \`cross-user \${Date.now()}\`;
453
- await owner.data.from("notes").insert({ title });
454
- const { data: mine } = await owner.data.from("notes").select("*").eq("title", title);
455
- assert.equal(mine.length, 1, "the owner reads the note back");
456
- const id = mine[0].id;
457
-
458
- // 1. SELECT by id as the second person: no row.
459
- const { data: seen } = await other.data.from("notes").select("*").eq("id", id);
460
- assert.equal(seen.length, 0, "notes: a second signed-in user can read another user's row — the owner policy does not isolate users");
461
-
462
- // 2. UPDATE and 3. DELETE by id as the second person: refused (403) or no row affected.
463
- const denied = async (verb, operation) => {
464
- let affected;
465
- try { affected = (await operation()).data ?? []; }
466
- catch (error) { if (error?.category === "FORBIDDEN") return; throw error; }
467
- assert.equal(affected.length, 0, \`notes \${verb}: a second user mutated another user's row despite an owner-scoped source policy\`);
468
- };
469
- await denied("UPDATE", () => other.data.from("notes").update({ done: true }).eq("id", id));
470
- await denied("DELETE", () => other.data.from("notes").delete().eq("id", id));
471
- const { data: after } = await owner.data.from("notes").select("*").eq("id", id);
472
- assert.equal(after.length, 1, "the owner's note still exists after the second user's attempts");
473
- assert.equal(after[0].done, false, "the owner's note is unchanged after the second user's attempts");
474
- await owner.data.from("notes").delete().eq("id", id);
475
- `;
476
- const FILES_JOURNEY_CHECK = `// Journey check: the signed-in identity can upload a private file and list it back
477
- // through the running app (HARBOUR_APP_URL) — the two operations the "Private
478
- // files" section of src/App.tsx performs (\`harbour.files.upload\` / \`harbour.files.list\`).
479
- // The deployment pipeline refuses an app whose checks never exercise a capability
480
- // its own code uses, so this runs under \`harbour check\` while \`harbour dev\` is up,
481
- // and in the pipeline's retained-check container.
482
- //
483
- // This file exists only for that "Private files" section — the only code in the app that
484
- // calls \`harbour.files.*\`. If the app stops offering file upload, DELETE THIS FILE in the
485
- // same edit that removes the section: the gate runs both ways, and a retained check for a
486
- // capability the code no longer has refuses the deploy with
487
- // "flow.check-failed: files-journey.mjs: exit status 1".
488
- import assert from "node:assert/strict";
489
-
490
- const appUrl = process.env.HARBOUR_APP_URL;
491
- assert.ok(appUrl, "HARBOUR_APP_URL is required");
492
- const sdk = await import(process.env.HARBOUR_SDK_MODULE ?? "@harbour/app-sdk");
493
- // The signed-in identity is attached by the SDK itself (HARBOUR_IDENTITY_CONTEXT
494
- // from \`harbour check\` and from the pipeline's runner): one injector per request.
495
- const harbour = sdk.createClient({ baseUrl: appUrl });
496
-
497
- const user = await harbour.identity.current();
498
- assert.ok(user && user.email, "identity/current must return the signed-in user");
499
- const name = \`journey-\${Date.now()}.txt\`;
500
- await harbour.files.upload(\`private/\${name}\`, new Blob(["harbour journey check\\n"], { type: "text/plain" }));
501
- const listed = await harbour.files.list("private/");
502
- assert.ok(Array.isArray(listed), "files.list must return a list");
503
- const found = listed.some(entry => String(typeof entry === "string" ? entry : entry?.path ?? entry?.name ?? "").endsWith(name));
504
- assert.ok(found, \`the uploaded file is listed back to its owner (looking for \${name})\`);
505
- `;
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.25";
1
+ export const CLI_VERSION = "0.1.26";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": {