@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/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";
@@ -2853,7 +2862,13 @@ export function hubFetch(
2853
2862
  if (req.method === "OPTIONS") {
2854
2863
  return new Response(null, { status: 204, headers: corsHeaders });
2855
2864
  }
2856
- const res = handleAccountCapabilities(req, { issuer: oauthDeps(req).issuer });
2865
+ if (!getDb) {
2866
+ return new Response('{"error":"account descriptor unavailable: db not configured"}', {
2867
+ status: 503,
2868
+ headers: { "content-type": "application/json", ...corsHeaders },
2869
+ });
2870
+ }
2871
+ const res = handleAccountCapabilities(req, { db: getDb(), issuer: oauthDeps(req).issuer });
2857
2872
  const merged = new Headers(res.headers);
2858
2873
  for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
2859
2874
  return new Response(res.body, { status: res.status, headers: merged });
@@ -3746,6 +3761,20 @@ export function hubFetch(
3746
3761
  return new Response("method not allowed", { status: 405 });
3747
3762
  }
3748
3763
 
3764
+ // /account/session — the same-origin boot oracle (hub-parity P1). A
3765
+ // PURE STATE ORACLE, not a mutating surface, so it is mounted ABOVE the
3766
+ // force-change-password gate immediately below: a pre-rotation user
3767
+ // must still be able to read `{signed_in, csrf}` to drive the rotation
3768
+ // ceremony itself, rather than get walled off before it can even ask.
3769
+ // `/account/token` (below the gate) stays gated — a pre-rotation user
3770
+ // hits its 403 `force_change_password` there instead. See
3771
+ // `account-session.ts`.
3772
+ if (pathname === "/account/session") {
3773
+ if (!getDb) return dbNotConfigured();
3774
+ const sessionDeps: AccountSessionDeps = { db: getDb() };
3775
+ return handleAccountSession(req, sessionDeps);
3776
+ }
3777
+
3749
3778
  // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
3750
3779
  // every `/account/*` route BELOW this line is gated. `/logout` and
3751
3780
  // `/account/change-password` (the rotation/exit path) ran above and already
@@ -3937,9 +3966,10 @@ export function hubFetch(
3937
3966
  }
3938
3967
  return new Response("method not allowed", { status: 405 });
3939
3968
  }
3940
- // GET /account (Bearer) — account bootstrap {account_id,email,door}. Only
3941
- // intercepts when an Authorization header is present, so the cookie-authed
3942
- // HTML account home at `/account`/`/account/` below stays unaffected.
3969
+ // GET /account (Bearer) — account bootstrap {id,email,door} (the
3970
+ // door-contract's AccountBootstrap). Only intercepts when an
3971
+ // Authorization header is present, so the cookie-authed HTML account
3972
+ // home at `/account`/`/account/` below stays unaffected.
3943
3973
  if (
3944
3974
  (pathname === "/account" || pathname === "/account/") &&
3945
3975
  req.headers.get("authorization")
@@ -122,7 +122,21 @@ export type HubSettingKey =
122
122
  // wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
123
123
  // not-yet-set-up hub still lands on setup, not a surface that can't work
124
124
  // yet.
125
- | "root_redirect";
125
+ | "root_redirect"
126
+ // hub-parity P2 (Q2): the RAW token of the newest PUBLIC (multi-use,
127
+ // `max_uses > 1`) invite — persisted so `GET /.well-known/parachute-account`
128
+ // can advertise `signup_path` without being able to reconstruct it from the
129
+ // `invites` table (which stores only `sha256(token)`, see invites.ts's
130
+ // module doc). Written by `api-invites.ts`'s `handleCreateInvite` right
131
+ // after a successful multi-use `issueInvite`; read (and lazily cleared once
132
+ // stale) by `invites.ts`'s `activePublicSignupPath`.
133
+ //
134
+ // This does NOT weaken the hash-only posture of single-use friend invites
135
+ // (`max_uses === 1`, the default) — those NEVER write this row. A
136
+ // multi-use link is, by construction, the operator's deliberately PUBLIC
137
+ // signup page (minted to broadcast), so persisting its raw token is no
138
+ // more sensitive than the link itself already being handed out widely.
139
+ | "public_signup_token";
126
140
 
127
141
  export type SetupExposeMode = "localhost" | "tailnet" | "public";
128
142
 
package/src/invites.ts CHANGED
@@ -57,6 +57,7 @@
57
57
  */
58
58
  import type { Database } from "bun:sqlite";
59
59
  import { createHash, randomBytes } from "node:crypto";
60
+ import { deleteSetting, getSetting } from "./hub-settings.ts";
60
61
 
61
62
  /** Default invite lifetime — long enough to deliver out-of-band (no email), short enough to bound a leaked link. */
62
63
  export const DEFAULT_INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -502,3 +503,44 @@ export function revokeInvitesForVault(
502
503
  .run(now.toISOString(), vaultName);
503
504
  return Number(res.changes);
504
505
  }
506
+
507
+ /**
508
+ * Q2 (hub-parity P2) — the account descriptor's conditional `signup_path`.
509
+ * The hub cannot derive a redemption URL from the `invites` table (only
510
+ * `sha256(token)` is stored, never the raw value — see the module doc), so
511
+ * this reads back the ONE raw token persisted at mint time for a
512
+ * deliberately PUBLIC (multi-use) invite (`api-invites.ts`'s
513
+ * `handleCreateInvite`, which writes `hub_settings.public_signup_token`
514
+ * after `issueInvite` when `maxUses > 1`) and re-validates it's still live.
515
+ *
516
+ * Returns `/account/setup/<token>` when the persisted invite is still
517
+ * `"pending"`, still multi-use (`maxUses > 1`), AND provisioning
518
+ * (`provisionVault` — each redeemer gets their own new vault); `null` on ANY
519
+ * miss (never set, redeemed, revoked, exhausted, expired, or a non-provisioning
520
+ * shared-vault link) — and lazily clears the stale setting in that case. No
521
+ * separate revoke/exhaust/expire hook is needed: `inviteStatus` already derives
522
+ * all four terminal states from the invite's own columns, so every miss flows
523
+ * through this one status check.
524
+ *
525
+ * The `maxUses > 1` AND `provisionVault` re-checks are defense-in-depth mirrors
526
+ * of `handleCreateInvite`'s write condition (which only persists for a
527
+ * multi-use provisioning link): even a hand-edited settings row pointing at a
528
+ * single-use OR a shared-vault (`provision_vault=false`) invite must never be
529
+ * advertised as public signup — a shared-vault link would leak team-vault write
530
+ * on the anonymous, wildcard-CORS descriptor.
531
+ */
532
+ export function activePublicSignupPath(db: Database, now: Date = new Date()): string | null {
533
+ const raw = getSetting(db, "public_signup_token");
534
+ if (!raw) return null;
535
+ const invite = findInviteByRawToken(db, raw);
536
+ if (
537
+ !invite ||
538
+ inviteStatus(invite, now) !== "pending" ||
539
+ invite.maxUses <= 1 ||
540
+ !invite.provisionVault
541
+ ) {
542
+ deleteSetting(db, "public_signup_token");
543
+ return null;
544
+ }
545
+ return `/account/setup/${encodeURIComponent(raw)}`;
546
+ }
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=/.