@elinpf/dsh-ops-access-hub 0.2.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
@@ -20,18 +20,43 @@
20
20
  * - `POST /requests/:id/decide` → `{approved:boolean}`; approval writes the
21
21
  * tier, either way the request's fields are
22
22
  * wiped (admin; 409 unless pending)
23
+ * - `GET /cases` → case index rows, metadata only (read+)
24
+ * - `GET /cases/:id` → full case record (read+)
25
+ * - `POST /cases` / `PUT /cases/:id` → create / update a troubleshooting case
26
+ * (read+ — a deliberate relaxation: cases hold
27
+ * no secrets and the agent only carries the
28
+ * read token)
29
+ * - `POST /cases/:id/hit` → bump a case's hit count (read+)
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)
23
42
  *
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.
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.
27
51
  *
28
52
  * @module
29
53
  */
30
54
  import { timingSafeEqual } from 'node:crypto';
31
55
  import { createServer } from 'node:http';
56
+ import { NAME_PATTERN } from './store.js';
57
+ import { generateToken, hashToken, parseExpiresAt, parseExpiresAtPatch, parseTokenName, parseTokenRole, toTokenView, tokenPrefix, } from './tokens.js';
32
58
  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._@-]*$/;
59
+ export { NAME_PATTERN };
35
60
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
36
61
  class HttpError extends Error {
37
62
  status;
@@ -50,17 +75,38 @@ function tokenEqual(a, b) {
50
75
  return false;
51
76
  return timingSafeEqual(ba, bb);
52
77
  }
53
- function roleOf(req, opts) {
78
+ /** The presented Bearer token, or null when the header is absent/malformed. */
79
+ function bearerToken(req) {
54
80
  const header = req.headers.authorization;
55
81
  if (!header || !header.startsWith('Bearer '))
56
82
  return null;
57
- 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;
58
97
  if (tokenEqual(token, opts.adminToken))
59
- return 'admin';
98
+ return { role: 'admin', actor: 'admin', source: 'static' };
60
99
  if (tokenEqual(token, opts.readToken))
61
- 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' };
62
104
  return null;
63
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
+ }
64
110
  function send(res, status, body) {
65
111
  const text = JSON.stringify(body);
66
112
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
@@ -136,6 +182,78 @@ function tierOf(raw) {
136
182
  return raw;
137
183
  throw new HttpError(400, "tier must be 'ro' or 'rw'");
138
184
  }
185
+ /** One case record may not exceed this size once serialized (defense against an agent flooding the store). */
186
+ const MAX_CASE_BYTES = 32 * 1024;
187
+ const CASE_STRING_FIELDS = ['title', 'rootCause', 'fix', 'evidence', 'methodology', 'environment'];
188
+ const CASE_LIST_FIELDS = ['symptoms', 'tags'];
189
+ /**
190
+ * Validate a case write body. `partial` (PUT) requires at least one known
191
+ * field; otherwise (POST) title/rootCause/fix are required non-empty.
192
+ */
193
+ function sanitizeCaseInput(raw, partial) {
194
+ if (!isPlainObject(raw))
195
+ throw new HttpError(400, 'request body must be a JSON object');
196
+ const out = {};
197
+ for (const key of CASE_STRING_FIELDS) {
198
+ const value = raw[key];
199
+ if (value === undefined)
200
+ continue;
201
+ if (typeof value !== 'string')
202
+ throw new HttpError(400, `${key} must be a string`);
203
+ out[key] = value;
204
+ }
205
+ for (const key of CASE_LIST_FIELDS) {
206
+ const value = raw[key];
207
+ if (value === undefined)
208
+ continue;
209
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
210
+ throw new HttpError(400, `${key} must be an array of strings`);
211
+ }
212
+ out[key] = value;
213
+ }
214
+ if (raw.difficulty !== undefined) {
215
+ const d = raw.difficulty;
216
+ if (typeof d !== 'number' || !Number.isInteger(d) || d < 1 || d > 5) {
217
+ throw new HttpError(400, 'difficulty must be an integer between 1 and 5');
218
+ }
219
+ out.difficulty = d;
220
+ }
221
+ if (Object.keys(out).length === 0)
222
+ throw new HttpError(400, 'no case fields to write');
223
+ if (!partial) {
224
+ for (const key of ['title', 'rootCause', 'fix']) {
225
+ if (typeof out[key] !== 'string' || out[key].trim() === '') {
226
+ throw new HttpError(400, `${key} is required and must be non-empty`);
227
+ }
228
+ }
229
+ }
230
+ if (JSON.stringify(out).length > MAX_CASE_BYTES) {
231
+ throw new HttpError(400, `case exceeds ${MAX_CASE_BYTES} bytes`);
232
+ }
233
+ return out;
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
+ }
139
257
  export function createHubServer(opts) {
140
258
  const { store } = opts;
141
259
  async function handle(req, res) {
@@ -150,9 +268,18 @@ export function createHubServer(opts) {
150
268
  res.end(WEB_UI_HTML);
151
269
  return;
152
270
  }
153
- const role = roleOf(req, opts);
154
- if (!role)
155
- 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
+ }
156
283
  if (method === 'GET' && path === '/entries') {
157
284
  // Listing never exposes field values — tiers carry only probe state.
158
285
  const list = store.list().map((e) => ({
@@ -168,8 +295,8 @@ export function createHubServer(opts) {
168
295
  return send(res, 200, list);
169
296
  }
170
297
  if (method === 'GET' && path === '/audit') {
171
- if (role !== 'admin')
172
- 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');
173
300
  const raw = url.searchParams.get('limit');
174
301
  let limit = 100;
175
302
  if (raw !== null) {
@@ -181,6 +308,103 @@ export function createHubServer(opts) {
181
308
  return send(res, 200, await store.readAudit(limit));
182
309
  }
183
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
+ }
184
408
  if (parts[0] === 'requests' && parts.length === 1) {
185
409
  if (method === 'GET') {
186
410
  // Metadata only — the reviewer fetches values per request (admin).
@@ -202,8 +426,8 @@ export function createHubServer(opts) {
202
426
  }));
203
427
  return send(res, 200, list);
204
428
  }
205
- if (role !== 'admin')
206
- 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');
207
431
  if (method === 'POST') {
208
432
  const body = await readBody(req);
209
433
  const kind = segment(String(body.kind ?? ''), 'kind');
@@ -216,23 +440,23 @@ export function createHubServer(opts) {
216
440
  throw new HttpError(400, 'reason must be a string');
217
441
  const request = store.putRequest({ kind, name, tier, fields: body.fields, envelope, reason: body.reason });
218
442
  await store.save();
219
- await store.audit(role, 'request', kind, name, tier);
443
+ await store.audit(principal.role, 'request', kind, name, tier, actor);
220
444
  return send(res, 200, { ok: true, id: request.id });
221
445
  }
222
446
  throw new HttpError(405, 'method not allowed');
223
447
  }
224
448
  if (parts[0] === 'requests' && parts.length === 2 && method === 'GET') {
225
449
  // Full field values for pre-approval review — admin only.
226
- if (role !== 'admin')
227
- 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');
228
452
  const request = store.getRequest(parts[1]);
229
453
  if (!request)
230
454
  throw new HttpError(404, 'request not found');
231
455
  return send(res, 200, request);
232
456
  }
233
457
  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');
458
+ if (principal.role !== 'admin')
459
+ throw new HttpError(403, 'read role cannot access admin endpoints');
236
460
  if (method !== 'POST')
237
461
  throw new HttpError(405, 'method not allowed');
238
462
  const body = await readBody(req);
@@ -246,7 +470,68 @@ export function createHubServer(opts) {
246
470
  : new HttpError(404, 'request not found');
247
471
  }
248
472
  await store.save();
249
- 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);
474
+ return send(res, 200, { ok: true });
475
+ }
476
+ if (parts[0] === 'cases' && parts.length === 1) {
477
+ if (method === 'GET') {
478
+ // Index rows only — full text comes from GET /cases/:id.
479
+ return send(res, 200, store.listCases());
480
+ }
481
+ if (method === 'POST') {
482
+ // Deliberate role relaxation: cases hold no secrets, and the agent
483
+ // only carries the read token — read+ may write the knowledge base.
484
+ const input = sanitizeCaseInput(await readBody(req), false);
485
+ let record;
486
+ try {
487
+ record = store.putCase(input);
488
+ }
489
+ catch (err) {
490
+ throw new HttpError(400, err.message);
491
+ }
492
+ await store.save();
493
+ await store.auditCase(principal.role, 'case-put', record, actor);
494
+ return send(res, 200, { ok: true, id: record.id });
495
+ }
496
+ throw new HttpError(405, 'method not allowed');
497
+ }
498
+ if (parts[0] === 'cases' && parts.length === 2) {
499
+ const id = parts[1];
500
+ if (method === 'GET') {
501
+ const record = store.getCase(id);
502
+ if (!record)
503
+ throw new HttpError(404, 'case not found');
504
+ return send(res, 200, record);
505
+ }
506
+ if (method === 'PUT') {
507
+ const input = sanitizeCaseInput(await readBody(req), true);
508
+ const record = store.putCase(input, id);
509
+ if (!record)
510
+ throw new HttpError(404, 'case not found');
511
+ await store.save();
512
+ await store.auditCase(principal.role, 'case-put', record, actor);
513
+ return send(res, 200, { ok: true });
514
+ }
515
+ if (method === 'DELETE') {
516
+ if (principal.role !== 'admin')
517
+ throw new HttpError(403, 'read role cannot access admin endpoints');
518
+ const existing = store.getCase(id);
519
+ if (!existing || !store.deleteCase(id))
520
+ throw new HttpError(404, 'case not found');
521
+ await store.save();
522
+ await store.auditCase(principal.role, 'case-delete', existing, actor);
523
+ return send(res, 200, { ok: true });
524
+ }
525
+ throw new HttpError(405, 'method not allowed');
526
+ }
527
+ if (parts[0] === 'cases' && parts.length === 3 && parts[2] === 'hit') {
528
+ if (method !== 'POST')
529
+ throw new HttpError(405, 'method not allowed');
530
+ const record = store.getCase(parts[1]);
531
+ if (!record || !store.hitCase(parts[1]))
532
+ throw new HttpError(404, 'case not found');
533
+ await store.save();
534
+ await store.auditCase(principal.role, 'case-hit', record, actor);
250
535
  return send(res, 200, { ok: true });
251
536
  }
252
537
  if (parts[0] === 'entries' && parts.length === 4) {
@@ -258,7 +543,7 @@ export function createHubServer(opts) {
258
543
  const tierData = entry?.tiers[tier];
259
544
  if (!entry || !tierData)
260
545
  throw new HttpError(404, 'entry not found');
261
- await store.audit(role, 'resolve', kind, name, tier);
546
+ await store.audit(principal.role, 'resolve', kind, name, tier, actor);
262
547
  return send(res, 200, {
263
548
  kind,
264
549
  name,
@@ -268,8 +553,8 @@ export function createHubServer(opts) {
268
553
  ...(tierData.probe ? { probe: tierData.probe } : {}),
269
554
  });
270
555
  }
271
- if (role !== 'admin')
272
- 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');
273
558
  if (method === 'PUT') {
274
559
  const body = await readBody(req);
275
560
  if (!isPlainObject(body.fields))
@@ -278,14 +563,14 @@ export function createHubServer(opts) {
278
563
  const probe = body.probe === undefined ? undefined : sanitizeProbe(body.probe);
279
564
  store.putTier(kind, name, tier, { fields: body.fields, envelope, probe });
280
565
  await store.save();
281
- await store.audit(role, 'put', kind, name, tier);
566
+ await store.audit(principal.role, 'put', kind, name, tier, actor);
282
567
  return send(res, 200, { ok: true });
283
568
  }
284
569
  if (method === 'DELETE') {
285
570
  if (!store.deleteTier(kind, name, tier))
286
571
  throw new HttpError(404, 'entry not found');
287
572
  await store.save();
288
- await store.audit(role, 'delete', kind, name, tier);
573
+ await store.audit(principal.role, 'delete', kind, name, tier, actor);
289
574
  return send(res, 200, { ok: true });
290
575
  }
291
576
  throw new HttpError(405, 'method not allowed');
package/lib/store.d.ts CHANGED
@@ -17,13 +17,27 @@
17
17
  * "tiers": { "ro": { "fields": { ... }, "probe": { ... } }, "rw": { "fields": { ... } } },
18
18
  * "updatedAt": "<ISO>" } },
19
19
  * "requests": { "<uuid>": { "kind": "...", "name": "...", "tier": "rw",
20
- * "fields": { ... }, "status": "pending", ... } } }
20
+ * "fields": { ... }, "status": "pending", ... } },
21
+ * "cases": { "<uuid>": { "title": "...", "symptoms": [...],
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>" } } }
21
26
  * ```
22
27
  *
23
28
  * `requests` is the agent-registration approval queue (see server.ts
24
29
  * `/requests` routes); a decided request keeps its metadata but its `fields`
25
30
  * are wiped.
26
31
  *
32
+ * `cases` is the troubleshooting knowledge base (see server.ts `/cases`
33
+ * routes): distilled postmortems an agent records after an investigation
34
+ * resolves, searchable by later sessions. Cases hold no secret material.
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
+ *
27
41
  * The hub is dumb storage: file fields hold their *content* (inlined at
28
42
  * import time) and no kind-specific schema validation happens here.
29
43
  *
@@ -33,7 +47,10 @@
33
47
  *
34
48
  * @module
35
49
  */
50
+ import type { HubToken, TokenChange, TokenPatch, TokenRole } from './tokens.js';
36
51
  export type TierName = 'ro' | 'rw';
52
+ /** Profile name / kind charset; kinds additionally can never contain `/` (path segment). Shared by the HTTP surface and the offline importer. */
53
+ export declare const NAME_PATTERN: RegExp;
37
54
  export interface ProbeState {
38
55
  status: 'verified' | 'mismatch' | 'unverifiable';
39
56
  detail?: string;
@@ -76,13 +93,53 @@ export interface RegistrationRequest {
76
93
  createdAt: string;
77
94
  decidedAt?: string;
78
95
  }
96
+ /** Hard caps on the knowledge base, enforced by the store (it owns the doc). */
97
+ export declare const MAX_CASES = 500;
98
+ /**
99
+ * A distilled troubleshooting postmortem. `hitCount` rises every time a
100
+ * later session reports the case as useful, so the valuable cases float to
101
+ * the top of the index and the rest sink.
102
+ */
103
+ export interface CaseRecord {
104
+ id: string;
105
+ title: string;
106
+ symptoms: string[];
107
+ rootCause: string;
108
+ fix: string;
109
+ evidence?: string;
110
+ /** How the root cause was found — the discriminating steps/commands, for reuse in similar-but-not-identical situations. */
111
+ methodology?: string;
112
+ /** Self-assessed diagnosis difficulty, 1 (obvious at a glance) to 5 (multi-day, cross-system). */
113
+ difficulty?: number;
114
+ tags: string[];
115
+ environment?: string;
116
+ hitCount: number;
117
+ createdAt: string;
118
+ updatedAt: string;
119
+ }
120
+ /** Fields a client may write on a case; the server owns id/hitCount/timestamps. */
121
+ export type CaseInput = Partial<Omit<CaseRecord, 'id' | 'hitCount' | 'createdAt' | 'updatedAt'>>;
79
122
  export interface AuditRecord {
80
123
  ts: string;
81
124
  role: 'admin' | 'read';
82
- action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject';
125
+ action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject' | 'case-put' | 'case-hit' | 'case-delete' | 'token-create' | 'token-update' | 'token-revoke';
83
126
  kind: string;
84
127
  name: string;
85
- tier: TierName;
128
+ /** Absent on `case-*` actions (cases have no tiers). */
129
+ tier?: TierName;
130
+ /** Case title, recorded on `case-*` actions only. */
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[];
86
143
  }
87
144
  export interface HubStoreOptions {
88
145
  dataDir: string;
@@ -134,8 +191,81 @@ export declare class HubStore {
134
191
  * pending.
135
192
  */
136
193
  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>;
194
+ /** The cases map, created lazily (old data files predate the knowledge base). */
195
+ private cases;
196
+ /** Case index rows — metadata only, never the full text fields. */
197
+ listCases(): Array<Pick<CaseRecord, 'id' | 'title' | 'symptoms' | 'tags' | 'hitCount' | 'updatedAt'>>;
198
+ getCase(id: string): CaseRecord | undefined;
199
+ /**
200
+ * Create a case, or update one when `id` is given (only the provided
201
+ * fields change; hitCount/createdAt survive). Returns null when updating
202
+ * an absent id. Throws when the knowledge base is at MAX_CASES.
203
+ */
204
+ putCase(input: CaseInput, id?: string): CaseRecord | null;
205
+ /** Bump a case's hit count. Returns false when absent. */
206
+ hitCase(id: string): boolean;
207
+ /** Delete a case. Returns false when absent. */
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>;
261
+ /** Append one audit line for a case action (kind fixed to 'case', name = case id). */
262
+ auditCase(role: AuditRecord['role'], action: 'case-put' | 'case-hit' | 'case-delete', record: {
263
+ id: string;
264
+ title: string;
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>;
268
+ private appendAudit;
139
269
  /** Read the most recent `limit` audit records, oldest first. */
140
270
  readAudit(limit: number): Promise<AuditRecord[]>;
141
271
  }