@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 +32 -8
- package/package.json +1 -1
- package/src/__tests__/hub-server.test.ts +29 -0
- package/src/__tests__/oauth-handlers.test.ts +188 -0
- package/src/__tests__/oauth-ui.test.ts +43 -2
- package/src/__tests__/scope-registry.test.ts +45 -0
- package/src/__tests__/serve.test.ts +40 -0
- package/src/__tests__/setup-wizard.test.ts +32 -0
- package/src/api-account.ts +1 -1
- package/src/api-modules-ops.ts +12 -6
- package/src/commands/serve-boot.ts +5 -2
- package/src/commands/serve.ts +28 -8
- package/src/hub-server.ts +45 -5
- package/src/notes-redirect.ts +2 -0
- package/src/oauth-handlers.ts +77 -5
- package/src/oauth-ui.ts +15 -1
- package/src/rate-limit.ts +7 -0
- package/src/scope-registry.ts +5 -7
- package/src/setup-wizard.ts +25 -12
- package/src/vault-names.ts +3 -3
- package/src/well-known.ts +6 -2
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
|
|
19
|
+
### Hosted self-deploy
|
|
20
20
|
|
|
21
|
-
|
|
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
|
-
|
|
25
|
+
[](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
|
|
24
26
|
|
|
25
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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: {
|
|
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: {
|
|
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
|
+
});
|
package/src/api-account.ts
CHANGED
|
@@ -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
|
|
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(
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -48,7 +48,11 @@ import {
|
|
|
48
48
|
getSpec,
|
|
49
49
|
synthesizeManifestForKnownModule,
|
|
50
50
|
} from "./service-spec.ts";
|
|
51
|
-
import {
|
|
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).
|
|
363
|
-
//
|
|
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
|
-
|
|
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 —
|
|
789
|
-
|
|
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,
|
|
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
|
-
|
|
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) {
|
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
|
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.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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 (
|
|
133
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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:
|
|
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
|
|
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);
|
package/src/notes-redirect.ts
CHANGED
|
@@ -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 {
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
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
|
-
<
|
|
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
|
package/src/scope-registry.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
243
|
-
* the operator wastes a click and surfaces three options that
|
|
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.
|
|
247
|
-
* (
|
|
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
|
|
257
|
-
if (
|
|
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;
|
package/src/vault-names.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
53
|
+
* `listVaultNames(readManifestLenient(manifestPath))`.
|
|
54
54
|
*/
|
|
55
55
|
export function listVaultNamesFromPath(manifestPath: string): string[] {
|
|
56
|
-
return listVaultNames(
|
|
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
|
-
|
|
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
|
-
|
|
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
|