@openparachute/hub 0.7.7-rc.7 → 0.7.7-rc.8
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/account-api.ts +111 -66
- package/src/api-invites.ts +19 -0
- package/src/hub-server.ts +11 -4
- package/src/hub-settings.ts +15 -1
- package/src/invites.ts +42 -0
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { checkAccountDescriptor, validateVaultScopes } from "@openparachute/door-contract";
|
|
6
7
|
import {
|
|
7
8
|
handleAccountCapabilities,
|
|
8
9
|
handleAccountCreateVault,
|
|
@@ -12,7 +13,10 @@ import {
|
|
|
12
13
|
handleAccountRoot,
|
|
13
14
|
handleAccountSetVaultCaps,
|
|
14
15
|
} from "../account-api.ts";
|
|
16
|
+
import { handleCreateInvite } from "../api-invites.ts";
|
|
15
17
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
18
|
+
import { getSetting } from "../hub-settings.ts";
|
|
19
|
+
import { findInviteByRawToken, recordInviteRedemption, revokeInvite } from "../invites.ts";
|
|
16
20
|
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
17
21
|
import { upsertService } from "../services-manifest.ts";
|
|
18
22
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
@@ -115,31 +119,251 @@ function jsonReq(path: string, token: string, method: string, body?: unknown): R
|
|
|
115
119
|
// GET /.well-known/parachute-account — the capabilities descriptor
|
|
116
120
|
// ===========================================================================
|
|
117
121
|
describe("handleAccountCapabilities", () => {
|
|
118
|
-
test("descriptor is public and
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
122
|
+
test("descriptor is public, wildcard-CORS, and conforms to the shared door-contract canon", async () => {
|
|
123
|
+
const h = makeHarness();
|
|
124
|
+
try {
|
|
125
|
+
const res = handleAccountCapabilities(
|
|
126
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
127
|
+
{ db: h.db, issuer: `${ISSUER}/` },
|
|
128
|
+
);
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
131
|
+
expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
|
|
132
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
133
|
+
// The shared conformance checker — empty issue list means the shape
|
|
134
|
+
// is byte-conformant with the canonical ParachuteAccountDescriptor.
|
|
135
|
+
expect(checkAccountDescriptor(body, { issuer: ISSUER, door: "hub" })).toEqual([]);
|
|
136
|
+
expect(body.issuer).toBe(ISSUER); // trailing slash trimmed
|
|
137
|
+
expect(body.door).toBe("hub");
|
|
138
|
+
expect(body.account_endpoint).toBe(`${ISSUER}/account`);
|
|
139
|
+
expect(body.auth).toEqual({ methods: ["password"], signin_path: "/login" });
|
|
140
|
+
expect(body.vault_url_template).toContain("{name}");
|
|
141
|
+
expect(body.vault_url_template).toBe(`${ISSUER}/vault/{name}`);
|
|
142
|
+
expect(body.capabilities).toEqual({
|
|
143
|
+
vault_create: true,
|
|
144
|
+
vault_rename: false,
|
|
145
|
+
vault_delete: true,
|
|
146
|
+
});
|
|
147
|
+
expect(body.plans).toEqual([]);
|
|
148
|
+
expect(body.signup_path).toBeUndefined(); // fresh hub, no active public invite
|
|
149
|
+
// Hub extras — additive, outside the shared contract (conformance
|
|
150
|
+
// walks expected keys only, so these ride along without breaking it).
|
|
151
|
+
const features = body.features as Record<string, unknown>;
|
|
152
|
+
expect(features.billing).toBe(false);
|
|
153
|
+
expect(features.modules).toBe(true);
|
|
154
|
+
expect(features.expose).toBe(true);
|
|
155
|
+
expect(features.import).toBe(true);
|
|
156
|
+
expect(features.export).toBe(true);
|
|
157
|
+
expect(body.caps_writable).toBe(true);
|
|
158
|
+
} finally {
|
|
159
|
+
h.cleanup();
|
|
160
|
+
}
|
|
135
161
|
});
|
|
136
162
|
|
|
137
163
|
test("405 on non-GET", () => {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
const h = makeHarness();
|
|
165
|
+
try {
|
|
166
|
+
const res = handleAccountCapabilities(
|
|
167
|
+
new Request(`${ISSUER}/.well-known/parachute-account`, { method: "POST" }),
|
|
168
|
+
{ db: h.db, issuer: ISSUER },
|
|
169
|
+
);
|
|
170
|
+
expect(res.status).toBe(405);
|
|
171
|
+
} finally {
|
|
172
|
+
h.cleanup();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
// Q2 — the conditional `signup_path`. Present only while an active
|
|
178
|
+
// multi-use (public-signup) invite exists; absent on a fresh hub and
|
|
179
|
+
// absent again once the invite is revoked / exhausted / expired (the
|
|
180
|
+
// setting is lazily cleared on the next read, invites.ts's
|
|
181
|
+
// activePublicSignupPath).
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
test("signup_path appears after minting a max_uses:3 invite, disappears on revoke", async () => {
|
|
184
|
+
const h = makeHarness();
|
|
185
|
+
try {
|
|
186
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
187
|
+
allowMulti: true,
|
|
188
|
+
passwordChanged: true,
|
|
189
|
+
});
|
|
190
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
191
|
+
const mintRes = await handleCreateInvite(
|
|
192
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 3 }),
|
|
193
|
+
{ db: h.db, issuer: ISSUER },
|
|
194
|
+
);
|
|
195
|
+
expect(mintRes.status).toBe(201);
|
|
196
|
+
const minted = (await mintRes.json()) as { token: string };
|
|
197
|
+
|
|
198
|
+
const afterMint = handleAccountCapabilities(
|
|
199
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
200
|
+
{ db: h.db, issuer: ISSUER },
|
|
201
|
+
);
|
|
202
|
+
const afterMintBody = (await afterMint.json()) as Record<string, unknown>;
|
|
203
|
+
expect(afterMintBody.signup_path).toBe(`/account/setup/${minted.token}`);
|
|
204
|
+
|
|
205
|
+
const invite = findInviteByRawToken(h.db, minted.token);
|
|
206
|
+
expect(invite).not.toBeNull();
|
|
207
|
+
expect(revokeInvite(h.db, invite?.tokenHash ?? "")).toBe(true);
|
|
208
|
+
|
|
209
|
+
const afterRevoke = handleAccountCapabilities(
|
|
210
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
211
|
+
{ db: h.db, issuer: ISSUER },
|
|
212
|
+
);
|
|
213
|
+
expect(((await afterRevoke.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
214
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined(); // lazily cleared
|
|
215
|
+
} finally {
|
|
216
|
+
h.cleanup();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("signup_path disappears once the invite is exhausted (used_count == max_uses)", async () => {
|
|
221
|
+
const h = makeHarness();
|
|
222
|
+
try {
|
|
223
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
224
|
+
allowMulti: true,
|
|
225
|
+
passwordChanged: true,
|
|
226
|
+
});
|
|
227
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
228
|
+
const mintRes = await handleCreateInvite(
|
|
229
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 2 }),
|
|
230
|
+
{ db: h.db, issuer: ISSUER },
|
|
231
|
+
);
|
|
232
|
+
const minted = (await mintRes.json()) as { token: string };
|
|
233
|
+
const invite = findInviteByRawToken(h.db, minted.token);
|
|
234
|
+
expect(invite).not.toBeNull();
|
|
235
|
+
|
|
236
|
+
// Redeem both seats directly (invites.ts core — the redeem HTTP flow
|
|
237
|
+
// itself is out of scope here). `redeemed_user_id` FKs to `users`, so
|
|
238
|
+
// reuse the admin row as the redeemer stand-in — the test only cares
|
|
239
|
+
// that TWO redemptions land, not who they belong to.
|
|
240
|
+
expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
|
|
241
|
+
expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
|
|
242
|
+
|
|
243
|
+
const res = handleAccountCapabilities(
|
|
244
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
245
|
+
{ db: h.db, issuer: ISSUER },
|
|
246
|
+
);
|
|
247
|
+
expect(((await res.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
248
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
249
|
+
} finally {
|
|
250
|
+
h.cleanup();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("signup_path disappears once the invite expires (injected clock)", async () => {
|
|
255
|
+
const h = makeHarness();
|
|
256
|
+
try {
|
|
257
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
258
|
+
allowMulti: true,
|
|
259
|
+
passwordChanged: true,
|
|
260
|
+
});
|
|
261
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
262
|
+
const mintTime = new Date("2026-01-01T00:00:00.000Z");
|
|
263
|
+
const mintRes = await handleCreateInvite(
|
|
264
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 5, expires_in: 60 }),
|
|
265
|
+
{ db: h.db, issuer: ISSUER, now: () => mintTime },
|
|
266
|
+
);
|
|
267
|
+
expect(mintRes.status).toBe(201);
|
|
268
|
+
|
|
269
|
+
const stillLive = handleAccountCapabilities(
|
|
270
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
271
|
+
{ db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 30_000) },
|
|
272
|
+
);
|
|
273
|
+
expect(((await stillLive.json()) as Record<string, unknown>).signup_path).toBeDefined();
|
|
274
|
+
|
|
275
|
+
const afterExpiry = handleAccountCapabilities(
|
|
276
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
277
|
+
{ db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 120_000) },
|
|
278
|
+
);
|
|
279
|
+
expect(((await afterExpiry.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
280
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
281
|
+
} finally {
|
|
282
|
+
h.cleanup();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("signup_path is NOT set for a multi-use NON-provisioning invite (B1: a team link must never leak on the public descriptor)", async () => {
|
|
287
|
+
const h = makeHarness();
|
|
288
|
+
try {
|
|
289
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
290
|
+
allowMulti: true,
|
|
291
|
+
passwordChanged: true,
|
|
292
|
+
});
|
|
293
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
294
|
+
// A multi-use link with provision_vault=false is a team invite (here
|
|
295
|
+
// account-only; a shared-vault one additionally pins an existing
|
|
296
|
+
// vault_name and would hand out team-vault WRITE) — NOT a public signup
|
|
297
|
+
// that gives each redeemer their own fresh vault. It must never be
|
|
298
|
+
// persisted to public_signup_token nor advertised via the anonymous,
|
|
299
|
+
// wildcard-CORS descriptor. Same `provisionVault` guard covers both the
|
|
300
|
+
// account-only and shared-vault non-provisioning shapes.
|
|
301
|
+
const mintRes = await handleCreateInvite(
|
|
302
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 3, provision_vault: false }),
|
|
303
|
+
{ db: h.db, issuer: ISSUER },
|
|
304
|
+
);
|
|
305
|
+
expect(mintRes.status).toBe(201);
|
|
306
|
+
// The raw token was NOT persisted (the write-side guard)...
|
|
307
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
308
|
+
// ...and the public descriptor advertises no signup_path.
|
|
309
|
+
const desc = handleAccountCapabilities(
|
|
310
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
311
|
+
{ db: h.db, issuer: ISSUER },
|
|
312
|
+
);
|
|
313
|
+
expect(((await desc.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
314
|
+
} finally {
|
|
315
|
+
h.cleanup();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ===========================================================================
|
|
321
|
+
// The shared door-contract validateVaultScopes — behavioral no-op proof
|
|
322
|
+
// (hub-parity P2, item 5). These are the SAME fixtures the hub's prior local
|
|
323
|
+
// `parseScopesBody` scope-shape logic was exercised against (see the
|
|
324
|
+
// `handleAccountMintVaultToken` describe block below for the HTTP-level
|
|
325
|
+
// equivalents); asserting them directly against the shared validator proves
|
|
326
|
+
// the swap didn't change hub's wire behavior.
|
|
327
|
+
// ===========================================================================
|
|
328
|
+
describe("validateVaultScopes (shared door-contract validator) — hub's prior fixtures", () => {
|
|
329
|
+
test("undefined / null / [] all default to read+write for the named vault", () => {
|
|
330
|
+
for (const requested of [undefined, null, []]) {
|
|
331
|
+
expect(validateVaultScopes(requested, "field-notes")).toEqual({
|
|
332
|
+
ok: true,
|
|
333
|
+
scopes: ["vault:field-notes:read", "vault:field-notes:write"],
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("an explicit single-scope array is honored verbatim", () => {
|
|
339
|
+
expect(validateVaultScopes(["vault:field-notes:read"], "field-notes")).toEqual({
|
|
340
|
+
ok: true,
|
|
341
|
+
scopes: ["vault:field-notes:read"],
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("a scope naming another vault is invalid_scope", () => {
|
|
346
|
+
expect(validateVaultScopes(["vault:other:read"], "field-notes")).toEqual({
|
|
347
|
+
ok: false,
|
|
348
|
+
reason: "invalid_scope",
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("a non-array requested value is invalid_request", () => {
|
|
353
|
+
expect(validateVaultScopes("not-an-array", "field-notes")).toEqual({
|
|
354
|
+
ok: false,
|
|
355
|
+
reason: "invalid_request",
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("a mixed array with a non-string entry is invalid_request, even when a valid scope leads", () => {
|
|
360
|
+
// The whole-array pre-scan (byte-exact with hub's prior parseScopesBody):
|
|
361
|
+
// a non-string entry anywhere rejects the WHOLE request as
|
|
362
|
+
// invalid_request, never a positional invalid_scope.
|
|
363
|
+
expect(validateVaultScopes(["vault:field-notes:read", 123], "field-notes")).toEqual({
|
|
364
|
+
ok: false,
|
|
365
|
+
reason: "invalid_request",
|
|
366
|
+
});
|
|
143
367
|
});
|
|
144
368
|
});
|
|
145
369
|
|
|
@@ -327,6 +551,26 @@ describe("handleAccountCreateVault", () => {
|
|
|
327
551
|
h.cleanup();
|
|
328
552
|
}
|
|
329
553
|
});
|
|
554
|
+
|
|
555
|
+
// Q6 (hub-parity P2): create-existing-name converges on 409 vault_taken —
|
|
556
|
+
// this facade no longer answers 200-idempotent. `provisionVault` itself
|
|
557
|
+
// stays idempotent (the invite-redeem caller doesn't route through here).
|
|
558
|
+
test("409 vault_taken on an existing vault name (Q6 — no longer 200-idempotent)", async () => {
|
|
559
|
+
const h = makeHarness(["beta", "personal"]);
|
|
560
|
+
try {
|
|
561
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
562
|
+
const res = await handleAccountCreateVault(
|
|
563
|
+
jsonReq("/account/vaults", token, "POST", { name: "beta" }),
|
|
564
|
+
deps(h),
|
|
565
|
+
);
|
|
566
|
+
expect(res.status).toBe(409);
|
|
567
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
568
|
+
expect(body.error).toBe("vault_taken");
|
|
569
|
+
expect(body.message).toBe("That vault name is already taken.");
|
|
570
|
+
} finally {
|
|
571
|
+
h.cleanup();
|
|
572
|
+
}
|
|
573
|
+
});
|
|
330
574
|
});
|
|
331
575
|
|
|
332
576
|
// ===========================================================================
|
|
@@ -489,7 +733,7 @@ describe("account caps", () => {
|
|
|
489
733
|
// GET /account — bootstrap
|
|
490
734
|
// ===========================================================================
|
|
491
735
|
describe("handleAccountRoot", () => {
|
|
492
|
-
test("returns
|
|
736
|
+
test("returns the contract's AccountBootstrap — {id:self, door:hub, email}", async () => {
|
|
493
737
|
const h = makeHarness();
|
|
494
738
|
try {
|
|
495
739
|
const user = await createUser(h.db, "operator", "any-password", {
|
|
@@ -500,10 +744,26 @@ describe("handleAccountRoot", () => {
|
|
|
500
744
|
const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
|
|
501
745
|
const res = await handleAccountRoot(withBearer("/account", token), deps(h));
|
|
502
746
|
expect(res.status).toBe(200);
|
|
503
|
-
const body = (await res.json()) as {
|
|
504
|
-
expect(body
|
|
505
|
-
|
|
506
|
-
|
|
747
|
+
const body = (await res.json()) as { id: string; email?: string; door: string };
|
|
748
|
+
expect(body).toEqual({ id: "self", door: "hub", email: "op@example.com" });
|
|
749
|
+
} finally {
|
|
750
|
+
h.cleanup();
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("omits email when the operator row has none", async () => {
|
|
755
|
+
const h = makeHarness();
|
|
756
|
+
try {
|
|
757
|
+
const user = await createUser(h.db, "operator", "any-password", {
|
|
758
|
+
allowMulti: true,
|
|
759
|
+
passwordChanged: true,
|
|
760
|
+
});
|
|
761
|
+
const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
|
|
762
|
+
const res = await handleAccountRoot(withBearer("/account", token), deps(h));
|
|
763
|
+
expect(res.status).toBe(200);
|
|
764
|
+
const body = (await res.json()) as { id: string; email?: string; door: string };
|
|
765
|
+
expect(body).toEqual({ id: "self", door: "hub" });
|
|
766
|
+
expect(body.email).toBeUndefined();
|
|
507
767
|
} finally {
|
|
508
768
|
h.cleanup();
|
|
509
769
|
}
|
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/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
|
+
}
|