@t3b/hub-guard 0.7.0 → 0.8.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,6 +253,22 @@ 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
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.
@@ -263,6 +279,9 @@ export async function POST(req: Request) {
263
279
 
264
280
  ## Changes
265
281
 
282
+ - **0.8.0** (WORKFLOWS §18): the Apps button - the pass's and the 15-minute check's `apps` are kept in the session
283
+ (`session.apps`), `guard.appMenu(session)` / `appMenu()` give the lines, `readApps` reads a list defensively. Nothing
284
+ else changes; a module on 0.7.0 keeps working (its sessions simply carry no list).
266
285
  - **0.7.0** (WORKFLOWS §17): the packing station - `verifyStationCall` checks the Hub's pass for `station` (naming the
267
286
  station; on a pack, bound to the exact body), `stationRefusal`, `stationJson` and the answer types; `healthResponse(...,
268
287
  { 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 = {
@@ -66,6 +67,8 @@ export declare class Guard {
66
67
  allows(session: ModuleSession, pageKey: string, need: "read" | "write"): boolean;
67
68
  /** §14: "You don't have access to this page" - 403; the session stays (other pages still open). */
68
69
  refusePage(req: Request, what?: string): Response;
70
+ /** §18 (0.8.0): the Apps button's lines for this session - the Hub first, then every app this person may open. */
71
+ appMenu(session: ModuleSession): AppMenuItem[];
69
72
  /** Signs out of the module only (the Hub session is untouched). */
70
73
  signOut(): Response;
71
74
  }
package/dist/guard.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { appMenu } from "./apps.js";
1
2
  import { checkCloudflareAccess } from "./cf-access.js";
2
3
  import { sessionCheckPages } from "./hub-client.js";
3
4
  import { pageAllowed } from "./pages.js";
@@ -59,7 +60,7 @@ export class Guard {
59
60
  return this.refuse(req, words[v.reason] ?? "No valid ticket.");
60
61
  }
61
62
  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 };
63
+ 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
64
  const landed = new URL(this.cfg.landing ? this.cfg.landing(v.claims.path) : v.claims.path, url.origin);
64
65
  // never off this module's own address, whatever `landing` returns
65
66
  const target = landed.origin === url.origin ? landed : new URL("/", url.origin);
@@ -96,9 +97,9 @@ export class Guard {
96
97
  const r2 = await sessionCheckPages(this.hubClient(), s.personId, s.sv);
97
98
  if (r2.verdict === "refused")
98
99
  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
100
+ // §14: a change of ticks reaches the module here, within 15 minutes - §18: and so does the Apps button's list
100
101
  if (r2.verdict === "ok")
101
- s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}) };
102
+ s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}), ...(r2.apps ? { apps: r2.apps } : {}) };
102
103
  }
103
104
  s = { ...s, lastSeen: nowMs };
104
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) }) };
@@ -120,6 +121,10 @@ export class Guard {
120
121
  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
122
  return new Response(html, { status: 403, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
122
123
  }
124
+ /** §18 (0.8.0): the Apps button's lines for this session - the Hub first, then every app this person may open. */
125
+ appMenu(session) {
126
+ return appMenu(session, { hubUrl: this.cfg.hubUrl, moduleId: this.cfg.moduleId });
127
+ }
123
128
  /** Signs out of the module only (the Hub session is untouched). */
124
129
  signOut() {
125
130
  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,10 +11,14 @@ 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[];
17
22
  }>;
18
23
  export type BrokerToken = {
19
24
  accessToken: string;
@@ -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,21 @@ 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
+ };
46
54
  }
47
55
  /** 7.3: the store's token from the Hub, cached until an hour before it expires. */
48
56
  export function tokenBroker(cfg) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
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
5
  export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
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
5
  export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
package/dist/session.d.ts CHANGED
@@ -1,3 +1,4 @@
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;
@@ -14,6 +15,8 @@ export type ModuleSession = {
14
15
  checkedAt: number;
15
16
  /** §14 (0.4.0): the person's pages here, from the pass, refreshed by every 15-minute check */
16
17
  pages?: Pages;
18
+ /** §18 (0.8.0): the apps this person may open (the Apps button), from the pass, refreshed by every 15-minute check */
19
+ apps?: HubApp[];
17
20
  };
18
21
  export declare function signSession(s: ModuleSession, secret: string): Promise<string>;
19
22
  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 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.8.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",