@t3b/hub-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,21 @@
2
2
 
3
3
  The lock every The3Beez module installs. A module **opens only by a ticket from the Hub** and
4
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.
5
+
6
+ **No key from the Hub, since 0.2.0 (WORKFLOWS 8b, D-048).** Nothing is pasted into a module:
7
+
8
+ - **Towards the Hub** the module shows the ID Vercel gives each of its functions on its own — a
9
+ Vercel-signed token naming our team, the project, and production or preview. The Hub knows the
10
+ module by its Vercel project. Needs Vercel → the project → Settings → Security → *Secure backend
11
+ access with OIDC federation* on (Vercel's default).
12
+ - **From the Hub** the module accepts only what the Hub signed with its own key: a person's
13
+ ticket, a relayed Shopify event, the Hub's request for the manifest or the health answer. The
14
+ public key is read from the Hub itself (`<hub>/api/hub/jwks`), kept 10 minutes, and fetched again
15
+ at once when the Hub starts using a new key.
6
16
 
7
17
  **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.
18
+ edge runtimes — including Next.js middleware. A test fails if a shipped file ever imports a
19
+ Node-only module.
10
20
 
11
21
  ## Install
12
22
 
@@ -14,89 +24,70 @@ shipped file ever imports a Node-only module.
14
24
  npm install @t3b/hub-guard
15
25
  ```
16
26
 
17
- Environment (values live in the module's Vercel, names only here):
27
+ Environment (names only; nothing here is secret):
18
28
 
19
29
  | Name | What |
20
30
  |---|---|
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) |
31
+ | `T3B_HUB_URL` | `https://t3b-hub.com` (the default) or the Hub's test address |
25
32
  | `T3B_CF_ACCESS_TEAM`, `T3B_CF_ACCESS_AUD` | Cloudflare Access proof, once the module host is behind it |
33
+ | `VERCEL_ENV` | set by Vercel; a ticket, relay or call from the other environment's Hub is refused |
26
34
 
27
- ## Next.js — three files
35
+ ## The two answers the Hub reads — to the Hub only
28
36
 
29
37
  ```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
- });
38
+ // src/app/api/hub/manifest/route.ts
39
+ import { hubKeys, manifestResponse } from "@t3b/hub-guard";
40
+ const HUB = process.env.T3B_HUB_URL ?? "https://t3b-hub.com";
41
+ export async function GET(req: Request) {
42
+ return manifestResponse(req, {
43
+ id: "stores", name: { ar: "المتاجر", en: "Stores" }, sidebar: { order: 50, icon: "store" },
44
+ roles: ["owner", "manager"], scopes: ["read_products"], webhooks: [], carrier_services: [], settings: [],
45
+ health: "/api/hub/health",
46
+ }, { moduleId: "stores", keys: hubKeys(HUB), environment: process.env.VERCEL_ENV });
47
+ }
36
48
  ```
37
49
 
38
50
  ```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;
51
+ // src/app/api/hub/health/route.ts — one line per connection the module has; the Hub shows each one
52
+ import { healthResponse, hubKeys } from "@t3b/hub-guard";
53
+ export async function GET(req: Request) {
54
+ return healthResponse(req, async () => [
55
+ { name: "Rate follow (MyFatoorah)", ok: await followedToday(), reason: "no follow for 30 hours" },
56
+ { name: "Database", ok: await dbReachable() },
57
+ ], { moduleId: "stores", keys: hubKeys(process.env.T3B_HUB_URL ?? "https://t3b-hub.com"), environment: process.env.VERCEL_ENV });
48
58
  }
49
- export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
50
59
  ```
51
60
 
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
- ```
61
+ Anyone else asking gets `401` and nothing — not the manifest, not the health lines, not the
62
+ app's Vercel ID.
57
63
 
58
- ## The two answers the Hub reads
64
+ ## Talking to the Hub
59
65
 
60
66
  ```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
- }
67
+ import { tokenBroker, audit } from "@t3b/hub-guard";
68
+ const cfg = { hubUrl: process.env.T3B_HUB_URL ?? "https://t3b-hub.com" }; // the Vercel ID is found on its own
69
+ const broker = tokenBroker(cfg);
70
+ const t = await broker.get("kw"); // { ok, token: { accessToken, expiresAt, scopes } }, cached until an hour before expiry
71
+ await audit(cfg, { action: "shopify.write", target: "kw", detail: { what: "product 123 price" } });
73
72
  ```
74
73
 
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
- ```
74
+ Without a Vercel ID (OIDC switched off, or a local run without `VERCEL_OIDC_TOKEN`) nothing is
75
+ sent and the answer says why (`no_vercel_id`).
86
76
 
87
77
  ## Shopify's events (through the Hub)
88
78
 
89
79
  The module never holds the Shopify app secret. Shopify sends events to the Hub; the Hub checks
