@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.
- package/package.json +1 -1
- package/src/__tests__/account-api.test.ts +287 -27
- package/src/__tests__/account-session.test.ts +244 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/account-api.ts +111 -66
- package/src/account-session.ts +132 -0
- package/src/api-invites.ts +19 -0
- package/src/hub-server.ts +35 -5
- package/src/hub-settings.ts +15 -1
- package/src/invites.ts +42 -0
- package/src/sessions.ts +66 -30
|
@@ -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
|
-
|
|
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 (
|
|
61
|
-
const later = new Date(epoch.getTime() +
|
|
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() +
|
|
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 +
|
|
82
|
-
expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() +
|
|
83
|
-
// Touch 1h later → expiry becomes (t0 + 1h) +
|
|
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() +
|
|
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
|
|
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 +
|
|
99
|
-
touchSession(db, s.id, () => new Date(t0.getTime() +
|
|
100
|
-
// At +
|
|
101
|
-
const
|
|
102
|
-
expect(findSession(db, 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
|
|
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 —
|
|
114
|
-
//
|
|
115
|
-
const
|
|
116
|
-
expect(findSession(db, s.id, () =>
|
|
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("
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
expect(findSession(db, s.id, () =>
|
|
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();
|
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(
|
|
@@ -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
|
+
}
|
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({
|