@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 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
- /** Profile name / kind charset; kinds additionally can never contain `/` (path segment). */
33
- export declare const NAME_PATTERN: RegExp;
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
- /** Profile name / kind charset; kinds additionally can never contain `/` (path segment). */
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
- tier: TierName;
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
- const record = { ts: new Date().toISOString(), role, action, kind, name, tier };
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.2.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": {