@openparachute/hub 0.7.6 → 0.7.7-rc.3
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 +6 -3
- package/src/__tests__/account-api.test.ts +511 -0
- package/src/__tests__/account-token.test.ts +292 -0
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/scope-explanations.test.ts +22 -0
- package/src/account-api.ts +632 -0
- package/src/account-token.ts +200 -0
- package/src/hub-server.ts +176 -0
- package/src/scope-explanations.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7-rc.3",
|
|
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": {
|
|
@@ -28,8 +28,10 @@
|
|
|
28
28
|
"scripts": {
|
|
29
29
|
"start": "bun src/cli.ts",
|
|
30
30
|
"build:depcheck": "[ ! -f packages/depcheck/package.json ] || [ -f packages/depcheck/dist/index.js ] || bun run --cwd packages/depcheck build",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
31
|
+
"build:door-contract": "[ ! -f packages/door-contract/package.json ] || [ -f packages/door-contract/dist/index.js ] || bun run --cwd packages/door-contract build",
|
|
32
|
+
"build:packages": "bun run build:depcheck && bun run build:door-contract",
|
|
33
|
+
"prepare": "bun run build:packages",
|
|
34
|
+
"pretest": "bun run build:packages",
|
|
33
35
|
"test": "bun test ./src",
|
|
34
36
|
"lint": "biome check .",
|
|
35
37
|
"lint:fix": "biome check --write .",
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
42
44
|
"@biomejs/biome": "^1.9.4",
|
|
45
|
+
"@openparachute/door-contract": "workspace:*",
|
|
43
46
|
"@types/bun": "latest",
|
|
44
47
|
"@types/qrcode": "^1.5.6"
|
|
45
48
|
},
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
handleAccountCapabilities,
|
|
8
|
+
handleAccountCreateVault,
|
|
9
|
+
handleAccountGetVaultCaps,
|
|
10
|
+
handleAccountListVaults,
|
|
11
|
+
handleAccountMintVaultToken,
|
|
12
|
+
handleAccountRoot,
|
|
13
|
+
handleAccountSetVaultCaps,
|
|
14
|
+
} from "../account-api.ts";
|
|
15
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
17
|
+
import { upsertService } from "../services-manifest.ts";
|
|
18
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
19
|
+
import { createUser } from "../users.ts";
|
|
20
|
+
import { getVaultCap } from "../vault-caps.ts";
|
|
21
|
+
|
|
22
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
23
|
+
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
24
|
+
const ACCOUNT_ADMIN_SCOPE = "account:self:admin";
|
|
25
|
+
const ACCOUNT_READ_SCOPE = "account:self:read";
|
|
26
|
+
|
|
27
|
+
type RunResult = { exitCode: number; stdout: string; stderr: string };
|
|
28
|
+
|
|
29
|
+
/** Mirror of the `parachute-vault create --json` stdout shape (see admin-vaults.test). */
|
|
30
|
+
function vaultCreateJson(
|
|
31
|
+
name: string,
|
|
32
|
+
token = `hubjwt.${name}.access`,
|
|
33
|
+
tokenGuidance?: string,
|
|
34
|
+
): string {
|
|
35
|
+
return JSON.stringify({
|
|
36
|
+
name,
|
|
37
|
+
token,
|
|
38
|
+
...(tokenGuidance ? { token_guidance: tokenGuidance } : {}),
|
|
39
|
+
paths: {
|
|
40
|
+
vault_dir: `/home/test/.parachute/vault/${name}`,
|
|
41
|
+
vault_db: `/home/test/.parachute/vault/${name}/vault.db`,
|
|
42
|
+
vault_config: `/home/test/.parachute/vault/${name}/config.yaml`,
|
|
43
|
+
},
|
|
44
|
+
set_as_default: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Harness {
|
|
49
|
+
db: Database;
|
|
50
|
+
dir: string;
|
|
51
|
+
manifestPath: string;
|
|
52
|
+
cleanup: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeHarness(vaultNames: string[] = ["beta", "personal"]): Harness {
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-api-"));
|
|
57
|
+
const db = openHubDb(hubDbPath(dir));
|
|
58
|
+
rotateSigningKey(db);
|
|
59
|
+
const manifestPath = join(dir, "services.json");
|
|
60
|
+
const paths = vaultNames.map((n) => `/vault/${n}`);
|
|
61
|
+
writeFileSync(
|
|
62
|
+
manifestPath,
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
services: [
|
|
65
|
+
{ name: "parachute-vault", port: 4101, paths, health: "/health", version: "0.4.2" },
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
return {
|
|
70
|
+
db,
|
|
71
|
+
dir,
|
|
72
|
+
manifestPath,
|
|
73
|
+
cleanup: () => {
|
|
74
|
+
db.close();
|
|
75
|
+
rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function deps(
|
|
81
|
+
h: Harness,
|
|
82
|
+
extra: Partial<{ runCommand: (cmd: readonly string[]) => Promise<RunResult> }> = {},
|
|
83
|
+
) {
|
|
84
|
+
return { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, ...extra };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function bearer(h: Harness, scopes: string[], sub = "operator-user"): Promise<string> {
|
|
88
|
+
const minted = await signAccessToken(h.db, {
|
|
89
|
+
sub,
|
|
90
|
+
scopes,
|
|
91
|
+
audience: "account",
|
|
92
|
+
clientId: "parachute-hub-spa",
|
|
93
|
+
issuer: ISSUER,
|
|
94
|
+
ttlSeconds: 600,
|
|
95
|
+
});
|
|
96
|
+
return minted.token;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withBearer(path: string, token: string | null, init: RequestInit = {}): Request {
|
|
100
|
+
const headers = new Headers(init.headers ?? {});
|
|
101
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
102
|
+
return new Request(`${ISSUER}${path}`, { ...init, headers });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function jsonReq(path: string, token: string, method: string, body?: unknown): Request {
|
|
106
|
+
const init: RequestInit = { method };
|
|
107
|
+
if (body !== undefined) {
|
|
108
|
+
init.body = JSON.stringify(body);
|
|
109
|
+
init.headers = { "content-type": "application/json" };
|
|
110
|
+
}
|
|
111
|
+
return withBearer(path, token, init);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ===========================================================================
|
|
115
|
+
// GET /.well-known/parachute-account — the capabilities descriptor
|
|
116
|
+
// ===========================================================================
|
|
117
|
+
describe("handleAccountCapabilities", () => {
|
|
118
|
+
test("descriptor is public and reports the self-host door honestly", async () => {
|
|
119
|
+
const res = handleAccountCapabilities(new Request(`${ISSUER}/.well-known/parachute-account`), {
|
|
120
|
+
issuer: `${ISSUER}/`,
|
|
121
|
+
});
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
124
|
+
expect(body.door).toBe("self-host");
|
|
125
|
+
expect(body.issuer).toBe(ISSUER); // trailing slash trimmed
|
|
126
|
+
const features = body.features as Record<string, unknown>;
|
|
127
|
+
expect(features.billing).toBe(false); // no billing on self-host
|
|
128
|
+
expect(features.plans).toEqual([]);
|
|
129
|
+
expect(features.vault_create).toBe(true);
|
|
130
|
+
expect(features.vault_delete).toBe(true);
|
|
131
|
+
expect(features.modules).toBe(true);
|
|
132
|
+
expect(features.expose).toBe(true);
|
|
133
|
+
expect(body.caps_writable).toBe(true);
|
|
134
|
+
expect((body.limits as Record<string, unknown>).vaults_max).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("405 on non-GET", () => {
|
|
138
|
+
const res = handleAccountCapabilities(
|
|
139
|
+
new Request(`${ISSUER}/.well-known/parachute-account`, { method: "POST" }),
|
|
140
|
+
{ issuer: ISSUER },
|
|
141
|
+
);
|
|
142
|
+
expect(res.status).toBe(405);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ===========================================================================
|
|
147
|
+
// Auth gates (shared shape across the surface)
|
|
148
|
+
// ===========================================================================
|
|
149
|
+
describe("account API — auth gates", () => {
|
|
150
|
+
test("401 with no Authorization header", async () => {
|
|
151
|
+
const h = makeHarness();
|
|
152
|
+
try {
|
|
153
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", null), deps(h));
|
|
154
|
+
expect(res.status).toBe(401);
|
|
155
|
+
} finally {
|
|
156
|
+
h.cleanup();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("403 when the token carries neither account:self:* nor host:admin", async () => {
|
|
161
|
+
const h = makeHarness();
|
|
162
|
+
try {
|
|
163
|
+
const token = await bearer(h, ["vault:beta:read"]);
|
|
164
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
165
|
+
expect(res.status).toBe(403);
|
|
166
|
+
} finally {
|
|
167
|
+
h.cleanup();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a plain parachute:host:admin token is accepted (H1-independent)", async () => {
|
|
172
|
+
const h = makeHarness();
|
|
173
|
+
try {
|
|
174
|
+
const token = await bearer(h, [HOST_ADMIN_SCOPE]);
|
|
175
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
} finally {
|
|
178
|
+
h.cleanup();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("an account:self:admin token is accepted", async () => {
|
|
183
|
+
const h = makeHarness();
|
|
184
|
+
try {
|
|
185
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
186
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
187
|
+
expect(res.status).toBe(200);
|
|
188
|
+
} finally {
|
|
189
|
+
h.cleanup();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("account:self:read is accepted on reads but rejected (403) on mutations", async () => {
|
|
194
|
+
const h = makeHarness();
|
|
195
|
+
try {
|
|
196
|
+
const token = await bearer(h, [ACCOUNT_READ_SCOPE]);
|
|
197
|
+
const read = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
198
|
+
expect(read.status).toBe(200);
|
|
199
|
+
const write = await handleAccountCreateVault(
|
|
200
|
+
jsonReq("/account/vaults", token, "POST", { name: "nope" }),
|
|
201
|
+
deps(h),
|
|
202
|
+
);
|
|
203
|
+
expect(write.status).toBe(403);
|
|
204
|
+
} finally {
|
|
205
|
+
h.cleanup();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ===========================================================================
|
|
211
|
+
// GET /account/vaults — list
|
|
212
|
+
// ===========================================================================
|
|
213
|
+
describe("handleAccountListVaults", () => {
|
|
214
|
+
test("lists every registered vault with url + version + caps", async () => {
|
|
215
|
+
const h = makeHarness(["alpha", "beta"]);
|
|
216
|
+
try {
|
|
217
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
218
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
219
|
+
expect(res.status).toBe(200);
|
|
220
|
+
const body = (await res.json()) as {
|
|
221
|
+
vaults: Array<{
|
|
222
|
+
name: string;
|
|
223
|
+
url: string;
|
|
224
|
+
version: string;
|
|
225
|
+
caps: { cap_bytes: number | null };
|
|
226
|
+
}>;
|
|
227
|
+
};
|
|
228
|
+
const names = body.vaults.map((v) => v.name).sort();
|
|
229
|
+
expect(names).toEqual(["alpha", "beta"]);
|
|
230
|
+
const alpha = body.vaults.find((v) => v.name === "alpha");
|
|
231
|
+
expect(alpha?.url).toBe(`${ISSUER}/vault/alpha`);
|
|
232
|
+
expect(alpha?.version).toBe("0.4.2");
|
|
233
|
+
expect(alpha?.caps.cap_bytes).toBeNull();
|
|
234
|
+
} finally {
|
|
235
|
+
h.cleanup();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ===========================================================================
|
|
241
|
+
// POST /account/vaults — create (returns a ready-to-use vault token)
|
|
242
|
+
// ===========================================================================
|
|
243
|
+
describe("handleAccountCreateVault", () => {
|
|
244
|
+
test("201 returns the vault token + services block on a fresh create", async () => {
|
|
245
|
+
const h = makeHarness(["default"]);
|
|
246
|
+
try {
|
|
247
|
+
const token = await bearer(h, [HOST_ADMIN_SCOPE]);
|
|
248
|
+
const runCommand = async (_cmd: readonly string[]): Promise<RunResult> => {
|
|
249
|
+
upsertService(
|
|
250
|
+
{
|
|
251
|
+
name: "parachute-vault",
|
|
252
|
+
port: 4101,
|
|
253
|
+
paths: ["/vault/default", "/vault/work"],
|
|
254
|
+
health: "/health",
|
|
255
|
+
version: "0.4.2",
|
|
256
|
+
},
|
|
257
|
+
h.manifestPath,
|
|
258
|
+
);
|
|
259
|
+
return { exitCode: 0, stdout: vaultCreateJson("work", "hubjwt.work.access"), stderr: "" };
|
|
260
|
+
};
|
|
261
|
+
const res = await handleAccountCreateVault(
|
|
262
|
+
jsonReq("/account/vaults", token, "POST", { name: "work" }),
|
|
263
|
+
deps(h, { runCommand }),
|
|
264
|
+
);
|
|
265
|
+
expect(res.status).toBe(201);
|
|
266
|
+
const body = (await res.json()) as {
|
|
267
|
+
name: string;
|
|
268
|
+
url: string;
|
|
269
|
+
vault_token: string;
|
|
270
|
+
services: Record<string, { url: string; version: string }>;
|
|
271
|
+
};
|
|
272
|
+
expect(body.name).toBe("work");
|
|
273
|
+
expect(body.url).toBe(`${ISSUER}/vault/work`);
|
|
274
|
+
expect(body.vault_token).toBe("hubjwt.work.access");
|
|
275
|
+
expect(body.services["vault:work"]?.url).toBe(`${ISSUER}/vault/work`);
|
|
276
|
+
expect(body.services["vault:work"]?.version).toBe("0.4.2");
|
|
277
|
+
} finally {
|
|
278
|
+
h.cleanup();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("empty vault_token + token_guidance flow through so the app can fall back", async () => {
|
|
283
|
+
const h = makeHarness(["default"]);
|
|
284
|
+
try {
|
|
285
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
286
|
+
const runCommand = async (_cmd: readonly string[]): Promise<RunResult> => {
|
|
287
|
+
upsertService(
|
|
288
|
+
{
|
|
289
|
+
name: "parachute-vault",
|
|
290
|
+
port: 4101,
|
|
291
|
+
paths: ["/vault/default", "/vault/work"],
|
|
292
|
+
health: "/health",
|
|
293
|
+
version: "0.4.2",
|
|
294
|
+
},
|
|
295
|
+
h.manifestPath,
|
|
296
|
+
);
|
|
297
|
+
return {
|
|
298
|
+
exitCode: 0,
|
|
299
|
+
stdout: vaultCreateJson("work", "", "no hub origin reachable to mint against"),
|
|
300
|
+
stderr: "",
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
const res = await handleAccountCreateVault(
|
|
304
|
+
jsonReq("/account/vaults", token, "POST", { name: "work" }),
|
|
305
|
+
deps(h, { runCommand }),
|
|
306
|
+
);
|
|
307
|
+
expect(res.status).toBe(201);
|
|
308
|
+
const body = (await res.json()) as { vault_token: string; token_guidance?: string };
|
|
309
|
+
expect(body.vault_token).toBe("");
|
|
310
|
+
expect(body.token_guidance).toBe("no hub origin reachable to mint against");
|
|
311
|
+
} finally {
|
|
312
|
+
h.cleanup();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("400 invalid_name on a missing name", async () => {
|
|
317
|
+
const h = makeHarness();
|
|
318
|
+
try {
|
|
319
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
320
|
+
const res = await handleAccountCreateVault(
|
|
321
|
+
jsonReq("/account/vaults", token, "POST", {}),
|
|
322
|
+
deps(h),
|
|
323
|
+
);
|
|
324
|
+
expect(res.status).toBe(400);
|
|
325
|
+
expect(((await res.json()) as { error: string }).error).toBe("invalid_name");
|
|
326
|
+
} finally {
|
|
327
|
+
h.cleanup();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ===========================================================================
|
|
333
|
+
// POST /account/vaults/<name>/token — per-vault token mint
|
|
334
|
+
// ===========================================================================
|
|
335
|
+
describe("handleAccountMintVaultToken", () => {
|
|
336
|
+
test("mints a vault token with aud=vault.<name> and default read+write scope", async () => {
|
|
337
|
+
const h = makeHarness(["field-notes"]);
|
|
338
|
+
try {
|
|
339
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
340
|
+
const res = await handleAccountMintVaultToken(
|
|
341
|
+
withBearer("/account/vaults/field-notes/token", token, { method: "POST" }),
|
|
342
|
+
"field-notes",
|
|
343
|
+
deps(h),
|
|
344
|
+
);
|
|
345
|
+
expect(res.status).toBe(200);
|
|
346
|
+
const body = (await res.json()) as {
|
|
347
|
+
vault_token: string;
|
|
348
|
+
expires_at: string;
|
|
349
|
+
services: Record<string, { url: string }>;
|
|
350
|
+
};
|
|
351
|
+
expect(body.vault_token.length).toBeGreaterThan(0);
|
|
352
|
+
expect(body.services["vault:field-notes"]?.url).toBe(`${ISSUER}/vault/field-notes`);
|
|
353
|
+
const validated = await validateAccessToken(h.db, body.vault_token, ISSUER);
|
|
354
|
+
expect(validated.payload.aud).toBe("vault.field-notes");
|
|
355
|
+
expect(validated.payload.scope).toBe("vault:field-notes:read vault:field-notes:write");
|
|
356
|
+
} finally {
|
|
357
|
+
h.cleanup();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("honors an explicit scopes list", async () => {
|
|
362
|
+
const h = makeHarness(["field-notes"]);
|
|
363
|
+
try {
|
|
364
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
365
|
+
const res = await handleAccountMintVaultToken(
|
|
366
|
+
jsonReq("/account/vaults/field-notes/token", token, "POST", {
|
|
367
|
+
scopes: ["vault:field-notes:read"],
|
|
368
|
+
}),
|
|
369
|
+
"field-notes",
|
|
370
|
+
deps(h),
|
|
371
|
+
);
|
|
372
|
+
expect(res.status).toBe(200);
|
|
373
|
+
const body = (await res.json()) as { vault_token: string };
|
|
374
|
+
const validated = await validateAccessToken(h.db, body.vault_token, ISSUER);
|
|
375
|
+
expect(validated.payload.scope).toBe("vault:field-notes:read");
|
|
376
|
+
} finally {
|
|
377
|
+
h.cleanup();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("404 when the vault does not exist", async () => {
|
|
382
|
+
const h = makeHarness(["field-notes"]);
|
|
383
|
+
try {
|
|
384
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
385
|
+
const res = await handleAccountMintVaultToken(
|
|
386
|
+
withBearer("/account/vaults/ghost/token", token, { method: "POST" }),
|
|
387
|
+
"ghost",
|
|
388
|
+
deps(h),
|
|
389
|
+
);
|
|
390
|
+
expect(res.status).toBe(404);
|
|
391
|
+
expect(((await res.json()) as { error: string }).error).toBe("vault_not_found");
|
|
392
|
+
} finally {
|
|
393
|
+
h.cleanup();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("400 invalid_scope when a requested scope names another vault", async () => {
|
|
398
|
+
const h = makeHarness(["field-notes", "other"]);
|
|
399
|
+
try {
|
|
400
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
401
|
+
const res = await handleAccountMintVaultToken(
|
|
402
|
+
jsonReq("/account/vaults/field-notes/token", token, "POST", {
|
|
403
|
+
scopes: ["vault:other:read"],
|
|
404
|
+
}),
|
|
405
|
+
"field-notes",
|
|
406
|
+
deps(h),
|
|
407
|
+
);
|
|
408
|
+
expect(res.status).toBe(400);
|
|
409
|
+
expect(((await res.json()) as { error: string }).error).toBe("invalid_scope");
|
|
410
|
+
} finally {
|
|
411
|
+
h.cleanup();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ===========================================================================
|
|
417
|
+
// GET / PUT /account/vaults/<name>/caps
|
|
418
|
+
// ===========================================================================
|
|
419
|
+
describe("account caps", () => {
|
|
420
|
+
test("GET reports null cap for an uncapped vault; PUT sets it; GET reflects", async () => {
|
|
421
|
+
const h = makeHarness(["field-notes"]);
|
|
422
|
+
try {
|
|
423
|
+
const adminTok = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
424
|
+
const readTok = await bearer(h, [ACCOUNT_READ_SCOPE]);
|
|
425
|
+
|
|
426
|
+
const get0 = await handleAccountGetVaultCaps(
|
|
427
|
+
withBearer("/account/vaults/field-notes/caps", readTok),
|
|
428
|
+
"field-notes",
|
|
429
|
+
deps(h),
|
|
430
|
+
);
|
|
431
|
+
expect(get0.status).toBe(200);
|
|
432
|
+
expect(
|
|
433
|
+
((await get0.json()) as { caps: { cap_bytes: number | null } }).caps.cap_bytes,
|
|
434
|
+
).toBeNull();
|
|
435
|
+
|
|
436
|
+
const put = await handleAccountSetVaultCaps(
|
|
437
|
+
jsonReq("/account/vaults/field-notes/caps", adminTok, "PUT", { cap_bytes: 1048576 }),
|
|
438
|
+
"field-notes",
|
|
439
|
+
deps(h),
|
|
440
|
+
);
|
|
441
|
+
expect(put.status).toBe(200);
|
|
442
|
+
expect(getVaultCap(h.db, "field-notes")?.capBytes).toBe(1048576);
|
|
443
|
+
|
|
444
|
+
const get1 = await handleAccountGetVaultCaps(
|
|
445
|
+
withBearer("/account/vaults/field-notes/caps", readTok),
|
|
446
|
+
"field-notes",
|
|
447
|
+
deps(h),
|
|
448
|
+
);
|
|
449
|
+
expect(((await get1.json()) as { caps: { cap_bytes: number | null } }).caps.cap_bytes).toBe(
|
|
450
|
+
1048576,
|
|
451
|
+
);
|
|
452
|
+
} finally {
|
|
453
|
+
h.cleanup();
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("PUT 400 on a non-positive cap", async () => {
|
|
458
|
+
const h = makeHarness(["field-notes"]);
|
|
459
|
+
try {
|
|
460
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
461
|
+
const res = await handleAccountSetVaultCaps(
|
|
462
|
+
jsonReq("/account/vaults/field-notes/caps", token, "PUT", { cap_bytes: 0 }),
|
|
463
|
+
"field-notes",
|
|
464
|
+
deps(h),
|
|
465
|
+
);
|
|
466
|
+
expect(res.status).toBe(400);
|
|
467
|
+
} finally {
|
|
468
|
+
h.cleanup();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("GET 404 for an unknown vault", async () => {
|
|
473
|
+
const h = makeHarness(["field-notes"]);
|
|
474
|
+
try {
|
|
475
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
476
|
+
const res = await handleAccountGetVaultCaps(
|
|
477
|
+
withBearer("/account/vaults/ghost/caps", token),
|
|
478
|
+
"ghost",
|
|
479
|
+
deps(h),
|
|
480
|
+
);
|
|
481
|
+
expect(res.status).toBe(404);
|
|
482
|
+
} finally {
|
|
483
|
+
h.cleanup();
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ===========================================================================
|
|
489
|
+
// GET /account — bootstrap
|
|
490
|
+
// ===========================================================================
|
|
491
|
+
describe("handleAccountRoot", () => {
|
|
492
|
+
test("returns account_id=self, door=self-host, and the operator email", async () => {
|
|
493
|
+
const h = makeHarness();
|
|
494
|
+
try {
|
|
495
|
+
const user = await createUser(h.db, "operator", "any-password", {
|
|
496
|
+
allowMulti: true,
|
|
497
|
+
passwordChanged: true,
|
|
498
|
+
email: "op@example.com",
|
|
499
|
+
});
|
|
500
|
+
const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
|
|
501
|
+
const res = await handleAccountRoot(withBearer("/account", token), deps(h));
|
|
502
|
+
expect(res.status).toBe(200);
|
|
503
|
+
const body = (await res.json()) as { account_id: string; email: string | null; door: string };
|
|
504
|
+
expect(body.account_id).toBe("self");
|
|
505
|
+
expect(body.door).toBe("self-host");
|
|
506
|
+
expect(body.email).toBe("op@example.com");
|
|
507
|
+
} finally {
|
|
508
|
+
h.cleanup();
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
});
|