90
80
  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.
81
+ for that store, with a pass (`x-t3b-relay`) signed by the Hub for that module, that event and
82
+ those exact bytes. Answer 2xx once the event is taken; any other answer makes the Hub (and
83
+ Shopify behind it) try again later.
93
84
 
94
85
  ```ts
95
- // src/app/api/hub/webhooks/route.ts (an open path - its lock is the signature)
96
- import { verifyRelay } from "@t3b/hub-guard";
86
+ // src/app/api/hub/webhooks/route.ts (an open path - its lock is the Hub's pass)
87
+ import { hubKeys, verifyRelay } from "@t3b/hub-guard";
97
88
  export async function POST(req: Request) {
98
89
  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 ?? "");
90
+ const v = await verifyRelay(req.headers, body, { moduleId: "stores", keys: hubKeys(process.env.T3B_HUB_URL ?? "https://t3b-hub.com"), environment: process.env.VERCEL_ENV });
100
91
  if (!v.ok) return new Response(null, { status: 401 });
101
92
  if (await alreadyTaken(v.webhookId)) return new Response(null, { status: 200 }); // a repeat
102
93
  await handle(v.topic, v.store, JSON.parse(new TextDecoder().decode(body)));
@@ -104,20 +95,52 @@ export async function POST(req: Request) {
104
95
  }
105
96
  ```
106
97
 
107
- ## Talking to the Hub
98
+ ## Opening from the Hub — three files
99
+
100
+ For a module people open from the Hub's sidebar. `sessionSecret` signs the module's own session
101
+ cookie (32+ characters); where a module gets it is settled with the first module that opens from
102
+ the Hub (WORKFLOWS 8b, as built).
108
103
 
109
104
  ```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" } });
105
+ // src/lib/guard.ts
106
+ import { Guard, guardConfigFromEnv } from "@t3b/hub-guard";
107
+ export const guard = new Guard({
108
+ ...guardConfigFromEnv(process.env, "delivery"),
109
+ sessionSecret: mySessionSecret(),
110
+ openPaths: ["/api/hub/webhooks", /^\/api\/cron\//], // machine doors with their own locks
111
+ });
112
+ ```
113
+
114
+ ```ts
115
+ // src/middleware.ts (or src/proxy.ts on Next 16)
116
+ import { NextResponse, type NextRequest } from "next/server";
117
+ import { guard } from "@/lib/guard";
118
+ export async function middleware(req: NextRequest) {
119
+ const r = await guard.check(req);
120
+ if (!r.allow) return r.response;
121
+ const res = NextResponse.next();
122
+ if (r.setCookie) res.headers.append("set-cookie", r.setCookie);
123
+ return res;
124
+ }
125
+ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
126
+ ```
127
+
128
+ ```ts
129
+ // src/app/hub/enter/route.ts
130
+ import { guard } from "@/lib/guard";
131
+ export const GET = (req: Request) => guard.enter(req);
115
132
  ```
116
133
 
117
134
  ## What it enforces
118
135
 
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).
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 sends to the page.
137
+ - 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
+ - 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).
122
139
  - 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.
140
+ - `verifyRelay`, `manifestResponse`, `healthResponse`: the Hub's pass, for this module, for this purpose, from this environment's Hub; a relay also for these exact bytes and at most 5 minutes old; a call at most 60 seconds old.
141
+
142
+ ## Changes
143
+
144
+ - **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
+ - **0.1.1**: `enter()` answers with an absolute Location (B-016).
146
+ - **0.1.0**: first publish, Web Crypto build.
package/dist/guard.d.ts CHANGED
@@ -1,12 +1,18 @@
1
+ import type { JWTVerifyGetKey } from "jose";
2
+ import { type TokenProvider } from "./identity.js";
1
3
  import { type ModuleSession } from "./session.js";
