@t3b/hub-guard 0.9.0 → 0.10.1
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 +38 -0
- package/dist/guard.d.ts +13 -0
- package/dist/guard.js +16 -3
- package/dist/hub-client.d.ts +15 -2
- package/dist/hub-client.js +11 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/memos.d.ts +172 -0
- package/dist/memos.js +251 -0
- package/dist/pages.d.ts +10 -2
- package/dist/pages.js +5 -2
- package/dist/session.d.ts +2 -0
- package/dist/ticket.d.ts +4 -0
- package/dist/ticket.js +5 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -269,6 +269,35 @@ 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
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.
|
|
@@ -279,6 +308,15 @@ const lines = guard.appMenu(session);
|
|
|
279
308
|
|
|
280
309
|
## Changes
|
|
281
310
|
|
|
311
|
+
- **0.10.1** (owner, 2026-10-11): the memo window's Arabic button reads **تمت القراءة و تم إبلاغي** (was "قرأت وأوافق"). Nothing else
|
|
312
|
+
changes but one fix: the accept button wraps onto a second line on a narrow screen instead of running past its card
|
|
313
|
+
(found by the Stores Sync session at 390 px). An app on 0.10.0 keeps working and shows the old words until it takes 0.10.1.
|
|
314
|
+
- **0.10.0** (WORKFLOWS §22, D-088): memos - the pass's and the 15-minute check's `memos` (the waiting number) is kept in
|
|
315
|
+
the session (`session.memos`, `readMemoCount`); `guard.memoBell`, `guard.memoData`, `guard.memoAct`, `memoFragment`,
|
|
316
|
+
`MEMO_TEXTS` (Arabic and English), `memosCall`; for the module that holds the memos `verifyMemosCall`, `memosRefusal`
|
|
317
|
+
and `healthResponse(..., { memos })`; a manifest page may be `everyone: true` (Hub §22.7) and `pagesFingerprint` adds
|
|
318
|
+
that word only for such a page. **The Hub must be on its §22 release before an app takes 0.10.0.** Nothing else
|
|
319
|
+
changes; a module on 0.9.0 keeps working (its sessions simply carry no number).
|
|
282
320
|
- **0.9.0** (the Hub audit of 2026-10-04, H07-H09): (1) the 15-minute check refreshes `session.stores` - a store taken
|
|
283
321
|
away leaves the open app within 15 minutes; (2) after `maxStaleMs` (default 180 minutes, `MAX_STALE_MS`) without the
|
|
284
322
|
Hub's answer the session is `stale`: reads go on, changes are refused (503 `hub_unreachable`, `pageAllowed(…, "write")`
|
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 = {
|
|
@@ -75,6 +76,18 @@ export declare class Guard {
|
|
|
75
76
|
refusePage(req: Request, what?: string): Response;
|
|
76
77
|
/** §18 (0.8.0): the Apps button's lines for this session - the Hub first, then every app this person may open. */
|
|
77
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>;
|
|
78
91
|
/** Signs out of the module only (the Hub session is untouched). */
|
|
79
92
|
signOut(): Response;
|
|
80
93
|
}
|
package/dist/guard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appMenu } from "./apps.js";
|
|
2
2
|
import { checkCloudflareAccess } from "./cf-access.js";
|
|
3
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
7
|
import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, MAX_STALE_MS, RECHECK_MS, SESSION_COOKIE } from "./session.js";
|
|
@@ -68,7 +69,7 @@ export class Guard {
|
|
|
68
69
|
return this.refuse(req, "The Hub could not confirm this ticket - open the app again from the Hub.");
|
|
69
70
|
}
|
|
70
71
|
const now = this.now().getTime();
|
|
71
|
-
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 };
|
|
72
73
|
const landed = new URL(this.cfg.landing ? this.cfg.landing(v.claims.path) : v.claims.path, url.origin);
|
|
73
74
|
// never off this module's own address, whatever `landing` returns
|
|
74
75
|
const target = landed.origin === url.origin ? landed : new URL("/", url.origin);
|
|
@@ -106,9 +107,9 @@ export class Guard {
|
|
|
106
107
|
if (r2.verdict === "refused")
|
|
107
108
|
return { allow: false, response: this.refuse(req, "The Hub ended this session (signed out everywhere, or access changed).") };
|
|
108
109
|
// §14: a change of ticks reaches the module here, within 15 minutes - §18: and so does the Apps button's list -
|
|
109
|
-
// H07: and so does a store taken away (or given)
|
|
110
|
+
// H07: and so does a store taken away (or given) - §22.2: and the bell's number
|
|
110
111
|
if (r2.verdict === "ok")
|
|
111
|
-
s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}), ...(r2.apps ? { apps: r2.apps } : {}), ...(r2.stores ? { stores: r2.stores } : {}) };
|
|
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 }) };
|
|
112
113
|
}
|
|
113
114
|
// H08: the Hub has not answered for longer than the limit - the module still opens and reads, changes wait
|
|
114
115
|
const stale = nowMs - s.checkedAt >= (this.cfg.maxStaleMs ?? MAX_STALE_MS);
|
|
@@ -149,6 +150,18 @@ export class Guard {
|
|
|
149
150
|
appMenu(session) {
|
|
150
151
|
return appMenu(session, { hubUrl: this.cfg.hubUrl, moduleId: this.cfg.moduleId });
|
|
151
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
|
+
}
|
|
152
165
|
/** Signs out of the module only (the Hub session is untouched). */
|
|
153
166
|
signOut() {
|
|
154
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
|
@@ -20,6 +20,17 @@ export declare function sessionCheckPages(cfg: HubClientConfig, personId: string
|
|
|
20
20
|
pages?: Pages;
|
|
21
21
|
apps?: HubApp[];
|
|
22
22
|
stores?: string[];
|
|
23
|
+
memos?: number;
|
|
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;
|
|
23
34
|
}>;
|
|
24
35
|
/**
|
|
25
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
|
|
@@ -81,7 +92,7 @@ export type HubCallCheck = {
|
|
|
81
92
|
export declare function verifyHubCall(req: Request, opts: {
|
|
82
93
|
moduleId: string;
|
|
83
94
|
keys: JWTVerifyGetKey;
|
|
84
|
-
use: "manifest" | "health" | "activity";
|
|
95
|
+
use: "manifest" | "health" | "activity" | "memos";
|
|
85
96
|
now?: Date;
|
|
86
97
|
environment?: string;
|
|
87
98
|
}): Promise<HubCallCheck>;
|
|
@@ -104,7 +115,7 @@ export type Connection = {
|
|
|
104
115
|
store?: string | null;
|
|
105
116
|
};
|
|
106
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. */
|
|
107
|
-
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<{
|
|
108
119
|
status: number;
|
|
109
120
|
body: {
|
|
110
121
|
ok: boolean;
|
|
@@ -114,6 +125,7 @@ export declare function healthAnswer(check: () => Promise<Connection[]> | Connec
|
|
|
114
125
|
pages?: string;
|
|
115
126
|
activity?: string;
|
|
116
127
|
station?: string;
|
|
128
|
+
memos?: string;
|
|
117
129
|
};
|
|
118
130
|
}>;
|
|
119
131
|
/** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
|
|
@@ -121,6 +133,7 @@ export declare function healthResponse(req: Request, check: () => Promise<Connec
|
|
|
121
133
|
pages?: PageDef[];
|
|
122
134
|
activity?: string;
|
|
123
135
|
station?: string;
|
|
136
|
+
memos?: string;
|
|
124
137
|
}): Promise<Response>;
|
|
125
138
|
/** One change, worded by the app for the Hub's Activity page (English, like the Hub). */
|
|
126
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) {
|
|
@@ -52,8 +53,14 @@ export async function sessionCheckPages(cfg, personId, sessionVersion) {
|
|
|
52
53
|
...(r.json.apps === undefined ? {} : { apps: readApps(r.json.apps) }),
|
|
53
54
|
// H07 (0.9.0): the person's stores here as the Hub holds them now (absent from a Hub before 0.9.0)
|
|
54
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) } : {}),
|
|
55
58
|
};
|
|
56
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
|
+
}
|
|
57
64
|
/**
|
|
58
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
|
|
59
66
|
* one server is not enough on serverless). "first" = this is its first use; "used" = it was used before; "unknown" =
|
|
@@ -149,7 +156,7 @@ export async function manifestResponse(req, manifest, cfg) {
|
|
|
149
156
|
return jsonResponse(200, manifest, { [APP_ID_HEADER]: own });
|
|
150
157
|
}
|
|
151
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. */
|
|
152
|
-
export async function healthAnswer(check, pages, activity, station) {
|
|
159
|
+
export async function healthAnswer(check, pages, activity, station, memos) {
|
|
153
160
|
let connections;
|
|
154
161
|
try {
|
|
155
162
|
connections = await check();
|
|
@@ -171,6 +178,8 @@ export async function healthAnswer(check, pages, activity, station) {
|
|
|
171
178
|
...(activity ? { activity } : {}),
|
|
172
179
|
// §17: the app's packing-station door; when it differs from the one the Hub holds, the Hub reads the manifest again
|
|
173
180
|
...(station ? { station } : {}),
|
|
181
|
+
// §22.3 (0.10.0): the memos door of the app that holds the memos (HR); likewise
|
|
182
|
+
...(memos ? { memos } : {}),
|
|
174
183
|
},
|
|
175
184
|
};
|
|
176
185
|
}
|
|
@@ -179,7 +188,7 @@ export async function healthResponse(req, check, cfg) {
|
|
|
179
188
|
const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
|
|
180
189
|
if (!v.ok)
|
|
181
190
|
return jsonResponse(401, { error: "hub_only", reason: v.reason });
|
|
182
|
-
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);
|
|
183
192
|
return jsonResponse(a.status, a.body);
|
|
184
193
|
}
|
|
185
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";
|
|
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";
|
|
5
6
|
export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, MAX_STALE_MS, type ModuleSession } from "./session.js";
|
|
6
|
-
export { sessionCheck, sessionCheckPages, spendTicket, 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
|
+
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";
|
|
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";
|
|
5
6
|
export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, MAX_STALE_MS } from "./session.js";
|
|
6
|
-
export { sessionCheck, sessionCheckPages, spendTicket, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, activityResponse, readActivityQuery, cleanActivityLine, ACTIVITY_AT_RE, ACTIVITY_ID_RE, ACTIVITY_MAX_LIMIT, } from "./hub-client.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: "تمت القراءة و تم إبلاغي", // owner 2026-10-11 (was "قرأت وأوافق")
|
|
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); white-space: normal; block-size: auto; min-block-size: var(--touch-min, 44px); max-inline-size: 100%; text-align: center; line-height: 1.4;">${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. */
|
|
@@ -28,5 +33,8 @@ export declare function pageAllowed(session: {
|
|
|
28
33
|
pages?: Pages;
|
|
29
34
|
stale?: boolean;
|
|
30
35
|
}, key: string, need: "read" | "write"): boolean;
|
|
31
|
-
/**
|
|
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
|
+
*/
|
|
32
40
|
export declare function pagesFingerprint(pages: PageDef[]): Promise<string>;
|
package/dist/pages.js
CHANGED
|
@@ -28,8 +28,11 @@ export function pageAllowed(session, key, need) {
|
|
|
28
28
|
const a = pageAccess(session, key);
|
|
29
29
|
return need === "read" ? a === "r" || a === "w" : a === "w";
|
|
30
30
|
}
|
|
31
|
-
/**
|
|
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
|
+
*/
|
|
32
35
|
export async function pagesFingerprint(pages) {
|
|
33
|
-
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"] : [])]));
|
|
34
37
|
return b64urlEncode(await sha256(utf8(text))).slice(0, 16);
|
|
35
38
|
}
|
package/dist/session.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type ModuleSession = {
|
|
|
23
23
|
pages?: Pages;
|
|
24
24
|
/** §18 (0.8.0): the apps this person may open (the Apps button), from the pass, refreshed by every 15-minute check */
|
|
25
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;
|
|
26
28
|
/** H08 (0.9.0): set by `check` (never stored) when the Hub has not answered for MAX_STALE_MS - no changes */
|
|
27
29
|
stale?: boolean;
|
|
28
30
|
};
|
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.1",
|
|
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",
|