@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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 +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- 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 +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- 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 +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- 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 +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- 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 +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -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-tRmPbbC7.js +0 -61
|
@@ -22,11 +22,146 @@ 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
|
+
probeHubDbHasTotp: () => false,
|
|
43
|
+
});
|
|
44
|
+
expect(status.hasOwnerPassword).toBe(true);
|
|
45
|
+
// No TOTP enrolled in hub.db (and no legacy YAML) → false.
|
|
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
|
+
probeHubDbHasTotp: () => false,
|
|
61
|
+
});
|
|
62
|
+
expect(status.hasOwnerPassword).toBe(true);
|
|
63
|
+
expect(status.hasTotp).toBe(false);
|
|
64
|
+
} finally {
|
|
65
|
+
env.cleanup();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("hub.db unreachable, YAML has owner_password_hash → falls back to YAML", () => {
|
|
70
|
+
const env = makeVaultHome();
|
|
71
|
+
try {
|
|
72
|
+
writeConfig(env.path, 'owner_password_hash: "$2b$12$superlegacyhash"\n');
|
|
73
|
+
const status = readVaultAuthStatus({
|
|
74
|
+
vaultHome: env.path,
|
|
75
|
+
countTokens: () => 0,
|
|
76
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
77
|
+
});
|
|
78
|
+
expect(status.hasOwnerPassword).toBe(true);
|
|
79
|
+
} finally {
|
|
80
|
+
env.cleanup();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("hub.db users empty AND no YAML → hasOwnerPassword: false (fresh wide-open install)", () => {
|
|
85
|
+
const env = makeVaultHome();
|
|
86
|
+
try {
|
|
87
|
+
const status = readVaultAuthStatus({
|
|
88
|
+
vaultHome: env.path,
|
|
89
|
+
countTokens: () => 0,
|
|
90
|
+
probeHubDbHasUserPassword: () => false,
|
|
91
|
+
probeHubDbHasTotp: () => false,
|
|
92
|
+
});
|
|
93
|
+
expect(status.hasOwnerPassword).toBe(false);
|
|
94
|
+
expect(status.hasTotp).toBe(false);
|
|
95
|
+
} finally {
|
|
96
|
+
env.cleanup();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("hub.db password=true, hub.db TOTP unreachable → TOTP falls back to YAML state", () => {
|
|
101
|
+
const env = makeVaultHome();
|
|
102
|
+
try {
|
|
103
|
+
// hub#473: hub.db is the canonical TOTP source, but when the TOTP probe
|
|
104
|
+
// is unreachable (pre-v11 column absent) it falls back to the legacy
|
|
105
|
+
// vault YAML totp_secret. password=true (hub.db), totp=true (YAML fallback).
|
|
106
|
+
writeConfig(env.path, 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
|
|
107
|
+
const status = readVaultAuthStatus({
|
|
108
|
+
vaultHome: env.path,
|
|
109
|
+
countTokens: () => 0,
|
|
110
|
+
probeHubDbHasUserPassword: () => true,
|
|
111
|
+
probeHubDbHasTotp: () => undefined,
|
|
112
|
+
});
|
|
113
|
+
expect(status.hasOwnerPassword).toBe(true);
|
|
114
|
+
expect(status.hasTotp).toBe(true);
|
|
115
|
+
} finally {
|
|
116
|
+
env.cleanup();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("hub.db TOTP=true is the real signal — overrides absent YAML", () => {
|
|
121
|
+
const env = makeVaultHome();
|
|
122
|
+
try {
|
|
123
|
+
// No YAML totp_secret, but a hub.db user has enrolled real 2FA (hub#473).
|
|
124
|
+
const status = readVaultAuthStatus({
|
|
125
|
+
vaultHome: env.path,
|
|
126
|
+
countTokens: () => 0,
|
|
127
|
+
probeHubDbHasUserPassword: () => true,
|
|
128
|
+
probeHubDbHasTotp: () => true,
|
|
129
|
+
});
|
|
130
|
+
expect(status.hasTotp).toBe(true);
|
|
131
|
+
} finally {
|
|
132
|
+
env.cleanup();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("hub.db TOTP=false (column present, none enrolled) overrides a stale YAML true", () => {
|
|
137
|
+
const env = makeVaultHome();
|
|
138
|
+
try {
|
|
139
|
+
// Legacy YAML totp_secret present, but hub.db definitively says no user
|
|
140
|
+
// has enrolled real hub-login 2FA → report false (the real signal wins).
|
|
141
|
+
writeConfig(env.path, 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
|
|
142
|
+
const status = readVaultAuthStatus({
|
|
143
|
+
vaultHome: env.path,
|
|
144
|
+
countTokens: () => 0,
|
|
145
|
+
probeHubDbHasUserPassword: () => true,
|
|
146
|
+
probeHubDbHasTotp: () => false,
|
|
147
|
+
});
|
|
148
|
+
expect(status.hasTotp).toBe(false);
|
|
149
|
+
} finally {
|
|
150
|
+
env.cleanup();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () => {
|
|
156
|
+
test("missing config.yaml AND hub.db unreachable → both signals false", () => {
|
|
27
157
|
const env = makeVaultHome();
|
|
28
158
|
try {
|
|
29
|
-
const status = readVaultAuthStatus({
|
|
159
|
+
const status = readVaultAuthStatus({
|
|
160
|
+
vaultHome: env.path,
|
|
161
|
+
countTokens: () => 0,
|
|
162
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
163
|
+
probeHubDbHasTotp: hubDbUnreachable,
|
|
164
|
+
});
|
|
30
165
|
expect(status.hasOwnerPassword).toBe(false);
|
|
31
166
|
expect(status.hasTotp).toBe(false);
|
|
32
167
|
} finally {
|
|
@@ -34,7 +169,7 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
|
|
|
34
169
|
}
|
|
35
170
|
});
|
|
36
171
|
|
|
37
|
-
test("both keys present
|
|
172
|
+
test("both YAML keys present, hub.db unreachable → both true", () => {
|
|
38
173
|
const env = makeVaultHome();
|
|
39
174
|
try {
|
|
40
175
|
writeConfig(
|
|
@@ -46,7 +181,12 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
|
|
|
46
181
|
"",
|
|
47
182
|
].join("\n"),
|
|
48
183
|
);
|
|
49
|
-
const status = readVaultAuthStatus({
|
|
184
|
+
const status = readVaultAuthStatus({
|
|
185
|
+
vaultHome: env.path,
|
|
186
|
+
countTokens: () => 0,
|
|
187
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
188
|
+
probeHubDbHasTotp: hubDbUnreachable,
|
|
189
|
+
});
|
|
50
190
|
expect(status.hasOwnerPassword).toBe(true);
|
|
51
191
|
expect(status.hasTotp).toBe(true);
|
|
52
192
|
} finally {
|
|
@@ -54,11 +194,16 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
|
|
|
54
194
|
}
|
|
55
195
|
});
|
|
56
196
|
|
|
57
|
-
test("empty quoted values are
|
|
197
|
+
test("empty quoted YAML values are absent (matches vault's readGlobalConfig)", () => {
|
|
58
198
|
const env = makeVaultHome();
|
|
59
199
|
try {
|
|
60
200
|
writeConfig(env.path, ['owner_password_hash: ""', 'totp_secret: ""', ""].join("\n"));
|
|
61
|
-
const status = readVaultAuthStatus({
|
|
201
|
+
const status = readVaultAuthStatus({
|
|
202
|
+
vaultHome: env.path,
|
|
203
|
+
countTokens: () => 0,
|
|
204
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
205
|
+
probeHubDbHasTotp: hubDbUnreachable,
|
|
206
|
+
});
|
|
62
207
|
expect(status.hasOwnerPassword).toBe(false);
|
|
63
208
|
expect(status.hasTotp).toBe(false);
|
|
64
209
|
} finally {
|
|
@@ -66,11 +211,16 @@ describe("readVaultAuthStatus — config.yaml parse", () => {
|
|
|
66
211
|
}
|
|
67
212
|
});
|
|
68
213
|
|
|
69
|
-
test("only owner_password_hash present", () => {
|
|
214
|
+
test("only YAML owner_password_hash present, hub.db unreachable", () => {
|
|
70
215
|
const env = makeVaultHome();
|
|
71
216
|
try {
|
|
72
217
|
writeConfig(env.path, 'owner_password_hash: "$2b$12$abc"\n');
|
|
73
|
-
const status = readVaultAuthStatus({
|
|
218
|
+
const status = readVaultAuthStatus({
|
|
219
|
+
vaultHome: env.path,
|
|
220
|
+
countTokens: () => 0,
|
|
221
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
222
|
+
probeHubDbHasTotp: hubDbUnreachable,
|
|
223
|
+
});
|
|
74
224
|
expect(status.hasOwnerPassword).toBe(true);
|
|
75
225
|
expect(status.hasTotp).toBe(false);
|
|
76
226
|
} finally {
|
|
@@ -83,7 +233,11 @@ describe("readVaultAuthStatus — vault discovery", () => {
|
|
|
83
233
|
test("no data/ dir → vaultNames empty, tokenCount 0", () => {
|
|
84
234
|
const env = makeVaultHome();
|
|
85
235
|
try {
|
|
86
|
-
const status = readVaultAuthStatus({
|
|
236
|
+
const status = readVaultAuthStatus({
|
|
237
|
+
vaultHome: env.path,
|
|
238
|
+
countTokens: () => 999,
|
|
239
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
240
|
+
});
|
|
87
241
|
expect(status.vaultNames).toEqual([]);
|
|
88
242
|
expect(status.tokenCount).toBe(0);
|
|
89
243
|
} finally {
|
|
@@ -98,7 +252,11 @@ describe("readVaultAuthStatus — vault discovery", () => {
|
|
|
98
252
|
seedVault(env.path, "default", { withDb: true });
|
|
99
253
|
// garbage dir that happens to sit under data/
|
|
100
254
|
mkdirSync(join(env.path, "data", "stray"), { recursive: true });
|
|
101
|
-
const status = readVaultAuthStatus({
|
|
255
|
+
const status = readVaultAuthStatus({
|
|
256
|
+
vaultHome: env.path,
|
|
257
|
+
countTokens: () => 0,
|
|
258
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
259
|
+
});
|
|
102
260
|
expect(status.vaultNames).toEqual(["default"]);
|
|
103
261
|
} finally {
|
|
104
262
|
env.cleanup();
|
|
@@ -115,6 +273,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
|
|
|
115
273
|
const status = readVaultAuthStatus({
|
|
116
274
|
vaultHome: env.path,
|
|
117
275
|
countTokens: (dbPath) => (dbPath.includes("/default/") ? 2 : 3),
|
|
276
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
118
277
|
});
|
|
119
278
|
expect(status.tokenCount).toBe(5);
|
|
120
279
|
expect(new Set(status.vaultNames)).toEqual(new Set(["default", "work"]));
|
|
@@ -135,6 +294,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
|
|
|
135
294
|
if (dbPath.includes("/default/")) throw new Error("should not open missing DB");
|
|
136
295
|
return 4;
|
|
137
296
|
},
|
|
297
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
138
298
|
});
|
|
139
299
|
expect(status.tokenCount).toBe(4);
|
|
140
300
|
} finally {
|
|
@@ -153,6 +313,7 @@ describe("readVaultAuthStatus — token count resilience", () => {
|
|
|
153
313
|
if (dbPath.includes("/work/")) throw new Error("locked");
|
|
154
314
|
return 2;
|
|
155
315
|
},
|
|
316
|
+
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
156
317
|
});
|
|
157
318
|
// Even though "default" succeeded with 2, we return null — callers
|
|
158
319
|
// shouldn't see a misleading partial count.
|
|
@@ -162,3 +323,143 @@ describe("readVaultAuthStatus — token count resilience", () => {
|
|
|
162
323
|
}
|
|
163
324
|
});
|
|
164
325
|
});
|
|
326
|
+
|
|
327
|
+
describe("readVaultAuthStatus — defaultProbeHubDbHasUserPassword end-to-end", () => {
|
|
328
|
+
// These tests exercise the real `bun:sqlite` probe (no injected fake)
|
|
329
|
+
// to catch breakage in the on-disk read path: schema drift, opening
|
|
330
|
+
// semantics, undefined-returns-on-failure.
|
|
331
|
+
|
|
332
|
+
test("hub.db missing → probe returns undefined → falls back to YAML cleanly", () => {
|
|
333
|
+
const env = makeVaultHome();
|
|
334
|
+
try {
|
|
335
|
+
writeConfig(env.path, 'owner_password_hash: "$2b$12$legacyfromYAML"\n');
|
|
336
|
+
const status = readVaultAuthStatus({
|
|
337
|
+
vaultHome: env.path,
|
|
338
|
+
hubDbPath: join(env.path, "definitely-not-here.db"),
|
|
339
|
+
countTokens: () => 0,
|
|
340
|
+
});
|
|
341
|
+
expect(status.hasOwnerPassword).toBe(true);
|
|
342
|
+
} finally {
|
|
343
|
+
env.cleanup();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("hub.db exists with users.password_hash set → hasOwnerPassword: true", () => {
|
|
348
|
+
const env = makeVaultHome();
|
|
349
|
+
try {
|
|
350
|
+
// Build a real hub.db with the canonical schema + an `unforced` user.
|
|
351
|
+
const { Database } = require("bun:sqlite");
|
|
352
|
+
const dbPath = join(env.path, "hub.db");
|
|
353
|
+
const db = new Database(dbPath);
|
|
354
|
+
db.exec(`
|
|
355
|
+
CREATE TABLE users (
|
|
356
|
+
id TEXT PRIMARY KEY,
|
|
357
|
+
username TEXT UNIQUE NOT NULL,
|
|
358
|
+
password_hash TEXT NOT NULL,
|
|
359
|
+
created_at TEXT NOT NULL,
|
|
360
|
+
updated_at TEXT NOT NULL,
|
|
361
|
+
password_changed INTEGER NOT NULL DEFAULT 0
|
|
362
|
+
);
|
|
363
|
+
INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed)
|
|
364
|
+
VALUES ('u1', 'unforced', '$argon2id$v=19$realhashhere', '2026-05-26T00:00:00Z', '2026-05-26T00:00:00Z', 1);
|
|
365
|
+
`);
|
|
366
|
+
db.close();
|
|
367
|
+
const status = readVaultAuthStatus({
|
|
368
|
+
vaultHome: env.path,
|
|
369
|
+
hubDbPath: dbPath,
|
|
370
|
+
countTokens: () => 0,
|
|
371
|
+
});
|
|
372
|
+
expect(status.hasOwnerPassword).toBe(true);
|
|
373
|
+
} finally {
|
|
374
|
+
env.cleanup();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("hub.db exists but users table empty → falls back to YAML", () => {
|
|
379
|
+
const env = makeVaultHome();
|
|
380
|
+
try {
|
|
381
|
+
const { Database } = require("bun:sqlite");
|
|
382
|
+
const dbPath = join(env.path, "hub.db");
|
|
383
|
+
const db = new Database(dbPath);
|
|
384
|
+
db.exec(`
|
|
385
|
+
CREATE TABLE users (
|
|
386
|
+
id TEXT PRIMARY KEY,
|
|
387
|
+
username TEXT UNIQUE NOT NULL,
|
|
388
|
+
password_hash TEXT NOT NULL,
|
|
389
|
+
created_at TEXT NOT NULL,
|
|
390
|
+
updated_at TEXT NOT NULL,
|
|
391
|
+
password_changed INTEGER NOT NULL DEFAULT 0
|
|
392
|
+
);
|
|
393
|
+
`);
|
|
394
|
+
db.close();
|
|
395
|
+
// YAML provides the password — should still be true.
|
|
396
|
+
writeConfig(env.path, 'owner_password_hash: "$2b$12$fallbackhash"\n');
|
|
397
|
+
const status = readVaultAuthStatus({
|
|
398
|
+
vaultHome: env.path,
|
|
399
|
+
hubDbPath: dbPath,
|
|
400
|
+
countTokens: () => 0,
|
|
401
|
+
});
|
|
402
|
+
expect(status.hasOwnerPassword).toBe(true);
|
|
403
|
+
} finally {
|
|
404
|
+
env.cleanup();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("hub.db exists but schema is missing the users table → probe returns undefined, YAML fallback", () => {
|
|
409
|
+
const env = makeVaultHome();
|
|
410
|
+
try {
|
|
411
|
+
// A hub.db that hasn't run migration v2 yet (only signing_keys, v1).
|
|
412
|
+
const { Database } = require("bun:sqlite");
|
|
413
|
+
const dbPath = join(env.path, "hub.db");
|
|
414
|
+
const db = new Database(dbPath);
|
|
415
|
+
db.exec(`
|
|
416
|
+
CREATE TABLE signing_keys (kid TEXT PRIMARY KEY);
|
|
417
|
+
`);
|
|
418
|
+
db.close();
|
|
419
|
+
writeConfig(env.path, 'owner_password_hash: "$2b$12$onlyinYAML"\n');
|
|
420
|
+
const status = readVaultAuthStatus({
|
|
421
|
+
vaultHome: env.path,
|
|
422
|
+
hubDbPath: dbPath,
|
|
423
|
+
countTokens: () => 0,
|
|
424
|
+
});
|
|
425
|
+
// SELECT against nonexistent `users` throws → probe returns undefined
|
|
426
|
+
// → YAML wins → password reported as set.
|
|
427
|
+
expect(status.hasOwnerPassword).toBe(true);
|
|
428
|
+
} finally {
|
|
429
|
+
env.cleanup();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("hub.db users table has a row with empty password_hash → treated as no password", () => {
|
|
434
|
+
const env = makeVaultHome();
|
|
435
|
+
try {
|
|
436
|
+
const { Database } = require("bun:sqlite");
|
|
437
|
+
const dbPath = join(env.path, "hub.db");
|
|
438
|
+
const db = new Database(dbPath);
|
|
439
|
+
// NOT NULL on password_hash, but allow empty string — schema check
|
|
440
|
+
// is just for "non-empty hash exists."
|
|
441
|
+
db.exec(`
|
|
442
|
+
CREATE TABLE users (
|
|
443
|
+
id TEXT PRIMARY KEY,
|
|
444
|
+
username TEXT UNIQUE NOT NULL,
|
|
445
|
+
password_hash TEXT NOT NULL,
|
|
446
|
+
created_at TEXT NOT NULL,
|
|
447
|
+
updated_at TEXT NOT NULL,
|
|
448
|
+
password_changed INTEGER NOT NULL DEFAULT 0
|
|
449
|
+
);
|
|
450
|
+
INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed)
|
|
451
|
+
VALUES ('u1', 'placeholder', '', '2026-05-26T00:00:00Z', '2026-05-26T00:00:00Z', 0);
|
|
452
|
+
`);
|
|
453
|
+
db.close();
|
|
454
|
+
const status = readVaultAuthStatus({
|
|
455
|
+
vaultHome: env.path,
|
|
456
|
+
hubDbPath: dbPath,
|
|
457
|
+
countTokens: () => 0,
|
|
458
|
+
});
|
|
459
|
+
// No YAML, no non-empty hub.db password → wide open.
|
|
460
|
+
expect(status.hasOwnerPassword).toBe(false);
|
|
461
|
+
} finally {
|
|
462
|
+
env.cleanup();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
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 type { ExposeState } from "../expose-state.ts";
|
|
14
|
+
import { writeExposeState } from "../expose-state.ts";
|
|
15
|
+
import {
|
|
16
|
+
clearVaultHubOrigin,
|
|
17
|
+
isLoopbackOrigin,
|
|
18
|
+
persistVaultHubOrigin,
|
|
19
|
+
publicOriginFromExposeState,
|
|
20
|
+
selfHealVaultHubOrigin,
|
|
21
|
+
} from "../vault-hub-origin-env.ts";
|
|
22
|
+
|
|
23
|
+
let dir: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
dir = mkdtempSync(join(tmpdir(), "pcli-vhoe-"));
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(dir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function vaultEnv(): string {
|
|
33
|
+
return join(dir, "vault", ".env");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("isLoopbackOrigin", () => {
|
|
37
|
+
test("flags 127.0.0.1 / localhost / [::1] / 0.0.0.0", () => {
|
|
38
|
+
expect(isLoopbackOrigin("http://127.0.0.1:1939")).toBe(true);
|
|
39
|
+
expect(isLoopbackOrigin("http://localhost:1939")).toBe(true);
|
|
40
|
+
expect(isLoopbackOrigin("http://[::1]:1939")).toBe(true);
|
|
41
|
+
// 0.0.0.0 is a bind-all wildcard, not a reachable origin.
|
|
42
|
+
expect(isLoopbackOrigin("http://0.0.0.0:1939")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does not flag a public FQDN", () => {
|
|
46
|
+
expect(isLoopbackOrigin("https://parachute-aaron.tailc75afc.ts.net")).toBe(false);
|
|
47
|
+
expect(isLoopbackOrigin("https://hub.example.com")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("non-URL strings are treated as non-loopback (don't block persistence)", () => {
|
|
51
|
+
expect(isLoopbackOrigin("not a url")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("persistVaultHubOrigin", () => {
|
|
56
|
+
test("writes a non-loopback public origin into vault/.env", () => {
|
|
57
|
+
const wrote = persistVaultHubOrigin(dir, "https://parachute-aaron.tailc75afc.ts.net", () => {});
|
|
58
|
+
expect(wrote).toBe(true);
|
|
59
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
|
|
60
|
+
"https://parachute-aaron.tailc75afc.ts.net",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("refuses to persist a loopback origin (would shadow a later exposure)", () => {
|
|
65
|
+
const wrote = persistVaultHubOrigin(dir, "http://127.0.0.1:1939", () => {});
|
|
66
|
+
expect(wrote).toBe(false);
|
|
67
|
+
expect(existsSync(vaultEnv())).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("refuses to persist a 0.0.0.0 origin (--hub-origin flows straight through)", () => {
|
|
71
|
+
// `--hub-origin http://0.0.0.0:1939` bypasses deriveHubOrigin and reaches
|
|
72
|
+
// here verbatim; baking a bind-all wildcard into vault/.env would advertise
|
|
73
|
+
// a non-functional issuer and recreate the iss-mismatch class.
|
|
74
|
+
const wrote = persistVaultHubOrigin(dir, "http://0.0.0.0:1939", () => {});
|
|
75
|
+
expect(wrote).toBe(false);
|
|
76
|
+
expect(existsSync(vaultEnv())).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("is idempotent — no rewrite when the value is already current", () => {
|
|
80
|
+
const log: string[] = [];
|
|
81
|
+
expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
|
|
82
|
+
expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
|
|
83
|
+
// Only the first call logged.
|
|
84
|
+
expect(log).toHaveLength(1);
|
|
85
|
+
expect(log[0]).toMatch(/persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("updates a stale origin in-place and preserves sibling keys", () => {
|
|
89
|
+
writeFileSync(
|
|
90
|
+
mkVaultDir(),
|
|
91
|
+
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://old.example.com\nSCRIBE_URL=http://127.0.0.1:1943\n",
|
|
92
|
+
);
|
|
93
|
+
const wrote = persistVaultHubOrigin(dir, "https://new.example.com", () => {});
|
|
94
|
+
expect(wrote).toBe(true);
|
|
95
|
+
const values = readEnvFileValues(vaultEnv());
|
|
96
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://new.example.com");
|
|
97
|
+
// Sibling keys untouched.
|
|
98
|
+
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
99
|
+
expect(values.SCRIBE_URL).toBe("http://127.0.0.1:1943");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("clearVaultHubOrigin", () => {
|
|
104
|
+
test("removes a persisted origin and leaves sibling keys", () => {
|
|
105
|
+
writeFileSync(
|
|
106
|
+
mkVaultDir(),
|
|
107
|
+
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\n",
|
|
108
|
+
);
|
|
109
|
+
const wrote = clearVaultHubOrigin(dir, () => {});
|
|
110
|
+
expect(wrote).toBe(true);
|
|
111
|
+
const values = readEnvFileValues(vaultEnv());
|
|
112
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
113
|
+
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("no-op when no origin is present", () => {
|
|
117
|
+
writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
|
|
118
|
+
expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("no-op when vault/.env doesn't exist", () => {
|
|
122
|
+
expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** Create `<dir>/vault/` and return the `.env` path so writeFileSync lands. */
|
|
127
|
+
function mkVaultDir(): string {
|
|
128
|
+
mkdirSync(join(dir, "vault"), { recursive: true });
|
|
129
|
+
return vaultEnv();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function exposeStatePath(): string {
|
|
133
|
+
return join(dir, "expose-state.json");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Cloudflare-shaped expose state (subdomain mode, single hub-catchall entry). */
|
|
137
|
+
function cloudflareState(overrides: Partial<ExposeState> = {}): ExposeState {
|
|
138
|
+
return {
|
|
139
|
+
version: 1,
|
|
140
|
+
layer: "public",
|
|
141
|
+
mode: "subdomain",
|
|
142
|
+
canonicalFqdn: "gitcoin-parachute.unforced.dev",
|
|
143
|
+
port: 1939,
|
|
144
|
+
funnel: false,
|
|
145
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
146
|
+
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
147
|
+
...overrides,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Tailnet-shaped expose state (path mode). */
|
|
152
|
+
function tailnetState(overrides: Partial<ExposeState> = {}): ExposeState {
|
|
153
|
+
return {
|
|
154
|
+
version: 1,
|
|
155
|
+
layer: "tailnet",
|
|
156
|
+
mode: "path",
|
|
157
|
+
canonicalFqdn: "parachute-aaron.tailc75afc.ts.net",
|
|
158
|
+
port: 1939,
|
|
159
|
+
funnel: false,
|
|
160
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
161
|
+
hubOrigin: "https://parachute-aaron.tailc75afc.ts.net",
|
|
162
|
+
...overrides,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
describe("publicOriginFromExposeState", () => {
|
|
167
|
+
test("undefined when no expose-state file exists", () => {
|
|
168
|
+
expect(publicOriginFromExposeState(exposeStatePath())).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("returns the cloudflare hubOrigin", () => {
|
|
172
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
173
|
+
expect(publicOriginFromExposeState(exposeStatePath())).toBe(
|
|
174
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("returns the tailnet hubOrigin", () => {
|
|
179
|
+
writeExposeState(tailnetState(), exposeStatePath());
|
|
180
|
+
expect(publicOriginFromExposeState(exposeStatePath())).toBe(
|
|
181
|
+
"https://parachute-aaron.tailc75afc.ts.net",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("synthesizes https://<canonicalFqdn> when hubOrigin is absent (pre-Phase-0 state)", () => {
|
|
186
|
+
// hubOrigin is optional on older state files; canonicalFqdn is mandatory.
|
|
187
|
+
const { hubOrigin, ...rest } = cloudflareState();
|
|
188
|
+
void hubOrigin;
|
|
189
|
+
writeExposeState(rest as ExposeState, exposeStatePath());
|
|
190
|
+
expect(publicOriginFromExposeState(exposeStatePath())).toBe(
|
|
191
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("selfHealVaultHubOrigin (Cloudflare 401 self-heal)", () => {
|
|
197
|
+
test("writes the cloudflare public origin when vault/.env is UNSET", () => {
|
|
198
|
+
// The exact broken-deploy shape: expose-state carries a public cloudflare
|
|
199
|
+
// hubOrigin but vault/.env has no PARACHUTE_HUB_ORIGIN, so the daemon falls
|
|
200
|
+
// back to loopback and 401s every hub token. Restart self-corrects it.
|
|
201
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
202
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
203
|
+
expect(wrote).toBe(true);
|
|
204
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
|
|
205
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("overwrites a LOOPBACK value already persisted in vault/.env", () => {
|
|
210
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
211
|
+
writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n");
|
|
212
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
213
|
+
expect(wrote).toBe(true);
|
|
214
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
|
|
215
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("tailnet shape still self-heals (no regression)", () => {
|
|
220
|
+
writeExposeState(tailnetState(), exposeStatePath());
|
|
221
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
222
|
+
expect(wrote).toBe(true);
|
|
223
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
|
|
224
|
+
"https://parachute-aaron.tailc75afc.ts.net",
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("does NOT persist when there's no exposure (genuine loopback / local dev)", () => {
|
|
229
|
+
// No expose-state file → no public origin → vault keeps its loopback
|
|
230
|
+
// default. Persisting loopback would shadow a later exposure.
|
|
231
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
232
|
+
expect(wrote).toBe(false);
|
|
233
|
+
expect(existsSync(vaultEnv())).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("leaves a DIFFERENT non-loopback value alone (deliberate --hub-origin override)", () => {
|
|
237
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
238
|
+
writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=https://custom.example.com\n");
|
|
239
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
240
|
+
expect(wrote).toBe(false);
|
|
241
|
+
// Untouched — self-heal only fixes unset/loopback, never clobbers a public
|
|
242
|
+
// value an operator may have set on purpose.
|
|
243
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe("https://custom.example.com");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("no-op (no double-write) when the persisted value already equals the public origin", () => {
|
|
247
|
+
writeExposeState(cloudflareState(), exposeStatePath());
|
|
248
|
+
writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=https://gitcoin-parachute.unforced.dev\n");
|
|
249
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
250
|
+
expect(wrote).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("expose-state with a loopback hubOrigin is treated as no public exposure", () => {
|
|
254
|
+
// A loopback hubOrigin (local-dev hub) must never be persisted — it would
|
|
255
|
+
// recreate the iss mismatch on the daemon boot path.
|
|
256
|
+
writeExposeState(cloudflareState({ hubOrigin: "http://127.0.0.1:1939" }), exposeStatePath());
|
|
257
|
+
// canonicalFqdn is still public here, but hubOrigin wins — we honor the
|
|
258
|
+
// explicit value the writer chose.
|
|
259
|
+
const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
|
|
260
|
+
expect(wrote).toBe(false);
|
|
261
|
+
expect(existsSync(vaultEnv())).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
});
|