@elinpf/dsh-ops-access 0.2.1 → 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/backend.d.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  */
19
19
  import type { EntryEnvelope, ProbeState } from './types.js';
20
20
  export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
21
+ /** Expand a leading `~` (or `~/`) to the user's home directory. */
22
+ export declare function expandHome(p: string): string;
21
23
  /** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
22
24
  export declare function buildEnvelope(sources: Array<Record<string, unknown> | undefined>): EntryEnvelope;
23
25
  /** Read a persisted probe result off a raw tier object (durable boundary — sanitize). */
package/lib/backend.js CHANGED
@@ -17,11 +17,21 @@
17
17
  * @module @elinpf/dsh-ops-access/backend
18
18
  */
19
19
  import { readFile, writeFile } from 'node:fs/promises';
20
+ import os from 'node:os';
20
21
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
21
22
  // ── Shared helpers ───────────────────────────────────────────────────────────
22
23
  export function isPlainObject(value) {
23
24
  return typeof value === 'object' && value !== null && !Array.isArray(value);
24
25
  }
26
+ /** Expand a leading `~` (or `~/`) to the user's home directory. */
27
+ export function expandHome(p) {
28
+ const home = process.env.HOME ?? os.homedir();
29
+ if (p === '~')
30
+ return home;
31
+ if (p.startsWith('~/'))
32
+ return home + p.slice(1);
33
+ return p;
34
+ }
25
35
  /** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
26
36
  export function buildEnvelope(sources) {
27
37
  const envelope = {};
@@ -75,7 +75,7 @@ export declare class HubBackend implements AccessBackend {
75
75
  reason?: string;
76
76
  }): Promise<string>;
77
77
  /** Pending-request metadata for the approval UI — field values never cross. */
78
- listRequests(status?: 'pending' | 'approved' | 'rejected'): Promise<unknown>;
78
+ listRequests(): Promise<unknown>;
79
79
  /** Full request incl. field values, for pre-approval review. Null when absent. */
80
80
  getRequest(id: string): Promise<unknown>;
81
81
  /** Approve (hub writes the tier) or reject. Returns false when already settled/absent. */
@@ -23,17 +23,7 @@
23
23
  */
24
24
  import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
25
25
  import { dirname } from 'node:path';
26
- import os from 'node:os';
27
- import { buildEnvelope, isPlainObject, mergeEnvelope, probeOf } from './backend.js';
28
- /** Expand a leading `~` (or `~/`) to the user's home directory. */
29
- function expandHome(p) {
30
- const home = process.env.HOME ?? os.homedir();
31
- if (p === '~')
32
- return home;
33
- if (p.startsWith('~/'))
34
- return home + p.slice(1);
35
- return p;
36
- }
26
+ import { buildEnvelope, expandHome, isPlainObject, mergeEnvelope, probeOf } from './backend.js';
37
27
  /**
38
28
  * Write content to a managed file, skipping the write when the on-disk
39
29
  * bytes already match (resolve runs on every tool call — touching the file
@@ -253,8 +243,8 @@ export class HubBackend {
253
243
  return data.id;
254
244
  }
255
245
  /** Pending-request metadata for the approval UI — field values never cross. */
