@crewhaus/data-retention-engine 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ import { CrewhausError } from "@crewhaus/errors";
30
30
  * Layer R17. Pairs with `audit-log` (R-infra — primary record store
31
31
  * for audit data), `tenancy` (R-infra — tenant-scoping primitives).
32
32
  */
33
+ export { DEFAULT_SESSION_MAX_AGE_DAYS, RETENTION_CONFIG_RELPATH, type RetentionAuditWindowConfig, type RetentionConfig, RetentionConfigError, SESSION_ID_REGEX, loadRetentionConfig, } from "./retention-config";
33
34
  export declare class DataRetentionError extends CrewhausError {
34
35
  readonly name = "DataRetentionError";
35
36
  constructor(message: string, cause?: unknown);
package/dist/index.js CHANGED
@@ -30,6 +30,11 @@ import { CrewhausError } from "@crewhaus/errors";
30
30
  * Layer R17. Pairs with `audit-log` (R-infra — primary record store
31
31
  * for audit data), `tenancy` (R-infra — tenant-scoping primitives).
32
32
  */
33
+ // `.crewhaus/retention.json` — the shared on-disk policy file read by BOTH
34
+ // the `crewhaus retention` CLI and the daemon shapes' boot-time janitor, so
35
+ // the two enforcement paths cannot drift (ops-review F2). See
36
+ // retention-config.ts for the schema + safety stance.
37
+ export { DEFAULT_SESSION_MAX_AGE_DAYS, RETENTION_CONFIG_RELPATH, RetentionConfigError, SESSION_ID_REGEX, loadRetentionConfig, } from "./retention-config";
33
38
  export class DataRetentionError extends CrewhausError {
34
39
  name = "DataRetentionError";
35
40
  constructor(message, cause) {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `.crewhaus/retention.json` — the shared on-disk retention policy for a
3
+ * harness directory. Owned by this package (not the CLI) so EVERY enforcement
4
+ * surface reads the same file with the same parser:
5
+ *
6
+ * - `crewhaus retention sweep|export|purge` (apps/cli/src/retention.ts),
7
+ * - the boot-time self-heal janitor the daemon shapes run
8
+ * (`@crewhaus/runtime-core` createJanitor — managed gateway, channel
9
+ * bots, batch workers), which must honor the SAME pins and
10
+ * `sessions.maxAgeDays` or the two enforcement paths contradict each
11
+ * other (ops-review F2: a janitor on the 30-day default would delete
12
+ * sessions the operator pinned or configured to keep longer).
13
+ *
14
+ * Schema (all keys optional):
15
+ *
16
+ * {
17
+ * "version": 1,
18
+ * "sessions": { "maxAgeDays": 30 }, // default: DEFAULT_SESSION_MAX_AGE_DAYS
19
+ * "pins": ["sess_0123456789abcdef"], // session ids never deleted
20
+ * "auditWindows": [ // active windows defer ALL deletion
21
+ * { // (compliance-controls evidence
22
+ * "frameworkId": "soc2", // collection in flight)
23
+ * "controlId": "CC6.1",
24
+ * "expiresAt": "2026-08-01T00:00:00Z"
25
+ * }
26
+ * ]
27
+ * }
28
+ *
29
+ * Safety stance: a malformed file throws `RetentionConfigError` — an enforcer
30
+ * that half-understands its policy must not guess. Already-expired
31
+ * auditWindows are dropped at load (a stale entry should stop deferring, not
32
+ * brick the sweep).
33
+ */
34
+ export declare const RETENTION_CONFIG_RELPATH = ".crewhaus/retention.json";
35
+ /**
36
+ * Default `sessions.maxAgeDays` when `.crewhaus/retention.json` is absent or
37
+ * silent. Mirrors `@crewhaus/session-store`'s `DEFAULT_TTL_DAYS` (30) — the
38
+ * TTL `list()`-side eviction uses — kept as a local constant so this package
39
+ * does not depend on session-store; apps/cli pins the two together in a test.
40
+ */
41
+ export declare const DEFAULT_SESSION_MAX_AGE_DAYS = 30;
42
+ /** The `sess_<16 hex>` session-id shape shared with `@crewhaus/session-store`. */
43
+ export declare const SESSION_ID_REGEX: RegExp;
44
+ /** Thrown on a malformed `.crewhaus/retention.json` (and, in the CLI, on an
45
+ * export outDir that would write into a live store). The CLI entry file
46
+ * catches it and routes the message through `die()`; daemons catch it and
47
+ * fail safe (disable eviction); tests assert on `.message`. */
48
+ export declare class RetentionConfigError extends Error {
49
+ readonly name = "RetentionConfigError";
50
+ }
51
+ export type RetentionAuditWindowConfig = {
52
+ readonly frameworkId: string;
53
+ readonly controlId: string;
54
+ /** Epoch ms (parsed from the file's ISO string or numeric value). */
55
+ readonly expiresAt: number;
56
+ };
57
+ export type RetentionConfig = {
58
+ /** Max session age in days before enforcement deletes it (mtime-keyed). */
59
+ readonly sessionMaxAgeDays: number;
60
+ /** Session ids (`sess_<16 hex>`) enforcement refuses to delete. */
61
+ readonly pins: ReadonlyArray<string>;
62
+ /** Declared audit windows; expired entries are dropped at load. */
63
+ readonly auditWindows: ReadonlyArray<RetentionAuditWindowConfig>;
64
+ /** Whether `.crewhaus/retention.json` existed (defaults were used if not). */
65
+ readonly fromFile: boolean;
66
+ };
67
+ /**
68
+ * Load `.crewhaus/retention.json` from `rootDir`, falling back to defaults
69
+ * (sessions at {@link DEFAULT_SESSION_MAX_AGE_DAYS}, no pins, no windows)
70
+ * when the file is absent. A malformed file throws `RetentionConfigError` —
71
+ * an enforcer that half-understands its policy must not guess.
72
+ */
73
+ export declare function loadRetentionConfig(rootDir: string, now?: () => number): Promise<RetentionConfig>;
@@ -0,0 +1,143 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * `.crewhaus/retention.json` — the shared on-disk retention policy for a
5
+ * harness directory. Owned by this package (not the CLI) so EVERY enforcement
6
+ * surface reads the same file with the same parser:
7
+ *
8
+ * - `crewhaus retention sweep|export|purge` (apps/cli/src/retention.ts),
9
+ * - the boot-time self-heal janitor the daemon shapes run
10
+ * (`@crewhaus/runtime-core` createJanitor — managed gateway, channel
11
+ * bots, batch workers), which must honor the SAME pins and
12
+ * `sessions.maxAgeDays` or the two enforcement paths contradict each
13
+ * other (ops-review F2: a janitor on the 30-day default would delete
14
+ * sessions the operator pinned or configured to keep longer).
15
+ *
16
+ * Schema (all keys optional):
17
+ *
18
+ * {
19
+ * "version": 1,
20
+ * "sessions": { "maxAgeDays": 30 }, // default: DEFAULT_SESSION_MAX_AGE_DAYS
21
+ * "pins": ["sess_0123456789abcdef"], // session ids never deleted
22
+ * "auditWindows": [ // active windows defer ALL deletion
23
+ * { // (compliance-controls evidence
24
+ * "frameworkId": "soc2", // collection in flight)
25
+ * "controlId": "CC6.1",
26
+ * "expiresAt": "2026-08-01T00:00:00Z"
27
+ * }
28
+ * ]
29
+ * }
30
+ *
31
+ * Safety stance: a malformed file throws `RetentionConfigError` — an enforcer
32
+ * that half-understands its policy must not guess. Already-expired
33
+ * auditWindows are dropped at load (a stale entry should stop deferring, not
34
+ * brick the sweep).
35
+ */
36
+ export const RETENTION_CONFIG_RELPATH = ".crewhaus/retention.json";
37
+ /**
38
+ * Default `sessions.maxAgeDays` when `.crewhaus/retention.json` is absent or
39
+ * silent. Mirrors `@crewhaus/session-store`'s `DEFAULT_TTL_DAYS` (30) — the
40
+ * TTL `list()`-side eviction uses — kept as a local constant so this package
41
+ * does not depend on session-store; apps/cli pins the two together in a test.
42
+ */
43
+ export const DEFAULT_SESSION_MAX_AGE_DAYS = 30;
44
+ /** The `sess_<16 hex>` session-id shape shared with `@crewhaus/session-store`. */
45
+ export const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
46
+ /** Thrown on a malformed `.crewhaus/retention.json` (and, in the CLI, on an
47
+ * export outDir that would write into a live store). The CLI entry file
48
+ * catches it and routes the message through `die()`; daemons catch it and
49
+ * fail safe (disable eviction); tests assert on `.message`. */
50
+ export class RetentionConfigError extends Error {
51
+ name = "RetentionConfigError";
52
+ }
53
+ function isRecord(v) {
54
+ return typeof v === "object" && v !== null && !Array.isArray(v);
55
+ }
56
+ /**
57
+ * Load `.crewhaus/retention.json` from `rootDir`, falling back to defaults
58
+ * (sessions at {@link DEFAULT_SESSION_MAX_AGE_DAYS}, no pins, no windows)
59
+ * when the file is absent. A malformed file throws `RetentionConfigError` —
60
+ * an enforcer that half-understands its policy must not guess.
61
+ */
62
+ export async function loadRetentionConfig(rootDir, now = () => Date.now()) {
63
+ const path = join(rootDir, RETENTION_CONFIG_RELPATH);
64
+ let raw;
65
+ try {
66
+ raw = await readFile(path, "utf8");
67
+ }
68
+ catch (err) {
69
+ if (err.code === "ENOENT") {
70
+ return {
71
+ sessionMaxAgeDays: DEFAULT_SESSION_MAX_AGE_DAYS,
72
+ pins: [],
73
+ auditWindows: [],
74
+ fromFile: false,
75
+ };
76
+ }
77
+ throw err;
78
+ }
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ }
83
+ catch (err) {
84
+ throw new RetentionConfigError(`${path}: malformed JSON — ${err.message}`);
85
+ }
86
+ if (!isRecord(parsed)) {
87
+ throw new RetentionConfigError(`${path}: expected a JSON object at the root`);
88
+ }
89
+ let sessionMaxAgeDays = DEFAULT_SESSION_MAX_AGE_DAYS;
90
+ if (parsed["sessions"] !== undefined) {
91
+ if (!isRecord(parsed["sessions"])) {
92
+ throw new RetentionConfigError(`${path}: "sessions" must be an object`);
93
+ }
94
+ const maxAge = parsed["sessions"]["maxAgeDays"];
95
+ if (maxAge !== undefined) {
96
+ if (typeof maxAge !== "number" || !Number.isFinite(maxAge) || maxAge <= 0) {
97
+ throw new RetentionConfigError(`${path}: "sessions.maxAgeDays" must be a finite number > 0 (got ${JSON.stringify(maxAge)})`);
98
+ }
99
+ sessionMaxAgeDays = maxAge;
100
+ }
101
+ }
102
+ const pins = [];
103
+ if (parsed["pins"] !== undefined) {
104
+ if (!Array.isArray(parsed["pins"])) {
105
+ throw new RetentionConfigError(`${path}: "pins" must be an array of session ids`);
106
+ }
107
+ for (const pin of parsed["pins"]) {
108
+ if (typeof pin !== "string" || !SESSION_ID_REGEX.test(pin)) {
109
+ throw new RetentionConfigError(`${path}: pin ${JSON.stringify(pin)} is not a session id (expected sess_<16 hex>)`);
110
+ }
111
+ pins.push(pin);
112
+ }
113
+ }
114
+ const auditWindows = [];
115
+ if (parsed["auditWindows"] !== undefined) {
116
+ if (!Array.isArray(parsed["auditWindows"])) {
117
+ throw new RetentionConfigError(`${path}: "auditWindows" must be an array`);
118
+ }
119
+ for (const w of parsed["auditWindows"]) {
120
+ if (!isRecord(w) ||
121
+ typeof w["frameworkId"] !== "string" ||
122
+ typeof w["controlId"] !== "string") {
123
+ throw new RetentionConfigError(`${path}: each auditWindow needs string "frameworkId" + "controlId" + "expiresAt"`);
124
+ }
125
+ const rawExpires = w["expiresAt"];
126
+ const expiresAt = typeof rawExpires === "number" ? rawExpires : Date.parse(String(rawExpires));
127
+ if (!Number.isFinite(expiresAt)) {
128
+ throw new RetentionConfigError(`${path}: auditWindow ${w["frameworkId"]}/${w["controlId"]} has an unparseable "expiresAt"`);
129
+ }
130
+ // Already-expired windows are dropped here (the engine's addAuditWindow
131
+ // throws on a past expiry — a stale config entry should stop deferring,
132
+ // not brick the sweep).
133
+ if (expiresAt > now()) {
134
+ auditWindows.push({
135
+ frameworkId: w["frameworkId"],
136
+ controlId: w["controlId"],
137
+ expiresAt,
138
+ });
139
+ }
140
+ }
141
+ }
142
+ return { sessionMaxAgeDays, pins, auditWindows, fromFile: true };
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/data-retention-engine",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "GDPR-shaped data retention: retain / export / purge with cron-style sweeper + audit window override (Section 39)",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.1.7"
18
+ "@crewhaus/errors": "0.2.0"
19
19
  },
20
20
  "license": "Apache-2.0",
21
21
  "author": {