@openparachute/hub 0.7.7-rc.6 → 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.
@@ -5,13 +5,15 @@ import { join } from "node:path";
5
5
  import { hubDbPath, openHubDb } from "../hub-db.ts";
6
6
  import {
7
7
  SESSION_COOKIE_NAME,
8
- SESSION_MAX_LIFETIME_MS,
8
+ SESSION_SLIDE_THRESHOLD_MS,
9
+ SESSION_TTL_MS,
9
10
  buildSessionClearCookie,
10
11
  buildSessionCookie,
11
12
  createSession,
12
13
  deleteSession,
13
14
  findSession,
14
15
  parseSessionCookie,
16
+ shouldSlideSession,
15
17
  touchSession,
16
18
  } from "../sessions.ts";
17
19
  import { createUser } from "../users.ts";
@@ -57,11 +59,11 @@ describe("createSession + findSession", () => {
57
59
  try {
58
60
  const epoch = new Date("2026-01-01T00:00:00Z");
59
61
  const s = createSession(db, { userId, now: () => epoch });
60
- // Past TTL (24h).
61
- const later = new Date(epoch.getTime() + 25 * 3600 * 1000);
62
+ // Past TTL (90 days).
63
+ const later = new Date(epoch.getTime() + SESSION_TTL_MS + 3600 * 1000);
62
64
  expect(findSession(db, s.id, () => later)).toBeNull();
63
65
  // Still valid one second before expiry.
64
- const justBefore = new Date(epoch.getTime() + 24 * 3600 * 1000 - 1000);
66
+ const justBefore = new Date(epoch.getTime() + SESSION_TTL_MS - 1000);
65
67
  expect(findSession(db, s.id, () => justBefore)?.id).toBe(s.id);
66
68
  } finally {
67
69
  cleanup();
@@ -78,60 +80,64 @@ describe("touchSession (sliding renewal)", () => {
78
80
  try {
79
81
  const t0 = new Date("2026-01-01T00:00:00Z");
80
82
  const s = createSession(db, { userId, now: () => t0 });
81
- // Original expiry: t0 + 24h.
82
- expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + DAY);
83
- // Touch 1h later → expiry becomes (t0 + 1h) + 24h.
83
+ // Original expiry: t0 + 90d.
84
+ expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + SESSION_TTL_MS);
85
+ // Touch 1h later → expiry becomes (t0 + 1h) + 90d.
84
86
  const t1 = new Date(t0.getTime() + HOUR);
85
87
  touchSession(db, s.id, () => t1);
86
88
  const found = findSession(db, s.id, () => t1);
87
- expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + DAY);
89
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + SESSION_TTL_MS);
88
90
  } finally {
89
91
  cleanup();
90
92
  }
91
93
  });
92
94
 
