@t3b/hub-guard 0.8.0 → 0.10.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
@@ -269,16 +269,58 @@ const lines = guard.appMenu(session);
269
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
270
  - `appMenu(session, { hubUrl, moduleId })` is the same without a Guard; `readApps(raw)` reads a list defensively.
271
271
 
272
+ ## Memos: the bell and the window (0.10.0, WORKFLOWS §22)
273
+
274
+ HR holds the memos; the Hub carries them. Your app shows a bell on its top bar and one page, `/memos`, that every
275
+ signed-in person has (no page tick - a person's own memos are theirs). Nothing is kept in your app: the page asks the
276
+ Hub (your Vercel ID, the person from your own session), the Hub asks HR with its signed pass and passes the answer back.
277
+
278
+ ```ts
279
+ // the bell, in your top bar (the number comes with the pass and every 15-minute check)
280
+ const bell = guard.memoBell(session, "ar"); // { count: 2, href: "/memos", label: "التعاميم: 2 تعاميم بانتظارك" }
281
+
282
+ // GET /memos (and /memos?id=<memo>) - the list, and one memo opened (HR marks it read)
283
+ const data = await guard.memoData(session, { memoId: url.searchParams.get("id"), lang: "ar" });
284
+ const html = memoFragment(data, { lang: "ar", basePath: "/memos", backHref: "/" }); // put it inside your own shell
285
+
286
+ // POST /memos/:id/accept and POST /memos/:id/reply (form field `text`) - then 303 back to /memos?id=<memo>
287
+ const r = await guard.memoAct(session, "accept", id, { lang: "ar" });
288
+ const r2 = await guard.memoAct(session, "reply", id, { text, lang: "ar" });
289
+ ```
290
+
291
+ - Texts in Arabic and English (`MEMO_TEXTS`); pick the language your app speaks (the till Arabic, International
292
+ Orders English). Latin digits; times on Kuwait's clock (`memoWhen`).
293
+ - `memoFragment` escapes every word HR sent and draws with the design system's classes; or draw your own page from
294
+ `data` (`MemoView`). `readMemo` / `readMemos` cut HR's words to shape.
295
+ - The Hub silent → `{ ok: false, reason: "hr_silent" }` with the text to show; the bell keeps its last number.
296
+ - A session from a Hub before §22 carries no number: the bell shows nothing.
297
+ - **HR's own door** (the module that holds the memos): `verifyMemosCall(req, rawBody, { moduleId, keys })` checks the
298
+ Hub's pass - person, app and action named, bound to the exact bytes - and `memosRefusal(reason)` answers a refusal;
299
+ name the door in the manifest as `memos` and in `healthResponse(..., { memos })`.
300
+
272
301
  ## What it enforces
273
302
 
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.
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.
303
+ - `/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 - spent at the Hub, so once across all servers (0.9.0) → 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.
304
+ - 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, and refreshes their pages, apps and stores (0.9.0). A Hub blip keeps the session until the next check; after **180 minutes** without an answer (`maxStaleMs`) the module still opens and reads, but every change waits - a POST/PUT/PATCH/DELETE answers 503 `hub_unreachable` and `allows(…, "write")` / `pageAllowed` say no (0.9.0).
276
305
  - 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).
277
306
  - Cloudflare Access proof checked first when the team and audience are set.
278
307
  - `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.
279
308
 
280
309
  ## Changes
281
310
 
311
+ - **0.10.0** (WORKFLOWS §22, D-088): memos - the pass's and the 15-minute check's `memos` (the waiting number) is kept in
312
+ the session (`session.memos`, `readMemoCount`); `guard.memoBell`, `guard.memoData`, `guard.memoAct`, `memoFragment`,
313
+ `MEMO_TEXTS` (Arabic and English), `memosCall`; for the module that holds the memos `verifyMemosCall`, `memosRefusal`
314
+ and `healthResponse(..., { memos })`; a manifest page may be `everyone: true` (Hub §22.7) and `pagesFingerprint` adds
315
+ that word only for such a page. **The Hub must be on its §22 release before an app takes 0.10.0.** Nothing else
316
+ changes; a module on 0.9.0 keeps working (its sessions simply carry no number).
317
+ - **0.9.0** (the Hub audit of 2026-10-04, H07-H09): (1) the 15-minute check refreshes `session.stores` - a store taken
318
+ away leaves the open app within 15 minutes; (2) after `maxStaleMs` (default 180 minutes, `MAX_STALE_MS`) without the
319
+ Hub's answer the session is `stale`: reads go on, changes are refused (503 `hub_unreachable`, `pageAllowed(…, "write")`
320
+ false - the owner too) until the Hub answers again; (3) every entry ticket is spent at the Hub
321
+ (`POST /api/hub/ticket-used`, `spendTicket`) - a ticket works once across every server; if the Hub does not confirm
322
+ it, the entry is refused (open the app again from the Hub). **The Hub must be on its 2026-10-05 release or later before
323
+ an app takes 0.9.0** (the ticket door). `sharedReplay: false` turns (3) off (tests only).
282
324
  - **0.8.0** (WORKFLOWS §18): the Apps button - the pass's and the 15-minute check's `apps` are kept in the session
283
325
  (`session.apps`), `guard.appMenu(session)` / `appMenu()` give the lines, `readApps` reads a list defensively. Nothing
284
326
  else changes; a module on 0.7.0 keeps working (its sessions simply carry no list).
package/dist/guard.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { JWTVerifyGetKey } from "jose";
2
2
  import { type AppMenuItem } from "./apps.js";
3
+ import { type MemoActResult, type MemoBell, type MemoData, type MemoLang } from "./memos.js";
3
4
  import { type TokenProvider } from "./identity.js";
4
5
  import { type ModuleSession } from "./session.js";
5
6
  export type GuardConfig = {
@@ -27,6 +28,10 @@ export type GuardConfig = {
27
28
  audience?: string;
28
29
  };
29
30
  absoluteMs?: number;
31
+ /** H08 (0.9.0): how long changes stay allowed while the Hub does not answer; default 180 minutes */
32
+ maxStaleMs?: number;
33
+ /** H09 (0.9.0): spend each ticket at the Hub (works once across all servers); default on - off only in tests */
34
+ sharedReplay?: boolean;
30
35
  fetchImpl?: typeof fetch;
31
36
  now?: () => Date;
32
37
  };
@@ -60,6 +65,8 @@ export declare class Guard {
60
65
  enter(req: Request): Promise<Response>;
61
66
  /** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
62
67
  check(req: Request): Promise<CheckResult>;
68
+ /** H08: 503 while the Hub has been silent too long - the session stays (nothing is signed out), changes wait. */
69
+ pauseChanges(req: Request): Response;
63
70
  /**
64
71
  * §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
65
72
  * Ask on the server for every page and every action, then answer `refusePage` when not.
@@ -69,6 +76,18 @@ export declare class Guard {
69
76
  refusePage(req: Request, what?: string): Response;
70
77
  /** §18 (0.8.0): the Apps button's lines for this session - the Hub first, then every app this person may open. */
71
78
  appMenu(session: ModuleSession): AppMenuItem[];
79
+ /** §22.4 (0.10.0): the bell for the top bar - the waiting number and the window's address. */
80
+ memoBell(session: ModuleSession, lang?: MemoLang, basePath?: string): MemoBell;
81
+ /** §22.4 (0.10.0): the memo window's data for this session - the list, and one memo open (marked read) when `memoId` is given. */
82
+ memoData(session: ModuleSession, opts?: {
83
+ memoId?: string | null;
84
+ lang?: MemoLang;
85
+ }): Promise<MemoData>;
86
+ /** §22.4 (0.10.0): accept or reply, for this session's person only; answers what the page should do next. */
87
+ memoAct(session: ModuleSession, action: "accept" | "reply", memoId: string, opts?: {
88
+ text?: string;
89
+ lang?: MemoLang;
90
+ }): Promise<MemoActResult>;
72
91
  /** Signs out of the module only (the Hub session is untouched). */
73
92
  signOut(): Response;
74
93
  }
