@t3b/hub-guard 0.6.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
@@ -216,21 +216,76 @@ export async function GET(req: Request) {
216
216
  - `at`: ISO 8601 in UTC at your database's full precision (Postgres: `to_char(at at time zone 'UTC',
217
217
  'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`), and compare `before.at` at that same precision - a coarser time skips lines.
218
218
  - `person`: the Hub person's id (the pass gave it to you); `actor` instead, for a line no person made
219
- ("International Orders, on its own").
219
+ ("International Orders, on its own"). An app that records people by name reads the Hub's `person_name`
220
+ from the address to filter (`q.person` is the id).
220
221
  - `store`: the Hub's store key; a module serving one store answers nothing when asked for another.
221
222
  - The Hub drops a line out of shape (`cleanActivityLine`) and cuts long words: `what` 200, `detail` 400,
222
223
  `actor`/`place` 60 characters. It waits 5 seconds; an app that does not answer is named on the page.
223
224
 
225
+ ## Packing station: pack for the Hub's station (0.7.0, WORKFLOWS §17)
226
+
227
+ The packer scans an invoice at the Packing app; the Hub has already checked that station's key, then asks every app
228
+ that names a station door, with its own 60-second pass for `station`. Your app answers whether the order is its own and
229
+ what to scan; on the last scan the Hub sends the scans and your app packs exactly as its own station does.
230
+
231
+ 1. **Name the door** in the manifest and in the health answer (`station: "/api/hub/station"`, and
232
+ `healthResponse(req, check, { ...cfg, station: "/api/hub/station" })`).
233
+ 2. **Answer it** - the Hub's pass first, then your own rules:
234
+
235
+ ```ts
236
+ // GET ?code=<digits from the invoice QR> -> { mine:false } | { mine:true, ok:true, order } | { mine:true, ok:false, reason, reason_ar }
237
+ // POST { orderId, scans:[...] } -> { ok:true, name } | { ok:false, reason, reason_ar, missing, extra }
238
+ import { verifyStationCall, stationRefusal, stationJson } from "@t3b/hub-guard";
239
+ export async function POST(req: Request) {
240
+ const raw = new Uint8Array(await req.arrayBuffer()); // the pass covers these exact bytes
241
+ const v = await verifyStationCall(req, raw, cfg); // GET: verifyStationCall(req, null, cfg)
242
+ if (!v.ok) return stationRefusal(v.reason);
243
+ const body = JSON.parse(new TextDecoder().decode(raw));
244
+ // ... check every scan again against the order as Shopify has it now, then fulfil; record it under v.station.name
245
+ return stationJson({ ok: true, name: "#KW1100" });
246
+ }
247
+ ```
248
+
249
+ - `order.kind`: `"kuwait"` (local delivery - the screen shows the Kuwait flag) or `"international"` (the globe),
250
+ with `country` `{ code, ar, en }`.
251
+ - `items[]`: `title` (Arabic), `title_en`, `sku`, `qty`, `barcode`, `image` (Shopify's CDN only), `offer`
252
+ (`{ title, qty }` from `lineItemGroup` when the line came from a bundle).
253
+ - `mine:true, ok:false` puts the order aside: say why in the words the manager needs (`reason`, `reason_ar`).
254
+ - Answer 200 for every case once the pass is good; the Hub waits 5 seconds and treats anything else as no answer.
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
+
224
272
  ## What it enforces
225
273
 
226
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.
227
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.
228
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).
229
277
  - Cloudflare Access proof checked first when the team and audience are set.
230
- - `verifyRelay`, `manifestResponse`, `healthResponse`, `activityResponse`: the Hub's pass, for this module, for this purpose, from this environment's Hub; a relay also for these exact bytes and at most 5 minutes old; a call at most 60 seconds old.
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.
231
279
 
232
280
  ## Changes
233
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).
285
+ - **0.7.0** (WORKFLOWS §17): the packing station - `verifyStationCall` checks the Hub's pass for `station` (naming the
286
+ station; on a pack, bound to the exact body), `stationRefusal`, `stationJson` and the answer types; `healthResponse(...,
287
+ { station })` names the door so the Hub reads the manifest again by itself. Nothing else changes; a module on 0.6.0 keeps
288
+ working.
234
289
  - **0.6.0** (WORKFLOWS §16): Activity - `activityResponse` answers the module's own change log to the Hub's pass for
235
290
  `activity` (`readActivityQuery`, `cleanActivityLine`, `ActivityLine`, `ActivityQuery`); `healthResponse(..., { activity })`
236
291
  names the door so the Hub reads the manifest again by itself. Nothing else changes; a module on 0.5.0 keeps working.
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;
@@ -92,7 +97,7 @@ export type Connection = {
92
97
  store?: string | null;
93
98
  };
94
99
  /** 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. */
