@t3b/hub-guard 0.8.0 → 0.9.0

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/README.md CHANGED
@@ -271,14 +271,21 @@ const lines = guard.appMenu(session);
271
271
 
272
272
  ## What it enforces
273
273
 
274
- - `/hub/enter?ticket=…`: an EdDSA ticket signed by the Hub's published key, for this module, from this environment's Hub, 60 seconds, single use → sets the module session (HttpOnly, Secure, SameSite=Strict) and answers a short "Opening …" page that moves on to the page (0.4.0: a SameSite=Strict cookie set on a redirect ending a navigation from another site is not sent on it). `appName` names the app on that page; `landing(path)` maps the pass's page (e.g. `"/"` → `"/app"`), never off the module's own address.
275
- - Every other request: no session → 403 (HTML for browsers, JSON for APIs), on **every host** the module answers on. Idle 180 minutes (0.5.0), absolute 8 hours. Every 15 minutes the Hub is asked (with the app's Vercel ID) whether the person's session still stands. A Hub blip keeps the session until the next check.
274
+ - `/hub/enter?ticket=…`: an EdDSA ticket signed by the Hub's published key, for this module, from this environment's Hub, 60 seconds, single use - spent at the Hub, so once across all servers (0.9.0) → sets the module session (HttpOnly, Secure, SameSite=Strict) and answers a short "Opening …" page that moves on to the page (0.4.0: a SameSite=Strict cookie set on a redirect ending a navigation from another site is not sent on it). `appName` names the app on that page; `landing(path)` maps the pass's page (e.g. `"/"` → `"/app"`), never off the module's own address.
275
+ - Every other request: no session → 403 (HTML for browsers, JSON for APIs), on **every host** the module answers on. Idle 180 minutes (0.5.0), absolute 8 hours. Every 15 minutes the Hub is asked (with the app's Vercel ID) whether the person's session still stands, and refreshes their pages, apps and stores (0.9.0). A Hub blip keeps the session until the next check; after **180 minutes** without an answer (`maxStaleMs`) the module still opens and reads, but every change waits - a POST/PUT/PATCH/DELETE answers 503 `hub_unreachable` and `allows(…, "write")` / `pageAllowed` say no (0.9.0).
276
276
  - Open paths (`/hub/enter`, `/api/hub/manifest`, `/api/hub/health`, `/api/hub/activity` and what you add) never need a session — they carry their own locks (the Hub's pass, a cron secret).
277
277
  - Cloudflare Access proof checked first when the team and audience are set.
278
278
  - `verifyRelay`, `manifestResponse`, `healthResponse`, `activityResponse`, `verifyStationCall`: the Hub's pass, for this module, for this purpose, from this environment's Hub; a relay and a station pack also for these exact bytes; a relay at most 5 minutes old; a call at most 60 seconds old.
279
279
 
280
280
  ## Changes
281
281
 
282
+ - **0.9.0** (the Hub audit of 2026-10-04, H07-H09): (1) the 15-minute check refreshes `session.stores` - a store taken
283
+ away leaves the open app within 15 minutes; (2) after `maxStaleMs` (default 180 minutes, `MAX_STALE_MS`) without the
284
+ Hub's answer the session is `stale`: reads go on, changes are refused (503 `hub_unreachable`, `pageAllowed(…, "write")`
285
+ false - the owner too) until the Hub answers again; (3) every entry ticket is spent at the Hub
286
+ (`POST /api/hub/ticket-used`, `spendTicket`) - a ticket works once across every server; if the Hub does not confirm
287
+ it, the entry is refused (open the app again from the Hub). **The Hub must be on its 2026-10-05 release or later before
288
+ an app takes 0.9.0** (the ticket door). `sharedReplay: false` turns (3) off (tests only).
282
289
  - **0.8.0** (WORKFLOWS §18): the Apps button - the pass's and the 15-minute check's `apps` are kept in the session
283
290
  (`session.apps`), `guard.appMenu(session)` / `appMenu()` give the lines, `readApps` reads a list defensively. Nothing
284
291
  else changes; a module on 0.7.0 keeps working (its sessions simply carry no list).
package/dist/guard.d.ts CHANGED
@@ -27,6 +27,10 @@ export type GuardConfig = {
27
27
  audience?: string;
28
28
  };
29
29
  absoluteMs?: number;
30
+ /** H08 (0.9.0): how long changes stay allowed while the Hub does not answer; default 180 minutes */
31
+ maxStaleMs?: number;
32
+ /** H09 (0.9.0): spend each ticket at the Hub (works once across all servers); default on - off only in tests */
33
+ sharedReplay?: boolean;
30
34
  fetchImpl?: typeof fetch;
31
35
  now?: () => Date;
32
36
  };
@@ -60,6 +64,8 @@ export declare class Guard {
60
64
  enter(req: Request): Promise<Response>;
61
65
  /** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
62
66
  check(req: Request): Promise<CheckResult>;
67
+ /** H08: 503 while the Hub has been silent too long - the session stays (nothing is signed out), changes wait. */
68
+ pauseChanges(req: Request): Response;
63
69
  /**
64
70
  * §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
65
71
  * Ask on the server for every page and every action, then answer `refusePage` when not.
package/dist/guard.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { appMenu } from "./apps.js";
2
2
  import { checkCloudflareAccess } from "./cf-access.js";
3
- import { sessionCheckPages } from "./hub-client.js";
3
+ import { sessionCheckPages, spendTicket } from "./hub-client.js";
4
4
  import { pageAllowed } from "./pages.js";
5
5
  import { hubKeys } from "./identity.js";
6
- import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, RECHECK_MS, SESSION_COOKIE } from "./session.js";
6
+ import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, MAX_STALE_MS, RECHECK_MS, SESSION_COOKIE } from "./session.js";
7
7
  import { JtiMemory, verifyTicket } from "./ticket.js";
8
8
  import { hmacSha256, toHex } from "./webcrypto.js";
9
9
  export class Guard {
@@ -59,6 +59,14 @@ export class Guard {
59
59
  const words = { expired: "The ticket has expired (it lives 60 seconds) - open the module again from the Hub.", wrong_audience: "This ticket was made for another module.", bad_signature: "This ticket was not signed by the Hub.", replayed: "This ticket was already used.", malformed: "No valid ticket.", wrong_environment: "This ticket comes from another Hub (test or live)." };
60
60
  return this.refuse(req, words[v.reason] ?? "No valid ticket.");
61
61
  }
62
+ // H09: this server's memory stopped a replay here; the Hub stops one on any other server
63
+ if (this.cfg.sharedReplay !== false) {
64
+ const spent = await spendTicket(this.hubClient(), v.claims.jti, v.claims.exp);
65
+ if (spent === "used")
66
+ return this.refuse(req, "This ticket was already used.");
67
+ if (spent === "unknown")
68
+ return this.refuse(req, "The Hub could not confirm this ticket - open the app again from the Hub.");
69
+ }
62
70
  const now = this.now().getTime();
63
71
  const session = { personId: v.claims.sub, name: v.claims.name, role: v.claims.role, stores: v.claims.stores, sv: v.claims.sv, iat: now, lastSeen: now, checkedAt: now, pages: v.claims.pages, apps: v.claims.apps };
64
72
  const landed = new URL(this.cfg.landing ? this.cfg.landing(v.claims.path) : v.claims.path, url.origin);
@@ -97,12 +105,28 @@ export class Guard {
97
105
  const r2 = await sessionCheckPages(this.hubClient(), s.personId, s.sv);
98
106
  if (r2.verdict === "refused")
99
107
  return { allow: false, response: this.refuse(req, "The Hub ended this session (signed out everywhere, or access changed).") };
100
- // §14: a change of ticks reaches the module here, within 15 minutes - §18: and so does the Apps button's list
108
+ // §14: a change of ticks reaches the module here, within 15 minutes - §18: and so does the Apps button's list -
109
+ // H07: and so does a store taken away (or given)
101
110
  if (r2.verdict === "ok")
102
- s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}), ...(r2.apps ? { apps: r2.apps } : {}) };
111
+ s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}), ...(r2.apps ? { apps: r2.apps } : {}), ...(r2.stores ? { stores: r2.stores } : {}) };
103
112
  }
113
+ // H08: the Hub has not answered for longer than the limit - the module still opens and reads, changes wait
114
+ const stale = nowMs - s.checkedAt >= (this.cfg.maxStaleMs ?? MAX_STALE_MS);
115
+ if (stale && !["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase()))
116
+ return { allow: false, response: this.pauseChanges(req) };
104
117
  s = { ...s, lastSeen: nowMs };
105
- return { allow: true, session: s, setCookie: cookieHeader(await signSession(s, secret), { secure: url.protocol === "https:", maxAgeS: Math.floor((this.cfg.absoluteMs ?? 8 * 3_600_000) / 1000) }) };
118
+ const setCookie = cookieHeader(await signSession(s, secret), { secure: url.protocol === "https:", maxAgeS: Math.floor((this.cfg.absoluteMs ?? 8 * 3_600_000) / 1000) });
119
+ return { allow: true, session: stale ? { ...s, stale: true } : s, setCookie };
120
+ }
121
+ /** H08: 503 while the Hub has been silent too long - the session stays (nothing is signed out), changes wait. */
122
+ pauseChanges(req) {
123
+ const reason = "The Hub has not answered for a while, so changes are paused. You can still look; try again when the Hub is back.";
124
+ const accept = req.headers.get("accept") ?? "";
125
+ if (!accept.includes("text/html")) {
126
+ return new Response(JSON.stringify({ error: "hub_unreachable", reason }), { status: 503, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": "60" } });
127
+ }
128
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Changes paused</title></head><body style="font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0"><p style="max-width:440px;text-align:center">${escapeHtml(reason)}</p></body></html>`;
129
+ return new Response(html, { status: 503, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Retry-After": "60" } });
106
130
  }
107
131
  /**
108
132
  * §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
@@ -19,7 +19,14 @@ export declare function sessionCheckPages(cfg: HubClientConfig, personId: string
19
19
  verdict: "ok" | "refused" | "unknown";
20
20
  pages?: Pages;
21
21
  apps?: HubApp[];
22
+ stores?: string[];
22
23
  }>;
24
+ /**
25
+ * H09 (0.9.0): spend an entry ticket at the Hub, so it works once across every server of every module (the memory of
26
+ * one server is not enough on serverless). "first" = this is its first use; "used" = it was used before; "unknown" =
27
+ * the Hub did not say (the entry is refused - the ticket is a minute old, opening again from the Hub is cheap).
28
+ */
29
+ export declare function spendTicket(cfg: HubClientConfig, jti: string, exp: number): Promise<"first" | "used" | "unknown">;
23
30
  export type BrokerToken = {
24
31
  accessToken: string;
25
32
  expiresAt: Date;
@@ -50,8 +50,25 @@ export async function sessionCheckPages(cfg, personId, sessionVersion) {
50
50
  verdict: "ok",
51
51
  ...(r.json.pages === undefined ? {} : { pages: readPages(r.json.pages) }),
52
52
  ...(r.json.apps === undefined ? {} : { apps: readApps(r.json.apps) }),
53
+ // H07 (0.9.0): the person's stores here as the Hub holds them now (absent from a Hub before 0.9.0)
54
+ ...(Array.isArray(r.json.stores) ? { stores: r.json.stores.filter((s) => typeof s === "string" && /^[a-z]{2}$/.test(s)) } : {}),
53
55
  };
54
56
  }
57
+ /**
58
+ * H09 (0.9.0): spend an entry ticket at the Hub, so it works once across every server of every module (the memory of
59
+ * one server is not enough on serverless). "first" = this is its first use; "used" = it was used before; "unknown" =
60
+ * the Hub did not say (the entry is refused - the ticket is a minute old, opening again from the Hub is cheap).
61
+ */
62
+ export async function spendTicket(cfg, jti, exp) {
63
+ const r = await post(cfg, "/api/hub/ticket-used", { jti, exp });
64
+ if (r.status !== 200 || !r.json)
65
+ return "unknown";
66
+ if (r.json.first === true)
67
+ return "first";
68
+ if (r.json.first === false)
69
+ return "used";
70
+ return "unknown";
71
+ }
55
72
  /** 7.3: the store's token from the Hub, cached until an hour before it expires. */
56
73
  export function tokenBroker(cfg) {
57
74
  const cache = new Map();
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { Guard, openingPage, type GuardConfig, type CheckResult } from "./guard.
2
2
  export { appMenu, readApps, APP_ID_RE, type AppMenuItem, type HubApp } from "./apps.js";
3
3
  export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE, type PageAccess, type PageDef, type Pages } from "./pages.js";
4
4
  export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
5
- export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
6
- export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, type ActivityLine, type ActivityQuery, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
5
+ export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, MAX_STALE_MS, type ModuleSession } from "./session.js";
6
+ export { sessionCheck, sessionCheckPages, spendTicket, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, type ActivityLine, type ActivityQuery, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
7
7
  export { checkCloudflareAccess } from "./cf-access.js";
8
8
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
9
9
  export { verifyStationCall, stationRefusal, stationJson, STATION_ID_RE, STATION_NAME_MAX, type StationCheck, type StationAnswer, type StationOrder, type StationItem, type PackAnswer, } from "./station.js";
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@ export { Guard, openingPage } from "./guard.js";
2
2
  export { appMenu, readApps, APP_ID_RE } from "./apps.js";
3
3
  export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE } from "./pages.js";
4
4
  export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
5
- export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
6
- export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, } from "./hub-client.js";
5
+ export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, MAX_STALE_MS } from "./session.js";
6
+ export { sessionCheck, sessionCheckPages, spendTicket, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, } from "./hub-client.js";
7
7
  export { checkCloudflareAccess } from "./cf-access.js";
8
8
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
9
9
  export { verifyStationCall, stationRefusal, stationJson, STATION_ID_RE, STATION_NAME_MAX, } from "./station.js";
package/dist/pages.d.ts CHANGED
@@ -26,6 +26,7 @@ export declare function pageAccess(session: {
26
26
  export declare function pageAllowed(session: {
27
27
  role: string;
28
28
  pages?: Pages;
29
+ stale?: boolean;
29
30
  }, key: string, need: "read" | "write"): boolean;
30
31
  /** The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). */
31
32
  export declare function pagesFingerprint(pages: PageDef[]): Promise<string>;
package/dist/pages.js CHANGED
@@ -22,6 +22,9 @@ export function pageAccess(session, key) {
22
22
  * change (`"write"`) this page? Ask it on the server - hiding a button is not enough (§14.3).
23
23
  */
24
24
  export function pageAllowed(session, key, need) {
25
+ // H08 (0.9.0): while the Hub has not answered for 180 minutes nobody changes anything - the owner included
26
+ if (need === "write" && session.stale)
27
+ return false;
25
28
  const a = pageAccess(session, key);
26
29
  return need === "read" ? a === "r" || a === "w" : a === "w";
27
30
  }
package/dist/session.d.ts CHANGED
@@ -4,6 +4,12 @@ export declare const SESSION_COOKIE = "t3b_module_session";
4
4
  export declare const IDLE_MS: number;
5
5
  export declare const ABSOLUTE_MS: number;
6
6
  export declare const RECHECK_MS: number;
7
+ /**
8
+ * H08 (0.9.0): how long a module keeps allowing changes while the Hub does not answer its check - the same 180 minutes
9
+ * as the idle rule. Past it the module still opens and reads, but every change waits until the Hub answers again (a
10
+ * person the owner removed during an outage cannot keep changing things for the session's whole 8 hours).
11
+ */
12
+ export declare const MAX_STALE_MS: number;
7
13
  export type ModuleSession = {
8
14
  personId: string;
9
15
  name: string;
@@ -17,6 +23,8 @@ export type ModuleSession = {
17
23
  pages?: Pages;
18
24
  /** §18 (0.8.0): the apps this person may open (the Apps button), from the pass, refreshed by every 15-minute check */
19
25
  apps?: HubApp[];
26
+ /** H08 (0.9.0): set by `check` (never stored) when the Hub has not answered for MAX_STALE_MS - no changes */
27
+ stale?: boolean;
20
28
  };
21
29
  export declare function signSession(s: ModuleSession, secret: string): Promise<string>;
22
30
  export declare function readSession(cookie: string | undefined | null, secret: string, now?: number, opts?: {
package/dist/session.js CHANGED
@@ -7,8 +7,16 @@ export const SESSION_COOKIE = "t3b_module_session";
7
7
  export const IDLE_MS = 180 * 60_000;
8
8
  export const ABSOLUTE_MS = 8 * 3_600_000;
9
9
  export const RECHECK_MS = 15 * 60_000;
10
+ /**
11
+ * H08 (0.9.0): how long a module keeps allowing changes while the Hub does not answer its check - the same 180 minutes
12
+ * as the idle rule. Past it the module still opens and reads, but every change waits until the Hub answers again (a
13
+ * person the owner removed during an outage cannot keep changing things for the session's whole 8 hours).
14
+ */
15
+ export const MAX_STALE_MS = 180 * 60_000;
10
16
  export async function signSession(s, secret) {
11
- const body = b64urlEncode(utf8(JSON.stringify(s)));
17
+ const { stale: _stale, ...kept } = s; // computed on every request, never carried in the cookie
18
+ void _stale;
19
+ const body = b64urlEncode(utf8(JSON.stringify(kept)));
12
20
  const mac = b64urlEncode(await hmacSha256(secret, body));
13
21
  return `${body}.${mac}`;
14
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3b/hub-guard",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "The3Beez Hub guard: a module opens only by a Hub-signed ticket and refuses everything else. No key from the Hub: towards the Hub the app shows its own Vercel ID; from the Hub it accepts only what is signed by the key the Hub publishes. Entry route, refusal page, module session, idle rule, 15-minute re-check, Cloudflare Access proof, broker and audit clients, manifest and health answers for the Hub only. Web Crypto only: runs in Node 20+ and in edge runtimes.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",