@openparachute/hub 0.5.14-rc.3 → 0.5.14-rc.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.3",
3
+ "version": "0.5.14-rc.4",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -22,11 +22,108 @@ function seedVault(vaultHome: string, name: string, opts: { withDb?: boolean } =
22
22
  return dbPath;
23
23
  }
24
24
 
25
- describe("readVaultAuthStatus — config.yaml parse", () => {
26
- test("missing config.yaml hasOwnerPassword + hasTotp both false", () => {
25
+ /**
26
+ * Default tests pass a `probeHubDbHasUserPassword` of `() => undefined`
27
+ * (hub.db unreachable) so existing YAML-fallback behavior is exercised
28
+ * verbatim. Tests that specifically exercise the hub.db path pass their
29
+ * own probe.
30
+ */
31
+ const hubDbUnreachable = () => undefined;
32
+
33
+ describe("readVaultAuthStatus — hub.db source of truth (multi-user Phase 1+)", () => {
34
+ test("hub.db has a user with password_hash → hasOwnerPassword: true (no YAML needed)", () => {
35
+ const env = makeVaultHome();
36
+ try {
37
+ // No config.yaml at all on disk.
38
+ const status = readVaultAuthStatus({
39
+ vaultHome: env.path,
40
+ countTokens: () => 0,
41
+ probeHubDbHasUserPassword: () => true,
42
+ });
43
+ expect(status.hasOwnerPassword).toBe(true);
44
+ // No hub-side TOTP column yet (Phase 3). Stays false on the hub.db
45
+ // path until the schema gains a column.
46
+ expect(status.hasTotp).toBe(false);
47
+ } finally {
48
+ env.cleanup();
49
+ }
50
+ });
51
+
52
+ test("hub.db users empty, YAML has owner_password_hash → falls back to YAML (legacy install)", () => {
53
+ const env = makeVaultHome();
54
+ try {
55
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$legacyhash"\n');
56
+ const status = readVaultAuthStatus({
57
+ vaultHome: env.path,
58
+ countTokens: () => 0,
59
+ probeHubDbHasUserPassword: () => false,
60
+ });
61
+ expect(status.hasOwnerPassword).toBe(true);
62
+ expect(status.hasTotp).toBe(false);
63
+ } finally {
64
+ env.cleanup();
65
+ }
66
+ });
67
+
68
+ test("hub.db unreachable, YAML has owner_password_hash → falls back to YAML", () => {
69
+ const env = makeVaultHome();
70
+ try {
71
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$superlegacyhash"\n');
72
+ const status = readVaultAuthStatus({
73
+ vaultHome: env.path,
74
+ countTokens: () => 0,
75
+ probeHubDbHasUserPassword: hubDbUnreachable,
76
+ });
77
+ expect(status.hasOwnerPassword).toBe(true);
78
+ } finally {
79
+ env.cleanup();
80
+ }
81
+ });
82
+
83
+ test("hub.db users empty AND no YAML → hasOwnerPassword: false (fresh wide-open install)", () => {
84
+ const env = makeVaultHome();
85
+ try {
86
+ const status = readVaultAuthStatus({
87
+ vaultHome: env.path,
88
+ countTokens: () => 0,
89
+ probeHubDbHasUserPassword: () => false,
90
+ });
91
+ expect(status.hasOwnerPassword).toBe(false);
92
+ expect(status.hasTotp).toBe(false);
93
+ } finally {
94
+ env.cleanup();
95
+ }
96
+ });
97
+
98
+ test("hub.db says yes overrides absent YAML — TOTP still reflects YAML state", () => {
99
+ const env = makeVaultHome();
100
+ try {
101
+ // TOTP-only YAML: vault-side 2FA was configured but hub.db is the
102
+ // canonical password source. Should report password=true (hub.db),
103
+ // totp=true (YAML).
104
+ writeConfig(env.path, 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
105
+ const status = readVaultAuthStatus({
106
+ vaultHome: env.path,
107
+ countTokens: () => 0,
108
+ probeHubDbHasUserPassword: () => true,
109
+ });
110
+ expect(status.hasOwnerPassword).toBe(true);
111
+ expect(status.hasTotp).toBe(true);
112
+ } finally {
113
+ env.cleanup();
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () => {
119
+ test("missing config.yaml AND hub.db unreachable → both signals false", () => {
27
120
  const env = makeVaultHome();
28
121
  try {
29
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
122
+ const status = readVaultAuthStatus({
123
+ vaultHome: env.path,
124
+ countTokens: () => 0,
125
+ probeHubDbHasUserPassword: hubDbUnreachable,
126
+ });
30
127
  expect(status.hasOwnerPassword).toBe(false);
31
128
  expect(status.hasTotp).toBe(false);
32
129
  } finally {
@@ -34,7 +131,7 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
34
131
  }
35
132
  });
36
133
 
37
- test("both keys present and non-empty → both true", () => {
134
+ test("both YAML keys present, hub.db unreachable → both true", () => {
38
135
  const env = makeVaultHome();
39
136
  try {
40
137
  writeConfig(
@@ -46,7 +143,11 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
46
143
  "",
47
144
  ].join("\n"),
48
145
  );
49
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
146
+ const status = readVaultAuthStatus({
147
+ vaultHome: env.path,
148
+ countTokens: () => 0,
149
+ probeHubDbHasUserPassword: hubDbUnreachable,
150
+ });
50
151
  expect(status.hasOwnerPassword).toBe(true);
51
152
  expect(status.hasTotp).toBe(true);
52
153
  } finally {
@@ -54,11 +155,15 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
54
155
  }
55
156
  });
56
157
 
57
- test("empty quoted values are treated as absent (matches vault's readGlobalConfig)", () => {
158
+ test("empty quoted YAML values are absent (matches vault's readGlobalConfig)", () => {
58
159
  const env = makeVaultHome();
59
160
  try {
60
161
  writeConfig(env.path, ['owner_password_hash: ""', 'totp_secret: ""', ""].join("\n"));
61
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
162
+ const status = readVaultAuthStatus({
163
+ vaultHome: env.path,
164
+ countTokens: () => 0,
165
+ probeHubDbHasUserPassword: hubDbUnreachable,
166
+ });
62
167
  expect(status.hasOwnerPassword).toBe(false);
63
168
  expect(status.hasTotp).toBe(false);
64
169
  } finally {
@@ -66,11 +171,15 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
66
171
  }
67
172
  });
68
173
 
69
- test("only owner_password_hash present", () => {
174
+ test("only YAML owner_password_hash present, hub.db unreachable", () => {
70
175
  const env = makeVaultHome();
71
176
  try {
72
177
  writeConfig(env.path, 'owner_password_hash: "$2b$12$abc"\n');
73
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
178
+ const status = readVaultAuthStatus({
179
+ vaultHome: env.path,
180
+ countTokens: () => 0,
181
+ probeHubDbHasUserPassword: hubDbUnreachable,
182
+ });
74
183
  expect(status.hasOwnerPassword).toBe(true);
75
184
  expect(status.hasTotp).toBe(false);
76
185
  } finally {
@@ -83,7 +192,11 @@ describe("readVaultAuthStatus — vault discovery", () => {
83
192
  test("no data/ dir → vaultNames empty, tokenCount 0", () => {
84
193
  const env = makeVaultHome();
85
194
  try {
86
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 999 });
195
+ const status = readVaultAuthStatus({
196
+ vaultHome: env.path,
197
+ countTokens: () => 999,
198
+ probeHubDbHasUserPassword: hubDbUnreachable,
199
+ });
87
200
  expect(status.vaultNames).toEqual([]);
88
201
  expect(status.tokenCount).toBe(0);
89
202
  } finally {
@@ -98,7 +211,11 @@ describe("readVaultAuthStatus — vault discovery", () => {
98
211
  seedVault(env.path, "default", { withDb: true });
99
212
  // garbage dir that happens to sit under data/
100
213
  mkdirSync(join(env.path, "data", "stray"), { recursive: true });
101
- const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
214
+ const status = readVaultAuthStatus({
215
+ vaultHome: env.path,
216
+ countTokens: () => 0,
217
+ probeHubDbHasUserPassword: hubDbUnreachable,
218
+ });
102
219
  expect(status.vaultNames).toEqual(["default"]);
103
220
  } finally {
104
221
  env.cleanup();
@@ -115,6 +232,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
115
232
  const status = readVaultAuthStatus({
116
233
  vaultHome: env.path,
117
234
  countTokens: (dbPath) => (dbPath.includes("/default/") ? 2 : 3),
235
+ probeHubDbHasUserPassword: hubDbUnreachable,
118
236
  });
119
237
  expect(status.tokenCount).toBe(5);
120
238
  expect(new Set(status.vaultNames)).toEqual(new Set(["default", "work"]));
@@ -135,6 +253,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
135
253
  if (dbPath.includes("/default/")) throw new Error("should not open missing DB");
136
254
  return 4;
137
255
  },
256
+ probeHubDbHasUserPassword: hubDbUnreachable,
138
257
  });
139
258
  expect(status.tokenCount).toBe(4);
140
259
  } finally {
@@ -153,6 +272,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
153
272
  if (dbPath.includes("/work/")) throw new Error("locked");
154
273
  return 2;
155
274
  },
275
+ probeHubDbHasUserPassword: hubDbUnreachable,
156
276
  });
157
277
  // Even though "default" succeeded with 2, we return null — callers
158
278
  // shouldn't see a misleading partial count.
@@ -162,3 +282,143 @@ describe("readVaultAuthStatus — token count resilience", () => {
162
282
  }
163
283
  });
164
284
  });
285
+
286
+ describe("readVaultAuthStatus — defaultProbeHubDbHasUserPassword end-to-end", () => {
287
+ // These tests exercise the real `bun:sqlite` probe (no injected fake)
288
+ // to catch breakage in the on-disk read path: schema drift, opening
289
+ // semantics, undefined-returns-on-failure.
290
+
291
+ test("hub.db missing → probe returns undefined → falls back to YAML cleanly", () => {
292
+ const env = makeVaultHome();
293
+ try {
294
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$legacyfromYAML"\n');
295
+ const status = readVaultAuthStatus({
296
+ vaultHome: env.path,
297
+ hubDbPath: join(env.path, "definitely-not-here.db"),
298
+ countTokens: () => 0,
299
+ });
300
+ expect(status.hasOwnerPassword).toBe(true);
301
+ } finally {
302
+ env.cleanup();
303
+ }
304
+ });
305
+
306
+ test("hub.db exists with users.password_hash set → hasOwnerPassword: true", () => {
307
+ const env = makeVaultHome();
308
+ try {
309
+ // Build a real hub.db with the canonical schema + an `unforced` user.
310
+ const { Database } = require("bun:sqlite");
311
+ const dbPath = join(env.path, "hub.db");
312
+ const db = new Database(dbPath);
313
+ db.exec(`
314
+ CREATE TABLE users (
315
+ id TEXT PRIMARY KEY,
316
+ username TEXT UNIQUE NOT NULL,
317
+ password_hash TEXT NOT NULL,
318
+ created_at TEXT NOT NULL,
319
+ updated_at TEXT NOT NULL,
320
+ password_changed INTEGER NOT NULL DEFAULT 0
321
+ );
322
+ INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed)
323
+ VALUES ('u1', 'unforced', '$argon2id$v=19$realhashhere', '2026-05-26T00:00:00Z', '2026-05-26T00:00:00Z', 1);
324
+ `);
325
+ db.close();
326
+ const status = readVaultAuthStatus({
327
+ vaultHome: env.path,
328
+ hubDbPath: dbPath,
329
+ countTokens: () => 0,
330
+ });
331
+ expect(status.hasOwnerPassword).toBe(true);
332
+ } finally {
333
+ env.cleanup();
334
+ }
335
+ });
336
+
337
+ test("hub.db exists but users table empty → falls back to YAML", () => {
338
+ const env = makeVaultHome();
339
+ try {
340
+ const { Database } = require("bun:sqlite");
341
+ const dbPath = join(env.path, "hub.db");
342
+ const db = new Database(dbPath);
343
+ db.exec(`
344
+ CREATE TABLE users (
345
+ id TEXT PRIMARY KEY,
346
+ username TEXT UNIQUE NOT NULL,
347
+ password_hash TEXT NOT NULL,
348
+ created_at TEXT NOT NULL,
349
+ updated_at TEXT NOT NULL,
350
+ password_changed INTEGER NOT NULL DEFAULT 0
351
+ );
352
+ `);
353
+ db.close();
354
+ // YAML provides the password — should still be true.
355
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$fallbackhash"\n');
356
+ const status = readVaultAuthStatus({
357
+ vaultHome: env.path,
358
+ hubDbPath: dbPath,
359
+ countTokens: () => 0,
360
+ });
361
+ expect(status.hasOwnerPassword).toBe(true);
362
+ } finally {
363
+ env.cleanup();
364
+ }
365
+ });
366
+
367
+ test("hub.db exists but schema is missing the users table → probe returns undefined, YAML fallback", () => {
368
+ const env = makeVaultHome();
369
+ try {
370
+ // A hub.db that hasn't run migration v2 yet (only signing_keys, v1).
371
+ const { Database } = require("bun:sqlite");
372
+ const dbPath = join(env.path, "hub.db");
373
+ const db = new Database(dbPath);
374
+ db.exec(`
375
+ CREATE TABLE signing_keys (kid TEXT PRIMARY KEY);
376
+ `);
377
+ db.close();
378
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$onlyinYAML"\n');
379
+ const status = readVaultAuthStatus({
380
+ vaultHome: env.path,
381
+ hubDbPath: dbPath,
382
+ countTokens: () => 0,
383
+ });
384
+ // SELECT against nonexistent `users` throws → probe returns undefined
385
+ // → YAML wins → password reported as set.
386
+ expect(status.hasOwnerPassword).toBe(true);
387
+ } finally {
388
+ env.cleanup();
389
+ }
390
+ });
391
+
392
+ test("hub.db users table has a row with empty password_hash → treated as no password", () => {
393
+ const env = makeVaultHome();
394
+ try {
395
+ const { Database } = require("bun:sqlite");
396
+ const dbPath = join(env.path, "hub.db");
397
+ const db = new Database(dbPath);
398
+ // NOT NULL on password_hash, but allow empty string — schema check
399
+ // is just for "non-empty hash exists."
400
+ db.exec(`
401
+ CREATE TABLE users (
402
+ id TEXT PRIMARY KEY,
403
+ username TEXT UNIQUE NOT NULL,
404
+ password_hash TEXT NOT NULL,
405
+ created_at TEXT NOT NULL,
406
+ updated_at TEXT NOT NULL,
407
+ password_changed INTEGER NOT NULL DEFAULT 0
408
+ );
409
+ INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed)
410
+ VALUES ('u1', 'placeholder', '', '2026-05-26T00:00:00Z', '2026-05-26T00:00:00Z', 0);
411
+ `);
412
+ db.close();
413
+ const status = readVaultAuthStatus({
414
+ vaultHome: env.path,
415
+ hubDbPath: dbPath,
416
+ countTokens: () => 0,
417
+ });
418
+ // No YAML, no non-empty hub.db password → wide open.
419
+ expect(status.hasOwnerPassword).toBe(false);
420
+ } finally {
421
+ env.cleanup();
422
+ }
423
+ });
424
+ });
@@ -1,29 +1,47 @@
1
1
  /**
2
- * Read-only probe of vault's auth state, for the post-exposure preflight
3
- * nudge. We don't want to lock the DB or mutate anything — this is a
4
- * one-shot "should we warn the user their vault is wide open on the public
2
+ * Read-only probe of operator auth state, for the post-exposure preflight
3
+ * nudge. We don't want to lock anything or mutate state — this is a one-
4
+ * shot "should we warn the user their vault is wide open on the public
5
5
  * internet?" check.
6
6
  *
7
- * Two sources:
8
- * 1. ~/.parachute/vault/config.yaml → owner_password_hash + totp_secret
9
- * 2. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table count
7
+ * Three sources, checked in this order:
10
8
  *
11
- * The YAML path uses line-anchored regex parsing that matches vault's own
12
- * `readGlobalConfig()` semantics (parachute-vault src/config.ts): keys are
13
- * optional, quoted scalars, and empty-string / missing-key both mean "not
14
- * configured." We mirror that rather than bringing in a YAML dependency.
9
+ * 1. ~/.parachute/hub.db → users table (authoritative since multi-user
10
+ * Phase 1, hub#252 / PRs 279–281 / 425). Hub-issued OAuth + browser
11
+ * sign-in both verify against `users.password_hash`. If any user row
12
+ * exists with a non-empty password_hash, the operator has an account
13
+ * "owner password set." The earliest-created user row is the canonical
14
+ * operator (cf. `getFirstAdminId` in src/users.ts).
15
+ * 2. ~/.parachute/vault/config.yaml → owner_password_hash + totp_secret
16
+ * (pre-multi-user Phase 1 location). Fallback for super-old installs
17
+ * whose hub.db is absent or empty.
18
+ * 3. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table
19
+ * count, summed across every vault instance.
15
20
  *
16
- * The SQLite path is best-effort: if the DB is missing, locked (vault is
17
- * writing), or the schema has drifted, `tokenCount` comes back as `null`
18
- * and the caller surfaces "token status unknown" rather than lying with a
19
- * false zero. The exposure flow has already succeeded by the time this
20
- * runs a probe failure must never block the user's happy path.
21
+ * The hub.db schema doesn't yet carry a TOTP column (2FA lands in a later
22
+ * phase per the multi-user design doc); we always report `hasTotp: false`
23
+ * when the hub.db path is the source of truth. That matches what's
24
+ * actually shipped pretending otherwise would whisper "you're covered"
25
+ * when no second factor exists.
21
26
  *
22
- * Schema coupling note: we read the `tokens` table by name with a bare
23
- * COUNT(*). If vault ever renames that table, that's a breaking change on
24
- * vault's side and this probe is the least of the fallout. Post-launch,
25
- * a public `/api/auth/status` endpoint on vault (tracked separately) would
26
- * let us drop this coupling entirely.
27
+ * The YAML fallback path uses line-anchored regex parsing that matches
28
+ * vault's own `readGlobalConfig()` semantics (parachute-vault src/config.ts):
29
+ * keys are optional, quoted scalars, and empty-string / missing-key both
30
+ * mean "not configured." We mirror that rather than bringing in a YAML
31
+ * dependency.
32
+ *
33
+ * The vault-token SQLite path is best-effort: if the DB is missing,
34
+ * locked (vault is writing), or the schema has drifted, `tokenCount`
35
+ * comes back as `null` and the caller surfaces "token status unknown"
36
+ * rather than lying with a false zero. The exposure flow has already
37
+ * succeeded by the time this runs — a probe failure must never block
38
+ * the user's happy path.
39
+ *
40
+ * Schema coupling note: we read the `tokens` table in each vault.db by
41
+ * name with a bare COUNT(*). If vault ever renames that table, that's a
42
+ * breaking change on vault's side and this probe is the least of the
43
+ * fallout. Post-launch, a public `/api/auth/status` endpoint on vault
44
+ * (tracked separately) would let us drop this coupling entirely.
27
45
  */
28
46
 
29
47
  import { existsSync, readFileSync, readdirSync } from "node:fs";
@@ -49,6 +67,8 @@ export interface VaultAuthStatus {
49
67
  export interface AuthStatusOpts {
50
68
  /** Override `~/.parachute/vault` for tests. */
51
69
  vaultHome?: string;
70
+ /** Override `~/.parachute/hub.db` for tests. */
71
+ hubDbPath?: string;
52
72
  /** Read a YAML file; defaults to `readFileSync(path, "utf8")`. Missing
53
73
  * file should return `undefined` (not throw) so callers can distinguish
54
74
  * "no password configured" from "IO error." */
@@ -60,13 +80,31 @@ export interface AuthStatusOpts {
60
80
  * thrown error (missing, locked, schema drift) is caught by the caller
61
81
  * and mapped to `tokenCount: null`. */
62
82
  countTokens?: (dbPath: string) => number;
83
+ /**
84
+ * Probe hub.db for "does at least one user row with a non-empty
85
+ * `password_hash` exist?" — the canonical "owner password is set"
86
+ * signal post-multi-user-Phase-1. Returning:
87
+ * - `true` → at least one user has a password_hash; hub.db is
88
+ * the source of truth, YAML fallback is skipped.
89
+ * - `false` → hub.db opened cleanly but users is empty (fresh
90
+ * install pre-wizard); fall back to YAML.
91
+ * - `undefined` → hub.db missing / unreadable / migration not yet
92
+ * applied; fall back to YAML (legacy install path).
93
+ * The split between `false` and `undefined` matters: an empty hub.db
94
+ * on a fresh wizard run should NOT be allowed to mask an owner_password_hash
95
+ * that the operator set via vault's pre-multi-user flow. Callers that
96
+ * want true "I can sign in" semantics get the OR of hub.db∪YAML.
97
+ */
98
+ probeHubDbHasUserPassword?: (dbPath: string) => boolean | undefined;
63
99
  }
64
100
 
65
101
  interface Resolved {
66
102
  vaultHome: string;
103
+ hubDbPath: string;
67
104
  readText: (path: string) => string | undefined;
68
105
  listVaultNames: (dataDir: string) => string[];
69
106
  countTokens: (dbPath: string) => number;
107
+ probeHubDbHasUserPassword: (dbPath: string) => boolean | undefined;
70
108
  }
71
109
 
72
110
  function defaultVaultHome(): string {
@@ -77,6 +115,11 @@ function defaultVaultHome(): string {
77
115
  return root.length > 0 ? join(root, "vault") : join(homedir(), ".parachute", "vault");
78
116
  }
79
117
 
118
+ function defaultHubDbPath(): string {
119
+ const root = configDir();
120
+ return root.length > 0 ? join(root, "hub.db") : join(homedir(), ".parachute", "hub.db");
121
+ }
122
+
80
123
  function defaultReadText(path: string): string | undefined {
81
124
  try {
82
125
  return readFileSync(path, "utf8");
@@ -112,12 +155,58 @@ function defaultCountTokens(dbPath: string): number {
112
155
  }
113
156
  }
114
157
 
158
+ /**
159
+ * Open hub.db readonly and ask "does at least one user have a non-empty
160
+ * password_hash?" Returns `undefined` on any failure (DB missing, locked,
161
+ * schema drift, users table absent because migration v2 hasn't applied) —
162
+ * indistinguishable from "no hub.db at all," which is what the caller
163
+ * wants for the YAML-fallback branch.
164
+ *
165
+ * We deliberately do NOT open hub.db read-write here — `openHubDb()`
166
+ * would run migrations as a side effect, and this is a read probe from
167
+ * an unrelated command (`parachute expose`). `readonly: true` skips the
168
+ * WAL handshake and won't contend with the live hub server.
169
+ */
170
+ function defaultProbeHubDbHasUserPassword(dbPath: string): boolean | undefined {
171
+ if (!existsSync(dbPath)) return undefined;
172
+ const { Database } = require("bun:sqlite");
173
+ let db: { prepare: (sql: string) => { get: () => unknown }; close: () => void } | undefined;
174
+ try {
175
+ db = new Database(dbPath, { readonly: true }) as typeof db;
176
+ // COUNT(*) over users with non-empty password_hash. `length(...) > 0`
177
+ // matches the "missing/empty hash" treatment from the YAML side.
178
+ //
179
+ // Why "any user with a hash" not "first admin specifically": friend
180
+ // accounts can only be created by an already-authenticated admin
181
+ // (per api-users.ts's host:admin gate), so any user-with-hash
182
+ // implies the first admin has one too. Equivalent in practice and
183
+ // simpler than a JOIN on earliest-created-at. A future env-seed
184
+ // flow that creates friend accounts before the operator sets a
185
+ // password would need to revisit this assumption.
186
+ const row = db?.prepare(
187
+ "SELECT COUNT(*) AS n FROM users WHERE password_hash IS NOT NULL AND length(password_hash) > 0",
188
+ ).get() as { n: number } | null;
189
+ return (row?.n ?? 0) > 0;
190
+ } catch {
191
+ return undefined;
192
+ } finally {
193
+ try {
194
+ db?.close();
195
+ } catch {
196
+ // ignore
197
+ }
198
+ }
199
+ }
200
+
115
201
  function resolve(opts: AuthStatusOpts): Resolved {
116
202
  return {
117
203
  vaultHome: opts.vaultHome ?? defaultVaultHome(),
204
+ hubDbPath: opts.hubDbPath ?? defaultHubDbPath(),
118
205
  readText: opts.readText ?? defaultReadText,
119
206
  listVaultNames: opts.listVaultNames ?? defaultListVaultNames,
120
207
  countTokens: opts.countTokens ?? defaultCountTokens,
208
+ probeHubDbHasUserPassword:
209
+ opts.probeHubDbHasUserPassword ?? defaultProbeHubDbHasUserPassword,
121
210
  };
122
211
  }
123
212
 
@@ -134,7 +223,12 @@ function matchQuotedKey(yaml: string, key: string): string | undefined {
134
223
  return captured;
135
224
  }
136
225
 
137
- function readGlobalAuth(r: Resolved): { hasOwnerPassword: boolean; hasTotp: boolean } {
226
+ interface AuthSignals {
227
+ hasOwnerPassword: boolean;
228
+ hasTotp: boolean;
229
+ }
230
+
231
+ function readYamlAuth(r: Resolved): AuthSignals {
138
232
  const yaml = r.readText(join(r.vaultHome, "config.yaml"));
139
233
  if (yaml === undefined) return { hasOwnerPassword: false, hasTotp: false };
140
234
  return {
@@ -143,6 +237,35 @@ function readGlobalAuth(r: Resolved): { hasOwnerPassword: boolean; hasTotp: bool
143
237
  };
144
238
  }
145
239
 
240
+ /**
241
+ * Combine the hub.db probe + the legacy YAML probe into a single auth-signals
242
+ * read. Logic:
243
+ *
244
+ * - hub.db says yes → operator has an account in the canonical store;
245
+ * report `hasOwnerPassword: true`. TOTP is reported per the YAML probe
246
+ * (so a legacy operator who set both YAML password + YAML totp_secret,
247
+ * then migrated to a hub.db user, still surfaces "TOTP is on" — we
248
+ * don't have a hub-side TOTP column yet, hub#252 Phase 3 lands it).
249
+ * - hub.db says no AND was reachable → users table is empty, no hub
250
+ * account exists yet. Fall back to YAML for both signals — a pre-
251
+ * multi-user install would have its password in YAML.
252
+ * - hub.db unreachable → can't tell, fall back to YAML entirely.
253
+ *
254
+ * Net effect: `hasOwnerPassword` is the OR of (hub.db has a user with a
255
+ * password) ∪ (YAML has owner_password_hash). Either source counts.
256
+ */
257
+ function readAuthSignals(r: Resolved): AuthSignals {
258
+ const yaml = readYamlAuth(r);
259
+ const hubDbHasUser = r.probeHubDbHasUserPassword(r.hubDbPath);
260
+ const hasOwnerPassword = hubDbHasUser === true ? true : yaml.hasOwnerPassword;
261
+ return {
262
+ hasOwnerPassword,
263
+ // No hub-side TOTP column shipped yet (multi-user Phase 3). Until it
264
+ // lands, TOTP is YAML-only — matches what's actually true on disk.
265
+ hasTotp: yaml.hasTotp,
266
+ };
267
+ }
268
+
146
269
  /**
147
270
  * Sum token counts across every vault instance found under data/. If any
148
271
  * probe throws (missing DB, locked, schema drift), the whole result
@@ -171,7 +294,7 @@ function readTotalTokenCount(r: Resolved, vaultNames: string[]): number | null {
171
294
 
172
295
  export function readVaultAuthStatus(opts: AuthStatusOpts = {}): VaultAuthStatus {
173
296
  const r = resolve(opts);
174
- const { hasOwnerPassword, hasTotp } = readGlobalAuth(r);
297
+ const { hasOwnerPassword, hasTotp } = readAuthSignals(r);
175
298
  const dataDir = join(r.vaultHome, "data");
176
299
  const vaultNames = r.listVaultNames(dataDir);
177
300
  const tokenCount = readTotalTokenCount(r, vaultNames);