@openparachute/hub 0.7.7-rc.8 → 0.7.7
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 +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +6 -6
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2-12 — `/surface/notes` → `/surface/parachute` conditional alias.
|
|
3
|
+
*
|
|
4
|
+
* The condition is decided on `uis{}` MOUNT PATHS (the values
|
|
5
|
+
* `resolveUiMount` routes on), not map keys: fire only when no sub-unit
|
|
6
|
+
* resolves at/under `/surface/notes` AND one is mounted at exactly
|
|
7
|
+
* `/surface/parachute`. Unit tests pin the helper's condition + target
|
|
8
|
+
* shape; the integration tests drive `hubFetch` end-to-end to prove the
|
|
9
|
+
* dispatch placement (before the generic services proxy) and — the
|
|
10
|
+
* load-bearing half — that an EXISTING notes-ui install (a `/surface/notes`
|
|
11
|
+
* uis mount) passes through to its upstream untouched.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { hubFetch } from "../hub-server.ts";
|
|
18
|
+
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
19
|
+
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
20
|
+
import { isSurfaceNotesPath, maybeRedirectSurfaceNotes } from "../surface-notes-alias.ts";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Manifest fixtures
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** A surface-host row carrying the given uis map (or none). */
|
|
27
|
+
function surfaceRow(uis: ServiceEntry["uis"] | undefined, port = 1946): ServiceEntry {
|
|
28
|
+
const entry: ServiceEntry = {
|
|
29
|
+
name: "parachute-surface",
|
|
30
|
+
port,
|
|
31
|
+
paths: ["/surface"],
|
|
32
|
+
health: "/surface/healthz",
|
|
33
|
+
version: "0.4.0",
|
|
34
|
+
};
|
|
35
|
+
if (uis !== undefined) entry.uis = uis;
|
|
36
|
+
return entry;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Post-rename install: the app surface registered at /surface/parachute. */
|
|
40
|
+
const PARACHUTE_UIS: ServiceEntry["uis"] = {
|
|
41
|
+
parachute: { displayName: "Parachute", path: "/surface/parachute", audience: "public" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Existing install: notes-ui still registered at /surface/notes. */
|
|
45
|
+
const NOTES_UIS: ServiceEntry["uis"] = {
|
|
46
|
+
notes: { displayName: "Notes", path: "/surface/notes", audience: "public" },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// isSurfaceNotesPath — boundary shape
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe("isSurfaceNotesPath", () => {
|
|
54
|
+
test("matches the bare mount, trailing slash, and sub-paths", () => {
|
|
55
|
+
expect(isSurfaceNotesPath("/surface/notes")).toBe(true);
|
|
56
|
+
expect(isSurfaceNotesPath("/surface/notes/")).toBe(true);
|
|
57
|
+
expect(isSurfaceNotesPath("/surface/notes/index.html")).toBe(true);
|
|
58
|
+
expect(isSurfaceNotesPath("/surface/notes/a/b?ignored-not-part-of-path")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("does NOT match sibling prefixes or other mounts", () => {
|
|
62
|
+
expect(isSurfaceNotesPath("/surface/notesy")).toBe(false);
|
|
63
|
+
expect(isSurfaceNotesPath("/surface/notes-archive/")).toBe(false);
|
|
64
|
+
expect(isSurfaceNotesPath("/surface/parachute")).toBe(false);
|
|
65
|
+
expect(isSurfaceNotesPath("/notes/")).toBe(false);
|
|
66
|
+
expect(isSurfaceNotesPath("/surface")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// maybeRedirectSurfaceNotes — the condition + target shape
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
describe("maybeRedirectSurfaceNotes", () => {
|
|
75
|
+
test("fires when /surface/parachute is mounted and /surface/notes is not", () => {
|
|
76
|
+
const services = [surfaceRow(PARACHUTE_UIS)];
|
|
77
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes", "", services)).toBe("/surface/parachute");
|
|
78
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/", "", services)).toBe("/surface/parachute/");
|
|
79
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/a/b.html", "", services)).toBe(
|
|
80
|
+
"/surface/parachute/a/b.html",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("preserves the query string on the target", () => {
|
|
85
|
+
const services = [surfaceRow(PARACHUTE_UIS)];
|
|
86
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/x", "?q=1&n=2", services)).toBe(
|
|
87
|
+
"/surface/parachute/x?q=1&n=2",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("INERT when a /surface/notes mount exists (existing notes-ui install)", () => {
|
|
92
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(NOTES_UIS)])).toBe(
|
|
93
|
+
undefined,
|
|
94
|
+
);
|
|
95
|
+
// ... even when a parachute mount ALSO exists (transitional both-present
|
|
96
|
+
// install) — a live legacy mount is never preempted.
|
|
97
|
+
const both = [surfaceRow({ ...NOTES_UIS, ...PARACHUTE_UIS })];
|
|
98
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", both)).toBe(undefined);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("INERT when no uis{} exist at all (pre-uis surface-host rows — today's live shape)", () => {
|
|
102
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(undefined)])).toBe(
|
|
103
|
+
undefined,
|
|
104
|
+
);
|
|
105
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [])).toBe(undefined);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("INERT when the target mount is absent — never redirects into a 404", () => {
|
|
109
|
+
// A `parachute` KEY whose path was customized elsewhere does not count:
|
|
110
|
+
// the redirect target is the literal /surface/parachute mount, so its
|
|
111
|
+
// existence (by path) is part of the condition.
|
|
112
|
+
const customized: ServiceEntry["uis"] = {
|
|
113
|
+
parachute: { displayName: "Parachute", path: "/surface/app", audience: "public" },
|
|
114
|
+
};
|
|
115
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(customized)])).toBe(
|
|
116
|
+
undefined,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("fires in the in-place-upgrade caveat: uis KEY still `notes`, PATH flipped to /surface/parachute", () => {
|
|
121
|
+
// Re-adding the renamed package over the old instance with
|
|
122
|
+
// `instance_name=notes` and no `mount_path` keeps the map key `notes`
|
|
123
|
+
// while the mount flips — exactly the orphaned-bookmark scenario this
|
|
124
|
+
// alias exists for. The path-based condition (not key-based) covers it.
|
|
125
|
+
const upgraded: ServiceEntry["uis"] = {
|
|
126
|
+
notes: { displayName: "Parachute", path: "/surface/parachute", audience: "public" },
|
|
127
|
+
};
|
|
128
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/x", "?a=1", [surfaceRow(upgraded)])).toBe(
|
|
129
|
+
"/surface/parachute/x?a=1",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a sub-unit mounted UNDER /surface/notes counts as the legacy identity resolving", () => {
|
|
134
|
+
// A deeper mount like /surface/notes/foo still serves real content under
|
|
135
|
+
// the legacy prefix; redirecting across it would hijack a live route.
|
|
136
|
+
const deep: ServiceEntry["uis"] = {
|
|
137
|
+
...PARACHUTE_UIS,
|
|
138
|
+
foo: { displayName: "Foo", path: "/surface/notes/foo", audience: "public" },
|
|
139
|
+
};
|
|
140
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/foo/x", "", [surfaceRow(deep)])).toBe(
|
|
141
|
+
undefined,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("non-matching pathnames return undefined regardless of manifest state", () => {
|
|
146
|
+
const services = [surfaceRow(PARACHUTE_UIS)];
|
|
147
|
+
expect(maybeRedirectSurfaceNotes("/surface/notesy", "", services)).toBe(undefined);
|
|
148
|
+
expect(maybeRedirectSurfaceNotes("/surface/parachute/x", "", services)).toBe(undefined);
|
|
149
|
+
expect(maybeRedirectSurfaceNotes("/notes/x", "", services)).toBe(undefined);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("normalizes trailing slashes on declared uis paths", () => {
|
|
153
|
+
const slashed: ServiceEntry["uis"] = {
|
|
154
|
+
parachute: { displayName: "Parachute", path: "/surface/parachute/", audience: "public" },
|
|
155
|
+
};
|
|
156
|
+
expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(slashed)])).toBe(
|
|
157
|
+
"/surface/parachute/x",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// hubFetch integration — dispatch placement + passthrough
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
interface Harness {
|
|
167
|
+
dir: string;
|
|
168
|
+
manifestPath: string;
|
|
169
|
+
cleanup: () => void;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function makeHarness(): Harness {
|
|
173
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-surface-notes-alias-"));
|
|
174
|
+
return {
|
|
175
|
+
dir,
|
|
176
|
+
manifestPath: join(dir, "services.json"),
|
|
177
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function req(path: string): Request {
|
|
182
|
+
return new Request(`http://127.0.0.1${path}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Loopback peer so the default publicExposure cloak never interferes —
|
|
186
|
+
// same shape audience-gate.test.ts uses.
|
|
187
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
188
|
+
|
|
189
|
+
function startEchoUpstream(): { port: number; stop: () => void } {
|
|
190
|
+
const server = Bun.serve({
|
|
191
|
+
port: 0,
|
|
192
|
+
hostname: "127.0.0.1",
|
|
193
|
+
fetch: (r) =>
|
|
194
|
+
new Response(JSON.stringify({ upstream: "notes-ui", path: new URL(r.url).pathname }), {
|
|
195
|
+
status: 200,
|
|
196
|
+
headers: { "content-type": "application/json" },
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
describe("hubFetch × surface-notes alias (W2-12)", () => {
|
|
203
|
+
test("301: /surface/notes/* → /surface/parachute/* when only the parachute mount exists (tail + query preserved)", async () => {
|
|
204
|
+
clearNotesRedirectLogState();
|
|
205
|
+
const h = makeHarness();
|
|
206
|
+
try {
|
|
207
|
+
writeManifest({ services: [surfaceRow(PARACHUTE_UIS)] }, h.manifestPath);
|
|
208
|
+
const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
209
|
+
const res = await f(req("/surface/notes/some/path?q=1&n=2"), fakeServer("127.0.0.1"));
|
|
210
|
+
expect(res.status).toBe(301);
|
|
211
|
+
expect(res.headers.get("location")).toBe("/surface/parachute/some/path?q=1&n=2");
|
|
212
|
+
} finally {
|
|
213
|
+
h.cleanup();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("301: bare /surface/notes → /surface/parachute", async () => {
|
|
218
|
+
clearNotesRedirectLogState();
|
|
219
|
+
const h = makeHarness();
|
|
220
|
+
try {
|
|
221
|
+
writeManifest({ services: [surfaceRow(PARACHUTE_UIS)] }, h.manifestPath);
|
|
222
|
+
const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
223
|
+
const res = await f(req("/surface/notes"), fakeServer("127.0.0.1"));
|
|
224
|
+
expect(res.status).toBe(301);
|
|
225
|
+
expect(res.headers.get("location")).toBe("/surface/parachute");
|
|
226
|
+
} finally {
|
|
227
|
+
h.cleanup();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("200 passthrough: an existing notes-ui install (notes uis mount) proxies to its upstream untouched", async () => {
|
|
232
|
+
clearNotesRedirectLogState();
|
|
233
|
+
const upstream = startEchoUpstream();
|
|
234
|
+
const h = makeHarness();
|
|
235
|
+
try {
|
|
236
|
+
writeManifest({ services: [surfaceRow(NOTES_UIS, upstream.port)] }, h.manifestPath);
|
|
237
|
+
const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
238
|
+
const res = await f(req("/surface/notes/index.html"), fakeServer("127.0.0.1"));
|
|
239
|
+
expect(res.status).toBe(200);
|
|
240
|
+
const body = (await res.json()) as { upstream: string; path: string };
|
|
241
|
+
// The blind proxy forwarded the ORIGINAL path — no rewrite rode along.
|
|
242
|
+
expect(body.upstream).toBe("notes-ui");
|
|
243
|
+
expect(body.path).toBe("/surface/notes/index.html");
|
|
244
|
+
} finally {
|
|
245
|
+
upstream.stop();
|
|
246
|
+
h.cleanup();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("404: /surface/notesy (sibling prefix) is untouched by the alias", async () => {
|
|
251
|
+
clearNotesRedirectLogState();
|
|
252
|
+
const h = makeHarness();
|
|
253
|
+
try {
|
|
254
|
+
// parachute-mount-only manifest, but NO row claims /surface/notesy —
|
|
255
|
+
// the boundary check keeps the alias out of the way and the dispatch
|
|
256
|
+
// falls through to the branded 404.
|
|
257
|
+
writeManifest(
|
|
258
|
+
{
|
|
259
|
+
services: [
|
|
260
|
+
{
|
|
261
|
+
name: "parachute-app",
|
|
262
|
+
port: 1944,
|
|
263
|
+
paths: ["/app"],
|
|
264
|
+
health: "/app",
|
|
265
|
+
version: "0.5.0",
|
|
266
|
+
uis: PARACHUTE_UIS,
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
h.manifestPath,
|
|
271
|
+
);
|
|
272
|
+
const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
273
|
+
const res = await f(req("/surface/notesy"), fakeServer("127.0.0.1"));
|
|
274
|
+
expect(res.status).toBe(404);
|
|
275
|
+
} finally {
|
|
276
|
+
h.cleanup();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("404 (not 301): no uis at all — today's live shape stays inert end-to-end", async () => {
|
|
281
|
+
clearNotesRedirectLogState();
|
|
282
|
+
const h = makeHarness();
|
|
283
|
+
try {
|
|
284
|
+
// A pre-uis surface row would normally proxy /surface/* blind; with no
|
|
285
|
+
// live upstream the proxy path yields a 502/404-class response — the
|
|
286
|
+
// assertion here is only that NO 301 fires. Use an empty manifest so
|
|
287
|
+
// the outcome is a deterministic 404.
|
|
288
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
289
|
+
const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
290
|
+
const res = await f(req("/surface/notes/"), fakeServer("127.0.0.1"));
|
|
291
|
+
expect(res.status).toBe(404);
|
|
292
|
+
} finally {
|
|
293
|
+
h.cleanup();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -82,6 +82,41 @@ describe("walkTranscriptionStep — mode resolution (the CLI question)", () => {
|
|
|
82
82
|
}
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
test("non-interactive stdin (no flag) DEFAULTS to none — never hangs on the prompt", async () => {
|
|
86
|
+
// Headless-hardening: with no `--transcribe-mode` flag AND a closed / non-
|
|
87
|
+
// interactive stdin, an interactive `prompt("Pick [1]:")` would busy-hang
|
|
88
|
+
// Bun's readline question() forever (the exact e2e wedge). Transcription is
|
|
89
|
+
// optional and documented as never-blocking, so resolveChoice DEFAULTS to
|
|
90
|
+
// "none" (with an honest log line) rather than throwing. We drive the real
|
|
91
|
+
// defaultPrompt (no `prompt` seam) and force isTTY=false for determinism;
|
|
92
|
+
// if the guard regressed, this test would HANG instead of passing.
|
|
93
|
+
const h = makeHarness();
|
|
94
|
+
const origIsTTY = process.stdin.isTTY;
|
|
95
|
+
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
|
|
96
|
+
try {
|
|
97
|
+
const r = recordingRunner();
|
|
98
|
+
const logs: string[] = [];
|
|
99
|
+
const code = await walkTranscriptionStep({
|
|
100
|
+
configDir: h.dir,
|
|
101
|
+
log: (l) => logs.push(l),
|
|
102
|
+
// no transcribeMode, no prompt seam → real defaultPrompt would be hit
|
|
103
|
+
runCommand: r.run,
|
|
104
|
+
platform: "linux",
|
|
105
|
+
});
|
|
106
|
+
expect(code).toBe(0);
|
|
107
|
+
expect(r.cmds).toEqual([]); // nothing installed
|
|
108
|
+
expect(readCfg(h.dir)).toBeUndefined(); // no provider recorded
|
|
109
|
+
expect(logs.join("\n")).toContain("not interactive");
|
|
110
|
+
expect(logs.join("\n")).toContain("Transcription off");
|
|
111
|
+
} finally {
|
|
112
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
113
|
+
value: origIsTTY,
|
|
114
|
+
configurable: true,
|
|
115
|
+
});
|
|
116
|
+
h.cleanup();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
85
120
|
test("interactive prompt: '3' then 'g' chooses groq cloud", async () => {
|
|
86
121
|
const h = makeHarness();
|
|
87
122
|
try {
|
|
@@ -655,4 +655,83 @@ describe("runCliWizard", () => {
|
|
|
655
655
|
"/admin/setup/expose",
|
|
656
656
|
]);
|
|
657
657
|
});
|
|
658
|
+
|
|
659
|
+
test("non-interactive stdin fails fast (no hang) when a required answer is missing", async () => {
|
|
660
|
+
// Headless-hardening: with stdin closed (cloud-init, `ssh host 'parachute
|
|
661
|
+
// setup-wizard …'`, run.sh-exec'd stages.sh) an unanswered prompt would
|
|
662
|
+
// busy-hang Bun's readline question() forever. defaultPrompt now throws a
|
|
663
|
+
// clear, flag-naming error instead. We drive the real defaultPrompt (no
|
|
664
|
+
// `prompt` seam) with the account username unsupplied, forcing the account
|
|
665
|
+
// step to prompt — and force isTTY=false so the assertion is deterministic
|
|
666
|
+
// regardless of how the test runner's stdin is wired.
|
|
667
|
+
const { fetchImpl } = makeFakeHub();
|
|
668
|
+
const origIsTTY = process.stdin.isTTY;
|
|
669
|
+
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
|
|
670
|
+
try {
|
|
671
|
+
await expect(
|
|
672
|
+
runCliWizard({
|
|
673
|
+
hubUrl: "http://127.0.0.1:1939",
|
|
674
|
+
log: () => {},
|
|
675
|
+
fetchImpl,
|
|
676
|
+
sleep: async () => {},
|
|
677
|
+
// no accountUsername → account step calls the real defaultPrompt.
|
|
678
|
+
accountPassword: "longpassword",
|
|
679
|
+
vaultMode: "skip",
|
|
680
|
+
exposeMode: "localhost",
|
|
681
|
+
}),
|
|
682
|
+
).rejects.toThrow(/stdin is not interactive/);
|
|
683
|
+
} finally {
|
|
684
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
685
|
+
value: origIsTTY,
|
|
686
|
+
configurable: true,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("headless runCliWizard with NO --transcribe-mode completes (transcription defaults to none, not a throw)", async () => {
|
|
692
|
+
// Regression guard for the review must-fix: `runCliWizard` used to forward
|
|
693
|
+
// its (throwing) `defaultPrompt` into the transcription step, so a headless
|
|
694
|
+
// run with account/vault/expose all supplied via flags but NO
|
|
695
|
+
// `--transcribe-mode` would THROW at the transcription step ("cannot prompt
|
|
696
|
+
// for: Pick [1]:") instead of defaulting to none. The wizard now forwards a
|
|
697
|
+
// prompt to the transcription step ONLY when a real seam is injected, so
|
|
698
|
+
// headless hits `resolveChoice`'s undefined-prompt non-TTY guard → none.
|
|
699
|
+
const { state, fetchImpl } = makeFakeHub();
|
|
700
|
+
const logs: string[] = [];
|
|
701
|
+
let ran = false;
|
|
702
|
+
const origIsTTY = process.stdin.isTTY;
|
|
703
|
+
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
|
|
704
|
+
try {
|
|
705
|
+
const code = await runCliWizard({
|
|
706
|
+
hubUrl: "http://127.0.0.1:1939",
|
|
707
|
+
log: (l) => logs.push(l),
|
|
708
|
+
fetchImpl,
|
|
709
|
+
sleep: async () => {},
|
|
710
|
+
accountUsername: "admin",
|
|
711
|
+
accountPassword: "longpassword",
|
|
712
|
+
vaultMode: "skip",
|
|
713
|
+
exposeMode: "localhost",
|
|
714
|
+
configDir: "/tmp/pcli-wizard-headless-none", // triggers the step; none writes nothing
|
|
715
|
+
// NO transcribeMode, NO prompt seam → headless must default to none.
|
|
716
|
+
transcribeRunCommand: async () => {
|
|
717
|
+
ran = true;
|
|
718
|
+
return 0;
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
expect(code).toBe(0); // completed, did NOT throw
|
|
722
|
+
expect(ran).toBe(false); // none → no scribe install
|
|
723
|
+
expect(logs.join("\n")).toContain("Transcription off");
|
|
724
|
+
// Full flow still walked account → vault → expose.
|
|
725
|
+
expect(state.posted.map((p) => p.path)).toEqual([
|
|
726
|
+
"/admin/setup/account",
|
|
727
|
+
"/admin/setup/vault",
|
|
728
|
+
"/admin/setup/expose",
|
|
729
|
+
]);
|
|
730
|
+
} finally {
|
|
731
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
732
|
+
value: origIsTTY,
|
|
733
|
+
configurable: true,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
});
|
|
658
737
|
});
|
package/src/account-api.ts
CHANGED
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
* ownership gate the cloud twin runs per-vault is trivially satisfied here.
|
|
34
34
|
*/
|
|
35
35
|
import type { Database } from "bun:sqlite";
|
|
36
|
+
// NOTE: this is a VALUE import — hub can't run without
|
|
37
|
+
// `@openparachute/door-contract` resolving at runtime, so it's a real
|
|
38
|
+
// `^0.6.0` runtime `dependency` (package.json, mirroring `@openparachute/depcheck`)
|
|
39
|
+
// and NOT a `workspace:*` devDependency. The published hub tarball doesn't ship
|
|
40
|
+
// `packages/`, so the npm-installed hub resolves door-contract from the registry.
|
|
41
|
+
// See RELEASING.md → "Releasing door-contract".
|
|
36
42
|
import {
|
|
37
43
|
type AccountBootstrap,
|
|
38
44
|
type ParachuteAccountDescriptor,
|
package/src/admin-connections.ts
CHANGED
|
@@ -1082,7 +1082,8 @@ async function renewCredentialConnection(
|
|
|
1082
1082
|
}
|
|
1083
1083
|
|
|
1084
1084
|
const auth = req.headers.get("authorization");
|
|
1085
|
-
|
|
1085
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
1086
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
1086
1087
|
return jsonError(
|
|
1087
1088
|
401,
|
|
1088
1089
|
"unauthenticated",
|
|
@@ -1257,7 +1258,8 @@ async function claimCredentialConnection(
|
|
|
1257
1258
|
}
|
|
1258
1259
|
|
|
1259
1260
|
const auth = req.headers.get("authorization");
|
|
1260
|
-
|
|
1261
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
1262
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
1261
1263
|
return jsonError(
|
|
1262
1264
|
401,
|
|
1263
1265
|
"unauthenticated",
|
package/src/admin-lock.ts
CHANGED
|
@@ -24,11 +24,10 @@
|
|
|
24
24
|
* The admin SPA + every module config UI get their working Bearer from one of
|
|
25
25
|
* the cookie-gated mint endpoints:
|
|
26
26
|
* - `GET /admin/host-admin-token` (the SPA's own Bearer)
|
|
27
|
-
* - `GET /admin/agent-token` (agent chat + config UIs)
|
|
28
27
|
* - `GET /admin/vault-admin-token/<name>`(per-vault admin SPA)
|
|
29
28
|
* - `GET /admin/module-token/<short>` (generic module config UI Bearer)
|
|
30
29
|
*
|
|
31
|
-
* All
|
|
30
|
+
* All three share the exact `parseSessionCookie → findSession → [isFirstAdmin]
|
|
32
31
|
* → signAccessToken` shape. Inserting {@link requireUnlocked} into each makes
|
|
33
32
|
* the lock cascade to EVERY admin surface with no per-module changes: when
|
|
34
33
|
* locked, the mint returns 423 and the relevant UI shows the lock screen / its
|
|
@@ -8,15 +8,14 @@
|
|
|
8
8
|
* hub frames/links those surfaces consistently (the Modules page "Configure"
|
|
9
9
|
* action). Each module-owned config UI, served behind the hub proxy to a
|
|
10
10
|
* logged-in portal operator, needs an admin-scoped hub Bearer to call its own
|
|
11
|
-
* `<short>:admin`-gated endpoints
|
|
12
|
-
*
|
|
13
|
-
* `/admin/vault-admin-token/<name
|
|
14
|
-
* other self-registered single-audience module, so the hub doesn't grow a
|
|
11
|
+
* `<short>:admin`-gated endpoints. This generic mint covers every
|
|
12
|
+
* self-registered single-audience module, while the vault admin SPA uses
|
|
13
|
+
* `/admin/vault-admin-token/<name>`, so the hub doesn't grow a
|
|
15
14
|
* bespoke per-module mint endpoint as each module ships a config UI.
|
|
16
15
|
*
|
|
17
16
|
* Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
|
|
18
17
|
* prefix). Modules validate the JWT's `aud` against their literal short name
|
|
19
|
-
* (`scribe`, `surface`,
|
|
18
|
+
* (`scribe`, `surface`, or a third-party short) — the same shape `inferAudience`
|
|
20
19
|
* stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
|
|
21
20
|
* token are indistinguishable to the module. This mirrors the per-request
|
|
22
21
|
* `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
|
|
@@ -32,7 +31,7 @@
|
|
|
32
31
|
*
|
|
33
32
|
* Gate: the session must belong to the first admin (the single hub admin under
|
|
34
33
|
* the Phase 1 multi-user model — `users.ts:isFirstAdmin`), exactly like
|
|
35
|
-
* host-admin-token
|
|
34
|
+
* host-admin-token and vault-admin-token. A friend account holds
|
|
36
35
|
* a valid session but must not mint a module admin Bearer.
|
|
37
36
|
*
|
|
38
37
|
* Tokens are short-lived (10 min — matches the sibling admin-token mints); the
|
|
@@ -45,12 +44,12 @@ import {
|
|
|
45
44
|
type ModuleManifest,
|
|
46
45
|
readModuleManifest as defaultReadModuleManifest,
|
|
47
46
|
} from "./module-manifest.ts";
|
|
48
|
-
import { findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
|
|
47
|
+
import { RETIRED_MODULES, findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
|
|
49
48
|
import type { ServiceEntry } from "./services-manifest.ts";
|
|
50
49
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
51
50
|
import { isFirstAdmin } from "./users.ts";
|
|
52
51
|
|
|
53
|
-
/** Short TTL — matches
|
|
52
|
+
/** Short TTL — matches sibling admin-token mints. UI re-fetches on near-expiry. */
|
|
54
53
|
export const MODULE_TOKEN_TTL_SECONDS = 10 * 60;
|
|
55
54
|
const MODULE_TOKEN_CLIENT_ID = "parachute-hub-spa";
|
|
56
55
|
|
|
@@ -115,6 +114,12 @@ export async function handleModuleToken(
|
|
|
115
114
|
if (!MODULE_SHORT_RE.test(short)) {
|
|
116
115
|
return jsonError(400, "invalid_request", `module short "${short}" is not a valid identifier`);
|
|
117
116
|
}
|
|
117
|
+
// Retirement wins over the generic self-registration trust path. Otherwise a
|
|
118
|
+
// stale row plus readable module.json could keep minting after Hub support is
|
|
119
|
+
// deliberately removed.
|
|
120
|
+
if (RETIRED_MODULES[short]) {
|
|
121
|
+
return jsonError(404, "not_found", `no module "${short}" known to this hub`);
|
|
122
|
+
}
|
|
118
123
|
// Vault is per-instance — its admin scope needs a vault name. Route the caller
|
|
119
124
|
// to the dedicated per-vault endpoint rather than minting a useless bare
|
|
120
125
|
// `vault:admin` (no vault validates that audience).
|
package/src/admin-surfaces.ts
CHANGED
|
@@ -61,7 +61,8 @@ function jsonError(status: number, error: string, description: string): Response
|
|
|
61
61
|
/** Validate the operator bearer + require the surfaces scope. Mirrors api-modules-ops. */
|
|
62
62
|
async function authorize(req: Request, deps: AdminSurfacesDeps): Promise<Response | undefined> {
|
|
63
63
|
const auth = req.headers.get("authorization");
|
|
64
|
-
|
|
64
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
65
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
65
66
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
66
67
|
}
|
|
67
68
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-hub-upgrade.ts
CHANGED
|
@@ -158,7 +158,8 @@ interface ParsedBody {
|
|
|
158
158
|
|
|
159
159
|
async function authorize(req: Request, deps: ApiHubUpgradeDeps): Promise<Response | undefined> {
|
|
160
160
|
const auth = req.headers.get("authorization");
|
|
161
|
-
|
|
161
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
162
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
162
163
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
163
164
|
}
|
|
164
165
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-mint-token.ts
CHANGED
|
@@ -132,7 +132,8 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
132
132
|
|
|
133
133
|
// 1. Bearer presence + parsing.
|
|
134
134
|
const auth = req.headers.get("authorization");
|
|
135
|
-
|
|
135
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
136
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
136
137
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
137
138
|
}
|
|
138
139
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -308,7 +308,8 @@ export function parseModulesPath(pathname: string): PathMatch | undefined {
|
|
|
308
308
|
|
|
309
309
|
async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Response | undefined> {
|
|
310
310
|
const auth = req.headers.get("authorization");
|
|
311
|
-
|
|
311
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
312
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
312
313
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
313
314
|
}
|
|
314
315
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-modules.ts
CHANGED
|
@@ -82,7 +82,7 @@ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts"
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Resolve a known module to the display + install bootstrap data the admin SPA
|
|
85
|
-
* renders. Reads from FIRST_PARTY_FALLBACKS (notes) first,
|
|
85
|
+
* renders. Reads from FIRST_PARTY_FALLBACKS (notes / app) first,
|
|
86
86
|
* KNOWN_MODULES (vault / scribe / agent / surface) second.
|
|
87
87
|
*
|
|
88
88
|
* Returns `undefined` if the short is in neither table — a genuinely
|
|
@@ -351,15 +351,15 @@ interface ModuleWireShape {
|
|
|
351
351
|
* `.parachute/module.json` `configUiUrl`, joined against its mount path the
|
|
352
352
|
* same way `management_url` resolves `managementUrl`/`uiUrl`. Drives the
|
|
353
353
|
* Modules page's consistent **Configure** action — clicking lands the
|
|
354
|
-
* operator on the module's own config UI (
|
|
355
|
-
*
|
|
356
|
-
*
|
|
354
|
+
* operator on the module's own config UI (for example `/scribe/admin`),
|
|
355
|
+
* which mints its admin Bearer from the hub's cookie-gated
|
|
356
|
+
* `/admin/module-token/<short>`.
|
|
357
357
|
*
|
|
358
358
|
* Null when the module hasn't declared `configUiUrl` — the SPA omits the
|
|
359
359
|
* Configure action for that module rather than rendering a dead button.
|
|
360
360
|
* Distinct from `management_url`: a module may declare one, both, or
|
|
361
|
-
* neither.
|
|
362
|
-
*
|
|
361
|
+
* neither. Vault declares `managementUrl` (its admin SPA is the config
|
|
362
|
+
* surface).
|
|
363
363
|
*/
|
|
364
364
|
config_ui_url: string | null;
|
|
365
365
|
}
|
|
@@ -462,7 +462,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
462
462
|
|
|
463
463
|
// Bearer presence + parsing.
|
|
464
464
|
const auth = req.headers.get("authorization");
|
|
465
|
-
|
|
465
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
466
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
466
467
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
467
468
|
}
|
|
468
469
|
const bearer = auth.slice("Bearer ".length).trim();
|
|
@@ -780,7 +781,8 @@ export async function handleApiModulesChannel(
|
|
|
780
781
|
|
|
781
782
|
// Bearer presence + parsing.
|
|
782
783
|
const auth = req.headers.get("authorization");
|
|
783
|
-
|
|
784
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
785
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
784
786
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
785
787
|
}
|
|
786
788
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-revoke-token.ts
CHANGED
|
@@ -100,7 +100,8 @@ export async function handleApiRevokeToken(
|
|
|
100
100
|
|
|
101
101
|
// 1. Bearer presence + parsing.
|
|
102
102
|
const auth = req.headers.get("authorization");
|
|
103
|
-
|
|
103
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
104
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
104
105
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
105
106
|
}
|
|
106
107
|
const bearer = auth.slice("Bearer ".length).trim();
|
|
@@ -185,7 +185,8 @@ export async function handleApiSettingsHubOrigin(
|
|
|
185
185
|
// Bearer presence + parsing — identical shape to api-modules
|
|
186
186
|
// for consistency across hub-internal admin endpoints.
|
|
187
187
|
const auth = req.headers.get("authorization");
|
|
188
|
-
|
|
188
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
189
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
189
190
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
190
191
|
}
|
|
191
192
|
const bearer = auth.slice("Bearer ".length).trim();
|