@openparachute/hub 0.7.7-rc.7 → 0.7.7-rc.9
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-api.test.ts +287 -27
- package/src/__tests__/install.test.ts +187 -5
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/port-assign.test.ts +43 -14
- package/src/__tests__/services-manifest.test.ts +350 -40
- package/src/__tests__/setup.test.ts +5 -1
- package/src/account-api.ts +111 -66
- package/src/api-invites.ts +19 -0
- package/src/api-modules.ts +1 -1
- package/src/commands/install.ts +44 -0
- package/src/commands/setup.ts +9 -4
- package/src/help.ts +6 -5
- package/src/hub-server.ts +11 -4
- package/src/hub-settings.ts +15 -1
- package/src/invites.ts +42 -0
- package/src/notes-serve.ts +73 -31
- package/src/service-spec.ts +113 -29
- package/src/services-manifest.ts +104 -11
package/src/account-api.ts
CHANGED
|
@@ -33,6 +33,11 @@
|
|
|
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
|
+
import {
|
|
37
|
+
type AccountBootstrap,
|
|
38
|
+
type ParachuteAccountDescriptor,
|
|
39
|
+
validateVaultScopes,
|
|
40
|
+
} from "@openparachute/door-contract";
|
|
36
41
|
import { ACCOUNT_VAULT_TOKEN_TTL_SECONDS } from "./account-home-ui.ts";
|
|
37
42
|
import {
|
|
38
43
|
type AdminAuthContext,
|
|
@@ -42,6 +47,7 @@ import {
|
|
|
42
47
|
} from "./admin-auth.ts";
|
|
43
48
|
import { HOST_ADMIN_SCOPE, provisionVault } from "./admin-vaults.ts";
|
|
44
49
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
50
|
+
import { activePublicSignupPath } from "./invites.ts";
|
|
45
51
|
import { inferAudience } from "./jwt-audience.ts";
|
|
46
52
|
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
47
53
|
import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "./scope-explanations.ts";
|
|
@@ -102,10 +108,14 @@ export interface AccountApiDeps {
|
|
|
102
108
|
// Shared helpers
|
|
103
109
|
// ---------------------------------------------------------------------------
|
|
104
110
|
|
|
105
|
-
function json(status: number, body: unknown): Response {
|
|
111
|
+
function json(status: number, body: unknown, extraHeaders?: Record<string, string>): Response {
|
|
106
112
|
return new Response(JSON.stringify(body), {
|
|
107
113
|
status,
|
|
108
|
-
headers: {
|
|
114
|
+
headers: {
|
|
115
|
+
"content-type": "application/json",
|
|
116
|
+
"cache-control": "no-store",
|
|
117
|
+
...extraHeaders,
|
|
118
|
+
},
|
|
109
119
|
});
|
|
110
120
|
}
|
|
111
121
|
|
|
@@ -200,43 +210,69 @@ function servicesBlock(meta: VaultMeta): Record<string, { url: string; version:
|
|
|
200
210
|
// ---------------------------------------------------------------------------
|
|
201
211
|
|
|
202
212
|
/**
|
|
203
|
-
* The self-host door descriptor
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
213
|
+
* The self-host door descriptor — the canonical `ParachuteAccountDescriptor`
|
|
214
|
+
* (door-contract 0.4.0) both doors serve, so a client (the app) branches its
|
|
215
|
+
* front door without hardcoding per-door shapes. Public, no auth, wildcard
|
|
216
|
+
* CORS (the app pulls it cross-origin).
|
|
217
|
+
*
|
|
218
|
+
* `features`/`caps_writable` are hub EXTRAS beyond the shared contract — the
|
|
219
|
+
* shared conformance checker (`checkAccountDescriptor`) walks expected keys
|
|
220
|
+
* only, so these ride along without breaking cross-door conformance.
|
|
221
|
+
* `billing:false` + `plans:[]` (Q7, parked) mean the app shows no
|
|
222
|
+
* billing/upgrade UI on self-host; `caps_writable:true` means the operator
|
|
223
|
+
* can PUT caps freely (the cloud twin is plan-derived → false).
|
|
207
224
|
*
|
|
208
|
-
* `
|
|
209
|
-
* (
|
|
210
|
-
*
|
|
211
|
-
*
|
|
225
|
+
* `signup_path` is conditional (Q2): present only while an active multi-use
|
|
226
|
+
* public invite exists (`activePublicSignupPath`, invites.ts) — an operator-
|
|
227
|
+
* shared link is otherwise the only way in, so the app must not render a
|
|
228
|
+
* "create account" affordance when there is nowhere for it to go.
|
|
212
229
|
*/
|
|
213
|
-
export function handleAccountCapabilities(
|
|
230
|
+
export function handleAccountCapabilities(
|
|
231
|
+
req: Request,
|
|
232
|
+
deps: { db: Database; issuer: string; now?: () => Date },
|
|
233
|
+
): Response {
|
|
214
234
|
if (req.method !== "GET") return methodNotAllowed("GET");
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
235
|
+
const issuer = deps.issuer.replace(/\/$/, "");
|
|
236
|
+
const now = deps.now ? deps.now() : new Date();
|
|
237
|
+
const signupPath = activePublicSignupPath(deps.db, now);
|
|
238
|
+
const descriptor: ParachuteAccountDescriptor & {
|
|
219
239
|
features: {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
import:
|
|
223
|
-
export:
|
|
224
|
-
billing:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
240
|
+
modules: boolean;
|
|
241
|
+
expose: boolean;
|
|
242
|
+
import: boolean;
|
|
243
|
+
export: boolean;
|
|
244
|
+
billing: boolean;
|
|
245
|
+
};
|
|
246
|
+
caps_writable: boolean;
|
|
247
|
+
} = {
|
|
248
|
+
issuer,
|
|
249
|
+
door: "hub",
|
|
250
|
+
account_endpoint: `${issuer}/account`,
|
|
251
|
+
auth: { methods: ["password"], signin_path: "/login" },
|
|
252
|
+
...(signupPath ? { signup_path: signupPath } : {}),
|
|
253
|
+
vault_url_template: `${issuer}/vault/{name}`,
|
|
254
|
+
capabilities: { vault_create: true, vault_rename: false, vault_delete: true },
|
|
255
|
+
plans: [],
|
|
256
|
+
// Hub EXTRAS (kept — see the doc comment above).
|
|
257
|
+
features: { modules: true, expose: true, import: true, export: true, billing: false },
|
|
229
258
|
caps_writable: true,
|
|
230
|
-
limits: { vaults_max: null as number | null },
|
|
231
259
|
};
|
|
232
|
-
return json(200, descriptor
|
|
260
|
+
return json(200, descriptor, {
|
|
261
|
+
"access-control-allow-origin": "*",
|
|
262
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
263
|
+
});
|
|
233
264
|
}
|
|
234
265
|
|
|
235
266
|
// ---------------------------------------------------------------------------
|
|
236
267
|
// GET /account — account bootstrap
|
|
237
268
|
// ---------------------------------------------------------------------------
|
|
238
269
|
|
|
239
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* The contract's `AccountBootstrap` — `{ id, email?, door }`. On self-host the
|
|
272
|
+
* account id is the sentinel `self`; `email` is present only when the
|
|
273
|
+
* operator row has one (`users.email` is nullable-by-history, migration
|
|
274
|
+
* v15 — the door-contract type models it as optional, not nullable).
|
|
275
|
+
*/
|
|
240
276
|
export async function handleAccountRoot(req: Request, deps: AccountApiDeps): Promise<Response> {
|
|
241
277
|
if (req.method !== "GET") return methodNotAllowed("GET");
|
|
242
278
|
let ctx: AdminAuthContext;
|
|
@@ -246,11 +282,12 @@ export async function handleAccountRoot(req: Request, deps: AccountApiDeps): Pro
|
|
|
246
282
|
return adminAuthErrorResponse(err);
|
|
247
283
|
}
|
|
248
284
|
const user = getUserById(deps.db, ctx.sub);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
285
|
+
const body: AccountBootstrap = {
|
|
286
|
+
id: "self",
|
|
287
|
+
door: "hub",
|
|
288
|
+
...(user?.email ? { email: user.email } : {}),
|
|
289
|
+
};
|
|
290
|
+
return json(200, body);
|
|
254
291
|
}
|
|
255
292
|
|
|
256
293
|
// ---------------------------------------------------------------------------
|
|
@@ -369,6 +406,19 @@ export async function handleAccountCreateVault(
|
|
|
369
406
|
const error = provisioned.status === 400 ? "invalid_name" : "server_error";
|
|
370
407
|
return json(provisioned.status, { error, message: provisioned.message });
|
|
371
408
|
}
|
|
409
|
+
// Q6 (hub-parity P2): this facade no longer answers 200-idempotent on an
|
|
410
|
+
// existing name — it converges on cloud's exact 409 `vault_taken` shape.
|
|
411
|
+
// `provisionVault` itself is UNCHANGED (still idempotent for its other
|
|
412
|
+
// caller, the invite-redeem flow, which doesn't route through this
|
|
413
|
+
// facade) — only this facade's wire answer changes. A scripted consumer
|
|
414
|
+
// that relied on 200-on-existing must follow up with
|
|
415
|
+
// `POST /account/vaults/<name>/token` to get a usable token.
|
|
416
|
+
if (!provisioned.created) {
|
|
417
|
+
return json(409, {
|
|
418
|
+
error: "vault_taken",
|
|
419
|
+
message: "That vault name is already taken.",
|
|
420
|
+
});
|
|
421
|
+
}
|
|
372
422
|
|
|
373
423
|
const entry = provisioned.entry;
|
|
374
424
|
const meta: VaultMeta = { name: entry.name, url: entry.url, version: entry.version };
|
|
@@ -387,25 +437,30 @@ export async function handleAccountCreateVault(
|
|
|
387
437
|
: {}),
|
|
388
438
|
services: servicesBlock(meta),
|
|
389
439
|
};
|
|
390
|
-
|
|
391
|
-
// (no fresh token — `vault_token` is "" and the app mints via /token).
|
|
392
|
-
return json(provisioned.created ? 201 : 200, body);
|
|
440
|
+
return json(201, body);
|
|
393
441
|
}
|
|
394
442
|
|
|
395
443
|
// ---------------------------------------------------------------------------
|
|
396
444
|
// POST /account/vaults/<name>/token — per-vault token mint
|
|
397
445
|
// ---------------------------------------------------------------------------
|
|
398
446
|
|
|
399
|
-
const MINTABLE_VERBS = new Set(["read", "write", "admin"]);
|
|
400
|
-
|
|
401
447
|
interface ScopesBody {
|
|
402
448
|
ok: true;
|
|
403
449
|
scopes: string[];
|
|
404
450
|
}
|
|
405
451
|
|
|
406
452
|
/**
|
|
407
|
-
* Parse + validate the requested `scopes`.
|
|
408
|
-
*
|
|
453
|
+
* Parse + validate the requested `scopes`. The JSON-parse tolerance (optional
|
|
454
|
+
* body, optional content-type, swallow a malformed body) stays LOCAL — it's
|
|
455
|
+
* HTTP plumbing the shared validator knows nothing about (it's pure, no
|
|
456
|
+
* `Request`). The scope-SHAPE logic (array check, per-entry
|
|
457
|
+
* `vault:<name>:<verb>` grammar, empty/absent → default read+write) is the
|
|
458
|
+
* shared `validateVaultScopes` (door-contract 0.4.0) — the ONE implementation
|
|
459
|
+
* cloud's twin also imports, replacing the two hand-synced copies. Its reason
|
|
460
|
+
* taxonomy (`invalid_request` | `invalid_scope`) was built byte-exact with
|
|
461
|
+
* this function's prior behavior (see vault-scopes.ts's doc comment), so this
|
|
462
|
+
* swap is a behavioral no-op for the hub — verified by rerunning this file's
|
|
463
|
+
* existing test cases unchanged (account-api.test.ts).
|
|
409
464
|
*/
|
|
410
465
|
async function parseScopesBody(req: Request, vaultName: string): Promise<ScopesBody | BodyErr> {
|
|
411
466
|
const defaultScopes = [`vault:${vaultName}:read`, `vault:${vaultName}:write`];
|
|
@@ -422,34 +477,24 @@ async function parseScopesBody(req: Request, vaultName: string): Promise<ScopesB
|
|
|
422
477
|
}
|
|
423
478
|
if (!raw || typeof raw !== "object") return { ok: true, scopes: defaultScopes };
|
|
424
479
|
const requested = (raw as Record<string, unknown>).scopes;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
parts[1] !== vaultName ||
|
|
442
|
-
!MINTABLE_VERBS.has(parts[2] ?? "")
|
|
443
|
-
) {
|
|
444
|
-
return {
|
|
445
|
-
ok: false,
|
|
446
|
-
status: 400,
|
|
447
|
-
error: "invalid_scope",
|
|
448
|
-
message: `scope "${s}" must be vault:${vaultName}:{read|write|admin}`,
|
|
449
|
-
};
|
|
450
|
-
}
|
|
480
|
+
|
|
481
|
+
const result = validateVaultScopes(requested, vaultName);
|
|
482
|
+
if (!result.ok) {
|
|
483
|
+
return result.reason === "invalid_request"
|
|
484
|
+
? {
|
|
485
|
+
ok: false,
|
|
486
|
+
status: 400,
|
|
487
|
+
error: "invalid_request",
|
|
488
|
+
message: '"scopes" must be an array of strings',
|
|
489
|
+
}
|
|
490
|
+
: {
|
|
491
|
+
ok: false,
|
|
492
|
+
status: 400,
|
|
493
|
+
error: "invalid_scope",
|
|
494
|
+
message: `every scope must be vault:${vaultName}:{read|write|admin}`,
|
|
495
|
+
};
|
|
451
496
|
}
|
|
452
|
-
return { ok: true, scopes };
|
|
497
|
+
return { ok: true, scopes: result.scopes };
|
|
453
498
|
}
|
|
454
499
|
|
|
455
500
|
export async function handleAccountMintVaultToken(
|
package/src/api-invites.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { Database } from "bun:sqlite";
|
|
|
32
32
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
33
33
|
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
34
34
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
35
|
+
import { setSetting } from "./hub-settings.ts";
|
|
35
36
|
import {
|
|
36
37
|
DEFAULT_INVITE_TTL_SECONDS,
|
|
37
38
|
type Invite,
|
|
@@ -543,6 +544,24 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
|
|
|
543
544
|
vaultCapBytes: effectiveVaultCapBytes,
|
|
544
545
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
545
546
|
});
|
|
547
|
+
// Q2 (hub-parity P2, the raw-token reality): a multi-use PROVISIONING link
|
|
548
|
+
// IS the public signup page (the operator mints it to broadcast, and each
|
|
549
|
+
// redeemer gets their OWN new vault) — persisting ITS raw token so the
|
|
550
|
+
// account descriptor can advertise `signup_path` does NOT weaken the
|
|
551
|
+
// hash-only posture of single-use friend invites, which never write this row.
|
|
552
|
+
// The `provisionVault` guard is load-bearing security, not cosmetic: a
|
|
553
|
+
// multi-use SHARED-vault invite (`provision_vault=false` + an existing
|
|
554
|
+
// vault_name — "many users join one team vault", coherent per the mint gates
|
|
555
|
+
// above) must NEVER be auto-published on the anonymous, wildcard-CORS
|
|
556
|
+
// descriptor, or its team-vault-write link leaks to anyone who fetches the
|
|
557
|
+
// exposed hub. Only multi-use + provisioning = genuine public signup (the
|
|
558
|
+
// same conjunction the vault-cap heuristic above already treats as such).
|
|
559
|
+
// The newest public link wins (overwrites any prior value);
|
|
560
|
+
// `activePublicSignupPath` (invites.ts) re-validates liveness on every read
|
|
561
|
+
// and lazily clears a revoked/exhausted/expired one.
|
|
562
|
+
if (maxUses > 1 && provisionVault) {
|
|
563
|
+
setSetting(deps.db, "public_signup_token", issued.rawToken, deps.now ?? (() => new Date()));
|
|
564
|
+
}
|
|
546
565
|
const status = inviteStatus(issued.invite, now);
|
|
547
566
|
return new Response(
|
|
548
567
|
JSON.stringify({
|
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
|
package/src/commands/install.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
1
2
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
3
|
import { createConnection } from "node:net";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
defaultSleep,
|
|
16
17
|
readHubPort,
|
|
17
18
|
} from "../hub-control.ts";
|
|
19
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
20
|
+
import { resolveRootRedirectDetailed, setRootRedirect } from "../hub-settings.ts";
|
|
18
21
|
import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "../hub-unit.ts";
|
|
19
22
|
import {
|
|
20
23
|
type ModuleManifest,
|
|
@@ -408,6 +411,16 @@ export interface InstallOpts {
|
|
|
408
411
|
* origin for stable assertions.
|
|
409
412
|
*/
|
|
410
413
|
wellKnownOrigin?: string;
|
|
414
|
+
/**
|
|
415
|
+
* Test seam for the `app`-only set-if-unset root-redirect write (hub-parity
|
|
416
|
+
* P5). Production opens `openHubDb(hubDbPath(configDir))` fresh and closes
|
|
417
|
+
* it after the write; passing this injects an already-open DB (e.g. a
|
|
418
|
+
* tempdir `hub.db`) so a test never touches the real `~/.parachute/hub.db`.
|
|
419
|
+
* Passing this ALSO opts a tempdir-manifestPath test INTO the write path —
|
|
420
|
+
* mirrors `guidanceCtx`'s discriminant: without it, the production gate
|
|
421
|
+
* (`manifestPath === SERVICES_MANIFEST_PATH`) skips the write entirely.
|
|
422
|
+
*/
|
|
423
|
+
rootRedirectDb?: Database;
|
|
411
424
|
}
|
|
412
425
|
|
|
413
426
|
async function defaultRunner(cmd: readonly string[]): Promise<number> {
|
|
@@ -1351,6 +1364,37 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
|
|
|
1351
1364
|
}
|
|
1352
1365
|
}
|
|
1353
1366
|
|
|
1367
|
+
// App-only: default the hub's bare `/` redirect to the app's front door on
|
|
1368
|
+
// first install (hub-parity P5, 2026-07-11) — SET-IF-UNSET ONLY, never
|
|
1369
|
+
// clobbering an operator's existing choice (`parachute hub
|
|
1370
|
+
// set-root-redirect` or the admin SPA PUT both still win on any later run,
|
|
1371
|
+
// and either can change it back). "Unset" means the resolved redirect is
|
|
1372
|
+
// still the built-in `/admin` DEFAULT — i.e. NEITHER the DB row NOR the
|
|
1373
|
+
// `PARACHUTE_HUB_ROOT_REDIRECT` env var (container deploys pin their landing
|
|
1374
|
+
// page there) has set it. Gating on `getRootRedirect(db) === null` would
|
|
1375
|
+
// miss the env tier and silently override an env-configured operator, since
|
|
1376
|
+
// the DB row we'd write wins over env on read (`resolveRootRedirectDetailed`
|
|
1377
|
+
// is DB-first). Gated on the same production-vs-test discriminant the
|
|
1378
|
+
// guidance probe below uses: a test driving install against a tempdir
|
|
1379
|
+
// manifestPath never opens the real `~/.parachute/hub.db` unless it opts in
|
|
1380
|
+
// via `opts.rootRedirectDb`.
|
|
1381
|
+
if (short === "app") {
|
|
1382
|
+
const dbProbeAllowed =
|
|
1383
|
+
opts.rootRedirectDb !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
|
|
1384
|
+
if (dbProbeAllowed) {
|
|
1385
|
+
const db = opts.rootRedirectDb ?? openHubDb(hubDbPath(configDir));
|
|
1386
|
+
try {
|
|
1387
|
+
if (resolveRootRedirectDetailed(db).source === "default") {
|
|
1388
|
+
setRootRedirect(db, "/app/");
|
|
1389
|
+
log("✓ The hub's front page (`/`) now opens the app at /app/.");
|
|
1390
|
+
log(" Change it any time: `parachute hub set-root-redirect <path>` or the admin SPA.");
|
|
1391
|
+
}
|
|
1392
|
+
} finally {
|
|
1393
|
+
if (!opts.rootRedirectDb) db.close();
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1354
1398
|
// Per-service install footer — canonical next-step URLs and configuration
|
|
1355
1399
|
// hints. Vault prints its own (richer) footer from `parachute-vault init`
|
|
1356
1400
|
// (PR #166), so the spec leaves vault out and we don't double up here.
|
package/src/commands/setup.ts
CHANGED
|
@@ -110,7 +110,7 @@ function defaultAvailability(): InteractiveAvailability {
|
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
112
|
* Survey ALL known first-party shortnames (vault / notes / scribe / agent /
|
|
113
|
-
* surface) regardless of tier — `installed` is true when the service
|
|
113
|
+
* surface / app) regardless of tier — `installed` is true when the service
|
|
114
114
|
* has a row in services.json. The fresh-install OFFER is narrowed downstream
|
|
115
115
|
* by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
|
|
116
116
|
* notes); agent (`experimental`) is flagged exploratory in its blurb
|
|
@@ -121,7 +121,7 @@ function defaultAvailability(): InteractiveAvailability {
|
|
|
121
121
|
* like any unknown row it neither blocks setup nor appears in the offer.
|
|
122
122
|
*
|
|
123
123
|
* The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
|
|
124
|
-
* shorts (notes —
|
|
124
|
+
* shorts (notes / app — both carry a vendored manifest). KNOWN_MODULES shorts
|
|
125
125
|
* (vault / scribe / agent / surface) ship `.parachute/module.json`
|
|
126
126
|
* and self-register; pre-install we know manifestName + the urlForEntry quirk
|
|
127
127
|
* from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
|
|
@@ -175,8 +175,13 @@ export function isOfferable(choice: { short: string; installed: boolean }): bool
|
|
|
175
175
|
const BLURBS: Record<string, string> = {
|
|
176
176
|
vault: "knowledge graph (MCP) — your owner-authenticated note + tag store",
|
|
177
177
|
surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
|
|
178
|
-
//
|
|
179
|
-
|
|
178
|
+
// hub-parity P5 (2026-07-11): `app` is now the NEW super-surface front
|
|
179
|
+
// door (@openparachute/parachute-app) — a real FIRST_PARTY_FALLBACKS
|
|
180
|
+
// entry, not a legacy survey row. It is UNRELATED to the pre-2026-05-27
|
|
181
|
+
// `app` package that renamed to `surface` (that blurb lives under the
|
|
182
|
+
// `surface` key above); the two just happen to share the short name
|
|
183
|
+
// across a rename.
|
|
184
|
+
app: "the super-surface front door — one app to sign in, browse your vault, and manage your hub/cloud",
|
|
180
185
|
// notes is `deprecated` (not offered on a fresh setup) — this blurb only
|
|
181
186
|
// renders if a legacy install surfaces it in the survey.
|
|
182
187
|
notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
|
package/src/help.ts
CHANGED
|
@@ -362,11 +362,11 @@ Exit codes:
|
|
|
362
362
|
|
|
363
363
|
Example:
|
|
364
364
|
$ parachute status
|
|
365
|
-
SERVICE
|
|
366
|
-
parachute-vault
|
|
365
|
+
SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
|
|
366
|
+
parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
|
|
367
367
|
→ http://127.0.0.1:1940/vault/default/mcp
|
|
368
|
-
parachute-
|
|
369
|
-
→ http://127.0.0.1:1946/surface
|
|
368
|
+
parachute-surface 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
|
|
369
|
+
→ http://127.0.0.1:1946/surface
|
|
370
370
|
`;
|
|
371
371
|
}
|
|
372
372
|
|
|
@@ -551,8 +551,9 @@ Examples:
|
|
|
551
551
|
Module start commands (run by the supervisor under \`serve\`):
|
|
552
552
|
vault parachute-vault serve
|
|
553
553
|
scribe parachute-scribe serve
|
|
554
|
-
|
|
554
|
+
surface parachute-surface serve
|
|
555
555
|
agent parachute-agent
|
|
556
|
+
app bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> --package @openparachute/parachute-app
|
|
556
557
|
notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> # back-compat: legacy notes-daemon
|
|
557
558
|
`;
|
|
558
559
|
}
|
package/src/hub-server.ts
CHANGED
|
@@ -2862,7 +2862,13 @@ export function hubFetch(
|
|
|
2862
2862
|
if (req.method === "OPTIONS") {
|
|
2863
2863
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
2864
2864
|
}
|
|
2865
|
-
|
|
2865
|
+
if (!getDb) {
|
|
2866
|
+
return new Response('{"error":"account descriptor unavailable: db not configured"}', {
|
|
2867
|
+
status: 503,
|
|
2868
|
+
headers: { "content-type": "application/json", ...corsHeaders },
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
const res = handleAccountCapabilities(req, { db: getDb(), issuer: oauthDeps(req).issuer });
|
|
2866
2872
|
const merged = new Headers(res.headers);
|
|
2867
2873
|
for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
|
|
2868
2874
|
return new Response(res.body, { status: res.status, headers: merged });
|
|
@@ -3960,9 +3966,10 @@ export function hubFetch(
|
|
|
3960
3966
|
}
|
|
3961
3967
|
return new Response("method not allowed", { status: 405 });
|
|
3962
3968
|
}
|
|
3963
|
-
// GET /account (Bearer) — account bootstrap {
|
|
3964
|
-
// intercepts when an
|
|
3965
|
-
//
|
|
3969
|
+
// GET /account (Bearer) — account bootstrap {id,email,door} (the
|
|
3970
|
+
// door-contract's AccountBootstrap). Only intercepts when an
|
|
3971
|
+
// Authorization header is present, so the cookie-authed HTML account
|
|
3972
|
+
// home at `/account`/`/account/` below stays unaffected.
|
|
3966
3973
|
if (
|
|
3967
3974
|
(pathname === "/account" || pathname === "/account/") &&
|
|
3968
3975
|
req.headers.get("authorization")
|
package/src/hub-settings.ts
CHANGED
|
@@ -122,7 +122,21 @@ export type HubSettingKey =
|
|
|
122
122
|
// wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
|
|
123
123
|
// not-yet-set-up hub still lands on setup, not a surface that can't work
|
|
124
124
|
// yet.
|
|
125
|
-
| "root_redirect"
|
|
125
|
+
| "root_redirect"
|
|
126
|
+
// hub-parity P2 (Q2): the RAW token of the newest PUBLIC (multi-use,
|
|
127
|
+
// `max_uses > 1`) invite — persisted so `GET /.well-known/parachute-account`
|
|
128
|
+
// can advertise `signup_path` without being able to reconstruct it from the
|
|
129
|
+
// `invites` table (which stores only `sha256(token)`, see invites.ts's
|
|
130
|
+
// module doc). Written by `api-invites.ts`'s `handleCreateInvite` right
|
|
131
|
+
// after a successful multi-use `issueInvite`; read (and lazily cleared once
|
|
132
|
+
// stale) by `invites.ts`'s `activePublicSignupPath`.
|
|
133
|
+
//
|
|
134
|
+
// This does NOT weaken the hash-only posture of single-use friend invites
|
|
135
|
+
// (`max_uses === 1`, the default) — those NEVER write this row. A
|
|
136
|
+
// multi-use link is, by construction, the operator's deliberately PUBLIC
|
|
137
|
+
// signup page (minted to broadcast), so persisting its raw token is no
|
|
138
|
+
// more sensitive than the link itself already being handed out widely.
|
|
139
|
+
| "public_signup_token";
|
|
126
140
|
|
|
127
141
|
export type SetupExposeMode = "localhost" | "tailnet" | "public";
|
|
128
142
|
|
package/src/invites.ts
CHANGED
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
*/
|
|
58
58
|
import type { Database } from "bun:sqlite";
|
|
59
59
|
import { createHash, randomBytes } from "node:crypto";
|
|
60
|
+
import { deleteSetting, getSetting } from "./hub-settings.ts";
|
|
60
61
|
|
|
61
62
|
/** Default invite lifetime — long enough to deliver out-of-band (no email), short enough to bound a leaked link. */
|
|
62
63
|
export const DEFAULT_INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
@@ -502,3 +503,44 @@ export function revokeInvitesForVault(
|
|
|
502
503
|
.run(now.toISOString(), vaultName);
|
|
503
504
|
return Number(res.changes);
|
|
504
505
|
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Q2 (hub-parity P2) — the account descriptor's conditional `signup_path`.
|
|
509
|
+
* The hub cannot derive a redemption URL from the `invites` table (only
|
|
510
|
+
* `sha256(token)` is stored, never the raw value — see the module doc), so
|
|
511
|
+
* this reads back the ONE raw token persisted at mint time for a
|
|
512
|
+
* deliberately PUBLIC (multi-use) invite (`api-invites.ts`'s
|
|
513
|
+
* `handleCreateInvite`, which writes `hub_settings.public_signup_token`
|
|
514
|
+
* after `issueInvite` when `maxUses > 1`) and re-validates it's still live.
|
|
515
|
+
*
|
|
516
|
+
* Returns `/account/setup/<token>` when the persisted invite is still
|
|
517
|
+
* `"pending"`, still multi-use (`maxUses > 1`), AND provisioning
|
|
518
|
+
* (`provisionVault` — each redeemer gets their own new vault); `null` on ANY
|
|
519
|
+
* miss (never set, redeemed, revoked, exhausted, expired, or a non-provisioning
|
|
520
|
+
* shared-vault link) — and lazily clears the stale setting in that case. No
|
|
521
|
+
* separate revoke/exhaust/expire hook is needed: `inviteStatus` already derives
|
|
522
|
+
* all four terminal states from the invite's own columns, so every miss flows
|
|
523
|
+
* through this one status check.
|
|
524
|
+
*
|
|
525
|
+
* The `maxUses > 1` AND `provisionVault` re-checks are defense-in-depth mirrors
|
|
526
|
+
* of `handleCreateInvite`'s write condition (which only persists for a
|
|
527
|
+
* multi-use provisioning link): even a hand-edited settings row pointing at a
|
|
528
|
+
* single-use OR a shared-vault (`provision_vault=false`) invite must never be
|
|
529
|
+
* advertised as public signup — a shared-vault link would leak team-vault write
|
|
530
|
+
* on the anonymous, wildcard-CORS descriptor.
|
|
531
|
+
*/
|
|
532
|
+
export function activePublicSignupPath(db: Database, now: Date = new Date()): string | null {
|
|
533
|
+
const raw = getSetting(db, "public_signup_token");
|
|
534
|
+
if (!raw) return null;
|
|
535
|
+
const invite = findInviteByRawToken(db, raw);
|
|
536
|
+
if (
|
|
537
|
+
!invite ||
|
|
538
|
+
inviteStatus(invite, now) !== "pending" ||
|
|
539
|
+
invite.maxUses <= 1 ||
|
|
540
|
+
!invite.provisionVault
|
|
541
|
+
) {
|
|
542
|
+
deleteSetting(db, "public_signup_token");
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
return `/account/setup/${encodeURIComponent(raw)}`;
|
|
546
|
+
}
|