@openparachute/hub 0.7.2 → 0.7.3-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +113 -0
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +136 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/oauth-client.test.ts +75 -0
- package/src/__tests__/oauth-handlers.test.ts +413 -5
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-agent-grants.ts +51 -3
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-modules-ops.ts +12 -0
- package/src/api-modules.ts +47 -13
- package/src/api-users.ts +3 -0
- package/src/api-vault-caps.ts +206 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +173 -0
- package/src/commands/init.ts +73 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/help.ts +8 -0
- package/src/hub-db.ts +108 -1
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +27 -0
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +72 -3
- package/src/module-manifest.ts +23 -9
- package/src/oauth-client.ts +102 -12
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +145 -20
- package/src/rate-limit.ts +111 -20
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
- package/src/vault-hub-origin-env.ts +109 -16
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-B5AUE359.js +0 -61
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parachute hub set-origin <url>` (onboarding-streamline 2026-06-25,
|
|
3
|
+
* Caddy-direct zero-SSH path).
|
|
4
|
+
*
|
|
5
|
+
* The verb persists the operator's canonical public origin to
|
|
6
|
+
* `hub_settings.hub_origin` from the CLI — closing the "set the issuer without
|
|
7
|
+
* an admin browser session" gap on a headless reverse-proxy box. These tests
|
|
8
|
+
* pin: it writes the row, it validates + canonicalizes the URL (reusing the
|
|
9
|
+
* SPA's `validateHubOrigin`), and it warns-but-allows loopback.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { hub, hubSetOrigin } from "../commands/hub.ts";
|
|
16
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
+
import { getHubOrigin } from "../hub-settings.ts";
|
|
18
|
+
|
|
19
|
+
describe("parachute hub set-origin", () => {
|
|
20
|
+
let dir: string;
|
|
21
|
+
let log: string[];
|
|
22
|
+
const collect = (line: string) => log.push(line);
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
dir = mkdtempSync(join(tmpdir(), "hub-set-origin-"));
|
|
26
|
+
log = [];
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
29
|
+
|
|
30
|
+
/** Open the configDir's hub.db and read the persisted origin. */
|
|
31
|
+
function persisted(): string | null {
|
|
32
|
+
const db = openHubDb(hubDbPath(dir));
|
|
33
|
+
try {
|
|
34
|
+
return getHubOrigin(db);
|
|
35
|
+
} finally {
|
|
36
|
+
db.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("persists a valid public origin to hub_settings.hub_origin", async () => {
|
|
41
|
+
const code = await hubSetOrigin(["https://box.sslip.io"], { configDir: dir, log: collect });
|
|
42
|
+
expect(code).toBe(0);
|
|
43
|
+
expect(persisted()).toBe("https://box.sslip.io");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("strips a trailing slash (canonical bare-origin form)", async () => {
|
|
47
|
+
const code = await hubSetOrigin(["https://box.example.com/"], { configDir: dir, log: collect });
|
|
48
|
+
expect(code).toBe(0);
|
|
49
|
+
expect(persisted()).toBe("https://box.example.com");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("rejects a non-http(s) scheme without writing", async () => {
|
|
53
|
+
const code = await hubSetOrigin(["ftp://box.example.com"], { configDir: dir, log: collect });
|
|
54
|
+
expect(code).toBe(1);
|
|
55
|
+
expect(persisted()).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("rejects a URL with a path without writing", async () => {
|
|
59
|
+
const code = await hubSetOrigin(["https://box.example.com/admin"], {
|
|
60
|
+
configDir: dir,
|
|
61
|
+
log: collect,
|
|
62
|
+
});
|
|
63
|
+
expect(code).toBe(1);
|
|
64
|
+
expect(persisted()).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("rejects a bare hostname (no scheme) without writing", async () => {
|
|
68
|
+
const code = await hubSetOrigin(["box.example.com"], { configDir: dir, log: collect });
|
|
69
|
+
expect(code).toBe(1);
|
|
70
|
+
expect(persisted()).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("missing the URL argument is a usage error, no write", async () => {
|
|
74
|
+
const code = await hubSetOrigin([], { configDir: dir, log: collect });
|
|
75
|
+
expect(code).toBe(1);
|
|
76
|
+
expect(persisted()).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("warns but ALLOWS a loopback origin (dev/test escape hatch)", async () => {
|
|
80
|
+
const code = await hubSetOrigin(["http://127.0.0.1:1939"], { configDir: dir, log: collect });
|
|
81
|
+
expect(code).toBe(0);
|
|
82
|
+
expect(persisted()).toBe("http://127.0.0.1:1939");
|
|
83
|
+
expect(log.some((l) => l.toLowerCase().includes("loopback"))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("prints the restart note so the operator knows running modules need a restart", async () => {
|
|
87
|
+
await hubSetOrigin(["https://box.sslip.io"], { configDir: dir, log: collect });
|
|
88
|
+
const joined = log.join("\n");
|
|
89
|
+
expect(joined).toContain("parachute restart vault");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("openDb seam is honored (no touch of the live ~/.parachute)", async () => {
|
|
93
|
+
// Point the seam at a SEPARATE on-disk DB so we can assert (a) the seam was
|
|
94
|
+
// consulted, and (b) the write landed in the seam's DB — never the
|
|
95
|
+
// configDir's default path. (A real Database can't be Proxy-wrapped: Bun's
|
|
96
|
+
// prepared-statement private fields break under a Proxy.)
|
|
97
|
+
const altDir = mkdtempSync(join(tmpdir(), "hub-set-origin-alt-"));
|
|
98
|
+
try {
|
|
99
|
+
let opened = 0;
|
|
100
|
+
const code = await hubSetOrigin(["https://box.sslip.io"], {
|
|
101
|
+
configDir: dir,
|
|
102
|
+
log: collect,
|
|
103
|
+
openDb: () => {
|
|
104
|
+
opened++;
|
|
105
|
+
return openHubDb(hubDbPath(altDir));
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
expect(code).toBe(0);
|
|
109
|
+
expect(opened).toBe(1);
|
|
110
|
+
// The write landed in the seam's DB, NOT the configDir's default DB.
|
|
111
|
+
const alt = openHubDb(hubDbPath(altDir));
|
|
112
|
+
try {
|
|
113
|
+
expect(getHubOrigin(alt)).toBe("https://box.sslip.io");
|
|
114
|
+
} finally {
|
|
115
|
+
alt.close();
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(altDir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("parachute hub dispatcher", () => {
|
|
124
|
+
test("no subcommand prints help, exits 0", async () => {
|
|
125
|
+
const code = await hub([]);
|
|
126
|
+
expect(code).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("--help exits 0", async () => {
|
|
130
|
+
expect(await hub(["--help"])).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("unknown subcommand exits 1", async () => {
|
|
134
|
+
expect(await hub(["frobnicate"])).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-origin iss-set (onboarding-streamline 2026-06-25) — hub side.
|
|
3
|
+
*
|
|
4
|
+
* The hub publishes the SET of origins it legitimately answers on to its
|
|
5
|
+
* supervised resource servers via `PARACHUTE_HUB_ORIGINS` (comma-separated),
|
|
6
|
+
* alongside the single canonical `PARACHUTE_HUB_ORIGIN`. A resource server on
|
|
7
|
+
* scope-guard ≥0.5.0 widens its accepted-`iss` check to this set so a token
|
|
8
|
+
* minted under one URL of a multi-URL box validates via another URL of the
|
|
9
|
+
* SAME box.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin two things:
|
|
12
|
+
* 1. The serialize/parse round-trip + the assembly from hub-controlled
|
|
13
|
+
* inputs (issuer ∪ loopback aliases ∪ expose-state ∪ platform).
|
|
14
|
+
* 2. The SECURITY INVARIANT: the set is built ONLY from operator/hub config
|
|
15
|
+
* and on-disk state — never from an unvalidated request `Host` /
|
|
16
|
+
* `X-Forwarded-Host`. We feed an attacker-controlled "Host" through every
|
|
17
|
+
* input channel a request could plausibly reach and assert it never lands
|
|
18
|
+
* in the published set.
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
21
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { resolveStartupIssuer } from "../commands/serve.ts";
|
|
25
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
26
|
+
import { parseHubOrigins, serializeHubOrigins } from "../hub-origin.ts";
|
|
27
|
+
import { getHubOrigin, setHubOrigin } from "../hub-settings.ts";
|
|
28
|
+
import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
|
|
29
|
+
|
|
30
|
+
describe("serializeHubOrigins / parseHubOrigins round-trip", () => {
|
|
31
|
+
test("serialize dedupes, drops empties, strips trailing slashes", () => {
|
|
32
|
+
const v = serializeHubOrigins([
|
|
33
|
+
"https://a.example/",
|
|
34
|
+
"https://a.example",
|
|
35
|
+
"",
|
|
36
|
+
"https://b.example",
|
|
37
|
+
]);
|
|
38
|
+
expect(v).toBe("https://a.example,https://b.example");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("serialize returns undefined when nothing survives", () => {
|
|
42
|
+
expect(serializeHubOrigins([])).toBeUndefined();
|
|
43
|
+
expect(serializeHubOrigins(["", " "])).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("parse is the inverse — tolerant of whitespace + trailing slashes + empties", () => {
|
|
47
|
+
expect(parseHubOrigins("https://a.example, https://b.example/ ,,")).toEqual([
|
|
48
|
+
"https://a.example",
|
|
49
|
+
"https://b.example",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("parse returns [] for absent/empty/garbage", () => {
|
|
54
|
+
expect(parseHubOrigins(undefined)).toEqual([]);
|
|
55
|
+
expect(parseHubOrigins("")).toEqual([]);
|
|
56
|
+
expect(parseHubOrigins(" , , ")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("round-trips a real set", () => {
|
|
60
|
+
const origins = ["https://example.com", "http://127.0.0.1:1939", "http://localhost:1939"];
|
|
61
|
+
const wire = serializeHubOrigins(origins)!;
|
|
62
|
+
expect(parseHubOrigins(wire)).toEqual(origins);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("buildHubOriginsEnvValue — assembles the hub's legitimate-origin set", () => {
|
|
67
|
+
let dir: string;
|
|
68
|
+
const EXPOSE = () => join(dir, "expose-state.json");
|
|
69
|
+
|
|
70
|
+
/** Write a schema-valid expose-state.json carrying the given hubOrigin. */
|
|
71
|
+
function writeExposeState(hubOrigin: string): void {
|
|
72
|
+
writeFileSync(
|
|
73
|
+
EXPOSE(),
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
version: 1,
|
|
76
|
+
layer: "public",
|
|
77
|
+
mode: "path",
|
|
78
|
+
canonicalFqdn: new URL(hubOrigin).host,
|
|
79
|
+
port: 1939,
|
|
80
|
+
funnel: true,
|
|
81
|
+
entries: [],
|
|
82
|
+
hubOrigin,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
dir = mkdtempSync(join(tmpdir(), "hub-origins-set-"));
|
|
89
|
+
// No hub.port file in this fresh configDir → readHubPort falls back to
|
|
90
|
+
// HUB_UNIT_DEFAULT_PORT (1939), the deterministic value these cases assert.
|
|
91
|
+
});
|
|
92
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
93
|
+
|
|
94
|
+
test("issuer ∪ loopback aliases when no expose / platform origin", () => {
|
|
95
|
+
const v = buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE());
|
|
96
|
+
const set = parseHubOrigins(v);
|
|
97
|
+
expect(set).toContain("https://example.com");
|
|
98
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
99
|
+
expect(set).toContain("http://localhost:1939");
|
|
100
|
+
expect(set).toHaveLength(3);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("loopback aliases are ALWAYS present (the always-present invariant)", () => {
|
|
104
|
+
// Even with a public issuer, loopback must be in the set so the co-located
|
|
105
|
+
// CLI / loopback-proxied request path validates.
|
|
106
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
107
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
108
|
+
expect(set).toContain("http://localhost:1939");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("folds in the expose-state public origin", () => {
|
|
112
|
+
writeExposeState("https://box.sslip.io");
|
|
113
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
114
|
+
expect(set).toContain("https://example.com");
|
|
115
|
+
expect(set).toContain("https://box.sslip.io");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("folds in the platform origin (RENDER_EXTERNAL_URL)", () => {
|
|
119
|
+
const set = parseHubOrigins(
|
|
120
|
+
buildHubOriginsEnvValue(
|
|
121
|
+
dir,
|
|
122
|
+
"https://example.com",
|
|
123
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
124
|
+
EXPOSE(),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
expect(set).toContain("https://app.onrender.com");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("folds in the composed Fly default origin", () => {
|
|
131
|
+
const set = parseHubOrigins(
|
|
132
|
+
buildHubOriginsEnvValue(dir, "https://example.com", { FLY_APP_NAME: "myapp" }, EXPOSE()),
|
|
133
|
+
);
|
|
134
|
+
expect(set).toContain("https://myapp.fly.dev");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("absent issuer → loopback-only set (still useful, never empty on a normal box)", () => {
|
|
138
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, undefined, {}, EXPOSE()));
|
|
139
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
140
|
+
expect(set).toContain("http://localhost:1939");
|
|
141
|
+
// The empty issuer "" is dropped by buildHubBoundOrigins' URL parse.
|
|
142
|
+
expect(set).not.toContain("");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a malformed expose-state.json never throws — falls back to issuer + loopback", () => {
|
|
146
|
+
writeFileSync(EXPOSE(), "{ not valid json");
|
|
147
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
148
|
+
expect(set).toContain("https://example.com");
|
|
149
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("SECURITY INVARIANT — request Host never enters the set", () => {
|
|
153
|
+
const ATTACKER = "https://attacker.evil";
|
|
154
|
+
|
|
155
|
+
test("an attacker Host smuggled via expose-state IS honored ONLY because it's operator-written on-disk state — but a Host passed nowhere never appears", () => {
|
|
156
|
+
// There is NO request input to buildHubOriginsEnvValue at all — it takes
|
|
157
|
+
// configDir + issuer + env + expose-state-path. None of those is a
|
|
158
|
+
// request header. We assert the function's surface offers no channel for
|
|
159
|
+
// a request Host: feeding the attacker value to the only inputs an
|
|
160
|
+
// attacker might influence (a stray env var, a header-shaped string)
|
|
161
|
+
// never reaches the set unless it's a legitimate operator-config var.
|
|
162
|
+
const set = parseHubOrigins(
|
|
163
|
+
buildHubOriginsEnvValue(
|
|
164
|
+
dir,
|
|
165
|
+
"https://example.com",
|
|
166
|
+
{
|
|
167
|
+
// Header-shaped env vars an attacker might hope are read — none are
|
|
168
|
+
// consulted by the assembler (only RENDER_EXTERNAL_URL / FLY_APP_NAME).
|
|
169
|
+
HTTP_HOST: ATTACKER,
|
|
170
|
+
HTTP_X_FORWARDED_HOST: ATTACKER,
|
|
171
|
+
X_FORWARDED_HOST: ATTACKER,
|
|
172
|
+
HOST: ATTACKER,
|
|
173
|
+
} as NodeJS.ProcessEnv,
|
|
174
|
+
EXPOSE(),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
expect(set).not.toContain(ATTACKER);
|
|
178
|
+
expect(set.some((o) => o.includes("attacker"))).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("the set is exactly issuer ∪ loopback ∪ expose ∪ platform — no other source", () => {
|
|
182
|
+
writeExposeState("https://box.sslip.io");
|
|
183
|
+
const set = parseHubOrigins(
|
|
184
|
+
buildHubOriginsEnvValue(
|
|
185
|
+
dir,
|
|
186
|
+
"https://example.com",
|
|
187
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
188
|
+
EXPOSE(),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
// Every member is one of the four sanctioned sources; nothing else.
|
|
192
|
+
const sanctioned = new Set([
|
|
193
|
+
"https://example.com",
|
|
194
|
+
"http://127.0.0.1:1939",
|
|
195
|
+
"http://localhost:1939",
|
|
196
|
+
"https://box.sslip.io",
|
|
197
|
+
"https://app.onrender.com",
|
|
198
|
+
]);
|
|
199
|
+
for (const o of set) expect(sanctioned.has(o)).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* THE CRUX (onboarding-streamline 2026-06-25, Caddy-direct zero-SSH path):
|
|
206
|
+
* a box whose ONLY canonical-origin source is the DB row `hub_settings.hub_origin`
|
|
207
|
+
* (no PARACHUTE_HUB_ORIGIN env, no expose-state, no RENDER/FLY platform var —
|
|
208
|
+
* the bare-droplet-behind-Caddy shape) MUST inject that public origin into the
|
|
209
|
+
* supervised modules' PARACHUTE_HUB_ORIGINS. Otherwise vault/scribe accept only
|
|
210
|
+
* loopback `iss` and reject every token the hub mints under the public origin
|
|
211
|
+
* (which the per-request resolveIssuer DOES stamp from the DB).
|
|
212
|
+
*
|
|
213
|
+
* These tests prove the full boot chain: DB row → `resolveStartupIssuer`
|
|
214
|
+
* (boot-time issuer seed) → `buildHubOriginsEnvValue` (the env injected at
|
|
215
|
+
* child spawn) CONTAINS the public origin.
|
|
216
|
+
*/
|
|
217
|
+
describe("DB hub_origin flows into the injected PARACHUTE_HUB_ORIGINS (Caddy-direct boot chain)", () => {
|
|
218
|
+
let dir: string;
|
|
219
|
+
const noExpose = (): string | undefined => undefined;
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
dir = mkdtempSync(join(tmpdir(), "hub-origins-db-"));
|
|
223
|
+
});
|
|
224
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
225
|
+
|
|
226
|
+
test("DB-persisted public origin lands in the assembled origin set", () => {
|
|
227
|
+
// Persist the Caddy public origin to the DB exactly as `hub set-origin` /
|
|
228
|
+
// `init --hub-origin` would.
|
|
229
|
+
const db = openHubDb(hubDbPath(dir));
|
|
230
|
+
setHubOrigin(db, "https://box.sslip.io");
|
|
231
|
+
db.close();
|
|
232
|
+
|
|
233
|
+
// Boot-time issuer resolution reads the DB row (passed as dbHubOrigin) —
|
|
234
|
+
// no env, no expose-state, no platform var (the bare-Caddy shape).
|
|
235
|
+
const dbOrigin = getHubOrigin(openHubDb(hubDbPath(dir))) ?? undefined;
|
|
236
|
+
const issuer = resolveStartupIssuer(
|
|
237
|
+
{ ...(dbOrigin !== undefined ? { dbHubOrigin: dbOrigin } : {}) },
|
|
238
|
+
{},
|
|
239
|
+
noExpose,
|
|
240
|
+
);
|
|
241
|
+
expect(issuer).toBe("https://box.sslip.io");
|
|
242
|
+
|
|
243
|
+
// That issuer seeds the env injected into vault/scribe — the public origin
|
|
244
|
+
// MUST be in their accepted-`iss` set.
|
|
245
|
+
const set = parseHubOrigins(
|
|
246
|
+
buildHubOriginsEnvValue(dir, issuer, {}, join(dir, "expose-state.json")),
|
|
247
|
+
);
|
|
248
|
+
expect(set).toContain("https://box.sslip.io");
|
|
249
|
+
// Loopback aliases stay present (co-located CLI / loopback proxy path).
|
|
250
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
251
|
+
expect(set).toContain("http://localhost:1939");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("no DB origin AND nothing else → loopback-only (the regression this guards: no public origin would leak in)", () => {
|
|
255
|
+
// Fresh DB, no row, no env, no expose-state: the boot issuer is undefined
|
|
256
|
+
// and the set is loopback-only. Proves the fix doesn't fabricate an origin.
|
|
257
|
+
const dbOrigin = getHubOrigin(openHubDb(hubDbPath(dir))) ?? undefined;
|
|
258
|
+
const issuer = resolveStartupIssuer(
|
|
259
|
+
{ ...(dbOrigin !== undefined ? { dbHubOrigin: dbOrigin } : {}) },
|
|
260
|
+
{},
|
|
261
|
+
noExpose,
|
|
262
|
+
);
|
|
263
|
+
expect(issuer).toBeUndefined();
|
|
264
|
+
const set = parseHubOrigins(
|
|
265
|
+
buildHubOriginsEnvValue(dir, issuer, {}, join(dir, "expose-state.json")),
|
|
266
|
+
);
|
|
267
|
+
// Loopback-only — no public origin fabricated. (Order is Set-insertion
|
|
268
|
+
// dependent; assert membership + size rather than a brittle exact array.)
|
|
269
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
270
|
+
expect(set).toContain("http://localhost:1939");
|
|
271
|
+
expect(set).toHaveLength(2);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -960,6 +960,154 @@ describe("hasNoDisplay heuristic (Fix 2 — headless browser-open guard)", () =>
|
|
|
960
960
|
});
|
|
961
961
|
});
|
|
962
962
|
|
|
963
|
+
describe("init --hub-origin (Caddy-direct zero-SSH boot, onboarding-streamline 2026-06-25)", () => {
|
|
964
|
+
test("persists the origin BEFORE ensureHub so modules spawn with it in one pass", async () => {
|
|
965
|
+
const h = makeHarness();
|
|
966
|
+
try {
|
|
967
|
+
const order: string[] = [];
|
|
968
|
+
const code = await init({
|
|
969
|
+
configDir: h.configDir,
|
|
970
|
+
ensureHubVersion: async () => ({
|
|
971
|
+
outcome: "match" as const,
|
|
972
|
+
installedVersion: "test",
|
|
973
|
+
messages: [],
|
|
974
|
+
}),
|
|
975
|
+
manifestPath: h.manifestPath,
|
|
976
|
+
log: () => {},
|
|
977
|
+
alive: () => false,
|
|
978
|
+
hubOrigin: "https://box.sslip.io",
|
|
979
|
+
// The load-bearing ordering assertion: the origin write MUST happen
|
|
980
|
+
// before the hub unit starts (which boots + spawns vault/scribe), so
|
|
981
|
+
// the boot-time issuer + child env pick it up without a restart.
|
|
982
|
+
setHubOriginImpl: (_dir, origin) => {
|
|
983
|
+
order.push(`set-origin:${origin}`);
|
|
984
|
+
},
|
|
985
|
+
ensureHub: async () => {
|
|
986
|
+
order.push("ensureHub");
|
|
987
|
+
writeHubPort(1939, h.configDir);
|
|
988
|
+
return { pid: 5555, port: 1939, started: true };
|
|
989
|
+
},
|
|
990
|
+
readExposeStateFn: () => undefined,
|
|
991
|
+
isTty: false,
|
|
992
|
+
platform: "linux",
|
|
993
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
994
|
+
});
|
|
995
|
+
expect(code).toBe(0);
|
|
996
|
+
expect(order).toEqual(["set-origin:https://box.sslip.io", "ensureHub"]);
|
|
997
|
+
} finally {
|
|
998
|
+
h.cleanup();
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("default impl writes hub_settings.hub_origin to the real DB", async () => {
|
|
1003
|
+
const h = makeHarness();
|
|
1004
|
+
try {
|
|
1005
|
+
const code = await init({
|
|
1006
|
+
configDir: h.configDir,
|
|
1007
|
+
ensureHubVersion: async () => ({
|
|
1008
|
+
outcome: "match" as const,
|
|
1009
|
+
installedVersion: "test",
|
|
1010
|
+
messages: [],
|
|
1011
|
+
}),
|
|
1012
|
+
manifestPath: h.manifestPath,
|
|
1013
|
+
log: () => {},
|
|
1014
|
+
alive: () => false,
|
|
1015
|
+
hubOrigin: "https://box.sslip.io",
|
|
1016
|
+
// No setHubOriginImpl override → exercise the production default
|
|
1017
|
+
// (openHubDb + setHubOrigin) against the harness's temp configDir.
|
|
1018
|
+
ensureHub: async () => {
|
|
1019
|
+
writeHubPort(1939, h.configDir);
|
|
1020
|
+
return { pid: 5555, port: 1939, started: true };
|
|
1021
|
+
},
|
|
1022
|
+
readExposeStateFn: () => undefined,
|
|
1023
|
+
isTty: false,
|
|
1024
|
+
platform: "linux",
|
|
1025
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
1026
|
+
});
|
|
1027
|
+
expect(code).toBe(0);
|
|
1028
|
+
const { hubDbPath, openHubDb } = await import("../hub-db.ts");
|
|
1029
|
+
const { getHubOrigin } = await import("../hub-settings.ts");
|
|
1030
|
+
const db = openHubDb(hubDbPath(h.configDir));
|
|
1031
|
+
try {
|
|
1032
|
+
expect(getHubOrigin(db)).toBe("https://box.sslip.io");
|
|
1033
|
+
} finally {
|
|
1034
|
+
db.close();
|
|
1035
|
+
}
|
|
1036
|
+
} finally {
|
|
1037
|
+
h.cleanup();
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("no --hub-origin → no persistence call (laptop/loopback path unchanged)", async () => {
|
|
1042
|
+
const h = makeHarness();
|
|
1043
|
+
try {
|
|
1044
|
+
let setCalls = 0;
|
|
1045
|
+
const code = await init({
|
|
1046
|
+
configDir: h.configDir,
|
|
1047
|
+
ensureHubVersion: async () => ({
|
|
1048
|
+
outcome: "match" as const,
|
|
1049
|
+
installedVersion: "test",
|
|
1050
|
+
messages: [],
|
|
1051
|
+
}),
|
|
1052
|
+
manifestPath: h.manifestPath,
|
|
1053
|
+
log: () => {},
|
|
1054
|
+
alive: () => false,
|
|
1055
|
+
setHubOriginImpl: () => {
|
|
1056
|
+
setCalls++;
|
|
1057
|
+
},
|
|
1058
|
+
ensureHub: async () => {
|
|
1059
|
+
writeHubPort(1939, h.configDir);
|
|
1060
|
+
return { pid: 5555, port: 1939, started: true };
|
|
1061
|
+
},
|
|
1062
|
+
readExposeStateFn: () => undefined,
|
|
1063
|
+
isTty: false,
|
|
1064
|
+
platform: "linux",
|
|
1065
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
1066
|
+
});
|
|
1067
|
+
expect(code).toBe(0);
|
|
1068
|
+
expect(setCalls).toBe(0);
|
|
1069
|
+
} finally {
|
|
1070
|
+
h.cleanup();
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("a persistence failure is non-fatal — init still reaches the wizard", async () => {
|
|
1075
|
+
const h = makeHarness();
|
|
1076
|
+
try {
|
|
1077
|
+
const logs: string[] = [];
|
|
1078
|
+
const code = await init({
|
|
1079
|
+
configDir: h.configDir,
|
|
1080
|
+
ensureHubVersion: async () => ({
|
|
1081
|
+
outcome: "match" as const,
|
|
1082
|
+
installedVersion: "test",
|
|
1083
|
+
messages: [],
|
|
1084
|
+
}),
|
|
1085
|
+
manifestPath: h.manifestPath,
|
|
1086
|
+
log: (l) => logs.push(l),
|
|
1087
|
+
alive: () => false,
|
|
1088
|
+
hubOrigin: "https://box.sslip.io",
|
|
1089
|
+
setHubOriginImpl: () => {
|
|
1090
|
+
throw new Error("disk full");
|
|
1091
|
+
},
|
|
1092
|
+
ensureHub: async () => {
|
|
1093
|
+
writeHubPort(1939, h.configDir);
|
|
1094
|
+
return { pid: 5555, port: 1939, started: true };
|
|
1095
|
+
},
|
|
1096
|
+
readExposeStateFn: () => undefined,
|
|
1097
|
+
isTty: false,
|
|
1098
|
+
platform: "linux",
|
|
1099
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
1100
|
+
});
|
|
1101
|
+
expect(code).toBe(0);
|
|
1102
|
+
const joined = logs.join("\n");
|
|
1103
|
+
expect(joined).toContain("Couldn't persist the hub origin");
|
|
1104
|
+
expect(joined).toContain("http://127.0.0.1:1939/admin/");
|
|
1105
|
+
} finally {
|
|
1106
|
+
h.cleanup();
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
963
1111
|
describe("init exposure chain", () => {
|
|
964
1112
|
test("TTY + no exposure + no flags → prompt is shown", async () => {
|
|
965
1113
|
const h = makeHarness();
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
RefreshTokenInsertError,
|
|
11
11
|
findRefreshToken,
|
|
12
12
|
findTokenRowByJti,
|
|
13
|
+
linkRotation,
|
|
13
14
|
listActiveRevocations,
|
|
15
|
+
liveFamilyRefreshRows,
|
|
14
16
|
recordTokenMint,
|
|
15
17
|
revokeTokenByJti,
|
|
16
18
|
signAccessToken,
|
|
@@ -623,3 +625,80 @@ describe("token registry (hub#212 Phase 1)", () => {
|
|
|
623
625
|
}
|
|
624
626
|
});
|
|
625
627
|
});
|
|
628
|
+
|
|
629
|
+
// hub#685 — rotation-grace primitives. The grace decision in
|
|
630
|
+
// `handleTokenRefresh` is built on these two helpers; unit-cover them
|
|
631
|
+
// directly so the security-load-bearing predecessor check has a tight test.
|
|
632
|
+
describe("rotation grace primitives (hub#685)", () => {
|
|
633
|
+
test("rotatedTo is null on a freshly minted row, set by linkRotation", async () => {
|
|
634
|
+
const { db, cleanup } = makeDb();
|
|
635
|
+
try {
|
|
636
|
+
rotateSigningKey(db);
|
|
637
|
+
const u = await createUser(db, "owner", "pw");
|
|
638
|
+
signRefreshToken(db, {
|
|
639
|
+
jti: "pred",
|
|
640
|
+
userId: u.id,
|
|
641
|
+
clientId: "parachute-hub",
|
|
642
|
+
scopes: ["vault:read"],
|
|
643
|
+
familyId: "fam-1",
|
|
644
|
+
});
|
|
645
|
+
expect(findTokenRowByJti(db, "pred")?.rotatedTo).toBeNull();
|
|
646
|
+
|
|
647
|
+
signRefreshToken(db, {
|
|
648
|
+
jti: "succ",
|
|
649
|
+
userId: u.id,
|
|
650
|
+
clientId: "parachute-hub",
|
|
651
|
+
scopes: ["vault:read"],
|
|
652
|
+
familyId: "fam-1",
|
|
653
|
+
});
|
|
654
|
+
linkRotation(db, "pred", "succ");
|
|
655
|
+
expect(findTokenRowByJti(db, "pred")?.rotatedTo).toBe("succ");
|
|
656
|
+
// Successor itself has not rotated yet.
|
|
657
|
+
expect(findTokenRowByJti(db, "succ")?.rotatedTo).toBeNull();
|
|
658
|
+
} finally {
|
|
659
|
+
cleanup();
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("liveFamilyRefreshRows returns only un-revoked, un-expired refresh rows in the family", async () => {
|
|
664
|
+
const { db, cleanup } = makeDb();
|
|
665
|
+
try {
|
|
666
|
+
rotateSigningKey(db);
|
|
667
|
+
const u = await createUser(db, "owner", "pw");
|
|
668
|
+
const t0 = new Date("2026-06-24T00:00:00Z");
|
|
669
|
+
for (const jti of ["a", "b", "c"]) {
|
|
670
|
+
signRefreshToken(db, {
|
|
671
|
+
jti,
|
|
672
|
+
userId: u.id,
|
|
673
|
+
clientId: "parachute-hub",
|
|
674
|
+
scopes: ["vault:read"],
|
|
675
|
+
familyId: "fam-x",
|
|
676
|
+
now: () => t0,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
// A different family — must never leak in.
|
|
680
|
+
signRefreshToken(db, {
|
|
681
|
+
jti: "other",
|
|
682
|
+
userId: u.id,
|
|
683
|
+
clientId: "parachute-hub",
|
|
684
|
+
scopes: ["vault:read"],
|
|
685
|
+
familyId: "fam-y",
|
|
686
|
+
now: () => t0,
|
|
687
|
+
});
|
|
688
|
+
// Revoke one of the three.
|
|
689
|
+
revokeTokenByJti(db, "b", t0);
|
|
690
|
+
|
|
691
|
+
const live = liveFamilyRefreshRows(db, "fam-x", t0)
|
|
692
|
+
.map((r) => r.jti)
|
|
693
|
+
.sort();
|
|
694
|
+
expect(live).toEqual(["a", "c"]);
|
|
695
|
+
|
|
696
|
+
// Evaluated PAST the 30-day TTL: every row in the family is expired,
|
|
697
|
+
// so none count as a live tip — the grace path can't rotate into one.
|
|
698
|
+
const afterExpiry = new Date(t0.getTime() + REFRESH_TOKEN_TTL_MS + 1);
|
|
699
|
+
expect(liveFamilyRefreshRows(db, "fam-x", afterExpiry)).toEqual([]);
|
|
700
|
+
} finally {
|
|
701
|
+
cleanup();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
});
|
|
@@ -277,11 +277,13 @@ describe("validateModuleManifest", () => {
|
|
|
277
277
|
// --- 2026-06-09 modular-UI architecture P1 fields: focus / configUiUrl /
|
|
278
278
|
// adminCapabilities / events / actions. All optional + additive. ---
|
|
279
279
|
|
|
280
|
-
test("focus accepts core / experimental and rejects anything else", () => {
|
|
280
|
+
test("focus accepts core / experimental / deprecated and rejects anything else", () => {
|
|
281
281
|
expect(validateModuleManifest({ ...VALID, focus: "core" }, "x").focus).toBe("core");
|
|
282
282
|
expect(validateModuleManifest({ ...VALID, focus: "experimental" }, "x").focus).toBe(
|
|
283
283
|
"experimental",
|
|
284
284
|
);
|
|
285
|
+
// `deprecated` tier (2026-06-25) — a module can self-declare it.
|
|
286
|
+
expect(validateModuleManifest({ ...VALID, focus: "deprecated" }, "x").focus).toBe("deprecated");
|
|
285
287
|
// Absent stays absent (hub falls back to its default map downstream).
|
|
286
288
|
expect(validateModuleManifest(VALID, "x").focus).toBeUndefined();
|
|
287
289
|
expect(() => validateModuleManifest({ ...VALID, focus: "headline" }, "x")).toThrow(/focus/);
|