@elinpf/dsh-ops-access-hub 0.3.0 → 0.4.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 CHANGED
@@ -28,16 +28,33 @@
28
28
  * read token)
29
29
  * - `POST /cases/:id/hit` → bump a case's hit count (read+)
30
30
  * - `DELETE /cases/:id` → remove a case (admin)
31
+ * - `GET /whoami` → `{ok,role,actor,source}` for the presented
32
+ * token (read+)
33
+ * - `POST /tokens` → issue a named token, body `{name,role,expiresAt?}`;
34
+ * the plaintext is in this response only (admin)
35
+ * - `GET /tokens` → issued-token roster, metadata only — never
36
+ * the digest or the plaintext (admin)
37
+ * - `PATCH /tokens/:id` → edit a live token's `{name?,role?,expiresAt?}`
38
+ * (`null`/`''` clears the expiry); 404 unknown,
39
+ * 409 revoked or label taken (admin)
40
+ * - `DELETE /tokens/:id` → revoke a named token; 404 unknown, 409 already
41
+ * revoked (admin)
31
42
  *
32
- * Auth: two Bearer tokens admin (everything) and read (`GET /entries*`
33
- * only). Comparisons use `crypto.timingSafeEqual`. Every error response is
34
- * JSON `{ok:false,error}` and `error` never contains field values.
43
+ * Auth: `Authorization: Bearer <token>`, compared with `crypto.timingSafeEqual`.
44
+ * Two roles — admin (everything) and read (resolving, case writes, the
45
+ * requests list). A token is either one of the two static bootstrap tokens
46
+ * (CLI flag / env, always accepted, the break-glass path) or a named token
47
+ * issued through `POST /tokens` (ADR-0009): independently revocable,
48
+ * optionally expiring, and recorded as the audit `actor`. Named tokens are
49
+ * stored as SHA-256 digests only. Every error response is JSON
50
+ * `{ok:false,error}` and `error` never contains field values.
35
51
  *
36
52
  * @module
37
53
  */
38
54
  import { timingSafeEqual } from 'node:crypto';
39
55
  import { createServer } from 'node:http';
40
56
  import { NAME_PATTERN } from './store.js';
57
+ import { generateToken, hashToken, parseExpiresAt, parseExpiresAtPatch, parseTokenName, parseTokenRole, toTokenView, tokenPrefix, } from './tokens.js';
41
58
  import { WEB_UI_HTML } from './web.js';
42
59
  export { NAME_PATTERN };
43
60
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
@@ -58,17 +75,38 @@ function tokenEqual(a, b) {
58
75
  return false;
59
76
  return timingSafeEqual(ba, bb);
60
77
  }
