@lifeaitools/clauth 1.30.13 → 1.30.14

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.
@@ -11,23 +11,47 @@ Hardware-bound credential vault for the LIFEAI stack. Your machine is the second
11
11
 
12
12
  ## How Claude Interfaces with clauth
13
13
 
14
- > **CRITICAL:** Never pipe input to clauth or use interactive prompts. The CLI uses `inquirer` which produces ANSI garbage when stdin is piped. Always use one of the two methods below.
14
+ > **CRITICAL:** Agents should use the local daemon for credential discovery and
15
+ > exact secret retrieval. Do not ask the user for the vault password just to get
16
+ > a key, do not use interactive prompts, and do not walk every service.
15
17
 
16
- ### Method 1 — CLI with `--pw` flag (simplest)
18
+ ### Method 1 — Local daemon (preferred for agents)
17
19
 
18
- All clauth commands accept `-p` / `--pw <password>` to skip the interactive password prompt:
20
+ Discover services without reading secrets:
21
+
22
+ ```bash
23
+ curl -s http://127.0.0.1:52437/ping
24
+ curl -s http://127.0.0.1:52437/knowledge
25
+ curl -s http://127.0.0.1:52437/list-services
26
+ ```
27
+
28
+ Retrieve exactly one credential only when the next command needs it:
29
+
30
+ ```bash
31
+ curl -s http://127.0.0.1:52437/v/<service>
32
+ ```
33
+
34
+ `/knowledge` is safe to cache for a session: it returns service metadata and
35
+ `has_key`, but no raw secrets and no Vault secret names. `/status` uses the same
36
+ short metadata cache and can be forced with `?refresh=1`.
37
+
38
+ ### Method 2 — CLI with `--pw` flag (admin/setup only)
39
+
40
+ All clauth commands accept `-p` / `--pw <password>` to skip the interactive
41
+ password prompt. Use this for local admin/setup tasks, not routine agent secret
42
+ retrieval:
19
43
 
20
44
  ```bash
21
45
  clauth status -p "YourPassword"
22
- clauth get github -p "YourPassword"
23
46
  clauth test -p "YourPassword"
24
47
  clauth enable github -p "YourPassword"
25
48
  clauth write key github -p "YourPassword" # still prompts for the key value
26
49
  ```
27
50
 
28
- ### Method 2 — Direct API calls (full control, no CLI needed)
51
+ ### Method 3 — Direct API calls (implementation/debugging only)
29
52
 
30
- Call the auth-vault Edge Function directly. This is the most reliable method for Claude.
53
+ Call the auth-vault Edge Function directly only when debugging clauth internals.
54
+ Normal agents should not bypass the daemon metadata cache.
31
55
 
32
56
  **Base URL:** `https://<project-ref>.supabase.co/functions/v1/auth-vault`
33
57
  **Auth header:** `Authorization: Bearer <supabase-anon-key>`
@@ -75,10 +99,10 @@ All auth routes require: `machine_hash`, `token`, `timestamp`, `password`.
75
99
 
76
100
  ### Password Handling
77
101
 
78
- - Ask the user for their clauth password **once per session**
79
- - Store it in working memory for the duration of the conversation
102
+ - Do not ask the user for their clauth password for routine credential lookup
103
+ - Use the unlocked local daemon whenever possible
80
104
  - Never log or echo the password
81
- - If the user says "use clauth" or "get my github key", ask for the password if you don't have it yet
105
+ - If the daemon is locked, ask the user to unlock `http://127.0.0.1:52437`
82
106
 
83
107
  ---
84
108
 
@@ -144,7 +168,9 @@ clauth setup [--admin-token T] [-p P] Register this machine
144
168
  clauth status [-p P] All services + state
145
169
  clauth test [-p P] Verify HMAC connection
146
170
  clauth list [-p P] Service names
147
- clauth search <query> [-p P] Search names, metadata, and redacted server addresses
171
+ clauth search <query> [-p P] Search names and metadata
172
+ clauth search <query> --addresses [-p P]
173
+ Also search redacted address hints (may read multiple secrets)
148
174
 
149
175
  clauth write key <service> [-p P] Store a credential
150
176
  clauth write pw [-p P] Change password
@@ -175,32 +201,64 @@ Service type `fileserver` — mount configuration for fs tools (UI-only config).
175
201
 
