@openparachute/hub 0.7.7-rc.5 → 0.7.7-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.7-rc.5",
3
+ "version": "0.7.7-rc.7",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -0,0 +1,244 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { checkAccountSessionResponse } from "@openparachute/door-contract";
7
+ import { handleAccountSession } from "../account-session.ts";
8
+ import { CSRF_COOKIE_NAME, buildCsrfCookie, generateCsrfToken, parseCsrfCookie } from "../csrf.ts";
9
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
10
+ import {
11
+ SESSION_COOKIE_NAME,
12
+ SESSION_SLIDE_THRESHOLD_MS,
13
+ SESSION_TTL_MS,
14
+ buildSessionCookie,
15
+ createSession,
16
+ findSession,
17
+ } from "../sessions.ts";
18
+ import { createUser } from "../users.ts";
19
+
20
+ /**
21
+ * Tests for `GET /account/session` — the same-origin boot oracle (hub-parity
22
+ * P1, the twin of cloud's `account-session.ts`). Covers:
23
+ * - both branches' body shape (signed-out / signed-in).
24
+ * - CSRF minted when absent, reused (no re-Set-Cookie) when present — on
25
+ * BOTH branches (the G2 anonymous-CSRF invariant).
26
+ * - a deleted-user session row falls back to signed-out.
27
+ * - the bounded slide: an old-but-live session rolls + re-issues the
28
+ * cookie; a fresh session does neither.
29
+ * - 405 on non-GET, `cache-control: no-store`, no CORS headers.
30
+ * - drift: `checkAccountSessionResponse` reports zero issues against the
31
+ * live handler on both branches.
32
+ */
33
+ const ISSUER = "https://hub.test";
34
+
35
+ interface Harness {
36
+ db: Database;
37
+ cleanup: () => void;
38
+ }
39
+
40
+ function makeHarness(): Harness {
41
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-session-"));
42
+ const db = openHubDb(hubDbPath(dir));
43
+ return {
44
+ db,
45
+ cleanup: () => {
46
+ db.close();
47
+ rmSync(dir, { recursive: true, force: true });
48
+ },
49
+ };
50
+ }
51
+
52
+ let harness: Harness;
53
+ beforeEach(() => {
54
+ harness = makeHarness();
55
+ });
56
+ afterEach(() => {
57
+ harness.cleanup();
58
+ });
59
+
60
+ function getReq(cookie?: string, method = "GET"): Request {
61
+ return new Request(`${ISSUER}/account/session`, {
62
+ method,
63
+ ...(cookie ? { headers: { cookie } } : {}),
64
+ });
65
+ }
66
+
67
+ function sessionCookiePair(sid: string): string {
68
+ return buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000));
69
+ }
70
+
71
+ describe("handleAccountSession — signed-out branch", () => {
72
+ test("no cookie at all → {signed_in:false, csrf} + CSRF Set-Cookie minted", async () => {
73
+ const res = handleAccountSession(getReq(), { db: harness.db });
74
+ expect(res.status).toBe(200);
75
+ const body = (await res.json()) as { signed_in: boolean; csrf: string };
76
+ expect(body.signed_in).toBe(false);
77
+ expect(typeof body.csrf).toBe("string");
78
+ expect(body.csrf.length).toBeGreaterThan(0);
79
+
80
+ const setCookie = res.headers.getSetCookie();
81
+ const csrfSet = setCookie.find((c) => c.includes(CSRF_COOKIE_NAME));
82
+ expect(csrfSet).toBeDefined();
83
+ expect(parseCsrfCookie(csrfSet ?? null)).toBe(body.csrf);
84
+ });
85
+
86
+ test("reuses an existing CSRF cookie without re-minting (no Set-Cookie)", async () => {
87
+ const existing = generateCsrfToken();
88
+ const cookie = buildCsrfCookie(existing).split(";")[0] ?? "";
89
+ const res = handleAccountSession(getReq(cookie), { db: harness.db });
90
+ expect(res.status).toBe(200);
91
+ const body = (await res.json()) as { signed_in: boolean; csrf: string };
92
+ expect(body.signed_in).toBe(false);
93
+ expect(body.csrf).toBe(existing);
94
+ expect(res.headers.get("set-cookie")).toBeNull();
95
+ });
96
+
97
+ test("a session cookie naming a deleted user → signed-out", async () => {
98
+ // The on-disk FK normally guards against a session outliving its user, so
99
+ // forge the orphaned row directly with `PRAGMA foreign_keys=OFF` (the
100
+ // `api-account.test.ts` precedent) — simulating a delete-user-via-SQL-shell
101
+ // race, not something reachable through the app's own APIs.
102
+ const sessionId = "test-orphaned-session-id-base64url";
103
+ harness.db.exec("PRAGMA foreign_keys = OFF");
104
+ try {
105
+ harness.db
106
+ .prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
107
+ .run(
108
+ sessionId,
109
+ "nonexistent-user-uuid",
110
+ new Date(Date.now() + SESSION_TTL_MS).toISOString(),
111
+ new Date().toISOString(),
112
+ );
113
+ } finally {
114
+ harness.db.exec("PRAGMA foreign_keys = ON");
115
+ }
116
+ const cookie = sessionCookiePair(sessionId);
117
+ const res = handleAccountSession(getReq(cookie), { db: harness.db });
118
+ expect(res.status).toBe(200);
119
+ const body = (await res.json()) as { signed_in: boolean };
120
+ expect(body.signed_in).toBe(false);
121
+ });
122
+
123
+ test("drift: checkAccountSessionResponse reports zero issues (signed-out)", async () => {
124
+ const res = handleAccountSession(getReq(), { db: harness.db });
125
+ const body = (await res.json()) as Record<string, unknown>;
126
+ expect(checkAccountSessionResponse(body, { signedIn: false })).toEqual([]);
127
+ });
128
+ });
129
+
130
+ describe("handleAccountSession — signed-in branch", () => {
131
+ test("username always present; no email when the user row has none", async () => {
132
+ const user = await createUser(harness.db, "operator", "pw", { passwordChanged: true });
133
+ const session = createSession(harness.db, { userId: user.id });
134
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
135
+ expect(res.status).toBe(200);
136
+ const body = (await res.json()) as Record<string, unknown>;
137
+ expect(body.signed_in).toBe(true);
138
+ expect(body.username).toBe("operator");
139
+ expect(body.email).toBeUndefined();
140
+ expect(body.account_created_at).toBe(user.createdAt);
141
+ expect(body.password_change_required).toBeUndefined();
142
+ });
143
+
144
+ test("email present when the user row has one", async () => {
145
+ const user = await createUser(harness.db, "withmail", "pw", {
146
+ passwordChanged: true,
147
+ email: "friend@example.com",
148
+ });
149
+ const session = createSession(harness.db, { userId: user.id });
150
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
151
+ const body = (await res.json()) as Record<string, unknown>;
152
+ expect(body.email).toBe("friend@example.com");
153
+ expect(body.username).toBe("withmail");
154
+ });
155
+
156
+ test("password_change_required:true for a not-yet-rotated (passwordChanged:false) user", async () => {
157
+ // createUser defaults passwordChanged to false — the admin-created-user
158
+ // posture (force-redirect on first sign-in).
159
+ const user = await createUser(harness.db, "temp", "pw");
160
+ const session = createSession(harness.db, { userId: user.id });
161
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
162
+ const body = (await res.json()) as Record<string, unknown>;
163
+ expect(body.signed_in).toBe(true);
164
+ expect(body.password_change_required).toBe(true);
165
+ });
166
+
167
+ test("mints CSRF when absent even on the signed-in branch", async () => {
168
+ const user = await createUser(harness.db, "operator2", "pw", { passwordChanged: true });
169
+ const session = createSession(harness.db, { userId: user.id });
170
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
171
+ const body = (await res.json()) as { csrf: string };
172
+ expect(typeof body.csrf).toBe("string");
173
+ expect(body.csrf.length).toBeGreaterThan(0);
174
+ const setCookie = res.headers.getSetCookie();
175
+ expect(setCookie.some((c) => c.includes(CSRF_COOKIE_NAME))).toBe(true);
176
+ });
177
+
178
+ test("drift: checkAccountSessionResponse reports zero issues (signed-in)", async () => {
179
+ const user = await createUser(harness.db, "driftcheck", "pw", { passwordChanged: true });
180
+ const session = createSession(harness.db, { userId: user.id });
181
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
182
+ const body = (await res.json()) as Record<string, unknown>;
183
+ expect(checkAccountSessionResponse(body, { signedIn: true })).toEqual([]);
184
+ });
185
+ });
186
+
187
+ describe("handleAccountSession — bounded slide", () => {
188
+ test("an old-but-live session (within the slide threshold of expiry) rolls + re-issues the cookie", async () => {
189
+ const user = await createUser(harness.db, "slider", "pw", { passwordChanged: true });
190
+ const t0 = new Date();
191
+ // Created far enough in the past that remaining life < TTL - threshold.
192
+ const createdAt = new Date(
193
+ t0.getTime() - (SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS + 60_000),
194
+ );
195
+ const session = createSession(harness.db, { userId: user.id, now: () => createdAt });
196
+ const originalExpiry = new Date(session.expiresAt).getTime();
197
+
198
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
199
+ expect(res.status).toBe(200);
200
+ const setCookie = res.headers.getSetCookie();
201
+ const sessionSet = setCookie.find((c) => c.includes(SESSION_COOKIE_NAME));
202
+ expect(sessionSet).toBeDefined();
203
+ expect(sessionSet).toContain("HttpOnly");
204
+ expect(sessionSet).toContain("SameSite=Lax");
205
+
206
+ const found = findSession(harness.db, session.id);
207
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
208
+ });
209
+
210
+ test("a fresh session does NOT slide — no session Set-Cookie, no DB write", async () => {
211
+ const user = await createUser(harness.db, "fresh", "pw", { passwordChanged: true });
212
+ const session = createSession(harness.db, { userId: user.id });
213
+ const originalExpiry = session.expiresAt;
214
+
215
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
216
+ expect(res.status).toBe(200);
217
+ const setCookie = res.headers.getSetCookie();
218
+ expect(setCookie.some((c) => c.includes(SESSION_COOKIE_NAME))).toBe(false);
219
+
220
+ const found = findSession(harness.db, session.id);
221
+ expect(found?.expiresAt).toBe(originalExpiry);
222
+ });
223
+ });
224
+
225
+ describe("handleAccountSession — method + headers", () => {
226
+ test("405 on POST", async () => {
227
+ const res = handleAccountSession(getReq(undefined, "POST"), { db: harness.db });
228
+ expect(res.status).toBe(405);
229
+ expect(res.headers.get("allow")).toBe("GET");
230
+ });
231
+
232
+ test("cache-control: no-store on the 200 path", async () => {
233
+ const res = handleAccountSession(getReq(), { db: harness.db });
234
+ expect(res.headers.get("cache-control")).toBe("no-store");
235
+ });
236
+
237
+ test("no access-control-allow-origin (same-origin only, no CORS)", async () => {
238
+ const req = new Request(`${ISSUER}/account/session`, {
239
+ headers: { origin: "https://evil.example" },
240
+ });
241
+ const res = handleAccountSession(req, { db: harness.db });
242
+ expect(res.headers.get("access-control-allow-origin")).toBeNull();
243
+ });
244
+ });
@@ -603,4 +603,29 @@ describe("handleAdminLogoutPost (#113)", () => {
603
603
  expect(res.headers.get("location")).toBe("/login");
604
604
  expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
605
605
  });
