@openparachute/hub 0.3.0-rc.1 → 0.5.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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
package/src/service-spec.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
|
|
2
3
|
import type { ServiceEntry } from "./services-manifest.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -74,6 +75,71 @@ export function isCanonicalPort(port: number): boolean {
|
|
|
74
75
|
*/
|
|
75
76
|
export type ServiceKind = "api" | "tool" | "frontend";
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Imperative behaviors that don't fit the static `module.json` schema.
|
|
80
|
+
*
|
|
81
|
+
* First-party only. Each first-party fallback declares its own extras
|
|
82
|
+
* alongside its embedded manifest; when the upstream module ships its own
|
|
83
|
+
* `.parachute/module.json`, the corresponding fallback entry — extras and
|
|
84
|
+
* manifest both — gets deleted in one PR per module.
|
|
85
|
+
*
|
|
86
|
+
* Third-party modules don't get extras: anything they need at install time
|
|
87
|
+
* has to fit the manifest contract (or live as a runtime concern at
|
|
88
|
+
* `/.parachute/info`). The boundary is intentional — extras is the seam
|
|
89
|
+
* for transitional behavior, not a permanent escape hatch.
|
|
90
|
+
*/
|
|
91
|
+
export interface FirstPartyExtras {
|
|
92
|
+
/** Init command spawned post-install (e.g., `["parachute-vault", "init"]`). */
|
|
93
|
+
readonly init?: readonly string[];
|
|
94
|
+
/**
|
|
95
|
+
* Override startCmd to take the per-install services.json entry. Used by
|
|
96
|
+
* notes (which needs `--port` + `--mount` derived from the entry); plain
|
|
97
|
+
* static-argv `manifest.startCmd` covers everything else.
|
|
98
|
+
*/
|
|
99
|
+
readonly startCmd?: (entry: ServiceEntry) => readonly string[] | undefined;
|
|
100
|
+
/** Lines printed at the end of `parachute install <svc>`. */
|
|
101
|
+
readonly postInstallFooter?: () => readonly string[];
|
|
102
|
+
/**
|
|
103
|
+
* Does the service gate its endpoints behind auth today? Drives
|
|
104
|
+
* `effectivePublicExposure`'s default for api/tool services. True for
|
|
105
|
+
* vault/channel; conservatively false for scribe until its auth-gate ships.
|
|
106
|
+
*/
|
|
107
|
+
readonly hasAuth?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Override the canonical reachable URL for `parachute status`. Most
|
|
110
|
+
* services use `port + paths[0]`; vault appends `/mcp`, scribe is at root.
|
|
111
|
+
*/
|
|
112
|
+
readonly urlForEntry?: (entry: ServiceEntry) => string | undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Vendored fallback for a first-party module.
|
|
117
|
+
*
|
|
118
|
+
* The CLI prefers the installed module's own `.parachute/module.json` when
|
|
119
|
+
* present and falls back to this embedded manifest otherwise. The plan is
|
|
120
|
+
* to delete each fallback as its upstream module starts shipping the real
|
|
121
|
+
* file — see the `// FALLBACK: Delete when ...` markers below for the
|
|
122
|
+
* specific upstream reference per entry.
|
|
123
|
+
*
|
|
124
|
+
* Third-party modules never have a fallback; they ship `module.json` or
|
|
125
|
+
* the install hard-errors.
|
|
126
|
+
*/
|
|
127
|
+
export interface FirstPartyFallback {
|
|
128
|
+
/** npm package name for `bun add -g`. */
|
|
129
|
+
readonly package: string;
|
|
130
|
+
/** Embedded module.json — used when the install dir has no `.parachute/module.json`. */
|
|
131
|
+
readonly manifest: ModuleManifest;
|
|
132
|
+
/** Imperative behaviors not expressible in module.json. Optional. */
|
|
133
|
+
readonly extras?: FirstPartyExtras;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Façade combining a module's manifest with its install-time extras. All
|
|
138
|
+
* consumers (install, lifecycle, status, expose) read this — they don't
|
|
139
|
+
* care whether it came from a vendored fallback or a real
|
|
140
|
+
* `.parachute/module.json`. Non-readonly nothing — every field is read-only
|
|
141
|
+
* from the consumer's perspective.
|
|
142
|
+
*/
|
|
77
143
|
export interface ServiceSpec {
|
|
78
144
|
readonly package: string;
|
|
79
145
|
readonly manifestName: string;
|
|
@@ -81,55 +147,23 @@ export interface ServiceSpec {
|
|
|
81
147
|
/**
|
|
82
148
|
* Command to spawn for `parachute start <svc>`. Receives the services.json
|
|
83
149
|
* entry so commands that need per-install data (e.g., the notes static-serve
|
|
84
|
-
* shim needs the configured port) can pull it from there.
|
|
85
|
-
*
|
|
86
|
-
* Returns `undefined` to declare "lifecycle not supported for this service."
|
|
87
|
-
* That never applies today but leaves a seam for future services that
|
|
88
|
-
* shouldn't be managed by `parachute start`.
|
|
150
|
+
* shim needs the configured port) can pull it from there. Returns
|
|
151
|
+
* `undefined` to declare "lifecycle not supported for this service."
|
|
89
152
|
*/
|
|
90
153
|
readonly startCmd?: (entry: ServiceEntry) => readonly string[] | undefined;
|
|
91
154
|
/**
|
|
92
155
|
* Canonical initial services.json entry used when the service hasn't
|
|
93
|
-
* written its own
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* plan against. First service boot overwrites the seed with its own
|
|
100
|
-
* authoritative version.
|
|
156
|
+
* written its own. Fires post-install only if `findService` returns
|
|
157
|
+
* undefined — normal npm installs hit this almost never (the service's
|
|
158
|
+
* init or first boot writes the authoritative entry first). Main use case:
|
|
159
|
+
* `bun link` local-dev installs where the service hasn't run yet but
|
|
160
|
+
* `parachute expose` / `parachute start` need an entry to plan against.
|
|
161
|
+
* First service boot overwrites the seed with its own authoritative version.
|
|
101
162
|
*/
|
|
102
163
|
readonly seedEntry?: () => ServiceEntry;
|
|
103
|
-
|
|
104
|
-
* Declares the service's broad shape. Drives exposure defaults: api/tool
|
|
105
|
-
* services without auth fall back to `publicExposure: "auth-required"`
|
|
106
|
-
* (treated as loopback at launch); frontends default to "allowed".
|
|
107
|
-
*/
|
|
108
|
-
readonly kind?: ServiceKind;
|
|
109
|
-
/**
|
|
110
|
-
* Does the service gate its endpoints behind auth today? Used together with
|
|
111
|
-
* `kind` to pick a safe default when the services.json entry omits
|
|
112
|
-
* `publicExposure`. True for vault/channel (owner-authenticated);
|
|
113
|
-
* conservatively false for scribe until its auth-gate ships.
|
|
114
|
-
*/
|
|
164
|
+
readonly kind: ServiceKind;
|
|
115
165
|
readonly hasAuth?: boolean;
|
|
116
|
-
/**
|
|
117
|
-
* Canonical reachable URL for the service given its manifest entry. Drives
|
|
118
|
-
* the URL column in `parachute status` and any other place we need to
|
|
119
|
-
* render "where do I point a client?". Most services use port + paths[0],
|
|
120
|
-
* but some need to append a fixed suffix (vault's MCP endpoint lives at
|
|
121
|
-
* `/vault/<name>/mcp`, not the bare mount path).
|
|
122
|
-
*
|
|
123
|
-
* Returns undefined when the entry doesn't carry enough info — callers
|
|
124
|
-
* should fall back to the bare `http://127.0.0.1:<port>` form.
|
|
125
|
-
*/
|
|
126
166
|
readonly urlForEntry?: (entry: ServiceEntry) => string | undefined;
|
|
127
|
-
/**
|
|
128
|
-
* Lines printed at the end of `parachute install <svc>` so the user has a
|
|
129
|
-
* clear next step. Vault's footer comes from `parachute-vault init` itself
|
|
130
|
-
* (PR #166) — richer because it can read the freshly-minted API token —
|
|
131
|
-
* so vault's spec leaves this off.
|
|
132
|
-
*/
|
|
133
167
|
readonly postInstallFooter?: () => readonly string[];
|
|
134
168
|
}
|
|
135
169
|
|
|
@@ -150,45 +184,118 @@ function pathBasedUrl(entry: ServiceEntry): string {
|
|
|
150
184
|
return `http://127.0.0.1:${entry.port}${path}`;
|
|
151
185
|
}
|
|
152
186
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Build a services.json seed row from a module manifest. Pure: doesn't
|
|
189
|
+
* read the filesystem. The `version` is intentionally `0.0.0-linked` to
|
|
190
|
+
* telegraph "stopgap" — the service's own boot overwrites this entry.
|
|
191
|
+
*/
|
|
192
|
+
export function seedEntryFromManifest(manifest: ModuleManifest): ServiceEntry {
|
|
193
|
+
const entry: ServiceEntry = {
|
|
194
|
+
name: manifest.manifestName,
|
|
195
|
+
port: manifest.port,
|
|
196
|
+
paths: [...manifest.paths],
|
|
197
|
+
health: manifest.health,
|
|
198
|
+
version: SEED_VERSION,
|
|
199
|
+
};
|
|
200
|
+
if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
|
|
201
|
+
if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
|
|
202
|
+
return entry;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build the runtime ServiceSpec façade from a manifest + optional extras.
|
|
207
|
+
* Used by both the first-party-fallback path and the
|
|
208
|
+
* read-installed-`module.json` path so both produce identical specs.
|
|
209
|
+
*/
|
|
210
|
+
export function composeServiceSpec(opts: {
|
|
211
|
+
packageName: string;
|
|
212
|
+
manifest: ModuleManifest;
|
|
213
|
+
extras?: FirstPartyExtras;
|
|
214
|
+
}): ServiceSpec {
|
|
215
|
+
const { packageName, manifest, extras } = opts;
|
|
216
|
+
const startCmd = extras?.startCmd ?? (manifest.startCmd ? () => manifest.startCmd : undefined);
|
|
217
|
+
const spec: ServiceSpec = {
|
|
218
|
+
package: packageName,
|
|
219
|
+
manifestName: manifest.manifestName,
|
|
220
|
+
seedEntry: () => seedEntryFromManifest(manifest),
|
|
221
|
+
kind: manifest.kind,
|
|
222
|
+
};
|
|
223
|
+
if (extras?.init !== undefined) (spec as { init?: readonly string[] }).init = extras.init;
|
|
224
|
+
if (startCmd !== undefined) {
|
|
225
|
+
(spec as { startCmd?: (e: ServiceEntry) => readonly string[] | undefined }).startCmd = startCmd;
|
|
226
|
+
}
|
|
227
|
+
if (extras?.hasAuth !== undefined) (spec as { hasAuth?: boolean }).hasAuth = extras.hasAuth;
|
|
228
|
+
if (extras?.urlForEntry !== undefined) {
|
|
229
|
+
(
|
|
230
|
+
spec as {
|
|
231
|
+
urlForEntry?: (e: ServiceEntry) => string | undefined;
|
|
232
|
+
}
|
|
233
|
+
).urlForEntry = extras.urlForEntry;
|
|
234
|
+
}
|
|
235
|
+
if (extras?.postInstallFooter !== undefined) {
|
|
236
|
+
(spec as { postInstallFooter?: () => readonly string[] }).postInstallFooter =
|
|
237
|
+
extras.postInstallFooter;
|
|
238
|
+
}
|
|
239
|
+
return spec;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// First-party fallbacks
|
|
244
|
+
//
|
|
245
|
+
// Each entry below is a "delete-when-X-ships" marker — when the upstream
|
|
246
|
+
// module starts publishing its own `.parachute/module.json`, the matching
|
|
247
|
+
// FALLBACK comment names the issue that retires the vendored manifest +
|
|
248
|
+
// extras. One cleanup PR per module; the markers make those PRs a one-grep
|
|
249
|
+
// operation (`rg "FALLBACK: Delete when"`).
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
// FALLBACK: Delete when @openparachute/vault ships .parachute/module.json
|
|
253
|
+
// (parachute-vault repo: file follow-up after parachute-hub#56 lands).
|
|
254
|
+
const VAULT_FALLBACK: FirstPartyFallback = {
|
|
255
|
+
package: "@openparachute/vault",
|
|
256
|
+
manifest: {
|
|
257
|
+
name: "vault",
|
|
156
258
|
manifestName: "parachute-vault",
|
|
259
|
+
displayName: "Vault",
|
|
260
|
+
tagline: "Your owner-authenticated MCP knowledge store.",
|
|
261
|
+
kind: "api",
|
|
262
|
+
port: 1940,
|
|
263
|
+
paths: ["/vault/default"],
|
|
264
|
+
health: "/vault/default/health",
|
|
265
|
+
},
|
|
266
|
+
extras: {
|
|
157
267
|
init: ["parachute-vault", "init"],
|
|
158
268
|
startCmd: () => ["parachute-vault", "serve"],
|
|
159
|
-
kind: "api",
|
|
160
269
|
hasAuth: true,
|
|
161
|
-
seedEntry: () => ({
|
|
162
|
-
name: "parachute-vault",
|
|
163
|
-
port: 1940,
|
|
164
|
-
paths: ["/vault/default"],
|
|
165
|
-
health: "/vault/default/health",
|
|
166
|
-
version: SEED_VERSION,
|
|
167
|
-
}),
|
|
168
270
|
// Vault's MCP endpoint lives one segment past the mount path. The bare
|
|
169
271
|
// `/vault/<name>` URL is the discovery shape; clients (claude.ai et al.)
|
|
170
272
|
// need `/vault/<name>/mcp` to actually open the stream.
|
|
171
273
|
urlForEntry: (entry) => `${pathBasedUrl(entry)}/mcp`,
|
|
172
274
|
},
|
|
173
|
-
|
|
174
|
-
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// FALLBACK: Delete when @openparachute/notes ships .parachute/module.json
|
|
278
|
+
// (parachute-notes repo: file follow-up after parachute-hub#56 lands).
|
|
279
|
+
const NOTES_FALLBACK: FirstPartyFallback = {
|
|
280
|
+
package: "@openparachute/notes",
|
|
281
|
+
manifest: {
|
|
282
|
+
// Frontend product name is "Notes". Vault's internal `/api/notes` endpoint
|
|
175
283
|
// is unrelated — different concept (vault data primitive vs. PWA brand).
|
|
176
|
-
|
|
284
|
+
name: "notes",
|
|
177
285
|
manifestName: "parachute-notes",
|
|
286
|
+
displayName: "Notes",
|
|
287
|
+
tagline: "Notes PWA backed by your vault.",
|
|
288
|
+
kind: "frontend",
|
|
289
|
+
port: 1942,
|
|
290
|
+
paths: ["/notes"],
|
|
291
|
+
health: "/notes/health",
|
|
292
|
+
},
|
|
293
|
+
extras: {
|
|
178
294
|
startCmd: (entry) => {
|
|
179
295
|
const first = entry.paths[0] ?? "/notes";
|
|
180
296
|
const mount = first === "/" ? "" : first.replace(/\/+$/, "");
|
|
181
297
|
return ["bun", NOTES_SERVE_PATH, "--port", String(entry.port), "--mount", mount];
|
|
182
298
|
},
|
|
183
|
-
kind: "frontend",
|
|
184
|
-
seedEntry: () => ({
|
|
185
|
-
name: "parachute-notes",
|
|
186
|
-
port: 1942,
|
|
187
|
-
paths: ["/notes"],
|
|
188
|
-
health: "/notes/health",
|
|
189
|
-
version: SEED_VERSION,
|
|
190
|
-
}),
|
|
191
|
-
urlForEntry: pathBasedUrl,
|
|
192
299
|
postInstallFooter: () => [
|
|
193
300
|
"",
|
|
194
301
|
"Open your Notes UI at http://localhost:1942/notes — paste the vault URL",
|
|
@@ -196,22 +303,28 @@ export const SERVICE_SPECS: Record<string, ServiceSpec> = {
|
|
|
196
303
|
"and the API token from your vault install.",
|
|
197
304
|
],
|
|
198
305
|
},
|
|
199
|
-
|
|
200
|
-
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// FALLBACK: Delete when @openparachute/scribe ships .parachute/module.json
|
|
309
|
+
// (parachute-scribe repo: file follow-up after parachute-hub#56 lands).
|
|
310
|
+
const SCRIBE_FALLBACK: FirstPartyFallback = {
|
|
311
|
+
package: "@openparachute/scribe",
|
|
312
|
+
manifest: {
|
|
313
|
+
name: "scribe",
|
|
201
314
|
manifestName: "parachute-scribe",
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// once it lands and scribe writes `publicExposure: "allowed"` when a token
|
|
205
|
-
// is configured, that explicit declaration overrides this default.
|
|
315
|
+
displayName: "Scribe",
|
|
316
|
+
tagline: "Local audio transcription for vault recordings.",
|
|
206
317
|
kind: "api",
|
|
318
|
+
port: 1943,
|
|
319
|
+
paths: ["/scribe"],
|
|
320
|
+
health: "/scribe/health",
|
|
321
|
+
startCmd: ["parachute-scribe", "serve"],
|
|
322
|
+
},
|
|
323
|
+
extras: {
|
|
324
|
+
// No auth gate today. Scribe's launch PR adds optional SCRIBE_AUTH_TOKEN;
|
|
325
|
+
// once it lands and scribe writes `publicExposure: "allowed"` when a
|
|
326
|
+
// token is configured, that explicit declaration overrides this default.
|
|
207
327
|
hasAuth: false,
|
|
208
|
-
seedEntry: () => ({
|
|
209
|
-
name: "parachute-scribe",
|
|
210
|
-
port: 1943,
|
|
211
|
-
paths: ["/scribe"],
|
|
212
|
-
health: "/scribe/health",
|
|
213
|
-
version: SEED_VERSION,
|
|
214
|
-
}),
|
|
215
328
|
// Scribe's API is at the root, not under `/scribe`. The path prefix only
|
|
216
329
|
// shows up in the health endpoint; clients hit the bare port.
|
|
217
330
|
urlForEntry: (entry) => `http://127.0.0.1:${entry.port}`,
|
|
@@ -224,23 +337,42 @@ export const SERVICE_SPECS: Record<string, ServiceSpec> = {
|
|
|
224
337
|
"whisper, groq, openai.",
|
|
225
338
|
],
|
|
226
339
|
},
|
|
227
|
-
|
|
228
|
-
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// FALLBACK: Delete when @openparachute/channel ships .parachute/module.json
|
|
343
|
+
// (parachute-channel repo: file follow-up after parachute-hub#56 lands;
|
|
344
|
+
// channel is exploration tier — may be retired before module.json ships).
|
|
345
|
+
const CHANNEL_FALLBACK: FirstPartyFallback = {
|
|
346
|
+
package: "@openparachute/channel",
|
|
347
|
+
manifest: {
|
|
348
|
+
name: "channel",
|
|
229
349
|
manifestName: "parachute-channel",
|
|
230
|
-
|
|
350
|
+
displayName: "Channel",
|
|
351
|
+
tagline: "Notification fan-out across modules.",
|
|
231
352
|
kind: "api",
|
|
353
|
+
port: 1941,
|
|
354
|
+
paths: ["/channel"],
|
|
355
|
+
health: "/channel/health",
|
|
356
|
+
startCmd: ["parachute-channel", "daemon"],
|
|
357
|
+
},
|
|
358
|
+
extras: {
|
|
232
359
|
hasAuth: true,
|
|
233
|
-
seedEntry: () => ({
|
|
234
|
-
name: "parachute-channel",
|
|
235
|
-
port: 1941,
|
|
236
|
-
paths: ["/channel"],
|
|
237
|
-
health: "/channel/health",
|
|
238
|
-
version: SEED_VERSION,
|
|
239
|
-
}),
|
|
240
|
-
urlForEntry: pathBasedUrl,
|
|
241
360
|
},
|
|
242
361
|
};
|
|
243
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Vendored manifests + extras for first-party modules. Indexed by short name
|
|
365
|
+
* (the `parachute install <X>` token). Each entry retires when its upstream
|
|
366
|
+
* module starts shipping `.parachute/module.json` — see the per-entry
|
|
367
|
+
* `FALLBACK:` markers above.
|
|
368
|
+
*/
|
|
369
|
+
export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
|
|
370
|
+
vault: VAULT_FALLBACK,
|
|
371
|
+
notes: NOTES_FALLBACK,
|
|
372
|
+
scribe: SCRIBE_FALLBACK,
|
|
373
|
+
channel: CHANNEL_FALLBACK,
|
|
374
|
+
};
|
|
375
|
+
|
|
244
376
|
/**
|
|
245
377
|
* Effective publicExposure for a service, given what's on its services.json
|
|
246
378
|
* entry. Explicit wins. If absent, derive from the spec: known api/tool
|
|
@@ -254,19 +386,60 @@ export function effectivePublicExposure(
|
|
|
254
386
|
): "allowed" | "loopback" | "auth-required" {
|
|
255
387
|
if (entry.publicExposure !== undefined) return entry.publicExposure;
|
|
256
388
|
const short = shortNameForManifest(entry.name);
|
|
257
|
-
const
|
|
258
|
-
if (
|
|
389
|
+
const fb = short !== undefined ? FIRST_PARTY_FALLBACKS[short] : undefined;
|
|
390
|
+
if (
|
|
391
|
+
fb &&
|
|
392
|
+
(fb.manifest.kind === "api" || fb.manifest.kind === "tool") &&
|
|
393
|
+
fb.extras?.hasAuth === false
|
|
394
|
+
) {
|
|
259
395
|
return "auth-required";
|
|
260
396
|
}
|
|
261
397
|
return "allowed";
|
|
262
398
|
}
|
|
263
399
|
|
|
264
400
|
export function knownServices(): string[] {
|
|
265
|
-
return Object.keys(
|
|
401
|
+
return Object.keys(FIRST_PARTY_FALLBACKS);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Resolve the runtime spec for a known short name. Returns undefined for
|
|
406
|
+
* unknown names; third-party modules installed via `module.json` resolve
|
|
407
|
+
* via {@link getSpecFromInstallDir} instead, since their spec isn't
|
|
408
|
+
* compiled in.
|
|
409
|
+
*/
|
|
410
|
+
export function getSpec(short: string): ServiceSpec | undefined {
|
|
411
|
+
const fb = FIRST_PARTY_FALLBACKS[short];
|
|
412
|
+
if (!fb) return undefined;
|
|
413
|
+
return composeServiceSpec({
|
|
414
|
+
packageName: fb.package,
|
|
415
|
+
manifest: fb.manifest,
|
|
416
|
+
extras: fb.extras,
|
|
417
|
+
});
|
|
266
418
|
}
|
|
267
419
|
|
|
268
|
-
|
|
269
|
-
|
|
420
|
+
/**
|
|
421
|
+
* Resolve a third-party module's runtime spec by reading its
|
|
422
|
+
* `<installDir>/.parachute/module.json` fresh. Re-reading at lifecycle time
|
|
423
|
+
* (rather than baking the spec into services.json at install) means the
|
|
424
|
+
* module can ship `startCmd` updates without a re-install.
|
|
425
|
+
*
|
|
426
|
+
* Returns null when the manifest is missing — caller falls back to the
|
|
427
|
+
* "lifecycle not yet supported" message (same shape as a first-party spec
|
|
428
|
+
* with no startCmd). Throws ModuleManifestError on a malformed manifest;
|
|
429
|
+
* lifecycle catches and surfaces it as a per-service failure rather than
|
|
430
|
+
* crashing the whole sweep.
|
|
431
|
+
*
|
|
432
|
+
* `packageName` is informational only — the spec carries it forward for
|
|
433
|
+
* diagnostics. Lifecycle doesn't care; install passes it through from the
|
|
434
|
+
* services.json row's name.
|
|
435
|
+
*/
|
|
436
|
+
export async function getSpecFromInstallDir(
|
|
437
|
+
installDir: string,
|
|
438
|
+
packageName: string,
|
|
439
|
+
): Promise<ServiceSpec | null> {
|
|
440
|
+
const manifest = await readModuleManifest(installDir);
|
|
441
|
+
if (!manifest) return null;
|
|
442
|
+
return composeServiceSpec({ packageName, manifest });
|
|
270
443
|
}
|
|
271
444
|
|
|
272
445
|
/**
|
|
@@ -286,11 +459,11 @@ const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
|
|
|
286
459
|
"parachute-lens": "notes",
|
|
287
460
|
};
|
|
288
461
|
|
|
289
|
-
/** Short name (the key into
|
|
290
|
-
* `parachute-vault` → `vault`. Returns undefined for unknown manifests. */
|
|
462
|
+
/** Short name (the key into FIRST_PARTY_FALLBACKS) for a given manifest name,
|
|
463
|
+
* e.g. `parachute-vault` → `vault`. Returns undefined for unknown manifests. */
|
|
291
464
|
export function shortNameForManifest(manifestName: string): string | undefined {
|
|
292
|
-
for (const [short,
|
|
293
|
-
if (
|
|
465
|
+
for (const [short, fb] of Object.entries(FIRST_PARTY_FALLBACKS)) {
|
|
466
|
+
if (fb.manifest.manifestName === manifestName) return short;
|
|
294
467
|
}
|
|
295
468
|
return LEGACY_MANIFEST_ALIASES[manifestName];
|
|
296
469
|
}
|
package/src/services-manifest.ts
CHANGED
|
@@ -34,6 +34,17 @@ export interface ServiceEntry {
|
|
|
34
34
|
tagline?: string;
|
|
35
35
|
/** Opt-in or opt-out of public-facing expose layers. See PublicExposure. */
|
|
36
36
|
publicExposure?: PublicExposure;
|
|
37
|
+
/**
|
|
38
|
+
* Absolute path to the installed package directory. Set at install time
|
|
39
|
+
* for both npm-installed (`bunGlobalPrefixes()/<package>`) and local-path
|
|
40
|
+
* installs (`<absPath>`); first-party fallbacks may leave it absent.
|
|
41
|
+
*
|
|
42
|
+
* Lifecycle (`parachute start`) reads `<installDir>/.parachute/module.json`
|
|
43
|
+
* to recover startCmd for third-party modules whose spec isn't in
|
|
44
|
+
* FIRST_PARTY_FALLBACKS, and spawns with `cwd: installDir` so manifests
|
|
45
|
+
* can use clean relative paths in their `startCmd`.
|
|
46
|
+
*/
|
|
47
|
+
installDir?: string;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
export interface ServicesManifest {
|
|
@@ -74,6 +85,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
74
85
|
const displayName = e.displayName;
|
|
75
86
|
const tagline = e.tagline;
|
|
76
87
|
const publicExposure = e.publicExposure;
|
|
88
|
+
const installDir = e.installDir;
|
|
77
89
|
if (displayName !== undefined && typeof displayName !== "string") {
|
|
78
90
|
throw new ServicesManifestError(`${where}: "displayName" must be a string if present`);
|
|
79
91
|
}
|
|
@@ -90,10 +102,14 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
90
102
|
`${where}: "publicExposure" must be "allowed" | "loopback" | "auth-required" if present`,
|
|
91
103
|
);
|
|
92
104
|
}
|
|
105
|
+
if (installDir !== undefined && (typeof installDir !== "string" || installDir.length === 0)) {
|
|
106
|
+
throw new ServicesManifestError(`${where}: "installDir" must be a non-empty string if present`);
|
|
107
|
+
}
|
|
93
108
|
const entry: ServiceEntry = { name, port, paths: paths as string[], health, version };
|
|
94
109
|
if (displayName !== undefined) entry.displayName = displayName;
|
|
95
110
|
if (tagline !== undefined) entry.tagline = tagline;
|
|
96
111
|
if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
|
|
112
|
+
if (installDir !== undefined) entry.installDir = installDir;
|
|
97
113
|
return entry;
|
|
98
114
|
}
|
|
99
115
|
|
|
@@ -120,7 +136,47 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
|
|
|
120
136
|
`failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
121
137
|
);
|
|
122
138
|
}
|
|
123
|
-
|
|
139
|
+
const validated = validateManifest(raw, path);
|
|
140
|
+
const migrated = migrateClawToAgent(validated);
|
|
141
|
+
if (migrated.changed) writeManifest(migrated.manifest, path);
|
|
142
|
+
return migrated.manifest;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
|
|
147
|
+
* to parachute-agent across the ecosystem (npm package, mount path, short
|
|
148
|
+
* name); operators who upgraded hub but still have the old paraclaw row
|
|
149
|
+
* in services.json would otherwise see a tile labelled "Claw" and a hub
|
|
150
|
+
* route at `/claw` while their newly-upgraded daemon listens on `/agent`.
|
|
151
|
+
*
|
|
152
|
+
* Idempotent. Only rewrites when both `name === "claw"` AND the first path
|
|
153
|
+
* is `/claw` — narrow enough that a deliberately-named third-party module
|
|
154
|
+
* (e.g. `name: "claw"` on a different mount) is left alone. Health and any
|
|
155
|
+
* `/claw`-rooted paths are rewritten in lockstep.
|
|
156
|
+
*/
|
|
157
|
+
function migrateClawToAgent(manifest: ServicesManifest): {
|
|
158
|
+
manifest: ServicesManifest;
|
|
159
|
+
changed: boolean;
|
|
160
|
+
} {
|
|
161
|
+
let changed = false;
|
|
162
|
+
const services = manifest.services.map((entry) => {
|
|
163
|
+
if (entry.name !== "claw" || entry.paths[0] !== "/claw") return entry;
|
|
164
|
+
changed = true;
|
|
165
|
+
const next: ServiceEntry = {
|
|
166
|
+
...entry,
|
|
167
|
+
name: "agent",
|
|
168
|
+
paths: entry.paths.map((p) => rewriteClawPath(p)),
|
|
169
|
+
health: rewriteClawPath(entry.health),
|
|
170
|
+
};
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
return { manifest: { services }, changed };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function rewriteClawPath(p: string): string {
|
|
177
|
+
if (p === "/claw") return "/agent";
|
|
178
|
+
if (p.startsWith("/claw/")) return `/agent${p.slice("/claw".length)}`;
|
|
179
|
+
return p;
|
|
124
180
|
}
|
|
125
181
|
|
|
126
182
|
export function writeManifest(
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser sessions for the `/oauth/authorize` login + consent flow. The hub
|
|
3
|
+
* sets a session cookie when the user signs in; subsequent authorize requests
|
|
4
|
+
* with that cookie skip the login form and go straight to consent.
|
|
5
|
+
*
|
|
6
|
+
* Stored in `sessions` (one row per active session), so logout / forced
|
|
7
|
+
* revocation is just a delete. Cookies are 24h; sliding extension is a
|
|
8
|
+
* follow-up — for now, a session expires absolutely at `expires_at`.
|
|
9
|
+
*
|
|
10
|
+
* The cookie value is the session id directly. It's a 32-byte base64url
|
|
11
|
+
* random; collision is statistically impossible. No HMAC needed because the
|
|
12
|
+
* value is already opaque to the client and only ever compared to a row in
|
|
13
|
+
* the DB.
|
|
14
|
+
*/
|
|
15
|
+
import type { Database } from "bun:sqlite";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
export const SESSION_COOKIE_NAME = "parachute_hub_session";
|
|
19
|
+
export const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
export interface Session {
|
|
22
|
+
id: string;
|
|
23
|
+
userId: string;
|
|
24
|
+
expiresAt: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Row {
|
|
29
|
+
id: string;
|
|
30
|
+
user_id: string;
|
|
31
|
+
expires_at: string;
|
|
32
|
+
created_at: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rowToSession(r: Row): Session {
|
|
36
|
+
return {
|
|
37
|
+
id: r.id,
|
|
38
|
+
userId: r.user_id,
|
|
39
|
+
expiresAt: r.expires_at,
|
|
40
|
+
createdAt: r.created_at,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CreateSessionOpts {
|
|
45
|
+
userId: string;
|
|
46
|
+
now?: () => Date;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createSession(db: Database, opts: CreateSessionOpts): Session {
|
|
50
|
+
const id = randomBytes(32).toString("base64url");
|
|
51
|
+
const now = opts.now?.() ?? new Date();
|
|
52
|
+
const createdAt = now.toISOString();
|
|
53
|
+
const expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString();
|
|
54
|
+
db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run(
|
|
55
|
+
id,
|
|
56
|
+
opts.userId,
|
|
57
|
+
expiresAt,
|
|
58
|
+
createdAt,
|
|
59
|
+
);
|
|
60
|
+
return { id, userId: opts.userId, expiresAt, createdAt };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns the session row if it exists and isn't expired; otherwise null.
|
|
65
|
+
* Caller is expected to use this to gate the consent screen — no session
|
|
66
|
+
* means show the login form.
|
|
67
|
+
*/
|
|
68
|
+
export function findSession(
|
|
69
|
+
db: Database,
|
|
70
|
+
id: string,
|
|
71
|
+
now: () => Date = () => new Date(),
|
|
72
|
+
): Session | null {
|
|
73
|
+
const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
74
|
+
if (!row) return null;
|
|
75
|
+
const session = rowToSession(row);
|
|
76
|
+
if (now().getTime() > new Date(session.expiresAt).getTime()) return null;
|
|
77
|
+
return session;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function deleteSession(db: Database, id: string): void {
|
|
81
|
+
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a `Set-Cookie` header value for the given session id. HttpOnly +
|
|
86
|
+
* SameSite=Lax + Secure (we always assume a TLS terminator; localhost dev
|
|
87
|
+
* still sets Secure because Tailscale serves with HTTPS even on the tailnet
|
|
88
|
+
* mount). Path=/ covers the whole hub origin: the operator's session is "logged
|
|
89
|
+
* into this hub", and admin pages outside /oauth/ (config portal, etc.) ride
|
|
90
|
+
* the same session. State-changing admin POSTs require a CSRF token (see
|
|
91
|
+
* src/csrf.ts) since SameSite=Lax alone doesn't prevent same-site CSRF.
|
|
92
|
+
*/
|
|
93
|
+
export function buildSessionCookie(sessionId: string, maxAgeSeconds: number): string {
|
|
94
|
+
return [
|
|
95
|
+
`${SESSION_COOKIE_NAME}=${sessionId}`,
|
|
96
|
+
"HttpOnly",
|
|
97
|
+
"Secure",
|
|
98
|
+
"SameSite=Lax",
|
|
99
|
+
"Path=/",
|
|
100
|
+
`Max-Age=${maxAgeSeconds}`,
|
|
101
|
+
].join("; ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildSessionClearCookie(): string {
|
|
105
|
+
return `${SESSION_COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseSessionCookie(cookieHeader: string | null): string | null {
|
|
109
|
+
if (!cookieHeader) return null;
|
|
110
|
+
for (const part of cookieHeader.split(";")) {
|
|
111
|
+
const [name, ...rest] = part.trim().split("=");
|
|
112
|
+
if (name === SESSION_COOKIE_NAME) return rest.join("=");
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|