95
- export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[], activity?: string): Promise<{
100
+ export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[], activity?: string, station?: string): Promise<{
96
101
  status: number;
97
102
  body: {
98
103
  ok: boolean;
@@ -101,12 +106,14 @@ export declare function healthAnswer(check: () => Promise<Connection[]> | Connec
101
106
  checked_at: string;
102
107
  pages?: string;
103
108
  activity?: string;
109
+ station?: string;
104
110
  };
105
111
  }>;
106
112
  /** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
107
113
  export declare function healthResponse(req: Request, check: () => Promise<Connection[]> | Connection[], cfg: Omit<AnswerConfig, "token"> & {
108
114
  pages?: PageDef[];
109
115
  activity?: string;
116
+ station?: string;
110
117
  }): Promise<Response>;
111
118
  /** One change, worded by the app for the Hub's Activity page (English, like the Hub). */
112
119
  export type ActivityLine = {
@@ -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) {
@@ -124,7 +132,7 @@ export async function manifestResponse(req, manifest, cfg) {
124
132
  return jsonResponse(200, manifest, { [APP_ID_HEADER]: own });
125
133
  }
126
134
  /** 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. */
127
- export async function healthAnswer(check, pages, activity) {
135
+ export async function healthAnswer(check, pages, activity, station) {
128
136
  let connections;
129
137
  try {
130
138
  connections = await check();
@@ -144,6 +152,8 @@ export async function healthAnswer(check, pages, activity) {
144
152
  ...(pages ? { pages: await pagesFingerprint(pages) } : {}),
145
153
  // §16: the app's Activity door; when it differs from the one the Hub holds, the Hub reads the manifest again
146
154
  ...(activity ? { activity } : {}),
155
+ // §17: the app's packing-station door; when it differs from the one the Hub holds, the Hub reads the manifest again
156
+ ...(station ? { station } : {}),
147
157
  },
148
158
  };
149
159
  }
@@ -152,7 +162,7 @@ export async function healthResponse(req, check, cfg) {
152
162
  const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
153
163
  if (!v.ok)
154
164
  return jsonResponse(401, { error: "hub_only", reason: v.reason });
155
- const a = await healthAnswer(check, cfg.pages, cfg.activity);
165
+ const a = await healthAnswer(check, cfg.pages, cfg.activity, cfg.station);
156
166
  return jsonResponse(a.status, a.body);
157
167
  }
158
168
  export const ACTIVITY_AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
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";
5
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";
6
7
  export { checkCloudflareAccess } from "./cf-access.js";
7
8
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
9
+ export { verifyStationCall, stationRefusal, stationJson, STATION_ID_RE, STATION_NAME_MAX, type StationCheck, type StationAnswer, type StationOrder, type StationItem, type PackAnswer, } from "./station.js";
8
10
  export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER, type TokenProvider } from "./identity.js";
9
11
  /**
10
12
  * Reads the guard's settings from a module's environment. Nothing secret: the Hub's address
package/dist/index.js CHANGED
@@ -1,10 +1,12 @@
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";
5
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";
6
7
  export { checkCloudflareAccess } from "./cf-access.js";
7
8
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
9
+ export { verifyStationCall, stationRefusal, stationJson, STATION_ID_RE, STATION_NAME_MAX, } from "./station.js";
8
10
  export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER } from "./identity.js";
9
11
  /**
10
12
  * Reads the guard's settings from a module's environment. Nothing secret: the Hub's address
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?: {
@@ -0,0 +1,82 @@
1
+ import { type JWTVerifyGetKey } from "jose";
2
+ export declare const STATION_ID_RE: RegExp;
3
+ export declare const STATION_NAME_MAX = 60;
4
+ export type StationCheck = {
5
+ ok: true;
6
+ station: {
7
+ id: string;
8
+ name: string;
9
+ };
10
+ } | {
11
+ ok: false;
12
+ reason: "missing" | "bad_signature" | "expired" | "wrong_module" | "wrong_use" | "wrong_environment" | "body_changed";
13
+ };
14
+ /**
15
+ * Is this the Hub asking for a packing station? `body` is the request's raw bytes for a POST (read it with
16
+ * `await req.arrayBuffer()` before parsing - the pass covers the exact bytes) and null for a GET.
17
+ */
18
+ export declare function verifyStationCall(req: Request, body: Uint8Array | string | null, opts: {
19
+ moduleId: string;
20
+ keys: JWTVerifyGetKey;
21
+ now?: Date;
22
+ environment?: string;
23
+ }): Promise<StationCheck>;
24
+ /** The refusal every Hub door answers with: 401 and only the reason word (the Hub turns it into the owner's words). */
25
+ export declare function stationRefusal(reason: Extract<StationCheck, {
26
+ ok: false;
27
+ }>["reason"]): Response;
28
+ /** One line of an order to scan, as an app answers it (the Hub checks the shape again - an app's words are data). */
29
+ export type StationItem = {
30
+ title: string;
31
+ title_en?: string | null;
32
+ sku?: string | null;
33
+ qty: number;
34
+ barcode: string;
35
+ image?: string | null;
36
+ offer?: {
37
+ title: string;
38
+ qty: number;
39
+ } | null;
40
+ };
41
+ /** An order the app owns and can be packed now. `kind` decides the flag (kuwait) or the globe (international). */
42
+ export type StationOrder = {
43
+ id: string;
44
+ name: string;
45
+ kind: "kuwait" | "international";
46
+ country: {
47
+ code: string;
48
+ ar: string;
49
+ en: string;
50
+ };
51
+ items: StationItem[];
52
+ };
53
+ /** The three answers to the Hub's GET (always 200 once the pass is good). */
54
+ export type StationAnswer = {
55
+ mine: false;
56
+ } | {
57
+ mine: true;
58
+ ok: true;
59
+ order: StationOrder;
60
+ } | {
61
+ mine: true;
62
+ ok: false;
63
+ name?: string | null;
64
+ reason: string;
65
+ reason_ar?: string | null;
66
+ };
67
+ /** The two answers to the Hub's POST (200 once the pass is good). */
68
+ export type PackAnswer = {
69
+ ok: true;
70
+ name: string;
71
+ } | {
72
+ ok: false;
73
+ reason: string;
74
+ reason_ar?: string | null;
75
+ missing?: {
76
+ sku: string | null;
77
+ title: string;
78
+ need: number;
79
+ }[];
80
+ extra?: string[];
81
+ };
82
+ export declare function stationJson(body: StationAnswer | PackAnswer): Response;
@@ -0,0 +1,55 @@
1
+ import { jwtVerify } from "jose";
2
+ import { HUB_ISSUER, TYP } from "./identity.js";
3
+ import { b64urlEncode, constantTimeEqual, sha256 } from "./webcrypto.js";
4
+ // WORKFLOWS §17 (SPEC C-25, D-065): the packing station. A scan reaches an app only through the Hub:
5
+ // the Hub has checked the station's key, then asks each app with a station door, with its own
6
+ // 60-second pass for "station" naming the station. On the pack (POST) the pass also carries the
7
+ // SHA-256 of the exact body, so a pass cannot be lifted onto another order or other scans.
8
+ export const STATION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
9
+ export const STATION_NAME_MAX = 60;
10
+ /**
11
+ * Is this the Hub asking for a packing station? `body` is the request's raw bytes for a POST (read it with
12
+ * `await req.arrayBuffer()` before parsing - the pass covers the exact bytes) and null for a GET.
13
+ */
14
+ export async function verifyStationCall(req, body, opts) {
15
+ const auth = req.headers.get("authorization") ?? "";
16
+ const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
17
+ if (!token || token.length > 4096)
18
+ return { ok: false, reason: "missing" };
19
+ let p;
20
+ try {
21
+ ({ payload: p } = await jwtVerify(token, opts.keys, { issuer: HUB_ISSUER, audience: opts.moduleId, algorithms: ["EdDSA"], typ: TYP.call, currentDate: opts.now, clockTolerance: 5, maxTokenAge: 65 }));
22
+ }
23
+ catch (e) {
24
+ const code = e.code ?? "";
25
+ if (code === "ERR_JWT_EXPIRED")
26
+ return { ok: false, reason: "expired" };
27
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && /"aud"/.test(e.message))
28
+ return { ok: false, reason: "wrong_module" };
29
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED")
30
+ return { ok: false, reason: "expired" };
31
+ return { ok: false, reason: "bad_signature" };
32
+ }
33
+ if (p.use !== "station")
34
+ return { ok: false, reason: "wrong_use" };
35
+ if (opts.environment && p.env !== opts.environment)
36
+ return { ok: false, reason: "wrong_environment" };
37
+ const id = typeof p.station_id === "string" ? p.station_id : "";
38
+ const name = typeof p.station_name === "string" ? p.station_name.trim() : "";
39
+ if (!STATION_ID_RE.test(id) || !name || name.length > STATION_NAME_MAX)
40
+ return { ok: false, reason: "bad_signature" };
41
+ if (body !== null) {
42
+ const want = typeof p.body_sha256 === "string" ? p.body_sha256 : "";
43
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
44
+ if (!want || !constantTimeEqual(b64urlEncode(await sha256(bytes)), want))
45
+ return { ok: false, reason: "body_changed" };
46
+ }
47
+ return { ok: true, station: { id, name } };
48
+ }
49
+ /** The refusal every Hub door answers with: 401 and only the reason word (the Hub turns it into the owner's words). */
50
+ export function stationRefusal(reason) {
51
+ return new Response(JSON.stringify({ error: "hub_only", reason }), { status: 401, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
52
+ }
53
+ export function stationJson(body) {
54
+ return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
55
+ }
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.6.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",