606
+
607
+ // App-wire pin (hub-parity P1, §8-M4): the app's `logout()` posts form-
608
+ // encoded, not JSON (`parachute-app/src/lib/account/client.ts:246-258`:
609
+ // `content-type: application/x-www-form-urlencoded`, body
610
+ // `new URLSearchParams({ __csrf: csrf }).toString()`, credentials included).
611
+ // No hub code changed for this — the existing formData()-based handler
612
+ // already accepts exactly this shape; this test freezes the byte-shape so a
613
+ // future refactor of the handler can't silently drop form-body support out
614
+ // from under the app.
615
+ test("app-wire pin — form-encoded body matching client.ts's exact logout() shape", async () => {
616
+ const cookie = await cookieForUser(harness.db, "appuser", "pw");
617
+ const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
618
+ const req = new Request("http://hub.test/logout", {
619
+ method: "POST",
620
+ headers: {
621
+ cookie,
622
+ "content-type": "application/x-www-form-urlencoded",
623
+ },
624
+ body: new URLSearchParams({ [CSRF_FIELD_NAME]: TEST_CSRF }).toString(),
625
+ });
626
+ const res = await handleAdminLogoutPost(harness.db, req);
627
+ expect(res.status).toBe(302);
628
+ expect(res.headers.get("location")).toBe("/login");
629
+ expect(findSession(harness.db, sid)).toBeNull();
630
+ });
606
631
  });