2
4
  export type GuardConfig = {
3
5
  /** this module's id - the ticket's audience */
4
6
  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
7
  hubUrl: string;
8
+ /** signs the module's own session cookie (32+ characters) */
9
+ sessionSecret: string;
10
+ /** the Hub's keys; by default read from `<hubUrl>/api/hub/jwks` */
11
+ keys?: JWTVerifyGetKey;
12
+ /** the app's Vercel ID; by default found on its own */
13
+ token?: TokenProvider;
14
+ /** this deployment's environment ("production" / "preview"); a ticket from another environment's Hub is refused */
15
+ environment?: string;
10
16
  /** the refusal page's link back */
11
17
  hubHomeUrl?: string;
12
18
  /** paths that are machine doors with their own locks (webhooks, crons, health, manifest, the enter route) - never a session */
@@ -30,8 +36,9 @@ export type CheckResult = {
30
36
  export declare class Guard {
31
37
  private readonly cfg;
32
38
  private readonly jti;
33
- /** the session-cookie key, derived from the module key (same value as before the Web Crypto move) */
39
+ /** the session-cookie key, derived from the session secret */
34
40
  private readonly secretP;
41
+ private readonly keys;
35
42
  constructor(cfg: GuardConfig);
36
43
  private now;
37
44
  private hubClient;
package/dist/guard.js CHANGED
@@ -1,24 +1,29 @@
1
1
  import { checkCloudflareAccess } from "./cf-access.js";
2
2
  import { sessionCheck } from "./hub-client.js";
3
+ import { hubKeys } from "./identity.js";
3
4
  import { clearCookieHeader, cookieHeader, cookieValue, readSession, signSession, RECHECK_MS, SESSION_COOKIE } from "./session.js";
4
5
  import { JtiMemory, verifyTicket } from "./ticket.js";
5
6
  import { hmacSha256, toHex } from "./webcrypto.js";
6
7
  export class Guard {
7
8
  cfg;
8
9
  jti = new JtiMemory();
9
- /** the session-cookie key, derived from the module key (same value as before the Web Crypto move) */
10
+ /** the session-cookie key, derived from the session secret */
10
11
  secretP;
12
+ keys;
11
13
  constructor(cfg) {
12
14
  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);
15
+ if (!cfg.moduleId || !cfg.hubUrl)
16
+ throw new Error("hub-guard: moduleId and hubUrl are required");
17
+ if (!cfg.sessionSecret || cfg.sessionSecret.length < 32)
18
+ throw new Error("hub-guard: sessionSecret (32+ characters) is required to open a module from the Hub");
19
+ this.secretP = hmacSha256(cfg.sessionSecret, "t3b-hub-guard/session/v1").then(toHex);
20
+ this.keys = cfg.keys ?? hubKeys(cfg.hubUrl);
16
21
  }
17
22
  now() {
18
23
  return this.cfg.now ? this.cfg.now() : new Date();
19
24
  }
20
25
  hubClient() {
21
- return { hubUrl: this.cfg.hubUrl, moduleKey: this.cfg.moduleKey, fetchImpl: this.cfg.fetchImpl };
26
+ return { hubUrl: this.cfg.hubUrl, token: this.cfg.token, fetchImpl: this.cfg.fetchImpl };
22
27
  }
23
28
  isOpenPath(pathname) {
24
29
  const defaults = ["/hub/enter", "/api/hub/manifest", "/api/hub/health"];
@@ -41,15 +46,16 @@ export class Guard {
41
46
  if (!cf.ok)
42
47
  return this.refuse(req, "The Cloudflare check did not pass.");
43
48
  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 });
49
+ const v = await verifyTicket(ticket, { keys: this.keys, audience: this.cfg.moduleId, now: this.now(), jti: this.jti, environment: this.cfg.environment });
45
50
  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." };
51
+ const words = { expired: "The ticket has expired (it lives 60 seconds) - open the module again from the Hub.", wrong_audience: "This ticket was made for another module.", bad_signature: "This ticket was not signed by the Hub.", replayed: "This ticket was already used.", malformed: "No valid ticket.", wrong_environment: "This ticket comes from another Hub (test or live)." };
47
52
  return this.refuse(req, words[v.reason] ?? "No valid ticket.");
48
53
  }
49
54
  const now = this.now().getTime();
50
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 };
51
56
  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" } });
57
+ // Absolute: Next.js middleware refuses a relative redirect ("Invalid URL", B-016); any client accepts this.
58
+ return new Response(null, { status: 303, headers: { Location: target.toString(), "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
59
  }
54
60
  /** Every other request: open path → allow; else the session must stand, be fresh, and pass the 15-minute Hub check. */
55
61
  async check(req) {
@@ -1,6 +1,9 @@
1
+ import { type JWTVerifyGetKey } from "jose";
2
+ import { type TokenProvider } from "./identity.js";
1
3
  export type HubClientConfig = {
2
4
  hubUrl: string;
3
- moduleKey: string;
5
+ /** the app's Vercel ID; by default found on its own (ownVercelToken) */
6
+ token?: TokenProvider;
4
7
  fetchImpl?: typeof fetch;
5
8
  timeoutMs?: number;
6
9
  };
@@ -30,18 +33,39 @@ export declare function audit(cfg: HubClientConfig, line: {
30
33
  target?: string;
31
34
  detail?: Record<string, unknown>;
32
35
  }): 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
- }>;
36
+ export type HubCallCheck = {
37
+ ok: true;
38
+ } | {
39
+ ok: false;
40
+ reason: "missing" | "bad_signature" | "expired" | "wrong_module" | "wrong_use" | "wrong_environment";
41
+ };
42
+ /** Is this request the Hub's own, for this module and this purpose? (`Authorization: Bearer <pass>`) */
43
+ export declare function verifyHubCall(req: Request, opts: {
44
+ moduleId: string;
45
+ keys: JWTVerifyGetKey;
46
+ use: "manifest" | "health";
47
+ now?: Date;
48
+ environment?: string;
49
+ }): Promise<HubCallCheck>;
50
+ export type AnswerConfig = {
51
+ moduleId: string;
52
+ keys: JWTVerifyGetKey;
53
+ token?: TokenProvider;
54
+ now?: Date;
55
+ environment?: string;
56
+ };
57
+ /**
58
+ * C-5 / 8b.4: the manifest, only to the Hub. The answer carries this app's own Vercel ID so the
59
+ * Hub knows which Vercel project answered; nobody else ever sees that ID.
60
+ */
61
+ export declare function manifestResponse(req: Request, manifest: Record<string, unknown>, cfg: AnswerConfig): Promise<Response>;
38
62
  export type Connection = {
39
63
  name: string;
40
64
  ok: boolean;
41
65
  reason?: string | null;
42
66
  store?: string | null;
43
67
  };
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. */
68
+ /** 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. */
45
69
  export declare function healthAnswer(check: () => Promise<Connection[]> | Connection[]): Promise<{
46
70
  status: number;
47
71
  body: {
@@ -51,3 +75,5 @@ export declare function healthAnswer(check: () => Promise<Connection[]> | Connec
51
75
  checked_at: string;
52
76
  };
53
77
  }>;
78
+ /** 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">): Promise<Response>;
@@ -1,11 +1,16 @@
1
- import { hmacSha256, toHex } from "./webcrypto.js";
1
+ import { jwtVerify } from "jose";
2
+ import { APP_ID_HEADER, HUB_ISSUER, ownVercelToken, TYP, tokenFrom } from "./identity.js";
3
+ 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.";
2
4
  async function post(cfg, path, body) {
5
+ const id = await tokenFrom(cfg.token);
6
+ if (!id)
7
+ return { status: 0, json: { error: "no_vercel_id", message: NO_ID } };
3
8
  const ctrl = new AbortController();
4
9
  const timer = setTimeout(() => ctrl.abort(), cfg.timeoutMs ?? 6_000);
5
10
  try {
6
11
  const res = await (cfg.fetchImpl ?? fetch)(new URL(path, cfg.hubUrl).toString(), {
7
12
  method: "POST",
8
- headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${cfg.moduleKey}` },
13
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${id}` },
9
14
  body: JSON.stringify(body),
10
15
  signal: ctrl.signal,
11
16
  });
@@ -58,13 +63,49 @@ export async function audit(cfg, line) {
58
63
  const r = await post(cfg, "/api/hub/audit", line);
59
64
  return r.status === 200 || r.status === 201;
60
65
  }
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
+ /** Is this request the Hub's own, for this module and this purpose? (`Authorization: Bearer <pass>`) */
67
+ export async function verifyHubCall(req, opts) {
68
+ const auth = req.headers.get("authorization") ?? "";
69
+ const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
70
+ if (!token || token.length > 4096)
71
+ return { ok: false, reason: "missing" };
72
+ let p;
73
+ try {
74
+ ({ 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 }));
75
+ }
76
+ catch (e) {
77
+ const code = e.code ?? "";
78
+ if (code === "ERR_JWT_EXPIRED")
79
+ return { ok: false, reason: "expired" };
80
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && /"aud"/.test(e.message))
81
+ return { ok: false, reason: "wrong_module" };
82
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED")
83
+ return { ok: false, reason: "expired" };
84
+ return { ok: false, reason: "bad_signature" };
85
+ }
86
+ if (p.use !== opts.use)
87
+ return { ok: false, reason: "wrong_use" };
88
+ if (opts.environment && p.env !== opts.environment)
89
+ return { ok: false, reason: "wrong_environment" };
90
+ return { ok: true };
66
91
  }
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. */
92
+ function jsonResponse(status, body, extra = {}) {
93
+ return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...extra } });
94
+ }
95
+ /**
96
+ * C-5 / 8b.4: the manifest, only to the Hub. The answer carries this app's own Vercel ID so the
97
+ * Hub knows which Vercel project answered; nobody else ever sees that ID.
98
+ */
99
+ export async function manifestResponse(req, manifest, cfg) {
100
+ const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "manifest", now: cfg.now, environment: cfg.environment });
101
+ if (!v.ok)
102
+ return jsonResponse(401, { error: "hub_only", reason: v.reason });
103
+ const own = cfg.token ? await tokenFrom(cfg.token) : ownVercelToken(req);
104
+ if (!own)
105
+ return jsonResponse(503, { error: "no_vercel_id", message: NO_ID });
106
+ return jsonResponse(200, manifest, { [APP_ID_HEADER]: own });
107
+ }
108
+ /** 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. */
68
109
  export async function healthAnswer(check) {
69
110
  let connections;
70
111
  try {
@@ -79,3 +120,11 @@ export async function healthAnswer(check) {
79
120
  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
121
  };
81
122
  }
123
+ /** 8b.4: the health answer, in full, only to the Hub; anyone else gets 401 and nothing. */
124
+ export async function healthResponse(req, check, cfg) {
125
+ const v = await verifyHubCall(req, { moduleId: cfg.moduleId, keys: cfg.keys, use: "health", now: cfg.now, environment: cfg.environment });
126
+ if (!v.ok)
127
+ return jsonResponse(401, { error: "hub_only", reason: v.reason });
128
+ const a = await healthAnswer(check);
129
+ return jsonResponse(a.status, a.body);
130
+ }
@@ -0,0 +1,27 @@
1
+ import { type JWTVerifyGetKey } from "jose";
2
+ export declare const OIDC_HEADER = "x-vercel-oidc-token";
3
+ /** The header a manifest answer carries the app's own Vercel ID on (the Hub checks it). */
4
+ export declare const APP_ID_HEADER = "x-t3b-vercel-id";
5
+ export declare const HUB_ISSUER = "t3b-hub";
6
+ export declare const TYP: {
7
+ readonly ticket: "t3b-ticket+jwt";
8
+ readonly call: "t3b-call+jwt";
9
+ readonly relay: "t3b-relay+jwt";
10
+ };
11
+ /** Where the app's Vercel ID comes from; the default finds it on its own. */
12
+ export type TokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
13
+ /**
14
+ * This app's own Vercel ID: the given request's header, else the request Vercel is serving right
15
+ * now (the lookup @vercel/oidc makes), else VERCEL_OIDC_TOKEN (builds, local runs). Needs Vercel →
16
+ * the project → Settings → Security → Secure backend access (OIDC) on - Vercel's default.
17
+ */
18
+ export declare function ownVercelToken(from?: Request | Headers | null): string | null;
19
+ export declare function tokenFrom(provider: TokenProvider | undefined): Promise<string | null>;
20
+ /**
21
+ * The Hub's published keys, fetched from `<hubUrl>/api/hub/jwks`, kept 10 minutes, fetched again
22
+ * at once when a token names a key id not yet seen (a new Hub key works immediately; a retired one
23
+ * stops within 10 minutes).
24
+ */
25
+ export declare function hubKeys(hubUrl: string): JWTVerifyGetKey;
26
+ /** This deployment's environment as Vercel names it; undefined off Vercel. */
27
+ export declare function ownEnvironment(): string | undefined;
@@ -0,0 +1,51 @@
1
+ import { createRemoteJWKSet } from "jose";
2
+ // WORKFLOWS 8b (D-048): an app holds no key from the Hub. Towards the Hub it shows the ID Vercel
3
+ // gives each of its functions on its own (a Vercel-signed token naming the team, the project and
4
+ // production or preview); from the Hub it accepts only what is signed with the Hub's own key,
5
+ // read from the Hub's one public address (/api/hub/jwks).
6
+ export const OIDC_HEADER = "x-vercel-oidc-token";
7
+ /** The header a manifest answer carries the app's own Vercel ID on (the Hub checks it). */
8
+ export const APP_ID_HEADER = "x-t3b-vercel-id";
9
+ export const HUB_ISSUER = "t3b-hub";
10
+ export const TYP = { ticket: "t3b-ticket+jwt", call: "t3b-call+jwt", relay: "t3b-relay+jwt" };
11
+ const REQUEST_CONTEXT = Symbol.for("@vercel/request-context");
12
+ /**
13
+ * This app's own Vercel ID: the given request's header, else the request Vercel is serving right
14
+ * now (the lookup @vercel/oidc makes), else VERCEL_OIDC_TOKEN (builds, local runs). Needs Vercel →
15
+ * the project → Settings → Security → Secure backend access (OIDC) on - Vercel's default.
16
+ */
17
+ export function ownVercelToken(from) {
18
+ const h = from instanceof Headers ? from : from?.headers;
19
+ const direct = h?.get(OIDC_HEADER);
20
+ if (direct)
21
+ return direct;
22
+ const ctx = globalThis[REQUEST_CONTEXT];
23
+ const fromCtx = ctx?.get?.()?.headers?.[OIDC_HEADER];
24
+ if (fromCtx)
25
+ return fromCtx;
26
+ const env = globalThis.process?.env;
27
+ return env?.VERCEL_OIDC_TOKEN || null;
28
+ }
29
+ export async function tokenFrom(provider) {
30
+ return (provider ? await provider() : ownVercelToken()) || null;
31
+ }
32
+ const keySets = new Map();
33
+ /**
34
+ * The Hub's published keys, fetched from `<hubUrl>/api/hub/jwks`, kept 10 minutes, fetched again
35
+ * at once when a token names a key id not yet seen (a new Hub key works immediately; a retired one
36
+ * stops within 10 minutes).
37
+ */
38
+ export function hubKeys(hubUrl) {
39
+ const url = new URL("/api/hub/jwks", hubUrl).toString();
40
+ let k = keySets.get(url);
41
+ if (!k) {
42
+ k = createRemoteJWKSet(new URL(url), { cacheMaxAge: 600_000, cooldownDuration: 30_000, timeoutDuration: 5_000 });
43
+ keySets.set(url, k);
44
+ }
45
+ return k;
46
+ }
47
+ /** This deployment's environment as Vercel names it; undefined off Vercel. */
48
+ export function ownEnvironment() {
49
+ const env = globalThis.process?.env;
50
+ return env?.VERCEL_TARGET_ENV || env?.VERCEL_ENV || undefined;
51
+ }
package/dist/index.d.ts CHANGED
@@ -1,16 +1,20 @@
1
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";
2
+ export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG, type TicketClaims, type VerifyResult } from "./ticket.js";
3
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";
4
+ export { sessionCheck, tokenBroker, audit, verifyHubCall, manifestResponse, healthResponse, healthAnswer, type HubClientConfig, type HubCallCheck, type AnswerConfig, type BrokerToken, type Connection, } from "./hub-client.js";
5
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). */
6
+ export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S, type RelayCheck } from "./relay.js";
7
+ export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER, type TokenProvider } from "./identity.js";
8
+ /**
9
+ * Reads the guard's settings from a module's environment. Nothing secret: the Hub's address
10
+ * (T3B_HUB_URL, default https://t3b-hub.com), the Cloudflare door's team and audience, and the
11
+ * environment Vercel runs the app as. Since 0.2.0 (8b) no key from the Hub is pasted anywhere.
12
+ */
8
13
  export declare function guardConfigFromEnv(env: Record<string, string | undefined>, moduleId: string): {
9
14
  moduleId: string;
10
- hubPublicKeys: string[];
11
- moduleKey: string;
12
15
  hubUrl: string;
13
16
  hubHomeUrl: string;
17
+ environment: string | undefined;
14
18
  cloudflare: {
15
19
  team: string | undefined;
16
20
  audience: string | undefined;
package/dist/index.js CHANGED
@@ -1,18 +1,22 @@
1
1
  export { Guard } from "./guard.js";
2
- export { verifyTicket, importPublicKey, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
2
+ export { verifyTicket, JtiMemory, TICKET_ISSUER, TICKET_TTL_S, TICKET_ALG } from "./ticket.js";
3
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";
4
+ export { sessionCheck, tokenBroker, audit, verifyHubCall, manifestResponse, healthResponse, healthAnswer, } from "./hub-client.js";
5
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). */
6
+ export { verifyRelay, RELAY_HEADERS, RELAY_MAX_AGE_S } from "./relay.js";
7
+ export { ownVercelToken, ownEnvironment, hubKeys, APP_ID_HEADER, OIDC_HEADER } from "./identity.js";
8
+ /**
9
+ * Reads the guard's settings from a module's environment. Nothing secret: the Hub's address
10
+ * (T3B_HUB_URL, default https://t3b-hub.com), the Cloudflare door's team and audience, and the
11
+ * environment Vercel runs the app as. Since 0.2.0 (8b) no key from the Hub is pasted anywhere.
12
+ */
8
13
  export function guardConfigFromEnv(env, moduleId) {
9
- const keys = [env.T3B_HUB_PUBLIC_KEY, env.T3B_HUB_PUBLIC_KEY_PREVIOUS].filter((k) => Boolean(k));
14
+ const hubUrl = (env.T3B_HUB_URL ?? "https://t3b-hub.com").replace(/\/$/, "");
10
15
  return {
11
16
  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",
17
+ hubUrl,
18
+ hubHomeUrl: `${hubUrl}/home`,
19
+ environment: env.VERCEL_TARGET_ENV || env.VERCEL_ENV || undefined,
16
20
  cloudflare: { team: env.T3B_CF_ACCESS_TEAM, audience: env.T3B_CF_ACCESS_AUD },
17
21
  };
18
22
  }
package/dist/relay.d.ts CHANGED
@@ -1,6 +1,6 @@
1
+ import { type JWTVerifyGetKey } from "jose";
1
2
  export declare const RELAY_HEADERS: {
2
- readonly signature: "x-t3b-relay-signature";
3
- readonly timestamp: "x-t3b-relay-timestamp";
3
+ readonly token: "x-t3b-relay";
4
4
  readonly webhookId: "x-t3b-webhook-id";
5
5
  readonly topic: "x-t3b-topic";
6
6
  readonly store: "x-t3b-store";
@@ -8,14 +8,6 @@ export declare const RELAY_HEADERS: {
8
8
  };
9
9
  /** A relayed event older than this is refused (the Hub signs every attempt afresh). */
10
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
11
  export type RelayCheck = {
20
12
  ok: true;
21
13
  webhookId: string;
@@ -24,10 +16,15 @@ export type RelayCheck = {
24
16
  shop: string | null;
25
17
  } | {
26
18
  ok: false;
27
- reason: "missing_header" | "stale" | "bad_signature" | "no_key";
19
+ reason: "missing_header" | "stale" | "bad_signature" | "wrong_module" | "body_changed" | "wrong_environment";
28
20
  };
29
21
  /**
30
22
  * 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.
23
+ * it - the pass covers the exact bytes. Use the webhook id to ignore a repeat.
32
24
  */
33
- export declare function verifyRelay(headers: Headers, body: Uint8Array | string, moduleKey: string, now?: Date): Promise<RelayCheck>;
25
+ export declare function verifyRelay(headers: Headers, body: Uint8Array | string, opts: {
26
+ moduleId: string;
27
+ keys: JWTVerifyGetKey;
28
+ now?: Date;
29
+ environment?: string;
30
+ }): Promise<RelayCheck>;
package/dist/relay.js CHANGED
@@ -1,11 +1,14 @@
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.
1
+ import { jwtVerify } from "jose";
2
+ import { HUB_ISSUER, TYP } from "./identity.js";
3
+ import { b64urlEncode, sha256 } from "./webcrypto.js";
4
+ // WORKFLOWS 10.4 / 8b.3 (SPEC C-20): Shopify's events reach a module only through the Hub. The
5
+ // Hub checks Shopify's signature and which store sent the event, then re-sends the exact body to
6
+ // each module that asked for the topic, with a pass in `x-t3b-relay` signed by the Hub's key: for
7
+ // this module (aud), this event, this exact body (its SHA-256), five minutes. A module never holds
8
+ // the Shopify app secret, and since 0.2.0 no key from the Hub either - it checks the pass against
9
+ // the Hub's published key.
6
10
  export const RELAY_HEADERS = {
7
- signature: "x-t3b-relay-signature",
8
- timestamp: "x-t3b-relay-timestamp",
11
+ token: "x-t3b-relay",
9
12
  webhookId: "x-t3b-webhook-id",
10
13
  topic: "x-t3b-topic",
11
14
  store: "x-t3b-store",
@@ -13,29 +16,37 @@ export const RELAY_HEADERS = {
13
16
  };
14
17
  /** A relayed event older than this is refused (the Hub signs every attempt afresh). */
15
18
  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
19
  /**
21
20
  * 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.
21
+ * it - the pass covers the exact bytes. Use the webhook id to ignore a repeat.
23
22
  */
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)
23
+ export async function verifyRelay(headers, body, opts) {
24
+ const token = headers.get(RELAY_HEADERS.token) ?? "";
25
+ if (!token || token.length > 4096)
33
26
  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))
27
+ let p;
28
+ try {
29
+ ({ payload: p } = await jwtVerify(token, opts.keys, { issuer: HUB_ISSUER, audience: opts.moduleId, algorithms: ["EdDSA"], typ: TYP.relay, currentDate: opts.now, clockTolerance: 5, maxTokenAge: RELAY_MAX_AGE_S + 5 }));
30
+ }
31
+ catch (e) {
32
+ const code = e.code ?? "";
33
+ if (code === "ERR_JWT_EXPIRED")
34
+ return { ok: false, reason: "stale" };
35
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && /"aud"/.test(e.message))
36
+ return { ok: false, reason: "wrong_module" };
37
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED")
38
+ return { ok: false, reason: "stale" };
39
+ return { ok: false, reason: "bad_signature" };
40
+ }
41
+ if (p.use !== "relay")
39
42
  return { ok: false, reason: "bad_signature" };
40
- return { ok: true, webhookId, topic, store, shop: headers.get(RELAY_HEADERS.shop) };
43
+ if (opts.environment && p.env !== opts.environment)
44
+ return { ok: false, reason: "wrong_environment" };
45
+ const s = (k) => (typeof p[k] === "string" ? p[k] : "");
46
+ if (!s("webhook_id") || !s("topic") || !s("store") || !s("body_sha256"))
47
+ return { ok: false, reason: "missing_header" };
48
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
49
+ if (b64urlEncode(await sha256(bytes)) !== s("body_sha256"))
50
+ return { ok: false, reason: "body_changed" };
51
+ return { ok: true, webhookId: s("webhook_id"), topic: s("topic"), store: s("store"), shop: s("shop") || null };
41
52
  }
package/dist/ticket.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type JWTVerifyGetKey } from "jose";
1
2
  export declare const TICKET_ISSUER = "t3b-hub";
