@openparachute/hub 0.5.13-rc.49 → 0.5.13

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 CHANGED
@@ -16,25 +16,49 @@ bun add -g @openparachute/hub
16
16
 
17
17
  Prereqs: [Bun](https://bun.sh) 1.3.0 or later. `parachute expose` also requires [Tailscale](https://tailscale.com/download) **1.82 or newer** (installed + `tailscale up` run once); the `expose` path is under active polish for launch, so expect rough edges.
18
18
 
19
- ### Hosted (Render)
19
+ ### Hosted self-deploy
20
20
 
21
- [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
21
+ Hub runs as a single container with one persistent disk. Two equally-supported platforms; pick the one you prefer.
22
+
23
+ #### Render
22
24
 
23
- One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions a $7/mo Starter service + 1 GiB persistent disk + auto-deploys from `main`. Comes pre-configured with `PARACHUTE_INSTALL_CHANNEL=latest` so modules you install via the admin SPA (vault, app, scribe, runner) pull stable releases by default.
25
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
24
26
 
25
- **Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
27
+ One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions a $7/mo Starter service + 1 GiB persistent disk + auto-deploys from `main`. GUI-first ops; click-and-go for operators who don't want to install a CLI.
26
28
 
27
29
  After deploy completes:
28
30
 
29
- 1. Open Render Logs → search for `parachute-bootstrap-` to find your one-time admin setup token (printed in a prominent banner on first boot).
31
+ 1. Open Render Logs → search for `parachute-bootstrap-` to find your one-time admin setup token.
30
32
  2. Visit your Render service URL's `/admin/setup` → paste the token → create your admin account.
31
33
  3. Set custom domain (optional) → set `PARACHUTE_HUB_ORIGIN` env to match.
32
- 4. Install modules via the admin SPA at `/admin/modules` (or via the wizard).
33
-
34
- Operators who want env-var-driven seeding (CI, scripted deploys) can still set `PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD` manually in the Render dashboard — hub honors them when present.
34
+ 4. Install modules via the admin SPA at `/admin/modules`.
35
35
 
36
36
  Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
37
37
 
38
+ #### Fly.io
39
+
40
+ ```sh
41
+ gh repo fork ParachuteComputer/parachute-hub --clone && cd parachute-hub
42
+ ./scripts/deploy-to-fly.sh
43
+ ```
44
+
45
+ The script installs `flyctl` if missing, runs `fly launch --copy-config`, and prints the URL. Provisions a shared-cpu-1x 512MB machine in `iad` (override with `--region` if your operators are elsewhere) + 1 GiB persistent volume at `/parachute`. Cost: ~$3.34/mo all-in.
46
+
47
+ After deploy:
48
+
49
+ 1. `fly logs --app <your-app> | grep parachute-bootstrap-` to find your one-time admin token.
50
+ 2. Visit `https://<your-app>.fly.dev/admin/setup` → paste the token → create your admin account.
51
+ 3. Custom domain: `fly certs add <your-domain> --app <your-app>` then `fly secrets set PARACHUTE_HUB_ORIGIN=https://<your-domain>`.
52
+ 4. Install modules via the admin SPA at `/admin/modules`.
53
+
54
+ Config in `fly.toml`. CLI-first ops; bring your own `flyctl`.
55
+
56
+ #### Both platforms
57
+
58
+ Pre-configured with `PARACHUTE_INSTALL_CHANNEL=latest` so modules you install via the admin SPA (vault, app, scribe, runner) pull stable releases by default. Flip to `rc` in your platform's env vars for the pre-release cascade.
59
+
60
+ Operators who want env-var-driven seeding (CI, scripted deploys) can still set `PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD` manually — hub honors them on both platforms.
61
+
38
62
  ## First 5 minutes
39
63
 
40
64
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.49",
3
+ "version": "0.5.13",
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": {
@@ -3418,6 +3418,35 @@ describe("parseArgs — issuer env precedence (hub#365)", () => {
3418
3418
  // Bare slash collapses to empty after strip — must not become "" issuer.
3419
3419
  expect(parseArgs([], { RENDER_EXTERNAL_URL: "/" }).issuer).toBeUndefined();
3420
3420
  });
3421
+
3422
+ // Fly.io self-host path (patterns#100 / parachute.computer#62).
3423
+ test("FLY_APP_NAME composes https://<app>.fly.dev as fallback issuer", () => {
3424
+ const got = parseArgs([], { FLY_APP_NAME: "my-parachute" });
3425
+ expect(got.issuer).toBe("https://my-parachute.fly.dev");
3426
+ });
3427
+
3428
+ test("PARACHUTE_HUB_ORIGIN wins over FLY_APP_NAME (operator with custom domain)", () => {
3429
+ const got = parseArgs([], {
3430
+ PARACHUTE_HUB_ORIGIN: "https://hub.example",
3431
+ FLY_APP_NAME: "my-parachute",
3432
+ });
3433
+ expect(got.issuer).toBe("https://hub.example");
3434
+ });
3435
+
3436
+ test("RENDER_EXTERNAL_URL wins over FLY_APP_NAME (pathological co-set)", () => {
3437
+ // Both Render and Fly are unlikely to be set in the same env, but if a
3438
+ // weird container ever passes both, the existing Render branch wins.
3439
+ // Documents the precedence rather than asserting any product behavior.
3440
+ const got = parseArgs([], {
3441
+ RENDER_EXTERNAL_URL: "https://app.onrender.com",
3442
+ FLY_APP_NAME: "my-parachute",
3443
+ });
3444
+ expect(got.issuer).toBe("https://app.onrender.com");
3445
+ });
3446
+
3447
+ test("FLY_APP_NAME empty string → no fallback (treat as unset)", () => {
3448
+ expect(parseArgs([], { FLY_APP_NAME: "" }).issuer).toBeUndefined();
3449
+ });
3421
3450
  });
3422
3451
 
3423
3452
  describe("hubFetch persistent chrome strip injection (workstream G)", () => {
@@ -4545,6 +4545,194 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
4545
4545
  cleanup();
4546
4546
  }
4547
4547
  });
