@elinpf/dsh-ops-access 0.1.7 → 0.2.1

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
@@ -7,9 +7,11 @@ The ops access capability seam — owns the YAML credential registry (default `~
7
7
  - **Single registry file, zero cache**: every `resolve`/`list`/`writeEntry` re-reads, re-parses, and re-validates the YAML — edits take effect immediately, no restart.
8
8
  - **Tiered entries**: each profile carries an `ro` tier (agent-readable default) and an `rw` tier (served only through a registered broker grant).
9
9
  - **Provider seam**: one provider per credential kind (`k8s`/`ceph`/`ssh` packages) supplies only a zod schema plus field processing (`~` expansion, content validation, capability probe). Providers register via `registerAccessProvider(ctx, provider)` — never hand-write `ctx.inject` for sibling services, it deadlocks the loader.
10
- - **`register_access` tool**: the agent's self-service path for writing the ro tier (rw stays human-managed via the admin HTTP routes).
10
+ - **Reference fields** (`references`): a provider may declare that a field names another KIND's entry (ssh's `cred` → `ssh-cred`), so many entries share one credential instead of each carrying a copy. At resolve time core merges the referenced entry's fields UNDER the referring entry's (same registry, same tier, one level only); the broker is consulted once, on the referring entry. `validateResolved` is the post-merge hook for requirements that only hold on the merged shape (ssh needs a login user from either side). A dangling reference fails the referring resolve AND its `canResolve` precheck.
11
+ - **`register_access` tool**: the agent's self-service path for writing the ro tier; with `tier: "rw"` it instead **submits an rw registration request** (hub mode only, ADR-0008) — the request queues on the hub and is written only after an operator reviews the field contents and approves it in the admin settings section.
11
12
  - **Mention support**: `@[kind/name](dsh-access:<payload>)` mentions are parsed on `agent/pre-step` and rewritten to readable references with envelope context; `GET /ops-access/list` feeds the browser's `@` picker. The encoding lives in the `./mention` subpath.
12
13
  - **Admin routes**: `GET /ops-access/admin/list`, `GET /ops-access/admin/kinds`, `GET|POST|DELETE /ops-access/admin/entry` — envelope + validation status only, never field values.
14
+ - **Pluggable credential source** (`source`): `yaml` (default) is the local registry file; `hub` fetches entries from a standalone [ops-access-hub](../../ops-access-hub/) service on every call. In hub mode, file-field content is materialized to a SEPARATE cache dir (`hubCacheDir`, default `~/.dsh-ops/hub-cache`) as a TTL-bound cache (`materializeTtlMinutes`, default 15) — never a permanent copy: startup sweeps the whole cache (the grant ledger dies with the process, so cached rw material must not outlive it), the interval sweep expires by age, and resolve re-materializes transparently on demand. The yaml-mode `credentialsDir` (the documented fallback) is never swept.
13
15
 
14
16
  ## Design notes
15
17
 
@@ -25,6 +27,12 @@ The ops access capability seam — owns the YAML credential registry (default `~
25
27
  name: '@elinpf/dsh-ops-access'
26
28
  registryFile: ~/.dsh-ops/access.yaml # default
27
29
  credentialsDir: ~/.dsh-ops/credentials # default; managed credential content files (0600)
30
+ # source: hub # optional; default yaml
31
+ # hubUrl: http://127.0.0.1:3090 # hub source: hub base URL
32
+ # hubToken: ... # hub source: read token (or env ACCESS_HUB_READ_TOKEN)
33
+ # hubAdminToken: ... # hub source: write token (or env ACCESS_HUB_ADMIN_TOKEN)
34
+ # hubCacheDir: ~/.dsh-ops/hub-cache # hub source: TTL materialization cache (never credentialsDir)
35
+ # materializeTtlMinutes: 15 # hub source: cache TTL; startup sweeps all
28
36
  ```
29
37
 
30
38
  ## Testing
package/README.zh.md CHANGED
@@ -7,9 +7,11 @@
7
7
  - **单注册表文件、零缓存**:每次 `resolve`/`list`/`writeEntry` 都重新读取、解析、校验 YAML — 改文件立即生效,无需重启。
8
8
  - **分层条目**:每个 profile 携带 `ro` 层(agent 默认可读)和 `rw` 层(只有注册了 broker 授权后才发放)。
9
9
  - **Provider 缝**:每种凭据类型一个 provider(`k8s`/`ceph`/`ssh` 包),只提供 zod schema 加字段处理(`~` 展开、内容校验、能力探测)。provider 通过 `registerAccessProvider(ctx, provider)` 注册 — 绝不要手写 `ctx.inject` 依赖兄弟服务,会死锁 loader。
10
- - **`register_access` 工具**:agent 自助写入 ro 层的路径(rw 层始终由人通过 admin HTTP 路由管理)。
10
+ - **引用字段**(`references`):provider 可声明某字段指向另一个种类的条目(ssh 的 `cred` `ssh-cred`),让多个条目共享一份凭证而不是各自复制。resolve core 把被引用条目的字段合并到引用方**之下**(同注册表、同 tier、只展开一层);broker 只被咨询一次,针对引用方条目。`validateResolved` 是合并后的校验钩子,承载只在合并形状上成立的要求(ssh 的登录用户可来自任一侧)。悬挂引用会让引用方的 resolve 及其 `canResolve` 预检一起失败。
11
+ - **`register_access` 工具**:agent 自助写入 ro 层的路径;传 `tier: "rw"` 则**提交 rw 注册申请**(仅 hub 模式,ADR-0008)——申请排队在 hub 上,管理员在凭证管理设置区审查字段内容并批准后才真正写入。
11
12
  - **Mention 支持**:`@[kind/name](dsh-access:<payload>)` mention 在 `agent/pre-step` 上被解析、重写为可读引用并注入 envelope 上下文;`GET /ops-access/list` 给浏览器的 `@` 选择器供数。编码在 `./mention` 子路径。
12
13
  - **Admin 路由**:`GET /ops-access/admin/list`、`GET /ops-access/admin/kinds`、`GET|POST|DELETE /ops-access/admin/entry` — 只出 envelope + 校验状态,绝不出字段值。
14
+ - **可插拔凭证来源**(`source`):`yaml`(默认)读本地注册表文件;`hub` 每次调用都从独立部署的 [ops-access-hub](../../ops-access-hub/) 服务拉取。hub 模式下,文件类字段的内容物化到**独立的缓存目录**(`hubCacheDir`,默认 `~/.dsh-ops/hub-cache`),是受 TTL 约束的缓存(`materializeTtlMinutes`,默认 15 分钟),**绝不是永久副本**:启动时全量清扫(grant 账本随进程消亡,缓存的 rw 材料不得比它活得久),定时清扫按年龄过期,resolve 现取现物化、到期透明重建。yaml 模式的 `credentialsDir`(文档化的回退路径)永不被清扫。
13
15
 
14
16
  ## 设计要点
15
17
 
@@ -25,6 +27,12 @@
25
27
  name: '@elinpf/dsh-ops-access'
26
28
  registryFile: ~/.dsh-ops/access.yaml # 默认值
27
29
  credentialsDir: ~/.dsh-ops/credentials # 默认值;托管凭据内容文件(0600)
30
+ # source: hub # 可选;默认 yaml
31
+ # hubUrl: http://127.0.0.1:3090 # hub 来源:hub 服务地址
32
+ # hubToken: ... # hub 来源:读 token(或用环境变量 ACCESS_HUB_READ_TOKEN)
33
+ # hubAdminToken: ... # hub 来源:写 token(或用环境变量 ACCESS_HUB_ADMIN_TOKEN)
34
+ # hubCacheDir: ~/.dsh-ops/hub-cache # hub 来源:TTL 物化缓存目录(绝不用 credentialsDir)
35
+ # materializeTtlMinutes: 15 # hub 来源:缓存 TTL;启动时全量清扫
28
36
  ```
29
37
 
30
38
  ## 测试
@@ -0,0 +1,113 @@
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 type { EntryEnvelope, ProbeState } from './types.js';
20
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
21
+ /** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
22
+ export declare function buildEnvelope(sources: Array<Record<string, unknown> | undefined>): EntryEnvelope;
23
+ /** Read a persisted probe result off a raw tier object (durable boundary — sanitize). */
24
+ export declare function probeOf(tierRaw: unknown): ProbeState | undefined;
25
+ /**
26
+ * Apply the envelope patch discipline to a mutable target: undefined field =
27
+ * preserve, empty string = delete, else set. Shared by both backends so the
28
+ * merge semantics stay identical across sources.
29
+ */
30
+ export declare function applyEnvelopePatch(target: Record<string, unknown>, envelope: EntryEnvelope | undefined): void;
31
+ /** Merge an envelope patch over an existing envelope, returning the result (hub backend variant). */
32
+ export declare function mergeEnvelope(existing: EntryEnvelope, patch: EntryEnvelope | undefined): EntryEnvelope;
33
+ /** Tier presence + probe for the fields-free listing view. */
34
+ export interface BackendTierStatus {
35
+ probe?: ProbeState;
36
+ }
37
+ /** One entry in the fields-free listing view (listAll, resolve error hints). */
38
+ export interface BackendEntry {
39
+ kind: string;
40
+ name: string;
41
+ envelope: EntryEnvelope;
42
+ tiers: {
43
+ ro?: BackendTierStatus;
44
+ rw?: BackendTierStatus;
45
+ };
46
+ }
47
+ /** One tier's raw data: provider-shaped fields (file fields are LOCAL PATHS) + envelope + probe. */
48
+ export interface BackendTier {
49
+ fields: Record<string, unknown>;
50
+ envelope: EntryEnvelope;
51
+ probe?: ProbeState;
52
+ }
53
+ /** Thrown when the source itself is unavailable (yaml: registry file missing) — resolve surfaces it verbatim. */
54
+ export declare class SourceUnavailableError extends Error {
55
+ }
56
+ export interface AccessBackend {
57
+ /**
58
+ * Human phrase for error messages, used as `in ${label}`: yaml →
59
+ * `registry file <path>`, hub → `access hub at <url>`.
60
+ */
61
+ readonly label: string;
62
+ /**
63
+ * The fields-free listing of every entry: envelope + tier presence +
64
+ * probes. A missing source yields an empty list; an unreadable/corrupt
65
+ * source THROWS (callers pick their own degrade discipline, matching the
66
+ * pre-backend behavior per method).
67
+ */
68
+ listEntries(): Promise<BackendEntry[]>;
69
+ /**
70
+ * One tier's provider-shaped fields + envelope. Null when the entry or the
71
+ * tier does not exist. Throws SourceUnavailableError when the source
72
+ * itself is missing, and rethrows read/parse failures.
73
+ *
74
+ * `materialize` (default true) matters only for the hub backend: true
75
+ * writes fetched file-field contents to managed local files (resolve —
76
+ * the credential is being issued); false substitutes the would-be managed
77
+ * path WITHOUT touching disk (canResolve/list/getEntry — metadata reads
78
+ * must not write secret material, e.g. the gate's pre-approval check).
79
+ */
80
+ loadTier(kind: string, name: string, tier: 'ro' | 'rw', opts?: {
81
+ materialize?: boolean;
82
+ }): Promise<BackendTier | null>;
83
+ /**
84
+ * Persist one tier. `fields` is provider-shaped (file fields are local
85
+ * paths — the hub backend reads their content and uploads THAT; paths
86
+ * never leave the machine). `envelope` follows the patch discipline
87
+ * (undefined = preserve, '' = delete). `probe`, when given, is stored
88
+ * beside the tier.
89
+ */
90
+ putTier(kind: string, name: string, tier: 'ro' | 'rw', fields: Record<string, unknown>, envelope: EntryEnvelope | undefined, probe?: ProbeState): Promise<void>;
91
+ /**
92
+ * Remove one tier. Returns 'missing' when the entry did not exist, 'tier'
93
+ * when the tier is gone but the entry survives, 'entry' when the last
94
+ * tier went and the whole entry was dropped. (A tier that was already
95
+ * absent on an existing entry reports as if deleted — the pre-backend
96
+ * deleteEntry answered true for an existing entry regardless.)
97
+ */
98
+ deleteTier(kind: string, name: string, tier: 'ro' | 'rw'): Promise<'missing' | 'tier' | 'entry'>;
99
+ }
100
+ /**
101
+ * The original local YAML registry. Every operation re-reads (and writes
102
+ * back) the whole file — edits take effect immediately, nothing is cached.
103
+ * Sections whose kind has no registered provider are preserved untouched.
104
+ */
105
+ export declare class YamlBackend implements AccessBackend {
106
+ readonly registryFile: string;
107
+ readonly label: string;
108
+ constructor(registryFile: string);
109
+ listEntries(): Promise<BackendEntry[]>;
110
+ loadTier(kind: string, name: string, tier: 'ro' | 'rw'): Promise<BackendTier | null>;
111
+ putTier(kind: string, name: string, tier: 'ro' | 'rw', fields: Record<string, unknown>, envelope: EntryEnvelope | undefined, probe?: ProbeState): Promise<void>;
112
+ deleteTier(kind: string, name: string, tier: 'ro' | 'rw'): Promise<'missing' | 'tier' | 'entry'>;
113
+ }
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,83 @@
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 type { AccessProvider, EntryEnvelope, ProbeState } from './types.js';
25
+ import type { AccessBackend, BackendEntry, BackendTier } from './backend.js';
26
+ export interface HubBackendOptions {
27
+ /** Hub base URL, trailing slashes stripped (e.g. `http://127.0.0.1:3090`). */
28
+ baseUrl: string;
29
+ /** Bearer token for reads (resolve/list); empty = anonymous. */
30
+ readToken: string;
31
+ /** Bearer token for writes (put/delete); empty = anonymous. */
32
+ adminToken: string;
33
+ /**
34
+ * Cache root for materialized credential files (already `~`-expanded).
35
+ * Deliberately NOT the yaml mode's credentialsDir: hub-mode local files are
36
+ * a TTL-bound cache, never permanent copies, and the sweeper must never
37
+ * touch files the yaml registry still references (the documented fallback).
38
+ */
39
+ cacheDir: string;
40
+ /** Provider lookup — file-field declarations drive the content ↔ path conversion. */
41
+ getProvider: (kind: string) => AccessProvider | undefined;
42
+ }
43
+ /**
44
+ * Delete materialized credential files older than maxAgeMs under
45
+ * credentialsDir (pass 0 to sweep everything), then remove the directories
46
+ * left empty. In hub mode every local credential file is a TTL-bound cache
47
+ * of hub content — never a permanent copy: resolve re-materializes on demand
48
+ * (writeIfChanged), so deletion is always safe and transparent to consumers.
49
+ * Best-effort: individual failures are skipped, the next sweep retries.
50
+ * Returns the number of files removed.
51
+ */
52
+ export declare function sweepMaterialized(credentialsDir: string, maxAgeMs: number): Promise<number>;
53
+ export declare class HubBackend implements AccessBackend {
54
+ private readonly opts;
55
+ readonly label: string;
56
+ constructor(opts: HubBackendOptions);
57
+ /**
58
+ * One HTTP call. Returns null on 404; throws on every other failure with
59
+ * the hub's error message (which never carries field values). The auth
60
+ * token rides an Authorization header and never lands in error text.
61
+ */
62
+ private request;
63
+ listEntries(): Promise<BackendEntry[]>;
64
+ loadTier(kind: string, name: string, tier: 'ro' | 'rw', loadOpts?: {
65
+ materialize?: boolean;
66
+ }): Promise<BackendTier | null>;
67
+ putTier(kind: string, name: string, tier: 'ro' | 'rw', fields: Record<string, unknown>, envelope: EntryEnvelope | undefined, probe?: ProbeState): Promise<void>;
68
+ deleteTier(kind: string, name: string, tier: 'ro' | 'rw'): Promise<'missing' | 'tier' | 'entry'>;
69
+ submitRequest(req: {
70
+ kind: string;
71
+ name: string;
72
+ tier: 'ro' | 'rw';
73
+ fields: Record<string, unknown>;
74
+ envelope?: EntryEnvelope;
75
+ reason?: string;
76
+ }): Promise<string>;
77
+ /** Pending-request metadata for the approval UI — field values never cross. */
78
+ listRequests(status?: 'pending' | 'approved' | 'rejected'): Promise<unknown>;
79
+ /** Full request incl. field values, for pre-approval review. Null when absent. */
80
+ getRequest(id: string): Promise<unknown>;
81
+ /** Approve (hub writes the tier) or reject. Returns false when already settled/absent. */
82
+ decideRequest(id: string, approved: boolean): Promise<boolean>;
83
+ }