@hadoku/dev-vault 0.0.0-stage → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,140 @@
1
- # Temporary Holding Version
1
+ # @wolffm/dev-vault
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local-dev vault-broker shim for the hadoku ecosystem. Fetches secrets from the
4
+ mgmt-api vault broker and execs a child command with them in `process.env` —
5
+ the local-dev counterpart to the production PM2 wrappers.
6
+
7
+ It execs **any** child command, so it works for Node repos (`vite`, etc.) and
8
+ non-Node repos alike (e.g. a Python CLI). No `.env` files, ever — secret values
9
+ live only in the spawned child's `process.env` and die with the process.
10
+
11
+ ## Why a package
12
+
13
+ The shim used to live only inside the `hadoku_site` repo at
14
+ `scripts/secrets/dev-vault.mjs`, so every child repo's `pnpm dev` hardcoded a
15
+ `../hadoku_site/scripts/secrets/dev-vault.mjs` path and a contributor had to
16
+ clone `hadoku_site` as a sibling just to reach the vault. Publishing it removes
17
+ that coupling: a child repo depends on a versioned package, not a sibling repo.
18
+
19
+ ## Install (one-time, per machine)
20
+
21
+ The package lives on GitHub Packages under the `@wolffm` scope. GitHub Packages
22
+ requires auth even to read, so add to your `~/.npmrc`:
23
+
24
+ ```
25
+ @wolffm:registry=https://npm.pkg.github.com
26
+ //npm.pkg.github.com/:_authToken=<a GitHub token with read:packages>
27
+ ```
28
+
29
+ The `read:packages` token is a **bootstrap credential** — like `HADOKU_VAULT_KEY`,
30
+ you receive it directly from the operator; it is _not_ fetched from the vault
31
+ (that would be circular: you'd need the vault to install the tool that reaches
32
+ the vault). See `hadoku_site/docs/operations/SECRETS.md` → "Bootstrap secrets".
33
+
34
+ ## Use
35
+
36
+ ```sh
37
+ # Non-Node repo (e.g. hadoku-scraper) — npx, no install into the repo:
38
+ npx @wolffm/dev-vault -- hs hopper migrate
39
+
40
+ # Node repo — add as a devDependency, then `pnpm dev` is:
41
+ dev-vault -- vite
42
+
43
+ # Self-test — diagnoses mapping / broker / vault / per-key fetch / ACL drift:
44
+ npx @wolffm/dev-vault --check
45
+ ```
46
+
47
+ ## Your own credential: `dev-vault login`
48
+
49
+ If the operator gave you a service grant for this repo (command station →
50
+ Account → Service grants), you hold your own credential rather than a copy of
51
+ the repo's key:
52
+
53
+ ```sh
54
+ npx @wolffm/dev-vault login
55
+ ```
56
+
57
+ It prints a code and a hadoku.me link. Open the link signed in, set up your
58
+ authenticator app the first time (a QR code), then approve with your passkey
59
+ and a code from the app. The command writes `.devvault.local.json` (the main
60
+ checkout's, in a worktree) and every later `dev-vault -- …` uses it.
61
+
62
+ The credential lasts 7 days. dev-vault warns in its last day and refuses once
63
+ it has expired; run `dev-vault login` again. Revoking your grant stops it at
64
+ once, without touching anyone else's.
65
+
66
+ ## How it works
67
+
68
+ 1. Reads `.devvault.json` from the current working directory — a committed
69
+ file mapping env-var names → vault keys (no secret values in it).
70
+ 2. Reads the per-repo service-tier key from `.devvault.local.json`
71
+ (`{ "key": "<uuid>" }`, gitignored, `chmod 600`).
72
+ 3. Fetches each value from the broker: `MGMT_API_BASE` when set; otherwise
73
+ loopback (`http://127.0.0.1:4000`) when mgmt-api answers there within
74
+ 300 ms — i.e. on the mgmt-api host itself — else `https://hadoku.me/mgmt`.
75
+ The `[dev-vault] caller=…` line names the broker it chose. The key is sent
76
+ either way; loopback reads are ACL-checked exactly like edge reads.
77
+ 4. Spawns the child command with the fetched values merged into `process.env`.
78
+
79
+ ## `NODE_AUTH_TOKEN` is derived, not fetched
80
+
81
+ If the repo's `.devvault.json` declares `HADOKU_NPM_READ_TOKEN` and
82
+ `NODE_AUTH_TOKEN` is not already set, dev-vault exports it into the child as
83
+ well.
84
+
85
+ That name is what a tokenless `~/.npmrc` expands
86
+ (`//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}`) and what
87
+ `actions/setup-node` writes in CI, so one npmrc serves both. Without it every
88
+ `@wolffm/*` install fails:
89
+
90
+ ```
91
+ ERR_PNPM_FETCH_401 GET .../@wolffm/<pkg> — Unauthorized
92
+ No authorization header was set for the request.
93
+ ```
94
+
95
+ Two details worth knowing:
96
+
97
+ - **It is the `read:packages` token, never `HADOKU_SITE_TOKEN`.** That one is
98
+ repo/workflow-scoped; its `read:packages` was deliberately removed, so using
99
+ it authenticates and then 403s on every package — harder to diagnose than an
100
+ empty variable.
101
+ - **An existing `NODE_AUTH_TOKEN` always wins**, so CI is unaffected.
102
+
103
+ Derived rather than declared because a repo would otherwise need a vault item
104
+ literally named `NODE_AUTH_TOKEN`, and there isn't one.
105
+
106
+ Full setup and troubleshooting: `hadoku_site/docs/child-apps/USING_VAULT.md`.
107
+
108
+ ## In a git worktree
109
+
110
+ dev-vault reads the MAIN checkout's `.devvault.local.json`, not a copy in the
111
+ worktree. A key swap rewrites only the main checkout's file, so a copy goes
112
+ stale at the next swap.
113
+ A stale key does not 401: it resolves to `public`, and every read 403s like a
114
+ missing ACL grant. Don't copy the file into a worktree; if one has a copy,
115
+ delete it. The worktree's own file is used only when the main checkout has none.
116
+
117
+ ## Installation id
118
+
119
+ Every broker call carries `X-Hadoku-Install`, a random id kept in
120
+ `~/.config/hadoku/install-id` (one per machine and user), and a User-Agent
121
+ naming the host. A key sent as a header never signs in, so this is what lets
122
+ the edge alert when a key is first used from a new machine, including one on a
123
+ network it already knows (edge-router `src/key-use.ts`). It identifies the
124
+ installation and authenticates nothing; the edge stores only its digest. Under
125
+ CI (`CI` or `GITHUB_ACTIONS`) none is sent, because a runner would be a new
126
+ installation every job. Delete the file and the next run is a new installation.
127
+
128
+ ## Env overrides
129
+
130
+ | Var | Default | Purpose |
131
+ | --------------- | ----------------------------------------------- | --------------------- |
132
+ | `MGMT_API_BASE` | loopback if a broker answers, else the edge URL | Vault broker base URL |
133
+ | `DEVVAULT_FILE` | `.devvault.json` (in cwd) | Mapping config path |
134
+
135
+ ## Source of truth
136
+
137
+ This package is the canonical implementation. `hadoku_site` ships a thin
138
+ re-export at `scripts/secrets/dev-vault.mjs` so child repos that still hardcode
139
+ the legacy `node ../hadoku_site/scripts/secrets/dev-vault.mjs` path keep working
140
+ during migration.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Which vault broker dev-vault talks to.
3
+ *
4
+ * The broker is mgmt-api. Reached through the public edge
5
+ * (https://hadoku.me/mgmt) it costs a Cloudflare request, an edge-router auth
6
+ * lookup and a request_logs row per call — and on the host mgmt-api runs on,
7
+ * every one of those calls is the machine talking to itself. On 2026-10-10 a
8
+ * single systemd timer (hadoku-scraper's `hadoku-apply`, every ~5 minutes,
9
+ * ~30 keys per spawn) put ~7,000 such round trips a day through the edge
10
+ * (issue #463).
11
+ *
12
+ * So: an explicit MGMT_API_BASE always wins; otherwise use loopback when a
13
+ * broker answers there, and fall back to the edge everywhere else (a laptop,
14
+ * a claw, CI). The probe is the same unauthenticated `/api/secrets/status`
15
+ * call the pre-flight makes anyway, so its answer is handed back for reuse.
16
+ *
17
+ * Its own module, rather than inline in dev-vault.mjs, because dev-vault.mjs
18
+ * runs on import — this is the part a test can call.
19
+ */
20
+
21
+ export const EDGE_BASE = 'https://hadoku.me/mgmt';
22
+ export const LOOPBACK_BASE = 'http://127.0.0.1:4000';
23
+ export const LOOPBACK_PROBE_TIMEOUT_MS = 300;
24
+
25
+ /**
26
+ * Whether a `/api/secrets/status` body came from mgmt-api's vault broker.
27
+ *
28
+ * Port 4000 is not reserved for mgmt-api — any dev server can hold it on a
29
+ * laptop — so a bare 200 proves nothing. The broker's status payload always
30
+ * carries `success: true` and the boolean pair `sealed` / `unlocked`.
31
+ */
32
+ export function isBrokerStatus(body) {
33
+ return (
34
+ body !== null &&
35
+ typeof body === 'object' &&
36
+ body.success === true &&
37
+ typeof body.sealed === 'boolean' &&
38
+ typeof body.unlocked === 'boolean'
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Ask the loopback broker for its status.
44
+ *
45
+ * Returns the status body when a broker answered, `null` otherwise. `null` is
46
+ * the ordinary answer on every machine that is not the mgmt-api host
47
+ * (connection refused, or nothing answers inside the timeout), so a failure
48
+ * here is the RESULT, not an error to report: the caller falls back to the
49
+ * edge, where a real problem surfaces with the edge's own diagnostics.
50
+ */
51
+ export async function probeLoopback(
52
+ base = LOOPBACK_BASE,
53
+ { timeoutMs = LOOPBACK_PROBE_TIMEOUT_MS, fetchImpl = fetch } = {}
54
+ ) {
55
+ let res;
56
+ try {
57
+ res = await fetchImpl(`${base}/api/secrets/status`, {
58
+ signal: AbortSignal.timeout(timeoutMs),
59
+ });
60
+ } catch {
61
+ return null; // refused / timed out: no broker on this host
62
+ }
63
+ if (res.status !== 200) return null;
64
+ const body = await res.json().catch(() => null);
65
+ return isBrokerStatus(body) ? body : null;
66
+ }
67
+
68
+ /**
69
+ * Pick the broker base.
70
+ *
71
+ * Returns `{ base, source, status }`:
72
+ * - source `MGMT_API_BASE`: the caller set it; never probed, never second-guessed.
73
+ * - source `loopback`: a broker answered on this host; `status` is its body.
74
+ * - source `edge`: the default.
75
+ */
76
+ export async function chooseBrokerBase({ override, probe = probeLoopback } = {}) {
77
+ if (override) return { base: override, source: 'MGMT_API_BASE', status: null };
78
+ const status = await probe(LOOPBACK_BASE);
79
+ if (status) return { base: LOOPBACK_BASE, source: 'loopback', status };
80
+ return { base: EDGE_BASE, source: 'edge', status: null };
81
+ }
package/dev-vault.mjs ADDED
@@ -0,0 +1,545 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dev-vault — local-dev counterpart to the PM2 wrappers.
4
+ *
5
+ * Fetches secrets from the mgmt-api vault broker and execs a child
6
+ * command with them in process.env. Same model as services/pm2/lib/
7
+ * vault-fetch.mjs, but for `pnpm dev`-style local invocations from
8
+ * sibling repos.
9
+ *
10
+ * Published as @wolffm/dev-vault on GitHub Packages. Invoke it however
11
+ * suits the consuming repo — it execs ANY child command, so it works
12
+ * for Node and non-Node (e.g. Python CLI) repos alike:
13
+ *
14
+ * npx @wolffm/dev-vault -- hs hopper migrate # no install, any repo
15
+ * dev-vault -- pnpm vite dev # installed as a devDependency
16
+ * node ../hadoku_site/scripts/secrets/dev-vault.mjs --check # legacy in-repo path
17
+ *
18
+ * Reads `.devvault.json` from the current working directory:
19
+ *
20
+ * {
21
+ * "// comment": "left-hand side = env var name the child reads",
22
+ * "// comment": "right-hand side = vault key to fetch",
23
+ * "QBIT_HOST": "SCRAPE_QBIT_HOST",
24
+ * "QBIT_USERNAME": "SCRAPE_QBIT_USERNAME",
25
+ * "GITHUB_TOKEN": "HADOKU_SITE_TOKEN"
26
+ * }
27
+ *
28
+ * The .devvault.json file holds NO secret values — only a name-to-name
29
+ * mapping. Safe to commit. Secret values are fetched at runtime, live
30
+ * only in the spawned child's process.env, and die with the process.
31
+ *
32
+ * Comments (keys starting with "//") are ignored. Empty file or missing
33
+ * file means no secrets are fetched (still execs the child command).
34
+ *
35
+ * Modes:
36
+ * login put your own 7-day credential into this clone: prints a code
37
+ * and a hadoku.me link, waits for you to approve it there with
38
+ * your passkey and authenticator, then writes
39
+ * .devvault.local.json (login.mjs). Needs a service grant.
40
+ * --check self-test: shows mapping, broker reachability, vault
41
+ * status, per-key fetch result. Exits without spawning.
42
+ * -- CMD ... runs CMD with vault env populated.
43
+ *
44
+ * Broker:
45
+ * An explicit MGMT_API_BASE always wins. Otherwise dev-vault probes
46
+ * http://127.0.0.1:4000/api/secrets/status (300 ms) and uses loopback when
47
+ * mgmt-api's broker answers there — on the mgmt-api host itself, a call
48
+ * through the edge is the machine talking to itself through Cloudflare
49
+ * (issue #463). Everywhere else it falls back to https://hadoku.me/mgmt.
50
+ *
51
+ * Auth:
52
+ * The vault broker traverses edge-router at https://hadoku.me/mgmt/*,
53
+ * which admits service tier and above, resolved from the edge key
54
+ * registry. Each repo needs its own per-repo service-tier key in
55
+ * `<repo>/.devvault.local.json` of the
56
+ * shape `{ "key": "<uuid>" }`. The file is gitignored. Mint via
57
+ * `python ../hadoku_site/scripts/administration.py key-generate
58
+ * --tier service --repo <path>` — that command writes the local file
59
+ * and grants the ACL rows for declared vault keys in one shot. See
60
+ * ../operations/SECRETS.md "Bootstrap secrets".
61
+ *
62
+ * Env overrides:
63
+ * MGMT_API_BASE vault broker base URL (default: loopback when a broker
64
+ * answers on this host, else https://hadoku.me/mgmt)
65
+ * DEVVAULT_FILE config path (default: .devvault.json in cwd)
66
+ *
67
+ * Exit codes:
68
+ * 0 ok / child exited 0
69
+ * 1 fetch / spawn failure (broker unreachable, vault sealed,
70
+ * missing key, child failed to spawn)
71
+ * 2 usage error (no command after --, malformed config)
72
+ * N whatever the child process exited with
73
+ */
74
+
75
+ import { spawn } from 'node:child_process';
76
+ import { existsSync, readFileSync } from 'node:fs';
77
+ import { resolve } from 'node:path';
78
+ import process from 'node:process';
79
+ import { chooseBrokerBase } from './broker-base.mjs';
80
+ import { resolveKeyFile } from './key-file.mjs';
81
+ import { INSTALL_HEADER, installId, userAgent } from './install-id.mjs';
82
+ import { credentialState, login } from './login.mjs';
83
+
84
+ // `dev-vault login` needs no key and no broker: it is how a key arrives.
85
+ if (process.argv[2] === 'login') process.exit(await login());
86
+
87
+ const BROKER = await chooseBrokerBase({ override: process.env.MGMT_API_BASE });
88
+ const MGMT_API_BASE = BROKER.base;
89
+ const CONFIG_PATH = resolve(process.cwd(), process.env.DEVVAULT_FILE || '.devvault.json');
90
+ // In a linked worktree this is the MAIN checkout's file (key-file.mjs).
91
+ const LOCAL_KEY_PATH = resolveKeyFile(process.cwd());
92
+ const UNLOCK_URL = `${MGMT_API_BASE}/api/secrets/admin/unlock`;
93
+
94
+ // Per-repo service-tier key: each sibling repo carries its own UUID in
95
+ // .devvault.local.json (gitignored, mode 0600) of the shape { "key": "<uuid>" }.
96
+ // There is no env-var fallback — the local file is the single source of truth
97
+ // so the ACL grants tracked server-side always match the key being used here.
98
+ function loadLocalKey() {
99
+ if (!existsSync(LOCAL_KEY_PATH)) return { key: '', raw: null };
100
+ try {
101
+ const raw = JSON.parse(readFileSync(LOCAL_KEY_PATH, 'utf-8'));
102
+ if (raw && typeof raw === 'object' && typeof raw.key === 'string') {
103
+ return { key: raw.key.trim(), raw };
104
+ }
105
+ return { key: '', raw: null };
106
+ } catch {
107
+ return { key: '', raw: null };
108
+ }
109
+ }
110
+ const LOCAL = loadLocalKey();
111
+ const VAULT_KEY = LOCAL.key;
112
+
113
+ // A credential from `dev-vault login` lives 7 days. An expired one would
114
+ // authenticate as nobody and every read would 404 or 403 like a missing
115
+ // grant, so say what it is instead.
116
+ const KEY_STATE = credentialState(LOCAL.raw);
117
+ if (KEY_STATE === 'expired') {
118
+ console.error(
119
+ `[dev-vault] your credential expired ${new Date(LOCAL.raw.expiresAt).toLocaleString()}.`
120
+ );
121
+ console.error('[dev-vault] run: dev-vault login');
122
+ process.exit(1);
123
+ }
124
+ if (KEY_STATE === 'expiring') {
125
+ console.error(
126
+ `[dev-vault] your credential expires ${new Date(LOCAL.raw.expiresAt).toLocaleString()}; ` +
127
+ 'run `dev-vault login` to renew it.'
128
+ );
129
+ }
130
+ // VIA_EDGE: requests cross edge-router, which 404s (not 401s) a rejected key —
131
+ // that is what the 404 disambiguation below keys on.
132
+ const VIA_EDGE = /\/mgmt\b/.test(MGMT_API_BASE) && !MGMT_API_BASE.includes('localhost');
133
+ // An auto-selected loopback still needs the key: requireKeyAccess checks the
134
+ // per-key ACL by X-User-Key on loopback too. Without an override the key was
135
+ // always required, and choosing loopback must not change that.
136
+ const KEY_REQUIRED = VIA_EDGE || BROKER.source === 'loopback';
137
+
138
+ if (KEY_REQUIRED && !VAULT_KEY) {
139
+ console.error('[dev-vault] error: no service-tier key found.');
140
+ console.error(`[dev-vault] broker: ${MGMT_API_BASE}`);
141
+ console.error(`[dev-vault] expected file: ${LOCAL_KEY_PATH}`);
142
+ console.error('[dev-vault]');
143
+ console.error(
144
+ "[dev-vault] in a worktree, the main checkout's file is read; it has none either."
145
+ );
146
+ console.error('[dev-vault] a person with a service grant for this repo: run dev-vault login');
147
+ console.error('[dev-vault] otherwise the operator mints the repo key, which writes the file:');
148
+ console.error('[dev-vault] python3 ../hadoku_site/scripts/administration.py \\');
149
+ console.error('[dev-vault] key-generate --tier service --repo <this-repo-path> \\');
150
+ console.error('[dev-vault] --name <repo>-service-key --confirm');
151
+ console.error('[dev-vault] See ../hadoku_site/docs/child-apps/USING_VAULT.md');
152
+ process.exit(1);
153
+ }
154
+
155
+ const { version: VERSION } = JSON.parse(
156
+ readFileSync(new URL('./package.json', import.meta.url), 'utf8')
157
+ );
158
+ // Who is calling, for the edge's key-use alert (install-id.mjs). Sent only
159
+ // with the key: without one there is no identity to attribute it to.
160
+ const INSTALL_ID = VAULT_KEY ? installId() : null;
161
+
162
+ function authHeaders() {
163
+ if (!VAULT_KEY) return {};
164
+ return {
165
+ 'X-User-Key': VAULT_KEY,
166
+ 'User-Agent': userAgent(VERSION),
167
+ ...(INSTALL_ID ? { [INSTALL_HEADER]: INSTALL_ID } : {}),
168
+ };
169
+ }
170
+
171
+ const argv = process.argv.slice(2);
172
+
173
+ // `--` separates dev-vault's own flags from the child command. Anything
174
+ // after `--` belongs to the child (which often has its own `-c` / `--check`)
175
+ // and must not be parsed as a shim flag — see e.g. `-- sh -c '…'`, which
176
+ // previously short-circuited to doctor mode because of a naive `argv.includes`.
177
+ const sepIdx = argv.indexOf('--');
178
+ const shimArgs = sepIdx === -1 ? argv : argv.slice(0, sepIdx);
179
+
180
+ // ─── --check mode: self-test ─────────────────────────────────────────────
181
+ if (shimArgs.includes('--check') || shimArgs.includes('-c')) {
182
+ await runDoctor();
183
+ process.exit(0);
184
+ }
185
+
186
+ // ─── Normal mode: -- separator splits shim args from child command ───────
187
+ if (sepIdx === -1) {
188
+ printUsage();
189
+ process.exit(2);
190
+ }
191
+ const childCmd = argv.slice(sepIdx + 1);
192
+ if (childCmd.length === 0) {
193
+ console.error('[dev-vault] error: missing command after `--`');
194
+ console.error('[dev-vault]');
195
+ printUsage();
196
+ process.exit(2);
197
+ }
198
+
199
+ // ─── Read .devvault.json mapping ────────────────────────────────────────
200
+ let mapping;
201
+ try {
202
+ mapping = readMapping(CONFIG_PATH);
203
+ } catch (err) {
204
+ console.error(`[dev-vault] error: ${err.message}`);
205
+ console.error(`[dev-vault] path: ${CONFIG_PATH}`);
206
+ console.error('[dev-vault] the file should be JSON of the shape:');
207
+ console.error('[dev-vault] { "ENV_VAR_NAME": "VAULT_KEY_NAME", ... }');
208
+ process.exit(2);
209
+ }
210
+ if (mapping === null) {
211
+ console.warn(`[dev-vault] no ${CONFIG_PATH} found — running child with no vault env`);
212
+ console.warn('[dev-vault] if this child needs vault values, create the file:');
213
+ console.warn(`[dev-vault] echo '{}' > ${CONFIG_PATH}`);
214
+ mapping = {};
215
+ }
216
+
217
+ // ─── Pre-flight: broker reachability + unlock state ─────────────────────
218
+ let status = BROKER.status;
219
+ try {
220
+ status ??= await fetchStatus();
221
+ } catch (err) {
222
+ console.error(`[dev-vault] error: cannot reach broker at ${MGMT_API_BASE}`);
223
+ console.error(`[dev-vault] ${err.message}`);
224
+ console.error('[dev-vault] is mgmt-api running and the tunnel up?');
225
+ console.error(`[dev-vault] override the URL with: MGMT_API_BASE=<url> pnpm dev`);
226
+ process.exit(1);
227
+ }
228
+ if (!status.unlocked) {
229
+ console.error('[dev-vault] error: vault is sealed');
230
+ console.error(`[dev-vault] unlock at: ${UNLOCK_URL}`);
231
+ console.error('[dev-vault] (operator unlocks once with master password + TOTP;');
232
+ console.error('[dev-vault] after that the broker is open behind the tunnel)');
233
+ process.exit(1);
234
+ }
235
+
236
+ // ─── Fetch every requested key ─────────────────────────────────────────
237
+ const fetched = {};
238
+ const errors = [];
239
+ for (const [envName, vaultKey] of Object.entries(mapping)) {
240
+ try {
241
+ fetched[envName] = await fetchKey(vaultKey);
242
+ } catch (err) {
243
+ errors.push({ envName, vaultKey, err: err.message });
244
+ }
245
+ }
246
+
247
+ if (errors.length > 0) {
248
+ const allDenied = errors.every((e) => /HTTP 403 access denied/.test(e.err));
249
+ console.error('[dev-vault] error: missing or unfetchable keys:');
250
+ for (const e of errors) {
251
+ console.error(`[dev-vault] ${e.envName} → ${e.vaultKey}: ${e.err}`);
252
+ }
253
+ console.error('[dev-vault]');
254
+ if (allDenied) {
255
+ // All 403s → key exists in vault but caller lacks per-key ACL grant.
256
+ // Remediate via key-acl-sync, NOT set-many.
257
+ const prefix = VAULT_KEY ? `${VAULT_KEY.slice(0, 8)}…` : '<no key>';
258
+ console.error(`[dev-vault] all errors are HTTP 403 — secrets exist, but caller ${prefix}`);
259
+ console.error('[dev-vault] lacks per-key ACL grants. Operator: from hadoku_site/,');
260
+ console.error(`[dev-vault] python scripts/administration.py key-acl-sync \\`);
261
+ console.error(`[dev-vault] --repo ${process.cwd()} --prune`);
262
+ console.error('[dev-vault] (--key auto-resolves from .devvault.local.json)');
263
+ console.error('[dev-vault] (needs HADOKU_ADMIN_KEY or HADOKU_VAULT_KEY + fresh agent-token)');
264
+ } else {
265
+ // Name the command, not a curl. The snippet that used to live here
266
+ // omitted X-User-Key against an admin-tier route, so it was rejected as
267
+ // printed — and it routed around provision-secret, which is what writes
268
+ // the classification, the ACL grants and the Cloudflare push. A
269
+ // vault-only write is the half-state that broke
270
+ // ARCHIVEBOT_SPACES_WEBHOOK_SECRET on 2026-09-07.
271
+ const absent = errors.filter((e) => /not in vault/.test(String(e.err))).map((e) => e.vaultKey);
272
+ const example = absent[0] ?? 'VAULT_KEY';
273
+ console.error('[dev-vault] to add a missing key to the vault, from hadoku_site/:');
274
+ for (const name of absent.length ? absent : [example]) {
275
+ console.error(
276
+ `[dev-vault] python3 scripts/administration.py provision-secret ${name} --prompt --confirm`
277
+ );
278
+ }
279
+ console.error(
280
+ '[dev-vault] --prompt reads the value hidden; never paste a secret as an argv.'
281
+ );
282
+ console.error(
283
+ '[dev-vault] Or in the browser: hadoku.me/command-station → Account → Vault keys.'
284
+ );
285
+ }
286
+ console.error('[dev-vault] then retry pnpm dev.');
287
+ process.exit(1);
288
+ }
289
+
290
+ const keyCount = Object.keys(fetched).length;
291
+ const keyPreview = VAULT_KEY ? `${VAULT_KEY.slice(0, 8)}…` : '(none)';
292
+ // stderr, like every other line dev-vault prints: stdout belongs to the child,
293
+ // and a caller that captures it (a `cmd:` credential helper running
294
+ // `dev-vault -- printenv NAME`) must get the child's output and nothing else.
295
+ console.error(
296
+ `[dev-vault] caller=${keyPreview} (from ${LOCAL_KEY_PATH}) broker=${MGMT_API_BASE} (${BROKER.source}) — fetched ${keyCount} key(s); exec: ${childCmd.join(' ')}`
297
+ );
298
+
299
+ // ─── Derive NODE_AUTH_TOKEN for @wolffm installs ────────────────────────
300
+ // `~/.npmrc` on the dev host is deliberately TOKENLESS and reads
301
+ // `//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}`. That is the name
302
+ // `actions/setup-node` writes in CI, so the same npmrc serves both places —
303
+ // but locally NOTHING set it, and pnpm expanded it to empty. Every
304
+ // `@wolffm/*` install then failed:
305
+ //
306
+ // [ERR_PNPM_FETCH_401] GET .../@wolffm/dataplatform — Unauthorized
307
+ // No authorization header was set for the request.
308
+ //
309
+ // which reads as a broken lockfile or a revoked token rather than a missing
310
+ // variable, and `pnpm vault-install` — the documented fix — failed the same
311
+ // way because it never provided the name the npmrc actually asks for.
312
+ //
313
+ // Derived rather than declared: a repo would otherwise need a vault item
314
+ // literally named NODE_AUTH_TOKEN, and there isn't one. The value is the
315
+ // registry-scoped token, never HADOKU_SITE_TOKEN — that one carried
316
+ // `read:packages` historically but had it deliberately removed in the
317
+ // 2026-08-19 rotation (it is a repo/workflow credential), so falling back to
318
+ // it would authenticate and then 403 on every package, which is strictly
319
+ // worse than not setting the variable at all.
320
+ //
321
+ // An existing NODE_AUTH_TOKEN always wins, so CI is untouched.
322
+ const derived = {};
323
+ if (!process.env.NODE_AUTH_TOKEN && fetched.HADOKU_NPM_READ_TOKEN) {
324
+ derived.NODE_AUTH_TOKEN = fetched.HADOKU_NPM_READ_TOKEN;
325
+ }
326
+
327
+ // ─── Spawn child with merged env ────────────────────────────────────────
328
+ // HADOKU_DEV_VAULT_ACTIVE lets a child process detect it's already running
329
+ // inside dev-vault and skip any self-re-exec. (The old root `preinstall`
330
+ // guard that consumed this was removed 2026-08-03 — pnpm 11 runs preinstall
331
+ // AFTER dependency fetch, so it could never rescue an unauthenticated
332
+ // install; `pnpm vault-install` is the documented path.)
333
+ const child = spawn(childCmd[0], childCmd.slice(1), {
334
+ stdio: 'inherit',
335
+ env: { ...process.env, ...fetched, ...derived, HADOKU_DEV_VAULT_ACTIVE: '1' },
336
+ });
337
+
338
+ child.on('error', (err) => {
339
+ console.error(`[dev-vault] error: failed to spawn ${childCmd[0]}: ${err.message}`);
340
+ console.error(`[dev-vault] is ${childCmd[0]} on PATH? did you forget pnpm install?`);
341
+ process.exit(1);
342
+ });
343
+ child.on('close', (code) => process.exit(code ?? 0));
344
+
345
+ for (const sig of ['SIGINT', 'SIGTERM']) {
346
+ process.on(sig, () => child.kill(sig));
347
+ }
348
+
349
+ // ─── Helpers ────────────────────────────────────────────────────────────
350
+
351
+ function printUsage() {
352
+ console.error('[dev-vault] usage:');
353
+ console.error('[dev-vault] npx @wolffm/dev-vault -- <command> [args...]');
354
+ console.error('[dev-vault] dev-vault -- <command> [args...] (if installed as a dep)');
355
+ console.error(
356
+ '[dev-vault] node ../hadoku_site/scripts/secrets/dev-vault.mjs -- <command> [args...]'
357
+ );
358
+ console.error('[dev-vault] <any of the above> --check');
359
+ console.error('[dev-vault]');
360
+ console.error('[dev-vault] reads .devvault.json from cwd, fetches values from the');
361
+ console.error('[dev-vault] vault broker, execs <command> with them in process.env.');
362
+ }
363
+
364
+ function readMapping(path) {
365
+ if (!existsSync(path)) return null;
366
+ let raw;
367
+ try {
368
+ raw = readFileSync(path, 'utf-8');
369
+ } catch (e) {
370
+ throw new Error(`cannot read ${path}: ${e.message}`);
371
+ }
372
+ let parsed;
373
+ try {
374
+ parsed = JSON.parse(raw);
375
+ } catch (e) {
376
+ throw new Error(`${path} is not valid JSON: ${e.message}`);
377
+ }
378
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
379
+ throw new Error(
380
+ `${path} must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`
381
+ );
382
+ }
383
+ const out = {};
384
+ for (const [k, v] of Object.entries(parsed)) {
385
+ if (k.startsWith('//')) continue; // comments
386
+ if (typeof v !== 'string' || !v) {
387
+ throw new Error(`${path}: value for "${k}" must be a non-empty vault key string`);
388
+ }
389
+ out[k] = v;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ async function fetchStatus() {
395
+ const r = await fetch(`${MGMT_API_BASE}/api/secrets/status`, { headers: authHeaders() });
396
+ // 404 is edge-router's deliberate response to an unauthenticated caller on a
397
+ // gated prefix (it does not advertise the surface), so it is the LIKELY
398
+ // rejection signal — but it is not proof: a routing change or a broker that
399
+ // simply isn't there produces the same status. Say what we actually know.
400
+ if (r.status === 404 && VIA_EDGE) {
401
+ throw new Error(
402
+ 'broker /status returned HTTP 404 from the gated edge path. Most likely the key ' +
403
+ 'was rejected: edge-router 404s (rather than 401s) an unrecognised caller so it ' +
404
+ 'does not advertise the surface. Check the key resolves — it needs a live, ' +
405
+ 'service-tier record in the edge key registry AND a vault item holding its value ' +
406
+ '(hadoku_site: `pnpm run check:key-sync`). If it does, the 404 is a routing or ' +
407
+ 'broker-availability problem instead. Ping the operator.'
408
+ );
409
+ }
410
+ if (!r.ok) throw new Error(`broker /status returned HTTP ${r.status}`);
411
+ return await r.json();
412
+ }
413
+
414
+ async function fetchKey(vaultKey) {
415
+ const r = await fetch(`${MGMT_API_BASE}/api/secrets/get/${encodeURIComponent(vaultKey)}`, {
416
+ headers: authHeaders(),
417
+ });
418
+ const body = await r.json().catch(() => null);
419
+ // On the gated path, 404 ambiguously means either "key not in vault" OR
420
+ // "edge-router rejected your key". Disambiguate by body shape: vault-broker
421
+ // 404s have JSON; edge-router auth-failure 404s have plain text.
422
+ if (r.status === 404 && VIA_EDGE && body === null) {
423
+ throw new Error('gated /mgmt/* rejected HADOKU_VAULT_KEY (not "vault key missing")');
424
+ }
425
+ if (r.status === 404) throw new Error(`not in vault`);
426
+ if (!r.ok) throw new Error(`HTTP ${r.status} ${body?.error || ''}`.trim());
427
+ if (!body?.success || typeof body.value !== 'string') throw new Error('malformed response');
428
+ return body.value;
429
+ }
430
+
431
+ // ─── Doctor mode (--check) ─────────────────────────────────────────────
432
+
433
+ async function runDoctor() {
434
+ console.log('[dev-vault] doctor');
435
+ console.log('-----------------');
436
+ console.log(`broker: ${MGMT_API_BASE} (${BROKER.source})`);
437
+ console.log(`config: ${CONFIG_PATH}`);
438
+ if (KEY_REQUIRED) {
439
+ // Show only the prefix — never the full key, even in --check output, so
440
+ // pasting the doctor report into chat doesn't leak credentials.
441
+ const preview = VAULT_KEY ? `${VAULT_KEY.slice(0, 8)}…` : '(unset!)';
442
+ console.log(`auth: key=${preview} source=${LOCAL_KEY_PATH}`);
443
+ }
444
+ console.log();
445
+
446
+ // 1. mapping file
447
+ let mapping;
448
+ try {
449
+ mapping = readMapping(CONFIG_PATH);
450
+ } catch (err) {
451
+ console.log(`✗ mapping: ${err.message}`);
452
+ return;
453
+ }
454
+ if (mapping === null) {
455
+ console.log('✗ mapping: file not found');
456
+ console.log(` create one: echo '{}' > ${CONFIG_PATH}`);
457
+ return;
458
+ }
459
+ const entries = Object.entries(mapping);
460
+ console.log(`✓ mapping: ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`);
461
+ for (const [envName, vaultKey] of entries) {
462
+ console.log(` ${envName.padEnd(36)} → ${vaultKey}`);
463
+ }
464
+ console.log();
465
+
466
+ // 2. broker reachability
467
+ let status;
468
+ try {
469
+ status = await fetchStatus();
470
+ } catch (err) {
471
+ console.log(`✗ broker: unreachable (${err.message})`);
472
+ console.log(` override URL with: MGMT_API_BASE=<url>`);
473
+ return;
474
+ }
475
+ console.log(`✓ broker: reachable, ${status.keyCount ?? 0} keys cached`);
476
+
477
+ if (!status.unlocked) {
478
+ console.log('✗ vault: SEALED');
479
+ console.log(` unlock at: ${UNLOCK_URL}`);
480
+ return;
481
+ }
482
+ const ageS = status.cacheAgeMs ? Math.round(status.cacheAgeMs / 1000) : '?';
483
+ console.log(`✓ vault: unlocked (cache age ${ageS}s)`);
484
+ console.log();
485
+
486
+ // 3. per-key fetch
487
+ let okCount = 0;
488
+ for (const [envName, vaultKey] of entries) {
489
+ try {
490
+ const v = await fetchKey(vaultKey);
491
+ console.log(`✓ ${envName.padEnd(36)} → ${vaultKey} (${v.length} chars)`);
492
+ okCount++;
493
+ } catch (err) {
494
+ console.log(`✗ ${envName.padEnd(36)} → ${vaultKey} (${err.message})`);
495
+ }
496
+ }
497
+ console.log();
498
+
499
+ // 4. ACL drift check. Cross-references .devvault.json against the grants
500
+ // attached to this key. Catches "I added a new line to .devvault.json
501
+ // but the operator hasn't granted it yet" and "operator pruned a key
502
+ // from my grants but I still have it in the manifest". Service-tier
503
+ // callers can hit /secrets/acl/me without admin auth.
504
+ try {
505
+ const r = await fetch(`${MGMT_API_BASE}/api/secrets/acl/me`, { headers: authHeaders() });
506
+ if (r.ok) {
507
+ const aclBody = await r.json();
508
+ const granted = new Set(aclBody.grants ?? []);
509
+ const declared = new Set(entries.map(([, v]) => v));
510
+ const missingGrants = [...declared].filter((k) => !granted.has(k));
511
+ const extraGrants = [...granted].filter((k) => !declared.has(k));
512
+ if (granted.size === 0 && declared.size > 0) {
513
+ console.log(`⚠ ACL: 0 grants attached to this key — every read will 403.`);
514
+ console.log(` → operator: key-acl-sync --repo .`);
515
+ } else if (missingGrants.length === 0 && extraGrants.length === 0) {
516
+ console.log(`✓ ACL: ${granted.size} grant(s), all match .devvault.json`);
517
+ } else {
518
+ console.log(`⚠ ACL drift detected:`);
519
+ if (missingGrants.length) {
520
+ console.log(` declared in .devvault.json but NOT granted: ${missingGrants.join(', ')}`);
521
+ console.log(` → operator: key-acl-sync --repo .`);
522
+ }
523
+ if (extraGrants.length) {
524
+ console.log(` granted but NOT in .devvault.json: ${extraGrants.join(', ')}`);
525
+ console.log(` → operator: key-acl-sync --repo . --prune`);
526
+ }
527
+ }
528
+ }
529
+ // /secrets/acl/me may not exist on a stale mgmt-api; skip rather than
530
+ // fail the doctor.
531
+ } catch {
532
+ /* swallow */
533
+ }
534
+ console.log();
535
+
536
+ console.log(`summary: ${okCount}/${entries.length} keys fetchable`);
537
+ if (okCount < entries.length) {
538
+ console.log('next: add the missing keys, from hadoku_site/:');
539
+ console.log(
540
+ ' python3 scripts/administration.py provision-secret <NAME> --prompt --confirm'
541
+ );
542
+ console.log(' or in the browser: hadoku.me/command-station → Account → Vault keys');
543
+ console.log(' OR fix the mapping in .devvault.json');
544
+ }
545
+ }
package/install-id.mjs ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The installation id dev-vault sends as `X-Hadoku-Install`, and the
3
+ * User-Agent it sends beside it.
4
+ *
5
+ * A key sent as a header never signs in, so the edge's device ledger never
6
+ * saw where one was used from: on 2026-10-28 the hopper service key was set up
7
+ * on a new machine on the operator's home network and nothing alerted. The
8
+ * network cannot tell two machines on it apart; a random id kept per machine
9
+ * and user can. The edge records each identity's installations and alerts on
10
+ * an unseen one (edge-router `src/key-use.ts`).
11
+ *
12
+ * Not under CI: a runner is a fresh installation every job, so every service
13
+ * key would page on every run. CI is recognised by its network instead.
14
+ *
15
+ * The id is not a credential: it identifies, it authenticates nothing, and
16
+ * the edge stores only its digest.
17
+ */
18
+
19
+ import { randomBytes } from 'node:crypto';
20
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { homedir, hostname, platform } from 'node:os';
22
+ import { dirname, join } from 'node:path';
23
+
24
+ export const INSTALL_HEADER = 'X-Hadoku-Install';
25
+
26
+ export function installIdPath(env = process.env) {
27
+ const base = env.XDG_CONFIG_HOME || join(homedir(), '.config');
28
+ return join(base, 'hadoku', 'install-id');
29
+ }
30
+
31
+ /** Whether this process runs under CI, where no installation id is sent. */
32
+ export function underCi(env = process.env) {
33
+ return env.CI === 'true' || env.CI === '1' || env.GITHUB_ACTIONS === 'true';
34
+ }
35
+
36
+ /**
37
+ * This installation's id, created on first use. Null under CI, or when the
38
+ * file can be neither read nor written: the id only feeds detection, so a
39
+ * read-only home must not stop dev-vault.
40
+ */
41
+ export function installId(env = process.env, path = installIdPath(env)) {
42
+ if (underCi(env)) return null;
43
+ try {
44
+ if (existsSync(path)) {
45
+ const id = readFileSync(path, 'utf8').trim();
46
+ if (/^[0-9a-f]{32}$/.test(id)) return id;
47
+ }
48
+ const id = randomBytes(16).toString('hex');
49
+ mkdirSync(dirname(path), { recursive: true });
50
+ writeFileSync(path, `${id}\n`, { mode: 0o600 });
51
+ chmodSync(path, 0o600);
52
+ return id;
53
+ } catch (err) {
54
+ console.error(`[dev-vault] warning: no installation id (${path}): ${err.message}`);
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** `dev-vault/1.4.0 (linux) host:desk`, in characters the alert keeps. */
60
+ export function userAgent(version, host = hostname(), os = platform()) {
61
+ const clean = (s) =>
62
+ String(s)
63
+ .replace(/[^\w.-]/g, '')
64
+ .slice(0, 32);
65
+ return `dev-vault/${clean(version)} (${clean(os)}) host:${clean(host)}`;
66
+ }
package/key-file.mjs ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Which `.devvault.local.json` holds this repo's caller key.
3
+ *
4
+ * In a linked git worktree, the MAIN checkout's file wins over a copy in the
5
+ * worktree. A key swap rewrites the file in the main checkout only — it has
6
+ * no way to know where copies went. A worktree carrying its own copy therefore
7
+ * holds a retired key after the next swap, and a retired key does not
8
+ * 401: it resolves to `public`, so every vault read 403s and looks like a
9
+ * missing ACL grant (hadoku-task worktree, 2026-10-25). A worktree's own file
10
+ * is used only when the main checkout has none.
11
+ */
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync } from 'node:fs';
15
+ import { basename, dirname, resolve } from 'node:path';
16
+
17
+ const FILE = '.devvault.local.json';
18
+
19
+ /**
20
+ * The main checkout of the repo `cwd` is in, when `cwd` is inside a LINKED
21
+ * worktree; null in the main checkout itself, outside git, or when git is
22
+ * missing.
23
+ */
24
+ export function mainCheckoutOf(cwd, git = runGit) {
25
+ const common = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
26
+ const own = git(cwd, ['rev-parse', '--path-format=absolute', '--git-dir']);
27
+ if (!common || !own || resolve(common) === resolve(own)) return null;
28
+ // A bare repo's worktrees have no main checkout to read from.
29
+ return basename(common) === '.git' ? dirname(common) : null;
30
+ }
31
+
32
+ /** Absolute path of the key file dev-vault should read. */
33
+ export function resolveKeyFile(cwd, { git = runGit, exists = existsSync } = {}) {
34
+ const main = mainCheckoutOf(cwd, git);
35
+ if (main && exists(resolve(main, FILE))) return resolve(main, FILE);
36
+ return resolve(cwd, FILE);
37
+ }
38
+
39
+ function runGit(cwd, args) {
40
+ const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
41
+ return r.status === 0 ? r.stdout.trim() : '';
42
+ }
package/login.mjs ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `dev-vault login`: put a grantee's own 7-day credential into this clone
3
+ * (hadoku_site docs/planning/service-grants-2026-10-31.md).
4
+ *
5
+ * A passkey lives in the browser; dev-vault needs a key on disk. So this asks
6
+ * hadoku.me for a login request, prints a short code and a link, and waits.
7
+ * The person opens the link signed in, and approves with their passkey and a
8
+ * code from their authenticator app. This then receives the key, once, and
9
+ * writes `.devvault.local.json` (the main checkout's, in a worktree) with its
10
+ * expiry, mode 0600. When it expires, dev-vault says to run this again.
11
+ *
12
+ * Everything that touches the network, git or the clock is injected, so the
13
+ * flow is testable without hadoku.me.
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { hostname as osHostname } from 'node:os';
18
+ import { resolve } from 'node:path';
19
+ import { mainCheckoutOf } from './key-file.mjs';
20
+
21
+ export const EDGE = 'https://hadoku.me';
22
+ const FILE = '.devvault.local.json';
23
+ const POLL_MS = 3000;
24
+ const DAY_MS = 24 * 60 * 60 * 1000;
25
+
26
+ function runGit(cwd, args) {
27
+ const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
28
+ return r.status === 0 ? r.stdout.trim() : '';
29
+ }
30
+
31
+ /** `owner/name` from the origin remote (ssh or https), or null. */
32
+ export function repoSlug(cwd, git = runGit) {
33
+ const url = git(cwd, ['remote', 'get-url', 'origin']);
34
+ const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url);
35
+ return m ? `${m[1]}/${m[2]}` : null;
36
+ }
37
+
38
+ /** Where the key goes: the main checkout's file in a worktree, else this one's. */
39
+ export function keyFileTarget(cwd, git = runGit) {
40
+ return resolve(mainCheckoutOf(cwd, git) ?? cwd, FILE);
41
+ }
42
+
43
+ /**
44
+ * Whether a key file's credential still works: `ok`, `expiring` (under a day
45
+ * left) or `expired`. A file without `expiresAt` (a key minted before service
46
+ * grants) never expires here.
47
+ */
48
+ export function credentialState(raw, now = Date.now()) {
49
+ const at = raw && typeof raw.expiresAt === 'number' ? raw.expiresAt : null;
50
+ if (at === null) return 'ok';
51
+ if (at <= now) return 'expired';
52
+ return at - now < DAY_MS ? 'expiring' : 'ok';
53
+ }
54
+
55
+ async function postJson(fetchImpl, url, body) {
56
+ const res = await fetchImpl(url, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify(body),
60
+ });
61
+ const data = await res.json().catch(() => ({}));
62
+ return { status: res.status, data };
63
+ }
64
+
65
+ /**
66
+ * Run the login. Resolves to the process exit code: 0 written, 1 refused,
67
+ * expired or unreachable.
68
+ */
69
+ export async function login({
70
+ cwd = process.cwd(),
71
+ base = process.env.HADOKU_BASE || EDGE,
72
+ fetchImpl = fetch,
73
+ git = runGit,
74
+ host = osHostname(),
75
+ log = (line) => console.error(line),
76
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
77
+ now = () => Date.now(),
78
+ } = {}) {
79
+ const repo = repoSlug(cwd, git);
80
+ let started;
81
+ try {
82
+ started = await postJson(fetchImpl, `${base}/auth/service-access/device`, { host, repo });
83
+ } catch (err) {
84
+ log(`[dev-vault] login: cannot reach ${base}: ${err instanceof Error ? err.message : err}`);
85
+ return 1;
86
+ }
87
+ if (started.status !== 200) {
88
+ log(`[dev-vault] login: ${started.data.error ?? `HTTP ${started.status}`}`);
89
+ return 1;
90
+ }
91
+ const { deviceCode, userCode, verifyUrl, expiresIn } = started.data;
92
+ log('[dev-vault] login: open this link, signed in, and approve:');
93
+ log(`[dev-vault] ${verifyUrl}`);
94
+ log(`[dev-vault] code ${userCode} · ${host}${repo ? ` · ${repo}` : ''}`);
95
+ log('[dev-vault] waiting for approval…');
96
+
97
+ const deadline = now() + (Number(expiresIn) || 600) * 1000;
98
+ while (now() < deadline) {
99
+ await sleep(POLL_MS);
100
+ let polled;
101
+ try {
102
+ polled = await postJson(fetchImpl, `${base}/auth/service-access/device/poll`, {
103
+ deviceCode,
104
+ });
105
+ } catch {
106
+ continue;
107
+ }
108
+ const { data } = polled;
109
+ if (data.status === 'pending') continue;
110
+ if (data.status !== 'approved' || typeof data.key !== 'string') {
111
+ log('[dev-vault] login: the request expired or was refused. Run `dev-vault login` again.');
112
+ return 1;
113
+ }
114
+ const target = keyFileTarget(cwd, git);
115
+ let prior = {};
116
+ if (existsSync(target)) {
117
+ try {
118
+ prior = JSON.parse(readFileSync(target, 'utf-8'));
119
+ } catch {
120
+ prior = {};
121
+ }
122
+ }
123
+ const next = { ...prior, key: data.key, expiresAt: data.expiresAt, identity: data.name };
124
+ writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
125
+ chmodSync(target, 0o600);
126
+ log(
127
+ `[dev-vault] login: ${data.name} written to ${target}, ` +
128
+ `valid until ${new Date(data.expiresAt).toLocaleString()}.`
129
+ );
130
+ return 0;
131
+ }
132
+ log('[dev-vault] login: nobody approved within 10 minutes. Run `dev-vault login` again.');
133
+ return 1;
134
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@hadoku/dev-vault",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.5.0",
4
+ "description": "Local-dev vault-broker shim — fetches secrets from the hadoku mgmt-api broker and execs a child command with them in process.env. Works for any consuming repo (Node or non-Node).",
5
+ "type": "module",
6
+ "bin": {
7
+ "dev-vault": "./dev-vault.mjs"
8
+ },
9
+ "files": [
10
+ "dev-vault.mjs",
11
+ "broker-base.mjs",
12
+ "key-file.mjs",
13
+ "install-id.mjs",
14
+ "login.mjs",
15
+ "README.md"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/WolffM/hadoku_site.git",
20
+ "directory": "packages/dev-vault"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "hadoku",
27
+ "vault",
28
+ "secrets",
29
+ "dev"
30
+ ],
31
+ "license": "ISC"
32
+ }