4548
+
4549
+ // Deny path tests (hub#390). The shared describe block above pins the
4550
+ // approve path's security model; these tests pin that the deny path
4551
+ // honors the same guards AND constructs a spec-shaped error redirect.
4552
+
4553
+ test("deny POST happy path: valid redirect_uri + state → 302 to client with access_denied", async () => {
4554
+ const { db, cleanup } = await makeDb();
4555
+ try {
4556
+ const user = await createUser(db, "owner", "pw");
4557
+ const session = createSession(db, { userId: user.id });
4558
+ const reg = registerClient(db, {
4559
+ redirectUris: ["https://app.example/cb"],
4560
+ status: "pending",
4561
+ });
4562
+ const form = new URLSearchParams({
4563
+ __csrf: TEST_CSRF,
4564
+ client_id: reg.client.clientId,
4565
+ return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
4566
+ decision: "deny",
4567
+ redirect_uri: "https://app.example/cb",
4568
+ state: "deny-state-abc",
4569
+ });
4570
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
4571
+ method: "POST",
4572
+ body: form,
4573
+ headers: {
4574
+ "content-type": "application/x-www-form-urlencoded",
4575
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
4576
+ origin: ISSUER,
4577
+ },
4578
+ });
4579
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
4580
+ expect(res.status).toBe(302);
4581
+ const loc = res.headers.get("location") ?? "";
4582
+ const target = new URL(loc);
4583
+ expect(target.origin).toBe("https://app.example");
4584
+ expect(target.pathname).toBe("/cb");
4585
+ expect(target.searchParams.get("error")).toBe("access_denied");
4586
+ expect(target.searchParams.get("state")).toBe("deny-state-abc");
4587
+ // Client row stays pending — deny does not mutate.
4588
+ expect(getClient(db, reg.client.clientId)?.status).toBe("pending");
4589
+ } finally {
4590
+ cleanup();
4591
+ }
4592
+ });
4593
+
4594
+ test("deny POST: no state in form → redirect omits state param (spec-compliant)", async () => {
4595
+ const { db, cleanup } = await makeDb();
4596
+ try {
4597
+ const user = await createUser(db, "owner", "pw");
4598
+ const session = createSession(db, { userId: user.id });
4599
+ const reg = registerClient(db, {
4600
+ redirectUris: ["https://app.example/cb"],
4601
+ status: "pending",
4602
+ });
4603
+ const form = new URLSearchParams({
4604
+ __csrf: TEST_CSRF,
4605
+ client_id: reg.client.clientId,
4606
+ return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
4607
+ decision: "deny",
4608
+ redirect_uri: "https://app.example/cb",
4609
+ });
4610
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
4611
+ method: "POST",
4612
+ body: form,
4613
+ headers: {
4614
+ "content-type": "application/x-www-form-urlencoded",
4615
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
4616
+ origin: ISSUER,
4617
+ },
4618
+ });
4619
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
4620
+ expect(res.status).toBe(302);
4621
+ const target = new URL(res.headers.get("location") ?? "");
4622
+ expect(target.searchParams.get("error")).toBe("access_denied");
4623
+ expect(target.searchParams.has("state")).toBe(false);
4624
+ } finally {
4625
+ cleanup();
4626
+ }
4627
+ });
4628
+
4629
+ test("deny POST: redirect_uri not in client's registered URIs → 400 (open-redirect defense)", async () => {
4630
+ const { db, cleanup } = await makeDb();
4631
+ try {
4632
+ const user = await createUser(db, "owner", "pw");
4633
+ const session = createSession(db, { userId: user.id });
4634
+ const reg = registerClient(db, {
4635
+ redirectUris: ["https://app.example/cb"],
4636
+ status: "pending",
4637
+ });
4638
+ const form = new URLSearchParams({
4639
+ __csrf: TEST_CSRF,
4640
+ client_id: reg.client.clientId,
4641
+ return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
4642
+ decision: "deny",
4643
+ // attacker-controlled redirect_uri
4644
+ redirect_uri: "https://attacker.example/grab",
4645
+ state: "x",
4646
+ });
4647
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
4648
+ method: "POST",
4649
+ body: form,
4650
+ headers: {
4651
+ "content-type": "application/x-www-form-urlencoded",
4652
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
4653
+ origin: ISSUER,
4654
+ },
4655
+ });
4656
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
4657
+ expect(res.status).toBe(400);
4658
+ // No mutation, no redirect to attacker.
4659
+ expect(res.headers.get("location")).toBeNull();
4660
+ expect(getClient(db, reg.client.clientId)?.status).toBe("pending");
4661
+ } finally {
4662
+ cleanup();
4663
+ }
4664
+ });
4665
+
4666
+ test("deny POST: CSRF + session + same-origin guards apply to deny path too", async () => {
4667
+ const { db, cleanup } = await makeDb();
4668
+ try {
4669
+ const user = await createUser(db, "owner", "pw");
4670
+ const session = createSession(db, { userId: user.id });
4671
+ const reg = registerClient(db, {
4672
+ redirectUris: ["https://app.example/cb"],
4673
+ status: "pending",
4674
+ });
4675
+ const form = new URLSearchParams({
4676
+ __csrf: TEST_CSRF,
4677
+ client_id: reg.client.clientId,
4678
+ return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
4679
+ decision: "deny",
4680
+ redirect_uri: "https://app.example/cb",
4681
+ state: "x",
4682
+ });
4683
+ // Cross-origin Origin header — same defense as approve path.
4684
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
4685
+ method: "POST",
4686
+ body: form,
4687
+ headers: {
4688
+ "content-type": "application/x-www-form-urlencoded",
4689
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
4690
+ origin: "https://attacker.example",
4691
+ },
4692
+ });
4693
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
4694
+ expect(res.status).toBe(403);
4695
+ } finally {
4696
+ cleanup();
4697
+ }
4698
+ });
4699
+
4700
+ test("approve POST: explicit decision=approve still works (no regression)", async () => {
4701
+ // Back-compat: forms that explicitly carry decision=approve should
4702
+ // continue to flip the client to approved + redirect to return_to.
4703
+ // Pre-deny PR the field didn't exist; the new form sends it explicitly.
4704
+ const { db, cleanup } = await makeDb();
4705
+ try {
4706
+ const user = await createUser(db, "owner", "pw");
4707
+ const session = createSession(db, { userId: user.id });
4708
+ const reg = registerClient(db, {
4709
+ redirectUris: ["https://app.example/cb"],
4710
+ status: "pending",
4711
+ });
4712
+ const returnTo = `/oauth/authorize?client_id=${reg.client.clientId}&state=rt-208`;
4713
+ const form = new URLSearchParams({
4714
+ __csrf: TEST_CSRF,
4715
+ client_id: reg.client.clientId,
4716
+ return_to: returnTo,
4717
+ decision: "approve",
4718
+ });
4719
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
4720
+ method: "POST",
4721
+ body: form,
4722
+ headers: {
4723
+ "content-type": "application/x-www-form-urlencoded",
4724
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
4725
+ origin: ISSUER,
4726
+ },
4727
+ });
4728
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
4729
+ expect(res.status).toBe(302);
4730
+ expect(res.headers.get("location")).toBe(returnTo);
4731
+ expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
4732
+ } finally {
4733
+ cleanup();
4734
+ }
4735
+ });
4548
4736
  });
