@crewhaus/data-retention-engine 0.1.3 → 0.1.5

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,115 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /**
3
+ * Catalog R17 `data-retention-engine` — Section 39 GDPR-shaped retention.
4
+ *
5
+ * Three operations:
6
+ * retain(tenantId, kind, durationDays) — pin records for at least
7
+ * this long. The cron-style sweeper consults the policy table
8
+ * before deleting. Multiple retain() calls compose: the longest
9
+ * duration wins.
10
+ * export(tenantId, format) — right-to-export. Streams
11
+ * a tenant's records out in JSON or NDJSON.
12
+ * purge(tenantId, opts?) — right-to-delete. Honors a
13
+ * compliance-controls "do not purge during audit window" override
14
+ * so SOC 2 evidence collection isn't disrupted.
15
+ *
16
+ * The engine is record-store-agnostic. Callers supply a `RecordStore`
17
+ * shape (list / get / delete / count) so the engine can operate over
18
+ * the audit-log JSONL files, a SQL table, an object-store prefix, etc.
19
+ *
20
+ * Audit-window override: callers register an `AuditWindow` (with
21
+ * `frameworkId` / `controlId` / `expiresAt`) via `addAuditWindow`. As
22
+ * long as any window is active, `purge()` skips records and returns
23
+ * the deferral reason. Cleared automatically once `expiresAt` passes.
24
+ *
25
+ * Cross-tenant isolation: every operation requires an explicit
26
+ * `tenantId`; the engine refuses to operate on records that don't
27
+ * report a matching `tenantId`. T8 isolation test asserts that a
28
+ * tenant-A purge does not touch tenant-B records.
29
+ *
30
+ * Layer R17. Pairs with `audit-log` (R-infra — primary record store
31
+ * for audit data), `tenancy` (R-infra — tenant-scoping primitives).
32
+ */
33
+ export declare class DataRetentionError extends CrewhausError {
34
+ readonly name = "DataRetentionError";
35
+ constructor(message: string, cause?: unknown);
36
+ }
37
+ export type RetentionRecord = {
38
+ readonly id: string;
39
+ readonly tenantId: string;
40
+ readonly kind: string;
41
+ readonly createdAt: number;
42
+ readonly payload: unknown;
43
+ };
44
+ export interface RecordStore {
45
+ /** Iterate every record (retention engine filters per-tenant downstream). */
46
+ listAll(): AsyncIterable<RetentionRecord>;
47
+ /** Iterate records for a specific tenant. */
48
+ listByTenant(tenantId: string): AsyncIterable<RetentionRecord>;
49
+ /** Delete a record by id. Returns true if a record was actually deleted. */
50
+ delete(id: string): Promise<boolean>;
51
+ }
52
+ export type RetentionPolicy = {
53
+ readonly tenantId: string;
54
+ readonly kind: string;
55
+ readonly durationDays: number;
56
+ };
57
+ export type AuditWindow = {
58
+ readonly frameworkId: string;
59
+ readonly controlId: string;
60
+ readonly expiresAt: number;
61
+ };
62
+ export type ExportFormat = "json" | "ndjson";
63
+ export type ExportOptions = {
64
+ readonly format: ExportFormat;
65
+ readonly kinds?: ReadonlyArray<string>;
66
+ };
67
+ export type PurgeOptions = {
68
+ readonly kind?: string;
69
+ readonly before?: number;
70
+ };
71
+ export type PurgeResult = {
72
+ readonly tenantId: string;
73
+ readonly deleted: number;
74
+ readonly deferred: ReadonlyArray<string>;
75
+ readonly retentionDeferred: ReadonlyArray<string>;
76
+ readonly auditWindowDeferred: ReadonlyArray<{
77
+ readonly frameworkId: string;
78
+ readonly controlId: string;
79
+ readonly expiresAt: number;
80
+ }>;
81
+ };
82
+ export type SweepResult = {
83
+ readonly deletedCount: number;
84
+ readonly recordsKept: number;
85
+ };
86
+ export interface DataRetentionEngine {
87
+ retain(tenantId: string, kind: string, durationDays: number): void;
88
+ removeRetention(tenantId: string, kind: string): void;
89
+ listRetention(): ReadonlyArray<RetentionPolicy>;
90
+ addAuditWindow(window: AuditWindow): void;
91
+ listAuditWindows(): ReadonlyArray<AuditWindow>;
92
+ /** Purge a single tenant's records. Honors retention + audit windows. */
93
+ purge(tenantId: string, opts?: PurgeOptions): Promise<PurgeResult>;
94
+ /** Stream a single tenant's records in the requested format. */
95
+ export(tenantId: string, opts: ExportOptions): Promise<string>;
96
+ /** Cron-style sweeper: walk all records and delete expired-by-policy ones. */
97
+ sweep(): Promise<SweepResult>;
98
+ }
99
+ export type CreateOptions = {
100
+ readonly recordStore: RecordStore;
101
+ readonly now?: () => number;
102
+ readonly defaultRetentionDays?: number;
103
+ };
104
+ declare const MS_PER_DAY: number;
105
+ export declare function createDataRetentionEngine(opts: CreateOptions): DataRetentionEngine;
106
+ export declare class InMemoryRecordStore implements RecordStore {
107
+ private records;
108
+ constructor(initial?: ReadonlyArray<RetentionRecord>);
109
+ listAll(): AsyncIterable<RetentionRecord>;
110
+ listByTenant(tenantId: string): AsyncIterable<RetentionRecord>;
111
+ delete(id: string): Promise<boolean>;
112
+ size(): number;
113
+ ids(): ReadonlyArray<string>;
114
+ }
115
+ export { MS_PER_DAY as _msPerDayForTest };
package/dist/index.js ADDED
@@ -0,0 +1,210 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /**
3
+ * Catalog R17 `data-retention-engine` — Section 39 GDPR-shaped retention.
4
+ *
5
+ * Three operations:
6
+ * retain(tenantId, kind, durationDays) — pin records for at least
7
+ * this long. The cron-style sweeper consults the policy table
8
+ * before deleting. Multiple retain() calls compose: the longest
9
+ * duration wins.
10
+ * export(tenantId, format) — right-to-export. Streams
11
+ * a tenant's records out in JSON or NDJSON.
12
+ * purge(tenantId, opts?) — right-to-delete. Honors a
13
+ * compliance-controls "do not purge during audit window" override
14
+ * so SOC 2 evidence collection isn't disrupted.
15
+ *
16
+ * The engine is record-store-agnostic. Callers supply a `RecordStore`
17
+ * shape (list / get / delete / count) so the engine can operate over
18
+ * the audit-log JSONL files, a SQL table, an object-store prefix, etc.
19
+ *
20
+ * Audit-window override: callers register an `AuditWindow` (with
21
+ * `frameworkId` / `controlId` / `expiresAt`) via `addAuditWindow`. As
22
+ * long as any window is active, `purge()` skips records and returns
23
+ * the deferral reason. Cleared automatically once `expiresAt` passes.
24
+ *
25
+ * Cross-tenant isolation: every operation requires an explicit
26
+ * `tenantId`; the engine refuses to operate on records that don't
27
+ * report a matching `tenantId`. T8 isolation test asserts that a
28
+ * tenant-A purge does not touch tenant-B records.
29
+ *
30
+ * Layer R17. Pairs with `audit-log` (R-infra — primary record store
31
+ * for audit data), `tenancy` (R-infra — tenant-scoping primitives).
32
+ */
33
+ export class DataRetentionError extends CrewhausError {
34
+ name = "DataRetentionError";
35
+ constructor(message, cause) {
36
+ super("config", message, cause);
37
+ }
38
+ }
39
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
40
+ export function createDataRetentionEngine(opts) {
41
+ if (opts.recordStore === undefined) {
42
+ throw new DataRetentionError("recordStore is required");
43
+ }
44
+ const now = opts.now ?? (() => Date.now());
45
+ const defaultRetentionDays = opts.defaultRetentionDays ?? 90;
46
+ const policies = new Map();
47
+ const auditWindows = [];
48
+ const policyKey = (tenantId, kind) => `${tenantId}::${kind}`;
49
+ function activeAuditWindows() {
50
+ const active = auditWindows.filter((w) => w.expiresAt > now());
51
+ // Mutate in place to drop expired windows so the array doesn't grow
52
+ // unboundedly across many sweeps.
53
+ auditWindows.length = 0;
54
+ auditWindows.push(...active);
55
+ return active;
56
+ }
57
+ function retentionEnd(record) {
58
+ const key = policyKey(record.tenantId, record.kind);
59
+ const policy = policies.get(key);
60
+ const days = policy?.durationDays ?? defaultRetentionDays;
61
+ return record.createdAt + days * MS_PER_DAY;
62
+ }
63
+ return {
64
+ retain(tenantId, kind, durationDays) {
65
+ if (typeof tenantId !== "string" || tenantId === "") {
66
+ throw new DataRetentionError("retain: tenantId required");
67
+ }
68
+ if (typeof kind !== "string" || kind === "") {
69
+ throw new DataRetentionError("retain: kind required");
70
+ }
71
+ if (!Number.isFinite(durationDays) || durationDays <= 0) {
72
+ throw new DataRetentionError("retain: durationDays must be > 0");
73
+ }
74
+ const key = policyKey(tenantId, kind);
75
+ const existing = policies.get(key);
76
+ if (existing === undefined || durationDays > existing.durationDays) {
77
+ policies.set(key, { tenantId, kind, durationDays });
78
+ }
79
+ },
80
+ removeRetention(tenantId, kind) {
81
+ policies.delete(policyKey(tenantId, kind));
82
+ },
83
+ listRetention() {
84
+ return [...policies.values()].sort((a, b) => a.tenantId.localeCompare(b.tenantId) || a.kind.localeCompare(b.kind));
85
+ },
86
+ addAuditWindow(window) {
87
+ if (window.expiresAt <= now()) {
88
+ throw new DataRetentionError(`addAuditWindow: expiresAt (${window.expiresAt}) must be in the future (now=${now()})`);
89
+ }
90
+ auditWindows.push({ ...window });
91
+ },
92
+ listAuditWindows() {
93
+ return activeAuditWindows().map((w) => ({ ...w }));
94
+ },
95
+ async purge(tenantId, purgeOpts = {}) {
96
+ if (typeof tenantId !== "string" || tenantId === "") {
97
+ throw new DataRetentionError("purge: tenantId required");
98
+ }
99
+ const active = activeAuditWindows();
100
+ const result = {
101
+ tenantId,
102
+ deleted: 0,
103
+ deferred: [],
104
+ retentionDeferred: [],
105
+ auditWindowDeferred: [],
106
+ };
107
+ for (const window of active) {
108
+ result.auditWindowDeferred.push({
109
+ frameworkId: window.frameworkId,
110
+ controlId: window.controlId,
111
+ expiresAt: window.expiresAt,
112
+ });
113
+ }
114
+ const cutoff = purgeOpts.before;
115
+ const restrictKind = purgeOpts.kind;
116
+ for await (const record of opts.recordStore.listByTenant(tenantId)) {
117
+ if (record.tenantId !== tenantId)
118
+ continue; // T8 cross-tenant guard
119
+ if (restrictKind !== undefined && record.kind !== restrictKind)
120
+ continue;
121
+ if (cutoff !== undefined && record.createdAt >= cutoff)
122
+ continue;
123
+ if (active.length > 0) {
124
+ result.deferred.push(record.id);
125
+ continue;
126
+ }
127
+ if (retentionEnd(record) > now()) {
128
+ result.retentionDeferred.push(record.id);
129
+ continue;
130
+ }
131
+ const deleted = await opts.recordStore.delete(record.id);
132
+ if (deleted)
133
+ result.deleted += 1;
134
+ }
135
+ return {
136
+ tenantId: result.tenantId,
137
+ deleted: result.deleted,
138
+ deferred: result.deferred,
139
+ retentionDeferred: result.retentionDeferred,
140
+ auditWindowDeferred: result.auditWindowDeferred,
141
+ };
142
+ },
143
+ async export(tenantId, exportOpts) {
144
+ if (typeof tenantId !== "string" || tenantId === "") {
145
+ throw new DataRetentionError("export: tenantId required");
146
+ }
147
+ const records = [];
148
+ for await (const record of opts.recordStore.listByTenant(tenantId)) {
149
+ if (record.tenantId !== tenantId)
150
+ continue; // T8 cross-tenant guard
151
+ if (exportOpts.kinds !== undefined && !exportOpts.kinds.includes(record.kind))
152
+ continue;
153
+ records.push(record);
154
+ }
155
+ if (exportOpts.format === "ndjson") {
156
+ return records.map((r) => JSON.stringify(r)).join("\n");
157
+ }
158
+ return JSON.stringify(records, null, 2);
159
+ },
160
+ async sweep() {
161
+ const active = activeAuditWindows();
162
+ let deleted = 0;
163
+ let kept = 0;
164
+ for await (const record of opts.recordStore.listAll()) {
165
+ if (active.length > 0) {
166
+ kept += 1;
167
+ continue;
168
+ }
169
+ if (retentionEnd(record) > now()) {
170
+ kept += 1;
171
+ continue;
172
+ }
173
+ const ok = await opts.recordStore.delete(record.id);
174
+ if (ok)
175
+ deleted += 1;
176
+ else
177
+ kept += 1;
178
+ }
179
+ return { deletedCount: deleted, recordsKept: kept };
180
+ },
181
+ };
182
+ }
183
+ export class InMemoryRecordStore {
184
+ records;
185
+ constructor(initial = []) {
186
+ this.records = [...initial];
187
+ }
188
+ async *listAll() {
189
+ for (const r of [...this.records])
190
+ yield r;
191
+ }
192
+ async *listByTenant(tenantId) {
193
+ for (const r of [...this.records]) {
194
+ if (r.tenantId === tenantId)
195
+ yield r;
196
+ }
197
+ }
198
+ async delete(id) {
199
+ const before = this.records.length;
200
+ this.records = this.records.filter((r) => r.id !== id);
201
+ return this.records.length < before;
202
+ }
203
+ size() {
204
+ return this.records.length;
205
+ }
206
+ ids() {
207
+ return this.records.map((r) => r.id);
208
+ }
209
+ }
210
+ export { MS_PER_DAY as _msPerDayForTest };
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/data-retention-engine",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "GDPR-shaped data retention: retain / export / purge with cron-style sweeper + audit window override (Section 39)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,448 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- DataRetentionError,
4
- InMemoryRecordStore,
5
- type RecordStore,
6
- type RetentionRecord,
7
- _msPerDayForTest,
8
- createDataRetentionEngine,
9
- } from "./index";
10
-
11
- const DAY_MS = _msPerDayForTest;
12
-
13
- function record(
14
- id: string,
15
- tenantId: string,
16
- kind: string,
17
- ageDays: number,
18
- payload: unknown = {},
19
- fromNow = Date.now(),
20
- ): RetentionRecord {
21
- return {
22
- id,
23
- tenantId,
24
- kind,
25
- createdAt: fromNow - ageDays * DAY_MS,
26
- payload,
27
- };
28
- }
29
-
30
- describe("retention policies (T1)", () => {
31
- test("retain stores tenant+kind+duration", () => {
32
- const store = new InMemoryRecordStore();
33
- const eng = createDataRetentionEngine({ recordStore: store });
34
- eng.retain("tenant-a", "audit", 30);
35
- eng.retain("tenant-b", "metrics", 7);
36
- expect(eng.listRetention()).toEqual([
37
- { tenantId: "tenant-a", kind: "audit", durationDays: 30 },
38
- { tenantId: "tenant-b", kind: "metrics", durationDays: 7 },
39
- ]);
40
- });
41
-
42
- test("retain composition takes the LONGER duration when re-set", () => {
43
- const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
44
- eng.retain("tenant-a", "audit", 30);
45
- eng.retain("tenant-a", "audit", 7);
46
- eng.retain("tenant-a", "audit", 60);
47
- const policies = eng.listRetention();
48
- expect(policies).toHaveLength(1);
49
- expect(policies[0]?.durationDays).toBe(60);
50
- });
51
-
52
- test("removeRetention drops the policy", () => {
53
- const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
54
- eng.retain("tenant-a", "audit", 30);
55
- eng.removeRetention("tenant-a", "audit");
56
- expect(eng.listRetention()).toEqual([]);
57
- });
58
-
59
- test("invalid retain inputs throw", () => {
60
- const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
61
- expect(() => eng.retain("", "k", 1)).toThrow(DataRetentionError);
62
- expect(() => eng.retain("t", "", 1)).toThrow(DataRetentionError);
63
- expect(() => eng.retain("t", "k", 0)).toThrow(DataRetentionError);
64
- expect(() => eng.retain("t", "k", -1)).toThrow(DataRetentionError);
65
- });
66
- });
67
-
68
- describe("purge (T1 + T8 cross-tenant isolation)", () => {
69
- test("purge respects retention policy — recent records survive", async () => {
70
- const now = 1_700_000_000_000;
71
- const store = new InMemoryRecordStore([
72
- record("a-recent", "tenant-a", "audit", 5, {}, now),
73
- record("a-old", "tenant-a", "audit", 100, {}, now),
74
- ]);
75
- const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
76
- eng.retain("tenant-a", "audit", 30);
77
- const result = await eng.purge("tenant-a");
78
- expect(result.deleted).toBe(1);
79
- expect(result.retentionDeferred).toEqual(["a-recent"]);
80
- expect(store.ids()).toEqual(["a-recent"]);
81
- });
82
-
83
- test("purge with restrictKind only deletes that kind", async () => {
84
- const now = 1_700_000_000_000;
85
- const store = new InMemoryRecordStore([
86
- record("audit-1", "tenant-a", "audit", 100, {}, now),
87
- record("metrics-1", "tenant-a", "metrics", 100, {}, now),
88
- ]);
89
- const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
90
- await eng.purge("tenant-a", { kind: "audit" });
91
- expect(store.ids()).toEqual(["metrics-1"]);
92
- });
93
-
94
- test("purge with `before` cutoff only deletes pre-cutoff records", async () => {
95
- const now = 1_700_000_000_000;
96
- const cutoff = now - 50 * DAY_MS;
97
- const store = new InMemoryRecordStore([
98
- record("old", "tenant-a", "audit", 100, {}, now), // pre-cutoff
99
- record("recent", "tenant-a", "audit", 30, {}, now), // post-cutoff
100
- ]);
101
- const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
102
- await eng.purge("tenant-a", { before: cutoff });
103
- expect(store.ids()).toEqual(["recent"]);
104
- });
105
-
106
- test("T8 — tenant-A purge does NOT touch tenant-B records", async () => {
107
- const now = 1_700_000_000_000;
108
- const store = new InMemoryRecordStore([
109
- record("a-1", "tenant-a", "audit", 100, {}, now),
110
- record("a-2", "tenant-a", "audit", 200, {}, now),
111
- record("b-1", "tenant-b", "audit", 100, {}, now),
112
- record("b-2", "tenant-b", "audit", 200, {}, now),
113
- ]);
114
- const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
115
- await eng.purge("tenant-a");
116
- // tenant-a records gone; tenant-b records intact.
117
- expect([...store.ids()].sort()).toEqual(["b-1", "b-2"]);
118
- });
119
-
120
- test("missing tenantId throws", async () => {
121
- const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
122
- await expect(eng.purge("")).rejects.toThrow(DataRetentionError);
123
- });
124
- });
125
-
126
- describe("audit-window override", () => {
127
- test("active audit window blocks purge", async () => {
128
- const now = 1_700_000_000_000;
129
- const store = new InMemoryRecordStore([record("a-1", "tenant-a", "audit", 200, {}, now)]);
130
- const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
131
- eng.addAuditWindow({
132
- frameworkId: "soc2",
133
- controlId: "CC6.1",
134
- expiresAt: now + 10 * DAY_MS,
135
- });
136
- const result = await eng.purge("tenant-a");
137
- expect(result.deleted).toBe(0);
138
- expect(result.deferred).toEqual(["a-1"]);
139
- expect(result.auditWindowDeferred).toHaveLength(1);
140
- expect(result.auditWindowDeferred[0]?.frameworkId).toBe("soc2");
141
- expect(store.ids()).toEqual(["a-1"]);
142
- });
143
-
144
- test("expired audit window does not block purge", async () => {
145
- const now = 1_700_000_000_000;
146
- const store = new InMemoryRecordStore([record("a-1", "tenant-a", "audit", 200, {}, now)]);
147
- const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
148
- expect(() =>
149
- eng.addAuditWindow({
150
- frameworkId: "soc2",
151
- controlId: "CC6.1",
152
- expiresAt: now - 1, // already expired
153
- }),
154
- ).toThrow(DataRetentionError);
155
- });
156
-
157
- test("listAuditWindows drops expired entries", () => {
158
- let mutableNow = 1_700_000_000_000;
159
- const eng = createDataRetentionEngine({
160
- recordStore: new InMemoryRecordStore(),
161
- now: () => mutableNow,
162
- });
163
- eng.addAuditWindow({
164
- frameworkId: "soc2",
165
- controlId: "CC6.1",
166
- expiresAt: mutableNow + 5 * DAY_MS,
167
- });
168
- expect(eng.listAuditWindows()).toHaveLength(1);
169
- mutableNow += 10 * DAY_MS;
170
- expect(eng.listAuditWindows()).toHaveLength(0);
171
- });
172
- });
173
-
174
- describe("export (right-to-export)", () => {
175
- test("JSON format returns pretty-printed array", async () => {
176
- const store = new InMemoryRecordStore([
177
- record("a-1", "tenant-a", "audit", 5),
178
- record("a-2", "tenant-a", "audit", 10),
179
- record("b-1", "tenant-b", "audit", 5),
180
- ]);
181
- const eng = createDataRetentionEngine({ recordStore: store });
182
- const out = await eng.export("tenant-a", { format: "json" });
183
- const parsed = JSON.parse(out);
184
- expect(parsed).toHaveLength(2);
185
- expect(parsed[0].id).toBe("a-1");
186
- });
187
-
188
- test("NDJSON format returns one record per line", async () => {
189
- const store = new InMemoryRecordStore([
190
- record("a-1", "tenant-a", "audit", 5),
191
- record("a-2", "tenant-a", "metrics", 5),
192
- ]);
193
- const eng = createDataRetentionEngine({ recordStore: store });
194
- const out = await eng.export("tenant-a", { format: "ndjson" });
195
- const lines = out.split("\n");
196
- expect(lines).toHaveLength(2);
197
- const first = lines[0];
198
- expect(first).toBeDefined();
199
- if (first === undefined) throw new Error("missing line");
200
- expect(JSON.parse(first).id).toBe("a-1");
201
- });
202
-
203
- test("kinds filter restricts exported records", async () => {
204
- const store = new InMemoryRecordStore([
205
- record("a-1", "tenant-a", "audit", 5),
206
- record("a-2", "tenant-a", "metrics", 5),
207
- ]);
208
- const eng = createDataRetentionEngine({ recordStore: store });
209
- const out = await eng.export("tenant-a", { format: "ndjson", kinds: ["audit"] });
210
- expect(out.split("\n")).toHaveLength(1);
211
- });
212
-
213
- test("export refuses cross-tenant fishing", async () => {
214
- const store = new InMemoryRecordStore([
215
- record("a-1", "tenant-a", "audit", 5),
216
- record("b-1", "tenant-b", "audit", 5),
217
- ]);
218
- const eng = createDataRetentionEngine({ recordStore: store });
219
- const out = await eng.export("tenant-a", { format: "json" });
220
- expect(out.includes("tenant-b")).toBe(false);
221
- expect(out.includes("b-1")).toBe(false);
222
- });
223
- });
224
-
225
- describe("sweep (cron-style)", () => {
226
- test("deletes expired records across all tenants", async () => {
227
- const now = 1_700_000_000_000;
228
- const store = new InMemoryRecordStore([
229
- record("a-old", "tenant-a", "audit", 100, {}, now),
230
- record("a-new", "tenant-a", "audit", 5, {}, now),
231
- record("b-old", "tenant-b", "audit", 200, {}, now),
232
- ]);
233
- const eng = createDataRetentionEngine({
234
- recordStore: store,
235
- now: () => now,
236
- defaultRetentionDays: 30,
237
- });
238
- const result = await eng.sweep();
239
- expect(result.deletedCount).toBe(2);
240
- expect(result.recordsKept).toBe(1);
241
- expect(store.ids()).toEqual(["a-new"]);
242
- });
243
-
244
- test("idempotent — re-running yields zero deletes (T9)", async () => {
245
- const now = 1_700_000_000_000;
246
- const store = new InMemoryRecordStore([
247
- record("a-old", "tenant-a", "audit", 100, {}, now),
248
- record("a-new", "tenant-a", "audit", 5, {}, now),
249
- ]);
250
- const eng = createDataRetentionEngine({
251
- recordStore: store,
252
- now: () => now,
253
- defaultRetentionDays: 30,
254
- });
255
- const r1 = await eng.sweep();
256
- const r2 = await eng.sweep();
257
- expect(r1.deletedCount).toBe(1);
258
- expect(r2.deletedCount).toBe(0);
259
- });
260
-
261
- test("audit window blocks the entire sweep", async () => {
262
- const now = 1_700_000_000_000;
263
- const store = new InMemoryRecordStore([
264
- record("a-old", "tenant-a", "audit", 100, {}, now),
265
- record("b-old", "tenant-b", "audit", 200, {}, now),
266
- ]);
267
- const eng = createDataRetentionEngine({
268
- recordStore: store,
269
- now: () => now,
270
- defaultRetentionDays: 30,
271
- });
272
- eng.addAuditWindow({
273
- frameworkId: "iso27001",
274
- controlId: "A.12.4",
275
- expiresAt: now + 10 * DAY_MS,
276
- });
277
- const result = await eng.sweep();
278
- expect(result.deletedCount).toBe(0);
279
- expect(result.recordsKept).toBe(2);
280
- });
281
- });
282
-
283
- describe("InMemoryRecordStore", () => {
284
- test("size() reports the current record count and tracks deletes", async () => {
285
- const store = new InMemoryRecordStore([
286
- record("a-1", "tenant-a", "audit", 5),
287
- record("a-2", "tenant-a", "audit", 5),
288
- ]);
289
- expect(store.size()).toBe(2);
290
- await store.delete("a-1");
291
- expect(store.size()).toBe(1);
292
- await store.delete("nope"); // no-op
293
- expect(store.size()).toBe(1);
294
- });
295
-
296
- test("delete() returns false when the id is absent", async () => {
297
- const store = new InMemoryRecordStore([record("a-1", "tenant-a", "audit", 5)]);
298
- expect(await store.delete("ghost")).toBe(false);
299
- expect(await store.delete("a-1")).toBe(true);
300
- });
301
- });
302
-
303
- describe("default clock", () => {
304
- // Exercises the fallback `now` (Date.now) path when no `now` is injected.
305
- test("addAuditWindow uses Date.now() when no clock is supplied", () => {
306
- const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
307
- // A window far in the future must be accepted under the real clock...
308
- eng.addAuditWindow({
309
- frameworkId: "soc2",
310
- controlId: "CC6.1",
311
- expiresAt: Date.now() + 365 * DAY_MS,
312
- });
313
- expect(eng.listAuditWindows()).toHaveLength(1);
314
- // ...and an already-past window must be rejected under the real clock.
315
- expect(() =>
316
- eng.addAuditWindow({
317
- frameworkId: "soc2",
318
- controlId: "CC6.2",
319
- expiresAt: Date.now() - 1000,
320
- }),
321
- ).toThrow(DataRetentionError);
322
- });
323
-
324
- test("defaultRetentionDays falls back to 90 when unset", async () => {
325
- const now = 1_700_000_000_000;
326
- const store = new InMemoryRecordStore([
327
- record("fresh", "tenant-a", "audit", 80, {}, now), // < 90d → kept
328
- record("stale", "tenant-a", "audit", 100, {}, now), // > 90d → purged
329
- ]);
330
- const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
331
- const result = await eng.purge("tenant-a");
332
- expect(result.deleted).toBe(1);
333
- expect(store.ids()).toEqual(["fresh"]);
334
- });
335
- });
336
-
337
- describe("defensive cross-tenant guards (misbehaving store)", () => {
338
- // A custom store that ignores the tenant filter and leaks foreign records.
339
- // The engine's per-record `record.tenantId !== tenantId` guard must still
340
- // refuse to act on them (T8 belt-and-suspenders).
341
- class LeakyStore implements RecordStore {
342
- constructor(private readonly recs: ReadonlyArray<RetentionRecord>) {}
343
- async *listAll(): AsyncIterable<RetentionRecord> {
344
- for (const r of this.recs) yield r;
345
- }
346
- // Deliberately ignores `_tenantId` and yields EVERYTHING.
347
- async *listByTenant(_tenantId: string): AsyncIterable<RetentionRecord> {
348
- for (const r of this.recs) yield r;
349
- }
350
- async delete(): Promise<boolean> {
351
- return true;
352
- }
353
- }
354
-
355
- test("purge skips foreign-tenant records a leaky store yields", async () => {
356
- const now = 1_700_000_000_000;
357
- const deleted: string[] = [];
358
- const recs = [
359
- record("a-old", "tenant-a", "audit", 200, {}, now),
360
- record("b-old", "tenant-b", "audit", 200, {}, now),
361
- ];
362
- // Inline store so `delete` can record which ids the engine acts on.
363
- const store: RecordStore = {
364
- async *listAll() {
365
- for (const r of recs) yield r;
366
- },
367
- async *listByTenant() {
368
- for (const r of recs) yield r; // leaks every tenant
369
- },
370
- async delete(id: string): Promise<boolean> {
371
- deleted.push(id);
372
- return true;
373
- },
374
- };
375
- const eng = createDataRetentionEngine({
376
- recordStore: store,
377
- now: () => now,
378
- defaultRetentionDays: 30,
379
- });
380
- const result = await eng.purge("tenant-a");
381
- expect(result.deleted).toBe(1);
382
- expect(deleted).toEqual(["a-old"]); // tenant-b never touched
383
- });
384
-
385
- test("export omits foreign-tenant records a leaky store yields", async () => {
386
- const store = new LeakyStore([
387
- record("a-1", "tenant-a", "audit", 5),
388
- record("b-1", "tenant-b", "audit", 5),
389
- ]);
390
- const eng = createDataRetentionEngine({ recordStore: store });
391
- const out = await eng.export("tenant-a", { format: "json" });
392
- const parsed = JSON.parse(out) as RetentionRecord[];
393
- expect(parsed).toHaveLength(1);
394
- expect(parsed[0]?.id).toBe("a-1");
395
- expect(out.includes("b-1")).toBe(false);
396
- });
397
- });
398
-
399
- describe("delete-returns-false branches", () => {
400
- // When the store reports `delete` -> false (record already gone), neither
401
- // purge nor sweep should count it as deleted.
402
- class NoopDeleteStore implements RecordStore {
403
- constructor(private readonly recs: ReadonlyArray<RetentionRecord>) {}
404
- async *listAll(): AsyncIterable<RetentionRecord> {
405
- for (const r of this.recs) yield r;
406
- }
407
- async *listByTenant(tenantId: string): AsyncIterable<RetentionRecord> {
408
- for (const r of this.recs) if (r.tenantId === tenantId) yield r;
409
- }
410
- async delete(): Promise<boolean> {
411
- return false; // pretend the record vanished between list and delete
412
- }
413
- }
414
-
415
- test("purge does not count a delete that returns false", async () => {
416
- const now = 1_700_000_000_000;
417
- const store = new NoopDeleteStore([record("a-old", "tenant-a", "audit", 200, {}, now)]);
418
- const eng = createDataRetentionEngine({
419
- recordStore: store,
420
- now: () => now,
421
- defaultRetentionDays: 30,
422
- });
423
- const result = await eng.purge("tenant-a");
424
- expect(result.deleted).toBe(0);
425
- });
426
-
427
- test("sweep counts a delete that returns false as kept", async () => {
428
- const now = 1_700_000_000_000;
429
- const store = new NoopDeleteStore([record("a-old", "tenant-a", "audit", 200, {}, now)]);
430
- const eng = createDataRetentionEngine({
431
- recordStore: store,
432
- now: () => now,
433
- defaultRetentionDays: 30,
434
- });
435
- const result = await eng.sweep();
436
- expect(result.deletedCount).toBe(0);
437
- expect(result.recordsKept).toBe(1);
438
- });
439
- });
440
-
441
- describe("createDataRetentionEngine guard", () => {
442
- test("throws when recordStore is missing", () => {
443
- // Force the undefined-recordStore branch through the public factory.
444
- expect(() =>
445
- createDataRetentionEngine({ recordStore: undefined as unknown as RecordStore }),
446
- ).toThrow(DataRetentionError);
447
- });
448
- });
package/src/index.ts DELETED
@@ -1,304 +0,0 @@
1
- import { CrewhausError } from "@crewhaus/errors";
2
-
3
- /**
4
- * Catalog R17 `data-retention-engine` — Section 39 GDPR-shaped retention.
5
- *
6
- * Three operations:
7
- * retain(tenantId, kind, durationDays) — pin records for at least
8
- * this long. The cron-style sweeper consults the policy table
9
- * before deleting. Multiple retain() calls compose: the longest
10
- * duration wins.
11
- * export(tenantId, format) — right-to-export. Streams
12
- * a tenant's records out in JSON or NDJSON.
13
- * purge(tenantId, opts?) — right-to-delete. Honors a
14
- * compliance-controls "do not purge during audit window" override
15
- * so SOC 2 evidence collection isn't disrupted.
16
- *
17
- * The engine is record-store-agnostic. Callers supply a `RecordStore`
18
- * shape (list / get / delete / count) so the engine can operate over
19
- * the audit-log JSONL files, a SQL table, an object-store prefix, etc.
20
- *
21
- * Audit-window override: callers register an `AuditWindow` (with
22
- * `frameworkId` / `controlId` / `expiresAt`) via `addAuditWindow`. As
23
- * long as any window is active, `purge()` skips records and returns
24
- * the deferral reason. Cleared automatically once `expiresAt` passes.
25
- *
26
- * Cross-tenant isolation: every operation requires an explicit
27
- * `tenantId`; the engine refuses to operate on records that don't
28
- * report a matching `tenantId`. T8 isolation test asserts that a
29
- * tenant-A purge does not touch tenant-B records.
30
- *
31
- * Layer R17. Pairs with `audit-log` (R-infra — primary record store
32
- * for audit data), `tenancy` (R-infra — tenant-scoping primitives).
33
- */
34
-
35
- export class DataRetentionError extends CrewhausError {
36
- override readonly name = "DataRetentionError";
37
- constructor(message: string, cause?: unknown) {
38
- super("config", message, cause);
39
- }
40
- }
41
-
42
- export type RetentionRecord = {
43
- readonly id: string;
44
- readonly tenantId: string;
45
- readonly kind: string;
46
- readonly createdAt: number;
47
- readonly payload: unknown;
48
- };
49
-
50
- export interface RecordStore {
51
- /** Iterate every record (retention engine filters per-tenant downstream). */
52
- listAll(): AsyncIterable<RetentionRecord>;
53
- /** Iterate records for a specific tenant. */
54
- listByTenant(tenantId: string): AsyncIterable<RetentionRecord>;
55
- /** Delete a record by id. Returns true if a record was actually deleted. */
56
- delete(id: string): Promise<boolean>;
57
- }
58
-
59
- export type RetentionPolicy = {
60
- readonly tenantId: string;
61
- readonly kind: string;
62
- readonly durationDays: number;
63
- };
64
-
65
- export type AuditWindow = {
66
- readonly frameworkId: string;
67
- readonly controlId: string;
68
- readonly expiresAt: number;
69
- };
70
-
71
- export type ExportFormat = "json" | "ndjson";
72
-
73
- export type ExportOptions = {
74
- readonly format: ExportFormat;
75
- readonly kinds?: ReadonlyArray<string>;
76
- };
77
-
78
- export type PurgeOptions = {
79
- readonly kind?: string;
80
- readonly before?: number;
81
- };
82
-
83
- export type PurgeResult = {
84
- readonly tenantId: string;
85
- readonly deleted: number;
86
- readonly deferred: ReadonlyArray<string>;
87
- readonly retentionDeferred: ReadonlyArray<string>;
88
- readonly auditWindowDeferred: ReadonlyArray<{
89
- readonly frameworkId: string;
90
- readonly controlId: string;
91
- readonly expiresAt: number;
92
- }>;
93
- };
94
-
95
- export type SweepResult = {
96
- readonly deletedCount: number;
97
- readonly recordsKept: number;
98
- };
99
-
100
- export interface DataRetentionEngine {
101
- retain(tenantId: string, kind: string, durationDays: number): void;
102
- removeRetention(tenantId: string, kind: string): void;
103
- listRetention(): ReadonlyArray<RetentionPolicy>;
104
- addAuditWindow(window: AuditWindow): void;
105
- listAuditWindows(): ReadonlyArray<AuditWindow>;
106
- /** Purge a single tenant's records. Honors retention + audit windows. */
107
- purge(tenantId: string, opts?: PurgeOptions): Promise<PurgeResult>;
108
- /** Stream a single tenant's records in the requested format. */
109
- export(tenantId: string, opts: ExportOptions): Promise<string>;
110
- /** Cron-style sweeper: walk all records and delete expired-by-policy ones. */
111
- sweep(): Promise<SweepResult>;
112
- }
113
-
114
- export type CreateOptions = {
115
- readonly recordStore: RecordStore;
116
- readonly now?: () => number;
117
- readonly defaultRetentionDays?: number;
118
- };
119
-
120
- const MS_PER_DAY = 24 * 60 * 60 * 1000;
121
-
122
- export function createDataRetentionEngine(opts: CreateOptions): DataRetentionEngine {
123
- if (opts.recordStore === undefined) {
124
- throw new DataRetentionError("recordStore is required");
125
- }
126
- const now = opts.now ?? ((): number => Date.now());
127
- const defaultRetentionDays = opts.defaultRetentionDays ?? 90;
128
- const policies = new Map<string, RetentionPolicy>();
129
- const auditWindows: AuditWindow[] = [];
130
-
131
- const policyKey = (tenantId: string, kind: string): string => `${tenantId}::${kind}`;
132
-
133
- function activeAuditWindows(): ReadonlyArray<AuditWindow> {
134
- const active = auditWindows.filter((w) => w.expiresAt > now());
135
- // Mutate in place to drop expired windows so the array doesn't grow
136
- // unboundedly across many sweeps.
137
- auditWindows.length = 0;
138
- auditWindows.push(...active);
139
- return active;
140
- }
141
-
142
- function retentionEnd(record: RetentionRecord): number {
143
- const key = policyKey(record.tenantId, record.kind);
144
- const policy = policies.get(key);
145
- const days = policy?.durationDays ?? defaultRetentionDays;
146
- return record.createdAt + days * MS_PER_DAY;
147
- }
148
-
149
- return {
150
- retain(tenantId, kind, durationDays): void {
151
- if (typeof tenantId !== "string" || tenantId === "") {
152
- throw new DataRetentionError("retain: tenantId required");
153
- }
154
- if (typeof kind !== "string" || kind === "") {
155
- throw new DataRetentionError("retain: kind required");
156
- }
157
- if (!Number.isFinite(durationDays) || durationDays <= 0) {
158
- throw new DataRetentionError("retain: durationDays must be > 0");
159
- }
160
- const key = policyKey(tenantId, kind);
161
- const existing = policies.get(key);
162
- if (existing === undefined || durationDays > existing.durationDays) {
163
- policies.set(key, { tenantId, kind, durationDays });
164
- }
165
- },
166
-
167
- removeRetention(tenantId, kind): void {
168
- policies.delete(policyKey(tenantId, kind));
169
- },
170
-
171
- listRetention(): ReadonlyArray<RetentionPolicy> {
172
- return [...policies.values()].sort(
173
- (a, b) => a.tenantId.localeCompare(b.tenantId) || a.kind.localeCompare(b.kind),
174
- );
175
- },
176
-
177
- addAuditWindow(window: AuditWindow): void {
178
- if (window.expiresAt <= now()) {
179
- throw new DataRetentionError(
180
- `addAuditWindow: expiresAt (${window.expiresAt}) must be in the future (now=${now()})`,
181
- );
182
- }
183
- auditWindows.push({ ...window });
184
- },
185
-
186
- listAuditWindows(): ReadonlyArray<AuditWindow> {
187
- return activeAuditWindows().map((w) => ({ ...w }));
188
- },
189
-
190
- async purge(tenantId, purgeOpts = {}): Promise<PurgeResult> {
191
- if (typeof tenantId !== "string" || tenantId === "") {
192
- throw new DataRetentionError("purge: tenantId required");
193
- }
194
- const active = activeAuditWindows();
195
- const result = {
196
- tenantId,
197
- deleted: 0,
198
- deferred: [] as string[],
199
- retentionDeferred: [] as string[],
200
- auditWindowDeferred: [] as PurgeResult["auditWindowDeferred"][number][],
201
- };
202
- for (const window of active) {
203
- result.auditWindowDeferred.push({
204
- frameworkId: window.frameworkId,
205
- controlId: window.controlId,
206
- expiresAt: window.expiresAt,
207
- });
208
- }
209
- const cutoff = purgeOpts.before;
210
- const restrictKind = purgeOpts.kind;
211
- for await (const record of opts.recordStore.listByTenant(tenantId)) {
212
- if (record.tenantId !== tenantId) continue; // T8 cross-tenant guard
213
- if (restrictKind !== undefined && record.kind !== restrictKind) continue;
214
- if (cutoff !== undefined && record.createdAt >= cutoff) continue;
215
- if (active.length > 0) {
216
- result.deferred.push(record.id);
217
- continue;
218
- }
219
- if (retentionEnd(record) > now()) {
220
- result.retentionDeferred.push(record.id);
221
- continue;
222
- }
223
- const deleted = await opts.recordStore.delete(record.id);
224
- if (deleted) result.deleted += 1;
225
- }
226
- return {
227
- tenantId: result.tenantId,
228
- deleted: result.deleted,
229
- deferred: result.deferred,
230
- retentionDeferred: result.retentionDeferred,
231
- auditWindowDeferred: result.auditWindowDeferred,
232
- };
233
- },
234
-
235
- async export(tenantId, exportOpts): Promise<string> {
236
- if (typeof tenantId !== "string" || tenantId === "") {
237
- throw new DataRetentionError("export: tenantId required");
238
- }
239
- const records: RetentionRecord[] = [];
240
- for await (const record of opts.recordStore.listByTenant(tenantId)) {
241
- if (record.tenantId !== tenantId) continue; // T8 cross-tenant guard
242
- if (exportOpts.kinds !== undefined && !exportOpts.kinds.includes(record.kind)) continue;
243
- records.push(record);
244
- }
245
- if (exportOpts.format === "ndjson") {
246
- return records.map((r) => JSON.stringify(r)).join("\n");
247
- }
248
- return JSON.stringify(records, null, 2);
249
- },
250
-
251
- async sweep(): Promise<SweepResult> {
252
- const active = activeAuditWindows();
253
- let deleted = 0;
254
- let kept = 0;
255
- for await (const record of opts.recordStore.listAll()) {
256
- if (active.length > 0) {
257
- kept += 1;
258
- continue;
259
- }
260
- if (retentionEnd(record) > now()) {
261
- kept += 1;
262
- continue;
263
- }
264
- const ok = await opts.recordStore.delete(record.id);
265
- if (ok) deleted += 1;
266
- else kept += 1;
267
- }
268
- return { deletedCount: deleted, recordsKept: kept };
269
- },
270
- };
271
- }
272
-
273
- export class InMemoryRecordStore implements RecordStore {
274
- private records: RetentionRecord[];
275
- constructor(initial: ReadonlyArray<RetentionRecord> = []) {
276
- this.records = [...initial];
277
- }
278
-
279
- async *listAll(): AsyncIterable<RetentionRecord> {
280
- for (const r of [...this.records]) yield r;
281
- }
282
-
283
- async *listByTenant(tenantId: string): AsyncIterable<RetentionRecord> {
284
- for (const r of [...this.records]) {
285
- if (r.tenantId === tenantId) yield r;
286
- }
287
- }
288
-
289
- async delete(id: string): Promise<boolean> {
290
- const before = this.records.length;
291
- this.records = this.records.filter((r) => r.id !== id);
292
- return this.records.length < before;
293
- }
294
-
295
- size(): number {
296
- return this.records.length;
297
- }
298
-
299
- ids(): ReadonlyArray<string> {
300
- return this.records.map((r) => r.id);
301
- }
302
- }
303
-
304
- export { MS_PER_DAY as _msPerDayForTest };