176
202
  ---
177
203
 
178
- ## MCP Server (v1.5.38+)
204
+ ## Daemon Metadata Cache
205
+
206
+ When discovering service names, do not retrieve secrets. Use the daemon metadata
207
+ cache:
208
+
209
+ ```bash
210
+ curl -s http://127.0.0.1:52437/knowledge
211
+ curl -s http://127.0.0.1:52437/list-services
212
+ ```
179
213
 
180
- clauth runs as an MCP server with 3 namespaces and 27 tools:
214
+ `/knowledge` is agent-safe: it returns service metadata and `has_key`, but no
215
+ raw secrets and no Vault secret names. Fetch `GET /v/<service>` only for the one
216
+ exact credential needed by the next command. Never walk every service and never
217
+ bulk-fetch all secrets.
218
+
219
+ ## MCP Server (v1.16.9+)
220
+
221
+ clauth runs as an MCP server with five namespaced paths and safe
222
+ credential-discovery tools:
181
223
 
182
224
  | Path | Namespace | Tools |
183
225
  |------|-----------|-------|
184
- | `/clauth` | `clauth_*` | 13 credential vault tools |
226
+ | `/clauth` | `clauth_*` + runtime helpers | 23 default live tools, including `clauth_knowledge` |
185
227
  | `/gws` | `gws_*` | 6 Google Workspace tools |
186
- | `/fs` | `fs_*` | 8 filesystem tools (read, write, grep, glob, list, delete, mkdir, mounts) |
187
- | `/mcp` | all | 27 tools combined |
228
+ | `/fs` | `fs_*` | 20 filesystem and git-safe file tools |
229
+ | `/chitchat` | `chitchat_*` | 7 collaboration relay tools |
230
+ | `/codevelop` | `codevelop_*` | 7 peer development tools |
231
+ | `/mcp` | all | 63 tools combined |
232
+
233
+ The default live `/clauth` surface includes credential tools plus
234
+ `call_agent`, `monkey_dispatch`, `handoff_start`, `terminal_*`, and `channel_*`.
235
+ Admin write tools are gated by write-mode.
188
236
 
189
237
  ### claude.ai Connector URLs (noauth mode)
190
238
  - `https://clauth.regendevcorp.com/clauth` — credential tools
191
239
  - `https://clauth.regendevcorp.com/gws` — Google Workspace
240
+ - `https://clauth.regendevcorp.com/chitchat` — collaboration relay tools
241
+ - `https://clauth.regendevcorp.com/codevelop` — peer development tools
192
242
  - `https://fs.regendevcorp.com/fs` — filesystem tools
193
243
 
194
244
  Noauth mode: fresh domains that return 404 on OAuth endpoints. claude.ai connects directly (Anthropic OAuth proxy bug workaround).
195
245
 
246
+ Use `clauth_knowledge` or `clauth_status` for discovery. Use `clauth_get` only
247
+ for one exact secret. `clauth_search` is metadata-only by default; pass
248
+ `addresses: true` only when redacted address hints are intentionally needed.
249
+ `clauth_inject` is guarded against accidental bulk vault sweeps.
250
+
196
251
  ### FS Tools
197
- `fs_read` `fs_write` `fs_list` `fs_grep` `fs_glob` `fs_delete` `fs_mkdir` `fs_mounts`
252
+ `fs_read` `fs_write` `fs_stat` `fs_append` `fs_write_chunk` `fs_ingest_url`
253
+ `fs_import_git_files` `fs_list` `fs_grep` `fs_glob` `fs_delete` `fs_mkdir`
254
+ `fs_edit` `fs_move` `fs_copy` `fs_mounts` `fs_repo_status` `fs_use_branch`
255
+ `fs_commit` `fs_diff`
198
256
 
199
257
  Path-jail security: all paths resolved against mount root. Permission flags (r/w/d) per mount. Uses `@vscode/ripgrep` for grep, `fast-glob` for glob.
200
258
 
201
259
  ### Testing
202
260
  ```bash
203
- node test-tools.mjs # 25 tool execution tests across all 3 namespaces
261
+ node test-tools.mjs # MCP tool execution smoke tests
204
262
  ```
205
263
 
206
264
  ---
package/README.md CHANGED
@@ -92,7 +92,9 @@ clauth install Provision Supabase + install Claude skill
92
92
  clauth setup Register this machine with the vault