2
3
  export declare const TICKET_TTL_S = 60;
3
4
  export declare const TICKET_ALG = "EdDSA";
@@ -17,7 +18,7 @@ export type VerifyResult = {
17
18
  claims: TicketClaims;
18
19
  } | {
19
20
  ok: false;
20
- reason: "expired" | "wrong_audience" | "bad_signature" | "replayed" | "malformed";
21
+ reason: "expired" | "wrong_audience" | "bad_signature" | "replayed" | "malformed" | "wrong_environment";
21
22
  };
22
23
  /** 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
24
  export declare class JtiMemory {
@@ -27,11 +28,10 @@ export declare class JtiMemory {
27
28
  /** returns true when the id was already seen */
28
29
  check(jti: string, now?: number): boolean;
29
30
  }
30
- /** Accepts a public key as base64 SPKI (DER) or as a PEM block. */
31
- export declare function importPublicKey(key: string): Promise<CryptoKey>;
32
31
  export declare function verifyTicket(ticket: string, opts: {
33
- publicKeys: string[];
32
+ keys: JWTVerifyGetKey;
34
33
  audience: string;
35
34
  now?: Date;
36
35
  jti?: JtiMemory;
36
+ environment?: string;
37
37
  }): Promise<VerifyResult>;
package/dist/ticket.js CHANGED
@@ -1,8 +1,10 @@
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";
1
+ import { jwtVerify } from "jose";
2
+ import { HUB_ISSUER, TYP } from "./identity.js";
3
+ // SPEC C-4: the entry ticket. EdDSA (Ed25519) JWT signed by the Hub's key; since 0.2.0 (8b) the
4
+ // module reads the Hub's public key from the Hub's own address instead of holding a pasted copy.
5
+ // Header typ "t3b-ticket+jwt", kid. Claims: iss "t3b-hub", aud = module id, sub = person id, jti,
6
+ // iat, exp = iat + 60 s, use "enter", env, role, stores[], path, name, sv (session version).
7
+ export const TICKET_ISSUER = HUB_ISSUER;
6
8
  export const TICKET_TTL_S = 60;
