@tekyzinc/gsd-t 4.19.14 → 4.20.11

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,469 @@
1
+ /**
2
+ * GSD-T Audit Module Template (M100-D4)
3
+ *
4
+ * DESIGNED FRESH — no inherited BinVoice model (BinVoice has no audit log).
5
+ * Contract: .gsd-t/contracts/audit-logging-contract.md v1.0.0 DRAFT.
6
+ *
7
+ * Audit = a durable, admin-facing ACCOUNTABILITY record of who-did-what-when.
8
+ * Permanent (within retention). Append-only IMMUTABLE. Admin-queryable.
9
+ * DISTINCT from trace (transient debug signal) — NEVER share a store/stream.
10
+ *
11
+ * This file is a TEMPLATE: the scaffolder instantiates it per-project,
12
+ * pointing `dbPath` at the project's chosen embedded store (SQLite is
13
+ * flagged over flat-file for audit queryability per the scaffold-seam
14
+ * contract). Ship it as-is into `src/logging/audit-module.ts` (or the
15
+ * project's equivalent path) with `dbPath`/`retentionDays` wired to the
16
+ * project's own config — never hardcoded.
17
+ *
18
+ * Durability rules (non-negotiable):
19
+ * - Append-only IMMUTABLE: the write helper exposes NO update-existing /
20
+ * delete-existing path. The underlying store REJECTS UPDATE/DELETE of
21
+ * an existing row via a trigger, enforced through the SAME connection
22
+ * this module exposes (not merely by omitting an API method).
23
+ * - The immutability trigger itself is protected by self-healing: this
24
+ * better-sqlite3 build exposes no schema-write authorizer hook to
25
+ * pre-empt a DROP TRIGGER at the SQLite layer, so `pruneExpired()` (the
26
+ * module's only deletion entry point) detects a missing/dropped trigger
27
+ * and unconditionally re-asserts both triggers before proceeding — a
28
+ * DROP TRIGGER issued through this module's own connection is healed
29
+ * before the next sanctioned delete, never leaving the table unguarded
30
+ * across a normal-operation call sequence.
31
+ * - `pruneExpired()` is the SOLE sanctioned deletion path — it deletes
32
+ * ONLY rows outside the retention window, and is bounds-checked so no
33
+ * retention-window config value (including 0 or negative) can be
34
+ * coerced into deleting a live row.
35
+ * - Retention is CONFIGURABLE + extendable, never a hardcoded literal.
36
+ * - The admin query surface (`queryAudit`) is filterable by actor/target/
37
+ * time and is exported as a GSD-T-independent entry point (plain
38
+ * function + a generated CLI), usable after GSD-T itself is uninstalled.
39
+ */
40
+
41
+ import Database from 'better-sqlite3';
42
+
43
+ // ── Types ────────────────────────────────────────────────────────────────
44
+
45
+ /** The required audit-entry envelope (audit-logging-contract.md §Required audit-entry envelope). */
46
+ export interface AuditEntry {
47
+ /** ISO-8601 timestamp of when the action happened. */
48
+ ts: string;
49
+ /** Who performed the action (user/admin/system id). */
50
+ actor: string;
51
+ /** What happened — a PROJECT-VARYING verb, distilled per project. Never a fixed enum. */
52
+ action: string;
53
+ /** The entity acted on. */
54
+ target: string;
55
+ /** State before the action. May be `null` for a create. */
56
+ before: Record<string, unknown> | null;
57
+ /** State after the action. May be `null` for a delete. */
58
+ after: Record<string, unknown> | null;
59
+ /** ip / session / request-id and any other accountability context. */
60
+ context: Record<string, unknown>;
61
+ }
62
+
63
+ /** A row as read back from the store (adds the store-assigned primary key). */
64
+ export interface AuditRow extends AuditEntry {
65
+ id: number;
66
+ }
67
+
68
+ export interface AuditQueryFilter {
69
+ actor?: string;
70
+ target?: string;
71
+ /** Inclusive lower bound, ISO-8601. */
72
+ since?: string;
73
+ /** Inclusive upper bound, ISO-8601. */
74
+ until?: string;
75
+ }
76
+
77
+ export interface AuditRetentionConfig {
78
+ /** Retention window in days. Read from project config — NEVER a literal baked into this file. */
79
+ retentionDays: number;
80
+ }
81
+
82
+ export interface AuditModuleOptions {
83
+ /** Path to the project's embedded SQLite store (scaffolder-recorded, e.g. `.gsd-t/audit.db`). */
84
+ dbPath: string;
85
+ /** Retention config — read from the project's own config surface, extendable per project. */
86
+ retention: AuditRetentionConfig;
87
+ }
88
+
89
+ // ── Schema + immutability guard ─────────────────────────────────────────
90
+
91
+ const TABLE = 'audit_log';
92
+ const GATE_TABLE = 'audit_log_prune_gate';
93
+ const SENTINEL_TABLE = 'audit_log_prune_sentinel';
94
+ const IMMUTABLE_TRIGGER_UPDATE = 'audit_log_no_update';
95
+ const IMMUTABLE_TRIGGER_DELETE = 'audit_log_no_delete';
96
+ const GATE_TRIGGER_UPDATE = 'audit_log_prune_gate_no_update';
97
+ const GATE_TRIGGER_DELETE = 'audit_log_prune_gate_no_delete';
98
+
99
+ function ensureSchema(db: InstanceType<typeof Database>): void {
100
+ db.pragma('journal_mode = WAL');
101
+
102
+ db.exec(`
103
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ ts TEXT NOT NULL,
106
+ actor TEXT NOT NULL,
107
+ action TEXT NOT NULL,
108
+ target TEXT NOT NULL,
109
+ before TEXT,
110
+ after TEXT,
111
+ context TEXT NOT NULL
112
+ );
113
+ `);
114
+
115
+ // Append-only immutability enforced AT THE STORE via triggers that abort
116
+ // any UPDATE/DELETE against an existing row — not merely omitted from this
117
+ // module's API. A second raw connection to the SAME db file is still bound
118
+ // by these triggers (they live in the schema, not in application code).
119
+ db.exec(`
120
+ CREATE TRIGGER IF NOT EXISTS ${IMMUTABLE_TRIGGER_UPDATE}
121
+ BEFORE UPDATE ON ${TABLE}
122
+ BEGIN
123
+ SELECT RAISE(ABORT, 'audit_log is append-only: UPDATE is forbidden');
124
+ END;
125
+ `);
126
+
127
+ // COALESCE-hardened WHEN: an EMPTY gate table (subquery → NULL) is treated as
128
+ // active=0 (LOCKED) so the trigger FIRES and blocks the delete — fail-CLOSED,
129
+ // never fail-open. A hostile `DELETE FROM audit_log_prune_gate` therefore
130
+ // cannot slip a live-row DELETE through on a NULL=0 short-circuit.
131
+ db.exec(`
132
+ CREATE TRIGGER IF NOT EXISTS ${IMMUTABLE_TRIGGER_DELETE}
133
+ BEFORE DELETE ON ${TABLE}
134
+ WHEN COALESCE((SELECT active FROM ${GATE_TABLE} LIMIT 1), 0) = 0
135
+ BEGIN
136
+ SELECT RAISE(ABORT, 'audit_log is append-only: direct DELETE is forbidden — use pruneExpired()');
137
+ END;
138
+ `);
139
+ }
140
+
141
+ /**
142
+ * Returns true only when BOTH immutability triggers are present in
143
+ * sqlite_master with their expected bodies. Used to detect (and, inside
144
+ * pruneExpired, self-heal) a DROP TRIGGER / schema-tamper attempt against
145
+ * the append-only guard — this build of better-sqlite3 does not expose a
146
+ * schema-write authorizer hook (`db.authorizer` is undefined on 12.x), so
147
+ * the guard cannot pre-empt a DROP at the SQLite layer; instead it detects
148
+ * and RE-ASSERTS the trigger before every sanctioned delete, and the killing
149
+ * test proves a bare DROP TRIGGER + UPDATE/DELETE sequence still fails once
150
+ * ensureSchema/pruneExpired next run because the trigger is unconditionally
151
+ * recreated first.
152
+ */
153
+ function _triggersIntact(db: InstanceType<typeof Database>): boolean {
154
+ const rows = db
155
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?, ?, ?)`)
156
+ .all(
157
+ IMMUTABLE_TRIGGER_UPDATE,
158
+ IMMUTABLE_TRIGGER_DELETE,
159
+ GATE_TRIGGER_UPDATE,
160
+ GATE_TRIGGER_DELETE
161
+ ) as Array<{ name: string }>;
162
+ return rows.length === 4;
163
+ }
164
+
165
+ // pruneExpired needs to delete rows the DELETE trigger above unconditionally
166
+ // blocks. Rather than weaken the trigger (which would reopen the bypass this
167
+ // module exists to close), pruneExpired runs its bounded DELETE through a
168
+ // short-lived gate flag the trigger's WHEN clause checks: a flag set only
169
+ // inside pruneExpired's own transaction, and reset immediately after, never
170
+ // exposed on the module's public surface.
171
+ //
172
+ // The gate table is itself TAMPER-PROTECTED (Red Team M100 BUG 1/2 fix). A
173
+ // prior design delegated immutability to a WIDE-OPEN `active` flag: a second
174
+ // connection could `UPDATE audit_log_prune_gate SET active = 1` (then delete a
175
+ // live row while the DELETE trigger's WHEN read active=1), or `DELETE FROM
176
+ // audit_log_prune_gate` (emptying it → subquery NULL → `NULL = 0` is NULL, not
177
+ // TRUE → the WHEN was unsatisfied → the DELETE trigger never fired). Both
178
+ // defeated append-only immutability. The fix:
179
+ // (a) BEFORE UPDATE / BEFORE DELETE triggers on the gate table itself that
180
+ // RAISE(ABORT) unless an IN-BAND prune sentinel row is present — the same
181
+ // treatment audit_log rows get. pruneExpired inserts that sentinel inside
182
+ // its own transaction (and removes it after), so ONLY the sanctioned
183
+ // prune path may touch the gate; any out-of-band UPDATE/DELETE of the
184
+ // gate from a hostile second connection is aborted.
185
+ // (b) the audit_log DELETE trigger's WHEN is COALESCE-hardened (see
186
+ // ensureSchema) so an emptied gate reads as active=0 (LOCKED), fail-CLOSED.
187
+ // (c) ensurePruneGuard/pruneExpired self-heal the gate ROW (re-INSERT the
188
+ // single active=0 row if missing) AND re-assert every trigger before the
189
+ // gate is used, so the between-tamper-and-prune window can never leave a
190
+ // deletable table.
191
+ // Every call to ensurePruneGuard/pruneExpired first RE-ASSERTS all triggers
192
+ // (idempotent `CREATE TRIGGER` after an explicit `DROP TRIGGER IF EXISTS`) and
193
+ // restores the gate row, so a prior DROP TRIGGER / gate-tamper against this
194
+ // connection is healed before the gate is used.
195
+
196
+ function ensurePruneGuard(db: InstanceType<typeof Database>): void {
197
+ db.exec(`
198
+ CREATE TABLE IF NOT EXISTS ${GATE_TABLE} (
199
+ active INTEGER NOT NULL DEFAULT 0
200
+ );
201
+ `);
202
+ // The in-band prune sentinel: a row exists ONLY inside pruneExpired's own
203
+ // transaction. The gate-protection triggers key off its presence to tell a
204
+ // sanctioned gate write apart from a hostile out-of-band one.
205
+ db.exec(`
206
+ CREATE TABLE IF NOT EXISTS ${SENTINEL_TABLE} (
207
+ token INTEGER NOT NULL
208
+ );
209
+ `);
210
+ _restoreGateRow(db);
211
+ _reassertTriggers(db);
212
+ }
213
+
214
+ /** Re-INSERT the single gate row (active=0) if the gate table was emptied by a tamper attempt. */
215
+ function _restoreGateRow(db: InstanceType<typeof Database>): void {
216
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM ${GATE_TABLE}`).get() as { n: number };
217
+ if (row.n === 0) {
218
+ // Direct restore while the gate-protection trigger may be active: run it
219
+ // through the same in-band sentinel window the sanctioned path uses.
220
+ const restore = db.transaction(() => {
221
+ db.prepare(`INSERT INTO ${SENTINEL_TABLE} (token) VALUES (1)`).run();
222
+ db.prepare(`INSERT INTO ${GATE_TABLE} (active) VALUES (0)`).run();
223
+ db.prepare(`DELETE FROM ${SENTINEL_TABLE}`).run();
224
+ });
225
+ restore();
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Unconditionally (re)creates BOTH the audit_log immutability triggers AND the
231
+ * gate-table self-protection triggers — self-healing against a DROP TRIGGER
232
+ * attempt on any of them.
233
+ */
234
+ function _reassertTriggers(db: InstanceType<typeof Database>): void {
235
+ db.exec(`DROP TRIGGER IF EXISTS ${IMMUTABLE_TRIGGER_UPDATE};`);
236
+ db.exec(`
237
+ CREATE TRIGGER ${IMMUTABLE_TRIGGER_UPDATE}
238
+ BEFORE UPDATE ON ${TABLE}
239
+ BEGIN
240
+ SELECT RAISE(ABORT, 'audit_log is append-only: UPDATE is forbidden');
241
+ END;
242
+ `);
243
+
244
+ db.exec(`DROP TRIGGER IF EXISTS ${IMMUTABLE_TRIGGER_DELETE};`);
245
+ db.exec(`
246
+ CREATE TRIGGER ${IMMUTABLE_TRIGGER_DELETE}
247
+ BEFORE DELETE ON ${TABLE}
248
+ WHEN COALESCE((SELECT active FROM ${GATE_TABLE} LIMIT 1), 0) = 0
249
+ BEGIN
250
+ SELECT RAISE(ABORT, 'audit_log is append-only: direct DELETE is forbidden — use pruneExpired()');
251
+ END;
252
+ `);
253
+
254
+ // Gate-table self-protection: any UPDATE/DELETE of the gate row is aborted
255
+ // unless the in-band prune sentinel is present (i.e. we are inside
256
+ // pruneExpired's own transaction). This is what closes the wide-open-flag
257
+ // bypass — a hostile second connection cannot flip or clear the gate.
258
+ db.exec(`DROP TRIGGER IF EXISTS ${GATE_TRIGGER_UPDATE};`);
259
+ db.exec(`
260
+ CREATE TRIGGER ${GATE_TRIGGER_UPDATE}
261
+ BEFORE UPDATE ON ${GATE_TABLE}
262
+ WHEN (SELECT COUNT(*) FROM ${SENTINEL_TABLE}) = 0
263
+ BEGIN
264
+ SELECT RAISE(ABORT, 'audit_log_prune_gate is tamper-protected: out-of-band UPDATE is forbidden');
265
+ END;
266
+ `);
267
+
268
+ db.exec(`DROP TRIGGER IF EXISTS ${GATE_TRIGGER_DELETE};`);
269
+ db.exec(`
270
+ CREATE TRIGGER ${GATE_TRIGGER_DELETE}
271
+ BEFORE DELETE ON ${GATE_TABLE}
272
+ WHEN (SELECT COUNT(*) FROM ${SENTINEL_TABLE}) = 0
273
+ BEGIN
274
+ SELECT RAISE(ABORT, 'audit_log_prune_gate is tamper-protected: out-of-band DELETE is forbidden');
275
+ END;
276
+ `);
277
+ }
278
+
279
+ // ── Serialization helpers ───────────────────────────────────────────────
280
+
281
+ function serializeJsonField(value: Record<string, unknown> | null): string | null {
282
+ return value === null ? null : JSON.stringify(value);
283
+ }
284
+
285
+ function deserializeJsonField(value: string | null): Record<string, unknown> | null {
286
+ return value === null ? null : (JSON.parse(value) as Record<string, unknown>);
287
+ }
288
+
289
+ function rowToAuditRow(row: any): AuditRow {
290
+ return {
291
+ id: row.id,
292
+ ts: row.ts,
293
+ actor: row.actor,
294
+ action: row.action,
295
+ target: row.target,
296
+ before: deserializeJsonField(row.before),
297
+ after: deserializeJsonField(row.after),
298
+ context: JSON.parse(row.context) as Record<string, unknown>,
299
+ };
300
+ }
301
+
302
+ // ── Public module ────────────────────────────────────────────────────────
303
+
304
+ export class AuditModule {
305
+ private readonly db: InstanceType<typeof Database>;
306
+ private readonly retention: AuditRetentionConfig;
307
+
308
+ /** Declares append-only/immutable durability — consumed structurally by the verify gate's module-surface scan. */
309
+ public static readonly declaresAppendOnly = true as const;
310
+
311
+ constructor(opts: AuditModuleOptions) {
312
+ this.db = new Database(opts.dbPath);
313
+ this.retention = opts.retention;
314
+ ensureSchema(this.db);
315
+ ensurePruneGuard(this.db);
316
+ }
317
+
318
+ /**
319
+ * Appends one audit entry. INSERT-only — there is no updateEntry/deleteEntry
320
+ * export on this class; the store itself rejects UPDATE/DELETE via the
321
+ * triggers installed in ensureSchema/ensurePruneGuard.
322
+ */
323
+ appendAudit(entry: AuditEntry): AuditRow {
324
+ const stmt = this.db.prepare(`
325
+ INSERT INTO ${TABLE} (ts, actor, action, target, before, after, context)
326
+ VALUES (@ts, @actor, @action, @target, @before, @after, @context)
327
+ `);
328
+ const info = stmt.run({
329
+ ts: entry.ts,
330
+ actor: entry.actor,
331
+ action: entry.action,
332
+ target: entry.target,
333
+ before: serializeJsonField(entry.before),
334
+ after: serializeJsonField(entry.after),
335
+ context: JSON.stringify(entry.context),
336
+ });
337
+ const row = this.db.prepare(`SELECT * FROM ${TABLE} WHERE id = ?`).get(info.lastInsertRowid);
338
+ return rowToAuditRow(row);
339
+ }
340
+
341
+ /**
342
+ * The admin query surface (audit-logging-contract.md §Admin query surface).
343
+ * Filterable by actor / target / time window — the "look back without
344
+ * GSD-T" surface. GSD-T-independent: a plain method on this class, usable
345
+ * by the project's own admin tooling with no GSD-T toolchain present.
346
+ */
347
+ queryAudit(filter: AuditQueryFilter = {}): AuditRow[] {
348
+ const clauses: string[] = [];
349
+ const params: Record<string, unknown> = {};
350
+
351
+ if (filter.actor !== undefined) {
352
+ clauses.push('actor = @actor');
353
+ params.actor = filter.actor;
354
+ }
355
+ if (filter.target !== undefined) {
356
+ clauses.push('target = @target');
357
+ params.target = filter.target;
358
+ }
359
+ if (filter.since !== undefined) {
360
+ clauses.push('ts >= @since');
361
+ params.since = filter.since;
362
+ }
363
+ if (filter.until !== undefined) {
364
+ clauses.push('ts <= @until');
365
+ params.until = filter.until;
366
+ }
367
+
368
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
369
+ const rows = this.db.prepare(`SELECT * FROM ${TABLE} ${where} ORDER BY id ASC`).all(params);
370
+ return rows.map(rowToAuditRow);
371
+ }
372
+
373
+ /**
374
+ * The SOLE sanctioned deletion path. Deletes ONLY rows strictly older than
375
+ * the retention window — bounds-checked so no retention.retentionDays
376
+ * value (including 0, negative, or NaN) can prune a live/non-expired row:
377
+ * a non-positive or non-finite window is treated as "prune nothing" rather
378
+ * than being allowed to widen the cutoff into the present/future.
379
+ */
380
+ pruneExpired(): { deletedCount: number } {
381
+ // Self-heal first: if a prior statement on this connection DROPped any of
382
+ // the immutability / gate-protection triggers, re-assert them AND restore
383
+ // the gate row before doing anything else so pruneExpired never runs
384
+ // against an unguarded table or a missing gate row.
385
+ _restoreGateRow(this.db);
386
+ if (!_triggersIntact(this.db)) {
387
+ _reassertTriggers(this.db);
388
+ }
389
+
390
+ const days = this.retention.retentionDays;
391
+ const safeDays = Number.isFinite(days) && days > 0 ? days : Infinity;
392
+ if (safeDays === Infinity) {
393
+ // Non-positive/invalid retention window configured as "keep forever" —
394
+ // never coerced into deleting anything.
395
+ return { deletedCount: 0 };
396
+ }
397
+
398
+ const cutoff = new Date(Date.now() - safeDays * 24 * 60 * 60 * 1000).toISOString();
399
+
400
+ const openGate = this.db.transaction(() => {
401
+ // Raise the in-band prune sentinel so the gate-protection triggers allow
402
+ // THIS transaction (and only this one) to flip the gate flag. A hostile
403
+ // second connection has no such sentinel, so its gate UPDATE/DELETE is
404
+ // aborted.
405
+ this.db.prepare(`INSERT INTO ${SENTINEL_TABLE} (token) VALUES (1)`).run();
406
+ this.db.prepare(`UPDATE ${GATE_TABLE} SET active = 1`).run();
407
+ const info = this.db.prepare(`DELETE FROM ${TABLE} WHERE ts < ?`).run(cutoff);
408
+ this.db.prepare(`UPDATE ${GATE_TABLE} SET active = 0`).run();
409
+ this.db.prepare(`DELETE FROM ${SENTINEL_TABLE}`).run();
410
+ return info.changes;
411
+ });
412
+
413
+ const deletedCount = openGate();
414
+ return { deletedCount };
415
+ }
416
+
417
+ /** Test/ops surface: true iff both immutability triggers are present on the store. */
418
+ triggersIntact(): boolean {
419
+ return _triggersIntact(this.db);
420
+ }
421
+
422
+ close(): void {
423
+ this.db.close();
424
+ }
425
+ }
426
+
427
+ // ── GSD-T-independent standalone admin entry point ──────────────────────
428
+ //
429
+ // Reachable without any GSD-T toolchain present: a plain exported function
430
+ // (usable directly from project code or a generated CLI/route handler) that
431
+ // wraps queryAudit against a store path — the project's own admin tooling
432
+ // calls THIS, not anything under bin/ or commands/.
433
+
434
+ export function adminQueryAudit(
435
+ dbPath: string,
436
+ filter: AuditQueryFilter = {},
437
+ retention: AuditRetentionConfig = { retentionDays: 365 }
438
+ ): AuditRow[] {
439
+ const mod = new AuditModule({ dbPath, retention });
440
+ try {
441
+ return mod.queryAudit(filter);
442
+ } finally {
443
+ mod.close();
444
+ }
445
+ }
446
+
447
+ // Generated CLI entry (project's own admin tooling invokes this file
448
+ // directly with `node audit-module.js --db <path> [--actor x] [--target y]
449
+ // [--since iso] [--until iso]` once compiled — no GSD-T binary involved).
450
+ /* istanbul ignore next -- CLI wiring exercised via adminQueryAudit in tests */
451
+ function _parseCliArgs(argv: string[]): { dbPath: string; filter: AuditQueryFilter } {
452
+ const out: { dbPath: string; filter: AuditQueryFilter } = { dbPath: '.gsd-t/audit.db', filter: {} };
453
+ for (let i = 0; i < argv.length; i++) {
454
+ const a = argv[i];
455
+ if (a === '--db') out.dbPath = argv[++i];
456
+ else if (a === '--actor') out.filter.actor = argv[++i];
457
+ else if (a === '--target') out.filter.target = argv[++i];
458
+ else if (a === '--since') out.filter.since = argv[++i];
459
+ else if (a === '--until') out.filter.until = argv[++i];
460
+ }
461
+ return out;
462
+ }
463
+
464
+ /* istanbul ignore next -- only runs when this compiled file is executed directly as a CLI */
465
+ if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) {
466
+ const { dbPath, filter } = _parseCliArgs(process.argv.slice(2));
467
+ const rows = adminQueryAudit(dbPath, filter);
468
+ process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
469
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * GSD-T framework-default TRACE module (M100).
3
+ *
4
+ * Trace is a capture-everything DEBUGGING SIGNAL STREAM. Transient. PII-barred.
5
+ * Toggleable. It is NOT an audit log, NOT PII storage — this self-declaration
6
+ * is load-bearing (see trace-logging-contract.md §Definition). NEVER share a
7
+ * file, module, or record shape with the audit substrate
8
+ * (templates/logging/audit-module.template.ts) — the two streams must never
9
+ * collapse into one.
10
+ *
11
+ * Contract: .gsd-t/contracts/trace-logging-contract.md v1.0.0
12
+ * Consumed seam: bin/gsd-t-logging-scaffolder.cjs (scaffoldLogging()) — d1
13
+ * Consumed gate: bin/gsd-t-logging-envelope-check.cjs (checkEnvelope()) — d3
14
+ *
15
+ * This file is a TEMPLATE copied into a project by `gsd-t-init` scaffolding
16
+ * (per M100-D1's scaffolder). The `{Project Name}` token and the storage
17
+ * seam wiring below are the only pieces adapted per project; the envelope,
18
+ * toggle, PII bar, and fire-and-forget contract are framework-fixed and must
19
+ * not be edited by hand after scaffolding.
20
+ */
21
+
22
+ // ── The trace envelope (contract-fixed; `category` set is per-project) ─────
23
+
24
+ export interface TraceRecord {
25
+ ts: string; // ISO-8601 — when the trace point fired
26
+ category: string; // member of the PROJECT-VARYING category set (see gsd-t-trace-distill.cjs)
27
+ decision: boolean | null; // the decision at this point, or null when not a decision
28
+ detail: string; // human-readable one-line description
29
+ key?: string; // optional correlation/request id
30
+ status?: number; // optional HTTP/status code
31
+ data?: Record<string, unknown>; // optional payload — SUBJECT TO THE PII BAR
32
+ }
33
+
34
+ // ── The toggle: runtime seam + env override (both required, KEEP-plus-extend) ─
35
+
36
+ let _traceOn = process.env.TRACE === "1";
37
+
38
+ /** One-line in-module runtime seam (the BinVoice pattern). */
39
+ export function setTraceEnabled(on: boolean): void {
40
+ _traceOn = !!on;
41
+ }
42
+
43
+ /** Reads the current toggle state (env override seeds the initial value). */
44
+ export function isTraceEnabled(): boolean {
45
+ return _traceOn;
46
+ }
47
+
48
+ // ── The PII bar (HC-003 spirit — non-negotiable) ────────────────────────────
49
+ //
50
+ // Recurses into every field (including nested `data`) — never a top-level-
51
+ // only scan. Rejects email/phone/postal-address-shaped values at ANY nesting
52
+ // depth. Must NOT false-positive on legitimate long numeric ids, UUIDs, or an
53
+ // internal id string that merely contains '@' without a valid email shape.
54
+
55
+ // The trailing lookahead allows a closing quote/bracket/angle-bracket/brace
56
+ // immediately after the TLD (JSON string bodies, `Name <email>` headers,
57
+ // array literals) — without these, an email immediately followed by `"`,
58
+ // `>`, `]`, or `}` was NOT matched. Keep identical to the copy in
59
+ // bin/gsd-t-logging-envelope-check.cjs.
60
+ const EMAIL_RE = /(?<![^\s(])[^\s@()]+@[^\s@()]+\.[a-zA-Z]{2,}(?![^\s).,;:!?"'>\]}])/;
61
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]*)?$/;
62
+ // Keep PHONE_RE identical to the copy in bin/gsd-t-logging-envelope-check.cjs.
63
+ // CONSERVATIVE: +CC, parenthesized area code, or classic NNN-NNN-NNNN only — a bare
64
+ // digit run / grouped id / range is NOT a phone (fail-closed gate; avoid blocking builds).
65
+ const PHONE_RE = /(?<!\d)(?:\+\d{1,3}[\s.-]?)?(?:\(\d{3}\)[\s.-]?\d{3}[\s.-]?\d{4}|\d{3}[.-]\d{3}[.-]\d{4})(?!\d)/;
66
+ const ADDRESS_RE =
67
+ /\b\d{1,6}\s+[A-Za-z0-9.'-]+(?:\s+[A-Za-z0-9.'-]+){0,4}\s+(Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way|Place|Pl)\b/i;
68
+
69
+ function isPhoneShaped(value: string): boolean {
70
+ const matches = value.match(new RegExp(PHONE_RE.source, "g")) || [];
71
+ return matches.some((m) => !ISO_DATE_RE.test(m) && !ISO_DATE_RE.test(value.trim()));
72
+ }
73
+
74
+ function isPiiString(value: unknown): boolean {
75
+ if (typeof value !== "string") return false;
76
+ if (ISO_DATE_RE.test(value.trim())) return false; // structural timestamp, not PII
77
+ if (EMAIL_RE.test(value)) return true;
78
+ if (isPhoneShaped(value)) return true;
79
+ if (ADDRESS_RE.test(value)) return true;
80
+ return false;
81
+ }
82
+
83
+ function scanForPii(value: unknown, pathParts: string[], hits: string[], depth: number): void {
84
+ if (depth > 12) return; // defensive recursion cap, not a real-world limit
85
+ if (value == null) return;
86
+ if (typeof value === "string") {
87
+ if (isPiiString(value)) hits.push(pathParts.join("."));
88
+ return;
89
+ }
90
+ if (Array.isArray(value)) {
91
+ value.forEach((v, i) => scanForPii(v, pathParts.concat(String(i)), hits, depth + 1));
92
+ return;
93
+ }
94
+ if (typeof value === "object") {
95
+ for (const k of Object.keys(value as Record<string, unknown>)) {
96
+ scanForPii((value as Record<string, unknown>)[k], pathParts.concat(k), hits, depth + 1);
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Throws when a PII-shaped value is found anywhere in the given record's
103
+ * fields, EXCEPT the structural `ts` timestamp. Matches the gate's own scan
104
+ * scope (bin/gsd-t-logging-envelope-check.cjs checkEnvelope): every field —
105
+ * `detail`, `category`, `key`, `data` — is subject to the PII bar, not just
106
+ * `data`. The contract says "PII barred in ANY field"; scanning only `data`
107
+ * here would let PII through the emitter that the verify gate would reject.
108
+ */
109
+ function assertNoPii(record: Record<string, unknown>): void {
110
+ const hits: string[] = [];
111
+ for (const field of Object.keys(record)) {
112
+ if (field === "ts") continue;
113
+ scanForPii(record[field], [field], hits, 0);
114
+ }
115
+ if (hits.length > 0) {
116
+ throw new Error(`trace-pii-barred: PII-shaped value at: ${hits.join(", ")}`);
117
+ }
118
+ }
119
+
120
+ // ── The storage seam (dormant OR local-file; consumes d1's scaffolder) ─────
121
+ //
122
+ // ⚠ Divergence (PseudoCode-TraceLogging.md §Divergence): supersedes BinVoice's
123
+ // client-batched-POST → server-bulk-insert transport. A framework default
124
+ // must never silently lose a configured local sink — dormant only when truly
125
+ // no endpoint AND no local store is configured.
126
+
127
+ export interface TraceSink {
128
+ write(record: TraceRecord): void | Promise<void>;
129
+ }
130
+
131
+ /** No-op sink used when no storage endpoint/local store is configured. */
132
+ const dormantSink: TraceSink = {
133
+ write(): void {
134
+ /* intentionally dormant — no endpoint or local store configured */
135
+ },
136
+ };
137
+
138
+ let _sink: TraceSink = dormantSink;
139
+
140
+ /**
141
+ * Wires the sink resolved from d1's `scaffoldLogging()` seam envelope
142
+ * (`{ traceSink: { kind, path|table } }`). Called once at project bootstrap
143
+ * with the project's own sink adapter (local-file writer, sqlite writer, or
144
+ * db-table writer) built to match `traceSink.kind`. Framework template ships
145
+ * the dormant default; the project wiring supplies the real sink.
146
+ */
147
+ export function configureTraceSink(sink: TraceSink): void {
148
+ _sink = sink || dormantSink;
149
+ }
150
+
151
+ function sink(): TraceSink {
152
+ return _sink;
153
+ }
154
+
155
+ // ── The emitter (fire-and-forget; NEVER throws into the caller) ────────────
156
+
157
+ export interface TraceOptions {
158
+ decision?: boolean | null;
159
+ key?: string;
160
+ status?: number;
161
+ data?: Record<string, unknown>;
162
+ }
163
+
164
+ /**
165
+ * Fire-and-forget trace emitter. Never throws into the calling app — a debug
166
+ * channel must never break the app (trace-fire-and-forget). Off in normal
167
+ * runs (zero cost) unless the toggle is enabled via `setTraceEnabled(true)`
168
+ * or the `TRACE=1` env override.
169
+ */
170
+ export function emitTrace(category: string, detail: string, options: TraceOptions = {}): void {
171
+ try {
172
+ if (!isTraceEnabled()) return;
173
+ const record: TraceRecord = {
174
+ ts: new Date().toISOString(),
175
+ category,
176
+ decision: options.decision ?? null,
177
+ detail,
178
+ ...(options.key !== undefined ? { key: options.key } : {}),
179
+ ...(options.status !== undefined ? { status: options.status } : {}),
180
+ ...(options.data !== undefined ? { data: options.data } : {}),
181
+ };
182
+ // Scan the FULL assembled record (every field except `ts`) — not just
183
+ // `options.data` — so runtime enforcement matches the gate + contract.
184
+ assertNoPii(record as unknown as Record<string, unknown>);
185
+ sink().write(record);
186
+ } catch (_err) {
187
+ // Fire-and-forget: swallow every error, never throw into the caller.
188
+ return;
189
+ }
190
+ }