93
93
  clauth enroll Create one-time code to add another computer
94
94
  clauth status All services + state
95
- clauth search <query> Find services by name, project, description, or redacted address
95
+ clauth search <query> Find services by name, project, description, or type
96
+ clauth search <query> --addresses
97
+ Also search redacted address hints (may read multiple secrets)
96
98
  clauth test Verify connection
97
99
 
98
100
  clauth write key <service> Store a credential
@@ -136,7 +138,7 @@ Nothing stored locally. Password never persisted. Machine hash is one-way only.
136
138
  clauth runs as an HTTP daemon on `http://127.0.0.1:52437`. The daemon provides:
137
139
 
138
140
  - **Web UI** — unlock vault, manage services, configure mounts
139
- - **REST API** — `GET /get/<service>`, `GET /ping`, `POST /restart`, `GET /shutdown`
141
+ - **REST API** — `GET /knowledge`, `GET /status`, `GET /v/<service>`, `GET /ping`, `POST /restart`, `GET /shutdown`
140
142
  - **MCP server** — Model Context Protocol for Claude Code and claude.ai
141
143
  - **Cloudflare Tunnel** — exposes MCP endpoints publicly for claude.ai connectors
142
144
 
@@ -144,32 +146,88 @@ Start: `clauth serve start` (starts locked, auto-opens browser for unlock).
144
146
 
145
147
  Full daemon operations reference: see `regen-root/.claude/rules/clauth.md`.
146
148
 
149
+ ### Safe Metadata Cache
150
+
151
+ Agents and scripts must discover services through metadata endpoints, not by
152
+ walking every secret endpoint. The daemon caches service metadata for 30 seconds:
153
+
154
+ ```bash
155
+ curl -s http://127.0.0.1:52437/knowledge # sanitized, agent-safe metadata
156
+ curl -s http://127.0.0.1:52437/list-services # names only
157
+ curl -s http://127.0.0.1:52437/status # full daemon metadata, cached
158
+ ```
159
+
160
+ `/knowledge` returns service names, labels, key types, enabled state, project,
161
+ description, timestamps, and `has_key`. It never returns raw secrets or Vault
162
+ secret names. Use `GET /v/<service>` only for the one exact secret needed by the
163
+ next command. Do not bulk-fetch every service.
164
+
147
165
  ---
148
166
 
149
- ## MCP Server — 3 Namespaces, 32 Tools
167
+ ## MCP Server — 5 Namespaces, 63 Tools
150
168
 
151
169
  clauth is the single MCP interface for all local tools. One process, namespaced paths:
152
170
 
153
171
  | Path | Namespace | Tools | Description |
154
172
  |------|-----------|-------|-------------|
155
- | `/clauth` | `clauth_*` | 13 | Credential vault operations |
173
+ | `/clauth` | `clauth_*` + runtime helpers | 23 | Credential vault operations, safe metadata cache, and agent runtime helpers |
156
174
  | `/gws` | `gws_*` | 6 | Google Workspace (Gmail, Calendar, Drive) |
