@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/README.md +32 -4
- package/README.zh.md +31 -4
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +203 -12
- package/lib/import.js +8 -0
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -2
- package/lib/server.d.ts +35 -5
- package/lib/server.js +312 -27
- package/lib/store.d.ts +135 -5
- package/lib/store.js +219 -4
- package/lib/tokens.d.ts +110 -0
- package/lib/tokens.js +131 -0
- package/lib/web.d.ts +10 -1
- package/lib/web.js +246 -3
- package/package.json +3 -2
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:
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
if (!
|
|
155
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
138
|
-
|
|
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
|
}
|