@openparachute/hub 0.6.5-rc.8 → 0.7.1
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__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/api-tokens.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* "expires_at": "ISO-8601",
|
|
24
24
|
* "revoked_at": "ISO-8601" | null,
|
|
25
25
|
* "created_at": "ISO-8601",
|
|
26
|
-
* "created_via": "oauth_refresh" | "cli_mint" | "operator_mint",
|
|
26
|
+
* "created_via": "oauth_refresh" | "cli_mint" | "operator_mint" | "connection_provision",
|
|
27
27
|
* "permissions": "<json-string>" | null
|
|
28
28
|
* }
|
|
29
29
|
* ],
|
|
@@ -45,8 +45,10 @@
|
|
|
45
45
|
* - `created_via=<value>` — narrow by mint provenance. One of
|
|
46
46
|
* `oauth_refresh` (OAuth refresh-token rotation), `operator_mint`
|
|
47
47
|
* (operator-token rotation via `parachute auth rotate-operator`),
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* `cli_mint` (CLI / `POST /api/auth/mint-token`), or
|
|
49
|
+
* `connection_provision` (long-lived tokens the Connections engine
|
|
50
|
+
* mints — see admin-connections.ts). Powers the admin UI's
|
|
51
|
+
* "by source" filter pills (hub#212 Phase F).
|
|
50
52
|
*
|
|
51
53
|
* Why bearer-gated rather than session-cookie-gated: matches the rest
|
|
52
54
|
* of `/api/auth/*` (mint-token, revoke-token), so an automation client
|
|
@@ -149,14 +151,15 @@ export async function handleApiTokens(req: Request, deps: ApiTokensDeps): Promis
|
|
|
149
151
|
if (
|
|
150
152
|
createdViaParam === "oauth_refresh" ||
|
|
151
153
|
createdViaParam === "operator_mint" ||
|
|
152
|
-
createdViaParam === "cli_mint"
|
|
154
|
+
createdViaParam === "cli_mint" ||
|
|
155
|
+
createdViaParam === "connection_provision"
|
|
153
156
|
) {
|
|
154
157
|
createdVia = createdViaParam;
|
|
155
158
|
} else if (createdViaParam !== null) {
|
|
156
159
|
return jsonError(
|
|
157
160
|
400,
|
|
158
161
|
"invalid_request",
|
|
159
|
-
"created_via must be one of: oauth_refresh | operator_mint | cli_mint",
|
|
162
|
+
"created_via must be one of: oauth_refresh | operator_mint | cli_mint | connection_provision",
|
|
160
163
|
);
|
|
161
164
|
}
|
|
162
165
|
const cursor = url.searchParams.get("cursor");
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-UI audience gate (H3, surface-runtime design §12 — fixes
|
|
3
|
+
* parachute-surface#88: the `public` flag existed but nothing enforced it).
|
|
4
|
+
*
|
|
5
|
+
* A module's services.json row may carry a `uis{}` map of hosted UI
|
|
6
|
+
* sub-units; each sub-unit now declares an `audience`
|
|
7
|
+
* (`public | hub-users | operator | surface`, default `hub-users`). The HUB
|
|
8
|
+
* PROXY enforces it BEFORE forwarding — surface-host serves whatever the
|
|
9
|
+
* proxy lets through, exactly like the publicExposure cloak.
|
|
10
|
+
*
|
|
11
|
+
* Scope discipline: the gate covers the SURFACE UI MOUNTS specifically (the
|
|
12
|
+
* uis sub-unit paths), not every module path — a module's own APIs keep
|
|
13
|
+
* their own auth (vault validates Bearers, scribe validates its token, …).
|
|
14
|
+
* The gate also runs before WebSocket upgrades on a gated mount (threaded
|
|
15
|
+
* into `maybeUpgradeWebSocket`).
|
|
16
|
+
*
|
|
17
|
+
* The four audiences:
|
|
18
|
+
*
|
|
19
|
+
* public — pass (and the chrome strip is disabled — H5: public readers
|
|
20
|
+
* aren't hub users).
|
|
21
|
+
* hub-users — a valid hub session cookie OR a valid hub-issued Bearer
|
|
22
|
+
* whose scopes satisfy the sub-unit's `scopes_required`. The
|
|
23
|
+
* OR keeps installed PWAs working: a standalone PWA holds
|
|
24
|
+
* OAuth tokens, not a hub session. Bearer validation reuses
|
|
25
|
+
* the hub#516 seam (signature/expiry/revocation via the JWKS,
|
|
26
|
+
* `iss` ∈ the hub's bound-origin set — a PWA token carries the
|
|
27
|
+
* public origin while the proxied request may resolve the
|
|
28
|
+
* loopback issuer).
|
|
29
|
+
* operator — the first-admin session only. A Bearer never satisfies this
|
|
30
|
+
* tier (operator surfaces are interactive; the session is the
|
|
31
|
+
* operator's presence).
|
|
32
|
+
* surface — pass: the surface backend owns admission END-TO-END
|
|
33
|
+
* (backed surfaces — parachute-surface runtime, surfaces with
|
|
34
|
+
* their own server entry, kit-authenticated via
|
|
35
|
+
* @openparachute/surface-server: hub JWTs / capability links /
|
|
36
|
+
* anon, deny-by-default with a public conformance suite). The
|
|
37
|
+
* hub gating these would add a SECOND auth layer that BLOCKS
|
|
38
|
+
* the surface's own audience plane — e.g. the docs-editor's
|
|
39
|
+
* capability-link invitees are NOT hub users by design, so a
|
|
40
|
+
* hub-users gate would 302 them to /login before the surface's
|
|
41
|
+
* own auth ever ran. Chrome strip follows the `public`
|
|
42
|
+
* precedent (disabled — the visitors are mostly capability
|
|
43
|
+
* invitees, not hub users), and WS upgrades pass through too
|
|
44
|
+
* (the gate threads into `maybeUpgradeWebSocket`; null = no
|
|
45
|
+
* deny = the upgrade proceeds to the capability check).
|
|
46
|
+
*
|
|
47
|
+
* Deny shape: document requests (GET + Accept: text/html, no session) get a
|
|
48
|
+
* 302 to `/login?next=<path>`; everything else gets 401/403 JSON. A
|
|
49
|
+
* signed-in-but-insufficient caller (non-admin on an operator surface, or a
|
|
50
|
+
* Bearer missing the required scopes) gets 403, not a login redirect.
|
|
51
|
+
*
|
|
52
|
+
* Exposure layers are ORTHOGONAL to the audience: the `publicExposure`
|
|
53
|
+
* cloak runs at the service-row level BEFORE this gate (dispatch skips the
|
|
54
|
+
* gate entirely on a cloaked row so the 404 stays indistinguishable from
|
|
55
|
+
* not-installed). A `surface`-audience mount on a loopback-only row is
|
|
56
|
+
* therefore still unreachable from tailnet/funnel — exactly like `public`.
|
|
57
|
+
*
|
|
58
|
+
* Fail-closed posture: malformed `audience` metadata never reaches this
|
|
59
|
+
* module — `services-manifest.ts` validation rejects the row and the lenient
|
|
60
|
+
* read drops it (the mount 404s). An absent DB (hub booted stateless) denies
|
|
61
|
+
* every audience that needs an identity store: `hub-users` and `operator`.
|
|
62
|
+
* `public` and `surface` still pass — neither consults hub identity
|
|
63
|
+
* (`surface` self-auths every request; denying it on absent DB would break
|
|
64
|
+
* the surface for zero security gain). Version skew: a surface declaring
|
|
65
|
+
* `audience: "surface"` registered against an OLDER hub (pre-dating the
|
|
66
|
+
* value) gets its row rejected by manifest validation — the mount 404s
|
|
67
|
+
* until the hub upgrades; the surface-side meta-schema ships the value
|
|
68
|
+
* separately with a hub-version requirement note.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import type { Database } from "bun:sqlite";
|
|
72
|
+
import { validateHostAdminToken } from "./host-admin-token-validation.ts";
|
|
73
|
+
import type { ServiceEntry, UiAudience, UiSubUnit } from "./services-manifest.ts";
|
|
74
|
+
import { findActiveSession } from "./sessions.ts";
|
|
75
|
+
import { isFirstAdmin } from "./users.ts";
|
|
76
|
+
|
|
77
|
+
/** A pathname resolved to the UI sub-unit that hosts it. */
|
|
78
|
+
export interface UiMountMatch {
|
|
79
|
+
readonly entry: ServiceEntry;
|
|
80
|
+
readonly uiKey: string;
|
|
81
|
+
readonly ui: UiSubUnit;
|
|
82
|
+
/** The normalized mount path (no trailing slash) the match keyed on. */
|
|
83
|
+
readonly mount: string;
|
|
84
|
+
/** The effective audience (default applied). */
|
|
85
|
+
readonly audience: UiAudience;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The effective audience for a sub-unit — absent means `hub-users`. */
|
|
89
|
+
export function effectiveUiAudience(ui: UiSubUnit): UiAudience {
|
|
90
|
+
return ui.audience ?? "hub-users";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve which UI sub-unit (across every service's `uis{}` map) a pathname
|
|
95
|
+
* falls under. Longest-prefix match, same comparison shape as
|
|
96
|
+
* `findServiceUpstream` (trailing slashes normalized; `pathname === mount`
|
|
97
|
+
* or `pathname.startsWith(mount + "/")`). Returns undefined when the path is
|
|
98
|
+
* not under any declared UI — module API paths, undeclared mounts, and
|
|
99
|
+
* legacy flat rows are NOT gated here.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveUiMount(
|
|
102
|
+
services: readonly ServiceEntry[],
|
|
103
|
+
pathname: string,
|
|
104
|
+
): UiMountMatch | undefined {
|
|
105
|
+
let best: UiMountMatch | undefined;
|
|
106
|
+
for (const entry of services) {
|
|
107
|
+
if (!entry.uis) continue;
|
|
108
|
+
for (const [uiKey, ui] of Object.entries(entry.uis)) {
|
|
109
|
+
const norm = ui.path.replace(/\/+$/, "") || "/";
|
|
110
|
+
if (pathname === norm || pathname.startsWith(`${norm}/`)) {
|
|
111
|
+
if (!best || norm.length > best.mount.length) {
|
|
112
|
+
best = { entry, uiKey, ui, mount: norm, audience: effectiveUiAudience(ui) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return best;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Does one bearer scope satisfy one required-scope pattern?
|
|
122
|
+
*
|
|
123
|
+
* Patterns are colon-segmented with `*` as a single-segment wildcard —
|
|
124
|
+
* `vault:*:read` matches `vault:default:read`. One asymmetry is deliberate:
|
|
125
|
+
* the broad UNNAMED form (`vault:read`) satisfies `vault:*:<verb>` — a token
|
|
126
|
+
* carrying any-vault read authority is strictly wider than one pinned vault,
|
|
127
|
+
* so refusing it would deny a caller that holds MORE than required.
|
|
128
|
+
*/
|
|
129
|
+
export function scopeMatchesPattern(pattern: string, scope: string): boolean {
|
|
130
|
+
const p = pattern.split(":");
|
|
131
|
+
const s = scope.split(":");
|
|
132
|
+
if (p.length === s.length) {
|
|
133
|
+
return p.every((seg, i) => seg === "*" || seg === s[i]);
|
|
134
|
+
}
|
|
135
|
+
// Broad unnamed form: vault:<verb> ⊇ vault:*:<verb>.
|
|
136
|
+
if (p.length === 3 && s.length === 2 && p[1] === "*") {
|
|
137
|
+
return p[0] === s[0] && p[2] === s[1];
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Do the bearer's scopes satisfy the sub-unit's requirement? EVERY pattern
|
|
144
|
+
* in `scopes_required` must be matched by at least one bearer scope ("a
|
|
145
|
+
* Bearer whose scopes include the surface's scopes"). An empty/absent
|
|
146
|
+
* requirement means any valid hub-issued Bearer passes — the surface
|
|
147
|
+
* declared no scope shape, so hub identity alone is the bar.
|
|
148
|
+
*/
|
|
149
|
+
export function scopesSatisfyRequirement(
|
|
150
|
+
required: readonly string[] | undefined,
|
|
151
|
+
bearerScopes: readonly string[],
|
|
152
|
+
): boolean {
|
|
153
|
+
if (!required || required.length === 0) return true;
|
|
154
|
+
return required.every((pattern) => bearerScopes.some((s) => scopeMatchesPattern(pattern, s)));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface AudienceGateDeps {
|
|
158
|
+
/** Hub DB — absent (stateless boot) denies every hub-gated audience
|
|
159
|
+
* (`hub-users` / `operator`); `public` and `surface` pass without it. */
|
|
160
|
+
db: Database | undefined;
|
|
161
|
+
/**
|
|
162
|
+
* The hub's bound-origin set for Bearer `iss` validation (the hub#516
|
|
163
|
+
* seam — PWA tokens carry the public origin while the request may resolve
|
|
164
|
+
* loopback). Lazy: only consulted on the Bearer branch.
|
|
165
|
+
*/
|
|
166
|
+
knownIssuers: () => readonly string[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function wantsDocument(req: Request): boolean {
|
|
170
|
+
return req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function loginRedirect(req: Request): Response {
|
|
174
|
+
const url = new URL(req.url);
|
|
175
|
+
const next = encodeURIComponent(url.pathname + url.search);
|
|
176
|
+
return new Response(null, {
|
|
177
|
+
status: 302,
|
|
178
|
+
headers: { location: `/login?next=${next}`, "cache-control": "no-store" },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function denyJson(status: number, error: string, description: string): Response {
|
|
183
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
184
|
+
status,
|
|
185
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Enforce a sub-unit's audience for a request. Returns `null` when the
|
|
191
|
+
* request may proceed to the proxy, or the deny Response (302 login for
|
|
192
|
+
* anonymous document requests; 401/403 JSON otherwise).
|
|
193
|
+
*/
|
|
194
|
+
export async function gateUiAudience(
|
|
195
|
+
req: Request,
|
|
196
|
+
audience: UiAudience,
|
|
197
|
+
ui: UiSubUnit,
|
|
198
|
+
deps: AudienceGateDeps,
|
|
199
|
+
): Promise<Response | null> {
|
|
200
|
+
if (audience === "public") return null;
|
|
201
|
+
|
|
202
|
+
// `surface` — pass through unconditionally (incl. on an absent DB, below):
|
|
203
|
+
// the surface backend authenticates EVERY request itself (deny-by-default
|
|
204
|
+
// kit auth: hub JWTs / capability links / anon). A hub-side deny here
|
|
205
|
+
// would block the surface's own audience plane — its capability-link
|
|
206
|
+
// invitees are not hub users by design.
|
|
207
|
+
if (audience === "surface") return null;
|
|
208
|
+
|
|
209
|
+
// Fail closed without a DB: no identity store, no way to admit anyone to
|
|
210
|
+
// a hub-gated (`hub-users` / `operator`) surface.
|
|
211
|
+
if (!deps.db) {
|
|
212
|
+
return wantsDocument(req)
|
|
213
|
+
? loginRedirect(req)
|
|
214
|
+
: denyJson(401, "unauthenticated", "this surface requires a hub identity");
|
|
215
|
+
}
|
|
216
|
+
const db = deps.db;
|
|
217
|
+
|
|
218
|
+
const session = findActiveSession(db, req);
|
|
219
|
+
|
|
220
|
+
if (audience === "operator") {
|
|
221
|
+
if (session && isFirstAdmin(db, session.userId)) return null;
|
|
222
|
+
if (session) {
|
|
223
|
+
return denyJson(
|
|
224
|
+
403,
|
|
225
|
+
"not_admin",
|
|
226
|
+
"this surface is restricted to the hub operator — your account home is at /account/",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return wantsDocument(req)
|
|
230
|
+
? loginRedirect(req)
|
|
231
|
+
: denyJson(401, "unauthenticated", "this surface requires the hub operator's session");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// audience === "hub-users"
|
|
235
|
+
if (session) return null;
|
|
236
|
+
|
|
237
|
+
const auth = req.headers.get("authorization");
|
|
238
|
+
if (auth?.startsWith("Bearer ")) {
|
|
239
|
+
const token = auth.slice("Bearer ".length).trim();
|
|
240
|
+
try {
|
|
241
|
+
const validated = await validateHostAdminToken(db, token, [...deps.knownIssuers()]);
|
|
242
|
+
const bearerScopes =
|
|
243
|
+
typeof validated.payload.scope === "string"
|
|
244
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
245
|
+
: [];
|
|
246
|
+
if (scopesSatisfyRequirement(ui.scopes_required, bearerScopes)) return null;
|
|
247
|
+
return denyJson(
|
|
248
|
+
403,
|
|
249
|
+
"insufficient_scope",
|
|
250
|
+
`this surface requires scopes: ${(ui.scopes_required ?? []).join(", ")}`,
|
|
251
|
+
);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
return denyJson(
|
|
254
|
+
401,
|
|
255
|
+
"unauthenticated",
|
|
256
|
+
`bearer token invalid — ${err instanceof Error ? err.message : String(err)}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return wantsDocument(req)
|
|
262
|
+
? loginRedirect(req)
|
|
263
|
+
: denyJson(
|
|
264
|
+
401,
|
|
265
|
+
"unauthenticated",
|
|
266
|
+
"this surface requires a hub session or a hub-issued bearer token",
|
|
267
|
+
);
|
|
268
|
+
}
|
package/src/chrome-strip.ts
CHANGED
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* application, looks distinctively Notes, reads as Parachute because the
|
|
20
20
|
* tokens are continuous").
|
|
21
21
|
*
|
|
22
|
+
* H5 (surface-runtime design): the opt-out generalized — when a UI
|
|
23
|
+
* sub-unit's declared `audience` resolves `public` at the proxy's audience
|
|
24
|
+
* gate (H3), the dispatch passes that mount as an extra opt-out prefix
|
|
25
|
+
* (hub-server `decorateWithChrome`). Public readers aren't hub users; the
|
|
26
|
+
* identity chrome never rides their pages. The static list below remains
|
|
27
|
+
* for hub-users surfaces that own their own chrome (Notes).
|
|
28
|
+
*
|
|
22
29
|
* Why path-based and not module-declared:
|
|
23
30
|
* - Notes is a `uis[]` sub-unit of parachute-app, not its own module —
|
|
24
31
|
* adding `chrome: "off"` to parachute-app's module.json would suppress
|
|
@@ -38,7 +45,7 @@
|
|
|
38
45
|
* defense is cheap and protects future refactors).
|
|
39
46
|
*/
|
|
40
47
|
|
|
41
|
-
import {
|
|
48
|
+
import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
42
49
|
import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
|
|
43
50
|
|
|
44
51
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { rethrowIfMissing } from "@openparachute/depcheck";
|
|
4
4
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
@@ -609,6 +609,74 @@ export interface LogsOpts {
|
|
|
609
609
|
* Defaults to the group-aware `defaultAlive` (hub#88).
|
|
610
610
|
*/
|
|
611
611
|
alive?: AliveFn;
|
|
612
|
+
/**
|
|
613
|
+
* Filtered-follow source seam (hub#652) — the byte stream of lines appended
|
|
614
|
+
* to the hub log from attach onward. The production default spawns
|
|
615
|
+
* `tail -n 0 -f <hub.log>` with piped stdout so the `[<svc>] ` filter runs
|
|
616
|
+
* in-process; tests inject a deterministic stream.
|
|
617
|
+
*/
|
|
618
|
+
followStream?: (path: string) => ReadableStream<Uint8Array>;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Default `followStream`: tail the file from its end, stdout piped to us. */
|
|
622
|
+
function defaultFollowStream(path: string): ReadableStream<Uint8Array> {
|
|
623
|
+
try {
|
|
624
|
+
// Inherit env so `tail` sees PATH, etc. Bun.spawn defaults to empty
|
|
625
|
+
// env — see api-modules-ops.ts:defaultRun.
|
|
626
|
+
const proc = Bun.spawn(["tail", "-n", "0", "-f", path], {
|
|
627
|
+
stdin: "ignore",
|
|
628
|
+
stdout: "pipe",
|
|
629
|
+
stderr: "inherit",
|
|
630
|
+
env: process.env,
|
|
631
|
+
});
|
|
632
|
+
return proc.stdout;
|
|
633
|
+
} catch (err) {
|
|
634
|
+
// A missing `tail` (minimal container without coreutils) surfaces the
|
|
635
|
+
// friendly install UX instead of a raw spawn throw (cli.ts top-level
|
|
636
|
+
// catch renders the MissingDependencyError).
|
|
637
|
+
rethrowIfMissing(err, "tail");
|
|
638
|
+
throw err;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Pump a byte stream line-by-line, emitting only lines that carry `prefix`
|
|
644
|
+
* (stripped). Line-buffered the same way the supervisor's `pumpLines` is, so
|
|
645
|
+
* chunk boundaries inside a line don't drop or split matches.
|
|
646
|
+
*/
|
|
647
|
+
async function pumpFilteredLines(
|
|
648
|
+
stream: ReadableStream<Uint8Array>,
|
|
649
|
+
prefix: string,
|
|
650
|
+
output: (line: string) => void,
|
|
651
|
+
): Promise<void> {
|
|
652
|
+
const reader = stream.getReader();
|
|
653
|
+
const decoder = new TextDecoder();
|
|
654
|
+
let buf = "";
|
|
655
|
+
const emit = (line: string): void => {
|
|
656
|
+
if (line.startsWith(prefix)) output(line.slice(prefix.length));
|
|
657
|
+
};
|
|
658
|
+
try {
|
|
659
|
+
while (true) {
|
|
660
|
+
const { done, value } = await reader.read();
|
|
661
|
+
if (done) break;
|
|
662
|
+
buf += decoder.decode(value, { stream: true });
|
|
663
|
+
let nl = buf.indexOf("\n");
|
|
664
|
+
while (nl !== -1) {
|
|
665
|
+
emit(buf.slice(0, nl));
|
|
666
|
+
buf = buf.slice(nl + 1);
|
|
667
|
+
nl = buf.indexOf("\n");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (buf.length > 0) emit(buf);
|
|
671
|
+
} finally {
|
|
672
|
+
reader.releaseLock();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Split a log file's content into lines, dropping the trailing newline. */
|
|
677
|
+
function splitLogLines(content: string): string[] {
|
|
678
|
+
const trimmed = content.replace(/\n$/, "");
|
|
679
|
+
return trimmed === "" ? [] : trimmed.split("\n");
|
|
612
680
|
}
|
|
613
681
|
|
|
614
682
|
export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
@@ -620,11 +688,12 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
620
688
|
const alive = opts.alive ?? defaultAlive;
|
|
621
689
|
|
|
622
690
|
// logs only needs a valid short name to find the log file. First-party
|
|
623
|
-
// wins via the spec lookup; third-party rows match by `entry.name
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// the
|
|
627
|
-
//
|
|
691
|
+
// wins via the spec lookup; third-party rows match by `entry.name` (the
|
|
692
|
+
// same token the supervisor uses as its log prefix); the internal hub is
|
|
693
|
+
// a known short outside of services.json. installDir is irrelevant here —
|
|
694
|
+
// the log file is keyed by short name and exists once the service has
|
|
695
|
+
// run, regardless of how it was registered. We just need to confirm the
|
|
696
|
+
// name maps to something the CLI manages.
|
|
628
697
|
const isFirstParty = getSpec(svc) !== undefined;
|
|
629
698
|
if (!isFirstParty && svc !== HUB_SVC) {
|
|
630
699
|
const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
|
|
@@ -634,19 +703,111 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
634
703
|
}
|
|
635
704
|
}
|
|
636
705
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
706
|
+
// Per-file plain reader (the pre-#652 behavior): tail/print one log file.
|
|
707
|
+
const readPlain = async (path: string): Promise<number> => {
|
|
708
|
+
if (follow) {
|
|
709
|
+
const spawner = opts.tailSpawner ?? {
|
|
710
|
+
spawn(cmd) {
|
|
711
|
+
// Inherit env so `tail` sees PATH, etc. Bun.spawn defaults to empty
|
|
712
|
+
// env — see api-modules-ops.ts:defaultRun.
|
|
713
|
+
try {
|
|
714
|
+
const proc = Bun.spawn([...cmd], {
|
|
715
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
716
|
+
env: process.env,
|
|
717
|
+
});
|
|
718
|
+
return proc.pid;
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// A missing `tail` (minimal container without coreutils) surfaces
|
|
721
|
+
// the friendly install UX instead of a raw spawn throw. The CLI
|
|
722
|
+
// top-level catch in cli.ts renders the MissingDependencyError.
|
|
723
|
+
rethrowIfMissing(err, "tail");
|
|
724
|
+
throw err;
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
spawner.spawn(["tail", "-n", String(lines), "-f", path], path);
|
|
729
|
+
// tail runs until user Ctrl-C; block this process until it exits.
|
|
730
|
+
// When called from the real CLI, process.exit wraps us; in tests a
|
|
731
|
+
// stub spawner returns immediately and we fall through.
|
|
732
|
+
return 0;
|
|
733
|
+
}
|
|
734
|
+
// Non-follow path: read last N lines synchronously for a clean one-shot.
|
|
735
|
+
const tail = splitLogLines(await Bun.file(path).text()).slice(-lines);
|
|
736
|
+
for (const line of tail) log(line);
|
|
737
|
+
return 0;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const legacyPath = logPathFor(svc, configDir);
|
|
741
|
+
const hubLogPath = logPathFor(HUB_SVC, configDir);
|
|
742
|
+
const legacyExists = svc !== HUB_SVC && existsSync(legacyPath);
|
|
743
|
+
const hubLogExists = existsSync(hubLogPath);
|
|
744
|
+
|
|
745
|
+
// Source selection (hub#652): under hub-as-supervisor (Phase 5b) a module's
|
|
746
|
+
// stdout/stderr is multiplexed into the HUB log with a `[<svc>] ` line
|
|
747
|
+
// prefix (supervisor.ts pipeOutput) — the per-service file stops advancing
|
|
748
|
+
// at the cutover. Prefer whichever file is fresher: a pre-supervised
|
|
749
|
+
// install is still actively writing the per-service file (it wins); on a
|
|
750
|
+
// supervised box the hub log is the live stream and the per-service file
|
|
751
|
+
// is a stale remnant (it loses). `logs hub` always reads the hub log
|
|
752
|
+
// unfiltered — the interleaved prefixed stream IS the hub's own log.
|
|
753
|
+
const useHubStream =
|
|
754
|
+
svc !== HUB_SVC &&
|
|
755
|
+
hubLogExists &&
|
|
756
|
+
(!legacyExists || statSync(hubLogPath).mtimeMs >= statSync(legacyPath).mtimeMs);
|
|
757
|
+
|
|
758
|
+
if (!useHubStream) {
|
|
759
|
+
if (!existsSync(legacyPath)) {
|
|
760
|
+
// Distinguish "daemon never started" from "daemon is running but the
|
|
761
|
+
// log file is missing" (hub#335). The latter shape surfaces when a
|
|
762
|
+
// module self-registers + spawns its own logger without going through
|
|
763
|
+
// the hub (no hub-managed log file), or when an operator deletes the
|
|
764
|
+
// log mid-run. Previously both shapes printed the same
|
|
765
|
+
// `parachute start ${svc}` hint, leading operators to think their
|
|
766
|
+
// running daemon hadn't started.
|
|
767
|
+
const state = processState(svc, configDir, alive);
|
|
768
|
+
if (state.status === "running") {
|
|
769
|
+
const whereabouts =
|
|
770
|
+
svc === HUB_SVC
|
|
771
|
+
? `no log file at ${legacyPath}`
|
|
772
|
+
: `no log file at ${legacyPath} and no hub log at ${hubLogPath}`;
|
|
773
|
+
log(
|
|
774
|
+
`${svc} is running (pid ${state.pid}) but ${whereabouts}. The daemon may be writing logs elsewhere — check its stdout/stderr or its own log destination.`,
|
|
775
|
+
);
|
|
776
|
+
return 0;
|
|
777
|
+
}
|
|
778
|
+
log(`no logs yet for ${svc}. \`parachute start ${svc}\` to begin.`);
|
|
779
|
+
return 0;
|
|
780
|
+
}
|
|
781
|
+
return readPlain(legacyPath);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Supervised path (hub#652): the service's lines in the hub log, prefix
|
|
785
|
+
// stripped. Stripped rather than kept: every line would otherwise repeat
|
|
786
|
+
// the name the operator just typed, while the module's own output shape
|
|
787
|
+
// (e.g. surface's `[app-dcr]` sub-prefixes) stays intact — the same shape
|
|
788
|
+
// its per-service file had pre-cutover.
|
|
789
|
+
const prefix = `[${svc}] `;
|
|
790
|
+
const matched = splitLogLines(await Bun.file(hubLogPath).text())
|
|
791
|
+
.filter((l) => l.startsWith(prefix))
|
|
792
|
+
.map((l) => l.slice(prefix.length));
|
|
793
|
+
|
|
794
|
+
if (matched.length === 0 && !follow) {
|
|
795
|
+
if (legacyExists) {
|
|
796
|
+
// Transitional shape: nothing for this service in the hub log, but a
|
|
797
|
+
// (staler) per-service file exists — e.g. the module last ran detached,
|
|
798
|
+
// pre-cutover. Show it, with a note so a stale file isn't mistaken for
|
|
799
|
+
// the live stream (the exact hub#652 trap).
|
|
800
|
+
log(
|
|
801
|
+
`note: no ${svc} lines in the hub log (${hubLogPath}); showing the per-service log at ${legacyPath}.`,
|
|
802
|
+
);
|
|
803
|
+
return readPlain(legacyPath);
|
|
804
|
+
}
|
|
805
|
+
// Keep the hub#335 shapes coherent with the hub-stream source: a live
|
|
806
|
+
// pidfile here means a daemon the hub isn't logging for.
|
|
646
807
|
const state = processState(svc, configDir, alive);
|
|
647
808
|
if (state.status === "running") {
|
|
648
809
|
log(
|
|
649
|
-
`${svc} is running (pid ${state.pid}) but no log file at ${
|
|
810
|
+
`${svc} is running (pid ${state.pid}) but has no lines in the hub log (${hubLogPath}) and no log file at ${legacyPath}. The daemon may be writing logs elsewhere — check its stdout/stderr or its own log destination.`,
|
|
650
811
|
);
|
|
651
812
|
return 0;
|
|
652
813
|
}
|
|
@@ -654,38 +815,17 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
654
815
|
return 0;
|
|
655
816
|
}
|
|
656
817
|
|
|
818
|
+
for (const line of matched.slice(-lines)) log(line);
|
|
819
|
+
|
|
657
820
|
if (follow) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
});
|
|
667
|
-
return proc.pid;
|
|
668
|
-
} catch (err) {
|
|
669
|
-
// A missing `tail` (minimal container without coreutils) surfaces
|
|
670
|
-
// the friendly install UX instead of a raw spawn throw. The CLI
|
|
671
|
-
// top-level catch in cli.ts renders the MissingDependencyError.
|
|
672
|
-
rethrowIfMissing(err, "tail");
|
|
673
|
-
throw err;
|
|
674
|
-
}
|
|
675
|
-
},
|
|
676
|
-
};
|
|
677
|
-
spawner.spawn(["tail", "-n", String(lines), "-f", path], path);
|
|
678
|
-
// tail runs until user Ctrl-C; block this process until it exits.
|
|
679
|
-
// When called from the real CLI, process.exit wraps us; in tests a
|
|
680
|
-
// stub spawner returns immediately and we fall through.
|
|
681
|
-
return 0;
|
|
821
|
+
// Print the filtered backlog above, then follow new hub-log lines through
|
|
822
|
+
// the same filter. Runs until the tail is killed (Ctrl-C takes down the
|
|
823
|
+
// whole foreground process group); in tests the injected stream closes.
|
|
824
|
+
if (matched.length === 0) {
|
|
825
|
+
log(`(no prior ${svc} lines — waiting for new output…)`);
|
|
826
|
+
}
|
|
827
|
+
const source = opts.followStream ?? defaultFollowStream;
|
|
828
|
+
await pumpFilteredLines(source(hubLogPath), prefix, log);
|
|
682
829
|
}
|
|
683
|
-
|
|
684
|
-
// Non-follow path: read last N lines synchronously for a clean one-shot.
|
|
685
|
-
const content = await Bun.file(path).text();
|
|
686
|
-
const trimmed = content.replace(/\n$/, "");
|
|
687
|
-
const allLines = trimmed === "" ? [] : trimmed.split("\n");
|
|
688
|
-
const tail = allLines.slice(-lines);
|
|
689
|
-
for (const line of tail) log(line);
|
|
690
830
|
return 0;
|
|
691
831
|
}
|