157
- | `/fs` | `fs_*` | 13 | Filesystem (read, write, append, chunked write, URL ingest, Git import, stat, grep, glob, delete, mkdir, mounts) |
158
- | `/mcp` | all | 32 | All namespaces combined (Claude Code) |
175
+ | `/fs` | `fs_*` | 20 | Filesystem plus guarded git/local-branch tools |
176
+ | `/chitchat` | `chitchat_*` | 7 | Collaboration relay sessions |
177
+ | `/codevelop` | `codevelop_*` | 7 | Peer development sessions |
178
+ | `/mcp` | all | 63 | All namespaces combined (Claude Code) |
179
+
180
+ The default live `/clauth` surface includes 13 credential tools plus
181
+ `call_agent`, `monkey_dispatch`, `handoff_start`, `terminal_*`, and `channel_*`
182
+ runtime helpers. Admin write tools such as enable/disable/project/token actions
183
+ are gated by write-mode.
184
+
185
+ ### TinTin Agent Setup
186
+
187
+ clauth owns the local TinTin settings and isolated agent setup contract used by
188
+ app sidebars and the standalone TinTin console.
189
+
190
+ | Method | Route | Purpose |
191
+ | --- | --- | --- |
192
+ | `GET` | `/tintin/settings` | Read persisted TinTin app/agent/sidebar/dispatch/co-develop settings. |
193
+ | `PUT` | `/tintin/settings` | Save TinTin app/agent/sidebar/dispatch/co-develop settings. |
194
+ | `GET` | `/tintin/settings/ui` | Browser settings page for operators, with sections for Agent Setup, Agent Defaults, Sidebar Apps, Blackboard Dispatch, Co-develop Relay, and stored agent sessions. |
195
+ | `POST` | `/tintin/agent-sessions` | Create or reuse an isolated agent cwd, normally a git worktree. |
196
+
197
+ The agent-session response includes the requested agent/runtime/model, repo
198
+ root, isolated `cwd`, worktree path, branch, launch mode, and launch command.
199
+ This route prepares the proper Codex/Claude isolation path; separate supervisor
200
+ code is still responsible for long-running process parking and wake/resume.
201
+
202
+ ### clauth MCP Usage
203
+
204
+ Use `clauth_knowledge` or `clauth_status` for discovery. They use cached
205
+ metadata and never retrieve raw secrets. `clauth_search` is metadata-only by
206
+ default; pass `addresses: true` only when you intentionally need redacted address
207
+ hints from connstrings/fileserver/oauth secrets. `clauth_get` is for one exact
208
+ secret. `clauth_inject` refuses more than five services unless `allow_many: true`
209
+ is explicitly supplied, to prevent accidental rate-limit lockouts from bulk
210
+ secret sweeps.
159
211
 
160
212
  ### FS Tools
161
213
 
162
- 13 filesystem tools with path-jail security:
163
- - `fs_read`, `fs_write`, `fs_stat`, `fs_append`, `fs_write_chunk`, `fs_ingest_url`, `fs_import_git_files`, `fs_list`, `fs_grep`, `fs_glob`, `fs_delete`, `fs_mkdir`, `fs_mounts`
214
+ 20 filesystem tools with path-jail security:
215
+ - `fs_read`, `fs_write`, `fs_stat`, `fs_append`, `fs_write_chunk`, `fs_ingest_url`, `fs_import_git_files`, `fs_list`, `fs_grep`, `fs_glob`, `fs_delete`, `fs_mkdir`, `fs_edit`, `fs_move`, `fs_copy`, `fs_mounts`, `fs_repo_status`, `fs_use_branch`, `fs_commit`, `fs_diff`
164
216
  - Uses `node:fs/promises` (async), `@vscode/ripgrep` (shipped binary), `fast-glob`
165
217
  - Permission flags per mount: `r` (read), `w` (write), `d` (delete)
166
218
  - Mount config stored as "fileserver" service type in vault — only configurable through web UI
167
219
  - Large writes should use `fs_write_chunk`; cloud-to-local transfer should use `fs_ingest_url`; guarded appends should pass `expected_sha256` from `fs_stat`
168
220
  - Durable new files authored by Claude.ai in GitHub should use `fs_import_git_files` so the local dirty monorepo fetches and restores only named paths without `git pull`
169
221
 
222
+ ### Collaboration Tools
223
+
224
+ Chitchat tools: `chitchat_start`, `chitchat_list`, `chitchat_send`, `chitchat_recv`, `chitchat_reply`, `chitchat_poll`, `chitchat_stop`
225
+
226
+ Codevelop tools: `codevelop_start`, `codevelop_join`, `codevelop_send`, `codevelop_poll`, `codevelop_status`, `codevelop_stream`, `codevelop_stop`
227
+
170
228
  ### GWS Tools
171
229
 
172
- 6 Google Workspace tools: `gws_gmail_list`, `gws_gmail_read`, `gws_gmail_send`, `gws_gmail_draft`, `gws_calendar_list`, `gws_calendar_create`
230
+ 6 Google Workspace tools: `gws_run`, `gws_gmail_list`, `gws_gmail_read`, `gws_gmail_send`, `gws_calendar_list`, `gws_drive_list`
173
231
  - Calls `gws` CLI via `execSync` with `shell: 'bash'` (fixes Windows cmd.exe JSON quoting)
174
232
 
175
233
  ---
@@ -196,6 +254,8 @@ Full OAuth 2.1 protocol implementation is present for future use when Anthropic
196
254
  |-----------|-----|
