@tekyzinc/gsd-t 4.19.14 → 4.20.10
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/CHANGELOG.md +18 -0
- package/README.md +3 -1
- package/bin/gsd-t-audit-distill.cjs +171 -0
- package/bin/gsd-t-logging-envelope-check.cjs +566 -0
- package/bin/gsd-t-logging-scaffolder.cjs +281 -0
- package/bin/gsd-t-migrate-logging.cjs +203 -0
- package/bin/gsd-t-trace-distill.cjs +196 -0
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +67 -7
- package/commands/gsd-t-help.md +8 -0
- package/commands/gsd-t-init.md +11 -0
- package/commands/gsd-t-migrate-logging.md +46 -0
- package/commands/gsd-t-verify.md +2 -1
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +10 -0
- package/templates/logging/audit-module.template.ts +469 -0
- package/templates/logging/trace-module.template.ts +190 -0
|
@@ -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
|
+
}
|