@t3b/hub-guard 0.5.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 +80 -2
- package/dist/hub-client.d.ts +55 -2
- package/dist/hub-client.js +84 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/station.d.ts +82 -0
- package/dist/station.js +55 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -182,16 +182,94 @@ if (!guard.allows(r.session, "deliveries", "write")) return guard.refusePage(req
|
|
|
182
182
|
3. **Show only what may be used** — build the menu from `pageAccess(session, key)` and switch off buttons on a
|
|
183
183
|
Read-only page.
|
|
184
184
|
|
|
185
|
+
## Activity: your change log on the Hub's page (0.6.0, WORKFLOWS §16)
|
|
186
|
+
|
|
187
|
+
The Hub's Settings → Activity lists every change made in the Hub and in every app. Nothing is copied to the Hub: when
|
|
188
|
+
the page opens, the Hub asks your app for its lines with its own pass, and your app answers from its own log.
|
|
189
|
+
|
|
190
|
+
1. **Name the door** in the manifest and in the health answer (the Hub reads the manifest again by itself when the
|
|
191
|
+
health answer names a door it does not hold):
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// manifest: { ..., activity: "/api/hub/activity" } health: healthResponse(req, check, { ...cfg, activity: "/api/hub/activity" })
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
2. **Answer it** from your own log - only the lines that are someone changing something (settings, people and
|
|
198
|
+
access, money, writes to Shopify or a carrier), worded in English like the Hub:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// src/app/api/hub/activity/route.ts
|
|
202
|
+
import { activityResponse } from "@t3b/hub-guard";
|
|
203
|
+
export async function GET(req: Request) {
|
|
204
|
+
return activityResponse(req, async (q) => {
|
|
205
|
+
// q: { from, to, limit, before: { at, id } | null, person (Hub id) | null, store (Hub key) | null }
|
|
206
|
+
// newest first by `at`, then your id highest first; only lines after `before` in that order; at most q.limit
|
|
207
|
+
const rows = await readMyLog(q);
|
|
208
|
+
return {
|
|
209
|
+
lines: rows.map((r) => ({ id: String(r.id), at: r.at, person: r.hubPersonId, what: r.what, detail: r.detail, store: r.store, place: r.branch })),
|
|
210
|
+
count: await countMyLog(q), // the lines in these dates, person and store, all pages together
|
|
211
|
+
};
|
|
212
|
+
}, cfg);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- `at`: ISO 8601 in UTC at your database's full precision (Postgres: `to_char(at at time zone 'UTC',
|
|
217
|
+
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`), and compare `before.at` at that same precision - a coarser time skips lines.
|
|
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"). An app that records people by name reads the Hub's `person_name`
|
|
220
|
+
from the address to filter (`q.person` is the id).
|
|
221
|
+
- `store`: the Hub's store key; a module serving one store answers nothing when asked for another.
|
|
222
|
+
- The Hub drops a line out of shape (`cleanActivityLine`) and cuts long words: `what` 200, `detail` 400,
|
|
223
|
+
`actor`/`place` 60 characters. It waits 5 seconds; an app that does not answer is named on the page.
|
|
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
|
+
|
|
185
256
|
## What it enforces
|
|
186
257
|
|
|
187
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.
|
|
188
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.
|
|
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).
|
|
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).
|
|
190
261
|
- Cloudflare Access proof checked first when the team and audience are set.
|
|
191
|
-
- `verifyRelay`, `manifestResponse`, `healthResponse`: the Hub's pass, for this module, for this purpose, from this environment's Hub; a relay also for these exact bytes
|
|
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.
|
|
192
263
|
|
|
193
264
|
## Changes
|
|
194
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.
|
|
270
|
+
- **0.6.0** (WORKFLOWS §16): Activity - `activityResponse` answers the module's own change log to the Hub's pass for
|
|
271
|
+
`activity` (`readActivityQuery`, `cleanActivityLine`, `ActivityLine`, `ActivityQuery`); `healthResponse(..., { activity })`
|
|
272
|
+
names the door so the Hub reads the manifest again by itself. Nothing else changes; a module on 0.5.0 keeps working.
|
|
195
273
|
- **0.5.0** (D-062, A-509): the session's idle limit is 180 minutes (was 15); the Hub counts the 15-minute check as activity, so the person's Hub session stays alive while they work in the module.
|
|
196
274
|
- **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.
|
|
197
275
|
- **0.3.0** (WORKFLOWS 9.3): `alert()` — report a problem to the Hub, or say it is over.
|
package/dist/hub-client.d.ts
CHANGED
|
@@ -69,7 +69,7 @@ export type HubCallCheck = {
|
|
|
69
69
|
export declare function verifyHubCall(req: Request, opts: {
|
|
70
70
|
moduleId: string;
|
|
71
71
|
keys: JWTVerifyGetKey;
|
|
72
|
-
use: "manifest" | "health";
|
|
72
|
+
use: "manifest" | "health" | "activity";
|
|
73
73
|
now?: Date;
|
|
74
74
|
environment?: string;
|
|
75
75
|
}): Promise<HubCallCheck>;
|
|
@@ -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[]): 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;
|
|
@@ -100,9 +100,62 @@ export declare function healthAnswer(check: () => Promise<Connection[]> | Connec
|
|
|
100
100
|
connections: Connection[];
|
|
101
101
|
checked_at: string;
|
|
102
102
|
pages?: string;
|
|
103
|
+
activity?: string;
|
|
104
|
+
station?: string;
|
|
103
105
|
};
|
|
104
106
|
}>;
|
|
105
107
|
/** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
|
|
106
108
|
export declare function healthResponse(req: Request, check: () => Promise<Connection[]> | Connection[], cfg: Omit<AnswerConfig, "token"> & {
|
|
107
109
|
pages?: PageDef[];
|
|
110
|
+
activity?: string;
|
|
111
|
+
station?: string;
|
|
108
112
|
}): Promise<Response>;
|
|
113
|
+
/** One change, worded by the app for the Hub's Activity page (English, like the Hub). */
|
|
114
|
+
export type ActivityLine = {
|
|
115
|
+
/** the app's own row id; with `at` it fixes the order and the page boundary */
|
|
116
|
+
id: string;
|
|
117
|
+
/** when, ISO 8601 in UTC at the app's full precision (up to microseconds), e.g. "2026-10-03T15:49:49.600123Z" */
|
|
118
|
+
at: string;
|
|
119
|
+
/** the Hub person who made the change (their Hub id), or null */
|
|
120
|
+
person: string | null;
|
|
121
|
+
/** who, when no Hub person did it - "International Orders, on its own" */
|
|
122
|
+
actor?: string | null;
|
|
123
|
+
what: string;
|
|
124
|
+
detail?: string | null;
|
|
125
|
+
/** the Hub's store key the change belongs to, if one ("kw") */
|
|
126
|
+
store?: string | null;
|
|
127
|
+
/** a finer place inside the store, if one (a branch: "Mall 360") */
|
|
128
|
+
place?: string | null;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* What the Hub asks for. Answer the lines newest first - by `at`, then by your own id, highest first - and only
|
|
132
|
+
* those after `before` in that order (compare `at` at the precision you answered it with). At most `limit`.
|
|
133
|
+
*/
|
|
134
|
+
export type ActivityQuery = {
|
|
135
|
+
from: Date;
|
|
136
|
+
to: Date;
|
|
137
|
+
limit: number;
|
|
138
|
+
before: {
|
|
139
|
+
at: string;
|
|
140
|
+
id: string;
|
|
141
|
+
} | null;
|
|
142
|
+
/** only changes made by this Hub person */
|
|
143
|
+
person: string | null;
|
|
144
|
+
/** only changes for this Hub store key; a module serving one store answers nothing for another */
|
|
145
|
+
store: string | null;
|
|
146
|
+
};
|
|
147
|
+
export declare const ACTIVITY_AT_RE: RegExp;
|
|
148
|
+
export declare const ACTIVITY_ID_RE: RegExp;
|
|
149
|
+
export declare const ACTIVITY_MAX_LIMIT = 201;
|
|
150
|
+
/** The Hub's question, from the request's address; null when anything is out of shape (never a wider read). */
|
|
151
|
+
export declare function readActivityQuery(url: URL): ActivityQuery | null;
|
|
152
|
+
/** A line as the Hub accepts it: anything out of shape is dropped, long words are cut. */
|
|
153
|
+
export declare function cleanActivityLine(raw: unknown): ActivityLine | null;
|
|
154
|
+
/**
|
|
155
|
+
* §16: the module's change log, only to the Hub's pass for "activity"; anyone else gets 401 and nothing. `read`
|
|
156
|
+
* answers the lines for the question (see ActivityQuery) and how many lines the dates, person and store hold in all.
|
|
157
|
+
*/
|
|
158
|
+
export declare function activityResponse(req: Request, read: (q: ActivityQuery) => Promise<{
|
|
159
|
+
lines: ActivityLine[];
|
|
160
|
+
count: number;
|
|
161
|
+
}>, cfg: Omit<AnswerConfig, "token">): Promise<Response>;
|
package/dist/hub-client.js
CHANGED
|
@@ -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) {
|
|
127
|
+
export async function healthAnswer(check, pages, activity, station) {
|
|
128
128
|
let connections;
|
|
129
129
|
try {
|
|
130
130
|
connections = await check();
|
|
@@ -142,6 +142,10 @@ export async function healthAnswer(check, pages) {
|
|
|
142
142
|
checked_at: new Date().toISOString(),
|
|
143
143
|
// §14.5: the page list's fingerprint - when it changes, the Hub reads the manifest again by itself
|
|
144
144
|
...(pages ? { pages: await pagesFingerprint(pages) } : {}),
|
|
145
|
+
// §16: the app's Activity door; when it differs from the one the Hub holds, the Hub reads the manifest again
|
|
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 } : {}),
|
|
145
149
|
},
|
|
146
150
|
};
|
|
147
151
|
}
|
|
@@ -150,6 +154,84 @@ export async function healthResponse(req, check, cfg) {
|
|
|
150
154
|
const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
|
|
151
155
|
if (!v.ok)
|
|
152
156
|
return jsonResponse(401, { error: "hub_only", reason: v.reason });
|
|
153
|
-
const a = await healthAnswer(check, cfg.pages);
|
|
157
|
+
const a = await healthAnswer(check, cfg.pages, cfg.activity, cfg.station);
|
|
154
158
|
return jsonResponse(a.status, a.body);
|
|
155
159
|
}
|
|
160
|
+
export const ACTIVITY_AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/;
|
|
161
|
+
export const ACTIVITY_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
162
|
+
export const ACTIVITY_MAX_LIMIT = 201;
|
|
163
|
+
const ACTIVITY_MAX_DAYS = 400;
|
|
164
|
+
const HUB_PERSON_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
165
|
+
const clip = (v, n) => (typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null);
|
|
166
|
+
/** The Hub's question, from the request's address; null when anything is out of shape (never a wider read). */
|
|
167
|
+
export function readActivityQuery(url) {
|
|
168
|
+
const q = url.searchParams;
|
|
169
|
+
const from = Date.parse(q.get("from") ?? "");
|
|
170
|
+
const to = Date.parse(q.get("to") ?? "");
|
|
171
|
+
const limit = Number(q.get("limit") ?? "");
|
|
172
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from || to - from > ACTIVITY_MAX_DAYS * 86_400_000)
|
|
173
|
+
return null;
|
|
174
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > ACTIVITY_MAX_LIMIT)
|
|
175
|
+
return null;
|
|
176
|
+
const bAt = q.get("before_at");
|
|
177
|
+
const bId = q.get("before_id");
|
|
178
|
+
if ((bAt === null) !== (bId === null))
|
|
179
|
+
return null;
|
|
180
|
+
if (bAt !== null && (!ACTIVITY_AT_RE.test(bAt) || !ACTIVITY_ID_RE.test(bId ?? "")))
|
|
181
|
+
return null;
|
|
182
|
+
const person = q.get("person");
|
|
183
|
+
if (person !== null && !HUB_PERSON_RE.test(person))
|
|
184
|
+
return null;
|
|
185
|
+
const store = q.get("store");
|
|
186
|
+
if (store !== null && !/^[a-z0-9-]{1,32}$/.test(store))
|
|
187
|
+
return null;
|
|
188
|
+
return { from: new Date(from), to: new Date(to), limit, before: bAt !== null ? { at: bAt, id: bId } : null, person, store };
|
|
189
|
+
}
|
|
190
|
+
/** A line as the Hub accepts it: anything out of shape is dropped, long words are cut. */
|
|
191
|
+
export function cleanActivityLine(raw) {
|
|
192
|
+
const o = (raw ?? {});
|
|
193
|
+
const id = typeof o.id === "number" && Number.isSafeInteger(o.id) ? String(o.id) : o.id;
|
|
194
|
+
if (typeof id !== "string" || !ACTIVITY_ID_RE.test(id))
|
|
195
|
+
return null;
|
|
196
|
+
if (typeof o.at !== "string" || !ACTIVITY_AT_RE.test(o.at) || !Number.isFinite(Date.parse(o.at)))
|
|
197
|
+
return null;
|
|
198
|
+
const what = clip(o.what, 200);
|
|
199
|
+
if (!what)
|
|
200
|
+
return null;
|
|
201
|
+
return {
|
|
202
|
+
id,
|
|
203
|
+
at: o.at,
|
|
204
|
+
person: typeof o.person === "string" && HUB_PERSON_RE.test(o.person) ? o.person : null,
|
|
205
|
+
actor: clip(o.actor, 60),
|
|
206
|
+
what,
|
|
207
|
+
detail: clip(o.detail, 400),
|
|
208
|
+
store: typeof o.store === "string" && /^[a-z0-9-]{1,32}$/.test(o.store) ? o.store : null,
|
|
209
|
+
place: clip(o.place, 60),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* §16: the module's change log, only to the Hub's pass for "activity"; anyone else gets 401 and nothing. `read`
|
|
214
|
+
* answers the lines for the question (see ActivityQuery) and how many lines the dates, person and store hold in all.
|
|
215
|
+
*/
|
|
216
|
+
export async function activityResponse(req, read, cfg) {
|
|
217
|
+
const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "activity", now: cfg.now, environment: cfg.environment });
|
|
218
|
+
if (!v.ok)
|
|
219
|
+
return jsonResponse(401, { error: "hub_only", reason: v.reason });
|
|
220
|
+
const q = readActivityQuery(new URL(req.url));
|
|
221
|
+
if (!q)
|
|
222
|
+
return jsonResponse(400, { error: "bad_query" });
|
|
223
|
+
let got;
|
|
224
|
+
try {
|
|
225
|
+
got = await read(q);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
console.error("[hub-guard] activity read failed:", e instanceof Error ? e.message : String(e));
|
|
229
|
+
return jsonResponse(500, { error: "read_failed" });
|
|
230
|
+
}
|
|
231
|
+
const lines = (Array.isArray(got?.lines) ? got.lines : [])
|
|
232
|
+
.map(cleanActivityLine)
|
|
233
|
+
.filter((l) => l !== null)
|
|
234
|
+
.slice(0, q.limit);
|
|
235
|
+
const count = Number.isSafeInteger(got?.count) && got.count >= 0 ? got.count : lines.length;
|
|
236
|
+
return jsonResponse(200, { lines, count });
|
|
237
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,10 @@ export { Guard, openingPage, type GuardConfig, type CheckResult } from "./guard.
|
|
|
2
2
|
export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE, type PageAccess, type PageDef, type Pages } from "./pages.js";
|
|
3
3
|
export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
|
|
4
4
|
export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
|
|
5
|
-
export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, 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, 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
|
@@ -2,9 +2,10 @@ export { Guard, openingPage } from "./guard.js";
|
|
|
2
2
|
export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE } from "./pages.js";
|
|
3
3
|
export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
|
|
4
4
|
export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
|
|
5
|
-
export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, } from "./hub-client.js";
|
|
5
|
+
export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, } from "./hub-client.js";
|
|
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;
|
package/dist/station.js
ADDED
|
@@ -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.
|
|
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",
|