@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 +1 -1
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/vault/auth-status.ts +145 -22
package/package.json
CHANGED
|
@@ -22,11 +22,108 @@ function seedVault(vaultHome: string, name: string, opts: { withDb?: boolean } =
|
|
|
22
22
|
return dbPath;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
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({
|
|
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
|
|
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({
|
|
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
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
+
});
|
package/src/vault/auth-status.ts
CHANGED
|
@@ -1,29 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Read-only probe of
|
|
3
|
-
* nudge. We don't want to lock
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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 } =
|
|
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);
|