@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.
- package/dist/packages/harbour-cli/src/app-schema.js +178 -0
- package/dist/packages/harbour-cli/src/check.js +29 -4
- package/dist/packages/harbour-cli/src/flow-coverage.js +47 -21
- package/dist/packages/harbour-cli/src/local-runtime.js +3 -2
- package/dist/packages/harbour-cli/src/retained-checks.js +396 -0
- package/dist/packages/harbour-cli/src/starter.js +29 -145
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
112
|
-
* follows THIS `.from(...)` up to the end of its
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
142
|
-
* `<client>.<namespace>.<method>(` on an identifier
|
|
143
|
-
* TypeScript type arguments may sit between the
|
|
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
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
189
|
-
|
|
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(
|
|
265
|
-
|
|
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
|
|
32
|
-
//
|
|
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.
|
|
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.*`.
|
|
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.
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* checks
|
|
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.
|
|
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
|
|
343
|
-
|
|
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
|
|
362
|
-
that check
|
|
363
|
-
|
|
364
|
-
|
|
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.
|
|
1
|
+
export const CLI_VERSION = "0.1.26";
|