@openparachute/hub 0.5.13 → 0.5.14-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the durable half of the OAuth issuer-mismatch fix: persisting the
|
|
3
|
+
* hub's PUBLIC origin into `<configDir>/vault/.env` so the launchd / systemd
|
|
4
|
+
* daemon — which boots vault out-of-band and never sees the `parachute start`
|
|
5
|
+
* spawn env — validates hub-minted JWTs' `iss` against the public origin
|
|
6
|
+
* instead of vault's loopback default. See `vault-hub-origin-env.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { readEnvFileValues } from "../env-file.ts";
|
|
13
|
+
import {
|
|
14
|
+
clearVaultHubOrigin,
|
|
15
|
+
isLoopbackOrigin,
|
|
16
|
+
persistVaultHubOrigin,
|
|
17
|
+
} from "../vault-hub-origin-env.ts";
|
|
18
|
+
|
|
19
|
+
let dir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), "pcli-vhoe-"));
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function vaultEnv(): string {
|
|
29
|
+
return join(dir, "vault", ".env");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("isLoopbackOrigin", () => {
|
|
33
|
+
test("flags 127.0.0.1 / localhost / [::1] / 0.0.0.0", () => {
|
|
34
|
+
expect(isLoopbackOrigin("http://127.0.0.1:1939")).toBe(true);
|
|
35
|
+
expect(isLoopbackOrigin("http://localhost:1939")).toBe(true);
|
|
36
|
+
expect(isLoopbackOrigin("http://[::1]:1939")).toBe(true);
|
|
37
|
+
// 0.0.0.0 is a bind-all wildcard, not a reachable origin.
|
|
38
|
+
expect(isLoopbackOrigin("http://0.0.0.0:1939")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("does not flag a public FQDN", () => {
|
|
42
|
+
expect(isLoopbackOrigin("https://parachute-aaron.tailc75afc.ts.net")).toBe(false);
|
|
43
|
+
expect(isLoopbackOrigin("https://hub.example.com")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("non-URL strings are treated as non-loopback (don't block persistence)", () => {
|
|
47
|
+
expect(isLoopbackOrigin("not a url")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("persistVaultHubOrigin", () => {
|
|
52
|
+
test("writes a non-loopback public origin into vault/.env", () => {
|
|
53
|
+
const wrote = persistVaultHubOrigin(dir, "https://parachute-aaron.tailc75afc.ts.net", () => {});
|
|
54
|
+
expect(wrote).toBe(true);
|
|
55
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
|
|
56
|
+
"https://parachute-aaron.tailc75afc.ts.net",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("refuses to persist a loopback origin (would shadow a later exposure)", () => {
|
|
61
|
+
const wrote = persistVaultHubOrigin(dir, "http://127.0.0.1:1939", () => {});
|
|
62
|
+
expect(wrote).toBe(false);
|
|
63
|
+
expect(existsSync(vaultEnv())).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("refuses to persist a 0.0.0.0 origin (--hub-origin flows straight through)", () => {
|
|
67
|
+
// `--hub-origin http://0.0.0.0:1939` bypasses deriveHubOrigin and reaches
|
|
68
|
+
// here verbatim; baking a bind-all wildcard into vault/.env would advertise
|
|
69
|
+
// a non-functional issuer and recreate the iss-mismatch class.
|
|
70
|
+
const wrote = persistVaultHubOrigin(dir, "http://0.0.0.0:1939", () => {});
|
|
71
|
+
expect(wrote).toBe(false);
|
|
72
|
+
expect(existsSync(vaultEnv())).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("is idempotent — no rewrite when the value is already current", () => {
|
|
76
|
+
const log: string[] = [];
|
|
77
|
+
expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
|
|
78
|
+
expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
|
|
79
|
+
// Only the first call logged.
|
|
80
|
+
expect(log).toHaveLength(1);
|
|
81
|
+
expect(log[0]).toMatch(/persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("updates a stale origin in-place and preserves sibling keys", () => {
|
|
85
|
+
writeFileSync(
|
|
86
|
+
mkVaultDir(),
|
|
87
|
+
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://old.example.com\nSCRIBE_URL=http://127.0.0.1:1943\n",
|
|
88
|
+
);
|
|
89
|
+
const wrote = persistVaultHubOrigin(dir, "https://new.example.com", () => {});
|
|
90
|
+
expect(wrote).toBe(true);
|
|
91
|
+
const values = readEnvFileValues(vaultEnv());
|
|
92
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://new.example.com");
|
|
93
|
+
// Sibling keys untouched.
|
|
94
|
+
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
95
|
+
expect(values.SCRIBE_URL).toBe("http://127.0.0.1:1943");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("clearVaultHubOrigin", () => {
|
|
100
|
+
test("removes a persisted origin and leaves sibling keys", () => {
|
|
101
|
+
writeFileSync(
|
|
102
|
+
mkVaultDir(),
|
|
103
|
+
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\n",
|
|
104
|
+
);
|
|
105
|
+
const wrote = clearVaultHubOrigin(dir, () => {});
|
|
106
|
+
expect(wrote).toBe(true);
|
|
107
|
+
const values = readEnvFileValues(vaultEnv());
|
|
108
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
109
|
+
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("no-op when no origin is present", () => {
|
|
113
|
+
writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
|
|
114
|
+
expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("no-op when vault/.env doesn't exist", () => {
|
|
118
|
+
expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** Create `<dir>/vault/` and return the `.env` path so writeFileSync lands. */
|
|
123
|
+
function mkVaultDir(): string {
|
|
124
|
+
mkdirSync(join(dir, "vault"), { recursive: true });
|
|
125
|
+
return vaultEnv();
|
|
126
|
+
}
|
|
@@ -426,10 +426,10 @@ describe("buildWellKnown", () => {
|
|
|
426
426
|
// joined onto the canonical origin into a deep-linkable `url`.
|
|
427
427
|
describe("uis hierarchical sub-units (hub#313)", () => {
|
|
428
428
|
const app: ServiceEntry = {
|
|
429
|
-
name: "parachute-
|
|
429
|
+
name: "parachute-surface",
|
|
430
430
|
port: 1946,
|
|
431
|
-
paths: ["/
|
|
432
|
-
health: "/
|
|
431
|
+
paths: ["/surface"],
|
|
432
|
+
health: "/surface/healthz",
|
|
433
433
|
version: "0.1.0",
|
|
434
434
|
};
|
|
435
435
|
|
|
@@ -455,7 +455,7 @@ describe("buildWellKnown", () => {
|
|
|
455
455
|
services: [withUis],
|
|
456
456
|
canonicalOrigin: "https://x.example",
|
|
457
457
|
});
|
|
458
|
-
const appSvc = doc.services.find((s) => s.name === "parachute-
|
|
458
|
+
const appSvc = doc.services.find((s) => s.name === "parachute-surface");
|
|
459
459
|
expect(appSvc?.uis).toEqual([
|
|
460
460
|
{
|
|
461
461
|
name: "gitcoin-brain",
|
|
@@ -500,7 +500,7 @@ describe("buildWellKnown", () => {
|
|
|
500
500
|
services: [empty],
|
|
501
501
|
canonicalOrigin: "https://x.example",
|
|
502
502
|
});
|
|
503
|
-
const svc = doc.services.find((s) => s.name === "parachute-
|
|
503
|
+
const svc = doc.services.find((s) => s.name === "parachute-surface");
|
|
504
504
|
expect(svc).not.toHaveProperty("uis");
|
|
505
505
|
});
|
|
506
506
|
|
|
@@ -519,7 +519,7 @@ describe("buildWellKnown", () => {
|
|
|
519
519
|
services: [withIcon],
|
|
520
520
|
canonicalOrigin: "https://x.example",
|
|
521
521
|
});
|
|
522
|
-
const svc = doc.services.find((s) => s.name === "parachute-
|
|
522
|
+
const svc = doc.services.find((s) => s.name === "parachute-surface");
|
|
523
523
|
expect(svc?.uis?.[0]?.iconUrl).toBe("https://x.example/app/slug/icon.svg");
|
|
524
524
|
});
|
|
525
525
|
|
|
@@ -538,7 +538,7 @@ describe("buildWellKnown", () => {
|
|
|
538
538
|
services: [withIcon],
|
|
539
539
|
canonicalOrigin: "https://x.example",
|
|
540
540
|
});
|
|
541
|
-
const svc = doc.services.find((s) => s.name === "parachute-
|
|
541
|
+
const svc = doc.services.find((s) => s.name === "parachute-surface");
|
|
542
542
|
expect(svc?.uis?.[0]?.iconUrl).toBe("https://cdn.example.com/icon.svg");
|
|
543
543
|
});
|
|
544
544
|
|
|
@@ -562,7 +562,7 @@ describe("buildWellKnown", () => {
|
|
|
562
562
|
services: [mixed],
|
|
563
563
|
canonicalOrigin: "https://x.example",
|
|
564
564
|
});
|
|
565
|
-
const svc = doc.services.find((s) => s.name === "parachute-
|
|
565
|
+
const svc = doc.services.find((s) => s.name === "parachute-surface");
|
|
566
566
|
const full = svc?.uis?.find((u) => u.name === "full");
|
|
567
567
|
const minimal = svc?.uis?.find((u) => u.name === "minimal");
|
|
568
568
|
expect(full?.tagline).toBe("Has it all");
|
|
@@ -593,7 +593,7 @@ describe("buildWellKnown", () => {
|
|
|
593
593
|
services: [app1, app2],
|
|
594
594
|
canonicalOrigin: "https://x.example",
|
|
595
595
|
});
|
|
596
|
-
const svc1 = doc.services.find((s) => s.name === "parachute-
|
|
596
|
+
const svc1 = doc.services.find((s) => s.name === "parachute-surface");
|
|
597
597
|
const svc2 = doc.services.find((s) => s.name === "parachute-app-2");
|
|
598
598
|
expect(svc1?.uis?.map((u) => u.name)).toEqual(["a"]);
|
|
599
599
|
expect(svc2?.uis?.map((u) => u.name)).toEqual(["b"]);
|