@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 +44 -2
- package/dist/guard.d.ts +19 -0
- package/dist/guard.js +43 -6
- package/dist/hub-client.d.ts +22 -2
- package/dist/hub-client.js +28 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/memos.d.ts +172 -0
- package/dist/memos.js +251 -0
- package/dist/pages.d.ts +11 -2
- package/dist/pages.js +8 -2
- package/dist/session.d.ts +10 -0
- package/dist/session.js +9 -1
- package/dist/ticket.d.ts +4 -0
- package/dist/ticket.js +5 -1
- package/package.json +2 -2
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
|
-
|
|
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() } });
|
package/dist/hub-client.d.ts
CHANGED
|
@@ -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 = {
|
package/dist/hub-client.js
CHANGED
|
@@ -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 {
|
|
6
|
-
export {
|
|
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 {
|
|
6
|
-
export {
|
|
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";
|
package/dist/memos.d.ts
ADDED
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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",
|