4549
4737
 
4550
4738
  // DCR first-client auto-approve window (hub#268 Item 3). The wizard's
@@ -511,7 +511,11 @@ describe("renderApprovePending unauthenticated CTAs", () => {
511
511
  test("admin-authenticated branch hides the unauth CTAs and renders the inline approve form", () => {
512
512
  const html = renderApprovePending({
513
513
  ...COMMON,
514
- approveForm: { csrfToken: CSRF, returnTo: "/oauth/authorize?client_id=client-xyz" },
514
+ approveForm: {
515
+ csrfToken: CSRF,
516
+ returnTo: "/oauth/authorize?client_id=client-xyz",
517
+ redirectUri: "https://client.example/cb",
518
+ },
515
519
  });
516
520
  expect(html).toContain('action="/oauth/authorize/approve"');
517
521
  // Workstream I, 2026-05-25 — button label "Approve and continue"
@@ -522,6 +526,39 @@ describe("renderApprovePending unauthenticated CTAs", () => {
522
526
  expect(html).not.toContain("Or send this link to your hub admin");
523
527
  expect(html).not.toContain("parachute auth approve-client");
524
528
  });
529
+
530
+ test("authed branch renders Deny button + hidden inputs for redirect_uri / state (hub#390)", () => {
531
+ const html = renderApprovePending({
532
+ ...COMMON,
533
+ approveForm: {
534
+ csrfToken: CSRF,
535
+ returnTo: "/oauth/authorize?client_id=client-xyz",
536
+ redirectUri: "https://client.example/cb",
537
+ state: "abc-state",
538
+ },
539
+ });
540
+ // Both buttons present, distinguished by decision value.
541
+ expect(html).toContain('name="decision" value="approve"');
542
+ expect(html).toContain('name="decision" value="deny"');
543
+ expect(html).toContain(">Deny</button>");
544
+ // Hidden inputs carry redirect_uri + state for the deny handler.
545
+ expect(html).toContain('name="redirect_uri" value="https://client.example/cb"');
546
+ expect(html).toContain('name="state" value="abc-state"');
547
+ });
548
+
549
+ test("authed branch omits state hidden input when state is undefined (spec-allowed)", () => {
550
+ const html = renderApprovePending({
551
+ ...COMMON,
552
+ approveForm: {
553
+ csrfToken: CSRF,
554
+ returnTo: "/oauth/authorize?client_id=client-xyz",
555
+ redirectUri: "https://client.example/cb",
556
+ // state intentionally absent
557
+ },
558
+ });
559
+ expect(html).toContain('name="redirect_uri" value="https://client.example/cb"');
560
+ expect(html).not.toContain('name="state"');
561
+ });
525
562
  });
526
563
 
527
564
  describe("renderApprovePending scope display (wildcard substitution)", () => {
@@ -612,7 +649,11 @@ describe("renderApprovePending scope display (wildcard substitution)", () => {
612
649
  const html = renderApprovePending({
613
650
  ...COMMON,
614
651
  requestedScopes: ["vault:read", "vault:write"],
615
- approveForm: { csrfToken: CSRF, returnTo: "/oauth/authorize?client_id=client-xyz" },
652
+ approveForm: {
653
+ csrfToken: CSRF,
654
+ returnTo: "/oauth/authorize?client_id=client-xyz",
655
+ redirectUri: "https://client.example/cb",
656
+ },
616
657
  });
617
658
  expect(html).toContain('action="/oauth/authorize/approve"');
618
659
  expect(html).toMatch(/<code class="scope-name">vault:\*:read<\/code>/);
@@ -217,4 +217,49 @@ describe("loadDeclaredScopes", () => {
217
217
  cleanup();
218
218
  }
219
219
  });
220
+
221
+ test("one bad row in services.json — valid siblings' scopes still declared", () => {
222
+ // Regression for hub#411/#413/#415 lenient sweep: pre-sweep the reader
223
+ // threw on any bad row, the catch fired in loadDeclaredScopes, and we
224
+ // returned just FIRST_PARTY_SCOPES — dropping declared scopes from
225
+ // every VALID third-party module installed alongside the bad row.
226
+ // After the sweep we keep valid rows and skip the bad one with a warning.
227
+ const { manifestPath, cleanup } = tmp();
228
+ try {
229
+ writeFileSync(
230
+ manifestPath,
231
+ JSON.stringify({
232
+ services: [
233
+ // Bad row: port=0 violates the integer 1..65535 rule (the exact
234
+ // shape app@0.2.0-rc.4 wrote to operators' manifests).
235
+ {
236
+ name: "broken-module",
237
+ port: 0,
238
+ paths: ["/broken"],
239
+ health: "/healthz",
240
+ version: "0.0.1",
241
+ },
242
+ // Valid sibling.
243
+ {
244
+ name: "@acme/widget",
245
+ port: 1950,
246
+ paths: ["/widget"],
247
+ health: "/healthz",
248
+ version: "0.0.0-linked",
249
+ },
250
+ ],
251
+ }),
252
+ );
253
+ const declared = loadDeclaredScopes({
254
+ manifestPath,
255
+ readModuleScopes: (pkg) =>
256
+ pkg === "@acme/widget" ? ["widget:read", "widget:write"] : null,
257
+ });
258
+ expect(declared.has("vault:read")).toBe(true); // baseline
259
+ expect(declared.has("widget:read")).toBe(true); // valid sibling's scope survives
260
+ expect(declared.has("widget:write")).toBe(true);
261
+ } finally {
262
+ cleanup();
263
+ }
264
+ });
220
265
  });
@@ -308,4 +308,44 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
308
308
  // origin, which is the same as not setting it at all).
309
309
  expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "/" })).toBeUndefined();
310
310
  });