package/dist/guard.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { appMenu } from "./apps.js";
2
2
  import { checkCloudflareAccess } from "./cf-access.js";
3
- import { sessionCheckPages } from "./hub-client.js";
3
+ import { sessionCheckPages, spendTicket } from "./hub-client.js";
4
+ import { memoAct, memoBell, memoData } from "./memos.js";
4
5
  import { pageAllowed } from "./pages.js";
5
6
  import { hubKeys } from "./identity.js";
6
- import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, RECHECK_MS, SESSION_COOKIE } from "./session.js";
7
+ import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, MAX_STALE_MS, RECHECK_MS, SESSION_COOKIE } from "./session.js";
7
8
  import { JtiMemory, verifyTicket } from "./ticket.js";
8
9
  import { hmacSha256, toHex } from "./webcrypto.js";
9
10
  export class Guard {
@@ -59,8 +60,16 @@ export class Guard {
59
60
  const words = { expired: "The ticket has expired (it lives 60 seconds) - open the module again from the Hub.", wrong_audience: "This ticket was made for another module.", bad_signature: "This ticket was not signed by the Hub.", replayed: "This ticket was already used.", malformed: "No valid ticket.", wrong_environment: "This ticket comes from another Hub (test or live)." };
60
61
  return this.refuse(req, words[v.reason] ?? "No valid ticket.");
61
62
  }
63
+ // H09: this server's memory stopped a replay here; the Hub stops one on any other server
64
+ if (this.cfg.sharedReplay !== false) {
65
+ const spent = await spendTicket(this.hubClient(), v.claims.jti, v.claims.exp);
66
+ if (spent === "used")
67
+ return this.refuse(req, "This ticket was already used.");
68
+ if (spent === "unknown")
69
+ return this.refuse(req, "The Hub could not confirm this ticket - open the app again from the Hub.");
70
+ }
62
71
  const now = this.now().getTime();
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 };
72
+ 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, memos: v.claims.memos };
64
73
  const landed = new URL(this.cfg.landing ? this.cfg.landing(v.claims.path) : v.claims.path, url.origin);
65
74
  // never off this module's own address, whatever `landing` returns
66
75
  const target = landed.origin === url.origin ? landed : new URL("/", url.origin);
@@ -97,12 +106,28 @@ export class Guard {
97
106
  const r2 = await sessionCheckPages(this.hubClient(), s.personId, s.sv);
98
107
  if (r2.verdict === "refused")
99
108
  return { allow: false, response: this.refuse(req, "The Hub ended this session (signed out everywhere, or access changed).") };
100
- // §14: a change of ticks reaches the module here, within 15 minutes - §18: and so does the Apps button's list
109
+ // §14: a change of ticks reaches the module here, within 15 minutes - §18: and so does the Apps button's list -
110
+ // H07: and so does a store taken away (or given) - §22.2: and the bell's number
101
111
  if (r2.verdict === "ok")
102
- s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}), ...(r2.apps ? { apps: r2.apps } : {}) };
112
+ s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}), ...(r2.apps ? { apps: r2.apps } : {}), ...(r2.stores ? { stores: r2.stores } : {}), ...(r2.memos === undefined ? {} : { memos: r2.memos }) };
103
113
  }
