@elinpf/dsh-ops-access 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/lib/backend.js ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Credential-source backends for the ops-access seam.
3
+ *
4
+ * The `OpsAccess` handle (index.ts) owns policy — broker decisions, profile
5
+ * validation via provider schemas, envelope merge semantics, probes — and
6
+ * delegates raw entry persistence to an `AccessBackend`. Two backends exist:
7
+ *
8
+ * - `YamlBackend` — the original local YAML registry file (default).
9
+ * - `HubBackend` (hub-backend.ts) — a remote ops-access-hub service; secret
10
+ * content is fetched per resolve and materialized to managed local files.
11
+ *
12
+ * Both speak the same provider-shaped language: file fields are LOCAL PATHS
13
+ * in `fields` (the hub backend converts content ↔ path at its boundary), the
14
+ * envelope (`name`/`description`/`environment`) is per-entry, and a `probe`
15
+ * may ride beside each tier.
16
+ *
17
+ * @module @elinpf/dsh-ops-access/backend
18
+ */
19
+ import { readFile, writeFile } from 'node:fs/promises';
20
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
21
+ // ── Shared helpers ───────────────────────────────────────────────────────────
22
+ export function isPlainObject(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ }
25
+ /** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
26
+ export function buildEnvelope(sources) {
27
+ const envelope = {};
28
+ for (const source of sources) {
29
+ if (!isPlainObject(source))
30
+ continue;
31
+ if (envelope.name === undefined && typeof source.name === 'string')
32
+ envelope.name = source.name;
33
+ if (envelope.description === undefined && typeof source.description === 'string')
34
+ envelope.description = source.description;
35
+ if (envelope.environment === undefined && typeof source.environment === 'string')
36
+ envelope.environment = source.environment;
37
+ }
38
+ return envelope;
39
+ }
40
+ /** Read a persisted probe result off a raw tier object (durable boundary — sanitize). */
41
+ export function probeOf(tierRaw) {
42
+ if (!isPlainObject(tierRaw))
43
+ return undefined;
44
+ const p = tierRaw.probe;
45
+ if (!isPlainObject(p))
46
+ return undefined;
47
+ const probe = p;
48
+ if (probe.status !== 'verified' && probe.status !== 'mismatch' && probe.status !== 'unverifiable')
49
+ return undefined;
50
+ if (typeof probe.probedAt !== 'string')
51
+ return undefined;
52
+ const out = { status: probe.status, probedAt: probe.probedAt };
53
+ if (typeof probe.detail === 'string')
54
+ out.detail = probe.detail;
55
+ return out;
56
+ }
57
+ /**
58
+ * Apply the envelope patch discipline to a mutable target: undefined field =
59
+ * preserve, empty string = delete, else set. Shared by both backends so the
60
+ * merge semantics stay identical across sources.
61
+ */
62
+ export function applyEnvelopePatch(target, envelope) {
63
+ if (envelope === undefined)
64
+ return;
65
+ for (const key of ['name', 'description', 'environment']) {
66
+ const value = envelope[key];
67
+ if (value === undefined)
68
+ continue;
69
+ if (value === '')
70
+ delete target[key];
71
+ else
72
+ target[key] = value;
73
+ }
74
+ }
75
+ /** Merge an envelope patch over an existing envelope, returning the result (hub backend variant). */
76
+ export function mergeEnvelope(existing, patch) {
77
+ const target = { ...existing };
78
+ applyEnvelopePatch(target, patch);
79
+ return buildEnvelope([target]);
80
+ }
81
+ /** Thrown when the source itself is unavailable (yaml: registry file missing) — resolve surfaces it verbatim. */
82
+ export class SourceUnavailableError extends Error {
83
+ }
84
+ /**
85
+ * Read and parse the registry file. Returns null when the file does not
86
+ * exist so callers can pick their own discipline (list → empty, resolve →
87
+ * error). Never includes raw file text in errors.
88
+ */
89
+ async function loadRegistry(file) {
90
+ let text;
91
+ try {
92
+ text = await readFile(file, 'utf8');
93
+ }
94
+ catch (err) {
95
+ if (err?.code === 'ENOENT')
96
+ return null;
97
+ throw new Error(`ops-access: failed to read registry file ${file}: ${err?.message ?? err}`);
98
+ }
99
+ let doc;
100
+ try {
101
+ doc = parseYaml(text);
102
+ }
103
+ catch (err) {
104
+ // First line only — the yaml library appends a source snippet to its
105
+ // messages, and raw registry text must not leak into errors.
106
+ const summary = String(err?.message ?? err).split('\n')[0];
107
+ throw new Error(`ops-access: failed to parse registry file ${file}: ${summary}`);
108
+ }
109
+ // An empty file parses to null — treat it as an empty registry.
110
+ if (doc == null)
111
+ return {};
112
+ if (!isPlainObject(doc)) {
113
+ throw new Error(`ops-access: registry file ${file} must contain a top-level mapping`);
114
+ }
115
+ const registry = {};
116
+ for (const [kind, section] of Object.entries(doc)) {
117
+ if (kind === 'version')
118
+ continue;
119
+ if (!isPlainObject(section)) {
120
+ throw new Error(`ops-access: section "${kind}" in registry file ${file} must be a mapping of profile names`);
121
+ }
122
+ registry[kind] = section;
123
+ }
124
+ return registry;
125
+ }
126
+ /** Serialize a registry back to its YAML file with the version header. */
127
+ async function saveRegistry(file, registry) {
128
+ const doc = { version: 1 };
129
+ for (const [kind, section] of Object.entries(registry)) {
130
+ doc[kind] = section;
131
+ }
132
+ await writeFile(file, stringifyYaml(doc), 'utf8');
133
+ }
134
+ /**
135
+ * The original local YAML registry. Every operation re-reads (and writes
136
+ * back) the whole file — edits take effect immediately, nothing is cached.
137
+ * Sections whose kind has no registered provider are preserved untouched.
138
+ */
139
+ export class YamlBackend {
140
+ registryFile;
141
+ label;
142
+ constructor(registryFile) {
143
+ this.registryFile = registryFile;
144
+ this.label = `registry file ${registryFile}`;
145
+ }
146
+ async listEntries() {
147
+ const registry = await loadRegistry(this.registryFile);
148
+ if (registry === null)
149
+ return [];
150
+ const result = [];
151
+ for (const kind of Object.keys(registry).sort()) {
152
+ const section = registry[kind];
153
+ for (const name of Object.keys(section).sort()) {
154
+ const entry = section[name];
155
+ if (!isPlainObject(entry))
156
+ continue;
157
+ const raw = entry;
158
+ const tiers = {};
159
+ if (isPlainObject(raw.ro))
160
+ tiers.ro = { ...(probeOf(raw.ro) !== undefined ? { probe: probeOf(raw.ro) } : {}) };
161
+ if (isPlainObject(raw.rw))
162
+ tiers.rw = { ...(probeOf(raw.rw) !== undefined ? { probe: probeOf(raw.rw) } : {}) };
163
+ result.push({ kind, name, envelope: buildEnvelope([raw]), tiers });
164
+ }
165
+ }
166
+ return result;
167
+ }
168
+ async loadTier(kind, name, tier) {
169
+ const registry = await loadRegistry(this.registryFile);
170
+ if (registry === null) {
171
+ throw new SourceUnavailableError(`ops-access: registry file not found: ${this.registryFile}`);
172
+ }
173
+ const entry = registry[kind]?.[name];
174
+ if (!isPlainObject(entry))
175
+ return null;
176
+ const raw = entry[tier];
177
+ if (!isPlainObject(raw))
178
+ return null;
179
+ const fields = { ...raw };
180
+ const probe = probeOf(raw);
181
+ delete fields.probe;
182
+ return { fields, envelope: buildEnvelope([entry]), ...(probe !== undefined ? { probe } : {}) };
183
+ }
184
+ async putTier(kind, name, tier, fields, envelope, probe) {
185
+ // Read → merge → write back. A missing file starts from an empty
186
+ // registry; an unparseable file throws (we will not overwrite a file we
187
+ // cannot read).
188
+ let registry = {};
189
+ const loaded = await loadRegistry(this.registryFile);
190
+ if (loaded !== null)
191
+ registry = loaded;
192
+ if (!registry[kind])
193
+ registry[kind] = {};
194
+ if (!isPlainObject(registry[kind][name]))
195
+ registry[kind][name] = {};
196
+ const entry = registry[kind][name];
197
+ const tierData = { ...fields };
198
+ if (probe !== undefined)
199
+ tierData.probe = probe;
200
+ entry[tier] = tierData;
201
+ applyEnvelopePatch(entry, envelope);
202
+ await saveRegistry(this.registryFile, registry);
203
+ }
204
+ async deleteTier(kind, name, tier) {
205
+ const registry = await loadRegistry(this.registryFile);
206
+ if (registry === null)
207
+ return 'missing';
208
+ const section = registry[kind];
209
+ if (!section || !(name in section))
210
+ return 'missing';
211
+ const entry = section[name];
212
+ if (!isPlainObject(entry))
213
+ return 'missing';
214
+ delete entry[tier];
215
+ // If neither tier remains, drop the whole entry and empty sections.
216
+ const remaining = ['ro', 'rw'].filter((t) => entry[t] !== undefined);
217
+ if (remaining.length > 0) {
218
+ await saveRegistry(this.registryFile, registry);
219
+ return 'tier';
220
+ }
221
+ delete section[name];
222
+ if (Object.keys(section).length === 0)
223
+ delete registry[kind];
224
+ await saveRegistry(this.registryFile, registry);
225
+ return 'entry';
226
+ }
227
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Hub backend: fetch credentials from a remote ops-access-hub service.
3
+ *
4
+ * The hub stores file-field CONTENT; this backend converts at the boundary:
5
+ *
6
+ * - `loadTier` downloads the tier's fields and MATERIALIZES each declared
7
+ * file field to a managed local file (`<credentialsDir>/<kind>/<name>/<tier>/<field>`,
8
+ * 0600, atomic write, skipped when the content is unchanged), substituting
9
+ * the local path — so downstream consumers (kubectl/ssh CLIs, provider
10
+ * schemas, probes) see exactly the same provider-shaped profile the YAML
11
+ * backend serves, and secret paths never leave the machine.
12
+ * - `putTier` reads the managed local files back and uploads their CONTENT
13
+ * (the write path — register_access, the admin UI — stages content files
14
+ * locally first, exactly as in YAML mode).
15
+ *
16
+ * Every call hits the hub — nothing is cached, mirroring the YAML backend's
17
+ * re-read-on-every-call discipline. Listing and metadata reads never carry
18
+ * field values; secret content crosses the wire only on the resolve and
19
+ * write paths, over the operator-managed channel (the hub binds loopback or
20
+ * sits behind a TLS-terminating reverse proxy).
21
+ *
22
+ * @module @elinpf/dsh-ops-access/hub-backend
23
+ */
24
+ import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
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
+ }
37
+ /**
38
+ * Write content to a managed file, skipping the write when the on-disk
39
+ * bytes already match (resolve runs on every tool call — touching the file
40
+ * every time would churn mtimes and race concurrent writers). Atomic via
41
+ * write-temp-then-rename; mode 0600 — the file carries secret material.
42
+ */
43
+ async function writeIfChanged(filePath, content) {
44
+ try {
45
+ if ((await readFile(filePath, 'utf8')) === content)
46
+ return;
47
+ }
48
+ catch (err) {
49
+ if (err?.code !== 'ENOENT')
50
+ throw err;
51
+ }
52
+ await mkdir(dirname(filePath), { recursive: true });
53
+ const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
54
+ await writeFile(tmp, content, { encoding: 'utf8', mode: 0o600 });
55
+ await rename(tmp, filePath);
56
+ }
57
+ /**
58
+ * Delete materialized credential files older than maxAgeMs under
59
+ * credentialsDir (pass 0 to sweep everything), then remove the directories
60
+ * left empty. In hub mode every local credential file is a TTL-bound cache
61
+ * of hub content — never a permanent copy: resolve re-materializes on demand
62
+ * (writeIfChanged), so deletion is always safe and transparent to consumers.
63
+ * Best-effort: individual failures are skipped, the next sweep retries.
64
+ * Returns the number of files removed.
65
+ */
66
+ export async function sweepMaterialized(credentialsDir, maxAgeMs) {
67
+ const now = Date.now();
68
+ let removed = 0;
69
+ const walk = async (dir, isRoot) => {
70
+ let entries;
71
+ try {
72
+ entries = await readdir(dir, { withFileTypes: true });
73
+ }
74
+ catch {
75
+ return; // missing/unreadable dir — nothing to sweep
76
+ }
77
+ for (const entry of entries) {
78
+ const p = `${dir}/${entry.name}`;
79
+ if (entry.isDirectory()) {
80
+ await walk(p, false);
81
+ }
82
+ else {
83
+ const st = await stat(p).catch(() => null);
84
+ if (!st || !st.isFile())
85
+ continue;
86
+ if (maxAgeMs > 0 && now - st.mtimeMs <= maxAgeMs)
87
+ continue;
88
+ await rm(p, { force: true }).catch(() => { });
89
+ removed++;
90
+ }
91
+ }
92
+ // Prune emptied dirs on the way up (rmdir refuses non-empty — a
93
+ // concurrent materialization racing the sweep is never harmed).
94
+ if (!isRoot)
95
+ await rmdir(dir).catch(() => { });
96
+ };
97
+ await walk(credentialsDir, true);
98
+ return removed;
99
+ }
100
+ /** Sanitize one entry of the hub's GET /entries response (durable boundary). */ function sanitizeEntry(raw) {
101
+ if (!isPlainObject(raw))
102
+ return null;
103
+ if (typeof raw.kind !== 'string' || typeof raw.name !== 'string')
104
+ return null;
105
+ const tiers = {};
106
+ if (isPlainObject(raw.tiers)) {
107
+ for (const tier of ['ro', 'rw']) {
108
+ const t = raw.tiers[tier];
109
+ if (t === undefined || t === null)
110
+ continue;
111
+ const probe = probeOf(t);
112
+ tiers[tier] = probe !== undefined ? { probe } : {};
113
+ }
114
+ }
115
+ return { kind: raw.kind, name: raw.name, envelope: buildEnvelope([isPlainObject(raw.envelope) ? raw.envelope : undefined]), tiers };
116
+ }
117
+ export class HubBackend {
118
+ opts;
119
+ label;
120
+ constructor(opts) {
121
+ this.opts = opts;
122
+ this.label = `access hub at ${opts.baseUrl}`;
123
+ }
124
+ /**
125
+ * One HTTP call. Returns null on 404; throws on every other failure with
126
+ * the hub's error message (which never carries field values). The auth
127
+ * token rides an Authorization header and never lands in error text.
128
+ */
129
+ async request(method, path, body, admin = false) {
130
+ const token = admin ? this.opts.adminToken : this.opts.readToken;
131
+ let res;
132
+ try {
133
+ res = await fetch(this.opts.baseUrl + path, {
134
+ method,
135
+ headers: {
136
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
137
+ ...(token !== '' ? { authorization: `Bearer ${token}` } : {}),
138
+ },
139
+ body: body !== undefined ? JSON.stringify(body) : undefined,
140
+ });
141
+ }
142
+ catch (err) {
143
+ throw new Error(`ops-access: cannot reach ${this.label}: ${err?.message ?? err}`);
144
+ }
145
+ if (res.status === 404)
146
+ return null;
147
+ const text = await res.text();
148
+ let parsed = null;
149
+ if (text !== '') {
150
+ try {
151
+ parsed = JSON.parse(text);
152
+ }
153
+ catch {
154
+ throw new Error(`ops-access: ${this.label} returned a non-JSON response (${res.status})`);
155
+ }
156
+ }
157
+ if (!res.ok) {
158
+ const message = isPlainObject(parsed) && typeof parsed.error === 'string' ? parsed.error : res.statusText;
159
+ throw new Error(`ops-access: ${this.label} rejected ${method} ${path} (${res.status}): ${message}`);
160
+ }
161
+ return parsed;
162
+ }
163
+ async listEntries() {
164
+ const data = await this.request('GET', '/entries');
165
+ if (!Array.isArray(data))
166
+ return [];
167
+ return data
168
+ .map(sanitizeEntry)
169
+ .filter((e) => e !== null)
170
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
171
+ }
172
+ async loadTier(kind, name, tier, loadOpts) {
173
+ const data = await this.request('GET', `/entries/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${tier}`);
174
+ if (data === null || !isPlainObject(data) || !isPlainObject(data.fields))
175
+ return null;
176
+ const fields = { ...data.fields };
177
+ const provider = this.opts.getProvider(kind);
178
+ const materialize = loadOpts?.materialize !== false;
179
+ // File fields arrive as CONTENT; substitute the managed local path,
180
+ // writing the file only when the credential is actually being issued
181
+ // (materialize) — metadata reads must not persist secret material.
182
+ for (const ff of provider?.fileFields ?? []) {
183
+ const content = fields[ff];
184
+ if (typeof content !== 'string' || content === '')
185
+ continue;
186
+ const target = `${this.opts.cacheDir}/${kind}/${name}/${tier}/${ff}`;
187
+ if (materialize)
188
+ await writeIfChanged(target, content);
189
+ fields[ff] = target;
190
+ }
191
+ const probe = probeOf(data);
192
+ return {
193
+ fields,
194
+ envelope: buildEnvelope([isPlainObject(data.envelope) ? data.envelope : undefined]),
195
+ ...(probe !== undefined ? { probe } : {}),
196
+ };
197
+ }
198
+ async putTier(kind, name, tier, fields, envelope, probe) {
199
+ const provider = this.opts.getProvider(kind);
200
+ const out = { ...fields };
201
+ // Paths → content: the hub stores the secret material itself. An
202
+ // unreadable managed file fails loud — a half-written credential on the
203
+ // hub is worse than no write.
204
+ for (const ff of provider?.fileFields ?? []) {
205
+ const value = out[ff];
206
+ if (typeof value !== 'string' || value === '')
207
+ continue;
208
+ const source = expandHome(value);
209
+ try {
210
+ out[ff] = await readFile(source, 'utf8');
211
+ }
212
+ catch (err) {
213
+ throw new Error(`ops-access: cannot read credential file ${source} for upload to the hub: ${err?.message ?? err}`);
214
+ }
215
+ }
216
+ // Envelope merge needs the entry's current envelope (the hub replaces it
217
+ // wholesale); the fields-free listing carries it.
218
+ const entries = await this.listEntries().catch(() => []);
219
+ const existing = entries.find((e) => e.kind === kind && e.name === name);
220
+ const merged = mergeEnvelope(existing?.envelope ?? {}, envelope);
221
+ await this.request('PUT', `/entries/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${tier}`, {
222
+ fields: out,
223
+ envelope: merged,
224
+ ...(probe !== undefined ? { probe } : {}),
225
+ }, true);
226
+ }
227
+ async deleteTier(kind, name, tier) {
228
+ const res = await this.request('DELETE', `/entries/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${tier}`, undefined, true);
229
+ if (res === null)
230
+ return 'missing';
231
+ // Whether the whole entry went with the tier decides the managed-dir
232
+ // cleanup scope — re-list and look.
233
+ const entries = await this.listEntries().catch(() => []);
234
+ return entries.some((e) => e.kind === kind && e.name === name) ? 'tier' : 'entry';
235
+ }
236
+ // ── Registration-request queue (hub-only, not part of AccessBackend) ──────
237
+ // The agent-facing rw write path: submit a request, a human approves it in
238
+ // the admin UI, and only then does the hub write the tier. Fields carry
239
+ // CONTENT here (the agent pastes secret material directly — there is no
240
+ // local staging file on this path).
241
+ async submitRequest(req) {
242
+ const data = await this.request('POST', '/requests', {
243
+ kind: req.kind,
244
+ name: req.name,
245
+ tier: req.tier,
246
+ fields: req.fields,
247
+ ...(req.envelope !== undefined ? { envelope: req.envelope } : {}),
248
+ ...(req.reason !== undefined ? { reason: req.reason } : {}),
249
+ }, true);
250
+ if (!isPlainObject(data) || typeof data.id !== 'string') {
251
+ throw new Error(`ops-access: ${this.label} returned a malformed request id`);
252
+ }
253
+ return data.id;
254
+ }
255
+ /** 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}`);
258
+ }
259
+ /** Full request incl. field values, for pre-approval review. Null when absent. */
260
+ async getRequest(id) {
261
+ return this.request('GET', `/requests/${encodeURIComponent(id)}`, undefined, true);
262
+ }
263
+ /** Approve (hub writes the tier) or reject. Returns false when already settled/absent. */
264
+ async decideRequest(id, approved) {
265
+ const data = await this.request('POST', `/requests/${encodeURIComponent(id)}/decide`, { approved }, true);
266
+ return data !== null;
267
+ }
268
+ }
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;
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),
@@ -381,7 +383,11 @@ export function apply(ctx, config) {
381
383
  // default and behaves byte-for-byte as before; hub fetches entries from a
382
384
  // remote ops-access-hub on every call and materializes file-field content
383
385
  // to managed local files under credentialsDir.
384
- const source = config.source ?? 'yaml';
386
+ // Env seam (ACCESS_HUB_URL): setting it flips an unconfigured deployment to
387
+ // hub mode. The ops preset file is re-materialized on every suite upgrade,
388
+ // so config written into it is silently dropped — the process env (systemd
389
+ // unit) is the only upgrade-proof seam.
390
+ const source = config.source ?? (process.env.ACCESS_HUB_URL ? 'hub' : 'yaml');
385
391
  // In hub mode every local credential file — materialized reads AND staged
386
392
  // writes — lives under hubCacheDir as a TTL-bound cache. credentialsDir
387
393
  // stays yaml-mode territory: the sweeper must never touch files the yaml
@@ -389,9 +395,9 @@ export function apply(ctx, config) {
389
395
  const contentDir = source === 'hub' ? expandHome(config.hubCacheDir ?? '~/.dsh-ops/hub-cache') : credentialsDir;
390
396
  let backend;
391
397
  if (source === 'hub') {
392
- const hubUrl = (config.hubUrl ?? '').replace(/\/+$/, '');
398
+ const hubUrl = (config.hubUrl || process.env.ACCESS_HUB_URL || '').replace(/\/+$/, '');
393
399
  if (hubUrl === '') {
394
- throw new Error('ops-access: source "hub" requires hubUrl (e.g. http://127.0.0.1:3090)');
400
+ throw new Error('ops-access: source "hub" requires hubUrl (e.g. http://127.0.0.1:3090) or env ACCESS_HUB_URL');
395
401
  }
396
402
  backend = new HubBackend({
397
403
  baseUrl: hubUrl,
package/package.json CHANGED
@@ -1,15 +1,12 @@
1
1
  {
2
2
  "name": "@elinpf/dsh-ops-access",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
7
7
  "types": "lib/index.d.ts",
8
8
  "files": [
9
- "lib/index.js",
10
- "lib/invariant.js",
11
- "lib/types.js",
12
- "lib/mention.js",
9
+ "lib/**/*.js",
13
10
  "lib/**/*.d.ts",
14
11
  "cordis.patch.yml"
15
12
  ],