256
- async listRequests(status) {
257
- return this.request('GET', status === undefined ? '/requests' : `/requests?status=${status}`);
246
+ async listRequests() {
247
+ return this.request('GET', '/requests?status=pending');
258
248
  }
259
249
  /** Full request incl. field values, for pre-approval review. Null when absent. */
260
250
  async getRequest(id) {
package/lib/index.d.ts CHANGED
@@ -55,9 +55,12 @@ export interface Config {
55
55
  * Credential source: 'yaml' (default) reads the local registry file;
56
56
  * 'hub' fetches entries from a remote ops-access-hub service on every
57
57
  * call and materializes file-field content to managed local files.
58
+ * Unset + env ACCESS_HUB_URL present → 'hub' (upgrade-proof seam: the
59
+ * materialized preset file is rewritten on every suite upgrade, so the
60
+ * durable switch lives in the process environment, e.g. the systemd unit).
58
61
  */
59
62
  source?: 'yaml' | 'hub';
60
- /** Hub base URL (source: 'hub'), e.g. http://127.0.0.1:3090. */
63
+ /** Hub base URL (source: 'hub'), e.g. http://127.0.0.1:3090. Falls back to env ACCESS_HUB_URL. */
61
64
  hubUrl?: string;
62
65
  /** Hub read token (source: 'hub'); falls back to env ACCESS_HUB_READ_TOKEN. Never logged. */
63
66
  hubToken?: string;
@@ -80,7 +83,7 @@ export interface Config {
80
83
  hubCacheDir?: string;
81
84
  }
82
85
  export declare const Config: z<Config>;
83
- export type { AccessProvider, AccessProfile, EntryEnvelope, ProbeState, AdminTierStatus, AdminEntry, KindDescriptor, AccessAgent, AccessBrokerDecision, AccessBroker, OpsAccess, } from './types.js';
86
+ export type { AccessProvider, AccessProfile, EntryEnvelope, ProbeState, AdminTierStatus, AdminEntry, KindDescriptor, AccessAgent, AccessBrokerDecision, AccessBroker, AccessRequest, OpsAccess, } from './types.js';
84
87
  declare module '@deepseek-ai/cordis' {
85
88
  interface Context {
86
89
  opsAccess?: OpsAccess;
@@ -102,6 +105,13 @@ export declare function registerAccessProvider(ctx: Context, provider: AccessPro
102
105
  * `ctx.inject` and ties the registration to the plugin's effect lifecycle.
103
106
  */
104
107
  export declare function registerAccessBroker(ctx: Context, broker: AccessBroker): void;
105
- /** Expand a leading `~` (or `~/`) to the user's home directory. */
106
- export declare function expandHome(p: string): string;
108
+ import { expandHome } from './backend.js';
109
+ export { expandHome };
110
+ /**
111
+ * Single-line-secret check shared by provider validateContent hooks: a
112
+ * pasted value may carry one trailing newline; anything further (interior
113
+ * `\n`/`\r`) means a multi-line paste landed in a field whose reader only
114
+ * honors the first line (sshpass -f, an Authorization header).
115
+ */
116
+ export declare function hasSingleLineBody(content: string): boolean;
107
117
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -57,8 +57,10 @@ export const inject = ['tools'];
57
57
  export const Config = z.object({
58
58
  registryFile: z.string().default('~/.dsh-ops/access.yaml'),
59
59
  credentialsDir: z.string().default('~/.dsh-ops/credentials'),
60
- source: z.union(['yaml', 'hub']).default('yaml'),
61
- hubUrl: z.string().default(''),
60
+ // No defaults here: an absent key must STAY absent so apply() can tell
61
+ // "unset" apart from an explicit value (the ACCESS_HUB_URL env seam).
62
+ source: z.union(['yaml', 'hub']),
63
+ hubUrl: z.string(),
62
64
  hubToken: z.string().default(''),
63
65
  hubAdminToken: z.string().default(''),
64
66
  materializeTtlMinutes: z.number().default(15),
@@ -90,14 +92,17 @@ export function registerAccessBroker(ctx, broker) {
90
92
  });
91
93
  }
92
94
  // ── Helpers ──────────────────────────────────────────────────────────────────
93
- /** Expand a leading `~` (or `~/`) to the user's home directory. */
94
- export function expandHome(p) {
95
- const home = process.env.HOME ?? os.homedir();
96
- if (p === '~')
97
- return home;
98
- if (p.startsWith('~/'))
99
- return home + p.slice(1);
100
- return p;
95
+ import { expandHome } from './backend.js';
96
+ export { expandHome };
97
+ /**
98
+ * Single-line-secret check shared by provider validateContent hooks: a
99
+ * pasted value may carry one trailing newline; anything further (interior
100
+ * `\n`/`\r`) means a multi-line paste landed in a field whose reader only
101
+ * honors the first line (sshpass -f, an Authorization header).
102
+ */
103
+ export function hasSingleLineBody(content) {
104
+ const body = content.endsWith('\n') ? content.slice(0, -1) : content;
105
+ return !body.includes('\n') && !body.includes('\r');
101
106
  }
102
107
  /**
103
108
  * Validate one tier sub-object against the provider schema and build the
@@ -381,7 +386,11 @@ export function apply(ctx, config) {
381
386
  // default and behaves byte-for-byte as before; hub fetches entries from a
382
387
  // remote ops-access-hub on every call and materializes file-field content
383
388
  // to managed local files under credentialsDir.
384
- const source = config.source ?? 'yaml';
389
+ // Env seam (ACCESS_HUB_URL): setting it flips an unconfigured deployment to
390
+ // hub mode. The ops preset file is re-materialized on every suite upgrade,
391
+ // so config written into it is silently dropped — the process env (systemd
392
+ // unit) is the only upgrade-proof seam.
393
+ const source = config.source ?? (process.env.ACCESS_HUB_URL ? 'hub' : 'yaml');
385
394
  // In hub mode every local credential file — materialized reads AND staged
386
395
  // writes — lives under hubCacheDir as a TTL-bound cache. credentialsDir
387
396
  // stays yaml-mode territory: the sweeper must never touch files the yaml
@@ -389,9 +398,9 @@ export function apply(ctx, config) {
389
398
  const contentDir = source === 'hub' ? expandHome(config.hubCacheDir ?? '~/.dsh-ops/hub-cache') : credentialsDir;
390
399
  let backend;
391
400
  if (source === 'hub') {
392
- const hubUrl = (config.hubUrl ?? '').replace(/\/+$/, '');
401
+ const hubUrl = (config.hubUrl || process.env.ACCESS_HUB_URL || '').replace(/\/+$/, '');
393
402
  if (hubUrl === '') {
394
- throw new Error('ops-access: source "hub" requires hubUrl (e.g. http://127.0.0.1:3090)');
403
+ throw new Error('ops-access: source "hub" requires hubUrl (e.g. http://127.0.0.1:3090) or env ACCESS_HUB_URL');
395
404
  }
396
405
  backend = new HubBackend({
397
406
  baseUrl: hubUrl,
@@ -491,7 +500,7 @@ export function apply(ctx, config) {
491
500
  return { ok: false, error: String(err?.message ?? err) };
492
501
  }
493
502
  },
494
- async resolve(kind, profileName, agent) {
503
+ async resolve(kind, profileName, agent, request) {
495
504
  profileName = stripKindPrefix(kind, profileName);
496
505
  const provider = providers.get(kind);
497
506
  if (!provider) {
@@ -501,15 +510,21 @@ export function apply(ctx, config) {
501
510
  // Once a broker is registered it is consulted on EVERY resolve —
502
511
  // including calls without an agent. The no-agent ruling (fail closed to
503
512
  // ro, or deny outright) is policy, and policy lives in the broker, not
504
- // here. Without a broker, rw is never issued at all.
513
+ // here. Without a broker, rw is never issued at all — so an explicit rw
514
+ // request without a broker is an error, not a silent ro.
505
515
  let tier = 'ro';
506
516
  if (broker) {
507
- const decision = broker(kind, profileName, agent);
517
+ const decision = broker(kind, profileName, agent, request);
508
518
  if (typeof decision === 'object') {
509
519
  throw new Error(`ops-access: access denied for ${kind}/${profileName}: ${decision.deny}`);
510
520
  }
511
- if (decision === 'rw')
512
- tier = 'rw';
521
+ // An explicit 'ro' request caps the outcome at ro even when the broker
522
+ // would issue rw — the deliberate downgrade is the point: the caller
523
+ // declares "this call only reads".
524
+ tier = request?.tier === 'ro' ? 'ro' : decision;
525
+ }
526
+ else if (request?.tier === 'rw') {
527
+ throw new Error(`ops-access: rw tier requested for ${kind}/${profileName}, but no access gate is mounted — rw is never issued without one`);
513
528
  }
514
529
  // A missing SOURCE (yaml: no registry file) throws from the backend
515
530
  // verbatim (SourceUnavailableError); an unreadable source propagates
@@ -1041,7 +1056,7 @@ export function apply(ctx, config) {
1041
1056
  sendJsonError(res, 405, new Error('method not allowed'));
1042
1057
  return;
1043
1058
  }
1044
- const list = await backend.listRequests('pending');
1059
+ const list = await backend.listRequests();
1045
1060
  res.writeHead(200, { 'content-type': 'application/json' });
1046
1061
  res.end(JSON.stringify(list));
1047
1062
  }
package/lib/types.d.ts CHANGED
@@ -204,15 +204,28 @@ export interface AccessAgent {
204
204
  export type AccessBrokerDecision = 'ro' | 'rw' | {
205
205
  deny: string;
206
206
  };
207
+ /**
208
+ * What the caller asked for on one resolve call, beyond the defaults.
209
+ */
210
+ export interface AccessRequest {
211
+ /**
212
+ * Explicit tier declaration. `'ro'` is a deliberate downgrade: the session
213
+ * may hold an rw grant, but this call only reads — the ro credential is
214
+ * served (a lockdown deny still applies). `'rw'` is an explicit elevation
215
+ * request: the broker must deny loudly (with guidance) when the session
216
+ * holds no grant, rather than silently serving ro.
217
+ */
218
+ tier?: 'ro' | 'rw';
219
+ }
207
220
  /**
208
221
  * The pure decision function a gate registers. Receives only kind, profile
209
- * name, and the caller agent — never credential fields. Once a broker is
210
- * registered, resolve consults it on EVERY call; `agent` is `undefined` for
211
- * system-internal calls, and the no-agent ruling belongs to the broker (core
212
- * does not answer policy on its behalf). Without a registered broker, resolve
213
- * is unchanged from the broker-less behavior (ro).
222
+ * name, the caller agent, and the per-call request — never credential fields.
223
+ * Once a broker is registered, resolve consults it on EVERY call; `agent` is
224
+ * `undefined` for system-internal calls, and the no-agent ruling belongs to
225
+ * the broker (core does not answer policy on its behalf). Without a
226
+ * registered broker, resolve is unchanged from the broker-less behavior (ro).
214
227
  */
215
- export type AccessBroker = (kind: string, name: string, agent: AccessAgent | undefined) => AccessBrokerDecision;
228
+ export type AccessBroker = (kind: string, name: string, agent: AccessAgent | undefined, request?: AccessRequest) => AccessBrokerDecision;
216
229
  /** The ops access handle exposed via ctx.get('opsAccess'). */
217
230
  export interface OpsAccess {
218
231
  /** Register a credential-kind provider. Throws if the kind is already registered. Returns a disposer. */
@@ -245,7 +258,18 @@ export interface OpsAccess {
245
258
  * a broker the ro profile (from `registryFile`) is served, byte-for-byte
246
259
  * as before.
247
260
  */
248
- resolve(kind: string, name: string, agent?: AccessAgent): Promise<AccessProfile>;
261
+ /**
262
+ * Resolve one profile by kind and name. Throws on unknown kind, unknown
263
+ * name, or invalid entry. When a broker is registered it is consulted on
264
+ * every call — including calls without an `agent` (the broker owns the
265
+ * no-agent ruling) — and decides whether the rw profile is served. Without
266
+ * a broker the ro profile (from `registryFile`) is served, byte-for-byte
267
+ * as before. `request.tier` lets the caller declare the tier explicitly:
268
+ * `'ro'` caps the outcome at ro even under an rw grant (deliberate
269
+ * downgrade); `'rw'` demands the rw tier and fails loudly when it cannot
270
+ * be served.
271
+ */
272
+ resolve(kind: string, name: string, agent?: AccessAgent, request?: AccessRequest): Promise<AccessProfile>;
249
273
  /** List all profiles across all registered kinds. Sections without a registered provider are skipped. */
250
274
  list(): Promise<AccessProfile[]>;
251
275
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elinpf/dsh-ops-access",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Ops access capability seam — owns the YAML credential registry and exposes ctx.opsAccess (resolve/list/register) to provider plugins.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",