@t3b/hub-guard 0.2.0 → 0.4.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 +54 -1
- package/dist/guard.d.ts +20 -1
- package/dist/guard.js +52 -10
- package/dist/hub-client.d.ts +31 -2
- package/dist/hub-client.js +30 -5
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/pages.d.ts +31 -0
- package/dist/pages.js +32 -0
- package/dist/session.d.ts +3 -0
- package/dist/ticket.d.ts +3 -0
- package/dist/ticket.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,6 +74,22 @@ await audit(cfg, { action: "shopify.write", target: "kw", detail: { what: "produ
|
|
|
74
74
|
Without a Vercel ID (OIDC switched off, or a local run without `VERCEL_OIDC_TOKEN`) nothing is
|
|
75
75
|
sent and the answer says why (`no_vercel_id`).
|
|
76
76
|
|
|
77
|
+
## Telling the Hub something is wrong (0.3.0)
|
|
78
|
+
|
|
79
|
+
An app never emails anyone itself: it tells the Hub, and the Hub emails the people chosen in
|
|
80
|
+
Settings → Alerts — one email, at most one reminder a day while it lasts, one "back to normal".
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { alert } from "@t3b/hub-guard";
|
|
84
|
+
const cfg = { hubUrl: process.env.T3B_HUB_URL ?? "https://t3b-hub.com" };
|
|
85
|
+
await alert(cfg, { key: "rates-follow", ok: false, title: "MyFatoorah rates not followed for 30 hours", detail: "the 06:00 run failed: timeout", fix: "Nothing to do if tomorrow's run works." });
|
|
86
|
+
await alert(cfg, { key: "rates-follow", ok: true }); // it is over: one "back to normal" if you were told
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`key`: lower-case letters, digits and `._:-`, up to 80 — the app's own name for the problem.
|
|
90
|
+
`title` up to 120 characters, `detail` 500, `fix` 300, `store` a store key. No secrets, tokens
|
|
91
|
+
or customer data in any of them. At most 120 reports an hour per app.
|
|
92
|
+
|
|
77
93
|
## Shopify's events (through the Hub)
|
|
78
94
|
|
|
79
95
|
The module never holds the Shopify app secret. Shopify sends events to the Hub; the Hub checks
|
|
@@ -131,9 +147,44 @@ import { guard } from "@/lib/guard";
|
|
|
131
147
|
export const GET = (req: Request) => guard.enter(req);
|
|
132
148
|
```
|
|
133
149
|
|
|
150
|
+
## Pages: who may read or change what (0.4.0, WORKFLOWS §14)
|
|
151
|
+
|
|
152
|
+
The owner ticks **Read** or **Write** per page per person in the Hub (Settings → Staff). The Hub's pass and
|
|
153
|
+
its 15-minute check carry the person's pages in this module; the guard keeps them in the session.
|
|
154
|
+
|
|
155
|
+
1. **List your pages in the manifest** — one entry per page (a separate line for an action that needs its own
|
|
156
|
+
switch, e.g. clearing cash). A module that lists none opens for the owner only.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const PAGES = [
|
|
160
|
+
{ key: "deliveries", group: "Daily work", name: { en: "Deliveries", ar: "التوصيلات" }, read: "orders, invoices", write: "assign drivers, WhatsApp" },
|
|
161
|
+
{ key: "driver-tracking", group: "Daily work", name: { en: "Driver tracking", ar: "تتبع السائقين" }, read: "positions", write: null },
|
|
162
|
+
];
|
|
163
|
+
// manifest: { ..., pages: PAGES } health: healthResponse(req, check, { ...cfg, pages: PAGES })
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The health answer then carries the list's fingerprint; when you add, remove or reword a page, the Hub reads
|
|
167
|
+
the manifest again by itself. New pages arrive closed for everyone but the owner.
|
|
168
|
+
|
|
169
|
+
2. **Ask on the server, for every page and every action** — hiding a button is not enough:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
const r = await guard.check(req); // as before
|
|
173
|
+
if (!r.allow) return r.response;
|
|
174
|
+
if (!guard.allows(r.session, "deliveries", "read")) return guard.refusePage(req, "Deliveries");
|
|
175
|
+
// in an action:
|
|
176
|
+
if (!guard.allows(r.session, "deliveries", "write")) return guard.refusePage(req, "Deliveries");
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`refusePage` answers 403 ("You don't have access to …" for browsers, `{"error":"no_access"}` for APIs) and
|
|
180
|
+
keeps the session. The owner may do everything; `pageAllowed(session, key, need)` is the same check without a Guard.
|
|
181
|
+
|
|
182
|
+
3. **Show only what may be used** — build the menu from `pageAccess(session, key)` and switch off buttons on a
|
|
183
|
+
Read-only page.
|
|
184
|
+
|
|
134
185
|
## What it enforces
|
|
135
186
|
|
|
136
|
-
- `/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
|
|
187
|
+
- `/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.
|
|
137
188
|
- Every other request: no session → 403 (HTML for browsers, JSON for APIs), on **every host** the module answers on. Idle 15 minutes, 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.
|
|
138
189
|
- Open paths (`/hub/enter`, `/api/hub/manifest`, `/api/hub/health` and what you add) never need a session — they carry their own locks (the Hub's pass, a cron secret).
|
|
139
190
|
- Cloudflare Access proof checked first when the team and audience are set.
|
|
@@ -141,6 +192,8 @@ export const GET = (req: Request) => guard.enter(req);
|
|
|
141
192
|
|
|
142
193
|
## Changes
|
|
143
194
|
|
|
195
|
+
- **0.4.0** (WORKFLOWS §14): page access — the pass's `pages` in the session, refreshed at every 15-minute check (`sessionCheckPages`); `guard.allows()`, `guard.refusePage()`, `pageAllowed`, `pageAccess`, `readPages`; `healthResponse(..., { pages })` sends the page list's fingerprint (`pagesFingerprint`). `enter()` answers an "Opening …" page instead of a 303 (`appName`, `landing` options; `openingPage` exported). A module on 0.3.x keeps working, but the Hub opens it for the owner only until it lists its pages.
|
|
196
|
+
- **0.3.0** (WORKFLOWS 9.3): `alert()` — report a problem to the Hub, or say it is over.
|
|
144
197
|
- **0.2.0** (8b): no module key and no pasted Hub public key. Hub → module: tokens signed by the Hub's key published at `/api/hub/jwks` (`verifyTicket`, `verifyRelay`, `verifyHubCall`, `manifestResponse`, `healthResponse`). Module → Hub: the app's own Vercel ID (`tokenBroker`, `audit`, `sessionCheck`). Removed: `signedManifest`, `relaySignature`, `importPublicKey`, the `moduleKey` / `hubPublicKeys` settings. `Guard` takes `sessionSecret`.
|
|
145
198
|
- **0.1.1**: `enter()` answers with an absolute Location (B-016).
|
|
146
199
|
- **0.1.0**: first publish, Web Crypto build.
|
package/dist/guard.d.ts
CHANGED
|
@@ -15,6 +15,10 @@ export type GuardConfig = {
|
|
|
15
15
|
environment?: string;
|
|
16
16
|
/** the refusal page's link back */
|
|
17
17
|
hubHomeUrl?: string;
|
|
18
|
+
/** 0.4.0: the module's name on the "Opening …" page shown while the session starts */
|
|
19
|
+
appName?: string;
|
|
20
|
+
/** 0.4.0: where the pass's page lands on this module (e.g. "/" -> "/app"); default: the page itself */
|
|
21
|
+
landing?: (path: string) => string;
|
|
18
22
|
/** paths that are machine doors with their own locks (webhooks, crons, health, manifest, the enter route) - never a session */
|
|
19
23
|
openPaths?: (string | RegExp)[];
|
|
20
24
|
cloudflare?: {
|
|
@@ -45,10 +49,25 @@ export declare class Guard {
|
|
|
45
49
|
isOpenPath(pathname: string): boolean;
|
|
46
50
|
/** The refusal (board 11): a plain page for browsers, JSON for API routes. */
|
|
47
51
|
refuse(req: Request, reason: string, status?: number): Response;
|
|
48
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* /hub/enter?ticket=… : verify, open the module session, then a short "Opening …" page that moves on
|
|
54
|
+
* to the pass's page (0.4.0; 303 before). A browser drops a SameSite=Strict cookie set on a redirect
|
|
55
|
+
* that ends a navigation begun on another site (the Hub), so the first page after a 303 arrived
|
|
56
|
+
* without the session and was refused; an own-page step makes the next request same-site. Learnt on
|
|
57
|
+
* Delivery, 2026-09-29.
|
|
58
|
+
*/
|
|
49
59
|
enter(req: Request): Promise<Response>;
|
|
50
60
|
/** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
|
|
51
61
|
check(req: Request): Promise<CheckResult>;
|
|
62
|
+
/**
|
|
63
|
+
* §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
|
|
64
|
+
* Ask on the server for every page and every action, then answer `refusePage` when not.
|
|
65
|
+
*/
|
|
66
|
+
allows(session: ModuleSession, pageKey: string, need: "read" | "write"): boolean;
|
|
67
|
+
/** §14: "You don't have access to this page" - 403; the session stays (other pages still open). */
|
|
68
|
+
refusePage(req: Request, what?: string): Response;
|
|
52
69
|
/** Signs out of the module only (the Hub session is untouched). */
|
|
53
70
|
signOut(): Response;
|
|
54
71
|
}
|
|
72
|
+
/** 0.4.0: the step between the Hub's pass and the module's page (see `enter`). No script: a meta refresh and a link. */
|
|
73
|
+
export declare function openingPage(target: string, appName: string): string;
|
package/dist/guard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { checkCloudflareAccess } from "./cf-access.js";
|
|
2
|
-
import {
|
|
2
|
+
import { sessionCheckPages } from "./hub-client.js";
|
|
3
|
+
import { pageAllowed } from "./pages.js";
|
|
3
4
|
import { hubKeys } from "./identity.js";
|
|
4
5
|
import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, RECHECK_MS, SESSION_COOKIE } from "./session.js";
|
|
5
6
|
import { JtiMemory, verifyTicket } from "./ticket.js";
|
|
@@ -39,7 +40,13 @@ export class Guard {
|
|
|
39
40
|
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Open this from The3Beez</title><style>body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:#F6F5F3;color:#231F20;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:1px solid #E7E5E2;border-radius:12px;padding:40px;max-width:440px;text-align:center}h1{font-size:22px;margin:0 0 12px}p{color:#6B6866;line-height:1.5;margin:0 0 24px}a{display:inline-block;background:#231F20;color:#fff;text-decoration:none;padding:14px 26px;border-radius:12px;font-size:13px;letter-spacing:.08em;text-transform:uppercase;font-weight:600}</style></head><body><div class="card"><h1>Open this from The3Beez</h1><p>This app opens only from the Hub. ${escapeHtml(reason)}</p><a href="${escapeAttr(home)}">Go to the Hub</a></div></body></html>`;
|
|
40
41
|
return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Set-Cookie": clearCookieHeader() } });
|
|
41
42
|
}
|
|
42
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* /hub/enter?ticket=… : verify, open the module session, then a short "Opening …" page that moves on
|
|
45
|
+
* to the pass's page (0.4.0; 303 before). A browser drops a SameSite=Strict cookie set on a redirect
|
|
46
|
+
* that ends a navigation begun on another site (the Hub), so the first page after a 303 arrived
|
|
47
|
+
* without the session and was refused; an own-page step makes the next request same-site. Learnt on
|
|
48
|
+
* Delivery, 2026-09-29.
|
|
49
|
+
*/
|
|
43
50
|
async enter(req) {
|
|
44
51
|
const url = new URL(req.url);
|
|
45
52
|
const cf = await checkCloudflareAccess(req.headers, this.cfg.cloudflare ?? {});
|
|
@@ -52,10 +59,22 @@ export class Guard {
|
|
|
52
59
|
return this.refuse(req, words[v.reason] ?? "No valid ticket.");
|
|
53
60
|
}
|
|
54
61
|
const now = this.now().getTime();
|
|
55
|
-
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 };
|
|
56
|
-
const
|
|
57
|
-
//
|
|
58
|
-
|
|
62
|
+
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 };
|
|
63
|
+
const landed = new URL(this.cfg.landing ? this.cfg.landing(v.claims.path) : v.claims.path, url.origin);
|
|
64
|
+
// never off this module's own address, whatever `landing` returns
|
|
65
|
+
const target = landed.origin === url.origin ? landed : new URL("/", url.origin);
|
|
66
|
+
const cookie = cookieHeader(await signSession(session, await this.secretP), { secure: url.protocol === "https:", maxAgeS: Math.floor((this.cfg.absoluteMs ?? 8 * 3_600_000) / 1000) });
|
|
67
|
+
return new Response(openingPage(target.toString(), this.cfg.appName ?? "the app"), {
|
|
68
|
+
status: 200,
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
71
|
+
"Cache-Control": "no-store",
|
|
72
|
+
"Set-Cookie": cookie,
|
|
73
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
74
|
+
"Referrer-Policy": "no-referrer",
|
|
75
|
+
"X-Content-Type-Options": "nosniff",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
59
78
|
}
|
|
60
79
|
/** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
|
|
61
80
|
async check(req) {
|
|
@@ -74,20 +93,43 @@ export class Guard {
|
|
|
74
93
|
}
|
|
75
94
|
let s = r.session;
|
|
76
95
|
if (nowMs - s.checkedAt >= RECHECK_MS) {
|
|
77
|
-
const
|
|
78
|
-
if (verdict === "refused")
|
|
96
|
+
const r2 = await sessionCheckPages(this.hubClient(), s.personId, s.sv);
|
|
97
|
+
if (r2.verdict === "refused")
|
|
79
98
|
return { allow: false, response: this.refuse(req, "The Hub ended this session (signed out everywhere, or access changed).") };
|
|
80
|
-
|
|
81
|
-
|
|
99
|
+
// §14: a change of ticks reaches the module here, within 15 minutes
|
|
100
|
+
if (r2.verdict === "ok")
|
|
101
|
+
s = { ...s, checkedAt: nowMs, ...(r2.pages ? { pages: r2.pages } : {}) };
|
|
82
102
|
}
|
|
83
103
|
s = { ...s, lastSeen: nowMs };
|
|
84
104
|
return { allow: true, session: s, setCookie: cookieHeader(await signSession(s, secret), { secure: url.protocol === "https:", maxAgeS: Math.floor((this.cfg.absoluteMs ?? 8 * 3_600_000) / 1000) }) };
|
|
85
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* §14: may this person read (`"read"`) or change (`"write"`) this page? The owner may do everything.
|
|
108
|
+
* Ask on the server for every page and every action, then answer `refusePage` when not.
|
|
109
|
+
*/
|
|
110
|
+
allows(session, pageKey, need) {
|
|
111
|
+
return pageAllowed(session, pageKey, need);
|
|
112
|
+
}
|
|
113
|
+
/** §14: "You don't have access to this page" - 403; the session stays (other pages still open). */
|
|
114
|
+
refusePage(req, what = "this page") {
|
|
115
|
+
const accept = req.headers.get("accept") ?? "";
|
|
116
|
+
if (!accept.includes("text/html")) {
|
|
117
|
+
return new Response(JSON.stringify({ error: "no_access", page: what }), { status: 403, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
|
118
|
+
}
|
|
119
|
+
const home = this.cfg.hubHomeUrl ?? this.cfg.hubUrl;
|
|
120
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>No access</title><style>body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:#F6F5F3;color:#231F20;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:1px solid #E7E5E2;border-radius:12px;padding:40px;max-width:440px;text-align:center}h1{font-size:22px;margin:0 0 12px}p{color:#6B6866;line-height:1.5;margin:0 0 24px}a{display:inline-block;background:#231F20;color:#fff;text-decoration:none;padding:14px 26px;border-radius:12px;font-size:13px;letter-spacing:.08em;text-transform:uppercase;font-weight:600}</style></head><body><div class="card"><h1>You don't have access to ${escapeHtml(what)}</h1><p>The owner gives access in the Hub: Settings → Staff.</p><a href="${escapeAttr(home)}">Go to the Hub</a></div></body></html>`;
|
|
121
|
+
return new Response(html, { status: 403, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
122
|
+
}
|
|
86
123
|
/** Signs out of the module only (the Hub session is untouched). */
|
|
87
124
|
signOut() {
|
|
88
125
|
return new Response(null, { status: 303, headers: { Location: this.cfg.hubHomeUrl ?? this.cfg.hubUrl, "Set-Cookie": clearCookieHeader() } });
|
|
89
126
|
}
|
|
90
127
|
}
|
|
128
|
+
/** 0.4.0: the step between the Hub's pass and the module's page (see `enter`). No script: a meta refresh and a link. */
|
|
129
|
+
export function openingPage(target, appName) {
|
|
130
|
+
const t = escapeAttr(target);
|
|
131
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${t}"><title>Opening ${escapeHtml(appName)}</title></head><body style="font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0"><p>Opening ${escapeHtml(appName)}… <a href="${t}">Continue</a></p></body></html>`;
|
|
132
|
+
}
|
|
91
133
|
function escapeHtml(s) {
|
|
92
134
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
93
135
|
}
|
package/dist/hub-client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type JWTVerifyGetKey } from "jose";
|
|
2
|
+
import { type PageDef, type Pages } from "./pages.js";
|
|
2
3
|
import { type TokenProvider } from "./identity.js";
|
|
3
4
|
export type HubClientConfig = {
|
|
4
5
|
hubUrl: string;
|
|
@@ -9,6 +10,11 @@ export type HubClientConfig = {
|
|
|
9
10
|
};
|
|
10
11
|
/** 2.4: does this person's Hub session still stand? `unknown` when the Hub did not answer (the module keeps the session until the next check). */
|
|
11
12
|
export declare function sessionCheck(cfg: HubClientConfig, personId: string, sessionVersion: number): Promise<"ok" | "refused" | "unknown">;
|
|
13
|
+
/** §14 (0.4.0): the same check, with the person's pages as the Hub holds them now (absent from a Hub before §14). */
|
|
14
|
+
export declare function sessionCheckPages(cfg: HubClientConfig, personId: string, sessionVersion: number): Promise<{
|
|
15
|
+
verdict: "ok" | "refused" | "unknown";
|
|
16
|
+
pages?: Pages;
|
|
17
|
+
}>;
|
|
12
18
|
export type BrokerToken = {
|
|
13
19
|
accessToken: string;
|
|
14
20
|
expiresAt: Date;
|
|
@@ -33,6 +39,26 @@ export declare function audit(cfg: HubClientConfig, line: {
|
|
|
33
39
|
target?: string;
|
|
34
40
|
detail?: Record<string, unknown>;
|
|
35
41
|
}): Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* 9.3: tell the Hub a problem started (`ok: false`, with a title) or ended (`ok: true`), under a
|
|
44
|
+
* key of the app's own ("rates-follow", "feeds"). The Hub emails under its no-flood rule; the app
|
|
45
|
+
* never emails anyone itself. Never throws.
|
|
46
|
+
*/
|
|
47
|
+
export declare function alert(cfg: HubClientConfig, a: {
|
|
48
|
+
key: string;
|
|
49
|
+
ok: boolean;
|
|
50
|
+
title?: string;
|
|
51
|
+
detail?: string;
|
|
52
|
+
fix?: string;
|
|
53
|
+
store?: string;
|
|
54
|
+
}): Promise<{
|
|
55
|
+
ok: true;
|
|
56
|
+
state: string;
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
status: number;
|
|
60
|
+
error: string;
|
|
61
|
+
}>;
|
|
36
62
|
export type HubCallCheck = {
|
|
37
63
|
ok: true;
|
|
38
64
|
} | {
|
|
@@ -66,14 +92,17 @@ export type Connection = {
|
|
|
66
92
|
store?: string | null;
|
|
67
93
|
};
|
|
68
94
|
/** 8.4 / 7.6: the health answer's shape. `ok` is false when any connection is red; the Hub shows each connection as its own line. */
|
|
69
|
-
export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[]): Promise<{
|
|
95
|
+
export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[], pages?: PageDef[]): Promise<{
|
|
70
96
|
status: number;
|
|
71
97
|
body: {
|
|
72
98
|
ok: boolean;
|
|
73
99
|
reason: string | null;
|
|
74
100
|
connections: Connection[];
|
|
75
101
|
checked_at: string;
|
|
102
|
+
pages?: string;
|
|
76
103
|
};
|
|
77
104
|
}>;
|
|
78
105
|
/** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
|
|
79
|
-
export declare function healthResponse(req: Request, check: () => Promise<Connection[]> | Connection[], cfg: Omit<AnswerConfig, "token">
|
|
106
|
+
export declare function healthResponse(req: Request, check: () => Promise<Connection[]> | Connection[], cfg: Omit<AnswerConfig, "token"> & {
|
|
107
|
+
pages?: PageDef[];
|
|
108
|
+
}): Promise<Response>;
|
package/dist/hub-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jwtVerify } from "jose";
|
|
2
|
+
import { pagesFingerprint, readPages } from "./pages.js";
|
|
2
3
|
import { APP_ID_HEADER, HUB_ISSUER, ownVercelToken, TYP, tokenFrom } from "./identity.js";
|
|
3
4
|
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.";
|
|
4
5
|
async function post(cfg, path, body) {
|
|
@@ -32,10 +33,16 @@ async function post(cfg, path, body) {
|
|
|
32
33
|
}
|
|
33
34
|
/** 2.4: does this person's Hub session still stand? `unknown` when the Hub did not answer (the module keeps the session until the next check). */
|
|
34
35
|
export async function sessionCheck(cfg, personId, sessionVersion) {
|
|
36
|
+
return (await sessionCheckPages(cfg, personId, sessionVersion)).verdict;
|
|
37
|
+
}
|
|
38
|
+
/** §14 (0.4.0): the same check, with the person's pages as the Hub holds them now (absent from a Hub before §14). */
|
|
39
|
+
export async function sessionCheckPages(cfg, personId, sessionVersion) {
|
|
35
40
|
const r = await post(cfg, "/api/hub/session-check", { personId, sessionVersion });
|
|
36
41
|
if (r.status === 0 || r.status >= 500)
|
|
37
|
-
return "unknown";
|
|
38
|
-
|
|
42
|
+
return { verdict: "unknown" };
|
|
43
|
+
if (r.json?.ok !== true)
|
|
44
|
+
return { verdict: "refused" };
|
|
45
|
+
return r.json.pages === undefined ? { verdict: "ok" } : { verdict: "ok", pages: readPages(r.json.pages) };
|
|
39
46
|
}
|
|
40
47
|
/** 7.3: the store's token from the Hub, cached until an hour before it expires. */
|
|
41
48
|
export function tokenBroker(cfg) {
|
|
@@ -63,6 +70,17 @@ export async function audit(cfg, line) {
|
|
|
63
70
|
const r = await post(cfg, "/api/hub/audit", line);
|
|
64
71
|
return r.status === 200 || r.status === 201;
|
|
65
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* 9.3: tell the Hub a problem started (`ok: false`, with a title) or ended (`ok: true`), under a
|
|
75
|
+
* key of the app's own ("rates-follow", "feeds"). The Hub emails under its no-flood rule; the app
|
|
76
|
+
* never emails anyone itself. Never throws.
|
|
77
|
+
*/
|
|
78
|
+
export async function alert(cfg, a) {
|
|
79
|
+
const r = await post(cfg, "/api/hub/alerts", a);
|
|
80
|
+
if (r.status === 202)
|
|
81
|
+
return { ok: true, state: String(r.json?.state ?? "") };
|
|
82
|
+
return { ok: false, status: r.status, error: String(r.json?.field ?? r.json?.error ?? "no_answer") };
|
|
83
|
+
}
|
|
66
84
|
/** Is this request the Hub's own, for this module and this purpose? (`Authorization: Bearer <pass>`) */
|
|
67
85
|
export async function verifyHubCall(req, opts) {
|
|
68
86
|
const auth = req.headers.get("authorization") ?? "";
|
|
@@ -106,7 +124,7 @@ export async function manifestResponse(req, manifest, cfg) {
|
|
|
106
124
|
return jsonResponse(200, manifest, { [APP_ID_HEADER]: own });
|
|
107
125
|
}
|
|
108
126
|
/** 8.4 / 7.6: the health answer's shape. `ok` is false when any connection is red; the Hub shows each connection as its own line. */
|
|
109
|
-
export async function healthAnswer(check) {
|
|
127
|
+
export async function healthAnswer(check, pages) {
|
|
110
128
|
let connections;
|
|
111
129
|
try {
|
|
112
130
|
connections = await check();
|
|
@@ -117,7 +135,14 @@ export async function healthAnswer(check) {
|
|
|
117
135
|
const red = connections.filter((c) => !c.ok);
|
|
118
136
|
return {
|
|
119
137
|
status: 200,
|
|
120
|
-
body: {
|
|
138
|
+
body: {
|
|
139
|
+
ok: red.length === 0,
|
|
140
|
+
reason: red.length ? red.map((c) => `${c.name}: ${c.reason ?? "not ok"}`).join("; ").slice(0, 200) : null,
|
|
141
|
+
connections,
|
|
142
|
+
checked_at: new Date().toISOString(),
|
|
143
|
+
// §14.5: the page list's fingerprint - when it changes, the Hub reads the manifest again by itself
|
|
144
|
+
...(pages ? { pages: await pagesFingerprint(pages) } : {}),
|
|
145
|
+
},
|
|
121
146
|
};
|
|
122
147
|
}
|
|
123
148
|
/** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
|
|
@@ -125,6 +150,6 @@ export async function healthResponse(req, check, cfg) {
|
|
|
125
150
|
const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
|
|
126
151
|
if (!v.ok)
|
|
127
152
|
return jsonResponse(401, { error: "hub_only", reason: v.reason });
|
|
128
|
-
const a = await healthAnswer(check);
|
|
153
|
+
const a = await healthAnswer(check, cfg.pages);
|
|
129
154
|
return jsonResponse(a.status, a.body);
|
|
130
155
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export { Guard, type GuardConfig, type CheckResult } from "./guard.js";
|
|
1
|
+
export { Guard, openingPage, type GuardConfig, type CheckResult } from "./guard.js";
|
|
2
|
+
export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE, type PageAccess, type PageDef, type Pages } from "./pages.js";
|
|
2
3
|
export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
|
|
3
4
|
export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
|
|
4
|
-
export { sessionCheck, tokenBroker, audit, verifyHubCall, manifestResponse, healthResponse, healthAnswer, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
|
|
5
|
+
export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
|
|
5
6
|
export { checkCloudflareAccess } from "./cf-access.js";
|
|
6
7
|
export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
|
|
7
8
|
export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER, type TokenProvider } from "./identity.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export { Guard } from "./guard.js";
|
|
1
|
+
export { Guard, openingPage } from "./guard.js";
|
|
2
|
+
export { pageAllowed, pageAccess, pagesFingerprint, readPages, PAGE_KEY_RE } from "./pages.js";
|
|
2
3
|
export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
|
|
3
4
|
export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
|
|
4
|
-
export { sessionCheck, tokenBroker, audit, verifyHubCall, manifestResponse, healthResponse, healthAnswer, } from "./hub-client.js";
|
|
5
|
+
export { sessionCheck, sessionCheckPages, tokenBroker, audit, alert, verifyHubCall, manifestResponse, healthResponse, healthAnswer, } from "./hub-client.js";
|
|
5
6
|
export { checkCloudflareAccess } from "./cf-access.js";
|
|
6
7
|
export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
|
|
7
8
|
export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER } from "./identity.js";
|
package/dist/pages.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type PageAccess = "r" | "w";
|
|
2
|
+
export type Pages = Record<string, PageAccess>;
|
|
3
|
+
/** One page as a module lists it in its manifest. `write`: what Write allows, or null when there is nothing to change. */
|
|
4
|
+
export type PageDef = {
|
|
5
|
+
key: string;
|
|
6
|
+
group?: string | null;
|
|
7
|
+
name: {
|
|
8
|
+
en: string;
|
|
9
|
+
ar: string;
|
|
10
|
+
};
|
|
11
|
+
read: string;
|
|
12
|
+
write?: string | null;
|
|
13
|
+
};
|
|
14
|
+
export declare const PAGE_KEY_RE: RegExp;
|
|
15
|
+
/** A pass's or check's `pages`, read defensively: anything malformed is dropped, never widened. */
|
|
16
|
+
export declare function readPages(raw: unknown): Pages;
|
|
17
|
+
/** What a person may do on one page: "w", "r" or null. The owner always "w". */
|
|
18
|
+
export declare function pageAccess(session: {
|
|
19
|
+
role: string;
|
|
20
|
+
pages?: Pages;
|
|
21
|
+
}, key: string): PageAccess | null;
|
|
22
|
+
/**
|
|
23
|
+
* The one question a module asks on every page and every action: may this person read (`"read"`) or
|
|
24
|
+
* change (`"write"`) this page? Ask it on the server - hiding a button is not enough (§14.3).
|
|
25
|
+
*/
|
|
26
|
+
export declare function pageAllowed(session: {
|
|
27
|
+
role: string;
|
|
28
|
+
pages?: Pages;
|
|
29
|
+
}, key: string, need: "read" | "write"): boolean;
|
|
30
|
+
/** The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). */
|
|
31
|
+
export declare function pagesFingerprint(pages: PageDef[]): Promise<string>;
|
package/dist/pages.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { b64urlEncode, sha256, utf8 } from "./webcrypto.js";
|
|
2
|
+
export const PAGE_KEY_RE = /^[a-z][a-z0-9-]{1,39}$/;
|
|
3
|
+
/** A pass's or check's `pages`, read defensively: anything malformed is dropped, never widened. */
|
|
4
|
+
export function readPages(raw) {
|
|
5
|
+
const out = {};
|
|
6
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
7
|
+
return out;
|
|
8
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
9
|
+
if (PAGE_KEY_RE.test(k) && (v === "r" || v === "w"))
|
|
10
|
+
out[k] = v;
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
/** What a person may do on one page: "w", "r" or null. The owner always "w". */
|
|
15
|
+
export function pageAccess(session, key) {
|
|
16
|
+
if (session.role === "owner")
|
|
17
|
+
return "w";
|
|
18
|
+
return session.pages?.[key] ?? null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The one question a module asks on every page and every action: may this person read (`"read"`) or
|
|
22
|
+
* change (`"write"`) this page? Ask it on the server - hiding a button is not enough (§14.3).
|
|
23
|
+
*/
|
|
24
|
+
export function pageAllowed(session, key, need) {
|
|
25
|
+
const a = pageAccess(session, key);
|
|
26
|
+
return need === "read" ? a === "r" || a === "w" : a === "w";
|
|
27
|
+
}
|
|
28
|
+
/** The fingerprint of a page list - the same one the Hub computes for the list it read (§14.5). */
|
|
29
|
+
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]));
|
|
31
|
+
return b64urlEncode(await sha256(utf8(text))).slice(0, 16);
|
|
32
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Pages } from "./pages.js";
|
|
1
2
|
export declare const SESSION_COOKIE = "t3b_module_session";
|
|
2
3
|
export declare const IDLE_MS: number;
|
|
3
4
|
export declare const ABSOLUTE_MS: number;
|
|
@@ -11,6 +12,8 @@ export type ModuleSession = {
|
|
|
11
12
|
iat: number;
|
|
12
13
|
lastSeen: number;
|
|
13
14
|
checkedAt: number;
|
|
15
|
+
/** §14 (0.4.0): the person's pages here, from the pass, refreshed by every 15-minute check */
|
|
16
|
+
pages?: Pages;
|
|
14
17
|
};
|
|
15
18
|
export declare function signSession(s: ModuleSession, secret: string): Promise<string>;
|
|
16
19
|
export declare function readSession(cookie: string | undefined | null, secret: string, now?: number, opts?: {
|
package/dist/ticket.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Pages } from "./pages.js";
|
|
1
2
|
import { type JWTVerifyGetKey } from "jose";
|
|
2
3
|
export declare const TICKET_ISSUER = "t3b-hub";
|
|
3
4
|
export declare const TICKET_TTL_S = 60;
|
|
@@ -12,6 +13,8 @@ export type TicketClaims = {
|
|
|
12
13
|
sv: number;
|
|
13
14
|
iat: number;
|
|
14
15
|
exp: number;
|
|
16
|
+
/** §14 (0.4.0): the person's pages in this module; `{}` from a Hub before §14 */
|
|
17
|
+
pages: Pages;
|
|
15
18
|
};
|
|
16
19
|
export type VerifyResult = {
|
|
17
20
|
ok: true;
|
package/dist/ticket.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readPages } from "./pages.js";
|
|
1
2
|
import { jwtVerify } from "jose";
|
|
2
3
|
import { HUB_ISSUER, TYP } from "./identity.js";
|
|
3
4
|
// SPEC C-4: the entry ticket. EdDSA (Ed25519) JWT signed by the Hub's key; since 0.2.0 (8b) the
|
|
@@ -59,6 +60,6 @@ export async function verifyTicket(ticket, opts) {
|
|
|
59
60
|
return { ok: false, reason: "replayed" };
|
|
60
61
|
return {
|
|
61
62
|
ok: true,
|
|
62
|
-
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 },
|
|
63
|
+
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) },
|
|
63
64
|
};
|
|
64
65
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@t3b/hub-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The3Beez Hub guard: a module opens only by a Hub-signed ticket and refuses everything else. No key from the Hub: towards the Hub the app shows its own Vercel ID; from the Hub it accepts only what is signed by the key the Hub publishes. Entry route, refusal page, module session, idle rule, 15-minute re-check, Cloudflare Access proof, broker and audit clients, manifest and health answers for the Hub only. Web Crypto only: runs in Node 20+ and in edge runtimes.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|