@openparachute/hub 0.7.3-rc.5 → 0.7.3-rc.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.5",
3
+ "version": "0.7.3-rc.6",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Tests for `/api/vault-caps*` (B5 admin visibility / D-slice).
3
+ * Covers:
4
+ *
5
+ * - Auth boundary: GET + PUT require a bearer carrying `parachute:host:admin`.
6
+ * - GET joins services.json vault names with persisted caps (uncapped =
7
+ * null cap_bytes), ordered by name.
8
+ * - PUT sets/updates a cap (upsert), validates positive cap, refuses a
9
+ * vault not registered in services.json (400 vault_not_found).
10
+ * - 405 on wrong methods.
11
+ */
12
+ import type { Database } from "bun:sqlite";
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { handleListVaultCaps, handleSetVaultCap } from "../api-vault-caps.ts";
18
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
19
+ import { signAccessToken } from "../jwt-sign.ts";
20
+ import { createUser } from "../users.ts";
21
+ import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
22
+
23
+ const ISSUER = "https://hub.test";
24
+ const HOST_ADMIN_SCOPE = "parachute:host:admin";
25
+
26
+ interface Harness {
27
+ db: Database;
28
+ manifestPath: string;
29
+ cleanup: () => void;
30
+ }
31
+
32
+ function manifestWithVaults(...names: string[]): string {
33
+ const paths = names.map((n) => `/vault/${n}`);
34
+ return JSON.stringify({
35
+ services: [
36
+ { name: "parachute-vault", port: 4101, paths, health: "/health", version: "0.0.0-test" },
37
+ ],
38
+ });
39
+ }
40
+
41
+ function makeHarness(servicesJson?: string): Harness {
42
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-vault-caps-"));
43
+ const db = openHubDb(hubDbPath(dir));
44
+ const manifestPath = join(dir, "services.json");
45
+ writeFileSync(manifestPath, servicesJson ?? manifestWithVaults("beta", "personal"));
46
+ return {
47
+ db,
48
+ manifestPath,
49
+ cleanup: () => {
50
+ db.close();
51
+ rmSync(dir, { recursive: true, force: true });
52
+ },
53
+ };
54
+ }
55
+
56
+ let harness: Harness;
57
+ beforeEach(() => {
58
+ harness = makeHarness();
59
+ });
60
+ afterEach(() => {
61
+ harness.cleanup();
62
+ });
63
+
64
+ async function makeAdminBearer(scopes = [HOST_ADMIN_SCOPE]): Promise<string> {
65
+ const user = await createUser(harness.db, "operator", "any-password", {
66
+ allowMulti: true,
67
+ passwordChanged: true,
68
+ });
69
+ const minted = await signAccessToken(harness.db, {
70
+ sub: user.id,
71
+ scopes,
72
+ audience: "hub",
73
+ clientId: "parachute-hub-spa",
74
+ issuer: ISSUER,
75
+ ttlSeconds: 600,
76
+ });
77
+ return minted.token;
78
+ }
79
+
80
+ function req(path: string, init: RequestInit = {}): Request {
81
+ return new Request(`${ISSUER}${path}`, init);
82
+ }
83
+
84
+ function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
85
+ const headers = new Headers(init.headers ?? {});
86
+ headers.set("authorization", `Bearer ${bearer}`);
87
+ return new Request(`${ISSUER}${path}`, { ...init, headers });
88
+ }
89
+
90
+ function deps() {
91
+ return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
92
+ }
93
+
94
+ describe("handleListVaultCaps", () => {
95
+ test("401 with no Authorization header", async () => {
96
+ const res = await handleListVaultCaps(req("/api/vault-caps"), deps());
97
+ expect(res.status).toBe(401);
98
+ });
99
+
100
+ test("403 when bearer lacks parachute:host:admin", async () => {
101
+ const bearer = await makeAdminBearer(["other:scope"]);
102
+ const res = await handleListVaultCaps(withBearer("/api/vault-caps", bearer), deps());
103
+ expect(res.status).toBe(403);
104
+ });
105
+
106
+ test("405 on POST", async () => {
107
+ const bearer = await makeAdminBearer();
108
+ const res = await handleListVaultCaps(
109
+ withBearer("/api/vault-caps", bearer, { method: "POST" }),
110
+ deps(),
111
+ );
112
+ expect(res.status).toBe(405);
113
+ });
114
+
115
+ test("joins services.json vaults with caps, uncapped = null cap_bytes", async () => {
116
+ const bearer = await makeAdminBearer();
117
+ // Only "beta" has a persisted cap; "personal" stays uncapped.
118
+ setVaultCap(harness.db, "beta", 1024 * 1024 * 1024);
119
+ const res = await handleListVaultCaps(withBearer("/api/vault-caps", bearer), deps());
120
+ expect(res.status).toBe(200);
121
+ expect(res.headers.get("cache-control")).toBe("no-store");
122
+ const body = (await res.json()) as {
123
+ vault_caps: Array<{ vault_name: string; cap_bytes: number | null }>;
124
+ };
125
+ // Both vaults present, ordered by name (beta, personal).
126
+ expect(body.vault_caps.map((c) => c.vault_name)).toEqual(["beta", "personal"]);
127
+ const beta = body.vault_caps.find((c) => c.vault_name === "beta");
128
+ const personal = body.vault_caps.find((c) => c.vault_name === "personal");
129
+ expect(beta?.cap_bytes).toBe(1024 * 1024 * 1024);
130
+ expect(personal?.cap_bytes).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe("handleSetVaultCap", () => {
135
+ test("401 with no Authorization header", async () => {
136
+ const res = await handleSetVaultCap(
137
+ req("/api/vault-caps/beta", {
138
+ method: "PUT",
139
+ headers: { "content-type": "application/json" },
140
+ body: JSON.stringify({ cap_bytes: 1000 }),
141
+ }),
142
+ "beta",
143
+ deps(),
144
+ );
145
+ expect(res.status).toBe(401);
146
+ });
147
+
148
+ test("403 when bearer lacks parachute:host:admin", async () => {
149
+ const bearer = await makeAdminBearer(["other:scope"]);
150
+ const res = await handleSetVaultCap(
151
+ withBearer("/api/vault-caps/beta", bearer, {
152
+ method: "PUT",
153
+ headers: { "content-type": "application/json" },
154
+ body: JSON.stringify({ cap_bytes: 1000 }),
155
+ }),
156
+ "beta",
157
+ deps(),
158
+ );
159
+ expect(res.status).toBe(403);
160
+ });
161
+
162
+ test("400 on a fractional (non-integer) cap", async () => {
163
+ const bearer = await makeAdminBearer();
164
+ const res = await handleSetVaultCap(
165
+ withBearer("/api/vault-caps/beta", bearer, {
166
+ method: "PUT",
167
+ headers: { "content-type": "application/json" },
168
+ body: JSON.stringify({ cap_bytes: 1.5 }),
169
+ }),
170
+ "beta",
171
+ deps(),
172
+ );
173
+ expect(res.status).toBe(400);
174
+ });
175
+
176
+ test("405 on GET", async () => {
177
+ const bearer = await makeAdminBearer();
178
+ const res = await handleSetVaultCap(withBearer("/api/vault-caps/beta", bearer), "beta", deps());
179
+ expect(res.status).toBe(405);
180
+ });
181
+
182
+ test("sets a cap on a registered vault and persists it (upsert)", async () => {
183
+ const bearer = await makeAdminBearer();
184
+ const res = await handleSetVaultCap(
185
+ withBearer("/api/vault-caps/beta", bearer, {
186
+ method: "PUT",
187
+ headers: { "content-type": "application/json" },
188
+ body: JSON.stringify({ cap_bytes: 2 * 1024 * 1024 * 1024 }),
189
+ }),
190
+ "beta",
191
+ deps(),
192
+ );
193
+ expect(res.status).toBe(200);
194
+ const body = (await res.json()) as { vault_cap: { cap_bytes: number } };
195
+ expect(body.vault_cap.cap_bytes).toBe(2 * 1024 * 1024 * 1024);
196
+ expect(getVaultCapBytes(harness.db, "beta")).toBe(2 * 1024 * 1024 * 1024);
197
+ });
198
+
199
+ test("400 vault_not_found for a vault not in services.json", async () => {
200
+ const bearer = await makeAdminBearer();
201
+ const res = await handleSetVaultCap(
202
+ withBearer("/api/vault-caps/ghost", bearer, {
203
+ method: "PUT",
204
+ headers: { "content-type": "application/json" },
205
+ body: JSON.stringify({ cap_bytes: 1000 }),
206
+ }),
207
+ "ghost",
208
+ deps(),
209
+ );
210
+ expect(res.status).toBe(400);
211
+ const body = (await res.json()) as { error: string };
212
+ expect(body.error).toBe("vault_not_found");
213
+ // Nothing persisted for the rejected name.
214
+ expect(getVaultCapBytes(harness.db, "ghost")).toBeNull();
215
+ });
216
+
217
+ test("400 on non-positive cap", async () => {
218
+ const bearer = await makeAdminBearer();
219
+ const res = await handleSetVaultCap(
220
+ withBearer("/api/vault-caps/beta", bearer, {
221
+ method: "PUT",
222
+ headers: { "content-type": "application/json" },
223
+ body: JSON.stringify({ cap_bytes: 0 }),
224
+ }),
225
+ "beta",
226
+ deps(),
227
+ );
228
+ expect(res.status).toBe(400);
229
+ const body = (await res.json()) as { error: string };
230
+ expect(body.error).toBe("invalid_request");
231
+ });
232
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `/api/vault-caps*` — admin visibility + edit for per-vault storage caps
3
+ * (DEMO-PREP-2026-06-25 Workstream B5 / D-slice).
4
+ *
5
+ * PR #686 shipped the `vault_caps` table + `setVaultCap`/`getVaultCapBytes`
6
+ * (provision-time persistence) and a separate Phase-2 PR reads + ENFORCES the
7
+ * cap at upload time. This file is the OPERATOR-FACING seam in between: the
8
+ * admin SPA needs to SEE who has what cap and EDIT a cap live in the demo.
9
+ *
10
+ * Surfaces:
11
+ *
12
+ * GET /api/vault-caps list every vault (from services.json) joined
13
+ * with its persisted cap (host:admin)
14
+ * PUT /api/vault-caps/:name set/update a vault's cap to N bytes (host:admin)
15
+ *
16
+ * The list is the JOIN of "what vaults exist" (services.json, the canonical
17
+ * vault-name source) and "what caps are persisted" (`vault_caps`). A vault
18
+ * with no cap row appears with `cap_bytes: null` (uncapped) — the same
19
+ * "uncapped = no row" contract the Phase-2 enforcement reader relies on.
20
+ *
21
+ * Wire shape is snake_case (matches `/api/users`, `/api/invites`). Auth: same
22
+ * `parachute:host:admin` Bearer gate as every other `/api/*` admin surface;
23
+ * the SPA mints it from the session cookie via `/admin/host-admin-token`.
24
+ *
25
+ * Scope discipline: PUT only sets a cap on a vault that is REGISTERED in
26
+ * services.json (rejects a stale / typo name with 400 `vault_not_found`) so an
27
+ * operator can't seed a cap row for a vault that doesn't exist. Additive only —
28
+ * this never changes the provision-time cap-persistence from #686; it's a
29
+ * read + targeted-edit layer on the same table.
30
+ */
31
+ import type { Database } from "bun:sqlite";
32
+ import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
33
+ import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
34
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
35
+ import { getVaultCap, setVaultCap } from "./vault-caps.ts";
36
+ import { listVaultNamesFromPath } from "./vault-names.ts";
37
+
38
+ export interface ApiVaultCapsDeps {
39
+ db: Database;
40
+ /** Hub origin — JWT `iss` validation. */
41
+ issuer: string;
42
+ /** Override services.json path. Defaults to `~/.parachute/services.json`. */
43
+ manifestPath?: string;
44
+ }
45
+
46
+ /**
47
+ * One row in the `GET /api/vault-caps` response: a vault name + its cap. A
48
+ * vault with no persisted cap carries `cap_bytes: null` (uncapped); `created_at`
49
+ * / `updated_at` are null too in that case.
50
+ */
51
+ export interface VaultCapWireShape {
52
+ vault_name: string;
53
+ cap_bytes: number | null;
54
+ created_at: string | null;
55
+ updated_at: string | null;
56
+ }
57
+
58
+ function jsonError(status: number, error: string, description: string): Response {
59
+ return new Response(JSON.stringify({ error, error_description: description }), {
60
+ status,
61
+ headers: { "content-type": "application/json" },
62
+ });
63
+ }
64
+
65
+ /**
66
+ * GET /api/vault-caps — every vault registered in services.json, joined with
67
+ * its persisted cap (null = uncapped). Ordered by vault name.
68
+ */
69
+ export async function handleListVaultCaps(req: Request, deps: ApiVaultCapsDeps): Promise<Response> {
70
+ if (req.method !== "GET") {
71
+ return jsonError(405, "method_not_allowed", "use GET");
72
+ }
73
+ try {
74
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
75
+ } catch (err) {
76
+ return adminAuthErrorResponse(err as AdminAuthError);
77
+ }
78
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
79
+ const names = listVaultNamesFromPath(manifestPath);
80
+ const caps: VaultCapWireShape[] = names.map((vaultName) => {
81
+ const cap = getVaultCap(deps.db, vaultName);
82
+ return {
83
+ vault_name: vaultName,
84
+ cap_bytes: cap?.capBytes ?? null,
85
+ created_at: cap?.createdAt ?? null,
86
+ updated_at: cap?.updatedAt ?? null,
87
+ };
88
+ });
89
+ return new Response(JSON.stringify({ vault_caps: caps }), {
90
+ status: 200,
91
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
92
+ });
93
+ }
94
+
95
+ interface SetCapBody {
96
+ cap_bytes: number;
97
+ }
98
+
99
+ interface ParseErr {
100
+ ok: false;
101
+ status: number;
102
+ error: string;
103
+ description: string;
104
+ }
105
+
106
+ async function parseSetCapBody(req: Request): Promise<{ ok: true; body: SetCapBody } | ParseErr> {
107
+ const ctype = req.headers.get("content-type") ?? "";
108
+ if (!ctype.toLowerCase().includes("application/json")) {
109
+ return {
110
+ ok: false,
111
+ status: 400,
112
+ error: "invalid_request",
113
+ description: "Content-Type must be application/json",
114
+ };
115
+ }
116
+ let raw: unknown;
117
+ try {
118
+ raw = await req.json();
119
+ } catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ return {
122
+ ok: false,
123
+ status: 400,
124
+ error: "invalid_request",
125
+ description: `invalid JSON body: ${msg}`,
126
+ };
127
+ }
128
+ if (!raw || typeof raw !== "object") {
129
+ return {
130
+ ok: false,
131
+ status: 400,
132
+ error: "invalid_request",
133
+ description: "request body must be a JSON object",
134
+ };
135
+ }
136
+ const obj = raw as Record<string, unknown>;
137
+ const capBytes = obj.cap_bytes;
138
+ // Positive integer only — the table's CHECK (cap_bytes > 0) is the at-rest
139
+ // backstop; this is the edge validation so the operator gets a clean 400
140
+ // instead of a sqlite constraint error. A fractional byte count (which a byte
141
+ // count can never be) is rejected rather than silently floored downstream.
142
+ if (typeof capBytes !== "number" || !Number.isInteger(capBytes) || capBytes <= 0) {
143
+ return {
144
+ ok: false,
145
+ status: 400,
146
+ error: "invalid_request",
147
+ description: '"cap_bytes" must be a positive integer number of bytes',
148
+ };
149
+ }
150
+ return { ok: true, body: { cap_bytes: capBytes } };
151
+ }
152
+
153
+ /**
154
+ * PUT /api/vault-caps/:name — set or update a vault's storage cap.
155
+ *
156
+ * Order of checks (mirrors the /api/users handlers):
157
+ *
158
+ * 1. Method gate (405 on non-PUT).
159
+ * 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
160
+ * 3. Parse + validate body (400 on shape / non-positive cap).
161
+ * 4. Vault is registered in services.json (400 `vault_not_found`) — refuse
162
+ * to seed a cap for a vault that doesn't exist.
163
+ * 5. `setVaultCap` (upsert) — overwrites the cap, bumps `updated_at`,
164
+ * preserves the original `created_at`.
165
+ *
166
+ * Response on success: `200 { vault_cap: <wire shape> }`.
167
+ */
168
+ export async function handleSetVaultCap(
169
+ req: Request,
170
+ vaultName: string,
171
+ deps: ApiVaultCapsDeps,
172
+ ): Promise<Response> {
173
+ if (req.method !== "PUT") {
174
+ return jsonError(405, "method_not_allowed", "use PUT");
175
+ }
176
+ try {
177
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
178
+ } catch (err) {
179
+ return adminAuthErrorResponse(err as AdminAuthError);
180
+ }
181
+ const parsed = await parseSetCapBody(req);
182
+ if (!parsed.ok) {
183
+ return jsonError(parsed.status, parsed.error, parsed.description);
184
+ }
185
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
186
+ const known = new Set(listVaultNamesFromPath(manifestPath));
187
+ if (!known.has(vaultName)) {
188
+ return jsonError(
189
+ 400,
190
+ "vault_not_found",
191
+ `vault "${vaultName}" is not registered in services.json`,
192
+ );
193
+ }
194
+ const cap = setVaultCap(deps.db, vaultName, parsed.body.cap_bytes);
195
+ console.log(`vault cap set: vault=${vaultName} cap_bytes=${cap.capBytes}`);
196
+ const wire: VaultCapWireShape = {
197
+ vault_name: cap.vaultName,
198
+ cap_bytes: cap.capBytes,
199
+ created_at: cap.createdAt,
200
+ updated_at: cap.updatedAt,
201
+ };
202
+ return new Response(JSON.stringify({ vault_cap: wire }), {
203
+ status: 200,
204
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
205
+ });
206
+ }
package/src/hub-server.ts CHANGED
@@ -96,6 +96,8 @@
96
96
  * /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
97
97
  * /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
98
98
  * /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
99
+ * /api/vault-caps (GET) → list vaults + persisted storage caps (host:admin)
100
+ * /api/vault-caps/<name> (PUT) → set/update a vault's storage cap (host:admin)
99
101
  * /login (GET + POST) → operator password login
100
102
  * /login/2fa (POST) → second-factor (TOTP/backup) step
101
103
  * (hub#473; reached after a correct
@@ -221,6 +223,7 @@ import {
221
223
  handleResetUserPassword,
222
224
  handleUpdateUserVaults,
223
225
  } from "./api-users.ts";
226
+ import { handleListVaultCaps, handleSetVaultCap } from "./api-vault-caps.ts";
224
227
  import { gateUiAudience, resolveUiMount } from "./audience-gate.ts";
225
228
  import {
226
229
  CHROME_OPT_OUT_PREFIXES,
@@ -3351,6 +3354,30 @@ export function hubFetch(
3351
3354
  });
3352
3355
  }
3353
3356
 
3357
+ // Per-vault storage caps (B5 admin visibility / D-slice). GET lists every
3358
+ // vault from services.json joined with its persisted cap; PUT /:name
3359
+ // sets/updates a cap. host:admin-gated, same gate flavor as /api/users.
3360
+ if (pathname === "/api/vault-caps") {
3361
+ if (!getDb) return dbNotConfigured();
3362
+ return handleListVaultCaps(req, {
3363
+ db: getDb(),
3364
+ issuer: oauthDeps(req).issuer,
3365
+ manifestPath,
3366
+ });
3367
+ }
3368
+ if (pathname.startsWith("/api/vault-caps/")) {
3369
+ if (!getDb) return dbNotConfigured();
3370
+ const name = decodeURIComponent(pathname.slice("/api/vault-caps/".length));
3371
+ if (!name || name.includes("/")) {
3372
+ return new Response("not found", { status: 404 });
3373
+ }
3374
+ return handleSetVaultCap(req, name, {
3375
+ db: getDb(),
3376
+ issuer: oauthDeps(req).issuer,
3377
+ manifestPath,
3378
+ });
3379
+ }
3380
+
3354
3381
  // Canonical login/logout. The handlers themselves are unchanged from
3355
3382
  // when they lived at /admin/login + /admin/logout; the rename surfaced
3356
3383
  // via #231-followup so the URL reflects the surface's actual scope