@t3b/hub-guard 0.6.0 → 0.7.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,57 @@ 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
+
224
256
  ## What it enforces
225
257
 
226
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.
227
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.
228
260
  - 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
261
  - 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.
262
+ - `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
263
 
232
264
  ## Changes
233
265
 
266
+ - **0.7.0** (WORKFLOWS §17): the packing station - `verifyStationCall` checks the Hub's pass for `station` (naming the
267
+ station; on a pack, bound to the exact body), `stationRefusal`, `stationJson` and the answer types; `healthResponse(...,
268
+ { station })` names the door so the Hub reads the manifest again by itself. Nothing else changes; a module on 0.6.0 keeps
269
+ working.
234
270
  - **0.6.0** (WORKFLOWS §16): Activity - `activityResponse` answers the module's own change log to the Hub's pass for
235
271
  `activity` (`readActivityQuery`, `cleanActivityLine`, `ActivityLine`, `ActivityQuery`); `healthResponse(..., { activity })`
236
272
  names the door so the Hub reads the manifest again by itself. Nothing else changes; a module on 0.5.0 keeps working.
@@ -92,7 +92,7 @@ export type Connection = {
92
92
  store?: string | null;
93
93
  };
94
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. */
95
- export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[], activity?: string): Promise<{
95
+ export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[], activity?: string, station?: string): Promise<{
96
96
  status: number;
97
97
  body: {
98
98
  ok: boolean;
@@ -101,12 +101,14 @@ export declare function healthAnswer(check: () => Promise<Connection[]> | Connec
101
101
  checked_at: string;
102
102
  pages?: string;
103
103
  activity?: string;
104
+ station?: string;
104
105
  };
105
106
  }>;
106
107
  /** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
107
108
  export declare function healthResponse(req: Request, check: () => Promise<Connection[]> | Connection[], cfg: Omit<AnswerConfig, "token"> & {
108
109
  pages?: PageDef[];
109
110
  activity?: string;
111
+ station?: string;
110
112
  }): Promise<Response>;
111
113
  /** One change, worded by the app for the Hub's Activity page (English, like the Hub). */
112
114
  export type ActivityLine = {
@@ -124,7 +124,7 @@ export async function manifestResponse(req, manifest, cfg) {
124
124
  return jsonResponse(200, manifest, { [APP_ID_HEADER]: own });
125
125
  }
126
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. */
127
- export async function healthAnswer(check, pages, activity) {
127
+ export async function healthAnswer(check, pages, activity, station) {
128
128
  let connections;
129
129
  try {
130
130
  connections = await check();
@@ -144,6 +144,8 @@ export async function healthAnswer(check, pages, activity) {
144
144
  ...(pages ? { pages: await pagesFingerprint(pages) } : {}),
145
145
  // §16: the app's Activity door; when it differs from the one the Hub holds, the Hub reads the manifest again
146
146
  ...(activity ? { activity } : {}),
147
+ // §17: the app's packing-station door; when it differs from the one the Hub holds, the Hub reads the manifest again
148
+ ...(station ? { station } : {}),
147
149
  },
148
150
  };
149
151
  }
@@ -152,7 +154,7 @@ export async function healthResponse(req, check, cfg) {
152
154
  const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
153
155
  if (!v.ok)
154
156
  return jsonResponse(401, { error: "hub_only", reason: v.reason });
155
- const a = await healthAnswer(check, cfg.pages, cfg.activity);
157
+ const a = await healthAnswer(check, cfg.pages, cfg.activity, cfg.station);
156
158
  return jsonResponse(a.status, a.body);
157
159
  }
158
160
  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
@@ -5,6 +5,7 @@ export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue,
5
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";
6
6
  export { checkCloudflareAccess } from "./cf-access.js";
7
7
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
8
+ export { verifyStationCall, stationRefusal, stationJson, STATION_ID_RE, STATION_NAME_MAX, type StationCheck, type StationAnswer, type StationOrder, type StationItem, type PackAnswer, } from "./station.js";
8
9
  export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER, type TokenProvider } from "./identity.js";
9
10
  /**
10
11
  * Reads the guard's settings from a module's environment. Nothing secret: the Hub's address
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue,
5
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";
6
6
  export { checkCloudflareAccess } from "./cf-access.js";
7
7
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
8
+ export { verifyStationCall, stationRefusal, stationJson, STATION_ID_RE, STATION_NAME_MAX, } from "./station.js";
8
9
  export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER } from "./identity.js";
9
10
  /**
10
11
  * Reads the guard's settings from a module's environment. Nothing secret: the Hub's address
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3b/hub-guard",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",