@t3b/hub-guard 0.3.0 → 0.4.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
@@ -147,9 +147,44 @@ import { guard } from "@/lib/guard";
147
147
  export const GET = (req: Request) => guard.enter(req);
148
148
  ```
149
149
 
150
+ ## Pages: who may read or change what (0.4.0, WORKFLOWS §14)
151
+
152
+ The owner ticks **Read** or **Write** per page per person in the Hub (Settings → Staff). The Hub's pass and
153
+ its 15-minute check carry the person's pages in this module; the guard keeps them in the session.
154
+
155
+ 1. **List your pages in the manifest** — one entry per page (a separate line for an action that needs its own
156
+ switch, e.g. clearing cash). A module that lists none opens for the owner only.
157
+
158
+ ```ts
159
+ const PAGES = [
160
+ { key: "deliveries", group: "Daily work", name: { en: "Deliveries", ar: "التوصيلات" }, read: "orders, invoices", write: "assign drivers, WhatsApp" },
161
+ { key: "driver-tracking", group: "Daily work", name: { en: "Driver tracking", ar: "تتبع السائقين" }, read: "positions", write: null },
162
+ ];
163
+ // manifest: { ..., pages: PAGES } health: healthResponse(req, check, { ...cfg, pages: PAGES })
164
+ ```
165
+
166
+ The health answer then carries the list's fingerprint; when you add, remove or reword a page, the Hub reads
167
+ the manifest again by itself. New pages arrive closed for everyone but the owner.
168
+
169
+ 2. **Ask on the server, for every page and every action** — hiding a button is not enough:
170
+
171
+ ```ts
172
+ const r = await guard.check(req); // as before
173
+ if (!r.allow) return r.response;
174
+ if (!guard.allows(r.session, "deliveries", "read")) return guard.refusePage(req, "Deliveries");
175
+ // in an action:
176
+ if (!guard.allows(r.session, "deliveries", "write")) return guard.refusePage(req, "Deliveries");
177
+ ```
178
+
179
+ `refusePage` answers 403 ("You don't have access to …" for browsers, `{"error":"no_access"}` for APIs) and
180
+ keeps the session. The owner may do everything; `pageAllowed(session, key, need)` is the same check without a Guard.
181
+
182
+ 3. **Show only what may be used** — build the menu from `pageAccess(session, key)` and switch off buttons on a
183
+ Read-only page.
184
+
150
185
  ## What it enforces
151
186
 
152
- - `/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 sends to the page.
187
+ - `/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.
153
188
  - Every other request: no session → 403 (HTML for browsers, JSON for APIs), on **every host** the module answers on. Idle 15 minutes, 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.
154
189
  - Open paths (`/hub/enter`, `/api/hub/manifest`, `/api/hub/health` and what you add) never need a session — they carry their own locks (the Hub's pass, a cron secret).
155
190
  - Cloudflare Access proof checked first when the team and audience are set.
@@ -157,6 +192,7 @@ export const GET = (req: Request) => guard.enter(req);
157
192
 
158
193
  ## Changes
159
194
 
195
+ - **0.4.0** (WORKFLOWS §14): page access — the pass's `pages` in the session, refreshed at every 15-minute check (`sessionCheckPages`); `guard.allows()`, `guard.refusePage()`, `pageAllowed`, `pageAccess`, `readPages`; `healthResponse(..., { pages })` sends the page list's fingerprint (`pagesFingerprint`). `enter()` answers an "Opening …" page instead of a 303 (`appName`, `landing` options; `openingPage` exported). A module on 0.3.x keeps working, but the Hub opens it for the owner only until it lists its pages.
160
196
  - **0.3.0** (WORKFLOWS 9.3): `alert()` — report a problem to the Hub, or say it is over.
161
197
  - **0.2.0** (8b): no module key and no pasted Hub public key. Hub → module: tokens signed by the Hub's key published at `/api/hub/jwks` (`verifyTicket`, `verifyRelay`, `verifyHubCall`, `manifestResponse`, `healthResponse`). Module → Hub: the app's own Vercel ID (`tokenBroker`, `audit`, `sessionCheck`). Removed: `signedManifest`, `relaySignature`, `importPublicKey`, the `moduleKey` / `hubPublicKeys` settings. `Guard` takes `sessionSecret`.
162
198
  - **0.1.1**: `enter()` answers with an absolute Location (B-016).
package/dist/guard.d.ts CHANGED
@@ -15,6 +15,10 @@ export type GuardConfig = {
15
15
  environment?: string;
16
16
  /** the refusal page's link back */
17
17
  hubHomeUrl?: string;
18
+ /** 0.4.0: the module's name on the "Opening …" page shown while the session starts */
19
+ appName?: string;
20
+ /** 0.4.0: where the pass's page lands on this module (e.g. "/" -> "/app"); default: the page itself */
21
+ landing?: (path: string) => string;
18
22
  /** paths that are machine doors with their own locks (webhooks, crons, health, manifest, the enter route) - never a session */
19
23
  openPaths?: (string | RegExp)[];
20
24
  cloudflare?: {
@@ -45,10 +49,25 @@ export declare class Guard {
45
49
  isOpenPath(pathname: string): boolean;
46
50
  /** The refusal (board 11): a plain page for browsers, JSON for API routes. */
47
51
  refuse(req: Request, reason: string, status?: number): Response;
48
- /** /hub/enter?ticket=… : verify, open the module session, send to the page (303). */
52
+ /**
53
+ * /hub/enter?ticket=… : verify, open the module session, then a short "Opening …" page that moves on
54
+ * to the pass's page (0.4.0; 303 before). A browser drops a SameSite=Strict cookie set on a redirect
55
+ * that ends a navigation begun on another site (the Hub), so the first page after a 303 arrived
56
+ * without the session and was refused; an own-page step makes the next request same-site. Learnt on
57
+ * Delivery, 2026-09-29.
58
+ */
49
59
  enter(req: Request): Promise<Response>;
50
60
  /** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
51
61
  check(req: Request): Promise<CheckResult>;
62
+ /**
63
+ * §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
64
+ * Ask on the server for every page and every action, then answer `refusePage` when not.
65
+ */
66
+ allows(session: ModuleSession, pageKey: string, need: "read" | "write"): boolean;
67
+ /** §14: "You don't have access to this page" - 403; the session stays (other pages still open). */
68
+ refusePage(req: Request, what?: string): Response;
52
69
  /** Signs out of the module only (the Hub session is untouched). */
53
70
  signOut(): Response;
54
71
  }
72
+ /** 0.4.0: the step between the Hub's pass and the module's page (see `enter`). No script: a meta refresh and a link. */
73
+ export declare function openingPage(target: string, appName: string): string;
package/dist/guard.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { checkCloudflareAccess } from "./cf-access.js";
2
- import { sessionCheck } from "./hub-client.js";
2
+ import { sessionCheckPages } from "./hub-client.js";
3
+ import { pageAllowed } from "./pages.js";
3
4
  import { hubKeys } from "./identity.js";
4
5
  import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, RECHECK_MS, SESSION_COOKIE } from "./session.js";
5
6
  import { JtiMemory, verifyTicket } from "./ticket.js";
@@ -39,7 +40,13 @@ export class Guard {
39
40
  const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Open this from The3Beez</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>Open this from The3Beez</h1><p>This app opens only from the Hub. ${escapeHtml(reason)}</p><a href="${escapeAttr(home)}">Go to the Hub</a></div></body></html>`;
