@nanobpm/urban 0.70.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -58,3 +58,47 @@ barrel.
58
58
  - `npm run test:conformance --workspace @nanobpm/urban` runs the conformance
59
59
  corpus.
60
60
  - `npm run check:pii --workspace @nanobpm/urban` runs the PII gate.
61
+
62
+ ## PII: no-PII by construction, and the immutability-vs-erasure boundary
63
+
64
+ The MVP substrate is **no-PII by construction**. Nothing here stores personal
65
+ data, and two independent nets keep it that way:
66
+
67
+ 1. **In-process, at write time (S6-core + S3).** `@nanobpm/urban/context/pii`
68
+ ships a pure classifier (`classifyPii`) and a mandatory, default-DENY
69
+ pre-commit guard (`preCommitPiiGuard`). S3's `ContextWriter` registers that
70
+ guard as a **non-optional** pre-commit step on **every** write path
71
+ (`PreCommitGuardRegistry`, seeded non-removably), so a record carrying PII is
72
+ rejected **before any commit** — a caller cannot opt out.
73
+
74
+ 2. **Build-time, in CI (this slice, s6-pii-ci).** `npm run check:pii` walks the
75
+ S3 record layout (`records/<scope>/<scopeRef-bucket>/<id>.json`, where
76
+ `<scopeRef-bucket>` is the sanitised `scopeRef` or the shared `_` bucket when a
77
+ record carries no `scopeRef` — see `recordRelativePath`) and re-classifies
78
+ every record with the **same** `classifyPii`, failing the build on any violation.
79
+ The `.github/workflows/urban-context-pii.yml` workflow runs it (plus
80
+ build/typecheck/test) as a second line of defence in case content reaches a
81
+ git-backed context by some path other than the writer.
82
+
83
+ ### Why the guard, not erasure
84
+
85
+ The substrate is an **append-only git history**. "Deleting" a record is just
86
+ another commit that removes the file from the *tip*; every prior value remains
87
+ in the reachable history (and in every clone, fork, and reflog). On this
88
+ substrate there is **no true erasure** — you cannot unpublish a fact that was
89
+ ever committed short of a history rewrite that every consumer must also adopt.
90
+ Because real erasure is not achievable append-only, the only sound MVP policy is
91
+ to **never admit PII in the first place** — hence the by-construction guard + CI
92
+ net above rather than an after-the-fact delete.
93
+
94
+ ### The seam left for a future PII/mutable backend
95
+
96
+ Real erasure (GDPR-style "right to be forgotten", tombstoning, crypto-shredding)
97
+ requires a **mutable** substrate that can genuinely destroy a value, not merely
98
+ append over it. The write path is already abstracted over the substrate
99
+ (`WriteSubstrate` / `GitWriteSubstrate` in `@nanobpm/urban/context/git`) and the
100
+ binding layer does not assume "public git only", so a future opt-in
101
+ PII/mutable backend can slot in behind that seam and offer erasure semantics
102
+ **without** touching the record schema, the layout, or this guard. That backend
103
+ is intentionally **not built** in the MVP — only its seam is documented here, and
104
+ the deferred implementation is tracked in nano-ide#393.
@@ -0,0 +1,196 @@
1
+ // Slice s6-pii-ci — tests for the build-time PII gate (`scripts/check-pii.ts`).
2
+ //
3
+ // These seed records into the SAME S3 path/namespace layout the writer uses
4
+ // (`recordRelativePath`), then run the real check-pii.ts script against a local
5
+ // temp substrate and assert its exit code: non-zero on a seeded PII violation,
6
+ // zero on clean content. Nothing touches the network.
7
+
8
+ import assert from "node:assert/strict";
9
+ import { execFile } from "node:child_process";
10
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { after, test } from "node:test";
14
+ import { fileURLToPath } from "node:url";
15
+ import { promisify } from "node:util";
16
+ import { recordRelativePath } from "../git/index.ts";
17
+ import type { MemoryRecord } from "../schema/index.ts";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ // packages/urban/src/context/pii → packages/urban/scripts/check-pii.ts
22
+ const CHECK_PII = join(HERE, "..", "..", "..", "scripts", "check-pii.ts");
23
+ const TEMP_ROOTS: string[] = [];
24
+
25
+ after(async () => {
26
+ await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true })));
27
+ });
28
+
29
+ function record(overrides: Partial<MemoryRecord>): MemoryRecord {
30
+ const base: MemoryRecord = {
31
+ schemaVersion: 1,
32
+ id: "rec-1",
33
+ scope: "epic",
34
+ scopeRef: "issue-303",
35
+ mode: "empirical",
36
+ provenance: "measured",
37
+ authority: "authoritative",
38
+ statement: "the measured throughput was 42 rps",
39
+ createdAt: "2026-01-01T00:00:00Z",
40
+ };
41
+ return { ...base, ...overrides };
42
+ }
43
+
44
+ /** Write a record into a substrate root at its canonical S3 layout path. */
45
+ async function seed(root: string, rec: MemoryRecord): Promise<void> {
46
+ const rel = recordRelativePath(rec);
47
+ const abs = join(root, rel);
48
+ await mkdir(dirname(abs), { recursive: true });
49
+ await writeFile(abs, `${JSON.stringify(rec, null, 2)}\n`, "utf8");
50
+ }
51
+
52
+ /** Write raw file content into a substrate root at a record's canonical path. */
53
+ async function seedRaw(root: string, rec: MemoryRecord, content: string): Promise<void> {
54
+ const rel = recordRelativePath(rec);
55
+ const abs = join(root, rel);
56
+ await mkdir(dirname(abs), { recursive: true });
57
+ await writeFile(abs, content, "utf8");
58
+ }
59
+
60
+ async function makeRoot(): Promise<string> {
61
+ const dir = await mkdtemp(join(tmpdir(), "s6ci-substrate-"));
62
+ TEMP_ROOTS.push(dir);
63
+ return dir;
64
+ }
65
+
66
+ /** Run check-pii.ts against a root; resolve with its exit code + stderr. */
67
+ async function runCheck(root: string): Promise<{ code: number; stderr: string; stdout: string }> {
68
+ try {
69
+ const { stdout, stderr } = await execFileAsync(
70
+ process.execPath,
71
+ ["--experimental-strip-types", CHECK_PII, root],
72
+ { cwd: root },
73
+ );
74
+ return { code: 0, stdout, stderr };
75
+ } catch (error) {
76
+ return {
77
+ code: numberProp(error, "code") ?? 1,
78
+ stdout: stringProp(error, "stdout"),
79
+ stderr: stringProp(error, "stderr"),
80
+ };
81
+ }
82
+ }
83
+
84
+ function numberProp(error: unknown, key: string): number | undefined {
85
+ const value =
86
+ typeof error === "object" && error !== null ? Reflect.get(error, key) : undefined;
87
+ return typeof value === "number" ? value : undefined;
88
+ }
89
+
90
+ function stringProp(error: unknown, key: string): string {
91
+ const value =
92
+ typeof error === "object" && error !== null ? Reflect.get(error, key) : undefined;
93
+ return typeof value === "string" ? value : "";
94
+ }
95
+
96
+ test("check:pii passes (exit 0) on clean records in the S3 layout", async () => {
97
+ const root = await makeRoot();
98
+ await seed(root, record({ id: "clean-a" }));
99
+ await seed(
100
+ root,
101
+ record({ id: "clean-b", scope: "repo", scopeRef: undefined, statement: "latency held at 42ms" }),
102
+ );
103
+
104
+ const { code, stdout } = await runCheck(root);
105
+ assert.equal(code, 0, `expected clean content to pass; stdout=${stdout}`);
106
+ assert.match(stdout, /no PII detected/);
107
+ });
108
+
109
+ test("check:pii fails (non-zero) on a seeded PII violation in the S3 layout", async () => {
110
+ const root = await makeRoot();
111
+ await seed(root, record({ id: "clean-a" }));
112
+ // A record whose statement carries an email address — a PII violation.
113
+ await seed(
114
+ root,
115
+ record({ id: "leaky", statement: "reported by alice.example@contoso.com during the run" }),
116
+ );
117
+
118
+ const { code, stderr } = await runCheck(root);
119
+ assert.equal(code, 1, "expected a seeded PII violation to fail the build");
120
+ assert.match(stderr, /PII detected/);
121
+ assert.match(stderr, /email/);
122
+ // The gate must NOT re-leak the raw PII it caught (length-only redaction).
123
+ assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
124
+ });
125
+
126
+ test("check:pii catches PII in a JSON array record (arrays are not plain objects)", async () => {
127
+ const root = await makeRoot();
128
+ // A record file whose top-level JSON value is an array carrying an email
129
+ // address. Arrays must NOT be narrowed as plain objects — they fall through
130
+ // to the raw-text scan, so the leak is still caught.
131
+ await seedRaw(
132
+ root,
133
+ record({ id: "arr-leak" }),
134
+ `${JSON.stringify(["contact alice.example@contoso.com for details"], null, 2)}\n`,
135
+ );
136
+
137
+ const { code, stderr } = await runCheck(root);
138
+ assert.equal(code, 1, "expected a PII violation inside a JSON array record to fail the build");
139
+ assert.match(stderr, /PII detected/);
140
+ assert.match(stderr, /email/);
141
+ assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
142
+ });
143
+
144
+ test("check:pii is a no-op (exit 0) on a root with no records/ layout", async () => {
145
+ const root = await makeRoot();
146
+ const { code, stdout } = await runCheck(root);
147
+ assert.equal(code, 0);
148
+ assert.match(stdout, /0 record\(s\) scanned/);
149
+ });
150
+
151
+ test("check:pii does not follow a symlink record into a target outside the root", async () => {
152
+ const root = await makeRoot();
153
+ // A secret file OUTSIDE the substrate root (e.g. a CI secret the scanner
154
+ // should never touch), carrying an email address.
155
+ const secret = join(await makeRoot(), "ci-secret.txt");
156
+ await writeFile(secret, "token owner: alice.example@contoso.com\n", "utf8");
157
+ // A symlink placed at a canonical record path that points at that secret. The
158
+ // scanner must reject non-regular files (symlinks) up front — otherwise a
159
+ // crafted `records/**.json` symlink would make it read and classify content
160
+ // outside the intended substrate root.
161
+ const rel = recordRelativePath(record({ id: "evil-link" }));
162
+ const linkPath = join(root, rel);
163
+ await mkdir(dirname(linkPath), { recursive: true });
164
+ await symlink(secret, linkPath);
165
+
166
+ const { code, stdout } = await runCheck(root);
167
+ assert.equal(code, 0, `symlinked record must be skipped, not scanned; stdout=${stdout}`);
168
+ assert.match(stdout, /0 record\(s\) scanned/);
169
+ });
170
+
171
+ test("check:pii does not follow a symlinked DIRECTORY into a target outside the root", async () => {
172
+ const root = await makeRoot();
173
+ // A directory OUTSIDE the substrate root holding a record-shaped file that
174
+ // carries an email address — content the scanner must never reach.
175
+ const outsideDir = join(await makeRoot(), "outside-bucket");
176
+ await mkdir(outsideDir, { recursive: true });
177
+ await writeFile(
178
+ join(outsideDir, "rec-leak.json"),
179
+ `${JSON.stringify(record({ id: "rec-leak", statement: "owned by alice.example@contoso.com" }), null, 2)}\n`,
180
+ "utf8",
181
+ );
182
+ // Symlink an INTERMEDIATE bucket directory in the layout to that outside dir.
183
+ // `records/epic/issue-303` → outsideDir makes `records/epic/issue-303/rec-leak.json`
184
+ // look like a canonical record path. `readdir(recursive:true)` would follow
185
+ // the symlinked directory and scan (and re-leak) the outside file; the walk
186
+ // must refuse to descend into any symlinked directory.
187
+ const bucketLink = join(root, "records", "epic", "issue-303");
188
+ await mkdir(dirname(bucketLink), { recursive: true });
189
+ await symlink(outsideDir, bucketLink);
190
+
191
+ const { code, stdout, stderr } = await runCheck(root);
192
+ assert.equal(code, 0, `symlinked bucket dir must not be traversed; stderr=${stderr}`);
193
+ assert.match(stdout, /0 record\(s\) scanned/);
194
+ // And it must never re-leak the outside PII it refused to scan.
195
+ assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
196
+ });