311
+
312
+ // Fly.io self-host path (patterns#100). resolveStartupIssuer is the
313
+ // function that injects PARACHUTE_HUB_ORIGIN into every supervised module's
314
+ // env. Without a Fly branch here, vault/scribe/app on Fly without a custom
315
+ // domain get undefined → OAuth iss-mismatch on every token hub mints.
316
+ test("FLY_APP_NAME composes https://<app>.fly.dev as fallback issuer", () => {
317
+ const got = resolveStartupIssuer({}, { FLY_APP_NAME: "my-parachute" });
318
+ expect(got).toBe("https://my-parachute.fly.dev");
319
+ });
320
+
321
+ test("PARACHUTE_HUB_ORIGIN wins over FLY_APP_NAME (operator with custom domain)", () => {
322
+ const got = resolveStartupIssuer(
323
+ {},
324
+ {
325
+ PARACHUTE_HUB_ORIGIN: "https://hub.example",
326
+ FLY_APP_NAME: "my-parachute",
327
+ },
328
+ );
329
+ expect(got).toBe("https://hub.example");
330
+ });
331
+
332
+ test("RENDER_EXTERNAL_URL wins over FLY_APP_NAME (pathological co-set)", () => {
333
+ const got = resolveStartupIssuer(
334
+ {},
335
+ {
336
+ RENDER_EXTERNAL_URL: "https://app.onrender.com",
337
+ FLY_APP_NAME: "my-parachute",
338
+ },
339
+ );
340
+ expect(got).toBe("https://app.onrender.com");
341
+ });
342
+
343
+ test("FLY_APP_NAME with slash rejected (defensive — Fly slugs don't contain /)", () => {
344
+ expect(resolveStartupIssuer({}, { FLY_APP_NAME: "a/b" })).toBeUndefined();
345
+ expect(resolveStartupIssuer({}, { FLY_APP_NAME: "../etc/passwd" })).toBeUndefined();
346
+ });
347
+
348
+ test("FLY_APP_NAME empty string → no fallback", () => {
349
+ expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" })).toBeUndefined();
350
+ });
311
351
  });
@@ -3029,3 +3029,35 @@ describe("detectAutoExposeMode — Render env detection edge cases (hub#407 nit)
3029
3029
  expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: undefined })).toBeUndefined();
3030
3030
  });
3031
3031
  });
3032
+
3033
+ describe("detectAutoExposeMode — Fly env detection (patterns#100)", () => {
3034
+ test("returns 'public' when FLY_APP_NAME is a plausible app slug", () => {
3035
+ expect(detectAutoExposeMode({ FLY_APP_NAME: "my-parachute" })).toBe("public");
3036
+ });
3037
+
3038
+ test("returns 'public' when FLY_APP_NAME is the only platform var set", () => {
3039
+ expect(detectAutoExposeMode({ FLY_APP_NAME: "demo-hub" })).toBe("public");
3040
+ });
3041
+
3042
+ test("returns undefined when FLY_APP_NAME is absent", () => {
3043
+ expect(detectAutoExposeMode({})).toBeUndefined();
3044
+ });
3045
+
3046
+ test("returns undefined when FLY_APP_NAME is empty", () => {
3047
+ expect(detectAutoExposeMode({ FLY_APP_NAME: "" })).toBeUndefined();
3048
+ });
3049
+
3050
+ test("rejects FLY_APP_NAME with a slash (defensive — Fly slugs don't contain /)", () => {
3051
+ expect(detectAutoExposeMode({ FLY_APP_NAME: "../etc/passwd" })).toBeUndefined();
3052
+ expect(detectAutoExposeMode({ FLY_APP_NAME: "a/b" })).toBeUndefined();
3053
+ });
3054
+
3055
+ test("Render takes precedence when both are set (pathological co-set)", () => {
3056
+ expect(
3057
+ detectAutoExposeMode({
3058
+ RENDER_EXTERNAL_URL: "https://app.onrender.com",
3059
+ FLY_APP_NAME: "my-parachute",
3060
+ }),
3061
+ ).toBe("public");
3062
+ });
3063
+ });
@@ -249,7 +249,7 @@ export async function handleAccountChangePasswordPost(
249
249
  // grind window against argon2id. Same 429 + Retry-After shape as
250
250
  // /login (admin-handlers.ts), with the re-rendered form body so the
251
251
  // signed-in user sees a coherent page rather than a bare error chrome.
252
- const rlNow = deps.now ? deps.now() : new Date();
252
+ const rlNow = (deps.now ?? (() => new Date()))();
253
253
  const gate = changePasswordRateLimiter.checkAndRecord(user.id, rlNow);
254
254
  if (!gate.allowed) {
255
255
  return htmlResponse(
@@ -48,7 +48,11 @@ import {
48
48
  getSpec,
49
49
  synthesizeManifestForKnownModule,
50
50
  } from "./service-spec.ts";
51
- import { findService, readManifest, removeService } from "./services-manifest.ts";
51
+ import {
52
+ findService,
53
+ readManifestLenient,
54
+ removeService,
55
+ } from "./services-manifest.ts";
52
56
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
53
57
  import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
54
58
 
@@ -359,8 +363,8 @@ function resolveApiInstallChannel(
359
363
  );
360
364
  }
361
365
  }
362
- // 3. Admin-toggle setting (hub#275). Seeds from `PARACHUTE_MODULE_CHANNEL`
363
- // on first read; after that the row is source of truth.
366
+ // 3. Admin-toggle setting (hub#275). Default for the OS process when no env
367
+ // override (#2) is set see `getModuleInstallChannel` for the DB seed/read.
364
368
  return getModuleInstallChannel(deps.db);
365
369
  }
366
370
 