40
41
  return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Set-Cookie": clearCookieHeader() } });
41
42
  }
42
- /** /hub/enter?ticket=… : verify, open the module session, send to the page (303). */
43
+ /**
44
+ * /hub/enter?ticket=… : verify, open the module session, then a short "Opening …" page that moves on
45
+ * to the pass's page (0.4.0; 303 before). A browser drops a SameSite=Strict cookie set on a redirect
46
+ * that ends a navigation begun on another site (the Hub), so the first page after a 303 arrived
47
+ * without the session and was refused; an own-page step makes the next request same-site. Learnt on
48
+ * Delivery, 2026-09-29.
49
+ */
43
50
  async enter(req) {
44
51
  const url = new URL(req.url);
45
52
  const cf = await checkCloudflareAccess(req.headers, this.cfg.cloudflare ?? {});
@@ -52,10 +59,22 @@ export class Guard {
52
59
  return this.refuse(req, words[v.reason] ?? "No valid ticket.");
53
60
  }
54
61
  const now = this.now().getTime();
55
- 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 };
56
- const target = new URL(v.claims.path, url.origin);
57
- // Absolute: Next.js middleware refuses a relative redirect ("Invalid URL", B-016); any client accepts this.
58
- return new Response(null, { status: 303, headers: { Location: target.toString(), "Set-Cookie": cookieHeader(await signSession(session, await this.secretP), { secure: url.protocol === "https:", maxAgeS: Math.floor((this.cfg.absoluteMs ?? 8 * 3_600_000) / 1000) }), "Cache-Control": "no-store" } });
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 };
63
+ const landed = new URL(this.cfg.landing ? this.cfg.landing(v.claims.path) : v.claims.path, url.origin);
64
+ // never off this module's own address, whatever `landing` returns
65
+ const target = landed.origin === url.origin ? landed : new URL("/", url.origin);
66
+ const cookie = cookieHeader(await signSession(session, await this.secretP), { secure: url.protocol === "https:", maxAgeS: Math.floor((this.cfg.absoluteMs ?? 8 * 3_600_000) / 1000) });
67
+ return new Response(openingPage(target.toString(), this.cfg.appName ?? "the app"), {
68
+ status: 200,
69
+ headers: {
70
+ "Content-Type": "text/html; charset=utf-8",
71
+ "Cache-Control": "no-store",
72
+ "Set-Cookie": cookie,
73
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
74
+ "Referrer-Policy": "no-referrer",
75
+ "X-Content-Type-Options": "nosniff",
76
+ },
77
+ });
59
78
  }
