@t3b/hub-guard 0.7.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
@@ -253,16 +253,42 @@ export async function POST(req: Request) {
253
253
  - `mine:true, ok:false` puts the order aside: say why in the words the manager needs (`reason`, `reason_ar`).
254
254
  - Answer 200 for every case once the pass is good; the Hub waits 5 seconds and treats anything else as no answer.
255
255
 
256
+ ## Apps button: move between apps (0.8.0, WORKFLOWS §18)
257
+
258
+ The Hub's pass and its 15-minute check carry the apps this person may open. Show them in ONE button, **Apps
259
+ (التطبيقات)**, in the same place in your top bar, in place of a "Back to the Hub" link:
260
+
261
+ ```ts
262
+ const lines = guard.appMenu(session);
263
+ // [{ id: "hub", nameEn: "The Hub", nameAr: "الـHub", href: "https://t3b-hub.com/home", current: false },
264
+ // { id: "delivery", nameEn: "Delivery", nameAr: "التوصيل", href: "https://t3b-hub.com/go/delivery", current: true }, ...]
265
+ ```
266
+
267
+ - Each line is a plain link (`href`); the Hub mints the next app's pass and goes straight on. Mark the `current` line,
268
+ never link it to itself.
269
+ - A session opened before 0.8.0 (or by an older Hub) has no list: the button then holds the Hub line only.
270
+ - `appMenu(session, { hubUrl, moduleId })` is the same without a Guard; `readApps(raw)` reads a list defensively.
271
+
256
272
  ## What it enforces
257
273
 
258
- - `/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.
259
- - 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).
260
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).
261
277
  - Cloudflare Access proof checked first when the team and audience are set.
262
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.
263
279
 
264
280
  ## Changes
265
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).
289
+ - **0.8.0** (WORKFLOWS §18): the Apps button - the pass's and the 15-minute check's `apps` are kept in the session
290
+ (`session.apps`), `guard.appMenu(session)` / `appMenu()` give the lines, `readApps` reads a list defensively. Nothing
291
+ else changes; a module on 0.7.0 keeps working (its sessions simply carry no list).
266
292
  - **0.7.0** (WORKFLOWS §17): the packing station - `verifyStationCall` checks the Hub's pass for `station` (naming the
267
293
  station; on a pack, bound to the exact body), `stationRefusal`, `stationJson` and the answer types; `healthResponse(...,
268
294
  { station })` names the door so the Hub reads the manifest again by itself. Nothing else changes; a module on 0.6.0 keeps
package/dist/apps.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /** One app the person may open, as the Hub sends it. */
2
+ export type HubApp = {
3
+ id: string;
4
+ nameEn: string;
5
+ nameAr: string;
6
+ };
7
+ /** One line of the Apps button. `current`: this is the app the person is in. */
8
+ export type AppMenuItem = {
9
+ id: string;
10
+ nameEn: string;
11
+ nameAr: string;
12
+ href: string;
13
+ current: boolean;
14
+ };
15
+ export declare const APP_ID_RE: RegExp;
16
+ /** A pass's or check's `apps`, read defensively: anything malformed is dropped, never widened. */
17
+ export declare function readApps(raw: unknown): HubApp[];
18
+ /**
19
+ * The Apps button's lines for this person, in order: the Hub first (its Home), then every app they may open; the app
20
+ * they are in is marked `current`. A session from a Hub before 0.8.0 carries no list - the button then holds the Hub
21
+ * only, which is the way back as before.
22
+ */
23
+ export declare function appMenu(session: {
24
+ apps?: HubApp[];
25
+ }, cfg: {
26
+ hubUrl: string;
27
+ moduleId: string;
28
+ }): AppMenuItem[];
package/dist/apps.js ADDED
@@ -0,0 +1,37 @@
1
+ // WORKFLOWS §18 (0.8.0, D-069): the Apps button. The Hub's pass and its 15-minute check carry the apps this person may
2
+ // open (the same list as the Hub's sidebar); every app shows them in one button in its top bar, with the Hub first and
3
+ // the current app marked. A pick goes to `<hub>/go/<app>`, where the Hub mints that app's pass and goes straight on.
4
+ export const APP_ID_RE = /^[a-z][a-z0-9-]{1,30}$/;
5
+ const MAX_APPS = 30;
6
+ const MAX_NAME = 60;
7
+ /** A pass's or check's `apps`, read defensively: anything malformed is dropped, never widened. */
8
+ export function readApps(raw) {
9
+ if (!Array.isArray(raw))
10
+ return [];
11
+ const out = [];
12
+ const seen = new Set();
13
+ for (const a of raw.slice(0, MAX_APPS)) {
14
+ if (!a || typeof a !== "object")
15
+ continue;
16
+ const { id, nameEn, nameAr } = a;
17
+ if (typeof id !== "string" || !APP_ID_RE.test(id) || seen.has(id))
18
+ continue;
19
+ const en = typeof nameEn === "string" && nameEn.trim() ? nameEn.trim().slice(0, MAX_NAME) : id;
20
+ const ar = typeof nameAr === "string" && nameAr.trim() ? nameAr.trim().slice(0, MAX_NAME) : en;
21
+ seen.add(id);
22
+ out.push({ id, nameEn: en, nameAr: ar });
23
+ }
24
+ return out;
25
+ }
26
+ /**
27
+ * The Apps button's lines for this person, in order: the Hub first (its Home), then every app they may open; the app
28
+ * they are in is marked `current`. A session from a Hub before 0.8.0 carries no list - the button then holds the Hub
29
+ * only, which is the way back as before.
30
+ */
31
+ export function appMenu(session, cfg) {
32
+ const hub = cfg.hubUrl.replace(/\/$/, "");
33
+ return [
34
+ { id: "hub", nameEn: "The Hub", nameAr: "الـHub", href: `${hub}/home`, current: false },
35
+ ...(session.apps ?? []).map((a) => ({ id: a.id, nameEn: a.nameEn, nameAr: a.nameAr, href: `${hub}/go/${a.id}`, current: a.id === cfg.moduleId })),
36
+ ];
37
+ }
package/dist/guard.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { JWTVerifyGetKey } from "jose";
2
+ import { type AppMenuItem } from "./apps.js";
2
3
  import { type TokenProvider } from "./identity.js";
3
4
  import { type ModuleSession } from "./session.js";
4
5
  export type GuardConfig = {
@@ -26,6 +27,10 @@ export type GuardConfig = {
26
27
  audience?: string;
27
28
  };
28
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;
29
34
  fetchImpl?: typeof fetch;
30
35
  now?: () => Date;
31
36
  };
@@ -59,6 +64,8 @@ export declare class Guard {
59
64
  enter(req: Request): Promise<Response>;
60
65
  /** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
61
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;
62
69
  /**
63
70
  * §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
64
71
  * Ask on the server for every page and every action, then answer `refusePage` when not.
@@ -66,6 +73,8 @@ export declare class Guard {
66
73
  allows(session: ModuleSession, pageKey: string, need: "read" | "write"): boolean;
67
74
  /** §14: "You don't have access to this page" - 403; the session stays (other pages still open). */
68
75
  refusePage(req: Request, what?: string): Response;
76
+ /** §18 (0.8.0): the Apps button's lines for this session - the Hub first, then every app this person may open. */
77
+ appMenu(session: ModuleSession): AppMenuItem[];
69
78
  /** Signs out of the module only (the Hub session is untouched). */
70
79
  signOut(): Response;
71
80
  }
package/dist/guard.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { appMenu } from "./apps.js";
1
2
  import { checkCloudflareAccess } from "./cf-access.js";
2
- import { sessionCheckPages } from "./hub-client.js";
3
+ import { sessionCheckPages, spendTicket } from "./hub-client.js";
3
4
  import { pageAllowed } from "./pages.js";
4
5
  import { hubKeys } from "./identity.js";
5
- 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";
6
7
  import { JtiMemory, verifyTicket } from "./ticket.js";
7
8
  import { hmacSha256, toHex } from "./webcrypto.js";
8
9
  export class Guard {
@@ -58,8 +59,16 @@ export class Guard {
58
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)." };
59
60
  return this.refuse(req, words[v.reason] ?? "No valid ticket.");
60
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
+ }
61
70
  const now = this.now().getTime();
62
- 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 };
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 };
63
72
  const landed = new URL(this.cfg.landing ? this.cfg.landing(v.claims.path) : v.claims.path, url.origin);
64
73
  // never off this module's own address, whatever `landing` returns
65
74
  const target = landed.origin === url.origin ? landed : new URL("/", url.origin);
@@ -96,12 +105,28 @@ export class Guard {
96
105
  const r2 = await sessionCheckPages(this.hubClient(), s.personId, s.sv);
97
106
  if (r2.verdict === "refused")
98
107
  return { allow: false, response: this.refuse(req, "The Hub ended this session (signed out everywhere, or access changed).") };
99
- // §14: a change of ticks reaches the module here, within 15 minutes
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)
100
110
  if (r2.verdict === "ok")
101
- s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}) };
111
+ s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}), ...(r2.apps ? { apps: r2.apps } : {}), ...(r2.stores ? { stores: r2.stores } : {}) };
102
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) };
103
117
  s = { ...s, lastSeen: nowMs };
104
- 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" } });
105
130
  }
106
131
  /**
107
132
  * §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
@@ -120,6 +145,10 @@ export class Guard {
120
145
  const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>No access</title><style>body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:#F6F5F3;color:#231F20;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:1px solid #E7E5E2;border-radius:12px;padding:40px;max-width:440px;text-align:center}h1{font-size:22px;margin:0 0 12px}p{color:#6B6866;line-height:1.5;margin:0 0 24px}a{display:inline-block;background:#231F20;color:#fff;text-decoration:none;padding:14px 26px;border-radius:12px;font-size:13px;letter-spacing:.08em;text-transform:uppercase;font-weight:600}</style></head><body><div class="card"><h1>You don't have access to ${escapeHtml(what)}</h1><p>The owner gives access in the Hub: Settings → Staff.</p><a href="${escapeAttr(home)}">Go to the Hub</a></div></body></html>`;
121
146
  return new Response(html, { status: 403, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
122
147
  }
148
+ /** §18 (0.8.0): the Apps button's lines for this session - the Hub first, then every app this person may open. */
149
+ appMenu(session) {
150
+ return appMenu(session, { hubUrl: this.cfg.hubUrl, moduleId: this.cfg.moduleId });
151
+ }
123
152
  /** Signs out of the module only (the Hub session is untouched). */
124
153
  signOut() {
125
154
  return new Response(null, { status: 303, headers: { Location: this.cfg.hubHomeUrl ?? this.cfg.hubUrl, "Set-Cookie": clearCookieHeader() } });
@@ -1,4 +1,5 @@
1
1
  import { type JWTVerifyGetKey } from "jose";
2
+ import { type HubApp } from "./apps.js";
2
3
  import { type PageDef, type Pages } from "./pages.js";
3
4
  import { type TokenProvider } from "./identity.js";
4
5
  export type HubClientConfig = {
@@ -10,11 +11,22 @@ export type HubClientConfig = {
10
11
  };
11
12
  /** 2.4: does this person's Hub session still stand? `unknown` when the Hub did not answer (the module keeps the session until the next check). */
12
13
  export declare function sessionCheck(cfg: HubClientConfig, personId: string, sessionVersion: number): Promise<"ok" | "refused" | "unknown">;
13
- /** §14 (0.4.0): the same check, with the person's pages as the Hub holds them now (absent from a Hub before §14). */
14
+ /**
15
+ * §14 (0.4.0): the same check, with the person's pages as the Hub holds them now (absent from a Hub before §14);
16
+ * §18 (0.8.0): and the apps they may open (absent from a Hub before §18).
17
+ */
14
18
  export declare function sessionCheckPages(cfg: HubClientConfig, personId: string, sessionVersion: number): Promise<{
15
19
  verdict: "ok" | "refused" | "unknown";
16
20
  pages?: Pages;
21
+ apps?: HubApp[];
22
+ stores?: string[];
17
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">;
18
30
  export type BrokerToken = {
19
31
  accessToken: string;
20
32
  expiresAt: Date;
@@ -1,4 +1,5 @@
1
1
  import { jwtVerify } from "jose";
2
+ import { readApps } from "./apps.js";
2
3
  import { pagesFingerprint, readPages } from "./pages.js";
3
4
  import { APP_ID_HEADER, HUB_ISSUER, ownVercelToken, TYP, tokenFrom } from "./identity.js";
4
5
  const NO_ID = "This app has no Vercel ID to show the Hub - Vercel → the project → Settings → Security → Secure backend access (OIDC) must be on.";
@@ -35,14 +36,38 @@ async function post(cfg, path, body) {
35
36
  export async function sessionCheck(cfg, personId, sessionVersion) {
36
37
  return (await sessionCheckPages(cfg, personId, sessionVersion)).verdict;
37
38
  }
38
- /** §14 (0.4.0): the same check, with the person's pages as the Hub holds them now (absent from a Hub before §14). */
39
+ /**
40
+ * §14 (0.4.0): the same check, with the person's pages as the Hub holds them now (absent from a Hub before §14);
41
+ * §18 (0.8.0): and the apps they may open (absent from a Hub before §18).
42
+ */
39
43
  export async function sessionCheckPages(cfg, personId, sessionVersion) {
40
44
  const r = await post(cfg, "/api/hub/session-check", { personId, sessionVersion });
41
45
  if (r.status === 0 || r.status >= 500)
42
46
  return { verdict: "unknown" };
43
47
  if (r.json?.ok !== true)
44
48
  return { verdict: "refused" };
45
- return r.json.pages === undefined ? { verdict: "ok" } : { verdict: "ok", pages: readPages(r.json.pages) };
49
+ return {
50
+ verdict: "ok",
51
+ ...(r.json.pages === undefined ? {} : { pages: readPages(r.json.pages) }),
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)) } : {}),
55
+ };
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";
46
71
  }
47
72
  /** 7.3: the store's token from the Hub, cached until an hour before it expires. */
48
73
  export function tokenBroker(cfg) {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { Guard, openingPage, type GuardConfig, type CheckResult } from "./guard.js";
2
+ export { appMenu, readApps, APP_ID_RE, type AppMenuItem, type HubApp } from "./apps.js";
2
3
  export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE, type PageAccess, type PageDef, type Pages } from "./pages.js";
3
4
  export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
4
- export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
5
- 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";
6
7
  export { checkCloudflareAccess } from "./cf-access.js";
7
8
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
8
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
@@ -1,8 +1,9 @@
1
1
  export { Guard, openingPage } from "./guard.js";
2
+ export { appMenu, readApps, APP_ID_RE } from "./apps.js";
2
3
  export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE } from "./pages.js";
3
4
  export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
4
- export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
5
- 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";
6
7
  export { checkCloudflareAccess } from "./cf-access.js";
7
8
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
8
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
@@ -1,8 +1,15 @@
1
+ import type { HubApp } from "./apps.js";
1
2
  import type { Pages } from "./pages.js";
2
3
  export declare const SESSION_COOKIE = "t3b_module_session";
3
4
  export declare const IDLE_MS: number;
4
5
  export declare const ABSOLUTE_MS: number;
5
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;
6
13
  export type ModuleSession = {
7
14
  personId: string;
8
15
  name: string;
@@ -14,6 +21,10 @@ export type ModuleSession = {
14
21
  checkedAt: number;
15
22
  /** §14 (0.4.0): the person's pages here, from the pass, refreshed by every 15-minute check */
16
23
  pages?: Pages;
24
+ /** §18 (0.8.0): the apps this person may open (the Apps button), from the pass, refreshed by every 15-minute check */
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;
17
28
  };
18
29
  export declare function signSession(s: ModuleSession, secret: string): Promise<string>;
19
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/dist/ticket.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type HubApp } from "./apps.js";
1
2
  import { type Pages } from "./pages.js";
2
3
  import { type JWTVerifyGetKey } from "jose";
3
4
  export declare const TICKET_ISSUER = "t3b-hub";
@@ -15,6 +16,8 @@ export type TicketClaims = {
15
16
  exp: number;
16
17
  /** §14 (0.4.0): the person's pages in this module; `{}` from a Hub before §14 */
17
18
  pages: Pages;
19
+ /** §18 (0.8.0): the apps this person may open; `[]` from a Hub before §18 */
20
+ apps: HubApp[];
18
21
  };
19
22
  export type VerifyResult = {
20
23
  ok: true;
package/dist/ticket.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readApps } from "./apps.js";
1
2
  import { readPages } from "./pages.js";
2
3
  import { jwtVerify } from "jose";
3
4
  import { HUB_ISSUER, TYP } from "./identity.js";
@@ -60,6 +61,6 @@ export async function verifyTicket(ticket, opts) {
60
61
  return { ok: false, reason: "replayed" };
61
62
  return {
62
63
  ok: true,
63
- claims: { sub: p.sub, jti: p.jti, role: p.role, stores: p.stores.map(String), path: p.path, name: String(p.name ?? ""), sv: Number(p.sv ?? 0), iat: p.iat, exp: p.exp, pages: readPages(p.pages) },
64
+ claims: { sub: p.sub, jti: p.jti, role: p.role, stores: p.stores.map(String), path: p.path, name: String(p.name ?? ""), sv: Number(p.sv ?? 0), iat: p.iat, exp: p.exp, pages: readPages(p.pages), apps: readApps(p.apps) },
64
65
  };
65
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3b/hub-guard",
3
- "version": "0.7.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",