@elinpf/dsh-ops-access-hub 0.2.0 → 0.3.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 +5 -0
- package/lib/import.js +8 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +1 -1
- package/lib/server.d.ts +10 -2
- package/lib/server.js +121 -2
- package/lib/store.d.ts +61 -3
- package/lib/store.js +87 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -36,6 +36,11 @@ Every `serve` flag has an env counterpart (`ACCESS_HUB_PORT`, `ACCESS_HUB_HOST`,
|
|
|
36
36
|
| `PUT /entries/:kind/:name/:tier` | admin | upsert `{fields, envelope?, probe?}`; envelope replaces wholesale |
|
|
37
37
|
| `DELETE /entries/:kind/:name/:tier` | admin | removing the last tier deletes the whole entry |
|
|
38
38
|
| `GET /audit?limit=N` | admin | recent N audit records (default 100, cap 1000) |
|
|
39
|
+
| `GET /cases` | read+ | troubleshooting case index rows — metadata only, never full text |
|
|
40
|
+
| `GET /cases/:id` | read+ | one full case record |
|
|
41
|
+
| `POST /cases` / `PUT /cases/:id` | read+ | create / update a case — **deliberate role relaxation**: cases hold no secrets and the agent only carries the read token |
|
|
42
|
+
| `POST /cases/:id/hit` | read+ | bump a case's hit count |
|
|
43
|
+
| `DELETE /cases/:id` | admin | remove a case |
|
|
39
44
|
|
|
40
45
|
## Security notes
|
|
41
46
|
|
package/lib/import.js
CHANGED
|
@@ -20,6 +20,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
20
20
|
import os from 'node:os';
|
|
21
21
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
22
22
|
import { parse as parseYaml } from 'yaml';
|
|
23
|
+
import { NAME_PATTERN } from './store.js';
|
|
23
24
|
const PATH_PREFIX = /^(\/|~\/|\.\/|\.\.\/)/;
|
|
24
25
|
/** A value is path-shaped when it is a single-line string with a path prefix. */
|
|
25
26
|
function looksLikePath(v) {
|
|
@@ -113,6 +114,13 @@ export async function pushToHub(hubUrl, adminToken, entries) {
|
|
|
113
114
|
/** Write imported entries directly into a store (offline mode; caller owns init/save). */
|
|
114
115
|
export function applyToStore(store, entries) {
|
|
115
116
|
for (const entry of entries) {
|
|
117
|
+
// Same charset rule as the HTTP surface (spec 0006): an offline import
|
|
118
|
+
// bypasses segment(), so a hand-edited registry could otherwise smuggle
|
|
119
|
+
// a name the API can neither resolve nor delete into the store.
|
|
120
|
+
for (const [what, value] of [['kind', entry.kind], ['name', entry.name]]) {
|
|
121
|
+
if (!NAME_PATTERN.test(value))
|
|
122
|
+
throw new Error(`import: invalid ${what} ${JSON.stringify(value)}: must match ${NAME_PATTERN.source}`);
|
|
123
|
+
}
|
|
116
124
|
for (const tier of ['ro', 'rw']) {
|
|
117
125
|
const tierData = entry.tiers[tier];
|
|
118
126
|
if (!tierData)
|
package/lib/index.d.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* @module @elinpf/dsh-ops-access-hub
|
|
12
12
|
*/
|
|
13
13
|
export { MASTER_KEY_BYTES, generateMasterKey, parseMasterKey, loadMasterKey, encryptDoc, decryptDoc } from './crypto.js';
|
|
14
|
-
export { HubStore } from './store.js';
|
|
15
|
-
export type { TierName, ProbeState, EntryEnvelope, TierData, HubEntry, AuditRecord, HubStoreOptions } from './store.js';
|
|
14
|
+
export { HubStore, MAX_CASES } from './store.js';
|
|
15
|
+
export type { TierName, ProbeState, EntryEnvelope, TierData, HubEntry, AuditRecord, HubStoreOptions, CaseRecord, CaseInput } from './store.js';
|
|
16
16
|
export { createHubServer, NAME_PATTERN } from './server.js';
|
|
17
17
|
export type { HubServerOptions } from './server.js';
|
|
18
18
|
export { importRegistry, pushToHub, applyToStore } from './import.js';
|
package/lib/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module @elinpf/dsh-ops-access-hub
|
|
12
12
|
*/
|
|
13
13
|
export { MASTER_KEY_BYTES, generateMasterKey, parseMasterKey, loadMasterKey, encryptDoc, decryptDoc } from './crypto.js';
|
|
14
|
-
export { HubStore } from './store.js';
|
|
14
|
+
export { HubStore, MAX_CASES } from './store.js';
|
|
15
15
|
export { createHubServer, NAME_PATTERN } from './server.js';
|
|
16
16
|
export { importRegistry, pushToHub, applyToStore } from './import.js';
|
|
17
17
|
export { WEB_UI_HTML } from './web.js';
|
package/lib/server.d.ts
CHANGED
|
@@ -20,6 +20,14 @@
|
|
|
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)
|
|
23
31
|
*
|
|
24
32
|
* Auth: two Bearer tokens — admin (everything) and read (`GET /entries*`
|
|
25
33
|
* only). Comparisons use `crypto.timingSafeEqual`. Every error response is
|
|
@@ -29,8 +37,8 @@
|
|
|
29
37
|
*/
|
|
30
38
|
import type { Server } from 'node:http';
|
|
31
39
|
import type { HubStore } from './store.js';
|
|
32
|
-
|
|
33
|
-
export
|
|
40
|
+
import { NAME_PATTERN } from './store.js';
|
|
41
|
+
export { NAME_PATTERN };
|
|
34
42
|
export interface HubServerOptions {
|
|
35
43
|
store: HubStore;
|
|
36
44
|
adminToken: string;
|
package/lib/server.js
CHANGED
|
@@ -20,6 +20,14 @@
|
|
|
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)
|
|
23
31
|
*
|
|
24
32
|
* Auth: two Bearer tokens — admin (everything) and read (`GET /entries*`
|
|
25
33
|
* only). Comparisons use `crypto.timingSafeEqual`. Every error response is
|
|
@@ -29,9 +37,9 @@
|
|
|
29
37
|
*/
|
|
30
38
|
import { timingSafeEqual } from 'node:crypto';
|
|
31
39
|
import { createServer } from 'node:http';
|
|
40
|
+
import { NAME_PATTERN } from './store.js';
|
|
32
41
|
import { WEB_UI_HTML } from './web.js';
|
|
33
|
-
|
|
34
|
-
export const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@-]*$/;
|
|
42
|
+
export { NAME_PATTERN };
|
|
35
43
|
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
36
44
|
class HttpError extends Error {
|
|
37
45
|
status;
|
|
@@ -136,6 +144,56 @@ function tierOf(raw) {
|
|
|
136
144
|
return raw;
|
|
137
145
|
throw new HttpError(400, "tier must be 'ro' or 'rw'");
|
|
138
146
|
}
|
|
147
|
+
/** One case record may not exceed this size once serialized (defense against an agent flooding the store). */
|
|
148
|
+
const MAX_CASE_BYTES = 32 * 1024;
|
|
149
|
+
const CASE_STRING_FIELDS = ['title', 'rootCause', 'fix', 'evidence', 'methodology', 'environment'];
|
|
150
|
+
const CASE_LIST_FIELDS = ['symptoms', 'tags'];
|
|
151
|
+
/**
|
|
152
|
+
* Validate a case write body. `partial` (PUT) requires at least one known
|
|
153
|
+
* field; otherwise (POST) title/rootCause/fix are required non-empty.
|
|
154
|
+
*/
|
|
155
|
+
function sanitizeCaseInput(raw, partial) {
|
|
156
|
+
if (!isPlainObject(raw))
|
|
157
|
+
throw new HttpError(400, 'request body must be a JSON object');
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const key of CASE_STRING_FIELDS) {
|
|
160
|
+
const value = raw[key];
|
|
161
|
+
if (value === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
if (typeof value !== 'string')
|
|
164
|
+
throw new HttpError(400, `${key} must be a string`);
|
|
165
|
+
out[key] = value;
|
|
166
|
+
}
|
|
167
|
+
for (const key of CASE_LIST_FIELDS) {
|
|
168
|
+
const value = raw[key];
|
|
169
|
+
if (value === undefined)
|
|
170
|
+
continue;
|
|
171
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
172
|
+
throw new HttpError(400, `${key} must be an array of strings`);
|
|
173
|
+
}
|
|
174
|
+
out[key] = value;
|
|
175
|
+
}
|
|
176
|
+
if (raw.difficulty !== undefined) {
|
|
177
|
+
const d = raw.difficulty;
|
|
178
|
+
if (typeof d !== 'number' || !Number.isInteger(d) || d < 1 || d > 5) {
|
|
179
|
+
throw new HttpError(400, 'difficulty must be an integer between 1 and 5');
|
|
180
|
+
}
|
|
181
|
+
out.difficulty = d;
|
|
182
|
+
}
|
|
183
|
+
if (Object.keys(out).length === 0)
|
|
184
|
+
throw new HttpError(400, 'no case fields to write');
|
|
185
|
+
if (!partial) {
|
|
186
|
+
for (const key of ['title', 'rootCause', 'fix']) {
|
|
187
|
+
if (typeof out[key] !== 'string' || out[key].trim() === '') {
|
|
188
|
+
throw new HttpError(400, `${key} is required and must be non-empty`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (JSON.stringify(out).length > MAX_CASE_BYTES) {
|
|
193
|
+
throw new HttpError(400, `case exceeds ${MAX_CASE_BYTES} bytes`);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
139
197
|
export function createHubServer(opts) {
|
|
140
198
|
const { store } = opts;
|
|
141
199
|
async function handle(req, res) {
|
|
@@ -249,6 +307,67 @@ export function createHubServer(opts) {
|
|
|
249
307
|
await store.audit(role, body.approved ? 'approve' : 'reject', request.kind, request.name, request.tier);
|
|
250
308
|
return send(res, 200, { ok: true });
|
|
251
309
|
}
|
|
310
|
+
if (parts[0] === 'cases' && parts.length === 1) {
|
|
311
|
+
if (method === 'GET') {
|
|
312
|
+
// Index rows only — full text comes from GET /cases/:id.
|
|
313
|
+
return send(res, 200, store.listCases());
|
|
314
|
+
}
|
|
315
|
+
if (method === 'POST') {
|
|
316
|
+
// Deliberate role relaxation: cases hold no secrets, and the agent
|
|
317
|
+
// only carries the read token — read+ may write the knowledge base.
|
|
318
|
+
const input = sanitizeCaseInput(await readBody(req), false);
|
|
319
|
+
let record;
|
|
320
|
+
try {
|
|
321
|
+
record = store.putCase(input);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
throw new HttpError(400, err.message);
|
|
325
|
+
}
|
|
326
|
+
await store.save();
|
|
327
|
+
await store.auditCase(role, 'case-put', record);
|
|
328
|
+
return send(res, 200, { ok: true, id: record.id });
|
|
329
|
+
}
|
|
330
|
+
throw new HttpError(405, 'method not allowed');
|
|
331
|
+
}
|
|
332
|
+
if (parts[0] === 'cases' && parts.length === 2) {
|
|
333
|
+
const id = parts[1];
|
|
334
|
+
if (method === 'GET') {
|
|
335
|
+
const record = store.getCase(id);
|
|
336
|
+
if (!record)
|
|
337
|
+
throw new HttpError(404, 'case not found');
|
|
338
|
+
return send(res, 200, record);
|
|
339
|
+
}
|
|
340
|
+
if (method === 'PUT') {
|
|
341
|
+
const input = sanitizeCaseInput(await readBody(req), true);
|
|
342
|
+
const record = store.putCase(input, id);
|
|
343
|
+
if (!record)
|
|
344
|
+
throw new HttpError(404, 'case not found');
|
|
345
|
+
await store.save();
|
|
346
|
+
await store.auditCase(role, 'case-put', record);
|
|
347
|
+
return send(res, 200, { ok: true });
|
|
348
|
+
}
|
|
349
|
+
if (method === 'DELETE') {
|
|
350
|
+
if (role !== 'admin')
|
|
351
|
+
throw new HttpError(403, 'read token cannot access admin endpoints');
|
|
352
|
+
const existing = store.getCase(id);
|
|
353
|
+
if (!existing || !store.deleteCase(id))
|
|
354
|
+
throw new HttpError(404, 'case not found');
|
|
355
|
+
await store.save();
|
|
356
|
+
await store.auditCase(role, 'case-delete', existing);
|
|
357
|
+
return send(res, 200, { ok: true });
|
|
358
|
+
}
|
|
359
|
+
throw new HttpError(405, 'method not allowed');
|
|
360
|
+
}
|
|
361
|
+
if (parts[0] === 'cases' && parts.length === 3 && parts[2] === 'hit') {
|
|
362
|
+
if (method !== 'POST')
|
|
363
|
+
throw new HttpError(405, 'method not allowed');
|
|
364
|
+
const record = store.getCase(parts[1]);
|
|
365
|
+
if (!record || !store.hitCase(parts[1]))
|
|
366
|
+
throw new HttpError(404, 'case not found');
|
|
367
|
+
await store.save();
|
|
368
|
+
await store.auditCase(role, 'case-hit', record);
|
|
369
|
+
return send(res, 200, { ok: true });
|
|
370
|
+
}
|
|
252
371
|
if (parts[0] === 'entries' && parts.length === 4) {
|
|
253
372
|
const kind = segment(parts[1], 'kind');
|
|
254
373
|
const name = segment(parts[2], 'name');
|
package/lib/store.d.ts
CHANGED
|
@@ -17,13 +17,19 @@
|
|
|
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, ... } } }
|
|
21
23
|
* ```
|
|
22
24
|
*
|
|
23
25
|
* `requests` is the agent-registration approval queue (see server.ts
|
|
24
26
|
* `/requests` routes); a decided request keeps its metadata but its `fields`
|
|
25
27
|
* are wiped.
|
|
26
28
|
*
|
|
29
|
+
* `cases` is the troubleshooting knowledge base (see server.ts `/cases`
|
|
30
|
+
* routes): distilled postmortems an agent records after an investigation
|
|
31
|
+
* resolves, searchable by later sessions. Cases hold no secret material.
|
|
32
|
+
*
|
|
27
33
|
* The hub is dumb storage: file fields hold their *content* (inlined at
|
|
28
34
|
* import time) and no kind-specific schema validation happens here.
|
|
29
35
|
*
|
|
@@ -34,6 +40,8 @@
|
|
|
34
40
|
* @module
|
|
35
41
|
*/
|
|
36
42
|
export type TierName = 'ro' | 'rw';
|
|
43
|
+
/** Profile name / kind charset; kinds additionally can never contain `/` (path segment). Shared by the HTTP surface and the offline importer. */
|
|
44
|
+
export declare const NAME_PATTERN: RegExp;
|
|
37
45
|
export interface ProbeState {
|
|
38
46
|
status: 'verified' | 'mismatch' | 'unverifiable';
|
|
39
47
|
detail?: string;
|
|
@@ -76,13 +84,42 @@ export interface RegistrationRequest {
|
|
|
76
84
|
createdAt: string;
|
|
77
85
|
decidedAt?: string;
|
|
78
86
|
}
|
|
87
|
+
/** Hard caps on the knowledge base, enforced by the store (it owns the doc). */
|
|
88
|
+
export declare const MAX_CASES = 500;
|
|
89
|
+
/**
|
|
90
|
+
* A distilled troubleshooting postmortem. `hitCount` rises every time a
|
|
91
|
+
* later session reports the case as useful, so the valuable cases float to
|
|
92
|
+
* the top of the index and the rest sink.
|
|
93
|
+
*/
|
|
94
|
+
export interface CaseRecord {
|
|
95
|
+
id: string;
|
|
96
|
+
title: string;
|
|
97
|
+
symptoms: string[];
|
|
98
|
+
rootCause: string;
|
|
99
|
+
fix: string;
|
|
100
|
+
evidence?: string;
|
|
101
|
+
/** How the root cause was found — the discriminating steps/commands, for reuse in similar-but-not-identical situations. */
|
|
102
|
+
methodology?: string;
|
|
103
|
+
/** Self-assessed diagnosis difficulty, 1 (obvious at a glance) to 5 (multi-day, cross-system). */
|
|
104
|
+
difficulty?: number;
|
|
105
|
+
tags: string[];
|
|
106
|
+
environment?: string;
|
|
107
|
+
hitCount: number;
|
|
108
|
+
createdAt: string;
|
|
109
|
+
updatedAt: string;
|
|
110
|
+
}
|
|
111
|
+
/** Fields a client may write on a case; the server owns id/hitCount/timestamps. */
|
|
112
|
+
export type CaseInput = Partial<Omit<CaseRecord, 'id' | 'hitCount' | 'createdAt' | 'updatedAt'>>;
|
|
79
113
|
export interface AuditRecord {
|
|
80
114
|
ts: string;
|
|
81
115
|
role: 'admin' | 'read';
|
|
82
|
-
action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject';
|
|
116
|
+
action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject' | 'case-put' | 'case-hit' | 'case-delete';
|
|
83
117
|
kind: string;
|
|
84
118
|
name: string;
|
|
85
|
-
|
|
119
|
+
/** Absent on `case-*` actions (cases have no tiers). */
|
|
120
|
+
tier?: TierName;
|
|
121
|
+
/** Case title, recorded on `case-*` actions only. */
|
|
122
|
+
title?: string;
|
|
86
123
|
}
|
|
87
124
|
export interface HubStoreOptions {
|
|
88
125
|
dataDir: string;
|
|
@@ -134,8 +171,29 @@ export declare class HubStore {
|
|
|
134
171
|
* pending.
|
|
135
172
|
*/
|
|
136
173
|
decideRequest(id: string, approved: boolean): RegistrationRequest | null;
|
|
174
|
+
/** The cases map, created lazily (old data files predate the knowledge base). */
|
|
175
|
+
private cases;
|
|
176
|
+
/** Case index rows — metadata only, never the full text fields. */
|
|
177
|
+
listCases(): Array<Pick<CaseRecord, 'id' | 'title' | 'symptoms' | 'tags' | 'hitCount' | 'updatedAt'>>;
|
|
178
|
+
getCase(id: string): CaseRecord | undefined;
|
|
179
|
+
/**
|
|
180
|
+
* Create a case, or update one when `id` is given (only the provided
|
|
181
|
+
* fields change; hitCount/createdAt survive). Returns null when updating
|
|
182
|
+
* an absent id. Throws when the knowledge base is at MAX_CASES.
|
|
183
|
+
*/
|
|
184
|
+
putCase(input: CaseInput, id?: string): CaseRecord | null;
|
|
185
|
+
/** Bump a case's hit count. Returns false when absent. */
|
|
186
|
+
hitCase(id: string): boolean;
|
|
187
|
+
/** Delete a case. Returns false when absent. */
|
|
188
|
+
deleteCase(id: string): boolean;
|
|
189
|
+
/** Append one audit line for a case action (kind fixed to 'case', name = case id). */
|
|
190
|
+
auditCase(role: AuditRecord['role'], action: 'case-put' | 'case-hit' | 'case-delete', record: {
|
|
191
|
+
id: string;
|
|
192
|
+
title: string;
|
|
193
|
+
}): Promise<void>;
|
|
137
194
|
/** Append one audit line. Field values are never recorded. */
|
|
138
195
|
audit(role: AuditRecord['role'], action: AuditRecord['action'], kind: string, name: string, tier: TierName): Promise<void>;
|
|
196
|
+
private appendAudit;
|
|
139
197
|
/** Read the most recent `limit` audit records, oldest first. */
|
|
140
198
|
readAudit(limit: number): Promise<AuditRecord[]>;
|
|
141
199
|
}
|
package/lib/store.js
CHANGED
|
@@ -17,13 +17,19 @@
|
|
|
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, ... } } }
|
|
21
23
|
* ```
|
|
22
24
|
*
|
|
23
25
|
* `requests` is the agent-registration approval queue (see server.ts
|
|
24
26
|
* `/requests` routes); a decided request keeps its metadata but its `fields`
|
|
25
27
|
* are wiped.
|
|
26
28
|
*
|
|
29
|
+
* `cases` is the troubleshooting knowledge base (see server.ts `/cases`
|
|
30
|
+
* routes): distilled postmortems an agent records after an investigation
|
|
31
|
+
* resolves, searchable by later sessions. Cases hold no secret material.
|
|
32
|
+
*
|
|
27
33
|
* The hub is dumb storage: file fields hold their *content* (inlined at
|
|
28
34
|
* import time) and no kind-specific schema validation happens here.
|
|
29
35
|
*
|
|
@@ -37,6 +43,10 @@ import { appendFile, chmod, mkdir, open, readFile, rename } from 'node:fs/promis
|
|
|
37
43
|
import { join } from 'node:path';
|
|
38
44
|
import { randomUUID } from 'node:crypto';
|
|
39
45
|
import { decryptDoc, encryptDoc, loadMasterKey } from './crypto.js';
|
|
46
|
+
/** Profile name / kind charset; kinds additionally can never contain `/` (path segment). Shared by the HTTP surface and the offline importer. */
|
|
47
|
+
export const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@-]*$/;
|
|
48
|
+
/** Hard caps on the knowledge base, enforced by the store (it owns the doc). */
|
|
49
|
+
export const MAX_CASES = 500;
|
|
40
50
|
export class HubStore {
|
|
41
51
|
dataDir;
|
|
42
52
|
dataFile;
|
|
@@ -171,9 +181,84 @@ export class HubStore {
|
|
|
171
181
|
request.fields = {};
|
|
172
182
|
return request;
|
|
173
183
|
}
|
|
184
|
+
/** The cases map, created lazily (old data files predate the knowledge base). */
|
|
185
|
+
cases() {
|
|
186
|
+
return (this.doc.cases ??= {});
|
|
187
|
+
}
|
|
188
|
+
/** Case index rows — metadata only, never the full text fields. */
|
|
189
|
+
listCases() {
|
|
190
|
+
return Object.values(this.cases()).map((c) => ({
|
|
191
|
+
id: c.id,
|
|
192
|
+
title: c.title,
|
|
193
|
+
symptoms: c.symptoms,
|
|
194
|
+
tags: c.tags,
|
|
195
|
+
hitCount: c.hitCount,
|
|
196
|
+
updatedAt: c.updatedAt,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
getCase(id) {
|
|
200
|
+
return this.cases()[id];
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Create a case, or update one when `id` is given (only the provided
|
|
204
|
+
* fields change; hitCount/createdAt survive). Returns null when updating
|
|
205
|
+
* an absent id. Throws when the knowledge base is at MAX_CASES.
|
|
206
|
+
*/
|
|
207
|
+
putCase(input, id) {
|
|
208
|
+
const now = new Date().toISOString();
|
|
209
|
+
if (id !== undefined) {
|
|
210
|
+
const existing = this.cases()[id];
|
|
211
|
+
if (!existing)
|
|
212
|
+
return null;
|
|
213
|
+
Object.assign(existing, input);
|
|
214
|
+
existing.updatedAt = now;
|
|
215
|
+
return existing;
|
|
216
|
+
}
|
|
217
|
+
if (Object.keys(this.cases()).length >= MAX_CASES) {
|
|
218
|
+
throw new Error(`knowledge base is full (${MAX_CASES} cases); delete stale cases first`);
|
|
219
|
+
}
|
|
220
|
+
const record = {
|
|
221
|
+
id: randomUUID(),
|
|
222
|
+
title: input.title ?? '',
|
|
223
|
+
symptoms: input.symptoms ?? [],
|
|
224
|
+
rootCause: input.rootCause ?? '',
|
|
225
|
+
fix: input.fix ?? '',
|
|
226
|
+
...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
|
|
227
|
+
...(input.methodology !== undefined ? { methodology: input.methodology } : {}),
|
|
228
|
+
...(input.difficulty !== undefined ? { difficulty: input.difficulty } : {}),
|
|
229
|
+
tags: input.tags ?? [],
|
|
230
|
+
...(input.environment !== undefined ? { environment: input.environment } : {}),
|
|
231
|
+
hitCount: 0,
|
|
232
|
+
createdAt: now,
|
|
233
|
+
updatedAt: now,
|
|
234
|
+
};
|
|
235
|
+
this.cases()[record.id] = record;
|
|
236
|
+
return record;
|
|
237
|
+
}
|
|
238
|
+
/** Bump a case's hit count. Returns false when absent. */
|
|
239
|
+
hitCase(id) {
|
|
240
|
+
const record = this.cases()[id];
|
|
241
|
+
if (!record)
|
|
242
|
+
return false;
|
|
243
|
+
record.hitCount += 1;
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
/** Delete a case. Returns false when absent. */
|
|
247
|
+
deleteCase(id) {
|
|
248
|
+
if (!this.cases()[id])
|
|
249
|
+
return false;
|
|
250
|
+
delete this.cases()[id];
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
/** 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 });
|
|
256
|
+
}
|
|
174
257
|
/** Append one audit line. Field values are never recorded. */
|
|
175
258
|
async audit(role, action, kind, name, tier) {
|
|
176
|
-
|
|
259
|
+
await this.appendAudit({ ts: new Date().toISOString(), role, action, kind, name, tier });
|
|
260
|
+
}
|
|
261
|
+
async appendAudit(record) {
|
|
177
262
|
await mkdir(this.dataDir, { recursive: true });
|
|
178
263
|
// A crash mid-append can leave a torn tail line without a newline; a
|
|
179
264
|
// naive append would fuse the next record onto it and lose both. Start
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elinpf/dsh-ops-access-hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Standalone credential hub for the dsh ops suite: encrypted-at-rest storage, token-authenticated REST API, minimal web UI, and a YAML registry importer. Not a dsh plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@types/node": "^22.0.0",
|
|
19
19
|
"typescript": "^5.4.0",
|
|
20
|
-
"vitest": "^4.1.11"
|
|
20
|
+
"vitest": "^4.1.11",
|
|
21
|
+
"@elinpf/dsh-ops-test-support": "0.0.0"
|
|
21
22
|
},
|
|
22
23
|
"license": "MIT",
|
|
23
24
|
"exports": {
|