@openparachute/hub 0.5.9-rc.6 → 0.5.10-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.9-rc.6",
3
+ "version": "0.5.10-rc.2",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -9,6 +9,7 @@ import { findServiceUpstream, findVaultUpstream, hubFetch, layerOf } from "../hu
9
9
  import { pidPath } from "../process-state.ts";
10
10
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
11
11
  import { rotateSigningKey } from "../signing-keys.ts";
12
+ import { createUser } from "../users.ts";
12
13
 
13
14
  interface Harness {
14
15
  dir: string;
@@ -846,21 +847,31 @@ describe("hubFetch routing", () => {
846
847
  }
847
848
  });
848
849
 
849
- test("/oauth/authorize without configured db returns 503", async () => {
850
+ test("/oauth/authorize without configured db returns 503 JSON", async () => {
850
851
  const h = makeHarness();
851
852
  try {
852
853
  const res = await hubFetch(h.dir)(req("/oauth/authorize?client_id=x"));
853
854
  expect(res.status).toBe(503);
855
+ const body = (await res.json()) as { error: string; error_description: string };
856
+ expect(body.error).toBe("service_unavailable");
857
+ expect(body.error_description).toBe("hub db not configured");
854
858
  } finally {
855
859
  h.cleanup();
856
860
  }
857
861
  });
858
862
 
859
- test("every DB-dependent route returns 503 when getDb is absent (closes #139)", async () => {
863
+ test("every DB-dependent route returns 503 when getDb is absent (closes #139, JSON shape closes #227)", async () => {
860
864
  const h = makeHarness();
861
865
  try {
862
866
  const fetch = hubFetch(h.dir);
867
+ // Every DB-dependent guard returns the same JSON 503 shape
868
+ // (`service_unavailable`) so consumers don't branch on content-type to
869
+ // extract the message. The pattern was already canonical on
870
+ // /api/auth/* (hub#215, #226) and was extended to all guards in
871
+ // hub#227.
863
872
  const cases: Array<[string, RequestInit]> = [
873
+ ["/oauth/authorize?client_id=x", { method: "GET" }],
874
+ ["/oauth/authorize/approve", { method: "POST" }],
864
875
  ["/oauth/token", { method: "POST" }],
865
876
  ["/oauth/register", { method: "POST" }],
866
877
  ["/oauth/revoke", { method: "POST" }],
@@ -871,10 +882,23 @@ describe("hubFetch routing", () => {
871
882
  ["/login", { method: "POST" }],
872
883
  ["/logout", { method: "POST" }],
873
884
  ["/admin/host-admin-token", { method: "GET" }],
885
+ ["/admin/vault-admin-token/demo", { method: "GET" }],
886
+ ["/api/me", { method: "GET" }],
887
+ ["/api/auth/mint-token", { method: "POST" }],
888
+ ["/api/auth/revoke-token", { method: "POST" }],
889
+ ["/api/auth/tokens", { method: "GET" }],
890
+ ["/api/grants", { method: "GET" }],
891
+ ["/api/grants/client-x", { method: "DELETE" }],
892
+ ["/api/oauth/clients/client-x", { method: "GET" }],
893
+ ["/api/oauth/clients/client-x/approve", { method: "POST" }],
874
894
  ];
875
895
  for (const [path, init] of cases) {
876
896
  const res = await fetch(req(path, init));
877
897
  expect(res.status).toBe(503);
898
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
899
+ const body = (await res.json()) as { error: string; error_description: string };
900
+ expect(body.error).toBe("service_unavailable");
901
+ expect(body.error_description).toBe("hub db not configured");
878
902
  }
879
903
  } finally {
880
904
  h.cleanup();
@@ -886,6 +910,11 @@ describe("hubFetch routing", () => {
886
910
  try {
887
911
  const db = openHubDb(hubDbPath(h.dir));
888
912
  try {
913
+ // Seed an admin so the pre-admin setup gate (hub#258) doesn't
914
+ // 503 the request before the OAuth method-allow check runs.
915
+ // OAuth routing semantics are what this test pins; the setup
916
+ // gate has its own coverage in src/__tests__/setup-gate.test.ts.
917
+ await createUser(db, "owner", "pw");
889
918
  const res = await hubFetch(h.dir, { getDb: () => db })(
890
919
  req("/oauth/token", { method: "GET" }),
891
920
  );
@@ -903,6 +932,7 @@ describe("hubFetch routing", () => {
903
932
  try {
904
933
  const db = openHubDb(hubDbPath(h.dir));
905
934
  try {
935
+ await createUser(db, "owner", "pw");
906
936
  const res = await hubFetch(h.dir, {
907
937
  getDb: () => db,
908
938
  issuer: "https://hub.example",
@@ -924,6 +954,162 @@ describe("hubFetch routing", () => {
924
954
  }
925
955
  });
926
956
 
957
+ // Platform health check (hub#258). Returns 200 JSON regardless of DB
958
+ // state — Render et al. poll this every few seconds and a transient DB
959
+ // open shouldn't cascade into a restart loop. The body advertises the
960
+ // running version so a deploy verifier can confirm the rolled-out
961
+ // image is the one it expected.
962
+ test("/health returns 200 JSON without invoking the db", async () => {
963
+ const h = makeHarness();
964
+ try {
965
+ const res = await hubFetch(h.dir, {
966
+ getDb: () => {
967
+ throw new Error("getDb must not be called by /health");
968
+ },
969
+ })(req("/health"));
970
+ expect(res.status).toBe(200);
971
+ expect(res.headers.get("content-type")).toContain("application/json");
972
+ expect(res.headers.get("cache-control")).toBe("no-store");
973
+ const body = (await res.json()) as Record<string, unknown>;
974
+ expect(body.status).toBe("ok");
975
+ expect(body.service).toBe("parachute-hub");
976
+ expect(typeof body.version).toBe("string");
977
+ } finally {
978
+ h.cleanup();
979
+ }
980
+ });
981
+
982
+ // First-boot setup placeholder (hub#258). When no admin exists the page
983
+ // is the bootstrap onboarding surface; once an admin exists it 301s to
984
+ // /login so a stale bookmark still lands somewhere useful.
985
+ test("/admin/setup renders placeholder HTML when no admin exists", async () => {
986
+ const h = makeHarness();
987
+ try {
988
+ const db = openHubDb(hubDbPath(h.dir));
989
+ try {
990
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
991
+ expect(res.status).toBe(200);
992
+ expect(res.headers.get("content-type")).toContain("text/html");
993
+ const body = await res.text();
994
+ expect(body).toContain("first-boot setup");
995
+ expect(body).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
996
+ } finally {
997
+ db.close();
998
+ }
999
+ } finally {
1000
+ h.cleanup();
1001
+ }
1002
+ });
1003
+
1004
+ test("/admin/setup 301s to /login when an admin already exists", async () => {
1005
+ const h = makeHarness();
1006
+ try {
1007
+ const db = openHubDb(hubDbPath(h.dir));
1008
+ try {
1009
+ await createUser(db, "owner", "pw");
1010
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
1011
+ expect(res.status).toBe(301);
1012
+ expect(res.headers.get("location")).toBe("/login");
1013
+ } finally {
1014
+ db.close();
1015
+ }
1016
+ } finally {
1017
+ h.cleanup();
1018
+ }
1019
+ });
1020
+
1021
+ // Pre-admin lockout (hub#258). When no admin row exists, operator-
1022
+ // facing surfaces (admin/api/login) 503 with a JSON body pointing at
1023
+ // /admin/setup. Public surfaces (health, well-known, /, oauth, vault,
1024
+ // /admin/setup itself) stay open so the container is reachable and
1025
+ // OAuth third parties aren't held hostage by admin onboarding.
1026
+ test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
1027
+ const h = makeHarness();
1028
+ try {
1029
+ const db = openHubDb(hubDbPath(h.dir));
1030
+ try {
1031
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/vaults"));
1032
+ expect(res.status).toBe(503);
1033
+ const body = (await res.json()) as Record<string, unknown>;
1034
+ expect(body.error).toBe("setup_required");
1035
+ expect(body.setup_url).toBe("/admin/setup");
1036
+ } finally {
1037
+ db.close();
1038
+ }
1039
+ } finally {
1040
+ h.cleanup();
1041
+ }
1042
+ });
1043
+
1044
+ test("pre-admin lockout: /api/me returns 503 setup_required", async () => {
1045
+ const h = makeHarness();
1046
+ try {
1047
+ const db = openHubDb(hubDbPath(h.dir));
1048
+ try {
1049
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1050
+ expect(res.status).toBe(503);
1051
+ const body = (await res.json()) as Record<string, unknown>;
1052
+ expect(body.error).toBe("setup_required");
1053
+ } finally {
1054
+ db.close();
1055
+ }
1056
+ } finally {
1057
+ h.cleanup();
1058
+ }
1059
+ });
1060
+
1061
+ test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open", async () => {
1062
+ const h = makeHarness();
1063
+ try {
1064
+ writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
1065
+ writeManifest({ services: [] }, h.manifestPath);
1066
+ const db = openHubDb(hubDbPath(h.dir));
1067
+ try {
1068
+ const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
1069
+ // /login gated
1070
+ const loginRes = await handler(req("/login"));
1071
+ expect(loginRes.status).toBe(503);
1072
+ // /admin/setup open
1073
+ const setupRes = await handler(req("/admin/setup"));
1074
+ expect(setupRes.status).toBe(200);
1075
+ // /health open
1076
+ const healthRes = await handler(req("/health"));
1077
+ expect(healthRes.status).toBe(200);
1078
+ // / open
1079
+ const rootRes = await handler(req("/"));
1080
+ expect(rootRes.status).toBe(200);
1081
+ // /.well-known/parachute.json open
1082
+ const wkRes = await handler(req("/.well-known/parachute.json"));
1083
+ expect(wkRes.status).toBe(200);
1084
+ } finally {
1085
+ db.close();
1086
+ }
1087
+ } finally {
1088
+ h.cleanup();
1089
+ }
1090
+ });
1091
+
1092
+ test("pre-admin lockout falls away once an admin exists", async () => {
1093
+ const h = makeHarness();
1094
+ try {
1095
+ const db = openHubDb(hubDbPath(h.dir));
1096
+ try {
1097
+ // Before: /api/me 503s under the lockout.
1098
+ const before = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1099
+ expect(before.status).toBe(503);
1100
+ // After seeding an admin: dispatch resumes normal handling.
1101
+ await createUser(db, "owner", "pw");
1102
+ const after = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1103
+ // /api/me with no session returns `hasSession: false` 200, not 503.
1104
+ expect(after.status).toBe(200);
1105
+ } finally {
1106
+ db.close();
1107
+ }
1108
+ } finally {
1109
+ h.cleanup();
1110
+ }
1111
+ });
1112
+
927
1113
  test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
928
1114
  const h = makeHarness();
929
1115
  try {
@@ -3205,6 +3205,115 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
3205
3205
  // to the auth-code redirect. Strict superset (incremental scope) and
3206
3206
  // revoked grants still show consent.
3207
3207
  describe("handleAuthorizeGet — skip consent when scope already granted (#75)", () => {
3208
+ // hub#236 — pin the full silent-approve flow end-to-end in one test.
3209
+ // The per-branch tests below this one cover individual branches (subset,
3210
+ // superset, revoke, unnamed-vault, re-registered-client); this test
3211
+ // walks the operator-visible state machine in a single body so a
3212
+ // regression at any step surfaces immediately, and the JSDoc on
3213
+ // handleAuthorizeGet's silent-approve flow (1-5) has a single load-
3214
+ // bearing test to point at.
3215
+ test("first-use consent → silent-approve → novel-scope re-prompts (full silent-approve flow, #236)", async () => {
3216
+ const { db, cleanup } = await makeDb();
3217
+ try {
3218
+ const user = await createUser(db, "owner", "pw");
3219
+ const session = createSession(db, { userId: user.id });
3220
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
3221
+ const { challenge } = makePkce();
3222
+ const sessionCookie = buildSessionCookie(session.id, 86400);
3223
+
3224
+ // Step 1: first use — no grant exists; consent screen renders.
3225
+ const firstReq = new Request(
3226
+ authorizeUrl({
3227
+ client_id: reg.client.clientId,
3228
+ redirect_uri: "https://app.example/cb",
3229
+ response_type: "code",
3230
+ scope: "vault:default:read",
3231
+ code_challenge: challenge,
3232
+ code_challenge_method: "S256",
3233
+ state: "step1",
3234
+ }),
3235
+ { headers: { cookie: sessionCookie } },
3236
+ );
3237
+ const firstRes = handleAuthorizeGet(db, firstReq, { issuer: ISSUER });
3238
+ expect(firstRes.status).toBe(200);
3239
+ expect(firstRes.headers.get("content-type")).toContain("text/html");
3240
+
3241
+ // Step 1b: user approves via the consent form — grant gets recorded.
3242
+ const consentRes = await handleAuthorizePost(
3243
+ db,
3244
+ new Request(`${ISSUER}/oauth/authorize`, {
3245
+ method: "POST",
3246
+ body: new URLSearchParams({
3247
+ __action: "consent",
3248
+ __csrf: TEST_CSRF,
3249
+ approve: "yes",
3250
+ client_id: reg.client.clientId,
3251
+ redirect_uri: "https://app.example/cb",
3252
+ response_type: "code",
3253
+ scope: "vault:default:read",
3254
+ code_challenge: challenge,
3255
+ code_challenge_method: "S256",
3256
+ }),
3257
+ headers: {
3258
+ "content-type": "application/x-www-form-urlencoded",
3259
+ cookie: `${CSRF_COOKIE}; ${sessionCookie}`,
3260
+ },
3261
+ }),
3262
+ { issuer: ISSUER },
3263
+ );
3264
+ expect(consentRes.status).toBe(302);
3265
+
3266
+ // Step 2: subsequent use, same scopes — silent-approve fires.
3267
+ // Authoritative assertion: 302 redirect with auth code, NOT a 200
3268
+ // HTML consent screen. This is the operator-visible payoff.
3269
+ const secondReq = new Request(
3270
+ authorizeUrl({
3271
+ client_id: reg.client.clientId,
3272
+ redirect_uri: "https://app.example/cb",
3273
+ response_type: "code",
3274
+ scope: "vault:default:read",
3275
+ code_challenge: challenge,
3276
+ code_challenge_method: "S256",
3277
+ state: "step2",
3278
+ }),
3279
+ { headers: { cookie: sessionCookie } },
3280
+ );
3281
+ const secondRes = handleAuthorizeGet(db, secondReq, { issuer: ISSUER });
3282
+ expect(secondRes.status).toBe(302);
3283
+ const secondLoc = new URL(secondRes.headers.get("location") ?? "");
3284
+ expect(secondLoc.origin + secondLoc.pathname).toBe("https://app.example/cb");
3285
+ expect(secondLoc.searchParams.get("code")?.length).toBeGreaterThan(20);
3286
+ expect(secondLoc.searchParams.get("state")).toBe("step2");
3287
+
3288
+ // Step 3: subsequent use, novel scope NOT in the grant — gate must
3289
+ // NOT fire; consent re-renders with the new scope explicit. This is
3290
+ // the load-bearing security property: silent-approve must not
3291
+ // silently approve scopes the user never consented to.
3292
+ const novelReq = new Request(
3293
+ authorizeUrl({
3294
+ client_id: reg.client.clientId,
3295
+ redirect_uri: "https://app.example/cb",
3296
+ response_type: "code",
3297
+ // Adds scribe:transcribe to the original vault:default:read.
3298
+ scope: "vault:default:read scribe:transcribe",
3299
+ code_challenge: challenge,
3300
+ code_challenge_method: "S256",
3301
+ state: "step3",
3302
+ }),
3303
+ { headers: { cookie: sessionCookie } },
3304
+ );
3305
+ const novelRes = handleAuthorizeGet(db, novelReq, { issuer: ISSUER });
3306
+ expect(novelRes.status).toBe(200);
3307
+ expect(novelRes.headers.get("content-type")).toContain("text/html");
3308
+ const novelBody = await novelRes.text();
3309
+ // The new scope appears on the consent page — the user must approve
3310
+ // it explicitly.
3311
+ expect(novelBody).toContain("scribe:transcribe");
3312
+ } finally {
3313
+ cleanup();
3314
+ }
3315
+ });
3316
+
3208
3317
  test("first approval records grant; second flow with same scopes skips consent", async () => {
3209
3318
  const { db, cleanup } = await makeDb();
3210
3319
  try {
@@ -297,7 +297,7 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
297
297
  issuer: TEST_ISSUER,
298
298
  });
299
299
  expect(used).not.toBeNull();
300
- expect(used?.refreshed).toBe(false);
300
+ expect(used?.status.kind).toBe("fresh");
301
301
  expect(used?.rotated).toBeUndefined();
302
302
  expect(used?.token).toBe(issued.token);
303
303
  } finally {
@@ -328,7 +328,7 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
328
328
  issuer: TEST_ISSUER,
329
329
  });
330
330
  expect(used).not.toBeNull();
331
- expect(used?.refreshed).toBe(true);
331
+ expect(used?.status.kind).toBe("rotated");
332
332
  expect(used?.rotated?.scopeSet).toBe("start");
333
333
  // The on-disk token is now the rotated one.
334
334
  const onDisk = await readOperatorTokenFile(h.dir);
@@ -370,7 +370,10 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
370
370
  issuer: TEST_ISSUER,
371
371
  });
372
372
  expect(used).not.toBeNull();
373
- expect(used?.refreshed).toBe(false);
373
+ expect(used?.status.kind).toBe("skipped");
374
+ if (used?.status.kind === "skipped") {
375
+ expect(used.status.reason).toBe("aud-mismatch");
376
+ }
374
377
  expect(used?.rotated).toBeUndefined();
375
378
  expect(used?.token).toBe(signed.token);
376
379
  // On-disk file unchanged.
@@ -384,6 +387,51 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
384
387
  }
385
388
  });
386
389
 
390
+ // hub#224 — when a JWT carries the operator audience + short TTL but
391
+ // lacks a recognized `pa_scope_set` claim, the helper now refuses to
392
+ // auto-rotate. Pre-hardening the fallback widened to admin; the test
393
+ // pins the new "skipped, no-scope-set" outcome.
394
+ test("does NOT auto-rotate an aud=operator token that lacks pa_scope_set (no silent widening)", async () => {
395
+ const h = makeHarness();
396
+ try {
397
+ const db = openHubDb(hubDbPath(h.dir));
398
+ try {
399
+ rotateSigningKey(db);
400
+ // aud=operator + 1h TTL + NO pa_scope_set claim. Pre-#224 this would
401
+ // fall back to OPERATOR_TOKEN_DEFAULT_SCOPE_SET (admin) on rotation
402
+ // — a silent widening of a token of unknown provenance.
403
+ const signed = await signAccessToken(db, {
404
+ sub: "user-abc",
405
+ scopes: ["scribe:transcribe"],
406
+ audience: OPERATOR_TOKEN_AUDIENCE,
407
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
408
+ issuer: TEST_ISSUER,
409
+ ttlSeconds: 3600,
410
+ });
411
+ await writeOperatorTokenFile(signed.token, h.dir);
412
+
413
+ const used = await useOperatorTokenWithAutoRotate(db, {
414
+ configDir: h.dir,
415
+ issuer: TEST_ISSUER,
416
+ });
417
+ expect(used).not.toBeNull();
418
+ expect(used?.status.kind).toBe("skipped");
419
+ if (used?.status.kind === "skipped") {
420
+ expect(used.status.reason).toBe("no-scope-set");
421
+ }
422
+ expect(used?.rotated).toBeUndefined();
423
+ expect(used?.token).toBe(signed.token);
424
+ // On-disk file unchanged — no widening occurred.
425
+ const onDisk = await readOperatorTokenFile(h.dir);
426
+ expect(onDisk).toBe(signed.token);
427
+ } finally {
428
+ db.close();
429
+ }
430
+ } finally {
431
+ h.cleanup();
432
+ }
433
+ });
434
+
387
435
  test("returns null when no operator token file exists", async () => {
388
436
  const h = makeHarness();
389
437
  try {
@@ -488,7 +536,7 @@ describe("mintOperatorToken registry write (#212)", () => {
488
536
  configDir: h.dir,
489
537
  issuer: TEST_ISSUER,
490
538
  });
491
- expect(used?.refreshed).toBe(true);
539
+ expect(used?.status.kind).toBe("rotated");
492
540
  // The rotated token has a new jti.
493
541
  const newJti = used!.payload.jti as string;
494
542
  expect(newJti).not.toBe(original.jti);
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { seedInitialAdminIfNeeded } from "../commands/serve.ts";
6
+ import { openHubDb } from "../hub-db.ts";
7
+ import { userCount } from "../users.ts";
8
+
9
+ describe("seedInitialAdminIfNeeded", () => {
10
+ let dir: string;
11
+ let dbPath: string;
12
+
13
+ beforeEach(() => {
14
+ dir = mkdtempSync(join(tmpdir(), "parachute-serve-"));
15
+ dbPath = join(dir, "hub.db");
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ test("returns 'needs-setup' on fresh state with no env vars", async () => {
23
+ const db = openHubDb(dbPath);
24
+ const result = await seedInitialAdminIfNeeded(db, {}, () => {});
25
+ expect(result).toBe("needs-setup");
26
+ expect(userCount(db)).toBe(0);
27
+ });
28
+
29
+ test("seeds an admin from PARACHUTE_INITIAL_ADMIN_* on fresh state", async () => {
30
+ const db = openHubDb(dbPath);
31
+ const log = mock<(line: string) => void>(() => {});
32
+ const result = await seedInitialAdminIfNeeded(
33
+ db,
34
+ {
35
+ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
36
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "correct horse battery staple",
37
+ },
38
+ log,
39
+ );
40
+ expect(result).toBe("seeded");
41
+ expect(userCount(db)).toBe(1);
42
+ // The log line carries the username so operators can grep container
43
+ // logs to verify the seed fired.
44
+ expect(log).toHaveBeenCalledTimes(1);
45
+ expect(log.mock.calls[0]?.[0] ?? "").toContain("seeded initial admin");
46
+ expect(log.mock.calls[0]?.[0] ?? "").toContain("ops");
47
+ });
48
+
49
+ test("returns 'exists' when an admin already exists, even with env vars set", async () => {
50
+ // Seed once.
51
+ const db = openHubDb(dbPath);
52
+ await seedInitialAdminIfNeeded(
53
+ db,
54
+ {
55
+ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
56
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "first-pw",
57
+ },
58
+ () => {},
59
+ );
60
+
61
+ // Same env on a second boot must NOT clobber the existing admin —
62
+ // the seed is first-boot only. (Container restart with the env still
63
+ // set from the Render dashboard is the canonical second-boot.)
64
+ const result = await seedInitialAdminIfNeeded(
65
+ db,
66
+ {
67
+ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
68
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "different-pw",
69
+ },
70
+ () => {},
71
+ );
72
+ expect(result).toBe("exists");
73
+ expect(userCount(db)).toBe(1);
74
+ });
75
+
76
+ test("treats whitespace-only username as missing (needs-setup)", async () => {
77
+ const db = openHubDb(dbPath);
78
+ const result = await seedInitialAdminIfNeeded(
79
+ db,
80
+ {
81
+ PARACHUTE_INITIAL_ADMIN_USERNAME: " ",
82
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "pw",
83
+ },
84
+ () => {},
85
+ );
86
+ expect(result).toBe("needs-setup");
87
+ expect(userCount(db)).toBe(0);
88
+ });
89
+
90
+ test("requires both username and password — half-set env is needs-setup", async () => {
91
+ const db = openHubDb(dbPath);
92
+ const result = await seedInitialAdminIfNeeded(
93
+ db,
94
+ { PARACHUTE_INITIAL_ADMIN_USERNAME: "ops" },
95
+ () => {},
96
+ );
97
+ expect(result).toBe("needs-setup");
98
+ expect(userCount(db)).toBe(0);
99
+ });
100
+ });