@@ -396,7 +400,8 @@ async function spawnSupervised(
396
400
  spec: ServiceSpec,
397
401
  deps: ApiModulesOpsDeps,
398
402
  ): Promise<ModuleState | undefined> {
399
- const manifest = readManifest(deps.manifestPath);
403
+ // Lenient: one bad row elsewhere shouldn't block spawning a valid one.
404
+ const manifest = readManifestLenient(deps.manifestPath);
400
405
  const entry = manifest.services.find((s) => s.name === spec.manifestName);
401
406
  if (!entry) return undefined;
402
407
  const cmd = spec.startCmd?.(entry);
@@ -785,8 +790,9 @@ export async function handleUninstall(
785
790
  const stopped = await deps.supervisor.stop(short);
786
791
  log.push(stopped ? `${short} supervisor stopped` : `${short} not supervised`);
787
792
 
788
- // 2. Drop the services.json row (idempotent — readManifest is empty if missing).
789
- const before = readManifest(deps.manifestPath);
793
+ // 2. Drop the services.json row (idempotent — readManifestLenient is empty if missing).
794
+ // Lenient so a malformed sibling row doesn't block uninstall of an unrelated module.
795
+ const before = readManifestLenient(deps.manifestPath);
790
796
  if (before.services.some((s) => s.name === spec.manifestName)) {
791
797
  removeService(spec.manifestName, deps.manifestPath);
792
798
  log.push(`removed ${spec.manifestName} from services.json`);
@@ -26,7 +26,7 @@ import {
26
26
  getSpecFromInstallDir,
27
27
  shortNameForManifest,
28
28
  } from "../service-spec.ts";
29
- import { type ServiceEntry, readManifest } from "../services-manifest.ts";
29
+ import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
30
30
  import type { Supervisor } from "../supervisor.ts";
31
31
 
32
32
  export interface BootOpts {
@@ -57,7 +57,10 @@ export async function bootSupervisedModules(
57
57
  opts: BootOpts,
58
58
  ): Promise<BootedModule[]> {
59
59
  const log = opts.log ?? (() => {});
60
- const manifest = readManifest(opts.manifestPath);
60
+ // Lenient: a single bad row shouldn't prevent the supervisor from booting
61
+ // the rest of the services. The container deploy hot path depends on this —
62
+ // we'd rather have N-1 modules up + one warning than zero modules up.
63
+ const manifest = readManifestLenient(opts.manifestPath);
61
64
  const results: BootedModule[] = [];
62
65
 
63
66
  for (const entry of manifest.services) {
@@ -117,11 +117,13 @@ function parsePort(raw: string | undefined): number | undefined {
117
117
  * Precedence (highest first):
118
118
  * 1. Explicit `--issuer` flag (test override too)
119
119
  * 2. `PARACHUTE_HUB_ORIGIN` env (operator-set, typical custom-domain case)
120
- * 3. Platform-injected public URL — currently Render's RENDER_EXTERNAL_URL.
120
+ * 3. Platform-injected public URL:
121
+ * - Render: `RENDER_EXTERNAL_URL` (auto-injected on web services)
122
+ * - Fly.io: `FLY_APP_NAME` → `https://<app>.fly.dev` (patterns#100)
121
123
  * This is the load-bearing tier for container deploys where the
122
- * operator can't know the URL at deploy time. Render generates the
123
- * *.onrender.com hostname after service creation and injects it for
124
- * web services; hub picks it up automatically.
124
+ * operator can't know the URL at deploy time. Without this, supervised
125
+ * modules' iss-validation breaks on hub-minted tokens (iss-mismatch
126
+ * every time).
125
127
  * 4. None (returns undefined). Hub falls back to per-request derivation
126
128
  * via `resolveIssuer` in hub-server.ts — works for `/.well-known`
127
129
  * discovery but supervised modules with cached iss expectations
@@ -129,8 +131,8 @@ function parsePort(raw: string | undefined): number | undefined {
129
131
  * through hub-mint → vault-validate will fail with iss-mismatch.
130
132
  * This is the "no canonical origin known" degraded mode.
131
133
  *
132
- * Future platforms (Fly's `FLY_APP_NAME` + region, Railway's
133
- * `RAILWAY_PUBLIC_DOMAIN`, etc.) can extend tier 3 as needed.
134
+ * Future platforms (Railway's `RAILWAY_PUBLIC_DOMAIN`, etc.) can extend
135
+ * tier 3 as needed.
134
136
  *
135
137
  * Trailing slashes are stripped for canonical-form comparison; empty
136
138
  * strings collapse to undefined.
@@ -139,12 +141,30 @@ export function resolveStartupIssuer(
139
141
  opts: { issuer?: string },
140
142
  env: NodeJS.ProcessEnv,
141
143
  ): string | undefined {
144
+ const flyOrigin = flyDefaultOriginFromEnv(env);
142
145
  return (
143
- (opts.issuer ?? env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL)?.replace(/\/+$/, "") ||
144
- undefined
146
+ (opts.issuer ?? env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL ?? flyOrigin)?.replace(
147
+ /\/+$/,
148
+ "",
149
+ ) || undefined
145
150
  );
146
151
  }
147
152
 
153
+ /**
154
+ * Compose `https://${FLY_APP_NAME}.fly.dev` when FLY_APP_NAME is a plausible
155
+ * Fly app slug. Mirrors the validation in detectAutoExposeMode (no slashes —
156
+ * Fly slugs don't contain them). Kept local to this file (vs imported from
157
+ * hub-server.ts) because serve.ts boots before hub-server.ts is loaded and
158
+ * we want to avoid a cross-module dependency cycle at startup.
159
+ */
160
+ function flyDefaultOriginFromEnv(env: NodeJS.ProcessEnv): string | undefined {
161
+ const app = env.FLY_APP_NAME;
162
+ if (typeof app !== "string" || app.length === 0 || app.includes("/")) {
163
+ return undefined;
164
+ }
165
+ return `https://${app}.fly.dev`;
166
+ }
167
+
148
168
  /**
149
169
  * Seed the initial admin from env vars when no admin exists. Returns the
150
170
  * bootstrap state so the caller can log it for operator visibility.
package/src/hub-server.ts CHANGED
@@ -156,7 +156,7 @@ import {
156
156
  type ModuleManifest,
157
157
  readModuleManifest as defaultReadModuleManifest,
158
158
  } from "./module-manifest.ts";
159
- import { logNotesRedirect, maybeRedirectNotes } from "./notes-redirect.ts";
159
+ import { isLegacyNotesPath, logNotesRedirect, maybeRedirectNotes } from "./notes-redirect.ts";
160
160
  import {
161
161
  authorizationServerMetadata,
162
162
  handleApproveClientPost,
@@ -222,6 +222,12 @@ interface Args {
222
222
  * without operator config. Mirrors the
223
223
  * precedence in commands/serve.ts's
224
224
  * resolveStartupIssuer.
225
+ * FLY_APP_NAME — Fly.io sets this to the app name; we compose
226
+ * `https://${FLY_APP_NAME}.fly.dev` as a peer
227
+ * of RENDER_EXTERNAL_URL for the self-host-on-Fly
228
+ * path (patterns#100). Operators with custom
229
+ * domains attached set PARACHUTE_HUB_ORIGIN
230
+ * explicitly to override.
225
231
  */
226
232
  export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): Args {
227
233
  let port: number | undefined;
@@ -260,12 +266,39 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
260
266
  if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
261
267
  if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
262
268
  if (issuer === undefined) {
263
- const fromEnv = env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL;
269
+ const fromEnv =
270
+ env.PARACHUTE_HUB_ORIGIN ??
271
+ env.RENDER_EXTERNAL_URL ??
272
+ flyDefaultOrigin(env);
264
273
  if (fromEnv) issuer = fromEnv.replace(/\/+$/, "") || undefined;
265
274
  }
266
275
  return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
267
276
  }
268
277
 
278
+ /**
279
+ * Compose the default Fly.io public origin from FLY_APP_NAME. Mirrors what
280
+ * `RENDER_EXTERNAL_URL` provides on Render — without an operator-set
281
+ * PARACHUTE_HUB_ORIGIN, we need *some* fallback so OAuth issuance + same-
282
+ * origin checks work out of the box. Fly doesn't auto-set a public-URL env
283
+ * var the way Render does, but every Fly app on the free shared TLS tier
284
+ * is reachable at `<app>.fly.dev` — composing it from FLY_APP_NAME is the
285
+ * canonical default.
286
+ *
287
+ * Operators on Fly with a custom domain attached should set
288
+ * PARACHUTE_HUB_ORIGIN explicitly via `fly secrets set` — that wins over
289
+ * this fallback (precedence in `parseArgs` above).
290
+ */
291
+ function flyDefaultOrigin(env: NodeJS.ProcessEnv): string | undefined {
292
+ const app = env.FLY_APP_NAME;
293
+ // Mirror detectAutoExposeMode's slash-rejection — Fly slugs don't contain
294
+ // `/`, so anything with one is either spoofed or malformed. The composed
295
+ // URL is the OAuth issuer claim, so consistency in validation matters.
296
+ if (typeof app !== "string" || app.length === 0 || app.includes("/")) {
297
+ return undefined;
298
+ }
299
+ return `https://${app}.fly.dev`;
300
+ }
301
+
269
302
  function parsePort(v: string): number {
270
303
  const n = Number.parseInt(v, 10);
271
304
  if (!Number.isInteger(n) || n <= 0 || n > 65535) {
@@ -1080,9 +1113,13 @@ export function hubFetch(
1080
1113
  // configured issuer. On Render, an operator who set hub_origin
1081
1114
  // via the admin SPA (or via a stale db row) to a non-public URL
1082
1115
  // would otherwise reject legitimate browser POSTs that arrive
1083
- // with the public Render URL as Origin. See origin-check.ts
1116
+ // with the public Render URL as Origin. Same defense on Fly:
1117
+ // the public <app>.fly.dev URL is the canonical Origin for
1118
+ // browser POSTs and must be trusted even when the operator's
1119
+ // configured issuer points elsewhere. See origin-check.ts
1084
1120
  // jsdoc for the failure case this closes.
1085
- platformOrigin: process.env.RENDER_EXTERNAL_URL,
1121
+ platformOrigin:
1122
+ process.env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(process.env),
1086
1123
  }),
1087
1124
  };
1088
1125
  };
@@ -1178,7 +1215,7 @@ export function hubFetch(
1178
1215
  // Lazy DB read: only consult `getDb` when the path actually matches a
1179
1216
  // legacy notes prefix — every non-notes request must NOT touch the DB
1180
1217
  // here (some tests + the /health route assert getDb is never called).
1181
- if (pathname === "/notes" || pathname.startsWith("/notes/")) {
1218
+ if (isLegacyNotesPath(pathname)) {
1182
1219
  const notesRedirect = maybeRedirectNotes(pathname, url.search, getDb?.());
1183
1220
  if (notesRedirect !== undefined) {
1184
1221
  logNotesRedirect(pathname, notesRedirect);
@@ -1915,6 +1952,9 @@ export function hubFetch(
1915
1952
  // password (design §"Direct navigation").
1916
1953
  if (pathname === "/account/change-password") {
1917
1954
  if (!getDb) return dbNotConfigured();
1955
+ // `now` deliberately omitted — handlers fall through to `new Date()` in
1956
+ // production; the seam exists only so tests can advance the rate-limiter
1957
+ // clock deterministically.
1918
1958
  const accountDeps = { db: getDb() };
1919
1959
  if (req.method === "GET") return handleAccountChangePasswordGet(req, accountDeps);
1920
1960
  if (req.method === "POST") return handleAccountChangePasswordPost(req, accountDeps);
@@ -89,6 +89,8 @@ export function maybeRedirectNotes(
89
89
  * helper resets it between test runs.
90
90
  */
91
91
  const RATE_LIMIT_WINDOW_MS = 60_000;
92
+ // Bounded by O(distinct legacy notes paths bookmarked by operators) —
93
+ // operator-owned input set, not attacker-controlled. No eviction needed.
92
94
  const lastLogAt = new Map<string, number>();
93
95
 
94
96
  export interface LogNotesRedirectOpts {
@@ -590,6 +590,19 @@ function pendingClientResponse(
590
590
  // the authed branch's inline approve form, and the flow resumes.
591
591
  const returnTo = `${authorizeUrl.pathname}${authorizeUrl.search}`;
592
592
  if (session && sameOrigin) {
593
+ // Plumb redirect_uri + state for the Deny path (hub#390): an operator
594
+ // who clicks Deny gets routed back to the client's redirect_uri with
595
+ // an RFC 6749 §4.1.2.1 error response. redirect_uri is required by
596
+ // the spec on /oauth/authorize so it's reliably present; state is
597
+ // optional per the spec, so undefined-passes-through.
598
+ //
599
+ // We round-trip both values through hidden form inputs rather than
600
+ // re-parsing them from `return_to` in the handler. Reason: keeps the
601
+ // handler stateless about authorize-URL shape — it just reads the
602
+ // form. State is also not stored server-side (it never was; OAuth
603
+ // state lives in the query string by design).
604
+ const requestRedirectUri = authorizeUrl.searchParams.get("redirect_uri") ?? "";
605
+ const requestState = authorizeUrl.searchParams.get("state") ?? undefined;
593
606
  return htmlResponse(
594
607
  renderApprovePending({
595
608
  clientName: client.clientName ?? client.clientId,
@@ -598,7 +611,12 @@ function pendingClientResponse(
598
611
  requestedScopes,
599
612
  ...(requestedVault !== undefined && { requestedVault }),
600
613
  hubOrigin: deps.issuer,
601
- approveForm: { csrfToken: csrf.token, returnTo },
614
+ approveForm: {
615
+ csrfToken: csrf.token,
616
+ returnTo,
617
+ redirectUri: requestRedirectUri,
618
+ ...(requestState !== undefined && { state: requestState }),
619
+ },
602
620
  loginNextUrl: returnTo,
603
621
  }),
604
622
  403,
@@ -1359,10 +1377,64 @@ export async function handleApproveClientPost(
1359
1377
  if (!client) {
1360
1378
  return htmlError("Unknown application", "This client_id is not registered with this hub.", 404);
1361
1379
  }
1362
- // Validate return_to BEFORE the DB mutation: if an authenticated operator
1363
- // submits a hand-crafted form with a bad return_to, we refuse without
1364
- // committing the client to `approved`. Practical risk is low (all three
1365
- // belts already passed), but ordering matters — validate, then mutate.
1380
+
1381
+ // Deny branch (hub#390): RFC 6749 §4.1.2.1 when the resource owner
1382
+ // denies an access request, the AS bounces back to the client's
1383
+ // redirect_uri with `error=access_denied` (+ original state). Validate
1384
+ // redirect_uri against the client's registered URIs to prevent open
1385
+ // redirect via a hand-crafted form; refuse with 400 if it doesn't match.
1386
+ // Deny does NOT mutate the client row — the client stays `pending` and
1387
+ // the operator can revisit later from /admin/permissions or re-trigger
1388
+ // OAuth from the calling app.
1389
+ //
1390
+ // The decision default is `"approve"` — that covers both back-compat
1391
+ // (older forms that don't carry the field at all) AND any unexpected
1392
+ // value (e.g. `decision="garbage"`). The reasoning: only the literal
1393
+ // string `"deny"` triggers the destructive-from-the-client's-perspective
1394
+ // path; everything else is treated as the safe, prior behavior. A user
1395
+ // can't accidentally land on the error redirect by clicking a malformed
1396
+ // button.
1397
+ const decision = String(form.get("decision") ?? "approve");
1398
+ if (decision === "deny") {
1399
+ const denyRedirectUri = String(form.get("redirect_uri") ?? "");
1400
+ if (!denyRedirectUri || !client.redirectUris.includes(denyRedirectUri)) {
1401
+ return htmlError(
1402
+ "Invalid form submission",
1403
+ "The redirect_uri does not match any URI registered for this app.",
1404
+ 400,
1405
+ );
1406
+ }
1407
+ const stateRaw = form.get("state");
1408
+ const denyState = typeof stateRaw === "string" && stateRaw.length > 0 ? stateRaw : undefined;
1409
+ // `new URL()` could in principle throw if a legacy code path bypassed
1410
+ // `isValidRedirectUri` at registration and wrote a non-http(s) URI.
1411
+ // Current DCR enforces validity at write time, so this catch is
1412
+ // belt-and-suspenders, but cost is near-zero.
1413
+ let target: URL;
1414
+ try {
1415
+ target = new URL(denyRedirectUri);
1416
+ } catch {
1417
+ return htmlError(
1418
+ "Invalid form submission",
1419
+ "The registered redirect_uri for this app is not a valid URL.",
1420
+ 400,
1421
+ );
1422
+ }
1423
+ target.searchParams.set("error", "access_denied");
1424
+ target.searchParams.set(
1425
+ "error_description",
1426
+ "The user denied the authorization request.",
1427
+ );
1428
+ if (denyState !== undefined) target.searchParams.set("state", denyState);
1429
+ return redirectResponse(target.toString());
1430
+ }
1431
+
1432
+ // Approve branch (default — also the back-compat path for any form that
1433
+ // doesn't carry an explicit `decision` field). Validate return_to BEFORE
1434
+ // the DB mutation: if an authenticated operator submits a hand-crafted
1435
+ // form with a bad return_to, we refuse without committing the client to
1436
+ // `approved`. Practical risk is low (all three belts already passed),
1437
+ // but ordering matters — validate, then mutate.
1366
1438
  const returnTo = String(form.get("return_to") ?? "");
1367
1439
  if (!isSafeAuthorizeReturnTo(returnTo)) {
1368
1440
  return htmlError(
package/src/oauth-ui.ts CHANGED
@@ -226,10 +226,19 @@ export interface ApprovePendingViewProps {
226
226
  * server will redirect to after the approve commits — the original
227
227
  * `/oauth/authorize?...` URL so the OAuth flow re-enters with the now-
228
228
  * approved client and lands on the consent screen.
229
+ *
230
+ * Deny path (closes hub#390): the same form also surfaces a Deny button.
231
+ * `redirectUri` + `state` are plumbed through as hidden inputs so the deny
232
+ * handler can construct an RFC 6749 §4.1.2.1 error redirect back to the
233
+ * calling client (`<redirect_uri>?error=access_denied&state=<state>`).
234
+ * `redirectUri` is required because deny must signal back to the client;
235
+ * `state` may be undefined (OAuth clients sometimes omit it).
229
236
  */
230
237
  approveForm?: {
231
238
  csrfToken: string;
232
239
  returnTo: string;
240
+ redirectUri: string;
241
+ state?: string;
233
242
  };
234
243
  /**
235
244
  * Same-origin hub-relative URL (path + search) to send the operator to
@@ -506,7 +515,12 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
506
515
  ${renderCsrfHiddenInput(approveForm.csrfToken)}
507
516
  <input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
508
517
  <input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
509
- <button type="submit" class="btn btn-primary">Approve</button>
518
+ <input type="hidden" name="redirect_uri" value="${escapeHtml(approveForm.redirectUri)}" />
519
+ ${approveForm.state !== undefined ? `<input type="hidden" name="state" value="${escapeHtml(approveForm.state)}" />` : ""}
520
+ <div class="approve-actions">
521
+ <button type="submit" name="decision" value="approve" class="btn btn-primary">Approve</button>
522
+ <button type="submit" name="decision" value="deny" class="btn btn-secondary">Deny</button>
523
+ </div>
510
524
  </form>`
511
525
  : renderUnauthenticatedApproveCtas(hubOrigin, clientId, loginNextUrl);
512
526
  const body = `
package/src/rate-limit.ts CHANGED
@@ -36,6 +36,13 @@
36
36
  * the map, so an attacker cycling through keys can't grow the map
37
37
  * without also leaving timestamps in each.
38
38
  *
39
+ * One edge case worth naming: a per-stable-key limiter (e.g. /change-password
40
+ * keyed by user.id) can leave an empty bucket for a user who hit the limit
41
+ * once and never returned — the prune only fires on `checkAndRecord` for
42
+ * that same key. Real-world scale is tiny (hundreds of users → hundreds of
43
+ * empty bucket entries at worst), so this is a documentation note, not a
44
+ * leak. Per-IP limiters (e.g. /login) self-prune as attackers cycle keys.
45
+ *
39
46
  * Auth-stage independence: callers MUST gate via `checkAndRecord` *before*
40
47
  * the credential check. A 2FA (or password) failure should count toward
41
48
  * the same bucket as a wrong password — an attacker who knows the
@@ -24,7 +24,7 @@ import { homedir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { type ModuleManifest, validateModuleManifest } from "./module-manifest.ts";
26
26
  import { FIRST_PARTY_SCOPES } from "./scope-explanations.ts";
27
- import { readManifest as readServicesManifest } from "./services-manifest.ts";
27
+ import { readManifestLenient as readServicesManifest } from "./services-manifest.ts";
28
28
 
29
29
  /**
30
30
  * RFC 6749 §3.3: scope strings are 1*( SP scope-token ). We accept any
@@ -143,12 +143,10 @@ export interface LoadDeclaredScopesOpts {
143
143
  export function loadDeclaredScopes(opts: LoadDeclaredScopesOpts = {}): Set<string> {
144
144
  const declared = new Set<string>(FIRST_PARTY_SCOPES);
145
145
  const readModuleScopes = opts.readModuleScopes ?? defaultReadModuleScopes;
146
- let services: { name: string; installDir?: string }[];
147
- try {
148
- services = readServicesManifest(opts.manifestPath).services;
149
- } catch {
150
- return declared;
151
- }
146
+ // readServicesManifest is the lenient reader: it returns `{ services: [] }`
147
+ // on missing/unparseable files and skips individual bad rows with a warning.
148
+ // The for-loop below already degrades gracefully, so no try/catch needed.
149
+ const services = readServicesManifest(opts.manifestPath).services;
152
150
  for (const svc of services) {
153
151
  const defined = readModuleScopes(svc.name, svc.installDir);
154
152
  if (!defined) continue;
@@ -233,19 +233,19 @@ export interface SetupWizardDeps {
233
233
  /**
234
234
  * Returns `"public"` when the runtime env indicates the hub is deployed
235
235
  * on a platform where the "how will this hub be reached?" answer is
236
- * pre-determined by the platform. Today: Render (sets RENDER_EXTERNAL_URL
237
- * for any web service). Returns `undefined` otherwise the wizard's
238
- * expose step asks the operator.
236
+ * pre-determined by the platform. Today: Render (sets RENDER_EXTERNAL_URL)
237
+ * and Fly.io (sets FLY_APP_NAME, reachable at `<app>.fly.dev`). Returns
238
+ * `undefined` otherwise — the wizard's expose step asks the operator.
239
239
  *
240
- * Why this matters: on Render, none of the three radio options
240
+ * Why this matters: on a managed PaaS, none of the three radio options
241
241
  * (localhost, tailnet, public-with-custom-domain) match the actual
242
- * setup. The hub is reached at `*.onrender.com` automatically. Asking
243
- * the operator wastes a click and surfaces three options that don't
244
- * speak to their situation. Auto-pinning `public` skips the step.
242
+ * setup. The hub is reached at the platform-assigned URL automatically.
243
+ * Asking the operator wastes a click and surfaces three options that
244
+ * don't speak to their situation. Auto-pinning `public` skips the step.
245
245
  *
246
- * Add more platforms here when we encounter them — e.g. Fly.io
247
- * (FLY_APP_NAME), Railway (RAILWAY_ENVIRONMENT), etc. Each only auto-
248
- * detects when the platform clearly owns the public URL.
246
+ * Add more platforms here when we encounter them — e.g. Railway
247
+ * (RAILWAY_ENVIRONMENT), DigitalOcean App Platform (DIGITALOCEAN_APP_*),
248
+ * etc. Each only auto-detects when the platform clearly owns the public URL.
249
249
  */
250
250
  export function detectAutoExposeMode(env: Record<string, string | undefined>): "public" | undefined {
251
251
  // Render always sets `RENDER_EXTERNAL_URL` to a real `https://` URL on
@@ -253,8 +253,21 @@ export function detectAutoExposeMode(env: Record<string, string | undefined>): "
253
253
  // also accept `http://` as a defensive fallback in case Render ever
254
254
  // changes the scheme on some plan tier. Anything else (empty, weird,
255
255
  // not a URL) → don't auto-skip; let the operator choose.
256
- const url = env.RENDER_EXTERNAL_URL;
257
- if (typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://"))) {
256
+ const renderUrl = env.RENDER_EXTERNAL_URL;
257
+ if (
258
+ typeof renderUrl === "string" &&
259
+ (renderUrl.startsWith("https://") || renderUrl.startsWith("http://"))
260
+ ) {
261
+ return "public";
262
+ }
263
+ // Fly.io sets FLY_APP_NAME (the app slug) on every machine. Unlike
264
+ // Render, Fly doesn't auto-inject a public-URL env var — but every
265
+ // Fly app on shared TLS is reachable at `<app>.fly.dev`, so the
266
+ // presence of FLY_APP_NAME is the canonical "we're on Fly with a
267
+ // public URL" signal. Validate it's a plausible slug (non-empty,
268
+ // no scheme weirdness) before trusting it.
269
+ const flyApp = env.FLY_APP_NAME;
270
+ if (typeof flyApp === "string" && flyApp.length > 0 && !flyApp.includes("/")) {
258
271
  return "public";
259
272
  }
260
273
  return undefined;
@@ -26,7 +26,7 @@
26
26
  * `vaultInstanceNameFor`. Entries with no paths still resolve to a name via
27
27
  * the helper's manifest-suffix fallback (hub#143).
28
28
  */
29
- import { type ServicesManifest, readManifest } from "./services-manifest.ts";
29
+ import { type ServicesManifest, readManifestLenient } from "./services-manifest.ts";
30
30
  import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
31
31
 
32
32
  /**
@@ -50,8 +50,8 @@ export function listVaultNames(manifest: ServicesManifest): string[] {
50
50
  /**
51
51
  * Read-from-disk convenience for callers that already have a manifest path
52
52
  * (e.g. `/api/users/vaults` reading the live `services.json`). Equivalent to
53
- * `listVaultNames(readManifest(manifestPath))`.
53
+ * `listVaultNames(readManifestLenient(manifestPath))`.
54
54
  */
55
55
  export function listVaultNamesFromPath(manifestPath: string): string[] {
56
- return listVaultNames(readManifest(manifestPath));
56
+ return listVaultNames(readManifestLenient(manifestPath));
57
57
  }
package/src/well-known.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  type ServiceEntry,
7
7
  type UiSubUnit,
8
8
  type UiSubUnitStatus,
9
- readManifest,
9
+ readManifestLenient,
10
10
  } from "./services-manifest.ts";
11
11
 
12
12
  export interface WellKnownServiceEntry {
@@ -386,7 +386,11 @@ export async function regenerateWellKnown(
386
386
  ): Promise<{ path: string; doc: WellKnownDocument }> {
387
387
  const read = opts.readModuleManifest ?? readModuleManifest;
388
388
  const path = opts.wellKnownPath ?? WELL_KNOWN_PATH;
389
- const services = readManifest(opts.manifestPath).services;
389
+ // Lenient: one malformed row shouldn't block well-known regen for everyone
390
+ // else (downstream consumers — Notes, Scribe, MCP — poll this; if it 500s
391
+ // they lose discovery). The function below already tolerates per-entry
392
+ // manifest errors via console.warn, so partial valid set is the right shape.
393
+ const services = readManifestLenient(opts.manifestPath).services;
390
394
  // Build the resolver maps the same way hub-server does — read each
391
395
  // module's `.parachute/module.json` from `installDir` and harvest
392
396
  // managementUrl (vault rows), uiUrl + displayName (all rows). Vaults