@t3b/hub-guard 0.1.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 ADDED
@@ -0,0 +1,123 @@
1
+ # @t3b/hub-guard
2
+
3
+ The lock every The3Beez module installs. A module **opens only by a ticket from the Hub** and
4
+ refuses everything else with a plain "Open this from The3Beez" page (WORKFLOWS 2.2–2.4, 8.7).
5
+ It holds no secret of the Hub — only the Hub's public key and the module's own key.
6
+
7
+ **Runs anywhere:** Web Crypto only (no `node:crypto`, no `Buffer`), so it works in Node 20+ and in
8
+ edge runtimes — including Next.js 14 middleware, which runs only on the edge. A test fails if a
9
+ shipped file ever imports a Node-only module.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @t3b/hub-guard
15
+ ```
16
+
17
+ Environment (values live in the module's Vercel, names only here):
18
+
19
+ | Name | What |
20
+ |---|---|
21
+ | `T3B_MODULE_KEY` | the key the Hub made for this module (Settings → Modules → Make key), shown once |
22
+ | `T3B_HUB_PUBLIC_KEY` | the Hub's public key (Settings → Modules → Copy) |
23
+ | `T3B_HUB_PUBLIC_KEY_PREVIOUS` | only during a rotation: the previous public key, for one hour |
24
+ | `T3B_HUB_URL` | `https://t3b-hub.com` (or the Hub's test address) |
25
+ | `T3B_CF_ACCESS_TEAM`, `T3B_CF_ACCESS_AUD` | Cloudflare Access proof, once the module host is behind it |
26
+
27
+ ## Next.js — three files
28
+
29
+ ```ts
30
+ // src/lib/guard.ts
31
+ import { Guard, guardConfigFromEnv } from "@t3b/hub-guard";
32
+ export const guard = new Guard({
33
+ ...guardConfigFromEnv(process.env, "currencies"),
34
+ openPaths: ["/api/hub/webhooks", /^\/api\/cron\//], // machine doors with their own locks
35
+ });
36
+ ```
37
+
38
+ ```ts
39
+ // src/middleware.ts (or src/proxy.ts on Next 16)
40
+ import { NextResponse, type NextRequest } from "next/server";
41
+ import { guard } from "@/lib/guard";
42
+ export async function middleware(req: NextRequest) {
43
+ const r = await guard.check(req);
44
+ if (!r.allow) return r.response;
45
+ const res = NextResponse.next();
46
+ if (r.setCookie) res.headers.append("set-cookie", r.setCookie);
47
+ return res;
48
+ }
49
+ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
50
+ ```
51
+
52
+ ```ts
53
+ // src/app/hub/enter/route.ts
54
+ import { guard } from "@/lib/guard";
55
+ export const GET = (req: Request) => guard.enter(req);
56
+ ```
57
+
58
+ ## The two answers the Hub reads
59
+
60
+ ```ts
61
+ // src/app/api/hub/manifest/route.ts
62
+ import { signedManifest } from "@t3b/hub-guard";
63
+ export async function GET() {
64
+ const { body, headers } = await signedManifest(
65
+ { id: "currencies", name: { ar: "العملات", en: "Currencies" }, sidebar: { order: 40, icon: "currency" },
66
+ roles: ["owner", "manager"], scopes: ["read_products", "write_products"],
67
+ webhooks: [{ topic: "products/create", path: "/api/hub/webhooks" }], carrier_services: [], settings: [],
68
+ health: "/api/hub/health" },
69
+ process.env.T3B_MODULE_KEY ?? "",
70
+ );
71
+ return new Response(body, { headers });
72
+ }
73
+ ```
74
+
75
+ ```ts
76
+ // src/app/api/hub/health/route.ts — one line per connection the module has; the Hub shows each as a chip
77
+ import { healthAnswer } from "@t3b/hub-guard";
78
+ export async function GET() {
79
+ const { status, body } = await healthAnswer(async () => [
80
+ { name: "rates source", ok: await ratesReachable(), reason: null },
81
+ { name: "database", ok: await dbReachable() },
82
+ ]);
83
+ return Response.json(body, { status });
84
+ }
85
+ ```
86
+
87
+ ## Shopify's events (through the Hub)
88
+
89
+ The module never holds the Shopify app secret. Shopify sends events to the Hub; the Hub checks
90
+ them and passes the exact body to each module that declared the topic in its manifest and is on
91
+ for that store, signed with the module's key. Answer 2xx once the event is taken; any other answer
92
+ makes the Hub (and Shopify behind it) try again later.
93
+
94
+ ```ts
95
+ // src/app/api/hub/webhooks/route.ts (an open path - its lock is the signature)
96
+ import { verifyRelay } from "@t3b/hub-guard";
97
+ export async function POST(req: Request) {
98
+ const body = new Uint8Array(await req.arrayBuffer()); // the exact bytes - parse only after the check
99
+ const v = await verifyRelay(req.headers, body, process.env.T3B_MODULE_KEY ?? "");
100
+ if (!v.ok) return new Response(null, { status: 401 });
101
+ if (await alreadyTaken(v.webhookId)) return new Response(null, { status: 200 }); // a repeat
102
+ await handle(v.topic, v.store, JSON.parse(new TextDecoder().decode(body)));
103
+ return new Response(null, { status: 200 });
104
+ }
105
+ ```
106
+
107
+ ## Talking to the Hub
108
+
109
+ ```ts
110
+ import { tokenBroker, audit } from "@t3b/hub-guard";
111
+ const cfg = { hubUrl: process.env.T3B_HUB_URL!, moduleKey: process.env.T3B_MODULE_KEY! };
112
+ const broker = tokenBroker(cfg);
113
+ const t = await broker.get("kw"); // { ok, token: { accessToken, expiresAt, scopes } }, cached until an hour before expiry
114
+ await audit(cfg, { action: "shopify.write", target: "kw", detail: { what: "product 123 price" } });
115
+ ```
116
+
117
+ ## What it enforces
118
+
119
+ - `/hub/enter?ticket=…`: EdDSA ticket signed by the Hub, for this module, 60 seconds, single use → sets the module session (HttpOnly, Secure, SameSite=Strict) and sends to the page.
120
+ - 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 whether the person's session still stands (sign-everyone-out, removal, lockout, grant changes). A Hub blip keeps the session until the next check.
121
+ - Open paths (`/hub/enter`, `/api/hub/manifest`, `/api/hub/health` and what you add) never need a session — they carry their own locks (HMAC, cron secret, Shopify webhook HMAC).
122
+ - Cloudflare Access proof checked first when the team and audience are set.
123
+ - `verifyRelay`: the Hub's signature over timestamp, event id, topic, store and the exact body, with this module's key; refused when older or newer than 5 minutes.
@@ -0,0 +1,12 @@
1
+ export declare function checkCloudflareAccess(headers: Headers, cfg: {
2
+ team?: string;
3
+ audience?: string;
4
+ }): Promise<{
5
+ mode: "off";
6
+ ok: true;
7
+ } | {
8
+ mode: "enforced";
9
+ ok: boolean;
10
+ email?: string;
11
+ reason?: string;
12
+ }>;
@@ -0,0 +1,25 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ // Layer 1 (HUB-LOGIN-AND-SECURITY §2): Cloudflare Access puts a signed proof on every request
3
+ // that passed it (`Cf-Access-Jwt-Assertion`). When the module is told its team and audience, a
4
+ // request without a valid proof is refused before anything else - that is what locks the origin.
5
+ const jwksCache = new Map();
6
+ export async function checkCloudflareAccess(headers, cfg) {
7
+ if (!cfg.team || !cfg.audience)
8
+ return { mode: "off", ok: true };
9
+ const token = headers.get("cf-access-jwt-assertion");
10
+ if (!token)
11
+ return { mode: "enforced", ok: false, reason: "no Cloudflare proof" };
12
+ const issuer = `https://${cfg.team}.cloudflareaccess.com`;
13
+ let jwks = jwksCache.get(issuer);
14
+ if (!jwks) {
15
+ jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));
16
+ jwksCache.set(issuer, jwks);
17
+ }
18
+ try {
19
+ const { payload } = await jwtVerify(token, jwks, { issuer, audience: cfg.audience });
20
+ return { mode: "enforced", ok: true, email: typeof payload.email === "string" ? payload.email : undefined };
21
+ }
22
+ catch (e) {
23
+ return { mode: "enforced", ok: false, reason: e instanceof Error ? e.message : "invalid proof" };
24
+ }
25
+ }
@@ -0,0 +1,47 @@
1
+ import { type ModuleSession } from "./session.js";
2
+ export type GuardConfig = {
3
+ /** this module's id - the ticket's audience */
4
+ moduleId: string;
5
+ /** the Hub's public key(s): the current one and, for an hour after a rotation, the previous one */
6
+ hubPublicKeys: string[];
7
+ /** the module key made in the Hub (Settings → Modules → Make key) */
8
+ moduleKey: string;
9
+ hubUrl: string;
10
+ /** the refusal page's link back */
11
+ hubHomeUrl?: string;
12
+ /** paths that are machine doors with their own locks (webhooks, crons, health, manifest, the enter route) - never a session */
13
+ openPaths?: (string | RegExp)[];
14
+ cloudflare?: {
15
+ team?: string;
16
+ audience?: string;
17
+ };
18
+ absoluteMs?: number;
19
+ fetchImpl?: typeof fetch;
20
+ now?: () => Date;
21
+ };
22
+ export type CheckResult = {
23
+ allow: true;
24
+ session: ModuleSession;
25
+ setCookie?: string;
26
+ } | {
27
+ allow: false;
28
+ response: Response;
29
+ };
30
+ export declare class Guard {
31
+ private readonly cfg;
32
+ private readonly jti;
33
+ /** the session-cookie key, derived from the module key (same value as before the Web Crypto move) */
34
+ private readonly secretP;
35
+ constructor(cfg: GuardConfig);
36
+ private now;
37
+ private hubClient;
38
+ isOpenPath(pathname: string): boolean;
39
+ /** The refusal (board 11): a plain page for browsers, JSON for API routes. */
40
+ refuse(req: Request, reason: string, status?: number): Response;
41
+ /** /hub/enter?ticket=… : verify, open the module session, send to the page (303). */
42
+ enter(req: Request): Promise<Response>;
43
+ /** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
44
+ check(req: Request): Promise<CheckResult>;
45
+ /** Signs out of the module only (the Hub session is untouched). */
46
+ signOut(): Response;
47
+ }
package/dist/guard.js ADDED
@@ -0,0 +1,90 @@
1
+ import { checkCloudflareAccess } from "./cf-access.js";
2
+ import { sessionCheck } from "./hub-client.js";
3
+ import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, RECHECK_MS, SESSION_COOKIE } from "./session.js";
4
+ import { JtiMemory, verifyTicket } from "./ticket.js";
5
+ import { hmacSha256, toHex } from "./webcrypto.js";
6
+ export class Guard {
7
+ cfg;
8
+ jti = new JtiMemory();
9
+ /** the session-cookie key, derived from the module key (same value as before the Web Crypto move) */
10
+ secretP;
11
+ constructor(cfg) {
12
+ this.cfg = cfg;
13
+ if (!cfg.moduleId || !cfg.moduleKey || !cfg.hubUrl || !cfg.hubPublicKeys?.length)
14
+ throw new Error("hub-guard: moduleId, moduleKey, hubUrl and hubPublicKeys are required");
15
+ this.secretP = hmacSha256(cfg.moduleKey, "t3b-hub-guard/session/v1").then(toHex);
16
+ }
17
+ now() {
18
+ return this.cfg.now ? this.cfg.now() : new Date();
19
+ }
20
+ hubClient() {
21
+ return { hubUrl: this.cfg.hubUrl, moduleKey: this.cfg.moduleKey, fetchImpl: this.cfg.fetchImpl };
22
+ }
23
+ isOpenPath(pathname) {
24
+ const defaults = ["/hub/enter", "/api/hub/manifest", "/api/hub/health"];
25
+ return [...defaults, ...(this.cfg.openPaths ?? [])].some((p) => (typeof p === "string" ? pathname === p || pathname.startsWith(p.endsWith("/") ? p : `${p}/`) : p.test(pathname)));
26
+ }
27
+ /** The refusal (board 11): a plain page for browsers, JSON for API routes. */
28
+ refuse(req, reason, status = 403) {
29
+ const accept = req.headers.get("accept") ?? "";
30
+ const home = this.cfg.hubHomeUrl ?? this.cfg.hubUrl;
31
+ if (!accept.includes("text/html")) {
32
+ return new Response(JSON.stringify({ error: "open_from_hub", reason }), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Set-Cookie": clearCookieHeader() } });
33
+ }
34
+ 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>`;
35
+ return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Set-Cookie": clearCookieHeader() } });
36
+ }
37
+ /** /hub/enter?ticket=… : verify, open the module session, send to the page (303). */
38
+ async enter(req) {
39
+ const url = new URL(req.url);
40
+ const cf = await checkCloudflareAccess(req.headers, this.cfg.cloudflare ?? {});
41
+ if (!cf.ok)
42
+ return this.refuse(req, "The Cloudflare check did not pass.");
43
+ const ticket = url.searchParams.get("ticket") ?? "";
44
+ const v = await verifyTicket(ticket, { publicKeys: this.cfg.hubPublicKeys, audience: this.cfg.moduleId, now: this.now(), jti: this.jti });
45
+ if (!v.ok) {
46
+ 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." };
47
+ return this.refuse(req, words[v.reason] ?? "No valid ticket.");
48
+ }
49
+ const now = this.now().getTime();
50
+ 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 };
51
+ const target = new URL(v.claims.path, url.origin);
52
+ return new Response(null, { status: 303, headers: { Location: target.pathname + target.search, "Set-Cookie": cookieHeader(await signSession(session, await this.secretP), { secure: url.protocol === "https:", maxAgeS: Math.floor((this.cfg.absoluteMs ?? 8 * 3_600_000) / 1000) }), "Cache-Control": "no-store" } });
53
+ }
54
+ /** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
55
+ async check(req) {
56
+ const url = new URL(req.url);
57
+ if (this.isOpenPath(url.pathname))
58
+ return { allow: true, session: { personId: "", name: "", role: "", stores: [], sv: 0, iat: 0, lastSeen: 0, checkedAt: 0 } };
59
+ const cf = await checkCloudflareAccess(req.headers, this.cfg.cloudflare ?? {});
60
+ if (!cf.ok)
61
+ return { allow: false, response: this.refuse(req, "The Cloudflare check did not pass.") };
62
+ const nowMs = this.now().getTime();
63
+ const secret = await this.secretP;
64
+ const r = await readSession(cookieValue(req.headers.get("cookie"), SESSION_COOKIE), secret, nowMs, { absoluteMs: this.cfg.absoluteMs });
65
+ if (!r.ok) {
66
+ const words = { missing: "", bad: "", idle: "You were signed out after 15 minutes without activity.", expired: "Your session reached its 8-hour limit." };
67
+ return { allow: false, response: this.refuse(req, words[r.reason] ?? "") };
68
+ }
69
+ let s = r.session;
70
+ if (nowMs - s.checkedAt >= RECHECK_MS) {
71
+ const verdict = await sessionCheck(this.hubClient(), s.personId, s.sv);
72
+ if (verdict === "refused")
73
+ return { allow: false, response: this.refuse(req, "The Hub ended this session (signed out everywhere, or access changed).") };
74
+ if (verdict === "ok")
75
+ s = { ...s, checkedAt: nowMs };
76
+ }
77
+ s = { ...s, lastSeen: nowMs };
78
+ 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) }) };
79
+ }
80
+ /** Signs out of the module only (the Hub session is untouched). */
81
+ signOut() {
82
+ return new Response(null, { status: 303, headers: { Location: this.cfg.hubHomeUrl ?? this.cfg.hubUrl, "Set-Cookie": clearCookieHeader() } });
83
+ }
84
+ }
85
+ function escapeHtml(s) {
86
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
87
+ }
88
+ function escapeAttr(s) {
89
+ return escapeHtml(s);
90
+ }
@@ -0,0 +1,53 @@
1
+ export type HubClientConfig = {
2
+ hubUrl: string;
3
+ moduleKey: string;
4
+ fetchImpl?: typeof fetch;
5
+ timeoutMs?: number;
6
+ };
7
+ /** 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). */
8
+ export declare function sessionCheck(cfg: HubClientConfig, personId: string, sessionVersion: number): Promise<"ok" | "refused" | "unknown">;
9
+ export type BrokerToken = {
10
+ accessToken: string;
11
+ expiresAt: Date;
12
+ scopes: string[];
13
+ };
14
+ /** 7.3: the store's token from the Hub, cached until an hour before it expires. */
15
+ export declare function tokenBroker(cfg: HubClientConfig): {
16
+ get(store: string, now?: Date): Promise<{
17
+ ok: true;
18
+ token: BrokerToken;
19
+ } | {
20
+ ok: false;
21
+ status: number;
22
+ error: string;
23
+ message: string;
24
+ }>;
25
+ forget(store: string): void;
26
+ };
27
+ /** C-7: one audit line per consequential act (a Shopify write above all). Never throws. */
28
+ export declare function audit(cfg: HubClientConfig, line: {
29
+ action: string;
30
+ target?: string;
31
+ detail?: Record<string, unknown>;
32
+ }): Promise<boolean>;
33
+ /** C-5: the manifest body signed with the module key - the exact bytes sent are the bytes signed. */
34
+ export declare function signedManifest(manifest: Record<string, unknown>, moduleKey: string): Promise<{
35
+ body: string;
36
+ headers: Record<string, string>;
37
+ }>;
38
+ export type Connection = {
39
+ name: string;
40
+ ok: boolean;
41
+ reason?: string | null;
42
+ store?: string | null;
43
+ };
44
+ /** 8.4 / 7.6: the health answer. `ok` is false when any connection is red; the Hub shows each connection as its own chip. */
45
+ export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[]): Promise<{
46
+ status: number;
47
+ body: {
48
+ ok: boolean;
49
+ reason: string | null;
50
+ connections: Connection[];
51
+ checked_at: string;
52
+ };
53
+ }>;
@@ -0,0 +1,81 @@
1
+ import { hmacSha256, toHex } from "./webcrypto.js";
2
+ async function post(cfg, path, body) {
3
+ const ctrl = new AbortController();
4
+ const timer = setTimeout(() => ctrl.abort(), cfg.timeoutMs ?? 6_000);
5
+ try {
6
+ const res = await (cfg.fetchImpl ?? fetch)(new URL(path, cfg.hubUrl).toString(), {
7
+ method: "POST",
8
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${cfg.moduleKey}` },
9
+ body: JSON.stringify(body),
10
+ signal: ctrl.signal,
11
+ });
12
+ let json = null;
13
+ try {
14
+ json = (await res.json());
15
+ }
16
+ catch {
17
+ json = null;
18
+ }
19
+ return { status: res.status, json };
20
+ }
21
+ catch {
22
+ return { status: 0, json: null };
23
+ }
24
+ finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
28
+ /** 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). */
29
+ export async function sessionCheck(cfg, personId, sessionVersion) {
30
+ const r = await post(cfg, "/api/hub/session-check", { personId, sessionVersion });
31
+ if (r.status === 0 || r.status >= 500)
32
+ return "unknown";
33
+ return r.json?.ok === true ? "ok" : "refused";
34
+ }
35
+ /** 7.3: the store's token from the Hub, cached until an hour before it expires. */
36
+ export function tokenBroker(cfg) {
37
+ const cache = new Map();
38
+ return {
39
+ async get(store, now = new Date()) {
40
+ const c = cache.get(store);
41
+ if (c && c.expiresAt.getTime() - now.getTime() > 3_600_000)
42
+ return { ok: true, token: c };
43
+ const r = await post(cfg, "/api/broker/token", { store });
44
+ if (r.status !== 200 || !r.json || typeof r.json.access_token !== "string") {
45
+ return { ok: false, status: r.status, error: String(r.json?.error ?? "no_answer"), message: String(r.json?.message ?? "The Hub did not answer.") };
46
+ }
47
+ const token = { accessToken: r.json.access_token, expiresAt: new Date(String(r.json.expires_at)), scopes: Array.isArray(r.json.scopes) ? r.json.scopes.map(String) : [] };
48
+ cache.set(store, token);
49
+ return { ok: true, token };
50
+ },
51
+ forget(store) {
52
+ cache.delete(store);
53
+ },
54
+ };
55
+ }
56
+ /** C-7: one audit line per consequential act (a Shopify write above all). Never throws. */
57
+ export async function audit(cfg, line) {
58
+ const r = await post(cfg, "/api/hub/audit", line);
59
+ return r.status === 200 || r.status === 201;
60
+ }
61
+ /** C-5: the manifest body signed with the module key - the exact bytes sent are the bytes signed. */
62
+ export async function signedManifest(manifest, moduleKey) {
63
+ const body = JSON.stringify(manifest);
64
+ const sig = `sha256=${toHex(await hmacSha256(moduleKey, body))}`;
65
+ return { body, headers: { "Content-Type": "application/json", "x-t3b-signature": sig, "Cache-Control": "no-store" } };
66
+ }
67
+ /** 8.4 / 7.6: the health answer. `ok` is false when any connection is red; the Hub shows each connection as its own chip. */
68
+ export async function healthAnswer(check) {
69
+ let connections;
70
+ try {
71
+ connections = await check();
72
+ }
73
+ catch (e) {
74
+ connections = [{ name: "health check", ok: false, reason: e instanceof Error ? e.message : String(e) }];
75
+ }
76
+ const red = connections.filter((c) => !c.ok);
77
+ return {
78
+ status: 200,
79
+ body: { ok: red.length === 0, reason: red.length ? red.map((c) => `${c.name}: ${c.reason ?? "not ok"}`).join("; ").slice(0, 200) : null, connections, checked_at: new Date().toISOString() },
80
+ };
81
+ }
@@ -0,0 +1,18 @@
1
+ export { Guard, type GuardConfig, type CheckResult } from "./guard.js";
2
+ export { verifyTicket, importPublicKey, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
3
+ export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS, type ModuleSession } from "./session.js";
4
+ export { sessionCheck, tokenBroker, audit, signedManifest, healthAnswer, type HubClientConfig, type BrokerToken, type Connection } from "./hub-client.js";
5
+ export { checkCloudflareAccess } from "./cf-access.js";
6
+ export { verifyRelay, relaySignature, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck, type RelayFields } from "./relay.js";
7
+ /** Reads the guard's configuration from a module's environment (names only; values live in Vercel). */
8
+ export declare function guardConfigFromEnv(env: Record<string, string | undefined>, moduleId: string): {
9
+ moduleId: string;
10
+ hubPublicKeys: string[];
11
+ moduleKey: string;
12
+ hubUrl: string;
13
+ hubHomeUrl: string;
14
+ cloudflare: {
15
+ team: string | undefined;
16
+ audience: string | undefined;
17
+ };
18
+ };
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ export { Guard } from "./guard.js";
2
+ export { verifyTicket, importPublicKey, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
3
+ export { readSession, signSession, cookieHeader, clearCookieHeader, cookieValue, SESSION_COOKIE, IDLE_MS, ABSOLUTE_MS, RECHECK_MS } from "./session.js";
4
+ export { sessionCheck, tokenBroker, audit, signedManifest, healthAnswer } from "./hub-client.js";
5
+ export { checkCloudflareAccess } from "./cf-access.js";
6
+ export { verifyRelay, relaySignature, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
7
+ /** Reads the guard's configuration from a module's environment (names only; values live in Vercel). */
8
+ export function guardConfigFromEnv(env, moduleId) {
9
+ const keys = [env.T3B_HUB_PUBLIC_KEY, env.T3B_HUB_PUBLIC_KEY_PREVIOUS].filter((k) => Boolean(k));
10
+ return {
11
+ moduleId,
12
+ hubPublicKeys: keys,
13
+ moduleKey: env.T3B_MODULE_KEY ?? "",
14
+ hubUrl: env.T3B_HUB_URL ?? "https://t3b-hub.com",
15
+ hubHomeUrl: env.T3B_HUB_URL ? `${env.T3B_HUB_URL.replace(/\/$/, "")}/home` : "https://t3b-hub.com/home",
16
+ cloudflare: { team: env.T3B_CF_ACCESS_TEAM, audience: env.T3B_CF_ACCESS_AUD },
17
+ };
18
+ }
@@ -0,0 +1,33 @@
1
+ export declare const RELAY_HEADERS: {
2
+ readonly signature: "x-t3b-relay-signature";
3
+ readonly timestamp: "x-t3b-relay-timestamp";
4
+ readonly webhookId: "x-t3b-webhook-id";
5
+ readonly topic: "x-t3b-topic";
6
+ readonly store: "x-t3b-store";
7
+ readonly shop: "x-t3b-shop";
8
+ };
9
+ /** A relayed event older than this is refused (the Hub signs every attempt afresh). */
10
+ export declare const RELAY_MAX_AGE_S = 300;
11
+ export type RelayFields = {
12
+ timestamp: number;
13
+ webhookId: string;
14
+ topic: string;
15
+ store: string;
16
+ };
17
+ /** The bytes signed: `<ts>.<webhookId>.<topic>.<store>.` followed by the body exactly as received. */
18
+ export declare function relaySignature(moduleKey: string, f: RelayFields, body: Uint8Array | string): Promise<string>;
19
+ export type RelayCheck = {
20
+ ok: true;
21
+ webhookId: string;
22
+ topic: string;
23
+ store: string;
24
+ shop: string | null;
25
+ } | {
26
+ ok: false;
27
+ reason: "missing_header" | "stale" | "bad_signature" | "no_key";
28
+ };
29
+ /**
30
+ * Checks one relayed event. Read the body as raw bytes (`await req.arrayBuffer()`) before parsing
31
+ * it - the signature covers the exact bytes. Use the webhook id to ignore a repeat.
32
+ */
33
+ export declare function verifyRelay(headers: Headers, body: Uint8Array | string, moduleKey: string, now?: Date): Promise<RelayCheck>;
package/dist/relay.js ADDED
@@ -0,0 +1,41 @@
1
+ import { constantTimeEqual, hmacSha256, toHex } from "./webcrypto.js";
2
+ // WORKFLOWS 10.4 / SPEC C-20: Shopify's events reach a module only through the Hub. The Hub
3
+ // checks Shopify's signature and which store sent the event, then re-sends the exact body to each
4
+ // module that asked for the topic, signed with that module's key. A module never holds the
5
+ // Shopify app secret; it checks this signature instead.
6
+ export const RELAY_HEADERS = {
7
+ signature: "x-t3b-relay-signature",
8
+ timestamp: "x-t3b-relay-timestamp",
9
+ webhookId: "x-t3b-webhook-id",
10
+ topic: "x-t3b-topic",
11
+ store: "x-t3b-store",
12
+ shop: "x-t3b-shop",
13
+ };
14
+ /** A relayed event older than this is refused (the Hub signs every attempt afresh). */
15
+ export const RELAY_MAX_AGE_S = 300;
16
+ /** The bytes signed: `<ts>.<webhookId>.<topic>.<store>.` followed by the body exactly as received. */
17
+ export async function relaySignature(moduleKey, f, body) {
18
+ return `sha256=${toHex(await hmacSha256(moduleKey, `${f.timestamp}.${f.webhookId}.${f.topic}.${f.store}.`, body))}`;
19
+ }
20
+ /**
21
+ * Checks one relayed event. Read the body as raw bytes (`await req.arrayBuffer()`) before parsing
22
+ * it - the signature covers the exact bytes. Use the webhook id to ignore a repeat.
23
+ */
24
+ export async function verifyRelay(headers, body, moduleKey, now = new Date()) {
25
+ if (!moduleKey)
26
+ return { ok: false, reason: "no_key" };
27
+ const sig = headers.get(RELAY_HEADERS.signature) ?? "";
28
+ const tsRaw = headers.get(RELAY_HEADERS.timestamp) ?? "";
29
+ const webhookId = headers.get(RELAY_HEADERS.webhookId) ?? "";
30
+ const topic = headers.get(RELAY_HEADERS.topic) ?? "";
31
+ const store = headers.get(RELAY_HEADERS.store) ?? "";
32
+ if (!sig || !/^\d{9,11}$/.test(tsRaw) || !webhookId || !topic || !store)
33
+ return { ok: false, reason: "missing_header" };
34
+ const timestamp = Number(tsRaw);
35
+ if (Math.abs(Math.floor(now.getTime() / 1000) - timestamp) > RELAY_MAX_AGE_S)
36
+ return { ok: false, reason: "stale" };
37
+ const expected = await relaySignature(moduleKey, { timestamp, webhookId, topic, store }, body);
38
+ if (!constantTimeEqual(sig, expected))
39
+ return { ok: false, reason: "bad_signature" };
40
+ return { ok: true, webhookId, topic, store, shop: headers.get(RELAY_HEADERS.shop) };
41
+ }
@@ -0,0 +1,31 @@
1
+ export declare const SESSION_COOKIE = "t3b_module_session";
2
+ export declare const IDLE_MS: number;
3
+ export declare const ABSOLUTE_MS: number;
4
+ export declare const RECHECK_MS: number;
5
+ export type ModuleSession = {
6
+ personId: string;
7
+ name: string;
8
+ role: string;
9
+ stores: string[];
10
+ sv: number;
11
+ iat: number;
12
+ lastSeen: number;
13
+ checkedAt: number;
14
+ };
15
+ export declare function signSession(s: ModuleSession, secret: string): Promise<string>;
16
+ export declare function readSession(cookie: string | undefined | null, secret: string, now?: number, opts?: {
17
+ absoluteMs?: number;
18
+ }): Promise<{
19
+ ok: true;
20
+ session: ModuleSession;
21
+ } | {
22
+ ok: false;
23
+ reason: "missing" | "bad" | "idle" | "expired";
24
+ }>;
25
+ export declare function cookieHeader(value: string, opts?: {
26
+ secure?: boolean;
27
+ maxAgeS?: number;
28
+ }): string;
29
+ export declare function clearCookieHeader(): string;
30
+ /** Reads one cookie's value out of a Cookie header. */
31
+ export declare function cookieValue(header: string | null | undefined, name: string): string | undefined;
@@ -0,0 +1,60 @@
1
+ import { b64urlDecodeText, b64urlEncode, constantTimeEqual, hmacSha256, utf8 } from "./webcrypto.js";
2
+ // The module's own session (WORKFLOWS 2.2, 2.4): set at /hub/enter from a verified ticket,
3
+ // carried in an HttpOnly cookie signed with a key derived from the module key, 15 minutes idle,
4
+ // 8 hours absolute for admin surfaces (a module may set shift-long for a till), re-checked with
5
+ // the Hub every 15 minutes.
6
+ export const SESSION_COOKIE = "t3b_module_session";
7
+ export const IDLE_MS = 15 * 60_000;
8
+ export const ABSOLUTE_MS = 8 * 3_600_000;
9
+ export const RECHECK_MS = 15 * 60_000;
10
+ export async function signSession(s, secret) {
11
+ const body = b64urlEncode(utf8(JSON.stringify(s)));
12
+ const mac = b64urlEncode(await hmacSha256(secret, body));
13
+ return `${body}.${mac}`;
14
+ }
15
+ export async function readSession(cookie, secret, now = Date.now(), opts = {}) {
16
+ if (!cookie)
17
+ return { ok: false, reason: "missing" };
18
+ const [body, mac] = cookie.split(".");
19
+ if (!body || !mac)
20
+ return { ok: false, reason: "bad" };
21
+ const expected = b64urlEncode(await hmacSha256(secret, body));
22
+ if (!constantTimeEqual(mac, expected))
23
+ return { ok: false, reason: "bad" };
24
+ let s;
25
+ try {
26
+ s = JSON.parse(b64urlDecodeText(body));
27
+ }
28
+ catch {
29
+ return { ok: false, reason: "bad" };
30
+ }
31
+ if (typeof s.personId !== "string" || typeof s.iat !== "number" || typeof s.lastSeen !== "number")
32
+ return { ok: false, reason: "bad" };
33
+ if (now - s.iat > (opts.absoluteMs ?? ABSOLUTE_MS))
34
+ return { ok: false, reason: "expired" };
35
+ if (now - s.lastSeen > IDLE_MS)
36
+ return { ok: false, reason: "idle" };
37
+ return { ok: true, session: s };
38
+ }
39
+ export function cookieHeader(value, opts = {}) {
40
+ const parts = [`${SESSION_COOKIE}=${value}`, "Path=/", "HttpOnly", "SameSite=Strict"];
41
+ if (opts.secure !== false)
42
+ parts.push("Secure");
43
+ if (opts.maxAgeS !== undefined)
44
+ parts.push(`Max-Age=${opts.maxAgeS}`);
45
+ return parts.join("; ");
46
+ }
47
+ export function clearCookieHeader() {
48
+ return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Secure; Max-Age=0`;
49
+ }
50
+ /** Reads one cookie's value out of a Cookie header. */
51
+ export function cookieValue(header, name) {
52
+ if (!header)
53
+ return undefined;
54
+ for (const part of header.split(";")) {
55
+ const [k, ...v] = part.trim().split("=");
56
+ if (k === name)
57
+ return v.join("=");
58
+ }
59
+ return undefined;
60
+ }
@@ -0,0 +1,37 @@
1
+ export declare const TICKET_ISSUER = "t3b-hub";
2
+ export declare const TICKET_TTL_S = 60;
3
+ export declare const TICKET_ALG = "EdDSA";
4
+ export type TicketClaims = {
5
+ sub: string;
6
+ jti: string;
7
+ role: "owner" | "manager" | "staff" | "driver";
8
+ stores: string[];
9
+ path: string;
10
+ name: string;
11
+ sv: number;
12
+ iat: number;
13
+ exp: number;
14
+ };
15
+ export type VerifyResult = {
16
+ ok: true;
17
+ claims: TicketClaims;
18
+ } | {
19
+ ok: false;
20
+ reason: "expired" | "wrong_audience" | "bad_signature" | "replayed" | "malformed";
21
+ };
22
+ /** Seen ticket ids, kept for two minutes (C-4). One instance's memory; a replay across instances is stopped by the 60-s life. */
23
+ export declare class JtiMemory {
24
+ private readonly ttlMs;
25
+ private seen;
26
+ constructor(ttlMs?: number);
27
+ /** returns true when the id was already seen */
28
+ check(jti: string, now?: number): boolean;
29
+ }
30
+ /** Accepts a public key as base64 SPKI (DER) or as a PEM block. */
31
+ export declare function importPublicKey(key: string): Promise<CryptoKey>;
32
+ export declare function verifyTicket(ticket: string, opts: {
33
+ publicKeys: string[];
34
+ audience: string;
35
+ now?: Date;
36
+ jti?: JtiMemory;
37
+ }): Promise<VerifyResult>;
package/dist/ticket.js ADDED
@@ -0,0 +1,73 @@
1
+ import { importSPKI, jwtVerify } from "jose";
2
+ // SPEC C-4: the entry ticket. EdDSA (Ed25519) JWT signed by the Hub's private key; a module holds
3
+ // the public key(s) only. Claims: iss "t3b-hub", aud = module id, sub = person id, jti, iat,
4
+ // exp = iat + 60 s, role, stores[], path, name, sv (the person's session version).
5
+ export const TICKET_ISSUER = "t3b-hub";
6
+ export const TICKET_TTL_S = 60;
7
+ export const TICKET_ALG = "EdDSA";
8
+ /** Seen ticket ids, kept for two minutes (C-4). One instance's memory; a replay across instances is stopped by the 60-s life. */
9
+ export class JtiMemory {
10
+ ttlMs;
11
+ seen = new Map();
12
+ constructor(ttlMs = 120_000) {
13
+ this.ttlMs = ttlMs;
14
+ }
15
+ /** returns true when the id was already seen */
16
+ check(jti, now = Date.now()) {
17
+ for (const [k, t] of this.seen)
18
+ if (t < now)
19
+ this.seen.delete(k);
20
+ if (this.seen.has(jti))
21
+ return true;
22
+ this.seen.set(jti, now + this.ttlMs);
23
+ return false;
24
+ }
25
+ }
26
+ /** Accepts a public key as base64 SPKI (DER) or as a PEM block. */
27
+ export async function importPublicKey(key) {
28
+ const trimmed = key.trim();
29
+ const pem = trimmed.includes("-----BEGIN") ? trimmed : `-----BEGIN PUBLIC KEY-----\n${trimmed.replace(/\s+/g, "").match(/.{1,64}/g)?.join("\n") ?? ""}\n-----END PUBLIC KEY-----`;
30
+ return importSPKI(pem, TICKET_ALG);
31
+ }
32
+ export async function verifyTicket(ticket, opts) {
33
+ if (typeof ticket !== "string" || ticket.split(".").length !== 3 || ticket.length > 4096)
34
+ return { ok: false, reason: "malformed" };
35
+ const now = opts.now ?? new Date();
36
+ let payload = null;
37
+ let lastReason = "bad_signature";
38
+ for (const k of opts.publicKeys.filter(Boolean)) {
39
+ try {
40
+ const key = await importPublicKey(k);
41
+ const r = await jwtVerify(ticket, key, { issuer: TICKET_ISSUER, audience: opts.audience, algorithms: [TICKET_ALG], currentDate: now, clockTolerance: 5 });
42
+ payload = r.payload;
43
+ break;
44
+ }
45
+ catch (e) {
46
+ const code = e.code ?? "";
47
+ if (code === "ERR_JWT_EXPIRED")
48
+ lastReason = "expired";
49
+ else if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && /aud/.test(e.message))
50
+ lastReason = "wrong_audience";
51
+ else if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED")
52
+ lastReason = "wrong_audience";
53
+ else
54
+ lastReason = lastReason === "bad_signature" ? "bad_signature" : lastReason;
55
+ }
56
+ }
57
+ if (!payload)
58
+ return { ok: false, reason: lastReason };
59
+ const p = payload;
60
+ if (typeof p.sub !== "string" || typeof p.jti !== "string" || typeof p.role !== "string" || !Array.isArray(p.stores) || typeof p.path !== "string" || typeof p.exp !== "number" || typeof p.iat !== "number") {
61
+ return { ok: false, reason: "malformed" };
62
+ }
63
+ if (p.exp - p.iat > TICKET_TTL_S + 5)
64
+ return { ok: false, reason: "malformed" };
65
+ if (!p.path.startsWith("/") || p.path.startsWith("//") || p.path.includes("\\"))
66
+ return { ok: false, reason: "malformed" };
67
+ if (opts.jti && opts.jti.check(p.jti, now.getTime()))
68
+ return { ok: false, reason: "replayed" };
69
+ return {
70
+ ok: true,
71
+ 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 },
72
+ };
73
+ }
@@ -0,0 +1,8 @@
1
+ export declare function utf8(s: string): Uint8Array;
2
+ /** HMAC-SHA256 over the parts, in order, as one message. */
3
+ export declare function hmacSha256(secret: string, ...parts: (string | Uint8Array)[]): Promise<Uint8Array>;
4
+ export declare function toHex(b: Uint8Array): string;
5
+ export declare function b64urlEncode(b: Uint8Array): string;
6
+ export declare function b64urlDecodeText(s: string): string;
7
+ /** Compares two strings in time that does not depend on where they first differ. */
8
+ export declare function constantTimeEqual(a: string, b: string): boolean;
@@ -0,0 +1,66 @@
1
+ // Web Crypto only (globalThis.crypto.subtle), so the guard runs in Node 20+, in edge runtimes
2
+ // (Next.js middleware before Next 15.5, Vercel Edge, Cloudflare Workers) and in Deno alike.
3
+ // Nothing here imports node:crypto or uses Buffer.
4
+ const enc = new TextEncoder();
5
+ const keys = new Map();
6
+ function subtle() {
7
+ const s = globalThis.crypto?.subtle;
8
+ if (!s)
9
+ throw new Error("hub-guard: Web Crypto (crypto.subtle) is not available in this runtime");
10
+ return s;
11
+ }
12
+ function hmacKey(secret) {
13
+ let k = keys.get(secret);
14
+ if (!k) {
15
+ k = subtle().importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
16
+ keys.set(secret, k);
17
+ }
18
+ return k;
19
+ }
20
+ export function utf8(s) {
21
+ return enc.encode(s);
22
+ }
23
+ function concat(parts) {
24
+ const bytes = parts.map((p) => (typeof p === "string" ? enc.encode(p) : p));
25
+ const out = new Uint8Array(bytes.reduce((n, b) => n + b.byteLength, 0));
26
+ let at = 0;
27
+ for (const b of bytes) {
28
+ out.set(b, at);
29
+ at += b.byteLength;
30
+ }
31
+ return out;
32
+ }
33
+ /** HMAC-SHA256 over the parts, in order, as one message. */
34
+ export async function hmacSha256(secret, ...parts) {
35
+ const sig = await subtle().sign("HMAC", await hmacKey(secret), concat(parts));
36
+ return new Uint8Array(sig);
37
+ }
38
+ export function toHex(b) {
39
+ let s = "";
40
+ for (const x of b)
41
+ s += x.toString(16).padStart(2, "0");
42
+ return s;
43
+ }
44
+ export function b64urlEncode(b) {
45
+ let bin = "";
46
+ for (const x of b)
47
+ bin += String.fromCharCode(x);
48
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
49
+ }
50
+ export function b64urlDecodeText(s) {
51
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
52
+ const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
53
+ const bytes = new Uint8Array(bin.length);
54
+ for (let i = 0; i < bin.length; i++)
55
+ bytes[i] = bin.charCodeAt(i);
56
+ return new TextDecoder().decode(bytes);
57
+ }
58
+ /** Compares two strings in time that does not depend on where they first differ. */
59
+ export function constantTimeEqual(a, b) {
60
+ if (a.length !== b.length)
61
+ return false;
62
+ let diff = 0;
63
+ for (let i = 0; i < a.length; i++)
64
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
65
+ return diff === 0;
66
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@t3b/hub-guard",
3
+ "version": "0.1.0",
4
+ "description": "The3Beez Hub guard: a module opens only by a Hub-signed ticket and refuses everything else. Entry route, refusal page, module session, idle rule, 15-minute re-check with the Hub, Cloudflare Access proof, broker and audit clients, signed manifest and health answers. Web Crypto only: runs in Node 20+ and in edge runtimes.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "test": "vitest run"
23
+ },
24
+ "dependencies": {
25
+ "jose": "^6.2.12"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "^5",
29
+ "vitest": "^5.0.1"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/doofliet/t3b-hub",
40
+ "directory": "packages/hub-guard"
41
+ }
42
+ }