114
+ // H08: the Hub has not answered for longer than the limit - the module still opens and reads, changes wait
115
+ const stale = nowMs - s.checkedAt >= (this.cfg.maxStaleMs ?? MAX_STALE_MS);
116
+ if (stale && !["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase()))
117
+ return { allow: false, response: this.pauseChanges(req) };
104
118
  s = { ...s, lastSeen: nowMs };
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) }) };
119
+ const setCookie = cookieHeader(await signSession(s, secret), { secure: url.protocol === "https:", maxAgeS: Math.floor((this.cfg.absoluteMs ?? 8 * 3_600_000) / 1000) });
120
+ return { allow: true, session: stale ? { ...s, stale: true } : s, setCookie };
121
+ }
122
+ /** H08: 503 while the Hub has been silent too long - the session stays (nothing is signed out), changes wait. */
123
+ pauseChanges(req) {
124
+ const reason = "The Hub has not answered for a while, so changes are paused. You can still look; try again when the Hub is back.";
125
+ const accept = req.headers.get("accept") ?? "";
126
+ if (!accept.includes("text/html")) {
127
+ return new Response(JSON.stringify({ error: "hub_unreachable", reason }), { status: 503, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": "60" } });
128
+ }
129
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Changes paused</title></head><body style="font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0"><p style="max-width:440px;text-align:center">${escapeHtml(reason)}</p></body></html>`;
130
+ return new Response(html, { status: 503, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Retry-After": "60" } });
106
131
  }
107
132
  /**
108
133
  * §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
@@ -125,6 +150,18 @@ export class Guard {
125
150
  appMenu(session) {
126
151
  return appMenu(session, { hubUrl: this.cfg.hubUrl, moduleId: this.cfg.moduleId });
127
152
  }
153
+ /** §22.4 (0.10.0): the bell for the top bar - the waiting number and the window's address. */
154
+ memoBell(session, lang = "en", basePath = "/memos") {
155
+ return memoBell(session, lang, basePath);
156
+ }
157
+ /** §22.4 (0.10.0): the memo window's data for this session - the list, and one memo open (marked read) when `memoId` is given. */
158
+ memoData(session, opts = {}) {
159
+ return memoData(this.hubClient(), session, opts);
160
+ }
161
+ /** §22.4 (0.10.0): accept or reply, for this session's person only; answers what the page should do next. */
162
+ memoAct(session, action, memoId, opts = {}) {
163
+ return memoAct(this.hubClient(), session, action, memoId, opts);
164
+ }
128
165
  /** Signs out of the module only (the Hub session is untouched). */
129
166
  signOut() {
130
167
  return new Response(null, { status: 303, headers: { Location: this.cfg.hubHomeUrl ?? this.cfg.hubUrl, "Set-Cookie": clearCookieHeader() } });
@@ -19,7 +19,25 @@ export declare function sessionCheckPages(cfg: HubClientConfig, personId: string
19
19
  verdict: "ok" | "refused" | "unknown";
20
20
  pages?: Pages;
21
21
  apps?: HubApp[];
22
+ stores?: string[];
23
+ memos?: number;
22
24
  }>;
25
+ /** §22.3 (0.10.0): the memo window's one call to the Hub - list, open, accept, reply - for the session's person. */
26
+ export declare function memosCall(cfg: HubClientConfig, action: "list" | "open" | "accept" | "reply", body: {
27
+ personId: string;
28
+ memoId?: string;
29
+ text?: string;
30
+ lang?: "ar" | "en";
31
+ }): Promise<{
32
+ status: number;
33
+ json: Record<string, unknown> | null;
34
+ }>;
35
+ /**
36
+ * H09 (0.9.0): spend an entry ticket at the Hub, so it works once across every server of every module (the memory of
37
+ * one server is not enough on serverless). "first" = this is its first use; "used" = it was used before; "unknown" =
38
+ * the Hub did not say (the entry is refused - the ticket is a minute old, opening again from the Hub is cheap).
39
+ */
40
+ export declare function spendTicket(cfg: HubClientConfig, jti: string, exp: number): Promise<"first" | "used" | "unknown">;
23
41
  export type BrokerToken = {
24
42
  accessToken: string;
25
43
  expiresAt: Date;
@@ -74,7 +92,7 @@ export type HubCallCheck = {
74
92
  export declare function verifyHubCall(req: Request, opts: {
75
93
  moduleId: string;
76
94
  keys: JWTVerifyGetKey;
77
- use: "manifest" | "health" | "activity";
95
+ use: "manifest" | "health" | "activity" | "memos";
78
96
  now?: Date;
79
97
  environment?: string;
80
98
  }): Promise<HubCallCheck>;
@@ -97,7 +115,7 @@ export type Connection = {
97
115
  store?: string | null;
98
116
  };
99
117
  /** 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. */
100
- export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[], activity?: string, station?: string): Promise<{
118
+ export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[], activity?: string, station?: string, memos?: string): Promise<{
101
119
  status: number;
102
120
  body: {
103
121
  ok: boolean;
@@ -107,6 +125,7 @@ export declare function healthAnswer(check: () => Promise<Connection[]> | Connec
107
125
  pages?: string;
108
126
  activity?: string;
109
127
  station?: string;
128
+ memos?: string;
110
129
  };
111
130
  }>;
112
131
  /** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
@@ -114,6 +133,7 @@ export declare function healthResponse(req: Request, check: () => Promise<Connec
114
133
  pages?: PageDef[];
115
134
  activity?: string;
116
135
  station?: string;
136
+ memos?: string;
117
137
  }): Promise<Response>;
118
138
  /** One change, worded by the app for the Hub's Activity page (English, like the Hub). */
119
139
  export type ActivityLine = {
@@ -1,6 +1,7 @@
1
1
  import { jwtVerify } from "jose";
2
2
  import { readApps } from "./apps.js";
3
3
  import { pagesFingerprint, readPages } from "./pages.js";
4
+ import { readMemoCount } from "./ticket.js";
4
5
  import { APP_ID_HEADER, HUB_ISSUER, ownVercelToken, TYP, tokenFrom } from "./identity.js";
5
6
  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.";
6
7
  async function post(cfg, path, body) {
@@ -50,8 +51,31 @@ export async function sessionCheckPages(cfg, personId, sessionVersion) {
50
51
  verdict: "ok",
51
52
  ...(r.json.pages === undefined ? {} : { pages: readPages(r.json.pages) }),
52
53
  ...(r.json.apps === undefined ? {} : { apps: readApps(r.json.apps) }),
54
+ // H07 (0.9.0): the person's stores here as the Hub holds them now (absent from a Hub before 0.9.0)
55
+ ...(Array.isArray(r.json.stores) ? { stores: r.json.stores.filter((s) => typeof s === "string" && /^[a-z]{2}$/.test(s)) } : {}),
56
+ // §22.2 (0.10.0): the person's waiting memos, for the bell (absent from a Hub before §22)
57
+ ...(typeof r.json.memos === "number" ? { memos: readMemoCount(r.json.memos) } : {}),
53
58
  };
54
59
  }
60
+ /** §22.3 (0.10.0): the memo window's one call to the Hub - list, open, accept, reply - for the session's person. */
61
+ export async function memosCall(cfg, action, body) {
62
+ return post(cfg, `/api/hub/memos/${action}`, body);
63
+ }
64
+ /**
65
+ * H09 (0.9.0): spend an entry ticket at the Hub, so it works once across every server of every module (the memory of
66
+ * one server is not enough on serverless). "first" = this is its first use; "used" = it was used before; "unknown" =
67
+ * the Hub did not say (the entry is refused - the ticket is a minute old, opening again from the Hub is cheap).
68
+ */
69
+ export async function spendTicket(cfg, jti, exp) {
70
+ const r = await post(cfg, "/api/hub/ticket-used", { jti, exp });
71
+ if (r.status !== 200 || !r.json)
72
+ return "unknown";
73
+ if (r.json.first === true)
74
+ return "first";
75
+ if (r.json.first === false)
76
+ return "used";
77
+ return "unknown";
78
+ }
55
79
  /** 7.3: the store's token from the Hub, cached until an hour before it expires. */
56
80
  export function tokenBroker(cfg) {
57
81
  const cache = new Map();
@@ -132,7 +156,7 @@ export async function manifestResponse(req, manifest, cfg) {
132
156
  return jsonResponse(200, manifest, { [APP_ID_HEADER]: own });
133
157
  }
134
158
  /** 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. */
135
- export async function healthAnswer(check, pages, activity, station) {
159
+ export async function healthAnswer(check, pages, activity, station, memos) {
136
160
  let connections;
137
161
  try {
138
162
  connections = await check();
@@ -154,6 +178,8 @@ export async function healthAnswer(check, pages, activity, station) {
154
178
  ...(activity ? { activity } : {}),
155
179
  // §17: the app's packing-station door; when it differs from the one the Hub holds, the Hub reads the manifest again
156
180
  ...(station ? { station } : {}),
181
+ // §22.3 (0.10.0): the memos door of the app that holds the memos (HR); likewise
182
+ ...(memos ? { memos } : {}),
157
183
  },
158
184
  };
159
185
  }
@@ -162,7 +188,7 @@ export async function healthResponse(req, check, cfg) {
162
188
  const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
163
189
  if (!v.ok)
164
190
  return jsonResponse(401, { error: "hub_only", reason: v.reason });
165
- const a = await healthAnswer(check, cfg.pages, cfg.activity, cfg.station);
191
+ const a = await healthAnswer(check, cfg.pages, cfg.activity, cfg.station, cfg.memos);
166
192
  return jsonResponse(a.status, a.body);
167
193
  }
168
194
  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,9 +1,10 @@
1
1
  export { Guard, openingPage, type GuardConfig, type CheckResult } from "./guard.js";
2
2
  export { appMenu, readApps, APP_ID_RE, type AppMenuItem, type HubApp } from "./apps.js";
3
3
  export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE, type PageAccess, type PageDef, type Pages } from "./pages.js";
4
- export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
5
- export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
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";
4
+ export { verifyTicket, JtiMemory, readMemoCount, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
5
+ export { memoBell, memoData, memoAct, memoFragment, memoText, memoWhen, readMemo, readMemos, verifyMemosCall, memosRefusal, MEMO_TEXTS, MEMO_ID_RE, REPLY_MAX, type MemoLang, type MemoTextKey, type MemoView, type MemoReply, type MemoBell, type MemoData, type MemoActResult, type MemoFragmentOptions, type MemosCallCheck, } from "./memos.js";
6
+ export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, MAX_STALE_MS, type ModuleSession } from "./session.js";
7
+ export { sessionCheck, sessionCheckPages, spendTicket, memosCall, 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";
7
8
  export { checkCloudflareAccess } from "./cf-access.js";
8
9
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
9
10
  export { verifyStationCall, stationRefusal, stationJson, STATION_ID_RE, STATION_NAME_MAX, type StationCheck, type StationAnswer, type StationOrder, type StationItem, type PackAnswer, } from "./station.js";
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  export { Guard, openingPage } from "./guard.js";
2
2
  export { appMenu, readApps, APP_ID_RE } from "./apps.js";
3
3
  export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE } from "./pages.js";
4
- export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
5
- export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
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";
4
+ export { verifyTicket, JtiMemory, readMemoCount, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
5
+ export { memoBell, memoData, memoAct, memoFragment, memoText, memoWhen, readMemo, readMemos, verifyMemosCall, memosRefusal, MEMO_TEXTS, MEMO_ID_RE, REPLY_MAX, } from "./memos.js";
6
+ export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, MAX_STALE_MS } from "./session.js";
7
+ export { sessionCheck, sessionCheckPages, spendTicket, memosCall, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, } from "./hub-client.js";
7
8
  export { checkCloudflareAccess } from "./cf-access.js";
8
9
  export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
9
10
  export { verifyStationCall, stationRefusal, stationJson, STATION_ID_RE, STATION_NAME_MAX, } from "./station.js";
@@ -0,0 +1,172 @@
1
+ import { type JWTVerifyGetKey } from "jose";
2
+ import { type HubClientConfig } from "./hub-client.js";
3
+ export type MemoLang = "ar" | "en";
4
+ export declare const MEMO_ID_RE: RegExp;
5
+ export declare const REPLY_MAX = 2000;
6
+ /** Every word on the window, in both languages (the owner's rule: Arabic and English everywhere). */
7
+ export declare const MEMO_TEXTS: {
8
+ readonly en: {
9
+ readonly title: "Memos";
10
+ readonly bell: "Memos";
11
+ readonly waiting: "memos waiting";
12
+ readonly empty: "No memos.";
13
+ readonly from: "From";
14
+ readonly sent: "Sent";
15
+ readonly state_new: "New";
16
+ readonly state_read: "Read";
17
+ readonly state_accepted: "Accepted";
18
+ readonly state_withdrawn: "Withdrawn";
19
+ readonly accept: "I have read and accept";
20
+ readonly accepted_on: "Accepted";
21
+ readonly accept_note: "One press, recorded with the time and this app.";
22
+ readonly replies: "Your replies";
23
+ readonly replies_note: "Only the people ticked for Replies read them.";
24
+ readonly reply: "Reply";
25
+ readonly reply_placeholder: "Write a reply…";
26
+ readonly send_reply: "Send reply";
27
+ readonly answer: "Answer";
28
+ readonly open: "Open";
29
+ readonly back: "Back to the list";
30
+ readonly hr_silent: "HR did not answer. The bell keeps its last number - try again in a minute.";
31
+ readonly not_yours: "That memo is not yours.";
32
+ readonly too_long: "A reply is 1 to 2,000 characters.";
33
+ readonly new_answer: "New answer";
34
+ };
35
+ readonly ar: {
36
+ readonly title: "التعاميم";
37
+ readonly bell: "التعاميم";
38
+ readonly waiting: "تعاميم بانتظارك";
39
+ readonly empty: "لا توجد تعاميم.";
40
+ readonly from: "من";
41
+ readonly sent: "أُرسل";
42
+ readonly state_new: "جديد";
43
+ readonly state_read: "مقروء";
44
+ readonly state_accepted: "تمت الموافقة";
45
+ readonly state_withdrawn: "مسحوب";
46
+ readonly accept: "قرأت وأوافق";
47
+ readonly accepted_on: "تمت الموافقة";
48
+ readonly accept_note: "ضغطة واحدة، تُسجَّل مع الوقت واسم هذا التطبيق.";
49
+ readonly replies: "ردودك";
50
+ readonly replies_note: "يقرأها فقط من لديهم صلاحية الردود.";
51
+ readonly reply: "رد";
52
+ readonly reply_placeholder: "اكتب رداً…";
53
+ readonly send_reply: "إرسال الرد";
54
+ readonly answer: "الرد";
55
+ readonly open: "فتح";
56
+ readonly back: "عودة إلى القائمة";
57
+ readonly hr_silent: "لم يرد نظام الموارد البشرية. يبقى الجرس على آخر رقم - حاول بعد دقيقة.";
58
+ readonly not_yours: "هذا التعميم ليس لك.";
59
+ readonly too_long: "الرد من حرف واحد إلى 2,000 حرف.";
60
+ readonly new_answer: "رد جديد";
61
+ };
62
+ };
63
+ export type MemoTextKey = keyof (typeof MEMO_TEXTS)["en"];
64
+ export declare function memoText(lang: MemoLang, key: MemoTextKey): string;
65
+ export type MemoReply = {
66
+ id: string;
67
+ text: string;
68
+ at: string;
69
+ answer: {
70
+ text: string;
71
+ at: string;
72
+ by: string;
73
+ } | null;
74
+ };
75
+ export type MemoView = {
76
+ id: string;
77
+ title: string;
78
+ text: string | null;
79
+ from: string;
80
+ sent_at: string;
81
+ state: "new" | "read" | "accepted" | "withdrawn";
82
+ read_at: string | null;
83
+ accepted_at: string | null;
84
+ replies: MemoReply[];
85
+ unseen_answer: boolean;
86
+ };
87
+ export declare function readMemo(raw: unknown): MemoView | null;
88
+ export declare function readMemos(raw: unknown): MemoView[];
89
+ export type MemoBell = {
90
+ count: number;
91
+ href: string;
92
+ label: string;
93
+ };
94
+ /** The bell for the top bar: the waiting number from the pass or the last check, and the window's address. */
95
+ export declare function memoBell(session: {
96
+ memos?: number;
97
+ }, lang?: MemoLang, basePath?: string): MemoBell;
98
+ export type MemoData = {
99
+ ok: true;
100
+ memos: MemoView[];
101
+ open: MemoView | null;
102
+ waiting: number | null;
103
+ } | {
104
+ ok: false;
105
+ reason: "hr_silent" | "refused" | "not_yours";
106
+ message: string;
107
+ };
108
+ /** The person's list, and one memo opened (HR marks it read). Only for the session's person - the app never passes another id. */
109
+ export declare function memoData(cfg: HubClientConfig, session: {
110
+ personId: string;
111
+ memos?: number;
112
+ }, opts?: {
113
+ memoId?: string | null;
114
+ lang?: MemoLang;
115
+ }): Promise<MemoData>;
116
+ export type MemoActResult = {
117
+ ok: true;
118
+ memo: MemoView | null;
119
+ waiting: number | null;
120
+ } | {
121
+ ok: false;
122
+ reason: "bad_request" | "hr_silent" | "refused";
123
+ message: string;
124
+ };
125
+ /** Accept, or reply: for the session's person only. The caller then shows the memo again (a 303 to its address). */
126
+ export declare function memoAct(cfg: HubClientConfig, session: {
127
+ personId: string;
128
+ }, action: "accept" | "reply", memoId: string, opts?: {
129
+ text?: string;
130
+ lang?: MemoLang;
131
+ }): Promise<MemoActResult>;
132
+ /** A time as the window shows it: Kuwait's clock, Latin digits, `2026-10-12 10:30`. */
133
+ export declare function memoWhen(iso: string): string;
134
+ export type MemoFragmentOptions = {
135
+ lang?: MemoLang;
136
+ /** the window's address on this app (links and forms) */
137
+ basePath?: string;
138
+ /** a link back to the app's own page */
139
+ backHref?: string | null;
140
+ /** a message to show on top (an error from `memoAct`) */
141
+ notice?: string | null;
142
+ };
143
+ /**
144
+ * The window as HTML the app puts inside its own shell: the list on the start side, the open memo (or the list only)
145
+ * with the accept button, the person's replies and the reply box. Every HR word is escaped. Forms: POST
146
+ * `<basePath>/<id>/accept` and `<basePath>/<id>/reply` (field `text`); a list line links `<basePath>?id=<id>`.
147
+ */
148
+ export declare function memoFragment(data: MemoData, opts?: MemoFragmentOptions): string;
149
+ export type MemosCallCheck = {
150
+ ok: true;
151
+ person: string;
152
+ app: string;
153
+ action: "list" | "open" | "accept" | "reply" | "delivered";
154
+ } | {
155
+ ok: false;
156
+ reason: "missing" | "bad_signature" | "expired" | "wrong_module" | "wrong_use" | "wrong_environment" | "body_changed";
157
+ };
158
+ /**
159
+ * Is this the Hub asking the memos door? `body` is the request's raw bytes (read with `await req.arrayBuffer()` before
160
+ * parsing - the pass covers the exact bytes). The pass names the person, the app the person is in and the action; the
161
+ * body carries the same and the action's fields.
162
+ */
163
+ export declare function verifyMemosCall(req: Request, body: Uint8Array | string, opts: {
164
+ moduleId: string;
165
+ keys: JWTVerifyGetKey;
166
+ now?: Date;
167
+ environment?: string;
168
+ }): Promise<MemosCallCheck>;
169
+ /** The refusal HR's memos door answers with: 401 and only the reason word. */
170
+ export declare function memosRefusal(reason: Extract<MemosCallCheck, {
171
+ ok: false;
172
+ }>["reason"]): Response;
package/dist/memos.js ADDED
@@ -0,0 +1,251 @@
1
+ import { jwtVerify } from "jose";
2
+ import { memosCall } from "./hub-client.js";
3
+ import { HUB_ISSUER, TYP } from "./identity.js";
4
+ import { b64urlEncode, constantTimeEqual, sha256 } from "./webcrypto.js";
5
+ export const MEMO_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
6
+ export const REPLY_MAX = 2_000;
7
+ /** Every word on the window, in both languages (the owner's rule: Arabic and English everywhere). */
8
+ export const MEMO_TEXTS = {
9
+ en: {
10
+ title: "Memos",
11
+ bell: "Memos",
12
+ waiting: "memos waiting",
13
+ empty: "No memos.",
14
+ from: "From",
15
+ sent: "Sent",
16
+ state_new: "New",
17
+ state_read: "Read",
18
+ state_accepted: "Accepted",
19
+ state_withdrawn: "Withdrawn",
20
+ accept: "I have read and accept",
21
+ accepted_on: "Accepted",
22
+ accept_note: "One press, recorded with the time and this app.",
23
+ replies: "Your replies",
24
+ replies_note: "Only the people ticked for Replies read them.",
25
+ reply: "Reply",
26
+ reply_placeholder: "Write a reply…",
27
+ send_reply: "Send reply",
28
+ answer: "Answer",
29
+ open: "Open",
30
+ back: "Back to the list",
31
+ hr_silent: "HR did not answer. The bell keeps its last number - try again in a minute.",
32
+ not_yours: "That memo is not yours.",
33
+ too_long: "A reply is 1 to 2,000 characters.",
34
+ new_answer: "New answer",
35
+ },
36
+ ar: {
37
+ title: "التعاميم",
38
+ bell: "التعاميم",
39
+ waiting: "تعاميم بانتظارك",
40
+ empty: "لا توجد تعاميم.",
41
+ from: "من",
42
+ sent: "أُرسل",
43
+ state_new: "جديد",
44
+ state_read: "مقروء",
45
+ state_accepted: "تمت الموافقة",
46
+ state_withdrawn: "مسحوب",
47
+ accept: "قرأت وأوافق",
48
+ accepted_on: "تمت الموافقة",
49
+ accept_note: "ضغطة واحدة، تُسجَّل مع الوقت واسم هذا التطبيق.",
50
+ replies: "ردودك",
51
+ replies_note: "يقرأها فقط من لديهم صلاحية الردود.",
52
+ reply: "رد",
53
+ reply_placeholder: "اكتب رداً…",
54
+ send_reply: "إرسال الرد",
55
+ answer: "الرد",
56
+ open: "فتح",
57
+ back: "عودة إلى القائمة",
58
+ hr_silent: "لم يرد نظام الموارد البشرية. يبقى الجرس على آخر رقم - حاول بعد دقيقة.",
59
+ not_yours: "هذا التعميم ليس لك.",
60
+ too_long: "الرد من حرف واحد إلى 2,000 حرف.",
61
+ new_answer: "رد جديد",
62
+ },
63
+ };
64
+ export function memoText(lang, key) {
65
+ return MEMO_TEXTS[lang === "ar" ? "ar" : "en"][key];
66
+ }
67
+ const WHEN_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/;
68
+ const clip = (v, n) => (typeof v === "string" && v.trim() ? v.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").trim().slice(0, n) : null);
69
+ const when = (v) => (typeof v === "string" && WHEN_RE.test(v) ? v : null);
70
+ export function readMemo(raw) {
71
+ const o = (raw ?? {});
72
+ if (typeof o.id !== "string" || !MEMO_ID_RE.test(o.id))
73
+ return null;
74
+ const title = clip(o.title, 120);
75
+ const from = clip(o.from, 80);
76
+ const sent = when(o.sent_at);
77
+ const state = o.state === "new" || o.state === "read" || o.state === "accepted" || o.state === "withdrawn" ? o.state : null;
78
+ if (!title || !from || !sent || !state)
79
+ return null;
80
+ const replies = (Array.isArray(o.replies) ? o.replies : [])
81
+ .slice(0, 50)
82
+ .flatMap((r) => {
83
+ const x = (r ?? {});
84
+ const id = typeof x.id === "string" && MEMO_ID_RE.test(x.id) ? x.id : null;
85
+ const text = clip(x.text, REPLY_MAX);
86
+ const at = when(x.at);
87
+ if (!id || !text || !at)
88
+ return [];
89
+ const a = (x.answer ?? null);
90
+ const answer = a && clip(a.text, REPLY_MAX) && when(a.at) && clip(a.by, 80) ? { text: clip(a.text, REPLY_MAX), at: when(a.at), by: clip(a.by, 80) } : null;
91
+ return [{ id, text, at, answer }];
92
+ });
93
+ return { id: o.id, title, text: clip(o.text, 4_000), from, sent_at: sent, state, read_at: when(o.read_at), accepted_at: when(o.accepted_at), replies, unseen_answer: o.unseen_answer === true };
94
+ }
95
+ export function readMemos(raw) {
96
+ return (Array.isArray(raw) ? raw : []).slice(0, 200).map(readMemo).filter((m) => m !== null);
97
+ }
98
+ /** The bell for the top bar: the waiting number from the pass or the last check, and the window's address. */
99
+ export function memoBell(session, lang = "en", basePath = "/memos") {
100
+ const count = typeof session.memos === "number" && session.memos > 0 ? Math.min(Math.floor(session.memos), 9_999) : 0;
101
+ return { count, href: basePath, label: count > 0 ? `${memoText(lang, "bell")}: ${count} ${memoText(lang, "waiting")}` : memoText(lang, "bell") };
102
+ }
103
+ /** The person's list, and one memo opened (HR marks it read). Only for the session's person - the app never passes another id. */
104
+ export async function memoData(cfg, session, opts = {}) {
105
+ const lang = opts.lang ?? "en";
106
+ const list = await memosCall(cfg, "list", { personId: session.personId, lang });
107
+ if (list.status === 0 || list.status >= 500 || !list.json)
108
+ return { ok: false, reason: "hr_silent", message: memoText(lang, "hr_silent") };
109
+ if (list.json.ok !== true)
110
+ return { ok: false, reason: "refused", message: String(list.json.message ?? memoText(lang, "hr_silent")) };
111
+ const memos = readMemos(list.json.memos);
112
+ let waiting = typeof list.json.waiting === "number" ? list.json.waiting : null;
113
+ let open = null;
114
+ const id = typeof opts.memoId === "string" && MEMO_ID_RE.test(opts.memoId) ? opts.memoId : null;
115
+ if (id) {
116
+ if (!memos.some((m) => m.id === id))
117
+ return { ok: false, reason: "not_yours", message: memoText(lang, "not_yours") };
118
+ const r = await memosCall(cfg, "open", { personId: session.personId, memoId: id, lang });
119
+ if (r.status === 0 || r.status >= 500 || !r.json)
120
+ return { ok: false, reason: "hr_silent", message: memoText(lang, "hr_silent") };
121
+ if (r.json.ok === true) {
122
+ open = readMemo(r.json.memo);
123
+ if (typeof r.json.waiting === "number")
124
+ waiting = r.json.waiting;
125
+ }
126
+ }
127
+ return { ok: true, memos: open ? memos.map((m) => (m.id === open.id ? open : m)) : memos, open, waiting };
128
+ }
129
+ /** Accept, or reply: for the session's person only. The caller then shows the memo again (a 303 to its address). */
130
+ export async function memoAct(cfg, session, action, memoId, opts = {}) {
131
+ const lang = opts.lang ?? "en";
132
+ if (!MEMO_ID_RE.test(memoId))
133
+ return { ok: false, reason: "bad_request", message: memoText(lang, "not_yours") };
134
+ const text = action === "reply" ? (opts.text ?? "").replace(/\r\n/g, "\n").trim() : undefined;
135
+ if (action === "reply" && (!text || text.length > REPLY_MAX))
136
+ return { ok: false, reason: "bad_request", message: memoText(lang, "too_long") };
137
+ const r = await memosCall(cfg, action, { personId: session.personId, memoId, lang, ...(text ? { text } : {}) });
138
+ if (r.status === 0 || r.status >= 500 || !r.json)
139
+ return { ok: false, reason: "hr_silent", message: memoText(lang, "hr_silent") };
140
+ if (r.json.ok !== true)
141
+ return { ok: false, reason: "refused", message: String(r.json.message ?? memoText(lang, "hr_silent")) };
142
+ return { ok: true, memo: readMemo(r.json.memo), waiting: typeof r.json.waiting === "number" ? r.json.waiting : null };
143
+ }
144
+ // ---- the window's markup (the same page in every app, the design system's classes, inline styles for its layout) ----
145
+ function esc(s) {
146
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
147
+ }
148
+ /** A time as the window shows it: Kuwait's clock, Latin digits, `2026-10-12 10:30`. */
149
+ export function memoWhen(iso) {
150
+ const d = new Date(iso);
151
+ if (Number.isNaN(d.getTime()))
152
+ return iso;
153
+ const p = new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Kuwait", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(d);
154
+ const g = (t) => p.find((x) => x.type === t)?.value ?? "";
155
+ return `${g("year")}-${g("month")}-${g("day")} ${g("hour")}:${g("minute")}`;
156
+ }
157
+ function stateBadge(m, lang) {
158
+ const tone = m.state === "accepted" ? "badge-success" : m.state === "new" ? "badge-warning" : m.state === "withdrawn" ? "badge-muted" : "badge-info";
159
+ const key = `state_${m.state}`;
160
+ const extra = m.unseen_answer ? ` <span class="badge badge-honey">${esc(memoText(lang, "new_answer"))}</span>` : "";
161
+ return `<span class="badge ${tone}">${esc(memoText(lang, key))}</span>${extra}`;
162
+ }
163
+ /**
164
+ * The window as HTML the app puts inside its own shell: the list on the start side, the open memo (or the list only)
165
+ * with the accept button, the person's replies and the reply box. Every HR word is escaped. Forms: POST
166
+ * `<basePath>/<id>/accept` and `<basePath>/<id>/reply` (field `text`); a list line links `<basePath>?id=<id>`.
167
+ */
168
+ export function memoFragment(data, opts = {}) {
169
+ const lang = opts.lang ?? "en";
170
+ const t = (k) => esc(memoText(lang, k));
171
+ const base = opts.basePath ?? "/memos";
172
+ const head = `<div class="page-head"><div class="row"><div class="title"><h1>${t("title")}</h1></div>${opts.backHref ? `<div class="actions"><a class="btn btn-ghost" href="${esc(opts.backHref)}">${t("back")}</a></div>` : ""}</div></div>`;
173
+ const notice = opts.notice ? `<div class="alert warning" role="status" style="margin-block-end: var(--s-4)"><div class="txt">${esc(opts.notice)}</div></div>` : "";
174
+ if (!data.ok)
175
+ return `${head}${notice}<div class="alert warning" role="alert"><div class="txt">${esc(data.message)}</div></div>`;
176
+ if (data.memos.length === 0)
177
+ return `${head}${notice}<div class="card"><p class="help-2" style="margin: 0">${t("empty")}</p></div>`;
178
+ const list = data.memos
179
+ .map((m) => {
180
+ const current = data.open?.id === m.id;
181
+ return `<a href="${esc(`${base}?id=${encodeURIComponent(m.id)}`)}" ${current ? 'aria-current="true"' : ""} style="display: grid; gap: 4px; padding: var(--s-3) var(--s-4); border-block-end: 1px solid var(--line); text-decoration: none; color: inherit;${current ? " background: var(--paper-2); box-shadow: inset 3px 0 0 var(--ink);" : ""}"><span style="display: flex; align-items: center; gap: var(--s-2); font-weight: 600; font-size: var(--fs-body-sm)"><span style="flex: 1; min-inline-size: 0">${esc(m.title)}</span>${stateBadge(m, lang)}</span><span class="help-2">${esc(m.from)} · ${esc(memoWhen(m.sent_at))}</span></a>`;
182
+ })
183
+ .join("");
184
+ const o = data.open;
185
+ const opened = !o
186
+ ? ""
187
+ : `<div class="card"><div class="card-head"><h2 class="card-title">${esc(o.title)}</h2><div class="help-2" style="display: flex; gap: var(--s-3); flex-wrap: wrap"><span>${t("from")} ${esc(o.from)}</span><span>${t("sent")} ${esc(memoWhen(o.sent_at))}</span></div></div>` +
188
+ `<p style="white-space: pre-line; line-height: 1.7; margin: 0 0 var(--s-4)">${esc(o.text ?? "")}</p>` +
189
+ (o.state === "accepted"
190
+ ? `<div class="alert success" role="status"><div class="txt"><b>${t("accepted_on")} · ${esc(memoText(lang === "ar" ? "en" : "ar", "accept"))}</b>${o.accepted_at ? esc(memoWhen(o.accepted_at)) : ""}</div></div>`
191
+ : o.state === "withdrawn"
192
+ ? `<div class="alert info" role="status"><div class="txt">${t("state_withdrawn")}</div></div>`
193
+ : `<form method="post" action="${esc(`${base}/${encodeURIComponent(o.id)}/accept`)}" style="display: flex; gap: var(--s-3); align-items: center; flex-wrap: wrap; padding-block: var(--s-3); border-block: 1px solid var(--line)"><button type="submit" class="btn btn-primary" style="font-size: var(--fs-body); padding-block: var(--s-3)">${t("accept")} · ${esc(memoText(lang === "ar" ? "en" : "ar", "accept"))}</button><span class="help-2">${t("accept_note")}</span></form>`) +
194
+ `<div style="display: grid; gap: var(--s-3); margin-block-start: var(--s-4)"><div class="section-head"><h3 style="margin: 0">${t("replies")} <span class="help-2" style="font-weight: 400">· ${t("replies_note")}</span></h3></div>` +
195
+ o.replies
196
+ .map((r) => `<div style="border-inline-start: 3px solid var(--line); padding-inline-start: var(--s-3); display: grid; gap: 4px; font-size: var(--fs-body-sm)"><span class="help-2">${esc(memoWhen(r.at))}</span><span style="white-space: pre-line">${esc(r.text)}</span></div>` +
197
+ (r.answer ? `<div style="border-inline-start: 3px solid var(--honey, #EDA94D); margin-inline-start: var(--s-5); padding-inline-start: var(--s-3); display: grid; gap: 4px; font-size: var(--fs-body-sm)"><span class="help-2">${t("answer")} · ${esc(r.answer.by)} · ${esc(memoWhen(r.answer.at))}</span><span style="white-space: pre-line">${esc(r.answer.text)}</span></div>` : ""))
198
+ .join("") +
199
+ (o.state === "withdrawn"
200
+ ? ""
201
+ : `<form method="post" action="${esc(`${base}/${encodeURIComponent(o.id)}/reply`)}" style="display: grid; gap: var(--s-2)"><div class="field"><label class="label" for="memo-reply">${t("reply")}</label><textarea id="memo-reply" name="text" class="textarea" rows="2" maxlength="${REPLY_MAX}" required placeholder="${t("reply_placeholder")}"></textarea></div><div style="display: flex; justify-content: flex-end"><button type="submit" class="btn btn-secondary">${t("send_reply")}</button></div></form>`) +
202
+ `</div></div>`;
203
+ // side by side on a desk (the list at most 360 px wide), one under the other on a phone - a wrapping row, no media query
204
+ return `${head}${notice}<div style="display: flex; flex-wrap: wrap; gap: var(--s-4); align-items: flex-start"><div class="card" role="list" style="padding: 0; overflow: hidden; flex: 1 1 280px; min-inline-size: 0;${o ? " max-inline-size: 360px;" : ""}">${list}</div>${o ? opened.replace('<div class="card">', '<div class="card" style="flex: 999 1 420px; min-inline-size: 0">') : ""}</div>`;
205
+ }
206
+ const PERSON_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
207
+ const APP_RE = /^[a-z][a-z0-9-]{1,30}$/;
208
+ const ACTIONS = ["list", "open", "accept", "reply", "delivered"];
209
+ /**
210
+ * Is this the Hub asking the memos door? `body` is the request's raw bytes (read with `await req.arrayBuffer()` before
211
+ * parsing - the pass covers the exact bytes). The pass names the person, the app the person is in and the action; the
212
+ * body carries the same and the action's fields.
213
+ */
214
+ export async function verifyMemosCall(req, body, opts) {
215
+ const auth = req.headers.get("authorization") ?? "";
216
+ const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
217
+ if (!token || token.length > 4096)
218
+ return { ok: false, reason: "missing" };
219
+ let p;
220
+ try {
221
+ ({ 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 }));
222
+ }
223
+ catch (e) {
224
+ const code = e.code ?? "";
225
+ if (code === "ERR_JWT_EXPIRED")
226
+ return { ok: false, reason: "expired" };
227
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && /"aud"/.test(e.message))
228
+ return { ok: false, reason: "wrong_module" };
229
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED")
230
+ return { ok: false, reason: "expired" };
231
+ return { ok: false, reason: "bad_signature" };
232
+ }
233
+ if (p.use !== "memos")
234
+ return { ok: false, reason: "wrong_use" };
235
+ if (opts.environment && p.env !== opts.environment)
236
+ return { ok: false, reason: "wrong_environment" };
237
+ const person = typeof p.person === "string" && PERSON_RE.test(p.person) ? p.person : null;
238
+ const app = typeof p.app === "string" && APP_RE.test(p.app) ? p.app : null;
239
+ const action = ACTIONS.find((a) => a === p.action) ?? null;
240
+ if (!person || !app || !action)
241
+ return { ok: false, reason: "bad_signature" };
242
+ const want = typeof p.body_sha256 === "string" ? p.body_sha256 : "";
243
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
244
+ if (!want || !constantTimeEqual(b64urlEncode(await sha256(bytes)), want))
245
+ return { ok: false, reason: "body_changed" };
246
+ return { ok: true, person, app, action };
247
+ }
248
+ /** The refusal HR's memos door answers with: 401 and only the reason word. */
249
+ export function memosRefusal(reason) {
250
+ return new Response(JSON.stringify({ error: "hub_only", reason }), { status: 401, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
251
+ }
package/dist/pages.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export type PageAccess = "r" | "w";
2
2
  export type Pages = Record<string, PageAccess>;
3
- /** One page as a module lists it in its manifest. `write`: what Write allows, or null when there is nothing to change. */
3
+ /**
4
+ * One page as a module lists it in its manifest. `write`: what Write allows, or null when there is nothing to change.
5
+ * `everyone` (0.10.0, Hub §22.7): every person has Read on it without a tick - for a page that only ever shows a
6
+ * person their own rows (HR's My profile); the Hub grants it by itself.
7
+ */
4
8
  export type PageDef = {
5
9
  key: string;
6
10
  group?: string | null;
@@ -10,6 +14,7 @@ export type PageDef = {
10
14
  };
11
15
  read: string;
12
16
  write?: string | null;
17
+ everyone?: boolean;
13
18
  };
14
19
  export declare const PAGE_KEY_RE: RegExp;
15
20
  /** A pass's or check's `pages`, read defensively: anything malformed is dropped, never widened. */
@@ -26,6 +31,10 @@ export declare function pageAccess(session: {
26
31
  export declare function pageAllowed(session: {
27
32
  role: string;
28
33
  pages?: Pages;
34
+ stale?: boolean;
29
35
  }, key: string, need: "read" | "write"): boolean;
30
- /** The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). */
36
+ /**
37
+ * The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). An everyone's page adds
38
+ * one more word; a list without one fingerprints exactly as before 0.10.0.
39
+ */
31
40
  export declare function pagesFingerprint(pages: PageDef[]): Promise<string>;
package/dist/pages.js CHANGED
@@ -22,11 +22,17 @@ export function pageAccess(session, key) {
22
22
  * change (`"write"`) this page? Ask it on the server - hiding a button is not enough (§14.3).
23
23
  */
24
24
  export function pageAllowed(session, key, need) {
25
+ // H08 (0.9.0): while the Hub has not answered for 180 minutes nobody changes anything - the owner included
26
+ if (need === "write" && session.stale)
27
+ return false;
25
28
  const a = pageAccess(session, key);
26
29
  return need === "read" ? a === "r" || a === "w" : a === "w";
27
30
  }
28
- /** The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). */
31
+ /**
32
+ * The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). An everyone's page adds
33
+ * one more word; a list without one fingerprints exactly as before 0.10.0.
34
+ */
29
35
  export async function pagesFingerprint(pages) {
30
- const text = JSON.stringify(pages.map((p) => [p.key, p.group ?? null, p.name.en, p.name.ar, p.read, p.write ?? null]));
36
+ const text = JSON.stringify(pages.map((p) => [p.key, p.group ?? null, p.name.en, p.name.ar, p.read, p.write ?? null, ...(p.everyone ? ["everyone"] : [])]));
31
37
  return b64urlEncode(await sha256(utf8(text))).slice(0, 16);
32
38
  }
package/dist/session.d.ts CHANGED
@@ -4,6 +4,12 @@ export declare const SESSION_COOKIE = "t3b_module_session";
4
4
  export declare const IDLE_MS: number;
5
5
  export declare const ABSOLUTE_MS: number;
6
6
  export declare const RECHECK_MS: number;
7
+ /**
8
+ * H08 (0.9.0): how long a module keeps allowing changes while the Hub does not answer its check - the same 180 minutes
9
+ * as the idle rule. Past it the module still opens and reads, but every change waits until the Hub answers again (a
10
+ * person the owner removed during an outage cannot keep changing things for the session's whole 8 hours).
11
+ */
12
+ export declare const MAX_STALE_MS: number;
7
13
  export type ModuleSession = {
8
14
  personId: string;
9
15
  name: string;
@@ -17,6 +23,10 @@ export type ModuleSession = {
17
23
  pages?: Pages;
18
24
  /** §18 (0.8.0): the apps this person may open (the Apps button), from the pass, refreshed by every 15-minute check */
19
25
  apps?: HubApp[];
26
+ /** §22.2 (0.10.0): the person's waiting memos (the bell), from the pass, refreshed by every 15-minute check; absent from a Hub before §22 */
27
+ memos?: number;
28
+ /** H08 (0.9.0): set by `check` (never stored) when the Hub has not answered for MAX_STALE_MS - no changes */
29
+ stale?: boolean;
20
30
  };
21
31
  export declare function signSession(s: ModuleSession, secret: string): Promise<string>;
22
32
  export declare function readSession(cookie: string | undefined | null, secret: string, now?: number, opts?: {
package/dist/session.js CHANGED
@@ -7,8 +7,16 @@ export const SESSION_COOKIE = "t3b_module_session";
7
7
  export const IDLE_MS = 180 * 60_000;
8
8
  export const ABSOLUTE_MS = 8 * 3_600_000;
9
9
  export const RECHECK_MS = 15 * 60_000;
10
+ /**
11
+ * H08 (0.9.0): how long a module keeps allowing changes while the Hub does not answer its check - the same 180 minutes
12
+ * as the idle rule. Past it the module still opens and reads, but every change waits until the Hub answers again (a
13
+ * person the owner removed during an outage cannot keep changing things for the session's whole 8 hours).
14
+ */
15
+ export const MAX_STALE_MS = 180 * 60_000;
10
16
  export async function signSession(s, secret) {
11
- const body = b64urlEncode(utf8(JSON.stringify(s)));
17
+ const { stale: _stale, ...kept } = s; // computed on every request, never carried in the cookie
18
+ void _stale;
19
+ const body = b64urlEncode(utf8(JSON.stringify(kept)));
12
20
  const mac = b64urlEncode(await hmacSha256(secret, body));
13
21
  return `${body}.${mac}`;
14
22
  }
package/dist/ticket.d.ts CHANGED
@@ -18,7 +18,11 @@ export type TicketClaims = {
18
18
  pages: Pages;
19
19
  /** §18 (0.8.0): the apps this person may open; `[]` from a Hub before §18 */
20
20
  apps: HubApp[];
21
+ /** §22.2 (0.10.0): the person's waiting memos (the bell); 0 from a Hub before §22 */
22
+ memos: number;
21
23
  };
24
+ /** A waiting number as the Hub sends it: a whole number from 0 to 9,999; anything else is 0. */
25
+ export declare function readMemoCount(raw: unknown): number;
22
26
  export type VerifyResult = {
23
27
  ok: true;
24
28
  claims: TicketClaims;
package/dist/ticket.js CHANGED
@@ -9,6 +9,10 @@ import { HUB_ISSUER, TYP } from "./identity.js";
9
9
  export const TICKET_ISSUER = HUB_ISSUER;
10
10
  export const TICKET_TTL_S = 60;
11
11
  export const TICKET_ALG = "EdDSA";
12
+ /** A waiting number as the Hub sends it: a whole number from 0 to 9,999; anything else is 0. */
13
+ export function readMemoCount(raw) {
14
+ return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? Math.min(raw, 9_999) : 0;
15
+ }
12
16
  /** Seen ticket ids, kept for two minutes (C-4). One instance's memory; a replay across instances is stopped by the 60-s life. */
13
17
  export class JtiMemory {
14
18
  ttlMs;
@@ -61,6 +65,6 @@ export async function verifyTicket(ticket, opts) {
61
65
  return { ok: false, reason: "replayed" };
62
66
  return {
63
67
  ok: true,
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) },
68
+ 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), memos: readMemoCount(p.memos) },
65
69
  };
66
70
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@t3b/hub-guard",
3
- "version": "0.8.0",
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.",
3
+ "version": "0.10.0",
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, the memo bell and window (0.10.0). Web Crypto only: runs in Node 20+ and in edge runtimes.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",