@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
AUTH_IP_CEILING_MAX_ATTEMPTS,
|
|
3
4
|
CHANGE_PASSWORD_MAX_ATTEMPTS,
|
|
4
5
|
CHANGE_PASSWORD_WINDOW_MS,
|
|
5
6
|
MAX_ATTEMPTS,
|
|
@@ -7,9 +8,12 @@ import {
|
|
|
7
8
|
UNKNOWN_IP_SENTINEL,
|
|
8
9
|
WINDOW_MS,
|
|
9
10
|
__resetForTests,
|
|
11
|
+
authIpCeilingRateLimiter,
|
|
10
12
|
changePasswordRateLimiter,
|
|
11
13
|
checkAndRecord,
|
|
12
14
|
clientIpFromRequest,
|
|
15
|
+
compositeKey,
|
|
16
|
+
loginRateLimiter,
|
|
13
17
|
} from "../rate-limit.ts";
|
|
14
18
|
|
|
15
19
|
afterEach(() => {
|
|
@@ -302,3 +306,85 @@ describe("changePasswordRateLimiter — tighter floor for /account/change-passwo
|
|
|
302
306
|
expect(changePasswordRateLimiter.checkAndRecord("user-a", now).allowed).toBe(true);
|
|
303
307
|
});
|
|
304
308
|
});
|
|
309
|
+
|
|
310
|
+
// The shared-egress-IP fix: the per-account FLOOR is now keyed by
|
|
311
|
+
// `compositeKey(ip, identity)` so a room of users behind ONE NAT'd /
|
|
312
|
+
// Cloudflare egress IP doesn't pool into one 5-slot bucket. A coarse per-IP
|
|
313
|
+
// CEILING (60/15min) backstops username-rotation across the floors.
|
|
314
|
+
describe("compositeKey + shared-egress-IP login floor", () => {
|
|
315
|
+
test("normalizes identity: trims + lowercases so casing/whitespace share a bucket", () => {
|
|
316
|
+
expect(compositeKey("1.2.3.4", "Alice")).toBe("1.2.3.4|alice");
|
|
317
|
+
expect(compositeKey("1.2.3.4", " ALICE ")).toBe("1.2.3.4|alice");
|
|
318
|
+
// Case-flip evasion is closed: 'Alice' and 'alice' resolve to one key.
|
|
319
|
+
expect(compositeKey("1.2.3.4", "Alice")).toBe(compositeKey("1.2.3.4", "alice"));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// (a) REGRESSION: two distinct usernames from the SAME ip each get a full
|
|
323
|
+
// independent 5/15min floor (the shared-wifi bug). Before the fix both would
|
|
324
|
+
// have pooled into one per-IP bucket and the second user's 1st attempt would
|
|
325
|
+
// have been the 6th overall → 429.
|
|
326
|
+
test("(a) two usernames from the same IP each get an independent 5/15min floor", () => {
|
|
327
|
+
const ip = "203.0.113.7";
|
|
328
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
329
|
+
// Alice exhausts her floor.
|
|
330
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
331
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(true);
|
|
332
|
+
}
|
|
333
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(false);
|
|
334
|
+
// Bob (same IP, different username) still has a fresh full floor.
|
|
335
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
336
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "bob"), now).allowed).toBe(true);
|
|
337
|
+
}
|
|
338
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "bob"), now).allowed).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// (b) a single (ip,username) still denies on the 6th attempt.
|
|
342
|
+
test("(b) a single (ip,username) still denies on the 6th attempt", () => {
|
|
343
|
+
const ip = "203.0.113.7";
|
|
344
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
345
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
346
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(true);
|
|
347
|
+
}
|
|
348
|
+
const denied = loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now);
|
|
349
|
+
expect(denied.allowed).toBe(false);
|
|
350
|
+
expect(denied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// (c) the per-IP ceiling denies on the 61st attempt from one IP even across
|
|
354
|
+
// rotated usernames (each username's own floor never trips because each only
|
|
355
|
+
// sees one attempt, but the coarse ceiling caps total per-IP volume).
|
|
356
|
+
test("(c) per-IP ceiling denies the 61st attempt across rotated usernames", () => {
|
|
357
|
+
const ip = "203.0.113.7";
|
|
358
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
359
|
+
for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
|
|
360
|
+
// Rotate a fresh username every attempt so no per-account floor ever fills.
|
|
361
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, `u${i}`), now).allowed).toBe(true);
|
|
362
|
+
expect(authIpCeilingRateLimiter.checkAndRecord(ip, now).allowed).toBe(true);
|
|
363
|
+
}
|
|
364
|
+
// 61st attempt: a brand-new username (floor is fresh) but the ceiling is full.
|
|
365
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "u60"), now).allowed).toBe(true);
|
|
366
|
+
const ceilingDenied = authIpCeilingRateLimiter.checkAndRecord(ip, now);
|
|
367
|
+
expect(ceilingDenied.allowed).toBe(false);
|
|
368
|
+
expect(ceilingDenied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("the ceiling is per-IP: a different IP is unaffected by another's full ceiling", () => {
|
|
372
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
373
|
+
for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
|
|
374
|
+
authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now);
|
|
375
|
+
}
|
|
376
|
+
expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(false);
|
|
377
|
+
expect(authIpCeilingRateLimiter.checkAndRecord("198.51.100.99", now).allowed).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("ceiling matches the signup precedent (60) and `__resetForTests` clears it", () => {
|
|
381
|
+
expect(AUTH_IP_CEILING_MAX_ATTEMPTS).toBe(60);
|
|
382
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
383
|
+
for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
|
|
384
|
+
authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now);
|
|
385
|
+
}
|
|
386
|
+
expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(false);
|
|
387
|
+
__resetForTests();
|
|
388
|
+
expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -3,10 +3,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
|
+
MIN_RAM_MIB,
|
|
6
7
|
SCRIBE_DEFAULT_PROVIDER,
|
|
7
8
|
SCRIBE_PROVIDERS,
|
|
8
9
|
apiKeyEnvFor,
|
|
10
|
+
clearScribeProvider,
|
|
11
|
+
decideLocalProvider,
|
|
9
12
|
isKnownScribeProvider,
|
|
13
|
+
platformLocalProvider,
|
|
14
|
+
readAvailableRamMib,
|
|
10
15
|
readScribeProviderState,
|
|
11
16
|
scribeConfigPath,
|
|
12
17
|
scribeEnvPath,
|
|
@@ -41,6 +46,118 @@ describe("provider catalog", () => {
|
|
|
41
46
|
});
|
|
42
47
|
});
|
|
43
48
|
|
|
49
|
+
describe("platformLocalProvider — the Linux 'local' trap fix", () => {
|
|
50
|
+
test("macOS → parakeet-mlx", () => {
|
|
51
|
+
expect(platformLocalProvider("darwin")).toBe("parakeet-mlx");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("Linux → onnx-asr (NOT the macOS-only parakeet-mlx)", () => {
|
|
55
|
+
expect(platformLocalProvider("linux")).toBe("onnx-asr");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("unsupported platform → null (steer to cloud)", () => {
|
|
59
|
+
expect(platformLocalProvider("win32")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("readAvailableRamMib", () => {
|
|
64
|
+
test("non-Linux returns a positive number (totalmem fallback) or null", () => {
|
|
65
|
+
const ram = readAvailableRamMib("darwin");
|
|
66
|
+
// On any real CI/dev box totalmem is well-defined + positive.
|
|
67
|
+
expect(ram === null || (typeof ram === "number" && ram > 0)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("Linux path reads /proc/meminfo (or null when unreadable)", () => {
|
|
71
|
+
const ram = readAvailableRamMib("linux");
|
|
72
|
+
// On macOS CI there's no /proc/meminfo → null; on Linux CI a positive MiB.
|
|
73
|
+
expect(ram === null || (typeof ram === "number" && ram > 0)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("decideLocalProvider — the RAM/platform gate", () => {
|
|
78
|
+
test("Linux with ample RAM → ok, onnx-asr", () => {
|
|
79
|
+
const d = decideLocalProvider("linux", 4096);
|
|
80
|
+
expect(d.ok).toBe(true);
|
|
81
|
+
expect(d.provider).toBe("onnx-asr");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("macOS with ample RAM → ok, parakeet-mlx", () => {
|
|
85
|
+
const d = decideLocalProvider("darwin", 16384);
|
|
86
|
+
expect(d.ok).toBe(true);
|
|
87
|
+
expect(d.provider).toBe("parakeet-mlx");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("below the RAM floor → refused, steers to groq, carries a reason", () => {
|
|
91
|
+
const d = decideLocalProvider("linux", MIN_RAM_MIB - 1);
|
|
92
|
+
expect(d.ok).toBe(false);
|
|
93
|
+
expect(d.steerTo).toBe("groq");
|
|
94
|
+
expect(d.reason).toContain(String(MIN_RAM_MIB));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("exactly at the floor is OK (>= floor)", () => {
|
|
98
|
+
expect(decideLocalProvider("linux", MIN_RAM_MIB).ok).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("unknown RAM (null) does not refuse on a supported platform", () => {
|
|
102
|
+
expect(decideLocalProvider("linux", null).ok).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("unsupported platform → refused regardless of RAM, steers to groq", () => {
|
|
106
|
+
const d = decideLocalProvider("win32", 99999);
|
|
107
|
+
expect(d.ok).toBe(false);
|
|
108
|
+
expect(d.steerTo).toBe("groq");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("clearScribeProvider", () => {
|
|
113
|
+
test("removes transcribe.provider, preserving other keys", () => {
|
|
114
|
+
const h = makeHarness();
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(join(h.dir, "scribe"), { recursive: true });
|
|
117
|
+
writeFileSync(
|
|
118
|
+
scribeConfigPath(h.dir),
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
transcribe: { provider: "onnx-asr", language: "en" },
|
|
121
|
+
auth: { required_token: "keep" },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
clearScribeProvider(h.dir);
|
|
125
|
+
const parsed = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
|
|
126
|
+
expect(parsed.transcribe).toEqual({ language: "en" });
|
|
127
|
+
expect(parsed.auth).toEqual({ required_token: "keep" });
|
|
128
|
+
} finally {
|
|
129
|
+
h.cleanup();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("drops the transcribe block entirely when provider was its only key", () => {
|
|
134
|
+
const h = makeHarness();
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(join(h.dir, "scribe"), { recursive: true });
|
|
137
|
+
writeFileSync(
|
|
138
|
+
scribeConfigPath(h.dir),
|
|
139
|
+
JSON.stringify({ transcribe: { provider: "onnx-asr" }, auth: { required_token: "x" } }),
|
|
140
|
+
);
|
|
141
|
+
clearScribeProvider(h.dir);
|
|
142
|
+
const parsed = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
|
|
143
|
+
expect(parsed.transcribe).toBeUndefined();
|
|
144
|
+
expect(parsed.auth).toEqual({ required_token: "x" });
|
|
145
|
+
} finally {
|
|
146
|
+
h.cleanup();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("no-op when the file is absent", () => {
|
|
151
|
+
const h = makeHarness();
|
|
152
|
+
try {
|
|
153
|
+
// No file written.
|
|
154
|
+
expect(() => clearScribeProvider(h.dir)).not.toThrow();
|
|
155
|
+
} finally {
|
|
156
|
+
h.cleanup();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
44
161
|
describe("readScribeProviderState", () => {
|
|
45
162
|
test("missing file: configExists false, no provider", () => {
|
|
46
163
|
const h = makeHarness();
|
|
@@ -125,6 +125,51 @@ describe("bootSupervisedModules", () => {
|
|
|
125
125
|
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
test("forwards PARACHUTE_HUB_ORIGINS (the multi-origin iss-set) alongside the single origin", async () => {
|
|
129
|
+
// Multi-origin iss-set (onboarding-streamline 2026-06-25): a resource
|
|
130
|
+
// server on scope-guard ≥0.5.0 widens its accepted-`iss` check to this set
|
|
131
|
+
// so a token minted under one URL of a multi-URL box validates via another.
|
|
132
|
+
// The set always carries the issuer + loopback aliases (the deterministic
|
|
133
|
+
// members); expose-state / platform members vary by box.
|
|
134
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
135
|
+
const recorder = makeRecorder();
|
|
136
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
137
|
+
|
|
138
|
+
await bootSupervisedModules(sup, {
|
|
139
|
+
manifestPath: h.manifestPath,
|
|
140
|
+
configDir: h.dir,
|
|
141
|
+
hubOrigin: "https://hub.example",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const origins = recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS;
|
|
145
|
+
expect(origins).toBeDefined();
|
|
146
|
+
const set = (origins ?? "").split(",");
|
|
147
|
+
expect(set).toContain("https://hub.example");
|
|
148
|
+
// SECURITY: the set is hub-controlled (issuer + loopback + expose +
|
|
149
|
+
// platform) — never a request Host. Loopback aliases are always present.
|
|
150
|
+
expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
|
|
151
|
+
expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
|
|
152
|
+
// And the single canonical origin is still written for back-compat.
|
|
153
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("no PARACHUTE_HUB_ORIGINS when hubOrigin is unset (no widening, single-origin only)", async () => {
|
|
157
|
+
// Back-compat: a boot with no hubOrigin neither sets PARACHUTE_HUB_ORIGIN
|
|
158
|
+
// nor the set — the child keeps its own loopback default + scope-guard's
|
|
159
|
+
// single-origin behavior.
|
|
160
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
161
|
+
const recorder = makeRecorder();
|
|
162
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
163
|
+
|
|
164
|
+
await bootSupervisedModules(sup, {
|
|
165
|
+
manifestPath: h.manifestPath,
|
|
166
|
+
configDir: h.dir,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
170
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
128
173
|
test("sets PORT in child env from services.json entry (hub#357)", async () => {
|
|
129
174
|
// Container deploys (Render etc.) set PORT in hub's process.env via
|
|
130
175
|
// Dockerfile / platform injection. The supervisor's defaultSpawnFn
|
|
@@ -21,7 +21,9 @@ describe("hubServeOptions — the production listener wires the WS bridge", () =
|
|
|
21
21
|
// upgrades (the channel in-page terminal) 500'd through the hub
|
|
22
22
|
// ("set the websocket object in Bun.serve({})"). The listener MUST declare a
|
|
23
23
|
// websocket handler or `server.upgrade()` throws.
|
|
24
|
-
const fakeFetch = (() => new Response("ok")) as unknown as Parameters<
|
|
24
|
+
const fakeFetch = (() => new Response("ok")) as unknown as Parameters<
|
|
25
|
+
typeof hubServeOptions
|
|
26
|
+
>[0]["fetch"];
|
|
25
27
|
|
|
26
28
|
test("declares a websocket handler set (open/message/close)", () => {
|
|
27
29
|
const o = hubServeOptions({ port: 0, hostname: "127.0.0.1", fetch: fakeFetch });
|
|
@@ -404,6 +406,70 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
404
406
|
test("FLY_APP_NAME empty string → no fallback", () => {
|
|
405
407
|
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" }, noExpose)).toBeUndefined();
|
|
406
408
|
});
|
|
409
|
+
|
|
410
|
+
// onboarding-streamline 2026-06-25 — the Caddy-direct zero-SSH boot-issuer
|
|
411
|
+
// fix. The operator-set `hub_settings.hub_origin` (tier-1 in resolveIssuer)
|
|
412
|
+
// MUST also drive the boot-time issuer, else a box whose ONLY canonical-
|
|
413
|
+
// origin source is the DB row boots without an issuer and injects only
|
|
414
|
+
// loopback into supervised children's PARACHUTE_HUB_ORIGINS.
|
|
415
|
+
describe("dbHubOrigin tier (DB hub_settings.hub_origin)", () => {
|
|
416
|
+
test("dbHubOrigin wins over env/platform/expose (mirrors resolveIssuer tier-1)", () => {
|
|
417
|
+
const got = resolveStartupIssuer(
|
|
418
|
+
{ dbHubOrigin: "https://box.sslip.io" },
|
|
419
|
+
{
|
|
420
|
+
PARACHUTE_HUB_ORIGIN: "https://env.example",
|
|
421
|
+
RENDER_EXTERNAL_URL: "https://render.example.onrender.com",
|
|
422
|
+
},
|
|
423
|
+
() => "https://exposed.example",
|
|
424
|
+
);
|
|
425
|
+
expect(got).toBe("https://box.sslip.io");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("dbHubOrigin even wins over explicit opts.issuer (DB is the operator's deliberate canonical)", () => {
|
|
429
|
+
const got = resolveStartupIssuer(
|
|
430
|
+
{ issuer: "https://flag.example", dbHubOrigin: "https://box.sslip.io" },
|
|
431
|
+
{},
|
|
432
|
+
noExpose,
|
|
433
|
+
);
|
|
434
|
+
expect(got).toBe("https://box.sslip.io");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("dbHubOrigin trailing slash is stripped", () => {
|
|
438
|
+
expect(resolveStartupIssuer({ dbHubOrigin: "https://box.sslip.io/" }, {}, noExpose)).toBe(
|
|
439
|
+
"https://box.sslip.io",
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("a loopback dbHubOrigin is rejected (sanitized) → falls through to next tier", () => {
|
|
444
|
+
// A stray loopback value in the DB must NOT pin the issuer to a non-
|
|
445
|
+
// public origin; fall through to env so the box keeps a usable issuer.
|
|
446
|
+
const got = resolveStartupIssuer(
|
|
447
|
+
{ dbHubOrigin: "http://127.0.0.1:1939" },
|
|
448
|
+
{ PARACHUTE_HUB_ORIGIN: "https://env.example" },
|
|
449
|
+
noExpose,
|
|
450
|
+
);
|
|
451
|
+
expect(got).toBe("https://env.example");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a non-http(s) dbHubOrigin is rejected → falls through", () => {
|
|
455
|
+
const got = resolveStartupIssuer(
|
|
456
|
+
{ dbHubOrigin: "ftp://box.example" },
|
|
457
|
+
{},
|
|
458
|
+
() => "https://exposed.example",
|
|
459
|
+
);
|
|
460
|
+
expect(got).toBe("https://exposed.example");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("undefined dbHubOrigin is a no-op (unchanged precedence)", () => {
|
|
464
|
+
expect(
|
|
465
|
+
resolveStartupIssuer(
|
|
466
|
+
{ dbHubOrigin: undefined },
|
|
467
|
+
{ PARACHUTE_HUB_ORIGIN: "https://e.x" },
|
|
468
|
+
noExpose,
|
|
469
|
+
),
|
|
470
|
+
).toBe("https://e.x");
|
|
471
|
+
});
|
|
472
|
+
});
|
|
407
473
|
});
|
|
408
474
|
|
|
409
475
|
describe("resolveStartupIssuer — expose-state fallback (#531)", () => {
|
|
@@ -53,14 +53,34 @@ describe("focusForShort", () => {
|
|
|
53
53
|
expect(focusForShort("vault")).toBe("core");
|
|
54
54
|
expect(focusForShort("scribe")).toBe("core");
|
|
55
55
|
expect(focusForShort("surface")).toBe("core");
|
|
56
|
+
// agent stays a legit experimental preview — still offered on a fresh install.
|
|
56
57
|
expect(focusForShort("agent")).toBe("experimental");
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// notes (notes-daemon, deprecated 2026-05-22) + runner (per Aaron
|
|
59
|
+
// 2026-06-25, not for new installs) are `deprecated`: still resolvable +
|
|
60
|
+
// shown-if-installed, but NOT offered on a fresh setup.
|
|
61
|
+
expect(focusForShort("runner")).toBe("deprecated");
|
|
62
|
+
expect(focusForShort("notes")).toBe("deprecated");
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
test("unlisted shorts default to experimental", () => {
|
|
62
66
|
expect(focusForShort("some-third-party-module")).toBe("experimental");
|
|
63
67
|
});
|
|
68
|
+
|
|
69
|
+
test("a declared deprecated focus is honored over the default map", () => {
|
|
70
|
+
// A module can self-declare `deprecated` in its module.json.
|
|
71
|
+
expect(focusForShort("agent", "deprecated")).toBe("deprecated");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("deprecated shorts stay resolvable (discoverable) — back-compat for existing installs", () => {
|
|
75
|
+
// The deprecated tier de-emphasizes + drops the fresh-install OFFER; it does
|
|
76
|
+
// NOT remove the short from the resolution surface, so an existing
|
|
77
|
+
// notes/runner install keeps routing + lifecycle.
|
|
78
|
+
const shorts = discoverableShorts();
|
|
79
|
+
expect(shorts).toContain("notes");
|
|
80
|
+
expect(shorts).toContain("runner");
|
|
81
|
+
expect(isKnownModuleShort("notes")).toBe(true);
|
|
82
|
+
expect(isKnownModuleShort("runner")).toBe(true);
|
|
83
|
+
});
|
|
64
84
|
});
|
|
65
85
|
|
|
66
86
|
describe("isKnownModuleShort", () => {
|
|
@@ -1825,6 +1825,24 @@ describe("handleSetupVaultPost", () => {
|
|
|
1825
1825
|
expect(cfg?.cleanup).toEqual({ provider: "anthropic", default: true });
|
|
1826
1826
|
expect(cfg?.cleanupProviders).toEqual({ anthropic: { apiKey: "sk-ant-cleanup-key" } });
|
|
1827
1827
|
});
|
|
1828
|
+
|
|
1829
|
+
test("scribe transcribe=local resolves to the host platform's backend (the Linux trap fix)", async () => {
|
|
1830
|
+
// The bug: `local` mapped UNCONDITIONALLY to parakeet-mlx (macOS-only),
|
|
1831
|
+
// silently broken on every Linux box. After the fix it resolves to the
|
|
1832
|
+
// platform backend. We assert against the actual host so it's correct on
|
|
1833
|
+
// both Mac (parakeet-mlx) and Linux CI (onnx-asr). The RAM gate only kicks
|
|
1834
|
+
// in below 2 GB — CI/dev boxes clear it, so the choice stays `local`.
|
|
1835
|
+
const { platformLocalProvider } = await import("../scribe-config.ts");
|
|
1836
|
+
const expected = platformLocalProvider(process.platform);
|
|
1837
|
+
const { response } = await postVaultWithFields(h, { scribe_provider: "local" });
|
|
1838
|
+
expect(response.status).toBe(303);
|
|
1839
|
+
const cfg = readScribeConfig(h.dir);
|
|
1840
|
+
if (expected !== null) {
|
|
1841
|
+
// On a supported platform with enough RAM, `local` resolves to the
|
|
1842
|
+
// platform backend — NOT a hardcoded parakeet-mlx on Linux.
|
|
1843
|
+
expect(cfg?.transcribe).toEqual({ provider: expected });
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1828
1846
|
});
|
|
1829
1847
|
|
|
1830
1848
|
// --- end-to-end through hubFetch -----------------------------------------
|
|
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { InstallOpts } from "../commands/install.ts";
|
|
6
|
-
import { parseServicePicks, setup } from "../commands/setup.ts";
|
|
6
|
+
import { isOfferable, parseServicePicks, setup } from "../commands/setup.ts";
|
|
7
7
|
import { upsertService } from "../services-manifest.ts";
|
|
8
8
|
|
|
9
9
|
interface InstallCall {
|
|
@@ -110,6 +110,26 @@ describe("parseServicePicks", () => {
|
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
describe("isOfferable (fresh-install OFFER, 2026-06-25)", () => {
|
|
114
|
+
test("offers an uninstalled core/experimental module", () => {
|
|
115
|
+
expect(isOfferable({ short: "vault", installed: false })).toBe(true);
|
|
116
|
+
expect(isOfferable({ short: "scribe", installed: false })).toBe(true);
|
|
117
|
+
expect(isOfferable({ short: "surface", installed: false })).toBe(true);
|
|
118
|
+
// agent stays a legit experimental preview — still offered.
|
|
119
|
+
expect(isOfferable({ short: "agent", installed: false })).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("does NOT offer a deprecated module (notes / runner) on a fresh install", () => {
|
|
123
|
+
expect(isOfferable({ short: "notes", installed: false })).toBe(false);
|
|
124
|
+
expect(isOfferable({ short: "runner", installed: false })).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("never offers an already-installed module regardless of tier", () => {
|
|
128
|
+
expect(isOfferable({ short: "vault", installed: true })).toBe(false);
|
|
129
|
+
expect(isOfferable({ short: "notes", installed: true })).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
113
133
|
describe("setup", () => {
|
|
114
134
|
test("exits 0 with friendly note when every known service is installed", async () => {
|
|
115
135
|
const h = makeHarness();
|
|
@@ -155,6 +175,96 @@ describe("setup", () => {
|
|
|
155
175
|
}
|
|
156
176
|
});
|
|
157
177
|
|
|
178
|
+
test("fresh box: the offered 'Available to install' list excludes deprecated notes/runner (2026-06-25)", async () => {
|
|
179
|
+
const h = makeHarness();
|
|
180
|
+
try {
|
|
181
|
+
// 'all' picks every OFFERED service. With a clean services.json the survey
|
|
182
|
+
// sees every known short; the offered filter must drop notes + runner
|
|
183
|
+
// (deprecated) while keeping vault/scribe/surface/agent. Only vault +
|
|
184
|
+
// scribe have pre-install follow-up prompts (vault name, scribe provider);
|
|
185
|
+
// surface + agent have none — so the scripted answers below are complete.
|
|
186
|
+
const availability = scriptedAvailability([
|
|
187
|
+
"all", // pick everything offered
|
|
188
|
+
"default", // vault name (vault is in the offered set)
|
|
189
|
+
"1", // scribe provider
|
|
190
|
+
]);
|
|
191
|
+
const code = await setup({
|
|
192
|
+
manifestPath: h.manifestPath,
|
|
193
|
+
configDir: h.configDir,
|
|
194
|
+
log: (l) => h.logs.push(l),
|
|
195
|
+
availability,
|
|
196
|
+
installFn: async (short, opts) => {
|
|
197
|
+
h.calls.push({ short, opts });
|
|
198
|
+
return 0;
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
expect(code).toBe(0);
|
|
202
|
+
// The "Available to install" banner must NOT list notes / runner.
|
|
203
|
+
const joined = h.logs.join("\n");
|
|
204
|
+
const availableBlock = joined.slice(joined.indexOf("Available to install:"));
|
|
205
|
+
expect(availableBlock).not.toMatch(/\bnotes\b/);
|
|
206
|
+
expect(availableBlock).not.toMatch(/\brunner\b/);
|
|
207
|
+
// …and `install()` is never invoked for the deprecated shorts.
|
|
208
|
+
const installedShorts = h.calls.map((c) => c.short);
|
|
209
|
+
expect(installedShorts).not.toContain("notes");
|
|
210
|
+
expect(installedShorts).not.toContain("runner");
|
|
211
|
+
// The non-deprecated set is still offered + installed.
|
|
212
|
+
expect(installedShorts).toContain("vault");
|
|
213
|
+
expect(installedShorts).toContain("scribe");
|
|
214
|
+
expect(installedShorts).toContain("surface");
|
|
215
|
+
expect(installedShorts).toContain("agent");
|
|
216
|
+
} finally {
|
|
217
|
+
h.cleanup();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("an already-installed deprecated module still shows in 'Already installed' + isn't re-offered (back-compat)", async () => {
|
|
222
|
+
const h = makeHarness();
|
|
223
|
+
try {
|
|
224
|
+
// Legacy operator with runner (deprecated) on disk. It must surface in the
|
|
225
|
+
// "Already installed" banner (so they know it's there + can manage it via
|
|
226
|
+
// `parachute <verb> runner`), and must NOT reappear in the fresh-install
|
|
227
|
+
// OFFER list.
|
|
228
|
+
upsertService(
|
|
229
|
+
{
|
|
230
|
+
name: "parachute-runner",
|
|
231
|
+
version: "0.2.0",
|
|
232
|
+
port: 1945,
|
|
233
|
+
paths: ["/runner"],
|
|
234
|
+
health: "/runner/healthz",
|
|
235
|
+
},
|
|
236
|
+
h.manifestPath,
|
|
237
|
+
);
|
|
238
|
+
const availability = scriptedAvailability([
|
|
239
|
+
"surface", // pick a still-offered module
|
|
240
|
+
]);
|
|
241
|
+
const code = await setup({
|
|
242
|
+
manifestPath: h.manifestPath,
|
|
243
|
+
configDir: h.configDir,
|
|
244
|
+
log: (l) => h.logs.push(l),
|
|
245
|
+
availability,
|
|
246
|
+
installFn: async (short, opts) => {
|
|
247
|
+
h.calls.push({ short, opts });
|
|
248
|
+
return 0;
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
expect(code).toBe(0);
|
|
252
|
+
const joined = h.logs.join("\n");
|
|
253
|
+
// Banner lists runner as already installed…
|
|
254
|
+
const installedBlock = joined.slice(
|
|
255
|
+
joined.indexOf("Already installed:"),
|
|
256
|
+
joined.indexOf("Available to install:"),
|
|
257
|
+
);
|
|
258
|
+
expect(installedBlock).toMatch(/\brunner\b/);
|
|
259
|
+
// …but runner is NOT in the fresh-install offer.
|
|
260
|
+
const availableBlock = joined.slice(joined.indexOf("Available to install:"));
|
|
261
|
+
expect(availableBlock).not.toMatch(/\brunner\b/);
|
|
262
|
+
expect(h.calls.map((c) => c.short)).not.toContain("runner");
|
|
263
|
+
} finally {
|
|
264
|
+
h.cleanup();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
158
268
|
test("rejects non-TTY when there's work to offer", async () => {
|
|
159
269
|
const h = makeHarness();
|
|
160
270
|
try {
|
|
@@ -220,7 +330,9 @@ describe("setup", () => {
|
|
|
220
330
|
const h = makeHarness();
|
|
221
331
|
try {
|
|
222
332
|
const availability = scriptedAvailability([
|
|
223
|
-
|
|
333
|
+
// surface — a non-deprecated module with no follow-up prompts. (notes,
|
|
334
|
+
// the prior pick, is now `deprecated` → not in the offered set.)
|
|
335
|
+
"surface",
|
|
224
336
|
]);
|
|
225
337
|
const code = await setup({
|
|
226
338
|
manifestPath: h.manifestPath,
|
|
@@ -231,10 +343,10 @@ describe("setup", () => {
|
|
|
231
343
|
h.calls.push({ short, opts });
|
|
232
344
|
upsertService(
|
|
233
345
|
{
|
|
234
|
-
name: "parachute-
|
|
346
|
+
name: "parachute-surface",
|
|
235
347
|
version: "0.1.0",
|
|
236
|
-
port:
|
|
237
|
-
paths: ["/
|
|
348
|
+
port: 1946,
|
|
349
|
+
paths: ["/surface"],
|
|
238
350
|
health: "/health",
|
|
239
351
|
},
|
|
240
352
|
opts.manifestPath ?? h.manifestPath,
|
|
@@ -257,7 +369,8 @@ describe("setup", () => {
|
|
|
257
369
|
const h = makeHarness();
|
|
258
370
|
try {
|
|
259
371
|
const availability = scriptedAvailability([
|
|
260
|
-
|
|
372
|
+
// surface replaces the prior `notes` pick (now deprecated → not offered).
|
|
373
|
+
"vault, surface", // multi-select
|
|
261
374
|
"default", // vault name
|
|
262
375
|
]);
|
|
263
376
|
const code = await setup({
|
|
@@ -270,10 +383,10 @@ describe("setup", () => {
|
|
|
270
383
|
if (short === "vault") return 7; // fail vault
|
|
271
384
|
upsertService(
|
|
272
385
|
{
|
|
273
|
-
name: "parachute-
|
|
386
|
+
name: "parachute-surface",
|
|
274
387
|
version: "0.1.0",
|
|
275
|
-
port:
|
|
276
|
-
paths: ["/
|
|
388
|
+
port: 1946,
|
|
389
|
+
paths: ["/surface"],
|
|
277
390
|
health: "/health",
|
|
278
391
|
},
|
|
279
392
|
opts.manifestPath ?? h.manifestPath,
|
|
@@ -282,7 +395,7 @@ describe("setup", () => {
|
|
|
282
395
|
},
|
|
283
396
|
});
|
|
284
397
|
expect(code).toBe(7);
|
|
285
|
-
expect(h.calls.map((c) => c.short)).toEqual(["vault", "
|
|
398
|
+
expect(h.calls.map((c) => c.short)).toEqual(["vault", "surface"]);
|
|
286
399
|
expect(h.logs.join("\n")).toMatch(/non-zero exit code/);
|
|
287
400
|
} finally {
|
|
288
401
|
h.cleanup();
|
|
@@ -295,7 +408,8 @@ describe("setup", () => {
|
|
|
295
408
|
const availability = scriptedAvailability([
|
|
296
409
|
"9", // out-of-range index — loop re-prompts
|
|
297
410
|
"nope", // unknown name — loop re-prompts again
|
|
298
|
-
|
|
411
|
+
// surface — a non-deprecated single pick with no follow-up prompts.
|
|
412
|
+
"surface",
|
|
299
413
|
]);
|
|
300
414
|
const code = await setup({
|
|
301
415
|
manifestPath: h.manifestPath,
|
|
@@ -306,10 +420,10 @@ describe("setup", () => {
|
|
|
306
420
|
h.calls.push({ short, opts });
|
|
307
421
|
upsertService(
|
|
308
422
|
{
|
|
309
|
-
name: "parachute-
|
|
423
|
+
name: "parachute-surface",
|
|
310
424
|
version: "0.1.0",
|
|
311
|
-
port:
|
|
312
|
-
paths: ["/
|
|
425
|
+
port: 1946,
|
|
426
|
+
paths: ["/surface"],
|
|
313
427
|
health: "/health",
|
|
314
428
|
},
|
|
315
429
|
opts.manifestPath ?? h.manifestPath,
|
|
@@ -318,7 +432,7 @@ describe("setup", () => {
|
|
|
318
432
|
},
|
|
319
433
|
});
|
|
320
434
|
expect(code).toBe(0);
|
|
321
|
-
expect(h.calls.map((c) => c.short)).toEqual(["
|
|
435
|
+
expect(h.calls.map((c) => c.short)).toEqual(["surface"]);
|
|
322
436
|
expect(availability.remaining()).toBe(0);
|
|
323
437
|
const joined = h.logs.join("\n");
|
|
324
438
|
expect(joined).toMatch(/out-of-range/);
|