7
9
  export const TICKET_ALG = "EdDSA";
8
10
  /** 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,40 +25,29 @@ export class JtiMemory {
23
25
  return false;
24
26
  }
25
27
  }
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
28
  export async function verifyTicket(ticket, opts) {
33
29
  if (typeof ticket !== "string" || ticket.split(".").length !== 3 || ticket.length > 4096)
34
30
  return { ok: false, reason: "malformed" };
35
31
  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
- }
32
+ let payload;
33
+ try {
34
+ ({ payload } = await jwtVerify(ticket, opts.keys, { issuer: TICKET_ISSUER, audience: opts.audience, algorithms: [TICKET_ALG], typ: TYP.ticket, currentDate: now, clockTolerance: 5 }));
35
+ }
36
+ catch (e) {
37
+ const code = e.code ?? "";
38
+ if (code === "ERR_JWT_EXPIRED")
39
+ return { ok: false, reason: "expired" };
40
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && /"aud"/.test(e.message))
41
+ return { ok: false, reason: "wrong_audience" };
42
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED")
43
+ return { ok: false, reason: "malformed" };
44
+ return { ok: false, reason: "bad_signature" };
56
45
  }
57
- if (!payload)
58
- return { ok: false, reason: lastReason };
59
46
  const p = payload;
47
+ if (p.use !== "enter")
48
+ return { ok: false, reason: "malformed" };
49
+ if (opts.environment && p.env !== opts.environment)
50
+ return { ok: false, reason: "wrong_environment" };
60
51
  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
52
  return { ok: false, reason: "malformed" };
62
53
  }
@@ -6,3 +6,5 @@ export declare function b64urlEncode(b: Uint8Array): string;
6
6
  export declare function b64urlDecodeText(s: string): string;
7
7
  /** Compares two strings in time that does not depend on where they first differ. */
8
8
  export declare function constantTimeEqual(a: string, b: string): boolean;
9
+ /** SHA-256 of the bytes. */
10
+ export declare function sha256(bytes: Uint8Array): Promise<Uint8Array>;
package/dist/webcrypto.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Web Crypto only (globalThis.crypto.subtle), so the guard runs in Node 20+, in edge runtimes
2
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.
3
+ // Nothing here imports node:crypto or uses Node's Buffer type.
4
4
  const enc = new TextEncoder();
5
5
  const keys = new Map();
6
6
  function subtle() {
@@ -64,3 +64,7 @@ export function constantTimeEqual(a, b) {
64
64
  diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
65
65
  return diff === 0;
66
66
  }
67
+ /** SHA-256 of the bytes. */
68
+ export async function sha256(bytes) {
69
+ return new Uint8Array(await subtle().digest("SHA-256", bytes));
70
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.2.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.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",