61
- function roleOf(req, opts) {
78
+ /** The presented Bearer token, or null when the header is absent/malformed. */
79
+ function bearerToken(req) {
62
80
  const header = req.headers.authorization;
63
81
  if (!header || !header.startsWith('Bearer '))
64
82
  return null;
65
- const token = header.slice('Bearer '.length).trim();
83
+ return header.slice('Bearer '.length).trim();
84
+ }
85
+ /**
86
+ * Resolve the presented Bearer token to a principal, or null.
87
+ *
88
+ * Static bootstrap tokens win first (a plain constant-time string compare);
89
+ * otherwise the digest is matched against the named-token roster. A token
90
+ * whose only match is revoked or expired is *not* a principal — it fails
91
+ * authentication like any unknown token.
92
+ */
93
+ function principalOf(req, opts) {
94
+ const token = bearerToken(req);
95
+ if (token === null)
96
+ return null;
66
97
  if (tokenEqual(token, opts.adminToken))
67
- return 'admin';
98
+ return { role: 'admin', actor: 'admin', source: 'static' };
68
99
  if (tokenEqual(token, opts.readToken))
69
- return 'read';
100
+ return { role: 'read', actor: 'read', source: 'static' };
101
+ const named = opts.store.findActiveTokenByHash(hashToken(token));
102
+ if (named)
103
+ return { role: named.role, actor: named.name, source: 'named' };
70
104
  return null;
71
105
  }
106
+ /** The audit `actor` of a principal: named tokens only (see `Principal`). */
107
+ function actorOf(principal) {
108
+ return principal.source === 'named' ? principal.actor : undefined;
109
+ }
72
110
  function send(res, status, body) {
73
111
  const text = JSON.stringify(body);
74
112
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
@@ -194,6 +232,28 @@ function sanitizeCaseInput(raw, partial) {
194
232
  }
195
233
  return out;
196
234
  }
235
+ /**
236
+ * Validate a `PATCH /tokens/:id` body into a `TokenPatch` (ADR-0010). Keeps
237
+ * "field absent" (keep) distinct from `expiresAt: null`/`''` (clear) and
238
+ * refuses an empty patch — a body with nothing to change is a caller error,
239
+ * not a silent 200. Label clashes are a conflict (409), so they are checked by
240
+ * the route, not here (which maps everything to 400).
241
+ */
242
+ function parseTokenPatch(raw) {
243
+ if (!isPlainObject(raw))
244
+ throw new Error('request body must be a JSON object');
245
+ const patch = {};
246
+ if (raw.name !== undefined)
247
+ patch.name = parseTokenName(raw.name);
248
+ if (raw.role !== undefined)
249
+ patch.role = parseTokenRole(raw.role);
250
+ if (raw.expiresAt !== undefined)
251
+ patch.expiresAt = parseExpiresAtPatch(raw.expiresAt) ?? null;
252
+ if (patch.name === undefined && patch.role === undefined && patch.expiresAt === undefined) {
253
+ throw new Error('no updatable fields (name, role, expiresAt)');
254
+ }
255
+ return patch;
256
+ }
197
257
  export function createHubServer(opts) {
198
258
  const { store } = opts;
199
259
  async function handle(req, res) {
@@ -208,9 +268,18 @@ export function createHubServer(opts) {
208
268
  res.end(WEB_UI_HTML);
209
269
  return;
210
270
  }
211
- const role = roleOf(req, opts);
212
- if (!role)
213
- throw new HttpError(401, 'missing or invalid bearer token');
271
+ const principal = principalOf(req, opts);
272
+ if (!principal) {
273
+ // A digest that matches a revoked/expired record deserves a clearer
274
+ // message than an outright unknown token (the holder already has it).
275
+ const token = bearerToken(req);
276
+ const known = token !== null && opts.store.hasTokenHash(hashToken(token));
277
+ throw new HttpError(401, known ? 'token revoked or expired' : 'missing or invalid bearer token');
278
+ }
279
+ const actor = actorOf(principal);
280
+ if (method === 'GET' && path === '/whoami') {
281
+ return send(res, 200, { ok: true, role: principal.role, actor: principal.actor, source: principal.source });
282
+ }
214
283
  if (method === 'GET' && path === '/entries') {
215
284
  // Listing never exposes field values — tiers carry only probe state.
216
285
  const list = store.list().map((e) => ({
@@ -226,8 +295,8 @@ export function createHubServer(opts) {
226
295
  return send(res, 200, list);
227
296
  }
228
297
  if (method === 'GET' && path === '/audit') {
229
- if (role !== 'admin')
230
- throw new HttpError(403, 'read token cannot access admin endpoints');
298
+ if (principal.role !== 'admin')
299
+ throw new HttpError(403, 'read role cannot access admin endpoints');
231
300
  const raw = url.searchParams.get('limit');
232
301
  let limit = 100;
233
302
  if (raw !== null) {
@@ -239,6 +308,103 @@ export function createHubServer(opts) {
239
308
  return send(res, 200, await store.readAudit(limit));
240
309
  }
241
310
  const parts = path.split('/').filter((p) => p !== '');
311
+ if (parts[0] === 'tokens' && parts.length === 1) {
312
+ if (principal.role !== 'admin')
313
+ throw new HttpError(403, 'read role cannot access admin endpoints');
314
+ if (method === 'GET') {
315
+ // Roster metadata only — the digest never leaves the store, and the
316
+ // plaintext cannot be reconstructed from anything here.
317
+ return send(res, 200, store.listTokens().map(toTokenView));
318
+ }
319
+ if (method === 'POST') {
320
+ const body = await readBody(req);
321
+ let name;
322
+ let role;
323
+ let expiresAt;
324
+ try {
325
+ name = parseTokenName(body.name);
326
+ role = parseTokenRole(body.role);
327
+ expiresAt = parseExpiresAt(body.expiresAt);
328
+ }
329
+ catch (err) {
330
+ throw new HttpError(400, err.message);
331
+ }
332
+ if (store.findTokenByName(name))
333
+ throw new HttpError(409, `token name '${name}' is already in use`);
334
+ // Mint, store only the digest, and hand the plaintext back exactly once.
335
+ const plaintext = generateToken();
336
+ let issued;
337
+ try {
338
+ issued = store.putToken({
339
+ name,
340
+ role,
341
+ hash: hashToken(plaintext),
342
+ prefix: tokenPrefix(plaintext),
343
+ createdBy: principal.actor,
344
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
345
+ });
346
+ }
347
+ catch (err) {
348
+ throw new HttpError(400, err.message);
349
+ }
350
+ await store.save();
351
+ await store.auditToken(principal.role, 'token-create', issued, actor);
352
+ return send(res, 200, { ok: true, ...toTokenView(issued), token: plaintext });
353
+ }
354
+ throw new HttpError(405, 'method not allowed');
355
+ }
356
+ if (parts[0] === 'tokens' && parts.length === 2) {
357
+ if (principal.role !== 'admin')
358
+ throw new HttpError(403, 'read role cannot access admin endpoints');
359
+ const token = store.getToken(parts[1]);
360
+ if (method === 'PATCH') {
361
+ // Edit in place (ADR-0010): a holder whose label, role or expiry is
362
+ // wrong gets a correction, not a revoke-and-reissue.
363
+ if (!token)
364
+ throw new HttpError(404, 'token not found');
365
+ if (token.revokedAt !== undefined) {
366
+ throw new HttpError(409, 'token is revoked and can no longer be edited; issue a new one');
367
+ }
368
+ const body = await readBody(req);
369
+ let patch;
370
+ try {
371
+ patch = parseTokenPatch(body);
372
+ }
373
+ catch (err) {
374
+ throw new HttpError(400, err.message);
375
+ }
376
+ // A label another live token holds is a conflict, not a bad request.
377
+ const holder = patch.name === undefined ? undefined : store.findTokenByName(patch.name);
378
+ if (holder && holder.id !== token.id) {
379
+ throw new HttpError(409, `token name '${patch.name}' is already in use`);
380
+ }
381
+ let updated;
382
+ try {
383
+ updated = store.updateToken(token.id, patch);
384
+ }
385
+ catch (err) {
386
+ throw new HttpError(409, err.message);
387
+ }
388
+ if (!updated)
389
+ throw new HttpError(404, 'token not found');
390
+ // A patch that matched the current values is a no-op: no write, no
391
+ // audit noise (the roster rewrite costs a full re-encrypt).
392
+ if (updated.changes.length > 0) {
393
+ await store.save();
394
+ await store.auditToken(principal.role, 'token-update', updated.token, actor, updated.changes);
395
+ }
396
+ return send(res, 200, { ok: true, ...toTokenView(updated.token), changes: updated.changes });
397
+ }
398
+ if (method !== 'DELETE')
399
+ throw new HttpError(405, 'method not allowed');
400
+ if (!token)
401
+ throw new HttpError(404, 'token not found');
402
+ if (!store.revokeToken(parts[1]))
403
+ throw new HttpError(409, 'token already revoked');
404
+ await store.save();
405
+ await store.auditToken(principal.role, 'token-revoke', token, actor);
406
+ return send(res, 200, { ok: true });
407
+ }
242
408
  if (parts[0] === 'requests' && parts.length === 1) {
243
409
  if (method === 'GET') {
244
410
  // Metadata only — the reviewer fetches values per request (admin).
@@ -260,8 +426,8 @@ export function createHubServer(opts) {
260
426
  }));
261
427
  return send(res, 200, list);
262
428
  }
263
- if (role !== 'admin')
264
- throw new HttpError(403, 'read token cannot access admin endpoints');
429
+ if (principal.role !== 'admin')
430
+ throw new HttpError(403, 'read role cannot access admin endpoints');
265
431
  if (method === 'POST') {
266
432
  const body = await readBody(req);
267
433
  const kind = segment(String(body.kind ?? ''), 'kind');
@@ -274,23 +440,23 @@ export function createHubServer(opts) {
274
440
  throw new HttpError(400, 'reason must be a string');
275
441
  const request = store.putRequest({ kind, name, tier, fields: body.fields, envelope, reason: body.reason });
276
442
  await store.save();
277
- await store.audit(role, 'request', kind, name, tier);
443
+ await store.audit(principal.role, 'request', kind, name, tier, actor);
278
444
  return send(res, 200, { ok: true, id: request.id });
279
445
  }
280
446
  throw new HttpError(405, 'method not allowed');
281
447
  }
282
448
  if (parts[0] === 'requests' && parts.length === 2 && method === 'GET') {
283
449
  // Full field values for pre-approval review — admin only.
284
- if (role !== 'admin')
285
- throw new HttpError(403, 'read token cannot review request contents');
450
+ if (principal.role !== 'admin')
451
+ throw new HttpError(403, 'read role cannot review request contents');
286
452
  const request = store.getRequest(parts[1]);
287
453
  if (!request)
288
454
  throw new HttpError(404, 'request not found');
289
455
  return send(res, 200, request);
290
456
  }
291
457
  if (parts[0] === 'requests' && parts.length === 3 && parts[2] === 'decide') {
292
- if (role !== 'admin')
293
- throw new HttpError(403, 'read token cannot access admin endpoints');
458
+ if (principal.role !== 'admin')
459
+ throw new HttpError(403, 'read role cannot access admin endpoints');
294
460
  if (method !== 'POST')
295
461
  throw new HttpError(405, 'method not allowed');
296
462
  const body = await readBody(req);
@@ -304,7 +470,7 @@ export function createHubServer(opts) {
304
470
  : new HttpError(404, 'request not found');
305
471
  }
306
472
  await store.save();
307
- await store.audit(role, body.approved ? 'approve' : 'reject', request.kind, request.name, request.tier);
473
+ await store.audit(principal.role, body.approved ? 'approve' : 'reject', request.kind, request.name, request.tier, actor);
308
474
  return send(res, 200, { ok: true });
309
475
  }
310
476
  if (parts[0] === 'cases' && parts.length === 1) {
@@ -324,7 +490,7 @@ export function createHubServer(opts) {
324
490
  throw new HttpError(400, err.message);
325
491
  }
326
492
  await store.save();
327
- await store.auditCase(role, 'case-put', record);
493
+ await store.auditCase(principal.role, 'case-put', record, actor);
328
494
  return send(res, 200, { ok: true, id: record.id });
329
495
  }
330
496
  throw new HttpError(405, 'method not allowed');
@@ -343,17 +509,17 @@ export function createHubServer(opts) {
343
509
  if (!record)
344
510
  throw new HttpError(404, 'case not found');
345
511
  await store.save();
346
- await store.auditCase(role, 'case-put', record);
512
+ await store.auditCase(principal.role, 'case-put', record, actor);
347
513
  return send(res, 200, { ok: true });
348
514
  }
349
515
  if (method === 'DELETE') {
350
- if (role !== 'admin')
351
- throw new HttpError(403, 'read token cannot access admin endpoints');
516
+ if (principal.role !== 'admin')
517
+ throw new HttpError(403, 'read role cannot access admin endpoints');
352
518
  const existing = store.getCase(id);
353
519
  if (!existing || !store.deleteCase(id))
354
520
  throw new HttpError(404, 'case not found');
355
521
  await store.save();
356
- await store.auditCase(role, 'case-delete', existing);
522
+ await store.auditCase(principal.role, 'case-delete', existing, actor);
357
523
  return send(res, 200, { ok: true });
358
524
  }
359
525
  throw new HttpError(405, 'method not allowed');
@@ -365,7 +531,7 @@ export function createHubServer(opts) {
365
531
  if (!record || !store.hitCase(parts[1]))
366
532
  throw new HttpError(404, 'case not found');
367
533
  await store.save();
368
- await store.auditCase(role, 'case-hit', record);
534
+ await store.auditCase(principal.role, 'case-hit', record, actor);
369
535
  return send(res, 200, { ok: true });
370
536
  }
371
537
  if (parts[0] === 'entries' && parts.length === 4) {
@@ -377,7 +543,7 @@ export function createHubServer(opts) {
377
543
  const tierData = entry?.tiers[tier];
378
544
  if (!entry || !tierData)
379
545
  throw new HttpError(404, 'entry not found');
380
- await store.audit(role, 'resolve', kind, name, tier);
546
+ await store.audit(principal.role, 'resolve', kind, name, tier, actor);
381
547
  return send(res, 200, {
382
548
  kind,
383
549
  name,
@@ -387,8 +553,8 @@ export function createHubServer(opts) {
387
553
  ...(tierData.probe ? { probe: tierData.probe } : {}),
388
554
  });
389
555
  }
390
- if (role !== 'admin')
391
- throw new HttpError(403, 'read token cannot access admin endpoints');
556
+ if (principal.role !== 'admin')
557
+ throw new HttpError(403, 'read role cannot access admin endpoints');
392
558
  if (method === 'PUT') {
393
559
  const body = await readBody(req);
394
560
  if (!isPlainObject(body.fields))
@@ -397,14 +563,14 @@ export function createHubServer(opts) {
397
563
  const probe = body.probe === undefined ? undefined : sanitizeProbe(body.probe);
398
564
  store.putTier(kind, name, tier, { fields: body.fields, envelope, probe });
399
565
  await store.save();
400
- await store.audit(role, 'put', kind, name, tier);
566
+ await store.audit(principal.role, 'put', kind, name, tier, actor);
401
567
  return send(res, 200, { ok: true });
402
568
  }
403
569
  if (method === 'DELETE') {
404
570
  if (!store.deleteTier(kind, name, tier))
405
571
  throw new HttpError(404, 'entry not found');
406
572
  await store.save();
407
- await store.audit(role, 'delete', kind, name, tier);
573
+ await store.audit(principal.role, 'delete', kind, name, tier, actor);
408
574
  return send(res, 200, { ok: true });
409
575
  }
410
576
  throw new HttpError(405, 'method not allowed');
package/lib/store.d.ts CHANGED
@@ -19,7 +19,10 @@
19
19
  * "requests": { "<uuid>": { "kind": "...", "name": "...", "tier": "rw",
20
20
  * "fields": { ... }, "status": "pending", ... } },
21
21
  * "cases": { "<uuid>": { "title": "...", "symptoms": [...],
22
- * "rootCause": "...", "fix": "...", "hitCount": 0, ... } } }
22
+ * "rootCause": "...", "fix": "...", "hitCount": 0, ... } },
23
+ * "tokens": { "<uuid>": { "name": "alice", "role": "read",
24
+ * "hash": "<sha256 hex>", "prefix": "AbCdEfGh", "createdAt": "<ISO>",
25
+ * "createdBy": "admin", "expiresAt": "<ISO>" } } }
23
26
  * ```
24
27
  *
25
28
  * `requests` is the agent-registration approval queue (see server.ts
@@ -30,6 +33,11 @@
30
33
  * routes): distilled postmortems an agent records after an investigation
31
34
  * resolves, searchable by later sessions. Cases hold no secret material.
32
35
  *
36
+ * `tokens` is the named-token roster (ADR-0009, see tokens.ts): every issued
37
+ * credential keeps its label, role, digest and lifecycle timestamps — never
38
+ * the plaintext, which exists only in the create response. The static
39
+ * bootstrap tokens are env/flag configuration and have no record here.
40
+ *
33
41
  * The hub is dumb storage: file fields hold their *content* (inlined at
34
42
  * import time) and no kind-specific schema validation happens here.
35
43
  *
@@ -39,6 +47,7 @@
39
47
  *
40
48
  * @module
41
49
  */
50
+ import type { HubToken, TokenChange, TokenPatch, TokenRole } from './tokens.js';
42
51
  export type TierName = 'ro' | 'rw';
43
52
  /** Profile name / kind charset; kinds additionally can never contain `/` (path segment). Shared by the HTTP surface and the offline importer. */
44
53
  export declare const NAME_PATTERN: RegExp;
@@ -113,13 +122,24 @@ export type CaseInput = Partial<Omit<CaseRecord, 'id' | 'hitCount' | 'createdAt'
113
122
  export interface AuditRecord {
114
123
  ts: string;
115
124
  role: 'admin' | 'read';
116
- action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject' | 'case-put' | 'case-hit' | 'case-delete';
125
+ action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject' | 'case-put' | 'case-hit' | 'case-delete' | 'token-create' | 'token-update' | 'token-revoke';
117
126
  kind: string;
118
127
  name: string;
119
128
  /** Absent on `case-*` actions (cases have no tiers). */
120
129
  tier?: TierName;
121
130
  /** Case title, recorded on `case-*` actions only. */
122
131
  title?: string;
132
+ /**
133
+ * Label of the named token that performed the action (ADR-0009). Absent for
134
+ * the static bootstrap tokens — their `role` field already says all there is
135
+ * to know about them.
136
+ */
137
+ actor?: string;
138
+ /**
139
+ * Which token fields an edit touched (`token-update` only) — the field
140
+ * names, never the values, so the line stays a metadata-only record.
141
+ */
142
+ changes?: TokenChange[];
123
143
  }
124
144
  export interface HubStoreOptions {
125
145
  dataDir: string;
@@ -186,13 +206,65 @@ export declare class HubStore {
186
206
  hitCase(id: string): boolean;
187
207
  /** Delete a case. Returns false when absent. */
188
208
  deleteCase(id: string): boolean;
209
+ /** The tokens map, created lazily (old data files predate named tokens). */
210
+ private tokens;
211
+ /** Every issued token record, revoked ones included (callers project to `TokenView`). */
212
+ listTokens(): HubToken[];
213
+ getToken(id: string): HubToken | undefined;
214
+ /**
215
+ * The first *non-revoked* token carrying this label, for uniqueness checks.
216
+ * A revoked label is reusable — the person it named is gone.
217
+ */
218
+ findTokenByName(name: string): HubToken | undefined;
219
+ /**
220
+ * The token a presented digest authenticates as, or undefined when no live
221
+ * token matches. Callers must reject the request either way — a digest that
222
+ * matches only revoked/expired records is an authentication failure.
223
+ */
224
+ findActiveTokenByHash(hash: string, now?: string): HubToken | undefined;
225
+ /** Whether a digest belongs to any record at all (live or not), for error wording only. */
226
+ hasTokenHash(hash: string): boolean;
227
+ /**
228
+ * Record one issued token. The caller mints the plaintext and passes only
229
+ * its digest + prefix; uniqueness of `name` and validation of the parsed
230
+ * inputs are the caller's job. Throws when the roster is at MAX_TOKENS.
231
+ */
232
+ putToken(data: {
233
+ name: string;
234
+ role: TokenRole;
235
+ hash: string;
236
+ prefix: string;
237
+ createdBy: string;
238
+ expiresAt?: string;
239
+ }): HubToken;
240
+ /**
241
+ * Revoke one token. Returns false when the id is unknown or the token is
242
+ * already revoked — revocation is a one-way, terminal state.
243
+ */
244
+ revokeToken(id: string): boolean;
245
+ /**
246
+ * Apply an operator edit to a live token (ADR-0010). Returns the record plus
247
+ * the fields that actually changed — an empty list means the patch matched
248
+ * the current values, so the caller can skip both the save and the audit
249
+ * line. Throws on a revoked record or a label clash; `null` when the id is
250
+ * unknown. Validation of the patch values is the caller's job.
251
+ */
252
+ updateToken(id: string, patch: TokenPatch): {
253
+ token: HubToken;
254
+ changes: TokenChange[];
255
+ } | null;
256
+ /** Append one audit line for a token-roster action (kind fixed to 'token', name = token label). */
257
+ auditToken(role: AuditRecord['role'], action: 'token-create' | 'token-update' | 'token-revoke', token: {
258
+ id: string;
259
+ name: string;
260
+ }, actor?: string, changes?: TokenChange[]): Promise<void>;
189
261
  /** Append one audit line for a case action (kind fixed to 'case', name = case id). */
190
262
  auditCase(role: AuditRecord['role'], action: 'case-put' | 'case-hit' | 'case-delete', record: {
191
263
  id: string;
192
264
  title: string;
193
- }): Promise<void>;
194
- /** Append one audit line. Field values are never recorded. */
195
- audit(role: AuditRecord['role'], action: AuditRecord['action'], kind: string, name: string, tier: TierName): Promise<void>;
265
+ }, actor?: string): Promise<void>;
266
+ /** Append one audit line. Field values are never recorded; `actor` names the (named) token used. */
267
+ audit(role: AuditRecord['role'], action: AuditRecord['action'], kind: string, name: string, tier: TierName, actor?: string): Promise<void>;
196
268
  private appendAudit;
197
269
  /** Read the most recent `limit` audit records, oldest first. */
198
270
  readAudit(limit: number): Promise<AuditRecord[]>;
package/lib/store.js CHANGED
@@ -19,7 +19,10 @@
19
19
  * "requests": { "<uuid>": { "kind": "...", "name": "...", "tier": "rw",
20
20
  * "fields": { ... }, "status": "pending", ... } },
21
21
  * "cases": { "<uuid>": { "title": "...", "symptoms": [...],
22
- * "rootCause": "...", "fix": "...", "hitCount": 0, ... } } }
22
+ * "rootCause": "...", "fix": "...", "hitCount": 0, ... } },
23
+ * "tokens": { "<uuid>": { "name": "alice", "role": "read",
24
+ * "hash": "<sha256 hex>", "prefix": "AbCdEfGh", "createdAt": "<ISO>",
25
+ * "createdBy": "admin", "expiresAt": "<ISO>" } } }
23
26
  * ```
24
27
  *
25
28
  * `requests` is the agent-registration approval queue (see server.ts
@@ -30,6 +33,11 @@
30
33
  * routes): distilled postmortems an agent records after an investigation
31
34
  * resolves, searchable by later sessions. Cases hold no secret material.
32
35
  *
36
+ * `tokens` is the named-token roster (ADR-0009, see tokens.ts): every issued
37
+ * credential keeps its label, role, digest and lifecycle timestamps — never
38
+ * the plaintext, which exists only in the create response. The static
39
+ * bootstrap tokens are env/flag configuration and have no record here.
40
+ *
33
41
  * The hub is dumb storage: file fields hold their *content* (inlined at
34
42
  * import time) and no kind-specific schema validation happens here.
35
43
  *
@@ -43,6 +51,7 @@ import { appendFile, chmod, mkdir, open, readFile, rename } from 'node:fs/promis
43
51
  import { join } from 'node:path';
44
52
  import { randomUUID } from 'node:crypto';
45
53
  import { decryptDoc, encryptDoc, loadMasterKey } from './crypto.js';
54
+ import { hashEqual, isTokenActive, MAX_TOKENS } from './tokens.js';
46
55
  /** Profile name / kind charset; kinds additionally can never contain `/` (path segment). Shared by the HTTP surface and the offline importer. */
47
56
  export const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@-]*$/;
48
57
  /** Hard caps on the knowledge base, enforced by the store (it owns the doc). */
@@ -250,13 +259,134 @@ export class HubStore {
250
259
  delete this.cases()[id];
251
260
  return true;
252
261
  }
262
+ /** The tokens map, created lazily (old data files predate named tokens). */
263
+ tokens() {
264
+ return (this.doc.tokens ??= {});
265
+ }
266
+ /** Every issued token record, revoked ones included (callers project to `TokenView`). */
267
+ listTokens() {
268
+ return Object.values(this.tokens());
269
+ }
270
+ getToken(id) {
271
+ return this.tokens()[id];
272
+ }
273
+ /**
274
+ * The first *non-revoked* token carrying this label, for uniqueness checks.
275
+ * A revoked label is reusable — the person it named is gone.
276
+ */
277
+ findTokenByName(name) {
278
+ return Object.values(this.tokens()).find((t) => t.revokedAt === undefined && t.name === name);
279
+ }
280
+ /**
281
+ * The token a presented digest authenticates as, or undefined when no live
282
+ * token matches. Callers must reject the request either way — a digest that
283
+ * matches only revoked/expired records is an authentication failure.
284
+ */
285
+ findActiveTokenByHash(hash, now) {
286
+ return Object.values(this.tokens()).find((t) => isTokenActive(t, now) && hashEqual(t.hash, hash));
287
+ }
288
+ /** Whether a digest belongs to any record at all (live or not), for error wording only. */
289
+ hasTokenHash(hash) {
290
+ return Object.values(this.tokens()).some((t) => hashEqual(t.hash, hash));
291
+ }
292
+ /**
293
+ * Record one issued token. The caller mints the plaintext and passes only
294
+ * its digest + prefix; uniqueness of `name` and validation of the parsed
295
+ * inputs are the caller's job. Throws when the roster is at MAX_TOKENS.
296
+ */
297
+ putToken(data) {
298
+ if (Object.keys(this.tokens()).length >= MAX_TOKENS) {
299
+ throw new Error(`token roster is full (${MAX_TOKENS} tokens); revoke stale tokens first`);
300
+ }
301
+ const token = {
302
+ id: randomUUID(),
303
+ name: data.name,
304
+ role: data.role,
305
+ hash: data.hash,
306
+ prefix: data.prefix,
307
+ createdAt: new Date().toISOString(),
308
+ createdBy: data.createdBy,
309
+ ...(data.expiresAt !== undefined ? { expiresAt: data.expiresAt } : {}),
310
+ };
311
+ this.tokens()[token.id] = token;
312
+ return token;
313
+ }
314
+ /**
315
+ * Revoke one token. Returns false when the id is unknown or the token is
316
+ * already revoked — revocation is a one-way, terminal state.
317
+ */
318
+ revokeToken(id) {
319
+ const token = this.tokens()[id];
320
+ if (!token || token.revokedAt !== undefined)
321
+ return false;
322
+ token.revokedAt = new Date().toISOString();
323
+ return true;
324
+ }
325
+ /**
326
+ * Apply an operator edit to a live token (ADR-0010). Returns the record plus
327
+ * the fields that actually changed — an empty list means the patch matched
328
+ * the current values, so the caller can skip both the save and the audit
329
+ * line. Throws on a revoked record or a label clash; `null` when the id is
330
+ * unknown. Validation of the patch values is the caller's job.
331
+ */
332
+ updateToken(id, patch) {
333
+ const token = this.tokens()[id];
334
+ if (!token)
335
+ return null;
336
+ if (token.revokedAt !== undefined)
337
+ throw new Error('token is revoked and can no longer be edited');
338
+ const changes = [];
339
+ if (patch.name !== undefined && patch.name !== token.name) {
340
+ const clash = Object.values(this.tokens()).find((t) => t.id !== id && t.revokedAt === undefined && t.name === patch.name);
341
+ if (clash)
342
+ throw new Error(`token name '${patch.name}' is already in use`);
343
+ token.name = patch.name;
344
+ changes.push('name');
345
+ }
346
+ if (patch.role !== undefined && patch.role !== token.role) {
347
+ token.role = patch.role;
348
+ changes.push('role');
349
+ }
350
+ if (patch.expiresAt !== undefined) {
351
+ // null = clear the expiry (back to a never-expiring token).
352
+ const next = patch.expiresAt ?? undefined;
353
+ if (next !== token.expiresAt) {
354
+ if (next === undefined)
355
+ delete token.expiresAt;
356
+ else
357
+ token.expiresAt = next;
358
+ changes.push('expiresAt');
359
+ }
360
+ }
361
+ return { token, changes };
362
+ }
363
+ /** Append one audit line for a token-roster action (kind fixed to 'token', name = token label). */
364
+ async auditToken(role, action, token, actor, changes) {
365
+ await this.appendAudit({
366
+ ts: new Date().toISOString(),
367
+ role,
368
+ action,
369
+ kind: 'token',
370
+ name: token.name,
371
+ ...(actor !== undefined ? { actor } : {}),
372
+ ...(changes !== undefined ? { changes } : {}),
373
+ });
374
+ }
253
375
  /** Append one audit line for a case action (kind fixed to 'case', name = case id). */
254
- async auditCase(role, action, record) {
255
- await this.appendAudit({ ts: new Date().toISOString(), role, action, kind: 'case', name: record.id, title: record.title });
376
+ async auditCase(role, action, record, actor) {
377
+ await this.appendAudit({
378
+ ts: new Date().toISOString(),
379
+ role,
380
+ action,
381
+ kind: 'case',
382
+ name: record.id,
383
+ title: record.title,
384
+ ...(actor !== undefined ? { actor } : {}),
385
+ });
256
386
  }
257
- /** Append one audit line. Field values are never recorded. */
258
- async audit(role, action, kind, name, tier) {
259
- await this.appendAudit({ ts: new Date().toISOString(), role, action, kind, name, tier });
387
+ /** Append one audit line. Field values are never recorded; `actor` names the (named) token used. */
388
+ async audit(role, action, kind, name, tier, actor) {
389
+ await this.appendAudit({ ts: new Date().toISOString(), role, action, kind, name, tier, ...(actor !== undefined ? { actor } : {}) });
260
390
  }
261
391
  async appendAudit(record) {
262
392
  await mkdir(this.dataDir, { recursive: true });