60
79
  /** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
61
80
  async check(req) {
@@ -74,20 +93,43 @@ export class Guard {
74
93
  }
75
94
  let s = r.session;
76
95
  if (nowMs - s.checkedAt >= RECHECK_MS) {
77
- const verdict = await sessionCheck(this.hubClient(), s.personId, s.sv);
78
- if (verdict === "refused")
96
+ const r2 = await sessionCheckPages(this.hubClient(), s.personId, s.sv);
97
+ if (r2.verdict === "refused")
79
98
  return { allow: false, response: this.refuse(req, "The Hub ended this session (signed out everywhere, or access changed).") };
80
- if (verdict === "ok")
81
- s = { ...s, checkedAt: nowMs };
99
+ // §14: a change of ticks reaches the module here, within 15 minutes
100
+ if (r2.verdict === "ok")
101
+ s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}) };
82
102
  }
83
103
  s = { ...s, lastSeen: nowMs };
84
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) }) };
85
105
  }
106
+ /**
107
+ * §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
108
+ * Ask on the server for every page and every action, then answer `refusePage` when not.
109
+ */
110
+ allows(session, pageKey, need) {
111
+ return pageAllowed(session, pageKey, need);
112
+ }
113
+ /** §14: "You don't have access to this page" - 403; the session stays (other pages still open). */
114
+ refusePage(req, what = "this page") {
115
+ const accept = req.headers.get("accept") ?? "";
116
+ if (!accept.includes("text/html")) {
117
+ return new Response(JSON.stringify({ error: "no_access", page: what }), { status: 403, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
118
+ }
119
+ const home = this.cfg.hubHomeUrl ?? this.cfg.hubUrl;
120
+ 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
+ return new Response(html, { status: 403, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
122
+ }
86
123
  /** Signs out of the module only (the Hub session is untouched). */
87
124
  signOut() {
88
125
  return new Response(null, { status: 303, headers: { Location: this.cfg.hubHomeUrl ?? this.cfg.hubUrl, "Set-Cookie": clearCookieHeader() } });
89
126
  }
90
127
  }
128
+ /** 0.4.0: the step between the Hub's pass and the module's page (see `enter`). No script: a meta refresh and a link. */
129
+ export function openingPage(target, appName) {
130
+ const t = escapeAttr(target);
131
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${t}"><title>Opening ${escapeHtml(appName)}</title></head><body style="font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0"><p>Opening ${escapeHtml(appName)}… <a href="${t}">Continue</a></p></body></html>`;
132
+ }
91
133
  function escapeHtml(s) {
92
134
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
93
135
  }
@@ -1,4 +1,5 @@
1
1
  import { type JWTVerifyGetKey } from "jose";
2
+ import { type PageDef, type Pages } from "./pages.js";
2
3
  import { type TokenProvider } from "./identity.js";
3
4
  export type HubClientConfig = {
4
5
  hubUrl: string;
@@ -9,6 +10,11 @@ export type HubClientConfig = {
9
10
  };
10
11
  /** 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). */
11
12
  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
+ export declare function sessionCheckPages(cfg: HubClientConfig, personId: string, sessionVersion: number): Promise<{
15
+ verdict: "ok" | "refused" | "unknown";
16
+ pages?: Pages;
17
+ }>;
12
18
  export type BrokerToken = {
13
19
  accessToken: string;
14
20
  expiresAt: Date;
@@ -86,14 +92,17 @@ export type Connection = {
86
92
  store?: string | null;
87
93
  };
88
94
  /** 8.4 / 7.6: the health answer's shape. `ok` is false when any connection is red; the Hub shows each connection as its own line. */
89
- export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[]): Promise<{
95
+ export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[]): Promise<{
90
96
  status: number;
91
97
  body: {
92
98
  ok: boolean;
93
99
  reason: string | null;
94
100
  connections: Connection[];
95
101
  checked_at: string;
102
+ pages?: string;
96
103
  };
97
104
  }>;
98
105
  /** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
99
- export declare function healthResponse(req: Request, check: () => Promise<Connection[]> | Connection[], cfg: Omit<AnswerConfig, "token">): Promise<Response>;
106
+ export declare function healthResponse(req: Request, check: () => Promise<Connection[]> | Connection[], cfg: Omit<AnswerConfig, "token"> & {
107
+ pages?: PageDef[];
108
+ }): Promise<Response>;
@@ -1,4 +1,5 @@
1
1
  import { jwtVerify } from "jose";
2
+ import { pagesFingerprint, readPages } from "./pages.js";
2
3
  import { APP_ID_HEADER, HUB_ISSUER, ownVercelToken, TYP, tokenFrom } from "./identity.js";
3
4
  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.";
4
5
  async function post(cfg, path, body) {
@@ -32,10 +33,16 @@ async function post(cfg, path, body) {
32
33
  }
33
34
  /** 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). */
34
35
  export async function sessionCheck(cfg, personId, sessionVersion) {
36
+ return (await sessionCheckPages(cfg, personId, sessionVersion)).verdict;
37
+ }
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
+ export async function sessionCheckPages(cfg, personId, sessionVersion) {
35
40
  const r = await post(cfg, "/api/hub/session-check", { personId, sessionVersion });
36
41
  if (r.status === 0 || r.status >= 500)
37
- return "unknown";
38
- return r.json?.ok === true ? "ok" : "refused";
42
+ return { verdict: "unknown" };
43
+ if (r.json?.ok !== true)
44
+ return { verdict: "refused" };
45
+ return r.json.pages === undefined ? { verdict: "ok" } : { verdict: "ok", pages: readPages(r.json.pages) };
39
46
  }
40
47
  /** 7.3: the store's token from the Hub, cached until an hour before it expires. */
41
48
  export function tokenBroker(cfg) {
@@ -117,7 +124,7 @@ export async function manifestResponse(req, manifest, cfg) {
117
124
  return jsonResponse(200, manifest, { [APP_ID_HEADER]: own });
118
125
  }
119
126
  /** 8.4 / 7.6: the health answer's shape. `ok` is false when any connection is red; the Hub shows each connection as its own line. */
120
- export async function healthAnswer(check) {
127
+ export async function healthAnswer(check, pages) {
121
128
  let connections;
122
129
  try {
123
130
  connections = await check();
@@ -128,7 +135,14 @@ export async function healthAnswer(check) {
128
135
  const red = connections.filter((c) => !c.ok);
129
136
  return {
130
137
  status: 200,
131
- body: { ok: red.length === 0, reason: red.length ? red.map((c) => `${c.name}: ${c.reason ?? "not ok"}`).join("; ").slice(0, 200) : null, connections, checked_at: new Date().toISOString() },
138
+ body: {
139
+ ok: red.length === 0,
140
+ reason: red.length ? red.map((c) => `${c.name}: ${c.reason ?? "not ok"}`).join("; ").slice(0, 200) : null,
141
+ connections,
142
+ checked_at: new Date().toISOString(),
143
+ // §14.5: the page list's fingerprint - when it changes, the Hub reads the manifest again by itself
144
+ ...(pages ? { pages: await pagesFingerprint(pages) } : {}),
145
+ },
132
146
  };
133
147
  }
134
148
  /** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
@@ -136,6 +150,6 @@ export async function healthResponse(req, check, cfg) {
136
150
  const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
137
151
  if (!v.ok)
138
152
  return jsonResponse(401, { error: "hub_only", reason: v.reason });
139
- const a = await healthAnswer(check);
153
+ const a = await healthAnswer(check, cfg.pages);
140
154
  return jsonResponse(a.status, a.body);
141
155
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- export { Guard, type GuardConfig, type CheckResult } from "./guard.js";
1
+ export { Guard, openingPage, type GuardConfig, type CheckResult } from "./guard.js";
2
+ export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE, type PageAccess, type PageDef, type Pages } from "./pages.js";
2
3
  export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
3
4
  export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
4
- export { sessionCheck, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
5
+ export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
5
6
  export { checkCloudflareAccess } from "./cf-access.js";
6
7
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
7
8
  export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER, type TokenProvider } from "./identity.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- export { Guard } from "./guard.js";
1
+ export { Guard, openingPage } from "./guard.js";
2
+ export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE } from "./pages.js";
2
3
  export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
3
4
  export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
4
- export { sessionCheck, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, } from "./hub-client.js";
5
+ export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, } from "./hub-client.js";
5
6
  export { checkCloudflareAccess } from "./cf-access.js";
6
7
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
7
8
  export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER } from "./identity.js";
@@ -0,0 +1,31 @@
1
+ export type PageAccess = "r" | "w";
2
+ export type Pages = Record<string, PageAccess>;
3
+ /** One page as a module lists it in its manifest. `write`: what Write allows, or null when there is nothing to change. */
4
+ export type PageDef = {
5
+ key: string;
6
+ group?: string | null;
7
+ name: {
8
+ en: string;
9
+ ar: string;
10
+ };
11
+ read: string;
12
+ write?: string | null;
13
+ };
14
+ export declare const PAGE_KEY_RE: RegExp;
15
+ /** A pass's or check's `pages`, read defensively: anything malformed is dropped, never widened. */
16
+ export declare function readPages(raw: unknown): Pages;
17
+ /** What a person may do on one page: "w", "r" or null. The owner always "w". */
18
+ export declare function pageAccess(session: {
19
+ role: string;
20
+ pages?: Pages;
21
+ }, key: string): PageAccess | null;
22
+ /**
23
+ * The one question a module asks on every page and every action: may this person read (`"read"`) or
24
+ * change (`"write"`) this page? Ask it on the server - hiding a button is not enough (§14.3).
25
+ */
26
+ export declare function pageAllowed(session: {
27
+ role: string;
28
+ pages?: Pages;
29
+ }, key: string, need: "read" | "write"): boolean;
30
+ /** The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). */
31
+ export declare function pagesFingerprint(pages: PageDef[]): Promise<string>;
package/dist/pages.js ADDED
@@ -0,0 +1,32 @@
1
+ import { b64urlEncode, sha256, utf8 } from "./webcrypto.js";
2
+ export const PAGE_KEY_RE = /^[a-z][a-z0-9-]{1,39}$/;
3
+ /** A pass's or check's `pages`, read defensively: anything malformed is dropped, never widened. */
4
+ export function readPages(raw) {
5
+ const out = {};
6
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
7
+ return out;
8
+ for (const [k, v] of Object.entries(raw)) {
9
+ if (PAGE_KEY_RE.test(k) && (v === "r" || v === "w"))
10
+ out[k] = v;
11
+ }
12
+ return out;
13
+ }
14
+ /** What a person may do on one page: "w", "r" or null. The owner always "w". */
15
+ export function pageAccess(session, key) {
16
+ if (session.role === "owner")
17
+ return "w";
18
+ return session.pages?.[key] ?? null;
19
+ }
20
+ /**
21
+ * The one question a module asks on every page and every action: may this person read (`"read"`) or
22
+ * change (`"write"`) this page? Ask it on the server - hiding a button is not enough (§14.3).
23
+ */
24
+ export function pageAllowed(session, key, need) {
25
+ const a = pageAccess(session, key);
26
+ return need === "read" ? a === "r" || a === "w" : a === "w";
27
+ }
28
+ /** The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). */
29
+ export async function pagesFingerprint(pages) {
30
+ const text = JSON.stringify(pages.map((p) => [p.key, p.group ?? null, p.name.en, p.name.ar, p.read, p.write ?? null]));
31
+ return b64urlEncode(await sha256(utf8(text))).slice(0, 16);
32
+ }
package/dist/session.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Pages } from "./pages.js";
1
2
  export declare const SESSION_COOKIE = "t3b_module_session";
2
3
  export declare const IDLE_MS: number;
3
4
  export declare const ABSOLUTE_MS: number;
@@ -11,6 +12,8 @@ export type ModuleSession = {
11
12
  iat: number;
12
13
  lastSeen: number;
13
14
  checkedAt: number;
15
+ /** §14 (0.4.0): the person's pages here, from the pass, refreshed by every 15-minute check */
16
+ pages?: Pages;
14
17
  };
15
18
  export declare function signSession(s: ModuleSession, secret: string): Promise<string>;
16
19
  export declare function readSession(cookie: string | undefined | null, secret: string, now?: number, opts?: {
package/dist/ticket.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Pages } from "./pages.js";
1
2
  import { type JWTVerifyGetKey } from "jose";
2
3
  export declare const TICKET_ISSUER = "t3b-hub";
3
4
  export declare const TICKET_TTL_S = 60;
@@ -12,6 +13,8 @@ export type TicketClaims = {
12
13
  sv: number;
13
14
  iat: number;
14
15
  exp: number;
16
+ /** §14 (0.4.0): the person's pages in this module; `{}` from a Hub before §14 */
17
+ pages: Pages;
15
18
  };
16
19
  export type VerifyResult = {
17
20
  ok: true;
package/dist/ticket.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readPages } from "./pages.js";
1
2
  import { jwtVerify } from "jose";
2
3
  import { HUB_ISSUER, TYP } from "./identity.js";
3
4
  // SPEC C-4: the entry ticket. EdDSA (Ed25519) JWT signed by the Hub's key; since 0.2.0 (8b) the
@@ -59,6 +60,6 @@ export async function verifyTicket(ticket, opts) {
59
60
  return { ok: false, reason: "replayed" };
60
61
  return {
61
62
  ok: true,
62
- 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 },
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) },
63
64
  };
64
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3b/hub-guard",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",