@@ -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();
@@ -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/hub-server.ts CHANGED
@@ -114,6 +114,13 @@
114
114
  * (hub#473; reached after a correct
115
115
  * password for a 2FA-enrolled user)
116
116
  * /logout (POST) → end admin session
117
+ * /account/session (GET) → same-origin boot oracle {signed_in,
118
+ * csrf,...} (hub-parity P1); cookie-gated,
119
+ * NOT Bearer — mounted ABOVE the
120
+ * force-change-password gate below (a pure
121
+ * state oracle, so a pre-rotation user can
122
+ * still read it to drive the rotation
123
+ * ceremony itself)
117
124
  * /account/change-password (GET + POST) → user self-service change-password
118
125
  * (force-redirect target for users
119
126
  * with password_changed=false; also
@@ -122,7 +129,8 @@
122
129
  * + the per-vault proxy is hard-gated
123
130
  * per-request on password_changed===true
124
131
  * (forceChangePasswordGate); only this
125
- * route and /logout stay reachable
132
+ * route, /logout, and /account/session
133
+ * (P1, also above the gate) stay reachable
126
134
  * pre-rotation.
127
135
  * /account/token (POST) → cookie→account-bearer mint (App
128
136
  * campaign Phase 2 H1): session cookie →
@@ -190,6 +198,7 @@ import {
190
198
  handleAccountSetVaultCaps,
191
199
  requireAnyScope,
192
200
  } from "./account-api.ts";
201
+ import { type AccountSessionDeps, handleAccountSession } from "./account-session.ts";
193
202
  import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
194
203
  import { handleAccountToken } from "./account-token.ts";
195
204
  import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
@@ -3746,6 +3755,20 @@ export function hubFetch(
3746
3755
  return new Response("method not allowed", { status: 405 });
3747
3756
  }
3748
3757
 
3758
+ // /account/session — the same-origin boot oracle (hub-parity P1). A
3759
+ // PURE STATE ORACLE, not a mutating surface, so it is mounted ABOVE the
3760
+ // force-change-password gate immediately below: a pre-rotation user
3761
+ // must still be able to read `{signed_in, csrf}` to drive the rotation
3762
+ // ceremony itself, rather than get walled off before it can even ask.
3763
+ // `/account/token` (below the gate) stays gated — a pre-rotation user
3764
+ // hits its 403 `force_change_password` there instead. See
3765
+ // `account-session.ts`.
3766
+ if (pathname === "/account/session") {
3767
+ if (!getDb) return dbNotConfigured();
3768
+ const sessionDeps: AccountSessionDeps = { db: getDb() };
3769
+ return handleAccountSession(req, sessionDeps);
3770
+ }
3771
+
3749
3772
  // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
3750
3773
  // every `/account/*` route BELOW this line is gated. `/logout` and
3751
3774
  // `/account/change-password` (the rotation/exit path) ran above and already
package/src/sessions.ts CHANGED
@@ -4,12 +4,21 @@
4
4
  * with that cookie skip the login form and go straight to consent.
5
5
  *
6
6
  * Stored in `sessions` (one row per active session), so logout / forced
7
- * revocation is just a delete. Sessions are SLIDING: `expires_at` starts at
8
- * `created_at + SESSION_TTL_MS`, and {@link touchSession} pushes it forward on
9
- * genuine activity (the admin SPA re-mints `/admin/host-admin-token` every
10
- * ~10 min while a tab is open). An idle session — no more mints — still
11
- * expires at the original 24h mark, and {@link SESSION_MAX_LIFETIME_MS} caps
12
- * total life so sliding can't keep a left-open tab alive forever.
7
+ * revocation is just a delete. Sessions are ROLLING (hub-parity P1, Q4 —
8
+ * adopts cloud's posture): `expires_at` starts at `created_at + SESSION_TTL_MS`
9
+ * (90 days), and {@link touchSession} pushes it forward on genuine activity
10
+ * (the admin SPA re-mints `/admin/host-admin-token` every ~10 min while a tab
11
+ * is open; `GET /account/session`, the app's boot/poll oracle, slides once it
12
+ * crosses {@link SESSION_SLIDE_THRESHOLD_MS} of its life see
13
+ * `account-session.ts`). There is NO absolute ceiling: an ACTIVELY-used
14
+ * session rolls forward indefinitely, while an idle one (no more touches)
15
+ * still lapses at the 90-day mark. Was 24h sliding / 30d hard cap through
16
+ * 2026-07; the cap is gone. The ONLY thing that terminates a session row today
17
+ * is logout (which deletes it). Note what does NOT: the admin screen-lock gates
18
+ * token MINTS (it does not touch or expire the session row), and per-user
19
+ * session revocation does not exist yet (P6-era). So an actively-used session
20
+ * — including a stolen cookie — currently lives on until 90 idle days or an
21
+ * explicit logout; a per-user revoke lever is the standing gap to close.
13
22
  *
14
23
  * The cookie value is the session id directly. It's a 32-byte base64url
15
24
  * random; collision is statistically impossible. No HMAC needed because the
@@ -20,16 +29,25 @@ import type { Database } from "bun:sqlite";
20
29
  import { randomBytes } from "node:crypto";
21
30
 
22
31
  export const SESSION_COOKIE_NAME = "parachute_hub_session";
23
- export const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
32
+ /**
33
+ * Session lifetime — 90 days (hub-parity P1, Q4: adopts cloud's rolling
34
+ * posture). A session (and its cookie Max-Age) lasts 90 days from its last
35
+ * slide; {@link findSession} still expires it hard past this, and logout
36
+ * deletes the row outright. Was 24h (sliding, 30d hard cap) prior to this PR.
37
+ */
38
+ export const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000;
24
39
 
25
40
  /**
26
- * Absolute ceiling on a session's total lifetime, independent of sliding
27
- * renewal. Sliding ({@link touchSession}) keeps an active console signed in,
28
- * but a left-open-but-idle tab whose background polls keep re-minting must
29
- * still be force-logged-out eventually this caps life at
30
- * `created_at + SESSION_MAX_LIFETIME_MS` so renewal can't extend forever.
41
+ * Roll a session forward once it has been used past this much of its life
42
+ * (30 days) so an ACTIVE session stays alive indefinitely, while an idle one
43
+ * still expires at the full 90-day TTL. Callers that slide on every touch
44
+ * (the admin SPA's host-admin-token re-mint) don't need this threshold; it's
45
+ * for a POLLING caller like `GET /account/session` (the app's `/check-email`
46
+ * screen hits it every few seconds) that must NOT rewrite the row on every
47
+ * request — see `shouldSlideSession` there. Mirrors cloud's
48
+ * `SESSION_REFRESH_THRESHOLD_MS` (`workers/identity/src/sessions.ts`).
31
49
  */
32
- export const SESSION_MAX_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
50
+ export const SESSION_SLIDE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
33
51
 
34
52
  export interface Session {
35
53
  id: string;
@@ -91,30 +109,33 @@ export function findSession(
91
109
  }
92
110
 
93
111
  /**
94
- * Slide a session's expiry forward to `now + SESSION_TTL_MS`, capped at
95
- * `created_at + SESSION_MAX_LIFETIME_MS`. No-op when the session doesn't exist.
112
+ * Slide a session's expiry forward to `now + SESSION_TTL_MS`. No-op when the
113
+ * session doesn't exist. NO ceiling (hub-parity P1, Q4) cloud's posture,
114
+ * adopted here: an ACTIVELY-used session rolls forward forever; a closed tab
115
+ * / idle client (no more touches) still expires `SESSION_TTL_MS` after its
116
+ * last activity. The old absolute cap (`SESSION_MAX_LIFETIME_MS`, 30d) is
117
+ * removed — the bounds on a rolling session are logout, the admin
118
+ * screen-lock, and (P6-era) per-user delete, not a lifetime ceiling.
96
119
  *
97
- * This is what makes sessions sliding rather than fixed-24h: the admin SPA
98
- * re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is open,
99
- * and each successful mint calls this so an actively-used console isn't
100
- * hard-logged-out at the 24h mark, while a closed tab (no more mints) still
101
- * expires 24h after its last activity. The ceiling bounds a left-open-but-idle
102
- * tab (background polls keep re-minting) so sliding can't run forever.
120
+ * This is what makes sessions sliding rather than fixed-TTL: the admin SPA
121
+ * re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is
122
+ * open, and each successful mint calls this unconditionally; `GET
123
+ * /account/session` (the app's boot/poll oracle) instead calls this only
124
+ * past `SESSION_SLIDE_THRESHOLD_MS` of remaining life (bounded slide see
125
+ * `account-session.ts`), since it's polled every few seconds and an
126
+ * unconditional touch there would rewrite the row on every poll.
103
127
  *
104
128
  * Monotonic in practice: the production wall clock only moves forward, so the
105
- * slid value never undershoots a previously-written expiry; once it reaches the
106
- * ceiling it stays pinned there. (The write is unconditional it does not read
107
- * the current expiry — so an injected backward `now` in tests would shorten the
108
- * session: a conservative failure mode, not a security issue.) `now` is
109
- * injectable for tests, matching {@link findSession}.
129
+ * slid value never undershoots a previously-written expiry. (The write is
130
+ * unconditional it does not read the current expiryso an injected
131
+ * backward `now` in tests would shorten the session: a conservative failure
132
+ * mode, not a security issue.) `now` is injectable for tests, matching
133
+ * {@link findSession}.
110
134
  */
111
135
  export function touchSession(db: Database, id: string, now: () => Date = () => new Date()): void {
112
136
  const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
113
137
  if (!row) return;
114
- const nowMs = now().getTime();
115
- const slidMs = nowMs + SESSION_TTL_MS;
116
- const ceilingMs = new Date(row.created_at).getTime() + SESSION_MAX_LIFETIME_MS;
117
- const newExpiresAt = new Date(Math.min(slidMs, ceilingMs)).toISOString();
138
+ const newExpiresAt = new Date(now().getTime() + SESSION_TTL_MS).toISOString();
118
139
  db.prepare("UPDATE sessions SET expires_at = ? WHERE id = ?").run(newExpiresAt, id);
119
140
  }
120
141
 
@@ -122,6 +143,21 @@ export function deleteSession(db: Database, id: string): void {
122
143
  db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
123
144
  }
124
145
 
146
+ /**
147
+ * Should this live session be rolled forward? True once it has been used past
148
+ * {@link SESSION_SLIDE_THRESHOLD_MS} of its life — i.e. its remaining life has
149
+ * dropped below `SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS`. A
150
+ * freshly-created/-slid session is NOT slid; one that's crossed the threshold
151
+ * is. Pure — the caller (`account-session.ts`'s bounded slide, the G3 twin of
152
+ * cloud's `shouldSlideSession`) does the write ({@link touchSession}) + cookie
153
+ * re-issue only when this returns true, bounding both to ~once per threshold
154
+ * per session even under frequent polling.
155
+ */
156
+ export function shouldSlideSession(session: Session, now: () => Date = () => new Date()): boolean {
157
+ const remainingMs = new Date(session.expiresAt).getTime() - now().getTime();
158
+ return remainingMs < SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS;
159
+ }
160
+
125
161
  /**
126
162
  * Build a `Set-Cookie` header value for the given session id. HttpOnly +
127
163
  * SameSite=Lax + Secure (conditional) + Path=/.