@elinpf/dsh-ops-access-hub 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/lib/server.js ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * HTTP API for the hub, built on bare `node:http` (no framework by design).
3
+ *
4
+ * Endpoints (default bind `127.0.0.1:3090`):
5
+ *
6
+ * - `GET /health` → `{ok:true}`, no auth
7
+ * - `GET /` → the static web UI, no auth (it holds no secrets)
8
+ * - `GET /entries` → `[{kind,name,envelope,tiers:{ro?:{probe?},rw?:{probe?}},updatedAt}]`
9
+ * (read+; field values never appear here)
10
+ * - `GET /entries/:kind/:name/:tier` → `{kind,name,tier,fields,envelope,probe?}` (read+; audited as `resolve`)
11
+ * - `PUT /entries/:kind/:name/:tier` → upsert, body `{fields,envelope?,probe?}` (admin)
12
+ * - `DELETE /entries/:kind/:name/:tier` → `{ok:true}` / 404 (admin; last tier removes the entry)
13
+ * - `GET /audit?limit=N` → recent N audit records (admin, default 100)
14
+ * - `POST /requests` → queue a tier-registration request, body
15
+ * `{kind,name,tier,fields,envelope?,reason?}` (admin)
16
+ * - `GET /requests?status=pending` → request list, metadata only — field
17
+ * names + byte sizes, never values (read+)
18
+ * - `GET /requests/:id` → full request incl. field values, for
19
+ * pre-approval review (admin)
20
+ * - `POST /requests/:id/decide` → `{approved:boolean}`; approval writes the
21
+ * tier, either way the request's fields are
22
+ * wiped (admin; 409 unless pending)
23
+ *
24
+ * Auth: two Bearer tokens — admin (everything) and read (`GET /entries*`
25
+ * only). Comparisons use `crypto.timingSafeEqual`. Every error response is
26
+ * JSON `{ok:false,error}` and `error` never contains field values.
27
+ *
28
+ * @module
29
+ */
30
+ import { timingSafeEqual } from 'node:crypto';
31
+ import { createServer } from 'node:http';
32
+ import { WEB_UI_HTML } from './web.js';
33
+ /** Profile name / kind charset; kinds additionally can never contain `/` (path segment). */
34
+ export const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@-]*$/;
35
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
36
+ class HttpError extends Error {
37
+ status;
38
+ constructor(status, message) {
39
+ super(message);
40
+ this.status = status;
41
+ }
42
+ }
43
+ function isPlainObject(v) {
44
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
45
+ }
46
+ function tokenEqual(a, b) {
47
+ const ba = Buffer.from(a);
48
+ const bb = Buffer.from(b);
49
+ if (ba.length !== bb.length)
50
+ return false;
51
+ return timingSafeEqual(ba, bb);
52
+ }
53
+ function roleOf(req, opts) {
54
+ const header = req.headers.authorization;
55
+ if (!header || !header.startsWith('Bearer '))
56
+ return null;
57
+ const token = header.slice('Bearer '.length).trim();
58
+ if (tokenEqual(token, opts.adminToken))
59
+ return 'admin';
60
+ if (tokenEqual(token, opts.readToken))
61
+ return 'read';
62
+ return null;
63
+ }
64
+ function send(res, status, body) {
65
+ const text = JSON.stringify(body);
66
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
67
+ res.end(text);
68
+ }
69
+ async function readBody(req) {
70
+ const chunks = [];
71
+ let size = 0;
72
+ for await (const chunk of req) {
73
+ size += chunk.length;
74
+ if (size > MAX_BODY_BYTES)
75
+ throw new HttpError(413, 'body too large');
76
+ chunks.push(chunk);
77
+ }
78
+ const text = Buffer.concat(chunks).toString('utf8');
79
+ if (text.trim() === '')
80
+ throw new HttpError(400, 'request body must be a JSON object');
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(text);
84
+ }
85
+ catch {
86
+ throw new HttpError(400, 'request body is not valid JSON');
87
+ }
88
+ if (!isPlainObject(parsed))
89
+ throw new HttpError(400, 'request body must be a JSON object');
90
+ return parsed;
91
+ }
92
+ /** Keep only the three known envelope keys, and only when they are strings. */
93
+ function sanitizeEnvelope(raw) {
94
+ if (!isPlainObject(raw))
95
+ throw new HttpError(400, 'envelope must be a JSON object');
96
+ const out = {};
97
+ if (typeof raw.name === 'string')
98
+ out.name = raw.name;
99
+ if (typeof raw.description === 'string')
100
+ out.description = raw.description;
101
+ if (typeof raw.environment === 'string')
102
+ out.environment = raw.environment;
103
+ return out;
104
+ }
105
+ function sanitizeProbe(raw) {
106
+ if (!isPlainObject(raw))
107
+ throw new HttpError(400, 'probe must be a JSON object');
108
+ const { status, detail, probedAt } = raw;
109
+ if (status !== 'verified' && status !== 'mismatch' && status !== 'unverifiable') {
110
+ throw new HttpError(400, "probe.status must be 'verified', 'mismatch' or 'unverifiable'");
111
+ }
112
+ if (typeof probedAt !== 'string')
113
+ throw new HttpError(400, 'probe.probedAt must be a string');
114
+ if (detail !== undefined && typeof detail !== 'string')
115
+ throw new HttpError(400, 'probe.detail must be a string');
116
+ const out = { status, probedAt };
117
+ if (typeof detail === 'string')
118
+ out.detail = detail;
119
+ return out;
120
+ }
121
+ /** Strip a path segment to a validated kind/name, or throw 400. */
122
+ function segment(raw, what) {
123
+ let decoded;
124
+ try {
125
+ decoded = decodeURIComponent(raw);
126
+ }
127
+ catch {
128
+ throw new HttpError(400, `invalid ${what}`);
129
+ }
130
+ if (!NAME_PATTERN.test(decoded))
131
+ throw new HttpError(400, `invalid ${what}: must match ${NAME_PATTERN.source}`);
132
+ return decoded;
133
+ }
134
+ function tierOf(raw) {
135
+ if (raw === 'ro' || raw === 'rw')
136
+ return raw;
137
+ throw new HttpError(400, "tier must be 'ro' or 'rw'");
138
+ }
139
+ export function createHubServer(opts) {
140
+ const { store } = opts;
141
+ async function handle(req, res) {
142
+ const method = req.method ?? 'GET';
143
+ const url = new URL(req.url ?? '/', 'http://localhost');
144
+ const path = url.pathname;
145
+ // Unauthenticated surface: health probe and the static UI shell.
146
+ if (method === 'GET' && path === '/health')
147
+ return send(res, 200, { ok: true });
148
+ if (method === 'GET' && path === '/') {
149
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
150
+ res.end(WEB_UI_HTML);
151
+ return;
152
+ }
153
+ const role = roleOf(req, opts);
154
+ if (!role)
155
+ throw new HttpError(401, 'missing or invalid bearer token');
156
+ if (method === 'GET' && path === '/entries') {
157
+ // Listing never exposes field values — tiers carry only probe state.
158
+ const list = store.list().map((e) => ({
159
+ kind: e.kind,
160
+ name: e.name,
161
+ envelope: e.envelope,
162
+ tiers: {
163
+ ...(e.tiers.ro ? { ro: { ...(e.tiers.ro.probe ? { probe: e.tiers.ro.probe } : {}) } } : {}),
164
+ ...(e.tiers.rw ? { rw: { ...(e.tiers.rw.probe ? { probe: e.tiers.rw.probe } : {}) } } : {}),
165
+ },
166
+ updatedAt: e.updatedAt,
167
+ }));
168
+ return send(res, 200, list);
169
+ }
170
+ if (method === 'GET' && path === '/audit') {
171
+ if (role !== 'admin')
172
+ throw new HttpError(403, 'read token cannot access admin endpoints');
173
+ const raw = url.searchParams.get('limit');
174
+ let limit = 100;
175
+ if (raw !== null) {
176
+ limit = Number.parseInt(raw, 10);
177
+ if (!Number.isFinite(limit) || limit < 1)
178
+ throw new HttpError(400, 'limit must be a positive integer');
179
+ limit = Math.min(limit, 1000);
180
+ }
181
+ return send(res, 200, await store.readAudit(limit));
182
+ }
183
+ const parts = path.split('/').filter((p) => p !== '');
184
+ if (parts[0] === 'requests' && parts.length === 1) {
185
+ if (method === 'GET') {
186
+ // Metadata only — the reviewer fetches values per request (admin).
187
+ const raw = url.searchParams.get('status');
188
+ if (raw !== null && raw !== 'pending' && raw !== 'approved' && raw !== 'rejected') {
189
+ throw new HttpError(400, "status must be 'pending', 'approved' or 'rejected'");
190
+ }
191
+ const list = store.listRequests(raw ?? undefined).map((r) => ({
192
+ id: r.id,
193
+ kind: r.kind,
194
+ name: r.name,
195
+ tier: r.tier,
196
+ envelope: r.envelope,
197
+ ...(r.reason !== undefined ? { reason: r.reason } : {}),
198
+ status: r.status,
199
+ createdAt: r.createdAt,
200
+ ...(r.decidedAt !== undefined ? { decidedAt: r.decidedAt } : {}),
201
+ fields: Object.fromEntries(Object.entries(r.fields).map(([k, v]) => [k, typeof v === 'string' ? v.length : JSON.stringify(v).length])),
202
+ }));
203
+ return send(res, 200, list);
204
+ }
205
+ if (role !== 'admin')
206
+ throw new HttpError(403, 'read token cannot access admin endpoints');
207
+ if (method === 'POST') {
208
+ const body = await readBody(req);
209
+ const kind = segment(String(body.kind ?? ''), 'kind');
210
+ const name = segment(String(body.name ?? ''), 'name');
211
+ const tier = tierOf(String(body.tier ?? ''));
212
+ if (!isPlainObject(body.fields))
213
+ throw new HttpError(400, 'fields must be a JSON object');
214
+ const envelope = body.envelope === undefined ? {} : sanitizeEnvelope(body.envelope);
215
+ if (body.reason !== undefined && typeof body.reason !== 'string')
216
+ throw new HttpError(400, 'reason must be a string');
217
+ const request = store.putRequest({ kind, name, tier, fields: body.fields, envelope, reason: body.reason });
218
+ await store.save();
219
+ await store.audit(role, 'request', kind, name, tier);
220
+ return send(res, 200, { ok: true, id: request.id });
221
+ }
222
+ throw new HttpError(405, 'method not allowed');
223
+ }
224
+ if (parts[0] === 'requests' && parts.length === 2 && method === 'GET') {
225
+ // Full field values for pre-approval review — admin only.
226
+ if (role !== 'admin')
227
+ throw new HttpError(403, 'read token cannot review request contents');
228
+ const request = store.getRequest(parts[1]);
229
+ if (!request)
230
+ throw new HttpError(404, 'request not found');
231
+ return send(res, 200, request);
232
+ }
233
+ if (parts[0] === 'requests' && parts.length === 3 && parts[2] === 'decide') {
234
+ if (role !== 'admin')
235
+ throw new HttpError(403, 'read token cannot access admin endpoints');
236
+ if (method !== 'POST')
237
+ throw new HttpError(405, 'method not allowed');
238
+ const body = await readBody(req);
239
+ if (typeof body.approved !== 'boolean')
240
+ throw new HttpError(400, 'approved must be a boolean');
241
+ const request = store.decideRequest(parts[1], body.approved);
242
+ if (!request) {
243
+ const existing = store.getRequest(parts[1]);
244
+ throw existing
245
+ ? new HttpError(409, `request already ${existing.status}`)
246
+ : new HttpError(404, 'request not found');
247
+ }
248
+ await store.save();
249
+ await store.audit(role, body.approved ? 'approve' : 'reject', request.kind, request.name, request.tier);
250
+ return send(res, 200, { ok: true });
251
+ }
252
+ if (parts[0] === 'entries' && parts.length === 4) {
253
+ const kind = segment(parts[1], 'kind');
254
+ const name = segment(parts[2], 'name');
255
+ const tier = tierOf(parts[3]);
256
+ if (method === 'GET') {
257
+ const entry = store.getEntry(kind, name);
258
+ const tierData = entry?.tiers[tier];
259
+ if (!entry || !tierData)
260
+ throw new HttpError(404, 'entry not found');
261
+ await store.audit(role, 'resolve', kind, name, tier);
262
+ return send(res, 200, {
263
+ kind,
264
+ name,
265
+ tier,
266
+ fields: tierData.fields,
267
+ envelope: entry.envelope,
268
+ ...(tierData.probe ? { probe: tierData.probe } : {}),
269
+ });
270
+ }
271
+ if (role !== 'admin')
272
+ throw new HttpError(403, 'read token cannot access admin endpoints');
273
+ if (method === 'PUT') {
274
+ const body = await readBody(req);
275
+ if (!isPlainObject(body.fields))
276
+ throw new HttpError(400, 'fields must be a JSON object');
277
+ const envelope = body.envelope === undefined ? undefined : sanitizeEnvelope(body.envelope);
278
+ const probe = body.probe === undefined ? undefined : sanitizeProbe(body.probe);
279
+ store.putTier(kind, name, tier, { fields: body.fields, envelope, probe });
280
+ await store.save();
281
+ await store.audit(role, 'put', kind, name, tier);
282
+ return send(res, 200, { ok: true });
283
+ }
284
+ if (method === 'DELETE') {
285
+ if (!store.deleteTier(kind, name, tier))
286
+ throw new HttpError(404, 'entry not found');
287
+ await store.save();
288
+ await store.audit(role, 'delete', kind, name, tier);
289
+ return send(res, 200, { ok: true });
290
+ }
291
+ throw new HttpError(405, 'method not allowed');
292
+ }
293
+ throw new HttpError(404, 'not found');
294
+ }
295
+ return createServer((req, res) => {
296
+ handle(req, res).catch((err) => {
297
+ if (err instanceof HttpError)
298
+ return send(res, err.status, { ok: false, error: err.message });
299
+ // Deliberately generic: internal details (paths, key material) stay out of responses.
300
+ send(res, 500, { ok: false, error: 'internal error' });
301
+ });
302
+ });
303
+ }
package/lib/store.d.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Encrypted document store for the hub.
3
+ *
4
+ * The whole dataset is a single JSON document (`<data-dir>/hub-data.json.enc`)
5
+ * holding at most a few dozen entries, so it is decrypted into memory on
6
+ * load and re-encrypted on every mutation; plaintext never touches the disk.
7
+ * Writes are atomic: encrypt to a uniquely-named temp file in the same
8
+ * directory, fsync it, rename over the target, then fsync the directory —
9
+ * a power cut cannot leave a torn or zero-length data file.
10
+ *
11
+ * Document shape:
12
+ *
13
+ * ```json
14
+ * { "version": 1, "entries": { "k8s/prod": {
15
+ * "kind": "k8s", "name": "prod",
16
+ * "envelope": { "name": "...", "description": "...", "environment": "prod" },
17
+ * "tiers": { "ro": { "fields": { ... }, "probe": { ... } }, "rw": { "fields": { ... } } },
18
+ * "updatedAt": "<ISO>" } },
19
+ * "requests": { "<uuid>": { "kind": "...", "name": "...", "tier": "rw",
20
+ * "fields": { ... }, "status": "pending", ... } } }
21
+ * ```
22
+ *
23
+ * `requests` is the agent-registration approval queue (see server.ts
24
+ * `/requests` routes); a decided request keeps its metadata but its `fields`
25
+ * are wiped.
26
+ *
27
+ * The hub is dumb storage: file fields hold their *content* (inlined at
28
+ * import time) and no kind-specific schema validation happens here.
29
+ *
30
+ * Mutations are also mirrored to an append-only audit log
31
+ * (`<data-dir>/audit.log`, one JSON object per line) — never containing
32
+ * field values.
33
+ *
34
+ * @module
35
+ */
36
+ export type TierName = 'ro' | 'rw';
37
+ export interface ProbeState {
38
+ status: 'verified' | 'mismatch' | 'unverifiable';
39
+ detail?: string;
40
+ probedAt: string;
41
+ }
42
+ export interface EntryEnvelope {
43
+ name?: string;
44
+ description?: string;
45
+ environment?: string;
46
+ }
47
+ export interface TierData {
48
+ fields: Record<string, unknown>;
49
+ probe?: ProbeState;
50
+ }
51
+ export interface HubEntry {
52
+ kind: string;
53
+ name: string;
54
+ envelope: EntryEnvelope;
55
+ tiers: {
56
+ ro?: TierData;
57
+ rw?: TierData;
58
+ };
59
+ updatedAt: string;
60
+ }
61
+ export type RequestStatus = 'pending' | 'approved' | 'rejected';
62
+ /**
63
+ * A tier-registration request submitted by an agent, pending human approval.
64
+ * `fields` holds the secret material (encrypted at rest with the rest of the
65
+ * document) and is WIPED on decision — the approved copy lives on the entry.
66
+ */
67
+ export interface RegistrationRequest {
68
+ id: string;
69
+ kind: string;
70
+ name: string;
71
+ tier: TierName;
72
+ fields: Record<string, unknown>;
73
+ envelope: EntryEnvelope;
74
+ reason?: string;
75
+ status: RequestStatus;
76
+ createdAt: string;
77
+ decidedAt?: string;
78
+ }
79
+ export interface AuditRecord {
80
+ ts: string;
81
+ role: 'admin' | 'read';
82
+ action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject';
83
+ kind: string;
84
+ name: string;
85
+ tier: TierName;
86
+ }
87
+ export interface HubStoreOptions {
88
+ dataDir: string;
89
+ /** Defaults to `<dataDir>/hub.key`. */
90
+ keyFile?: string;
91
+ /** Master key text (base64/hex); takes priority over the key file. */
92
+ envKey?: string;
93
+ }
94
+ export declare class HubStore {
95
+ readonly dataDir: string;
96
+ readonly dataFile: string;
97
+ readonly auditFile: string;
98
+ private readonly keyFile;
99
+ private readonly envKey?;
100
+ private key;
101
+ private doc;
102
+ constructor(opts: HubStoreOptions);
103
+ /** Resolve the master key and decrypt the data file (a missing file means a fresh hub). */
104
+ init(): Promise<void>;
105
+ /** Encrypt the in-memory document and atomically replace the data file (mode 0600). */
106
+ save(): Promise<void>;
107
+ list(): HubEntry[];
108
+ getEntry(kind: string, name: string): HubEntry | undefined;
109
+ /** Upsert one tier of an entry; `envelope` replaces the entry envelope wholesale when given. */
110
+ putTier(kind: string, name: string, tier: TierName, data: {
111
+ fields: Record<string, unknown>;
112
+ envelope?: EntryEnvelope;
113
+ probe?: ProbeState;
114
+ }): HubEntry;
115
+ /** Delete one tier; removes the whole entry when its last tier goes. Returns false when absent. */
116
+ deleteTier(kind: string, name: string, tier: TierName): boolean;
117
+ /** The requests map, created lazily (old data files predate the approval flow). */
118
+ private requests;
119
+ /** Queue a tier-registration request; returns the generated id. */
120
+ putRequest(data: {
121
+ kind: string;
122
+ name: string;
123
+ tier: TierName;
124
+ fields: Record<string, unknown>;
125
+ envelope: EntryEnvelope;
126
+ reason?: string;
127
+ }): RegistrationRequest;
128
+ listRequests(status?: RequestStatus): RegistrationRequest[];
129
+ getRequest(id: string): RegistrationRequest | undefined;
130
+ /**
131
+ * Settle a pending request. On approval the tier is written through
132
+ * `putTier`. Either way the request's `fields` are wiped — secret material
133
+ * must not linger in a decided record. Returns null when absent or not
134
+ * pending.
135
+ */
136
+ decideRequest(id: string, approved: boolean): RegistrationRequest | null;
137
+ /** Append one audit line. Field values are never recorded. */
138
+ audit(role: AuditRecord['role'], action: AuditRecord['action'], kind: string, name: string, tier: TierName): Promise<void>;
139
+ /** Read the most recent `limit` audit records, oldest first. */
140
+ readAudit(limit: number): Promise<AuditRecord[]>;
141
+ }
package/lib/store.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Encrypted document store for the hub.
3
+ *
4
+ * The whole dataset is a single JSON document (`<data-dir>/hub-data.json.enc`)
5
+ * holding at most a few dozen entries, so it is decrypted into memory on
6
+ * load and re-encrypted on every mutation; plaintext never touches the disk.
7
+ * Writes are atomic: encrypt to a uniquely-named temp file in the same
8
+ * directory, fsync it, rename over the target, then fsync the directory —
9
+ * a power cut cannot leave a torn or zero-length data file.
10
+ *
11
+ * Document shape:
12
+ *
13
+ * ```json
14
+ * { "version": 1, "entries": { "k8s/prod": {
15
+ * "kind": "k8s", "name": "prod",
16
+ * "envelope": { "name": "...", "description": "...", "environment": "prod" },
17
+ * "tiers": { "ro": { "fields": { ... }, "probe": { ... } }, "rw": { "fields": { ... } } },
18
+ * "updatedAt": "<ISO>" } },
19
+ * "requests": { "<uuid>": { "kind": "...", "name": "...", "tier": "rw",
20
+ * "fields": { ... }, "status": "pending", ... } } }
21
+ * ```
22
+ *
23
+ * `requests` is the agent-registration approval queue (see server.ts
24
+ * `/requests` routes); a decided request keeps its metadata but its `fields`
25
+ * are wiped.
26
+ *
27
+ * The hub is dumb storage: file fields hold their *content* (inlined at
28
+ * import time) and no kind-specific schema validation happens here.
29
+ *
30
+ * Mutations are also mirrored to an append-only audit log
31
+ * (`<data-dir>/audit.log`, one JSON object per line) — never containing
32
+ * field values.
33
+ *
34
+ * @module
35
+ */
36
+ import { appendFile, chmod, mkdir, open, readFile, rename } from 'node:fs/promises';
37
+ import { join } from 'node:path';
38
+ import { randomUUID } from 'node:crypto';
39
+ import { decryptDoc, encryptDoc, loadMasterKey } from './crypto.js';
40
+ export class HubStore {
41
+ dataDir;
42
+ dataFile;
43
+ auditFile;
44
+ keyFile;
45
+ envKey;
46
+ key;
47
+ doc = { version: 1, entries: {} };
48
+ constructor(opts) {
49
+ this.dataDir = opts.dataDir;
50
+ this.dataFile = join(opts.dataDir, 'hub-data.json.enc');
51
+ this.auditFile = join(opts.dataDir, 'audit.log');
52
+ this.keyFile = opts.keyFile ?? join(opts.dataDir, 'hub.key');
53
+ this.envKey = opts.envKey;
54
+ }
55
+ /** Resolve the master key and decrypt the data file (a missing file means a fresh hub). */
56
+ async init() {
57
+ this.key = await loadMasterKey({ envKey: this.envKey, keyFile: this.keyFile });
58
+ let blob;
59
+ try {
60
+ blob = await readFile(this.dataFile, 'utf8');
61
+ }
62
+ catch (err) {
63
+ if (err.code === 'ENOENT')
64
+ return;
65
+ throw err;
66
+ }
67
+ this.doc = JSON.parse(decryptDoc(blob, this.key));
68
+ }
69
+ /** Encrypt the in-memory document and atomically replace the data file (mode 0600). */
70
+ async save() {
71
+ await mkdir(this.dataDir, { recursive: true });
72
+ const blob = encryptDoc(JSON.stringify(this.doc), this.key);
73
+ // Unique tmp name per save: concurrent saves must never share one file.
74
+ const tmp = `${this.dataFile}.tmp-${randomUUID()}`;
75
+ // fsync the payload before the rename so a power cut cannot leave a
76
+ // zero-length or stale-bytes data file behind the new name.
77
+ const fh = await open(tmp, 'w', 0o600);
78
+ try {
79
+ await fh.writeFile(blob);
80
+ await fh.sync();
81
+ }
82
+ finally {
83
+ await fh.close();
84
+ }
85
+ await rename(tmp, this.dataFile);
86
+ await chmod(this.dataFile, 0o600);
87
+ // fsync the directory so the rename itself is durable.
88
+ const dh = await open(this.dataDir, 'r');
89
+ try {
90
+ await dh.sync();
91
+ }
92
+ finally {
93
+ await dh.close();
94
+ }
95
+ }
96
+ list() {
97
+ return Object.values(this.doc.entries);
98
+ }
99
+ getEntry(kind, name) {
100
+ return this.doc.entries[`${kind}/${name}`];
101
+ }
102
+ /** Upsert one tier of an entry; `envelope` replaces the entry envelope wholesale when given. */
103
+ putTier(kind, name, tier, data) {
104
+ const key = `${kind}/${name}`;
105
+ const entry = this.doc.entries[key] ?? { kind, name, envelope: {}, tiers: {}, updatedAt: '' };
106
+ if (data.envelope !== undefined)
107
+ entry.envelope = data.envelope;
108
+ const tierData = { fields: data.fields };
109
+ if (data.probe !== undefined)
110
+ tierData.probe = data.probe;
111
+ entry.tiers[tier] = tierData;
112
+ entry.updatedAt = new Date().toISOString();
113
+ this.doc.entries[key] = entry;
114
+ return entry;
115
+ }
116
+ /** Delete one tier; removes the whole entry when its last tier goes. Returns false when absent. */
117
+ deleteTier(kind, name, tier) {
118
+ const key = `${kind}/${name}`;
119
+ const entry = this.doc.entries[key];
120
+ if (!entry || !entry.tiers[tier])
121
+ return false;
122
+ delete entry.tiers[tier];
123
+ if (!entry.tiers.ro && !entry.tiers.rw)
124
+ delete this.doc.entries[key];
125
+ else
126
+ entry.updatedAt = new Date().toISOString();
127
+ return true;
128
+ }
129
+ /** The requests map, created lazily (old data files predate the approval flow). */
130
+ requests() {
131
+ return (this.doc.requests ??= {});
132
+ }
133
+ /** Queue a tier-registration request; returns the generated id. */
134
+ putRequest(data) {
135
+ const request = {
136
+ id: randomUUID(),
137
+ kind: data.kind,
138
+ name: data.name,
139
+ tier: data.tier,
140
+ fields: data.fields,
141
+ envelope: data.envelope,
142
+ ...(data.reason !== undefined ? { reason: data.reason } : {}),
143
+ status: 'pending',
144
+ createdAt: new Date().toISOString(),
145
+ };
146
+ this.requests()[request.id] = request;
147
+ return request;
148
+ }
149
+ listRequests(status) {
150
+ const all = Object.values(this.requests());
151
+ return status === undefined ? all : all.filter((r) => r.status === status);
152
+ }
153
+ getRequest(id) {
154
+ return this.requests()[id];
155
+ }
156
+ /**
157
+ * Settle a pending request. On approval the tier is written through
158
+ * `putTier`. Either way the request's `fields` are wiped — secret material
159
+ * must not linger in a decided record. Returns null when absent or not
160
+ * pending.
161
+ */
162
+ decideRequest(id, approved) {
163
+ const request = this.requests()[id];
164
+ if (!request || request.status !== 'pending')
165
+ return null;
166
+ if (approved) {
167
+ this.putTier(request.kind, request.name, request.tier, { fields: request.fields, envelope: request.envelope });
168
+ }
169
+ request.status = approved ? 'approved' : 'rejected';
170
+ request.decidedAt = new Date().toISOString();
171
+ request.fields = {};
172
+ return request;
173
+ }
174
+ /** Append one audit line. Field values are never recorded. */
175
+ async audit(role, action, kind, name, tier) {
176
+ const record = { ts: new Date().toISOString(), role, action, kind, name, tier };
177
+ await mkdir(this.dataDir, { recursive: true });
178
+ // A crash mid-append can leave a torn tail line without a newline; a
179
+ // naive append would fuse the next record onto it and lose both. Start
180
+ // a fresh line when the file does not end with one.
181
+ let prefix = '';
182
+ try {
183
+ const fh = await open(this.auditFile, 'r');
184
+ try {
185
+ const { size } = await fh.stat();
186
+ if (size > 0) {
187
+ const last = Buffer.alloc(1);
188
+ await fh.read(last, 0, 1, size - 1);
189
+ if (last[0] !== 0x0a)
190
+ prefix = '\n';
191
+ }
192
+ }
193
+ finally {
194
+ await fh.close();
195
+ }
196
+ }
197
+ catch (err) {
198
+ if (err.code !== 'ENOENT')
199
+ throw err;
200
+ }
201
+ await appendFile(this.auditFile, prefix + JSON.stringify(record) + '\n', { mode: 0o600 });
202
+ }
203
+ /** Read the most recent `limit` audit records, oldest first. */
204
+ async readAudit(limit) {
205
+ let text;
206
+ try {
207
+ text = await readFile(this.auditFile, 'utf8');
208
+ }
209
+ catch (err) {
210
+ if (err.code === 'ENOENT')
211
+ return [];
212
+ throw err;
213
+ }
214
+ // Tolerate a torn final line (crash mid-append): skip unparseable
215
+ // records instead of poisoning every future read.
216
+ const records = text
217
+ .split('\n')
218
+ .filter((line) => line.trim() !== '')
219
+ .flatMap((line) => {
220
+ try {
221
+ return [JSON.parse(line)];
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ });
227
+ return records.slice(-limit);
228
+ }
229
+ }