@crewhaus/data-retention-engine 0.1.8 → 0.2.1
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 +1 -0
- package/dist/index.js +5 -0
- package/dist/retention-config.d.ts +73 -0
- package/dist/retention-config.js +143 -0
- package/package.json +2 -2
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
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
|
18
|
+
"@crewhaus/errors": "0.2.1"
|
|
19
19
|
},
|
|
20
20
|
"license": "Apache-2.0",
|
|
21
21
|
"author": {
|