197
255
  | clauth | `https://clauth.regendevcorp.com/clauth` |
198
256
  | gws | `https://clauth.regendevcorp.com/gws` |
257
+ | chitchat | `https://clauth.regendevcorp.com/chitchat` |
258
+ | codevelop | `https://clauth.regendevcorp.com/codevelop` |
199
259
  | fs | `https://fs.regendevcorp.com/fs` |
200
260
 
201
261
  ---
@@ -210,7 +270,7 @@ Full OAuth 2.1 protocol implementation is present for future use when Anthropic
210
270
  ## Testing
211
271
 
212
272
  ```bash
213
- node test-tools.mjs # 25 tool execution tests across all 3 namespaces
273
+ node test-tools.mjs # MCP tool execution smoke tests
214
274
  ```
215
275
 
216
276
  Tests actual MCP tool calls (not just OAuth + listing).
@@ -0,0 +1,75 @@
1
+ // cli/api.classify.test.js
2
+ // Regression tests for backend/external-resource error classification.
3
+ //
4
+ // WHY THIS EXISTS: on 2026-06-19 a saturated Postgres database made every
5
+ // auth-vault verify call time out. clauth counted each timeout as an
6
+ // authentication failure ([AUTH FAIL n/10]), hit the strike ceiling, and
7
+ // hard-locked the vault — turning a transient DB blip into a full lockout that
8
+ // blocked Claude/Codex from starting. A database timeout, a 5xx, a network
9
+ // drop, or a rate-limit is NOT a wrong password and must never take a strike.
10
+ // These tests pin that classification so the regression can't return.
11
+
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import {
15
+ VaultBackendError,
16
+ classifyBackendError,
17
+ classifyServerReason,
18
+ } from "./api.js";
19
+
20
+ test("VaultBackendError marks failures as non-strike + retriable", () => {
21
+ const e = new VaultBackendError("timeout", "no response", { status: 504, detail: "abort" });
22
+ assert.equal(e.kind, "timeout");
23
+ assert.equal(e.isBackend, true);
24
+ assert.equal(e.retriable, true);
25
+ assert.equal(e.status, 504);
26
+ assert.ok(e instanceof Error);
27
+ });
28
+
29
+ test("classifyBackendError: AbortSignal.timeout shapes => 'timeout'", () => {
30
+ // This is the exact error that slipped through the old substring check.
31
+ assert.equal(classifyBackendError({ name: "TimeoutError", message: "The operation was aborted due to timeout" }), "timeout");
32
+ assert.equal(classifyBackendError({ name: "AbortError", message: "This operation was aborted" }), "timeout");
33
+ assert.equal(classifyBackendError(new Error("request timed out")), "timeout");
34
+ });
35
+
36
+ test("classifyBackendError: network/transport shapes => 'network'", () => {
37
+ assert.equal(classifyBackendError(new Error("fetch failed")), "network");
38
+ assert.equal(classifyBackendError(new Error("connect ECONNREFUSED 127.0.0.1:443")), "network");
39
+ assert.equal(classifyBackendError(new Error("getaddrinfo ENOTFOUND db.supabase.co")), "network");
40
+ assert.equal(classifyBackendError(new Error("read ECONNRESET")), "network");
41
+ });
42
+
43
+ test("classifyBackendError: a typed VaultBackendError passes its kind through", () => {
44
+ assert.equal(classifyBackendError(new VaultBackendError("rate_limited", "slow down")), "rate_limited");
45
+ assert.equal(classifyBackendError(new VaultBackendError("db_error", "pool exhausted")), "db_error");
46
+ });
47
+
48
+ test("classifyBackendError: a genuine credential error is NOT a backend error", () => {
49
+ assert.equal(classifyBackendError(new Error("Wrong password")), null);
50
+ assert.equal(classifyBackendError(new Error("invalid_token")), null);
51
+ assert.equal(classifyBackendError(null), null);
52
+ assert.equal(classifyBackendError(undefined), null);
53
+ });
54
+
55
+ test("classifyServerReason: rate-limit reasons => 'rate_limited'", () => {
56
+ assert.equal(classifyServerReason("rate_limited"), "rate_limited");
57
+ assert.equal(classifyServerReason("Rate limit: 38/30 per 60s"), "rate_limited");
58
+ });
59
+
60
+ test("classifyServerReason: database/backend reasons => 'db_error'", () => {
61
+ assert.equal(classifyServerReason("canceling statement due to statement timeout"), "db_error");
62
+ assert.equal(classifyServerReason("database connection pool exhausted"), "db_error");
63
+ assert.equal(classifyServerReason("internal server error"), "db_error");
64
+ assert.equal(classifyServerReason("service unavailable"), "db_error");
65
+ });
66
+
67
+ test("classifyServerReason: genuine auth verdicts are NOT backend errors (they strike)", () => {
68
+ assert.equal(classifyServerReason("invalid_token (3/5)"), null);
69
+ assert.equal(classifyServerReason("machine_locked"), null);
70
+ assert.equal(classifyServerReason("machine_disabled"), null);
71
+ assert.equal(classifyServerReason("machine_not_found"), null);
72
+ assert.equal(classifyServerReason("wrong_password"), null);
73
+ assert.equal(classifyServerReason(null), null);
74
+ assert.equal(classifyServerReason(""), null);
75
+ });
package/cli/api.js CHANGED
@@ -27,21 +27,119 @@ export function getAnonKey() {
27
27
  // ============================================================
28
28
  // Core POST helper
29
29
  // ============================================================
30
+ // Bound every vault round-trip with a wall-clock timeout. Without this, a TCP
31
+ // stall (DNS black-hole, half-open socket — NOT a clean 4xx) hangs the caller
32
+ // forever. Callers that treat persistence as best-effort (e.g. the call_agent
33
+ // scratchpad mirror) rely on this resolving to an error rather than blocking.
34
+ // Override via CLAUTH_VAULT_TIMEOUT_MS; default 8s.
35
+ const VAULT_FETCH_TIMEOUT_MS = (() => {
36
+ const n = Number(process.env.CLAUTH_VAULT_TIMEOUT_MS);
37
+ return Number.isFinite(n) && n > 0 ? n : 8000;
38
+ })();
39
+
40
+ // ============================================================
41
+ // Backend / external-resource error model
42
+ // ============================================================
43
+ // A VaultBackendError means the vault backend (the auth-vault Edge Function or
44
+ // the Postgres database behind it) never rendered an auth verdict — the request
45
+ // timed out, the network failed, the function 5xx'd, the DB was unreachable, or
46
+ // we were rate-limited. These are TRANSIENT and must NEVER be counted as an
47
+ // authentication failure (a wrong password). Treating a database timeout as a
48
+ // bad password is the bug that turned a DB blip into a permanent vault lockout.
49
+ export class VaultBackendError extends Error {
50
+ constructor(kind, message, { status = null, detail = null, cause = null } = {}) {
51
+ super(message);
52
+ this.name = "VaultBackendError";
53
+ this.kind = kind; // timeout | network | server_error | rate_limited | db_error | unknown
54
+ this.isBackend = true; // marker for callers: do not strike
55
+ this.retriable = true; // the verdict was never rendered — safe to retry
56
+ this.status = status; // HTTP status if the server responded
57
+ this.detail = detail; // human-readable backend detail
58
+ if (cause) this.cause = cause;
59
+ }
60
+ }
61
+
62
+ // Server `reason` strings that are genuine AUTH VERDICTS (the backend evaluated
63
+ // the credential and answered). These DO count — they are not backend errors.
64
+ const AUTH_VERDICT_REASONS = [
65
+ "invalid_token", "wrong_password", "invalid_password",
66
+ "machine_locked", "machine_disabled", "machine_not_found",
67
+ ];
68
+
69
+ // Classify a server-supplied `reason` string. Returns a VaultBackendError kind
70
+ // when the reason describes a backend/transient condition, or null when the
71
+ // reason is a real auth verdict (or unknown — caller decides).
72
+ export function classifyServerReason(reason) {
73
+ if (!reason || typeof reason !== "string") return null;
74
+ const r = reason.toLowerCase();
75
+ if (AUTH_VERDICT_REASONS.some((v) => r.includes(v))) return null; // genuine verdict
76
+ if (/rate[_\s-]?limit/.test(r)) return "rate_limited";
77
+ if (/(database|\bdb\b|postgres|connection|pool|statement timeout|unavailable|internal|timeout|5\d\d)/.test(r)) return "db_error";
78
+ return null;
79
+ }
80
+
81
+ // Classify a thrown error (transport/timeout/network/typed). Returns a kind
82
+ // string when it is a backend/transient failure, or null when it is not.
83
+ export function classifyBackendError(err) {
84
+ if (!err) return null;
85
+ if (err instanceof VaultBackendError) return err.kind;
86
+ const name = err.name || "";
87
+ const msg = (err.message || "").toString();
88
+ if (name === "TimeoutError" || name === "AbortError" || /\babort(ed)?\b|\btim(e|ed)?\s*out\b|timeout/i.test(msg)) return "timeout";
89
+ if (/fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EPIPE|network|socket hang/i.test(msg)) return "network";
90
+ return null;
91
+ }
92
+
30
93
  async function post(route, body) {
31
94
  const url = `${getBaseUrl()}/${route}`;
32
95
  const anonKey = getAnonKey();
33
96
 
34
- const res = await fetch(url, {
35
- method: "POST",
36
- headers: {
37
- "Content-Type": "application/json",
38
- "Authorization": `Bearer ${anonKey}`
39
- },
40
- body: JSON.stringify(body)
41
- });
97
+ let res;
98
+ try {
99
+ res = await fetch(url, {
100
+ method: "POST",
101
+ headers: {
102
+ "Content-Type": "application/json",
103
+ "Authorization": `Bearer ${anonKey}`
104
+ },
105
+ body: JSON.stringify(body),
106
+ signal: AbortSignal.timeout(VAULT_FETCH_TIMEOUT_MS)
107
+ });
108
+ } catch (err) {
109
+ // Transport-level failure — the backend never answered. Surface a typed,
110
+ // non-strike error so callers report "backend unreachable", not "bad password".
111
+ const kind = classifyBackendError(err) || "network";
112
+ const why = kind === "timeout"
113
+ ? `vault backend did not respond within ${VAULT_FETCH_TIMEOUT_MS}ms`
114
+ : `cannot reach vault backend (${err.message || "network error"})`;
115
+ throw new VaultBackendError(kind, why, { detail: err.message || String(err), cause: err });
116
+ }
117
+
118
+ let data;
119
+ try {
120
+ data = await res.json();
121
+ } catch (err) {
122
+ // 2xx/5xx with a non-JSON or empty body — the function errored without a
123
+ // structured verdict. Transient backend condition, not an auth failure.
124
+ throw new VaultBackendError("server_error", `vault backend returned an unreadable response (HTTP ${res.status})`, { status: res.status, detail: err.message });
125
+ }
126
+
127
+ // HTTP 5xx (or any non-ok with no structured error) = backend problem, never a verdict.
128
+ if (res.status >= 500 || (!res.ok && !data.error)) {
129
+ const reasonKind = classifyServerReason(data.reason || data.error) || "server_error";
130
+ throw new VaultBackendError(reasonKind, `vault backend error (HTTP ${res.status})`, { status: res.status, detail: data.error || data.reason || `HTTP ${res.status}` });
131
+ }
132
+
133
+ // 4xx WITH a structured error: could be a real verdict (invalid_token) or a
134
+ // transient backend signal surfaced as 4xx (rate_limited). Promote the latter
135
+ // to a typed backend error; leave genuine verdicts in `data` for the caller.
136
+ if (data.error) {
137
+ const reasonKind = classifyServerReason(data.reason || data.error);
138
+ if (reasonKind) {
139
+ throw new VaultBackendError(reasonKind, `vault backend ${reasonKind.replace("_", " ")}`, { status: res.status, detail: data.error || data.reason });
140
+ }
141
+ }
42
142
 
43
- const data = await res.json();
44
- if (!res.ok && !data.error) throw new Error(`HTTP ${res.status}`);
45
143
  return data;
46
144
  }
47
145
 
@@ -135,5 +233,6 @@ export async function redeemEnrollment(machineHash, seedHash, label, enrollmentC
135
233
 
136
234
  export default {
137
235
  retrieve, write, enable, addService, updateService, removeService, revoke,
138
- status, test, createEnrollment, registerMachine, redeemEnrollment, getBaseUrl, getAnonKey
236
+ status, test, createEnrollment, registerMachine, redeemEnrollment, getBaseUrl, getAnonKey,
237
+ VaultBackendError, classifyBackendError, classifyServerReason
139
238
  };