93
- test("a touched session outlives the ORIGINAL 24h expiry", async () => {
95
+ test("a touched session outlives the ORIGINAL 90d expiry", async () => {
94
96
  const { db, userId, cleanup } = await makeDb();
95
97
  try {
96
98
  const t0 = new Date("2026-01-01T00:00:00Z");
97
99
  const s = createSession(db, { userId, now: () => t0 });
98
- // Activity at +12h slides expiry to +36h.
99
- touchSession(db, s.id, () => new Date(t0.getTime() + 12 * HOUR));
100
- // At +30h — PAST the original +24h — the session is still alive.
101
- const at30h = new Date(t0.getTime() + 30 * HOUR);
102
- expect(findSession(db, s.id, () => at30h)?.id).toBe(s.id);
100
+ // Activity at +45d slides expiry to +135d.
101
+ touchSession(db, s.id, () => new Date(t0.getTime() + 45 * DAY));
102
+ // At +100d — PAST the original +90d — the session is still alive.
103
+ const at100d = new Date(t0.getTime() + 100 * DAY);
104
+ expect(findSession(db, s.id, () => at100d)?.id).toBe(s.id);
103
105
  } finally {
104
106
  cleanup();
105
107
  }
106
108
  });
107
109
 
108
- test("an UNtouched session still expires at the original 24h", async () => {
110
+ test("an UNtouched session still expires at the original 90d", async () => {
109
111
  const { db, userId, cleanup } = await makeDb();
110
112
  try {
111
113
  const t0 = new Date("2026-01-01T00:00:00Z");
112
114
  const s = createSession(db, { userId, now: () => t0 });
113
- // No touch — at +25h it's gone (today's absolute-TTL behavior preserved
114
- // for idle / closed tabs that stop re-minting).
115
- const at25h = new Date(t0.getTime() + 25 * HOUR);
116
- expect(findSession(db, s.id, () => at25h)).toBeNull();
115
+ // No touch — one day past the 90d TTL it's gone (idle / closed tabs
116
+ // that stop re-minting still lapse at the full TTL).
117
+ const past = new Date(t0.getTime() + SESSION_TTL_MS + DAY);
118
+ expect(findSession(db, s.id, () => past)).toBeNull();
117
119
  } finally {
118
120
  cleanup();
119
121
  }
120
122
  });
121
123
 
122
- test("caps at created_at + SESSION_MAX_LIFETIME_MS (sliding can't run forever)", async () => {
124
+ test("NO ceiling (Q4) an actively-touched session rolls forward indefinitely", async () => {
125
+ // The old SESSION_MAX_LIFETIME_MS (30d) absolute cap is gone: repeatedly
126
+ // touching a session keeps sliding its expiry to now + 90d with no upper
127
+ // bound, matching cloud's rolling posture (an idle session still lapses
128
+ // at the TTL; an active one never does).
123
129
  const { db, userId, cleanup } = await makeDb();
124
130
  try {
125
131
  const t0 = new Date("2026-01-01T00:00:00Z");
126
132
  const s = createSession(db, { userId, now: () => t0 });
127
- const ceiling = t0.getTime() + SESSION_MAX_LIFETIME_MS;
128
- // A touch near the ceiling would slide to now + 24h, but the cap pins it.
129
- const nearCeiling = new Date(ceiling - HOUR); // raw slide would be ceiling + 23h
130
- touchSession(db, s.id, () => nearCeiling);
131
- const found = findSession(db, s.id, () => nearCeiling);
132
- expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(ceiling);
133
- // Past the ceiling the session is dead even though it was just "active".
134
- expect(findSession(db, s.id, () => new Date(ceiling + 1000))).toBeNull();
133
+ // Touch well past where the old 30-day ceiling would have pinned it.
134
+ const farOut = new Date(t0.getTime() + 200 * DAY);
135
+ touchSession(db, s.id, () => farOut);
136
+ const found = findSession(db, s.id, () => farOut);
137
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(farOut.getTime() + SESSION_TTL_MS);
138
+ // Still alive nearly 90 more days out, right up to the fresh slide's TTL.
139
+ const stillAlive = new Date(farOut.getTime() + SESSION_TTL_MS - DAY);
140
+ expect(findSession(db, s.id, () => stillAlive)?.id).toBe(s.id);
135
141
  } finally {
136
142
  cleanup();
137
143
  }
@@ -147,6 +153,47 @@ describe("touchSession (sliding renewal)", () => {
147
153
  });
148
154
  });
149
155
 
156
+ describe("shouldSlideSession (bounded slide, G3 twin)", () => {
157
+ test("false for a freshly-created session (full TTL remaining)", async () => {
158
+ const { db, userId, cleanup } = await makeDb();
159
+ try {
160
+ const t0 = new Date("2026-01-01T00:00:00Z");
161
+ const s = createSession(db, { userId, now: () => t0 });
162
+ expect(shouldSlideSession(s, () => t0)).toBe(false);
163
+ } finally {
164
+ cleanup();
165
+ }
166
+ });
167
+
168
+ test("false just before crossing the slide threshold", async () => {
169
+ const { db, userId, cleanup } = await makeDb();
170
+ try {
171
+ const t0 = new Date("2026-01-01T00:00:00Z");
172
+ const s = createSession(db, { userId, now: () => t0 });
173
+ // Elapsed just UNDER the threshold (30d) → remaining life still above
174
+ // (TTL - threshold) → not yet due to slide.
175
+ const justBefore = new Date(t0.getTime() + SESSION_SLIDE_THRESHOLD_MS - 1000);
176
+ expect(shouldSlideSession(s, () => justBefore)).toBe(false);
177
+ } finally {
178
+ cleanup();
179
+ }
180
+ });
181
+
182
+ test("true once remaining life drops below the slide threshold", async () => {
183
+ const { db, userId, cleanup } = await makeDb();
184
+ try {
185
+ const t0 = new Date("2026-01-01T00:00:00Z");
186
+ const s = createSession(db, { userId, now: () => t0 });
187
+ // Elapsed just OVER the threshold (30d) → remaining life below
188
+ // (TTL - threshold) → due to slide.
189
+ const justAfter = new Date(t0.getTime() + SESSION_SLIDE_THRESHOLD_MS + 1000);
190
+ expect(shouldSlideSession(s, () => justAfter)).toBe(true);
191
+ } finally {
192
+ cleanup();
193
+ }
194
+ });
195
+ });
196
+
150
197
  describe("deleteSession", () => {
151
198
  test("removes the session row", async () => {
152
199
  const { db, userId, cleanup } = await makeDb();
@@ -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: { "content-type": "application/json", "cache-control": "no-store" },
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. Public, no auth it is what lets the app
204
- * render honestly per door (D2): `billing:false` + `plans:[]` means the app
205
- * shows no billing/upgrade UI on self-host; `caps_writable:true` means the
206
- * operator can PUT caps freely (the cloud twin is plan-derived → false).
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
- * `import`/`export` describe the self-host DOOR's portability capability
209
- * (which it has). The `/account/vaults/<name>/export` + `/account/vaults/import`
210
- * REST endpoints are a follow-on to this PR (H2 ships the vault-lifecycle +
211
- * caps core); the flags stay true because the door supports portability.
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(req: Request, deps: { issuer: string }): Response {
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 descriptor = {
216
- door: "self-host",
217
- issuer: deps.issuer.replace(/\/$/, ""),
218
- account_token: { endpoint: "/account/token", method: "POST", scheme: "cookie" },
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
- vault_create: true,
221
- vault_delete: true,
222
- import: true,
223
- export: true,
224
- billing: false,
225
- plans: [] as string[],
226
- modules: true,
227
- expose: true,
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
- /** `{ account_id, email, door }`. On self-host the account id is `self`. */
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
- return json(200, {
250
- account_id: "self",
251
- email: user?.email ?? null,
252
- door: "self-host",
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
- // 201 on a fresh create; 200 on an idempotent re-POST of an existing vault
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`. Each must be `vault:<name>:<verb>`
408
- * for THIS vault with a mintable verb. Omitted / empty → default read+write.
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
- if (requested === undefined || requested === null) return { ok: true, scopes: defaultScopes };
426
- if (!Array.isArray(requested) || requested.some((s) => typeof s !== "string")) {
427
- return {
428
- ok: false,
429
- status: 400,
430
- error: "invalid_request",
431
- message: '"scopes" must be an array of strings',
432
- };
433
- }
434
- const scopes = requested as string[];
435
- if (scopes.length === 0) return { ok: true, scopes: defaultScopes };
436
- for (const s of scopes) {
437
- const parts = s.split(":");
438
- if (
439
- parts.length !== 3 ||
440
- parts[0] !== "vault" ||
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(
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `GET /account/session` — the same-origin boot oracle (hub-parity P1; the
3
+ * hub's twin of cloud `account-session.ts`). Returns `{ signed_in, csrf, ... }`
4
+ * so the same-origin `parachute-app` — served at the hub's own origin — can
5
+ * (a) decide whether to render the signed-in UI or the sign-in ceremony, and
6
+ * (b) echo `csrf` back as `__csrf` on its next same-origin `POST /account/token`
7
+ * mint (C2). The CSRF cookie is `HttpOnly` (csrf.ts), so the SPA can't read it
8
+ * directly; this hands the token over the same way a server-rendered form's
9
+ * hidden input does.
10
+ *
11
+ * PUBLIC — no Bearer, no scope gate. It drives the `parachute_hub_session`
12
+ * cookie directly, same as `/api/me`: a client with no account Bearer yet has
13
+ * nothing else to authenticate a read with, and this endpoint IS the
14
+ * bootstrap. Mounted ABOVE the force-change-password gate (hub-server.ts
15
+ * CHOKE POINT 1) — it's a pure state oracle, not a mutating surface, so a
16
+ * pre-rotation user must still be able to read it (the app needs `signed_in` +
17
+ * `csrf` to drive the rotation ceremony itself, not get walled off before it
18
+ * can even ask). `POST /account/token` stays BELOW the gate — a pre-rotation
19
+ * user hits its 403 `force_change_password` there, and the app is expected to
20
+ * weather that (P4).
21
+ *
22
+ * SAME-ORIGIN ONLY, by design — mirrors cloud's posture exactly: the response
23
+ * is CREDENTIALED (it reflects the session cookie and sets the CSRF cookie),
24
+ * so it carries NO CORS headers. A cross-origin caller simply gets no
25
+ * `access-control-allow-origin`, so the browser blocks it from reading the
26
+ * body — intended, not an oversight.
27
+ */
28
+ import type { Database } from "bun:sqlite";
29
+ import { ensureCsrfToken } from "./csrf.ts";
30
+ import { isHttpsRequest } from "./request-protocol.ts";
31
+ import {
32
+ SESSION_TTL_MS,
33
+ buildSessionCookie,
34
+ findActiveSession,
35
+ shouldSlideSession,
36
+ touchSession,
37
+ } from "./sessions.ts";
38
+ import { getUserById } from "./users.ts";
39
+
40
+ export interface AccountSessionDeps {
41
+ db: Database;
42
+ }
43
+
44
+ /** Matches the `/account/*` 405 shape (`account-api.ts`'s `methodNotAllowed`)
45
+ * — same `{error, message}` body + `Allow` header, kept local to this file
46
+ * per the one-handler-one-file convention (`api-me.ts`, `account-token.ts`). */
47
+ function methodNotAllowed(allow: string): Response {
48
+ return new Response(JSON.stringify({ error: "method_not_allowed", message: `use ${allow}` }), {
49
+ status: 405,
50
+ headers: { "content-type": "application/json", allow },
51
+ });
52
+ }
53
+
54
+ /**
55
+ * A credentialed JSON reply that can carry MULTIPLE Set-Cookie headers (the
56
+ * CSRF mint and the session slide's re-issue can coincide — a plain
57
+ * `Record`-keyed header init can't hold two `set-cookie` values). NO CORS
58
+ * headers (see the module note); `no-store` so a back/forward navigation
59
+ * never replays a stale csrf/session state. Mirrors cloud's `sessionJson`.
60
+ */
61
+ function sessionJson(body: unknown, cookies: readonly string[]): Response {
62
+ const headers = new Headers({ "content-type": "application/json", "cache-control": "no-store" });
63
+ for (const c of cookies) headers.append("set-cookie", c);
64
+ return new Response(JSON.stringify(body), { status: 200, headers });
65
+ }
66
+
67
+ export function handleAccountSession(req: Request, deps: AccountSessionDeps): Response {
68
+ if (req.method !== "GET") return methodNotAllowed("GET");
69
+
70
+ // CSRF token for BOTH branches (G2 — anonymous CSRF; the piece `api-me.ts`
71
+ // lacks, since it mints only when signed in). ensureCsrfToken reuses an
72
+ // existing CSRF cookie or mints one (setCookie only when it minted) — it
73
+ // works WITHOUT a session and authorizes NOTHING alone (it's one half of a
74
+ // double-submit that also needs the matching cookie + same-origin + a live
75
+ // session at the mint gate it protects). Returning it on the signed-OUT
76
+ // branch lets the app run the sign-in moment in-app.
77
+ const csrf = ensureCsrfToken(req);
78
+ const cookies: string[] = [];
79
+ if (csrf.setCookie) cookies.push(csrf.setCookie);
80
+
81
+ // findActiveSession is the single chokepoint: it refuses an expired row (a
82
+ // logged-out row is gone outright). Nothing below rolls or reads a
83
+ // dead/absent session.
84
+ const session = findActiveSession(deps.db, req);
85
+ if (!session) {
86
+ return sessionJson({ signed_in: false, csrf: csrf.token }, cookies);
87
+ }
88
+
89
+ // Bounded slide (the G3 twin, and the reason NOT to call bare
90
+ // `touchSession` unconditionally here): this endpoint is POLLED — the
91
+ // app's `/check-email` screen hits it every few seconds while it waits for
92
+ // a magic-link-style click — so sliding only past
93
+ // `SESSION_SLIDE_THRESHOLD_MS` of remaining life bounds the write (and the
94
+ // cookie re-issue) to ~once per threshold per session, not per request.
95
+ if (shouldSlideSession(session)) {
96
+ touchSession(deps.db, session.id);
97
+ cookies.push(
98
+ buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
99
+ secure: isHttpsRequest(req),
100
+ }),
101
+ );
102
+ }
103
+
104
+ const user = getUserById(deps.db, session.userId);
105
+ if (!user) {
106
+ // Session row points at a deleted user — treat as signed-out (the
107
+ // `api-me.ts` precedent) rather than surface a stale identity. Any slide
108
+ // cookie computed above still rides along; it's harmless (the row is
109
+ // about to be orphaned regardless) and keeping the branch simple beats
110
+ // special-casing a cookie rollback for a should-never-happen state.
111
+ return sessionJson({ signed_in: false, csrf: csrf.token }, cookies);
112
+ }
113
+
114
+ return sessionJson(
115
+ {
116
+ signed_in: true,
117
+ csrf: csrf.token,
118
+ // users.email is null-by-history (migration v15) — username is always
119
+ // present, email only when the row has one (B2 signup capture).
120
+ username: user.username,
121
+ ...(user.email ? { email: user.email } : {}),
122
+ account_created_at: user.createdAt,
123
+ // HUB EXTRA — additive, not part of the door-contract pin
124
+ // (`checkAccountSessionResponse` tolerates extra fields): lets the app
125
+ // hop a temp-password user straight to the change-password ceremony
126
+ // instead of hitting the force-change-password 403 wall on their next
127
+ // `/account/*` call.
128
+ ...(user.passwordChanged === false ? { password_change_required: true } : {}),
129
+ },
130
+ cookies,
131
+ );
132
+ }
@@ -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({