passwd-sso-cli 0.4.46 → 0.4.48

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.
@@ -7,6 +7,7 @@
7
7
  * revoke — Revoke an API key
8
8
  */
9
9
  import { apiRequest } from "../lib/api-client.js";
10
+ import { readMainApiErrorBody } from "../lib/api-error-body.js";
10
11
  import * as output from "../lib/output.js";
11
12
  export async function apiKeyListCommand(options = {}) {
12
13
  const res = await apiRequest("/api/api-keys");
@@ -73,8 +74,8 @@ export async function apiKeyCreateCommand(opts) {
73
74
  },
74
75
  });
75
76
  if (!res.ok) {
76
- const err = res.data;
77
- output.error(`Failed to create API key: ${err.error ?? `HTTP ${res.status}`}`);
77
+ const err = readMainApiErrorBody(res.data);
78
+ output.error(`Failed to create API key: ${err?.error ?? `HTTP ${res.status}`}`);
78
79
  return;
79
80
  }
80
81
  if (opts.json) {
@@ -99,12 +100,12 @@ export async function apiKeyCreateCommand(opts) {
99
100
  export async function apiKeyRevokeCommand(id, options = {}) {
100
101
  const res = await apiRequest(`/api/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" });
101
102
  if (!res.ok) {
102
- const err = res.data;
103
+ const err = readMainApiErrorBody(res.data);
103
104
  if (options.json) {
104
- output.json({ success: false, error: err.error ?? `HTTP ${res.status}` });
105
+ output.json({ success: false, error: err?.error ?? `HTTP ${res.status}` });
105
106
  }
106
107
  else {
107
- output.error(`Failed to revoke API key: ${err.error ?? `HTTP ${res.status}`}`);
108
+ output.error(`Failed to revoke API key: ${err?.error ?? `HTTP ${res.status}`}`);
108
109
  }
109
110
  return;
110
111
  }
@@ -7,7 +7,7 @@
7
7
  * Requires the vault to be unlocked (encryption key in memory) OR
8
8
  * an apiKey + PSSO_PASSPHRASE env var for non-interactive mode.
9
9
  */
10
- import { loadSecretsConfig, getPasswordPath } from "../lib/secrets-config.js";
10
+ import { loadSecretsConfig, getPasswordPath, getSecretsServerUrl } from "../lib/secrets-config.js";
11
11
  import { getEncryptionKey, getUserId } from "../lib/vault-state.js";
12
12
  import { autoUnlockIfNeeded } from "./unlock.js";
13
13
  import { getToken } from "../lib/api-client.js";
@@ -22,10 +22,17 @@ export async function envCommand(opts) {
22
22
  }
23
23
  catch (err) {
24
24
  output.error(err instanceof Error ? err.message : "Failed to load config.");
25
- return;
25
+ process.exit(1);
26
26
  }
27
27
  const useV1 = !!config.apiKey;
28
- const baseUrl = config.server.replace(/\/$/, "");
28
+ let baseUrl;
29
+ try {
30
+ baseUrl = getSecretsServerUrl();
31
+ }
32
+ catch (err) {
33
+ output.error(err instanceof Error ? err.message : "Failed to resolve server URL.");
34
+ process.exit(1);
35
+ }
29
36
  // Determine auth header
30
37
  let authHeader;
31
38
  if (config.apiKey) {
@@ -35,14 +42,14 @@ export async function envCommand(opts) {
35
42
  const token = await getToken();
36
43
  if (!token) {
37
44
  output.error("Not logged in. Run `passwd-sso login` first, or set apiKey in config.");
38
- return;
45
+ process.exit(1);
39
46
  }
40
47
  authHeader = `Bearer ${token}`;
41
48
  }
42
49
  // Auto-unlock with PSSO_PASSPHRASE if needed
43
50
  if (!await autoUnlockIfNeeded()) {
44
51
  output.error("Vault is not unlocked. Run `passwd-sso unlock` first, or set PSSO_PASSPHRASE.");
45
- return;
52
+ process.exit(1);
46
53
  }
47
54
  const encryptionKey = getEncryptionKey();
48
55
  const userId = getUserId();
@@ -5,7 +5,7 @@
5
5
  * Blocks certain dangerous env var names from being overwritten.
6
6
  */
7
7
  import { spawn } from "node:child_process";
8
- import { loadSecretsConfig, getPasswordPath } from "../lib/secrets-config.js";
8
+ import { loadSecretsConfig, getPasswordPath, getSecretsServerUrl } from "../lib/secrets-config.js";
9
9
  import { getEncryptionKey, getUserId } from "../lib/vault-state.js";
10
10
  import { autoUnlockIfNeeded } from "./unlock.js";
11
11
  import { getToken } from "../lib/api-client.js";
@@ -27,7 +27,14 @@ export async function runCommand(opts) {
27
27
  process.exit(1);
28
28
  }
29
29
  const useV1 = !!config.apiKey;
30
- const baseUrl = config.server.replace(/\/$/, "");
30
+ let baseUrl;
31
+ try {
32
+ baseUrl = getSecretsServerUrl();
33
+ }
34
+ catch (err) {
35
+ output.error(err instanceof Error ? err.message : "Failed to resolve server URL.");
36
+ process.exit(1);
37
+ }
31
38
  let authHeader;
32
39
  if (config.apiKey) {
33
40
  authHeader = `Bearer ${config.apiKey}`;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Typed error-body access for main-API responses.
3
+ *
4
+ * Mirrors `src/lib/http/api-response.ts` `MainApiErrorBody` — keep in sync.
5
+ * The CLI cannot import from the app's `src/` (separate tsconfig / `rootDir`),
6
+ * so this is a deliberate type duplicate with the same shape and invariants.
7
+ * See `docs/api/error-handling.md` for the canonical envelope.
8
+ *
9
+ * Keep in sync with the other 2 copies:
10
+ * - src/lib/http/read-api-error-body.ts (main, canonical)
11
+ * - extension/src/lib/api-error-body.ts (browser extension)
12
+ * CI drift check: scripts/checks/check-api-error-body-drift.sh
13
+ *
14
+ * Note: OAuth endpoints (`/api/mcp/token`, `/api/mcp/register`) use the RFC 6749
15
+ * error envelope (`error`, `error_description`), NOT this shape. Reads from
16
+ * those endpoints belong in `cli/src/lib/oauth.ts` and are intentionally not
17
+ * migrated here.
18
+ */
19
+ export type MainApiErrorBody = {
20
+ readonly error: string;
21
+ readonly details?: unknown;
22
+ readonly lockedUntil?: string | null;
23
+ readonly currentKeyVersion?: number;
24
+ };
25
+ export declare function readApiErrorBody(res: Response): Promise<MainApiErrorBody | null>;
26
+ /**
27
+ * Read `details.message` from an already-parsed error body.
28
+ *
29
+ * The `apiRequest` wrapper in `api-client.ts` returns `res.data` as the raw
30
+ * JSON body (success OR error shape). On error paths, callers can pass that
31
+ * body here after narrowing it via `readMainApiErrorBody`.
32
+ */
33
+ export declare function getApiErrorMessage(body: MainApiErrorBody | null): string | null;
34
+ /**
35
+ * Read a single named field from `body.details` with a runtime type guard.
36
+ *
37
+ * Mirrors `getApiErrorDetail` in the main copy. See that file for full
38
+ * rationale.
39
+ */
40
+ export declare function getApiErrorDetail<T>(body: MainApiErrorBody | null, field: string, guard: (value: unknown) => value is T): T | null;
41
+ /**
42
+ * Read `details.properties[<field>].errors` from a Zod `treeifyError()` shape.
43
+ *
44
+ * Centralizes the per-field Zod-tree access pattern used by validation-error
45
+ * consumers (slug, url, etc.). Returns the errors array if present, `null`
46
+ * otherwise.
47
+ */
48
+ export declare function getApiErrorFieldErrors(body: MainApiErrorBody | null, field: string): readonly unknown[] | null;
49
+ /**
50
+ * Narrow an unknown value (e.g. `apiRequest`'s `res.data`) to `MainApiErrorBody`.
51
+ *
52
+ * Useful at error-path call sites that consume the wrapper's `res.data` rather
53
+ * than reading the `Response` directly.
54
+ */
55
+ export declare function readMainApiErrorBody(value: unknown): MainApiErrorBody | null;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Typed error-body access for main-API responses.
3
+ *
4
+ * Mirrors `src/lib/http/api-response.ts` `MainApiErrorBody` — keep in sync.
5
+ * The CLI cannot import from the app's `src/` (separate tsconfig / `rootDir`),
6
+ * so this is a deliberate type duplicate with the same shape and invariants.
7
+ * See `docs/api/error-handling.md` for the canonical envelope.
8
+ *
9
+ * Keep in sync with the other 2 copies:
10
+ * - src/lib/http/read-api-error-body.ts (main, canonical)
11
+ * - extension/src/lib/api-error-body.ts (browser extension)
12
+ * CI drift check: scripts/checks/check-api-error-body-drift.sh
13
+ *
14
+ * Note: OAuth endpoints (`/api/mcp/token`, `/api/mcp/register`) use the RFC 6749
15
+ * error envelope (`error`, `error_description`), NOT this shape. Reads from
16
+ * those endpoints belong in `cli/src/lib/oauth.ts` and are intentionally not
17
+ * migrated here.
18
+ */
19
+ export async function readApiErrorBody(res) {
20
+ if (res.ok)
21
+ return null;
22
+ const json = await res.json().catch(() => null);
23
+ if (!json ||
24
+ typeof json !== "object" ||
25
+ typeof json.error !== "string") {
26
+ return null;
27
+ }
28
+ return json;
29
+ }
30
+ /**
31
+ * Read `details.message` from an already-parsed error body.
32
+ *
33
+ * The `apiRequest` wrapper in `api-client.ts` returns `res.data` as the raw
34
+ * JSON body (success OR error shape). On error paths, callers can pass that
35
+ * body here after narrowing it via `readMainApiErrorBody`.
36
+ */
37
+ export function getApiErrorMessage(body) {
38
+ if (!body || typeof body.details !== "object" || body.details === null) {
39
+ return null;
40
+ }
41
+ const message = body.details.message;
42
+ return typeof message === "string" ? message : null;
43
+ }
44
+ /**
45
+ * Read a single named field from `body.details` with a runtime type guard.
46
+ *
47
+ * Mirrors `getApiErrorDetail` in the main copy. See that file for full
48
+ * rationale.
49
+ */
50
+ export function getApiErrorDetail(body, field, guard) {
51
+ if (!body || typeof body.details !== "object" || body.details === null) {
52
+ return null;
53
+ }
54
+ const value = body.details[field];
55
+ return guard(value) ? value : null;
56
+ }
57
+ /**
58
+ * Read `details.properties[<field>].errors` from a Zod `treeifyError()` shape.
59
+ *
60
+ * Centralizes the per-field Zod-tree access pattern used by validation-error
61
+ * consumers (slug, url, etc.). Returns the errors array if present, `null`
62
+ * otherwise.
63
+ */
64
+ export function getApiErrorFieldErrors(body, field) {
65
+ if (!body || typeof body.details !== "object" || body.details === null) {
66
+ return null;
67
+ }
68
+ const properties = body.details.properties;
69
+ if (!properties || typeof properties !== "object")
70
+ return null;
71
+ const fieldObj = properties[field];
72
+ if (!fieldObj || typeof fieldObj !== "object")
73
+ return null;
74
+ const errors = fieldObj.errors;
75
+ return Array.isArray(errors) ? errors : null;
76
+ }
77
+ /**
78
+ * Narrow an unknown value (e.g. `apiRequest`'s `res.data`) to `MainApiErrorBody`.
79
+ *
80
+ * Useful at error-path call sites that consume the wrapper's `res.data` rather
81
+ * than reading the `Response` directly.
82
+ */
83
+ export function readMainApiErrorBody(value) {
84
+ if (!value || typeof value !== "object")
85
+ return null;
86
+ if (typeof value.error !== "string")
87
+ return null;
88
+ return value;
89
+ }
90
+ //# sourceMappingURL=api-error-body.js.map
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Schema:
5
5
  * {
6
- * "server": "https://...",
7
6
  * "apiKey?": "api_...", // optional — uses /api/v1/ path
8
7
  * "secrets": {
9
8
  * "ENV_VAR_NAME": { "entry": "<entryId>", "field": "password" }
@@ -19,11 +18,11 @@ export interface SecretMapping {
19
18
  field: string;
20
19
  }
21
20
  export interface SecretsConfig {
22
- server: string;
23
21
  apiKey?: string;
24
22
  secrets: Record<string, SecretMapping>;
25
23
  }
26
24
  export declare function loadSecretsConfig(configPath?: string): SecretsConfig;
25
+ export declare function getSecretsServerUrl(): string;
27
26
  /**
28
27
  * Returns the API path for fetching a single password entry.
29
28
  * If apiKey is configured, use the public /api/v1/ path.
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Schema:
5
5
  * {
6
- * "server": "https://...",
7
6
  * "apiKey?": "api_...", // optional — uses /api/v1/ path
8
7
  * "secrets": {
9
8
  * "ENV_VAR_NAME": { "entry": "<entryId>", "field": "password" }
@@ -16,6 +15,11 @@
16
15
  */
17
16
  import { readFileSync, existsSync } from "node:fs";
18
17
  import { resolve } from "node:path";
18
+ import { loadConfig } from "./config.js";
19
+ import { validateServerUrl } from "./oauth.js";
20
+ function isPlaceholderEntryId(entryId) {
21
+ return entryId === "dummy-entry-id" || /^<[^>]+>$/.test(entryId);
22
+ }
19
23
  export function loadSecretsConfig(configPath) {
20
24
  const filePath = configPath
21
25
  ? resolve(configPath)
@@ -25,14 +29,41 @@ export function loadSecretsConfig(configPath) {
25
29
  }
26
30
  const raw = readFileSync(filePath, "utf-8");
27
31
  const parsed = JSON.parse(raw);
28
- if (!parsed.server || typeof parsed.server !== "string") {
29
- throw new Error("Config file must have a 'server' field.");
30
- }
31
32
  if (!parsed.secrets || typeof parsed.secrets !== "object") {
32
33
  throw new Error("Config file must have a 'secrets' field.");
33
34
  }
35
+ for (const [envName, mapping] of Object.entries(parsed.secrets)) {
36
+ if (!mapping || typeof mapping !== "object") {
37
+ throw new Error(`Secret mapping for '${envName}' must be an object.`);
38
+ }
39
+ const rawMapping = mapping;
40
+ if (typeof rawMapping.entry !== "string" || rawMapping.entry.trim().length === 0) {
41
+ throw new Error(`Secret mapping for '${envName}' must have a non-empty 'entry' string.`);
42
+ }
43
+ if (typeof rawMapping.field !== "string" || rawMapping.field.trim().length === 0) {
44
+ throw new Error(`Secret mapping for '${envName}' must have a non-empty 'field' string.`);
45
+ }
46
+ const entry = rawMapping.entry.trim();
47
+ const field = rawMapping.field.trim();
48
+ if (isPlaceholderEntryId(entry)) {
49
+ throw new Error(`Secret mapping for '${envName}' uses placeholder entry ID "${entry}". Replace it with a real vault entry ID.`);
50
+ }
51
+ // Preserve unknown keys (e.g. user-added comment fields) while normalising entry/field.
52
+ parsed.secrets[envName] = { ...rawMapping, entry, field };
53
+ }
34
54
  return parsed;
35
55
  }
56
+ export function getSecretsServerUrl() {
57
+ const { serverUrl } = loadConfig();
58
+ if (!serverUrl) {
59
+ throw new Error("Server URL not configured. Run `passwd-sso login -s <server-url>` once to configure it.");
60
+ }
61
+ // Defense-in-depth: re-validate the persisted URL before issuing a fetch
62
+ // with a Bearer token. Login validates at write time, but a hand-edited
63
+ // config file would otherwise reach fetch() unchecked.
64
+ validateServerUrl(serverUrl);
65
+ return serverUrl.replace(/\/$/, "");
66
+ }
36
67
  /**
37
68
  * Returns the API path for fetching a single password entry.
38
69
  * If apiKey is configured, use the public /api/v1/ path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passwd-sso-cli",
3
- "version": "0.4.46",
3
+ "version": "0.4.48",
4
4
  "description": "CLI for passwd-sso password manager",
5
5
  "type": "module",
6
6
  "license": "MIT",