@elinpf/dsh-ops-access-hub 0.2.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 ADDED
@@ -0,0 +1,53 @@
1
+ # @elinpf/dsh-ops-access-hub
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Standalone credential hub for the dsh ops suite — **not a dsh plugin**. A small, separately deployable service that stores every ops-access credential in a single AES-256-GCM-encrypted document and serves it over a token-authenticated HTTP API, with a minimal web UI and a YAML registry importer. `@elinpf/dsh-ops-access` (core) can use it as its credential source (`source: 'hub'`); the hub itself is dumb storage — kind schemas, validation, and probes all stay on the access side.
6
+
7
+ ## What it does
8
+
9
+ - **Encrypted-at-rest storage.** The whole dataset is one JSON document (`<data-dir>/hub-data.json.enc`), AES-256-GCM with a random nonce per write, atomic write-temp-then-rename, mode 0600. The master key comes from env `ACCESS_HUB_KEY` (base64/hex) or a key file (default `<data-dir>/hub.key`, generated 0600 on first start). File fields hold their *content*, not paths.
10
+ - **Dual-token HTTP API** (bare `node:http`, default bind `127.0.0.1:3090`): an admin token for everything, a read token for listing and resolve; compared with `crypto.timingSafeEqual`, 401/403 distinguished.
11
+ - **Append-only audit log** (`<data-dir>/audit.log`, JSONL): every successful resolve/put/delete with the token role — never field values.
12
+ - **Single-file web UI** (`GET /`, Chinese): token input (localStorage), entry list with probe badges, create/edit/delete, audit viewer. The page itself holds no secrets.
13
+
14
+ ## Usage
15
+
16
+ ```sh
17
+ dsh-ops-access-hub serve [--port 3090] [--host 127.0.0.1] [--data-dir ~/.dsh-ops-hub] \
18
+ [--key-file <file>] [--admin-token <t>] [--read-token <t>]
19
+
20
+ dsh-ops-access-hub import <access.yaml> \
21
+ (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
22
+ ```
23
+
24
+ Every `serve` flag has an env counterpart (`ACCESS_HUB_PORT`, `ACCESS_HUB_HOST`, `ACCESS_HUB_DATA_DIR`, `ACCESS_HUB_KEY_FILE`, `ACCESS_HUB_ADMIN_TOKEN`, `ACCESS_HUB_READ_TOKEN`). Tokens left unset are generated randomly and printed exactly once on first start.
25
+
26
+ `import` converts an existing ops-access YAML registry: a single-line field value starting with `/`, `~/`, `./` or `../` that points at a readable file is replaced by the file's content (relative paths resolve against the registry file's directory); everything else passes through unchanged. Push into a running hub with `--url`, or write the data file directly with `--data-dir`.
27
+
28
+ ## API overview
29
+
30
+ | Endpoint | Auth | Purpose |
31
+ |---|---|---|
32
+ | `GET /health` | none | `{ok:true}` |
33
+ | `GET /` | none | the static web UI |
34
+ | `GET /entries` | read+ | envelope + tier presence + probe per entry — **never field values** |
35
+ | `GET /entries/:kind/:name/:tier` | read+ | full fields for one tier (audited as `resolve`); 404 when absent |
36
+ | `PUT /entries/:kind/:name/:tier` | admin | upsert `{fields, envelope?, probe?}`; envelope replaces wholesale |
37
+ | `DELETE /entries/:kind/:name/:tier` | admin | removing the last tier deletes the whole entry |
38
+ | `GET /audit?limit=N` | admin | recent N audit records (default 100, cap 1000) |
39
+
40
+ ## Security notes
41
+
42
+ - v1 speaks plain HTTP and binds loopback by default — remote deployments must put the hub behind a TLS-terminating reverse proxy.
43
+ - The hub is a single point of custody: **back up both the data file and the master key.** Without the key the data file is unrecoverable.
44
+ - Keep tokens out of logs and shell history (prefer env injection); the read token suffices for consumers — only writers need the admin token.
45
+
46
+ ## Testing
47
+
48
+ ```sh
49
+ npm run build # tsc → lib/
50
+ npx vitest run # crypto round-trip, key-file generation and permissions,
51
+ # API auth (401/403), CRUD, last-tier cascade delete,
52
+ # probe write-back, audit append, import path→content
53
+ ```
package/README.zh.md ADDED
@@ -0,0 +1,52 @@
1
+ # @elinpf/dsh-ops-access-hub
2
+
3
+ [English](README.md) | 中文
4
+
5
+ dsh ops 插件集的独立凭证中心——**不是 dsh 插件**。一个小型、可独立部署的服务:全部 ops-access 凭证存于单一 AES-256-GCM 加密文档,对外提供 token 认证的 HTTP API,附带精简 Web 界面和 YAML 注册表导入器。`@elinpf/dsh-ops-access`(core)可以把它作为凭证来源(`source: 'hub'`);hub 本身是哑存储——kind schema、校验、能力探针都留在 access 侧。
6
+
7
+ ## 功能
8
+
9
+ - **加密落盘**:整个数据集是一个 JSON 文档(`<data-dir>/hub-data.json.enc`),AES-256-GCM、每次写随机 nonce、tmp+rename 原子写、0600。master key 来自环境变量 `ACCESS_HUB_KEY`(base64/hex)或 key 文件(默认 `<data-dir>/hub.key`,首启自动生成,0600)。文件类字段存**内容**而非路径。
10
+ - **双 token HTTP API**(裸 `node:http`,默认绑 `127.0.0.1:3090`):admin token 全量、read token 仅列表与解析;`crypto.timingSafeEqual` 比较,401/403 区分。
11
+ - **append-only 审计日志**(`<data-dir>/audit.log`,JSONL):每次成功的 resolve/put/delete 连同 token 角色各记一行——永不记字段值。
12
+ - **单文件中文 Web UI**(`GET /`):token 输入(localStorage)、带 probe 徽标的条目列表、新建/编辑/删除、审计查看。页面本身不含任何秘密。
13
+
14
+ ## 用法
15
+
16
+ ```sh
17
+ dsh-ops-access-hub serve [--port 3090] [--host 127.0.0.1] [--data-dir ~/.dsh-ops-hub] \
18
+ [--key-file <file>] [--admin-token <t>] [--read-token <t>]
19
+
20
+ dsh-ops-access-hub import <access.yaml> \
21
+ (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
22
+ ```
23
+
24
+ 每个 `serve` flag 都有环境变量对应(`ACCESS_HUB_PORT`、`ACCESS_HUB_HOST`、`ACCESS_HUB_DATA_DIR`、`ACCESS_HUB_KEY_FILE`、`ACCESS_HUB_ADMIN_TOKEN`、`ACCESS_HUB_READ_TOKEN`)。未配置的 token 首启随机生成并**只打印一次**。
25
+
26
+ `import` 把现有 ops-access YAML 注册表搬进 hub:单行且以 `/`、`~/`、`./`、`../` 开头并指向可读文件的字段值替换为文件内容(相对路径相对注册表文件目录解析),其余原样通过。`--url` 在线推送进运行中的 hub,或 `--data-dir` 离线直写数据文件。
27
+
28
+ ## API 一览
29
+
30
+ | 端点 | 鉴权 | 用途 |
31
+ |---|---|---|
32
+ | `GET /health` | 无 | `{ok:true}` |
33
+ | `GET /` | 无 | 静态 Web UI |
34
+ | `GET /entries` | read+ | 每条目的 envelope + tier 存在性 + probe——**永不含字段值** |
35
+ | `GET /entries/:kind/:name/:tier` | read+ | 单个 tier 的完整 fields(记 `resolve` 审计);缺失 404 |
36
+ | `PUT /entries/:kind/:name/:tier` | admin | upsert `{fields, envelope?, probe?}`;envelope 整体替换 |
37
+ | `DELETE /entries/:kind/:name/:tier` | admin | 删除最后一个 tier 时整条删除 |
38
+ | `GET /audit?limit=N` | admin | 最近 N 条审计(默认 100,上限 1000) |
39
+
40
+ ## 安全注意
41
+
42
+ - v1 是明文 HTTP,默认只绑 loopback——远程部署必须把 hub 放在 TLS 反向代理之后。
43
+ - hub 是单点:**数据文件和 master key 都要备份**。丢了 key,数据文件无法恢复。
44
+ - token 不要进日志和 shell 历史(优先用环境变量注入);消费方只配 read token,admin token 只给写入方。
45
+
46
+ ## 测试
47
+
48
+ ```sh
49
+ npm run build # tsc → lib/
50
+ npx vitest run # 加解密往返、key 文件生成与权限、API 鉴权(401/403)、
51
+ # CRUD、最后-tier 连锁删除、probe 回写、审计追加、import 路径→内容
52
+ ```
package/lib/cli.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-ops-access-hub` CLI. Hand-rolled minimal argv parsing — no commander
4
+ * & co. by design (dependency floor: `yaml` only).
5
+ *
6
+ * Commands:
7
+ *
8
+ * - `serve` — run the hub HTTP service.
9
+ * - `import <access.yaml>` — convert an ops-access YAML registry and push it
10
+ * into a running hub (`--url` + `--admin-token`) or straight into a data
11
+ * directory (`--data-dir`, needs the master key).
12
+ * - `--help` — usage.
13
+ *
14
+ * @module
15
+ */
16
+ export {};
package/lib/cli.js ADDED
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-ops-access-hub` CLI. Hand-rolled minimal argv parsing — no commander
4
+ * & co. by design (dependency floor: `yaml` only).
5
+ *
6
+ * Commands:
7
+ *
8
+ * - `serve` — run the hub HTTP service.
9
+ * - `import <access.yaml>` — convert an ops-access YAML registry and push it
10
+ * into a running hub (`--url` + `--admin-token`) or straight into a data
11
+ * directory (`--data-dir`, needs the master key).
12
+ * - `--help` — usage.
13
+ *
14
+ * @module
15
+ */
16
+ import { randomBytes } from 'node:crypto';
17
+ import os from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { HubStore } from './store.js';
20
+ import { createHubServer } from './server.js';
21
+ import { applyToStore, importRegistry, pushToHub } from './import.js';
22
+ const USAGE = `dsh-ops-access-hub — standalone credential hub for the dsh ops suite
23
+
24
+ Usage:
25
+ dsh-ops-access-hub serve [options]
26
+ dsh-ops-access-hub import <access.yaml> (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
27
+ dsh-ops-access-hub --help
28
+
29
+ serve options (flag / env / default):
30
+ --port ACCESS_HUB_PORT 3090
31
+ --host ACCESS_HUB_HOST 127.0.0.1
32
+ --data-dir ACCESS_HUB_DATA_DIR ~/.dsh-ops-hub
33
+ --key-file ACCESS_HUB_KEY_FILE <data-dir>/hub.key
34
+ --admin-token ACCESS_HUB_ADMIN_TOKEN (generated + printed once when unset)
35
+ --read-token ACCESS_HUB_READ_TOKEN (generated + printed once when unset)
36
+
37
+ Master key: env ACCESS_HUB_KEY (base64/hex) wins; otherwise the key file is
38
+ used and generated (0600) on first start.
39
+ `;
40
+ /** Minimal parser: `--flag value` pairs; everything else is positional. */
41
+ function parseArgs(argv) {
42
+ const out = { positional: [], flags: {} };
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const arg = argv[i];
45
+ if (arg.startsWith('--')) {
46
+ const eq = arg.indexOf('=');
47
+ if (eq !== -1)
48
+ out.flags[arg.slice(2, eq)] = arg.slice(eq + 1);
49
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith('--'))
50
+ out.flags[arg.slice(2)] = argv[++i];
51
+ else
52
+ out.flags[arg.slice(2)] = 'true';
53
+ }
54
+ else {
55
+ out.positional.push(arg);
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+ /** Expand a leading `~` to $HOME. */
61
+ function expandHome(p) {
62
+ return p === '~' ? os.homedir() : p.startsWith('~/') ? join(os.homedir(), p.slice(2)) : p;
63
+ }
64
+ function pick(flags, flag, env, fallback) {
65
+ return flags[flag] ?? env ?? fallback;
66
+ }
67
+ async function serve(args) {
68
+ const { flags } = args;
69
+ const port = Number.parseInt(pick(flags, 'port', process.env.ACCESS_HUB_PORT, '3090'), 10);
70
+ if (!Number.isFinite(port) || port < 1 || port > 65535)
71
+ throw new Error(`invalid port`);
72
+ const host = pick(flags, 'host', process.env.ACCESS_HUB_HOST, '127.0.0.1');
73
+ const dataDir = expandHome(pick(flags, 'data-dir', process.env.ACCESS_HUB_DATA_DIR, '~/.dsh-ops-hub'));
74
+ const keyFile = expandHome(pick(flags, 'key-file', process.env.ACCESS_HUB_KEY_FILE, join(dataDir, 'hub.key')));
75
+ // Any token not injected via flag/env is generated randomly and printed
76
+ // exactly once — there is no recovery path other than reconfiguring.
77
+ let adminToken = flags['admin-token'] ?? process.env.ACCESS_HUB_ADMIN_TOKEN;
78
+ let readToken = flags['read-token'] ?? process.env.ACCESS_HUB_READ_TOKEN;
79
+ if (!adminToken) {
80
+ adminToken = randomBytes(24).toString('base64url');
81
+ console.log(`generated admin token (save it now — it will not be shown again):\n ${adminToken}`);
82
+ }
83
+ if (!readToken) {
84
+ do {
85
+ readToken = randomBytes(24).toString('base64url');
86
+ } while (readToken === adminToken);
87
+ console.log(`generated read token (save it now — it will not be shown again):\n ${readToken}`);
88
+ }
89
+ const store = new HubStore({ dataDir, keyFile, envKey: process.env.ACCESS_HUB_KEY });
90
+ await store.init();
91
+ const server = createHubServer({ store, adminToken, readToken });
92
+ await new Promise((resolveListen, rejectListen) => {
93
+ server.once('error', rejectListen);
94
+ server.listen(port, host, () => resolveListen());
95
+ });
96
+ console.log(`dsh-ops-access-hub listening on http://${host}:${port} (data dir: ${dataDir})`);
97
+ }
98
+ async function importCmd(args) {
99
+ const { positional, flags } = args;
100
+ const registryFile = positional[1];
101
+ if (!registryFile)
102
+ throw new Error('import: missing <access.yaml> argument');
103
+ const hubUrl = flags.url;
104
+ const adminToken = flags['admin-token'] ?? process.env.ACCESS_HUB_ADMIN_TOKEN;
105
+ const dataDir = flags['data-dir'] ? expandHome(flags['data-dir']) : undefined;
106
+ if (!hubUrl && !dataDir)
107
+ throw new Error('import: specify either --url <hubUrl> or --data-dir <dir>');
108
+ if (hubUrl && dataDir)
109
+ throw new Error('import: --url and --data-dir are mutually exclusive');
110
+ if (hubUrl && !adminToken)
111
+ throw new Error('import: --url mode requires --admin-token (or ACCESS_HUB_ADMIN_TOKEN)');
112
+ const { entries, stats } = await importRegistry(expandHome(registryFile));
113
+ if (hubUrl) {
114
+ await pushToHub(hubUrl, adminToken, entries);
115
+ }
116
+ else {
117
+ const keyFile = expandHome(pick(flags, 'key-file', process.env.ACCESS_HUB_KEY_FILE, join(dataDir, 'hub.key')));
118
+ const store = new HubStore({ dataDir: dataDir, keyFile, envKey: process.env.ACCESS_HUB_KEY });
119
+ await store.init();
120
+ applyToStore(store, entries);
121
+ await store.save();
122
+ }
123
+ console.log(`imported ${stats.entries} entries, ${stats.tiers} tiers, ${stats.fileFields} file fields inlined`);
124
+ }
125
+ async function main() {
126
+ const args = parseArgs(process.argv.slice(2));
127
+ const command = args.positional[0];
128
+ if (command === 'serve')
129
+ return serve(args);
130
+ if (command === 'import')
131
+ return importCmd(args);
132
+ if (command === 'help' || args.flags.help === 'true' || args.flags.h === 'true' || command === undefined) {
133
+ console.log(USAGE);
134
+ return;
135
+ }
136
+ console.error(`unknown command: ${command}\n\n${USAGE}`);
137
+ process.exitCode = 1;
138
+ }
139
+ main().catch((err) => {
140
+ console.error(err.message);
141
+ process.exitCode = 1;
142
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * At-rest encryption for the hub data document.
3
+ *
4
+ * AES-256-GCM with a random 12-byte nonce per write. The on-disk file is a
5
+ * base64-encoded JSON document `{ nonce, data }` where `data` is
6
+ * ciphertext || 16-byte auth tag — a text container (chosen over raw binary
7
+ * concatenation) so the file stays inspectable and survives text-oriented
8
+ * tooling.
9
+ *
10
+ * The master key is 32 bytes and comes from, in priority order:
11
+ *
12
+ * 1. env `ACCESS_HUB_KEY` — base64 or hex (64 hex chars, checked first since
13
+ * a hex string is also valid base64 input);
14
+ * 2. a key file (default `<data-dir>/hub.key`, base64-encoded) — generated
15
+ * on first start with mode 0600 when absent.
16
+ *
17
+ * @module
18
+ */
19
+ export declare const MASTER_KEY_BYTES = 32;
20
+ /** Generate a fresh random master key. */
21
+ export declare function generateMasterKey(): Buffer;
22
+ /**
23
+ * Parse a master key from its text form: 64 hex chars, or base64 decoding to
24
+ * exactly 32 bytes. Hex is tried first (a hex string also parses as base64).
25
+ */
26
+ export declare function parseMasterKey(raw: string): Buffer;
27
+ /**
28
+ * Resolve the master key: `envKey` wins; otherwise read `keyFile`, generating
29
+ * it (mode 0600, parent dirs created) on first start.
30
+ */
31
+ export declare function loadMasterKey(opts: {
32
+ envKey?: string;
33
+ keyFile: string;
34
+ }): Promise<Buffer>;
35
+ /** Encrypt a UTF-8 document; returns the base64 text container for the file. */
36
+ export declare function encryptDoc(plaintext: string, key: Buffer): string;
37
+ /** Decrypt a document produced by {@link encryptDoc}; throws on any tampering (GCM tag check). */
38
+ export declare function decryptDoc(blob: string, key: Buffer): string;
package/lib/crypto.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * At-rest encryption for the hub data document.
3
+ *
4
+ * AES-256-GCM with a random 12-byte nonce per write. The on-disk file is a
5
+ * base64-encoded JSON document `{ nonce, data }` where `data` is
6
+ * ciphertext || 16-byte auth tag — a text container (chosen over raw binary
7
+ * concatenation) so the file stays inspectable and survives text-oriented
8
+ * tooling.
9
+ *
10
+ * The master key is 32 bytes and comes from, in priority order:
11
+ *
12
+ * 1. env `ACCESS_HUB_KEY` — base64 or hex (64 hex chars, checked first since
13
+ * a hex string is also valid base64 input);
14
+ * 2. a key file (default `<data-dir>/hub.key`, base64-encoded) — generated
15
+ * on first start with mode 0600 when absent.
16
+ *
17
+ * @module
18
+ */
19
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
20
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
21
+ import { dirname } from 'node:path';
22
+ export const MASTER_KEY_BYTES = 32;
23
+ const NONCE_BYTES = 12;
24
+ const TAG_BYTES = 16;
25
+ /** Generate a fresh random master key. */
26
+ export function generateMasterKey() {
27
+ return randomBytes(MASTER_KEY_BYTES);
28
+ }
29
+ /**
30
+ * Parse a master key from its text form: 64 hex chars, or base64 decoding to
31
+ * exactly 32 bytes. Hex is tried first (a hex string also parses as base64).
32
+ */
33
+ export function parseMasterKey(raw) {
34
+ const text = raw.trim();
35
+ if (/^[0-9a-fA-F]{64}$/.test(text))
36
+ return Buffer.from(text, 'hex');
37
+ const key = Buffer.from(text, 'base64');
38
+ if (key.length !== MASTER_KEY_BYTES) {
39
+ throw new Error(`invalid master key: expected ${MASTER_KEY_BYTES} bytes (base64 or hex)`);
40
+ }
41
+ return key;
42
+ }
43
+ /**
44
+ * Resolve the master key: `envKey` wins; otherwise read `keyFile`, generating
45
+ * it (mode 0600, parent dirs created) on first start.
46
+ */
47
+ export async function loadMasterKey(opts) {
48
+ if (opts.envKey)
49
+ return parseMasterKey(opts.envKey);
50
+ try {
51
+ return parseMasterKey(await readFile(opts.keyFile, 'utf8'));
52
+ }
53
+ catch (err) {
54
+ if (err.code !== 'ENOENT')
55
+ throw err;
56
+ }
57
+ const key = generateMasterKey();
58
+ await mkdir(dirname(opts.keyFile), { recursive: true });
59
+ await writeFile(opts.keyFile, key.toString('base64') + '\n', { mode: 0o600 });
60
+ return key;
61
+ }
62
+ /** Encrypt a UTF-8 document; returns the base64 text container for the file. */
63
+ export function encryptDoc(plaintext, key) {
64
+ const nonce = randomBytes(NONCE_BYTES);
65
+ const cipher = createCipheriv('aes-256-gcm', key, nonce);
66
+ const data = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final(), cipher.getAuthTag()]);
67
+ return Buffer.from(JSON.stringify({ nonce: nonce.toString('base64'), data: data.toString('base64') }), 'utf8')
68
+ .toString('base64');
69
+ }
70
+ /** Decrypt a document produced by {@link encryptDoc}; throws on any tampering (GCM tag check). */
71
+ export function decryptDoc(blob, key) {
72
+ const packed = JSON.parse(Buffer.from(blob.trim(), 'base64').toString('utf8'));
73
+ const nonce = Buffer.from(packed.nonce, 'base64');
74
+ const data = Buffer.from(packed.data, 'base64');
75
+ if (nonce.length !== NONCE_BYTES || data.length < TAG_BYTES)
76
+ throw new Error('corrupt hub data file');
77
+ const tag = data.subarray(data.length - TAG_BYTES);
78
+ const ciphertext = data.subarray(0, data.length - TAG_BYTES);
79
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce);
80
+ decipher.setAuthTag(tag);
81
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
82
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Import an existing ops-access YAML registry into the hub.
3
+ *
4
+ * Registry format (see `@elinpf/dsh-ops-access`): `version: 1` at the top;
5
+ * every other top-level key is a kind; keys inside a kind are profile names;
6
+ * an entry carries optional envelope fields (`name` / `description` /
7
+ * `environment`) and `ro` / `rw` tier sub-objects holding the fields.
8
+ *
9
+ * Conversion rule: within a tier, a field whose value is a single-line
10
+ * string starting with `/`, `~/`, `./` or `../` is treated as a file path;
11
+ * when it points at a readable file the value is replaced by the file's
12
+ * content (the hub stores content, not paths). A path-shaped value that
13
+ * cannot be read aborts the import with an error naming the entry and
14
+ * field. All other values pass through unchanged. Relative paths resolve
15
+ * against the registry file's directory; `~` expands to `$HOME`.
16
+ *
17
+ * @module
18
+ */
19
+ import type { EntryEnvelope, HubStore, ProbeState } from './store.js';
20
+ export interface ImportedEntry {
21
+ kind: string;
22
+ name: string;
23
+ envelope: EntryEnvelope;
24
+ tiers: {
25
+ ro?: {
26
+ fields: Record<string, unknown>;
27
+ probe?: ProbeState;
28
+ };
29
+ rw?: {
30
+ fields: Record<string, unknown>;
31
+ probe?: ProbeState;
32
+ };
33
+ };
34
+ }
35
+ export interface ImportStats {
36
+ entries: number;
37
+ tiers: number;
38
+ /** Tier fields whose path value was replaced by file content. */
39
+ fileFields: number;
40
+ }
41
+ export interface ImportResult {
42
+ entries: ImportedEntry[];
43
+ stats: ImportStats;
44
+ }
45
+ /** Parse and convert a registry file. Throws on malformed YAML or an unreadable path-shaped field. */
46
+ export declare function importRegistry(registryFile: string): Promise<ImportResult>;
47
+ /** Push imported entries into a running hub over HTTP (one PUT per tier). */
48
+ export declare function pushToHub(hubUrl: string, adminToken: string, entries: ImportedEntry[]): Promise<void>;
49
+ /** Write imported entries directly into a store (offline mode; caller owns init/save). */
50
+ export declare function applyToStore(store: HubStore, entries: ImportedEntry[]): void;
package/lib/import.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Import an existing ops-access YAML registry into the hub.
3
+ *
4
+ * Registry format (see `@elinpf/dsh-ops-access`): `version: 1` at the top;
5
+ * every other top-level key is a kind; keys inside a kind are profile names;
6
+ * an entry carries optional envelope fields (`name` / `description` /
7
+ * `environment`) and `ro` / `rw` tier sub-objects holding the fields.
8
+ *
9
+ * Conversion rule: within a tier, a field whose value is a single-line
10
+ * string starting with `/`, `~/`, `./` or `../` is treated as a file path;
11
+ * when it points at a readable file the value is replaced by the file's
12
+ * content (the hub stores content, not paths). A path-shaped value that
13
+ * cannot be read aborts the import with an error naming the entry and
14
+ * field. All other values pass through unchanged. Relative paths resolve
15
+ * against the registry file's directory; `~` expands to `$HOME`.
16
+ *
17
+ * @module
18
+ */
19
+ import { readFile } from 'node:fs/promises';
20
+ import os from 'node:os';
21
+ import { dirname, isAbsolute, resolve } from 'node:path';
22
+ import { parse as parseYaml } from 'yaml';
23
+ const PATH_PREFIX = /^(\/|~\/|\.\/|\.\.\/)/;
24
+ /** A value is path-shaped when it is a single-line string with a path prefix. */
25
+ function looksLikePath(v) {
26
+ return typeof v === 'string' && !v.includes('\n') && PATH_PREFIX.test(v);
27
+ }
28
+ function isPlainObject(v) {
29
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
30
+ }
31
+ /** Parse and convert a registry file. Throws on malformed YAML or an unreadable path-shaped field. */
32
+ export async function importRegistry(registryFile) {
33
+ const doc = parseYaml(await readFile(registryFile, 'utf8'));
34
+ if (!isPlainObject(doc))
35
+ throw new Error(`import: ${registryFile} is not a YAML mapping`);
36
+ const baseDir = dirname(resolve(registryFile));
37
+ const entries = [];
38
+ const stats = { entries: 0, tiers: 0, fileFields: 0 };
39
+ for (const [kind, section] of Object.entries(doc)) {
40
+ if (kind === 'version')
41
+ continue;
42
+ if (!isPlainObject(section))
43
+ throw new Error(`import: kind '${kind}' is not a mapping`);
44
+ for (const [name, rawEntry] of Object.entries(section)) {
45
+ if (!isPlainObject(rawEntry))
46
+ throw new Error(`import: entry '${kind}/${name}' is not a mapping`);
47
+ const envelope = {};
48
+ if (typeof rawEntry.name === 'string')
49
+ envelope.name = rawEntry.name;
50
+ if (typeof rawEntry.description === 'string')
51
+ envelope.description = rawEntry.description;
52
+ if (typeof rawEntry.environment === 'string')
53
+ envelope.environment = rawEntry.environment;
54
+ const entry = { kind, name, envelope, tiers: {} };
55
+ for (const tier of ['ro', 'rw']) {
56
+ const rawTier = rawEntry[tier];
57
+ if (rawTier === undefined)
58
+ continue;
59
+ if (!isPlainObject(rawTier))
60
+ throw new Error(`import: tier '${kind}/${name} ${tier}' is not a mapping`);
61
+ // The auto-managed `probe` key rides BESIDE the fields in the
62
+ // registry (written at save time by the access probe) — lift it to
63
+ // the tier level instead of importing it as a field.
64
+ const { probe: rawProbe, ...rawFields } = rawTier;
65
+ const probe = isPlainObject(rawProbe) ? rawProbe : undefined;
66
+ const fields = {};
67
+ for (const [field, value] of Object.entries(rawFields)) {
68
+ if (looksLikePath(value)) {
69
+ const expanded = value.startsWith('~') ? os.homedir() + value.slice(1) : value;
70
+ const filePath = isAbsolute(expanded) ? expanded : resolve(baseDir, expanded);
71
+ try {
72
+ fields[field] = await readFile(filePath, 'utf8');
73
+ }
74
+ catch (err) {
75
+ throw new Error(`import: cannot read file field '${kind}/${name} ${tier}.${field}' (${filePath}): ${err.message}`);
76
+ }
77
+ stats.fileFields++;
78
+ }
79
+ else {
80
+ fields[field] = value;
81
+ }
82
+ }
83
+ entry.tiers[tier] = probe !== undefined ? { fields, probe } : { fields };
84
+ stats.tiers++;
85
+ }
86
+ entries.push(entry);
87
+ stats.entries++;
88
+ }
89
+ }
90
+ return { entries, stats };
91
+ }
92
+ /** Push imported entries into a running hub over HTTP (one PUT per tier). */
93
+ export async function pushToHub(hubUrl, adminToken, entries) {
94
+ const base = hubUrl.replace(/\/+$/, '');
95
+ for (const entry of entries) {
96
+ for (const tier of ['ro', 'rw']) {
97
+ const tierData = entry.tiers[tier];
98
+ if (!tierData)
99
+ continue;
100
+ const url = `${base}/entries/${encodeURIComponent(entry.kind)}/${encodeURIComponent(entry.name)}/${tier}`;
101
+ const res = await fetch(url, {
102
+ method: 'PUT',
103
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${adminToken}` },
104
+ body: JSON.stringify({ fields: tierData.fields, envelope: entry.envelope, ...(tierData.probe !== undefined ? { probe: tierData.probe } : {}) }),
105
+ });
106
+ if (!res.ok) {
107
+ const body = (await res.json().catch(() => undefined));
108
+ throw new Error(`import: PUT ${entry.kind}/${entry.name} ${tier} failed (${res.status}): ${body?.error ?? res.statusText}`);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ /** Write imported entries directly into a store (offline mode; caller owns init/save). */
114
+ export function applyToStore(store, entries) {
115
+ for (const entry of entries) {
116
+ for (const tier of ['ro', 'rw']) {
117
+ const tierData = entry.tiers[tier];
118
+ if (!tierData)
119
+ continue;
120
+ store.putTier(entry.kind, entry.name, tier, { fields: tierData.fields, envelope: entry.envelope, ...(tierData.probe !== undefined ? { probe: tierData.probe } : {}) });
121
+ }
122
+ }
123
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Ops access hub — a standalone, centrally deployable credential management
3
+ * service for the dsh ops suite. **Not a dsh plugin**: no cordis patch, no
4
+ * preset row. The access side pulls secret content from it over HTTP.
5
+ *
6
+ * - encrypted-at-rest single-document store (AES-256-GCM), see `./store.js`;
7
+ * - token-authenticated REST API + minimal web UI, see `./server.js`;
8
+ * - YAML registry importer, see `./import.js`;
9
+ * - `dsh-ops-access-hub` bin (`serve` / `import`), see `./cli.js`.
10
+ *
11
+ * @module @elinpf/dsh-ops-access-hub
12
+ */
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';
16
+ export { createHubServer, NAME_PATTERN } from './server.js';
17
+ export type { HubServerOptions } from './server.js';
18
+ export { importRegistry, pushToHub, applyToStore } from './import.js';
19
+ export type { ImportedEntry, ImportStats, ImportResult } from './import.js';
20
+ export { WEB_UI_HTML } from './web.js';
package/lib/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Ops access hub — a standalone, centrally deployable credential management
3
+ * service for the dsh ops suite. **Not a dsh plugin**: no cordis patch, no
4
+ * preset row. The access side pulls secret content from it over HTTP.
5
+ *
6
+ * - encrypted-at-rest single-document store (AES-256-GCM), see `./store.js`;
7
+ * - token-authenticated REST API + minimal web UI, see `./server.js`;
8
+ * - YAML registry importer, see `./import.js`;
9
+ * - `dsh-ops-access-hub` bin (`serve` / `import`), see `./cli.js`.
10
+ *
11
+ * @module @elinpf/dsh-ops-access-hub
12
+ */
13
+ export { MASTER_KEY_BYTES, generateMasterKey, parseMasterKey, loadMasterKey, encryptDoc, decryptDoc } from './crypto.js';
14
+ export { HubStore } from './store.js';
15
+ export { createHubServer, NAME_PATTERN } from './server.js';
16
+ export { importRegistry, pushToHub, applyToStore } from './import.js';
17
+ export { WEB_UI_HTML } from './web.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * HTTP API for the hub, built on bare `node:http` (no framework by design).
3
+ *
4
+ * Endpoints (default bind `127.0.0.1:3090`):
5
+ *
6
+ * - `GET /health` → `{ok:true}`, no auth
7
+ * - `GET /` → the static web UI, no auth (it holds no secrets)
8
+ * - `GET /entries` → `[{kind,name,envelope,tiers:{ro?:{probe?},rw?:{probe?}},updatedAt}]`
9
+ * (read+; field values never appear here)
10
+ * - `GET /entries/:kind/:name/:tier` → `{kind,name,tier,fields,envelope,probe?}` (read+; audited as `resolve`)
11
+ * - `PUT /entries/:kind/:name/:tier` → upsert, body `{fields,envelope?,probe?}` (admin)
12
+ * - `DELETE /entries/:kind/:name/:tier` → `{ok:true}` / 404 (admin; last tier removes the entry)
13
+ * - `GET /audit?limit=N` → recent N audit records (admin, default 100)
14
+ * - `POST /requests` → queue a tier-registration request, body
15
+ * `{kind,name,tier,fields,envelope?,reason?}` (admin)
16
+ * - `GET /requests?status=pending` → request list, metadata only — field
17
+ * names + byte sizes, never values (read+)
18
+ * - `GET /requests/:id` → full request incl. field values, for
19
+ * pre-approval review (admin)
20
+ * - `POST /requests/:id/decide` → `{approved:boolean}`; approval writes the
21
+ * tier, either way the request's fields are
22
+ * wiped (admin; 409 unless pending)
23
+ *
24
+ * Auth: two Bearer tokens — admin (everything) and read (`GET /entries*`
25
+ * only). Comparisons use `crypto.timingSafeEqual`. Every error response is
26
+ * JSON `{ok:false,error}` and `error` never contains field values.
27
+ *
28
+ * @module
29
+ */
30
+ import type { Server } from 'node:http';
31
+ 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;
34
+ export interface HubServerOptions {
35
+ store: HubStore;
36
+ adminToken: string;
37
+ readToken: string;
38
+ }
39
+ export declare function createHubServer(opts: HubServerOptions): Server;