diff --git a/.env.example b/.env.example index ff2a33d..291a03a 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,9 @@ BASE44_ACCESS_TOKEN="" # workspace key does: signing a viewer into an embedded app (`app_users:provision` # + `app_users:embed_token`, app access set to all apps) and registering the # webhook endpoint. Sent as `api_key`. Optional: without it the builder still -# works, and embedded apps load signed out. +# works and embedded apps load signed out, but the webhook receiver answers 500 — +# the `@base44/platform` client it verifies with requires a key, though it never +# sends one. BASE44_SVC_KEY="" # Not secrets — the workspace id (sent as X-Active-Workspace-Id) and the platform # host. In env because the host rotates and the id becomes per-tenant later. Never diff --git a/CLAUDE.md b/CLAUDE.md index b23bea0..1cf2c04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Next.js App Router · TypeScript · Tailwind 4 · Postgres + Prisma · NextAuth | 2. One Base44 account, and the shell keeps its builders apart | `src/lib/base44Config.ts`, `src/lib/appOwnership.ts` | | 3. A server-side allow-list in front of Base44's REST API | `src/app/api/base44/platform/route.ts` | | 4. A public data API the built apps call | `src/app/api/sunny/route.ts`, `src/lib/builderInstructions.ts` | -| 5. Signed inbound webhooks from Base44 | `src/app/api/base44/webhooks/route.ts`, `src/lib/base44WebhookSignature.ts` | +| 5. Signed inbound webhooks from Base44 | `src/app/api/base44/webhooks/route.ts`, `src/lib/base44Webhooks.ts` | ## Conventions @@ -51,9 +51,11 @@ Next.js App Router · TypeScript · Tailwind 4 · Postgres + Prisma · NextAuth client can read and delete its own rows but never create one — a client-written row would claim another builder's app. The webhook receiver joins an event to users through `ownersOf(appId)` there, which returns emails and nothing else. -- **An inbound webhook is untrusted until its signature verifies.** `src/lib/base44WebhookSignature.ts` - is the boundary: the URL is public, so `app_id` — which decides whose pins get removed — is - attacker-controlled until it returns ok. Verify the **raw** body text. Keys come from +- **An inbound webhook is untrusted until its signature verifies.** `src/lib/base44Webhooks.ts` + is the boundary: it wires `@base44/platform/server`'s verifier to this deployment's config. The + URL is public, so `app_id` — which decides whose pins get removed — is attacker-controlled until + it verifies. The route mounts the SDK's `createHandler`, which reads the **raw** body, answers the + activation probe and picks the status code; don't parse the body before it. Keys come from `BASE44_WEBHOOK_PUBLIC_KEYS` only (public keys, not secrets); pin both through a rotation. - **The receiver keeps no state.** It acts on `app.deleted.v1` only, by deleting the owner's `Widget` rows through `entityCrud.ts`; that is idempotent and order-safe, so there is no event diff --git a/README.md b/README.md index f0bdc37..909c21b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Five boundaries, five steps — four calling out to Base44, one where it calls b | [2](#step-2--one-base44-account-and-you-keep-your-users-apart) | One Base44 account for the whole platform — and you keep your users apart. | `src/lib/base44Config.ts`, `src/lib/appOwnership.ts` | | [3](#step-3--call-base44-from-your-server-behind-an-allow-list) | A server-side proxy in front of Base44's REST API. | `src/app/api/base44/platform/route.ts` | | [4](#step-4--let-the-built-apps-talk-to-your-data) | A public API the built apps call, plus instructions teaching them how. | `src/app/api/sunny/route.ts`, `src/lib/builderInstructions.ts` | -| [5](#step-5--let-base44-tell-you-what-happened) | Signed inbound webhooks, so you learn about deletions at all — and act on them. | `src/app/api/base44/webhooks/route.ts`, `src/lib/base44WebhookSignature.ts` | +| [5](#step-5--let-base44-tell-you-what-happened) | Signed inbound webhooks, so you learn about deletions at all — and act on them. | `src/app/api/base44/webhooks/route.ts`, `src/lib/base44Webhooks.ts` | --- @@ -292,8 +292,9 @@ so duplicates and out-of-order deliveries need no extra state. **The signature is the whole security boundary.** The URL is public, so nothing in a request is trustworthy until it verifies. Base44 signs Ed25519 (`v1a`) over -`webhook-id + "." + webhook-timestamp + "." + raw body`. Verify the body text as received — -re-serializing it changes the bytes and breaks every signature. +`webhook-id + "." + webhook-timestamp + "." + raw body`. `@base44/platform/server` verifies it: +the route mounts its `createHandler`, which reads the body as received — re-serializing it changes +the bytes and breaks every signature — answers the activation probe, and picks the status code. **`app.deleted` is trash, not erasure** — restorable for 30 days. Remove only what would show a missing app; never purge. diff --git a/docs/base44-webhooks.md b/docs/base44-webhooks.md index 4734f5e..e5b6412 100644 --- a/docs/base44-webhooks.md +++ b/docs/base44-webhooks.md @@ -8,13 +8,16 @@ deleted app indefinitely. `POST /api/base44/webhooks` (`src/app/api/base44/webhooks/route.ts`): -1. Verifies the signature (`src/lib/base44WebhookSignature.ts`). Nothing else runs before this. -2. Echoes the `challenge` for `webhook.test.v1` — that is how the endpoint activates. +1. Verifies the signature. Nothing else runs before this. The route mounts `createHandler` from + `@base44/platform/server`, configured in `src/lib/base44Webhooks.ts`. +2. Echoes the `challenge` for `webhook.test.v1` — that is how the endpoint activates. The SDK + handler does this itself, after verifying. 3. On `app.deleted.v1`, looks up who built `app_id` in `AppOwnership` (`ownersOf()` in `src/lib/appOwnership.ts`) and deletes those users' `Widget` rows for the app. Nothing in the payload names a Sunny user — upstream every app belongs to the one integration account — and nothing needs to. -4. Answers 2xx to everything else. +4. Answers 204 to everything else, 400 to a delivery that fails verification, and 500 when the keys + are missing or a handler threw. It keeps no state. Deleting pins is idempotent, so a redelivery is harmless, and a restore never puts pins back, so a late `app.deleted` arriving after an `app.restored` leaves the same result as one @@ -46,7 +49,8 @@ follows redirects. For local development, tunnel (`ngrok`, `cloudflared`). ## Verifying a delivery -Standard Webhooks `v1a`, Ed25519: +The SDK does this (`platform.webhooks.verifyEvent`, which `createHandler` calls). For the record, +it is Standard Webhooks `v1a`, Ed25519: ``` signed payload = webhook-id + "." + webhook-timestamp + "." + @@ -62,8 +66,9 @@ Keys come from `BASE44_WEBHOOK_PUBLIC_KEYS` only, so verification makes no netwo malformed, the receiver answers 500 and the event stays on Base44's retry ladder. On a rotation, deploy the new key alongside the old one inside the overlap window, then drop the old one. -`npm run webhook:smoke` covers the negative cases: tampered body, relabelled id, unknown key, stale -timestamp, truncated key. +`npm run webhook:smoke` covers the negative cases (tampered body, relabelled id, unknown key, stale +timestamp, truncated key) and the handler's status codes, against the SDK as installed. Run it +after bumping `@base44/platform`. ## Delivery semantics diff --git a/examples/white-label-minimal/netlify.toml b/examples/white-label-minimal/netlify.toml index 2924d02..bb91561 100644 --- a/examples/white-label-minimal/netlify.toml +++ b/examples/white-label-minimal/netlify.toml @@ -7,3 +7,7 @@ [build.environment] NODE_VERSION = "24" + # This example pins @base44/platform 0.1.0, outside the root's 0.3.0 range, so + # npm installs it under the example's own node_modules, which the secrets + # scanner would otherwise read. Installed packages are never served. + SECRETS_SCAN_OMIT_PATHS = "examples/white-label-minimal/node_modules/**" diff --git a/netlify.toml b/netlify.toml index 9d82b3e..24a3483 100644 --- a/netlify.toml +++ b/netlify.toml @@ -15,8 +15,10 @@ # Turbopack's build cache under .netlify/.next/cache records resolved # process.env reads, so the secrets scanner finds real values there. That # cache is never served — only the deployed output is — so omit the path - # rather than the keys, which would blind the scan everywhere else. - SECRETS_SCAN_OMIT_PATHS = ".netlify/.next/cache/**" + # rather than the keys, which would blind the scan everywhere else. The + # example's own node_modules holds its pinned @base44/platform 0.1.0 (the root + # is on 0.3.0, outside its range); installed packages are never served either. + SECRETS_SCAN_OMIT_PATHS = ".netlify/.next/cache/**,examples/white-label-minimal/node_modules/**" # Preview contexts build but never migrate. # diff --git a/package-lock.json b/package-lock.json index 33b36b2..59375e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "examples/white-label-minimal" ], "dependencies": { + "@base44/platform": "^0.3.0", "@hello-pangea/dnd": "^18.0.1", "@prisma/client": "^6.19.3", "@radix-ui/react-checkbox": "^1.1.4", @@ -108,6 +109,15 @@ "node": "^22 || ^24 || >=26" } }, + "examples/white-label-minimal/node_modules/@base44/platform": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.1.0.tgz", + "integrity": "sha512-6InHb3mM2RPLqU42atFprwkkjR1Osu0CFQpAmloD1sAv7EfDg0h4rGMuUtSB5EZJLJiz4f6DaQ44dP/lR7mjtQ==", + "license": "MIT", + "dependencies": { + "socket.io-client": "^4.8.3" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -565,11 +575,23 @@ } }, "node_modules/@base44/platform": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.1.0.tgz", - "integrity": "sha512-6InHb3mM2RPLqU42atFprwkkjR1Osu0CFQpAmloD1sAv7EfDg0h4rGMuUtSB5EZJLJiz4f6DaQ44dP/lR7mjtQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@base44/platform/-/platform-0.3.0.tgz", + "integrity": "sha512-BlBWUIEdYLH0po4LFMzKPo1mIOKUv8bo9z3LmyABSEv/2AXGP/fIN4KxZl5DBYGlgnVf8h3aQeY36XT4eHnWVQ==", + "license": "MIT", "dependencies": { "socket.io-client": "^4.8.3" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "node_modules/@base44/white-label-minimal": { @@ -2411,23 +2433,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "playwright": "1.62.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@prisma/client": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", @@ -11217,55 +11222,6 @@ "node": ">=4" } }, - "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "playwright-core": "1.62.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 853f8d9..e099722 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "minimal:typecheck": "npm run typecheck --workspace @base44/white-label-minimal" }, "dependencies": { + "@base44/platform": "^0.3.0", "@hello-pangea/dnd": "^18.0.1", "@prisma/client": "^6.19.3", "@radix-ui/react-checkbox": "^1.1.4", diff --git a/scripts/webhook-smoke.ts b/scripts/webhook-smoke.ts index 3197fc1..52bab40 100644 --- a/scripts/webhook-smoke.ts +++ b/scripts/webhook-smoke.ts @@ -4,15 +4,18 @@ * npm run webhook:smoke * * Unlike the other smoke suites this one needs no dev server and no database: - * it mints its own Ed25519 keypair, pins its public half, and drives - * `verifyWebhook` directly. So it is safe to run anywhere, and it is the check - * worth running after touching anything in src/lib/base44WebhookSignature.ts. + * it mints its own Ed25519 keypair, pins its public half, and drives the + * `@base44/platform/server` verifier the receiver uses, wired from this + * deployment's config by src/lib/base44Webhooks.ts. So it is safe to run + * anywhere, and it is the check worth running after touching that module or + * bumping the SDK. * * The negative controls are the point. "A genuine signature verifies" passes * just as happily against a verifier that returns true unconditionally — what * makes the suite mean something is that a tampered body, a relabelled event id, * an unknown key and a stale timestamp are each refused, and refused for the - * stated reason. + * stated reason. The handler checks then pin the status codes, which are + * instructions to Base44: 400 is refused, 500 is retried, 2xx is done. * * `fetch` is replaced with one that fails the suite: verification is local by * design, and a verifier that quietly reached the network would pass every @@ -21,8 +24,10 @@ import crypto from "node:crypto"; +import { WebhookConfigurationError, WebhookVerificationError } from "@base44/platform/server"; + import { MissingConfigError, orgId } from "../src/lib/base44Config"; -import { verifyWebhook } from "../src/lib/base44WebhookSignature"; +import { verifyOptions, webhooks } from "../src/lib/base44Webhooks"; type Signer = { privateKey: crypto.KeyObject; wire: string }; @@ -56,13 +61,13 @@ const BODY = JSON.stringify({ }); function signedHeaders( - opts: { id?: string; ts?: number; signers?: Signer[]; scheme?: string } = {}, + opts: { id?: string; ts?: number; signers?: Signer[]; scheme?: string; body?: string } = {}, ): Headers { const id = opts.id ?? "evt_smoke"; const ts = opts.ts ?? Math.floor(Date.now() / 1000); const payload = Buffer.concat([ Buffer.from(`${id}.${ts}.`, "utf8"), - Buffer.from(BODY, "utf8"), + Buffer.from(opts.body ?? BODY, "utf8"), ]); const entries = (opts.signers ?? [known]).map( (signer) => @@ -77,18 +82,23 @@ function signedHeaders( let failed = 0; -function expect(name: string, want: true | string, headers: Headers, body = BODY) { - const result = verifyWebhook(headers, body); - const got = result.ok ? "ok" : result.reason; - const pass = want === true ? result.ok : got === want; +async function expect(name: string, want: true | string, headers: Headers, body = BODY) { + let got: string; + try { + await webhooks.verifyEvent(body, headers, verifyOptions()); + got = "ok"; + } catch (err) { + got = err instanceof WebhookVerificationError ? err.code : String(err); + } + const pass = got === (want === true ? "ok" : want); if (!pass) failed += 1; console.log(`${pass ? " ok " : " FAIL "} ${name} -> ${got}`); } -function throws(name: string, run: () => unknown, match: (err: unknown) => boolean = () => true) { +async function throws(name: string, run: () => unknown, match: (err: unknown) => boolean) { let pass = false; try { - run(); + await run(); } catch (err) { pass = match(err); } @@ -96,12 +106,21 @@ function throws(name: string, run: () => unknown, match: (err: unknown) => boole console.log(`${pass ? " ok " : " FAIL "} ${name}`); } -function main() { +async function status(name: string, want: number, body: string, headers: Headers, check?: (res: Response) => Promise) { + const res = await webhooks.createHandler(verifyOptions())( + new Request("https://shell.example.com/api/base44/webhooks", { method: "POST", headers, body }), + ); + const pass = res.status === want && (!check || (await check(res))); + if (!pass) failed += 1; + console.log(`${pass ? " ok " : " FAIL "} ${name} -> ${res.status}`); +} + +async function main() { console.log("boundary 5: inbound webhook signature\n"); - expect("a genuine signature verifies", true, signedHeaders()); + await expect("a genuine signature verifies", true, signedHeaders()); - expect( + await expect( "a tampered body is refused", "signature_mismatch", signedHeaders(), @@ -112,33 +131,33 @@ function main() { // relabel a captured delivery so it reads as a new event to de-duplication. const relabelled = new Headers(signedHeaders({ id: "evt_captured" })); relabelled.set("webhook-id", "evt_replayed"); - expect("a relabelled replay is refused", "signature_mismatch", relabelled); + await expect("a relabelled replay is refused", "signature_mismatch", relabelled); - expect( + await expect( "a signature from an unpinned key is refused", "signature_mismatch", signedHeaders({ signers: [stranger] }), ); - expect( + await expect( "a stale timestamp is refused even though it verifies", "timestamp_outside_tolerance", signedHeaders({ ts: Math.floor(Date.now() / 1000) - 400 }), ); - expect( + await expect( "missing headers are refused", "missing_headers", new Headers({ "webhook-id": "evt_smoke" }), ); - expect( + await expect( "an unknown signature scheme is refused", "no_signature_entries", signedHeaders({ scheme: "v1" }), ); - expect( + await expect( "a rotation header with two entries verifies on the known key", true, signedHeaders({ signers: [stranger, known] }), @@ -147,22 +166,46 @@ function main() { // Two pinned keys is how a rotation is survived: both are valid inside // Base44's overlap window, and either may be the one a delivery is signed with. process.env.BASE44_WEBHOOK_PUBLIC_KEYS = `${stranger.wire} ${known.wire}`; - expect("both keys of a pinned rotation verify", true, signedHeaders()); - expect("the incoming key of a rotation verifies too", true, signedHeaders({ signers: [stranger] })); + await expect("both keys of a pinned rotation verify", true, signedHeaders()); + await expect("the incoming key of a rotation verifies too", true, signedHeaders({ signers: [stranger] })); + + // The handler the route mounts: the status code is what Base44 acts on. + await status("a genuine event is answered 204", 204, BODY, signedHeaders()); + await status( + "a tampered event is answered 400, not retried", + 400, + BODY.replace("app.created", "app.deleted"), + signedHeaders(), + ); + const PROBE = JSON.stringify({ + specversion: "1.0", + id: "evt_probe", + type: "webhook.test.v1", + data: { endpoint_id: "whe_smoke", workspace_id: orgId(), sent_at: new Date().toISOString(), challenge: "chl_smoke" }, + }); + await status( + "the activation probe echoes its challenge", + 200, + PROBE, + signedHeaders({ id: "evt_probe", body: PROBE }), + async (res) => (await res.json()).challenge === "chl_smoke", + ); // A key truncated by a copy-paste must fail loudly. Silently verifying // nothing would look exactly like every event being forged. process.env.BASE44_WEBHOOK_PUBLIC_KEYS = known.wire.slice(0, -8); - throws("a truncated pinned key throws rather than verifying nothing", () => - verifyWebhook(signedHeaders(), BODY), + await throws( + "a truncated pinned key throws rather than verifying nothing", + () => webhooks.verifyEvent(BODY, signedHeaders(), verifyOptions()), + (err) => err instanceof WebhookConfigurationError && err.code === "invalid_public_key", ); // No key at all is a deployment that cannot verify anything: the route turns // this into a 500, so Base44 keeps the event until the key is deployed. delete process.env.BASE44_WEBHOOK_PUBLIC_KEYS; - throws( + await throws( "no pinned key throws MissingConfigError", - () => verifyWebhook(signedHeaders(), BODY), + () => webhooks.verifyEvent(BODY, signedHeaders(), verifyOptions()), (err) => err instanceof MissingConfigError && err.variable === "BASE44_WEBHOOK_PUBLIC_KEYS", ); diff --git a/src/app/api/base44/webhooks/route.ts b/src/app/api/base44/webhooks/route.ts index 57c3d33..c6130fb 100644 --- a/src/app/api/base44/webhooks/route.ts +++ b/src/app/api/base44/webhooks/route.ts @@ -4,20 +4,22 @@ * renders from its own stored url, and `listApps` never reports a deletion. * * Status codes are instructions to Base44: non-2xx is retried (8 attempts over - * ~27h) and enough failures auto-pause the endpoint, so unhandled types get 2xx. + * ~27h) and enough failures auto-pause the endpoint. The SDK's handler picks + * them: 400 for a delivery that fails verification, 500 for missing keys or a + * handler that threw, 204 otherwise, including event types with no handler. It + * answers the `webhook.test.v1` activation probe itself, after verifying it. */ +import { WebhookVerificationError } from "@base44/platform/server"; import { NextResponse } from "next/server"; import { ownersOf } from "@/lib/appOwnership"; -import { verifyWebhook } from "@/lib/base44WebhookSignature"; +import { verifyOptions, webhooks } from "@/lib/base44Webhooks"; import { deleteEntity, listEntities } from "@/lib/entityCrud"; -// node:crypto for Ed25519, so not the edge runtime. +// Prisma, so not the edge runtime. export const runtime = "nodejs"; -type CloudEvent = { type: string; data: Record }; - /** * The event names the app; `AppOwnership` says whose it is. Upstream every app has * the same owner — the integration account — so nothing in the payload could name @@ -28,8 +30,7 @@ type CloudEvent = { type: string; data: Record }; * an on-time one, since a restore never puts pins back. `AppOwnership` stays — * a trashed app already drops out of `listApps`, and a restored one comes back. */ -async function removePins(appId: unknown) { - if (typeof appId !== "string") return; +async function removePins(appId: string) { // Empty for an app built by another tool in the same workspace: nothing of ours to remove. for (const email of await ownersOf(appId)) { // Scoped through the RLS predicate to one owner at a time. @@ -41,26 +42,23 @@ async function removePins(appId: unknown) { } export async function POST(request: Request) { - const rawBody = await request.text(); - + let options; try { - const verified = verifyWebhook(request.headers, rawBody); - if (!verified.ok) { - console.warn("[base44-webhook] rejected:", verified.reason); - return NextResponse.json({ error: "invalid_signature" }, { status: 400 }); - } + options = verifyOptions(); } catch (err) { console.error("[base44-webhook] cannot verify:", err instanceof Error ? err.message : err); return NextResponse.json({ error: "receiver_misconfigured" }, { status: 500 }); } - const event = JSON.parse(rawBody) as CloudEvent; - // Activation: echo the challenge, only after the signature verified. - if (event.type === "webhook.test.v1") { - return NextResponse.json({ challenge: event.data.challenge }); - } - if (event.type === "app.deleted.v1") { - await removePins(event.data.app_id); - } - return NextResponse.json({ ok: true }); + const handle = webhooks.createHandler({ + ...options, + on: { + "app.deleted.v1": (event) => removePins(event.data.app_id), + }, + onError: (err) => { + if (err instanceof WebhookVerificationError) console.warn("[base44-webhook] rejected:", err.code); + else console.error("[base44-webhook] failed:", err instanceof Error ? err.message : err); + }, + }); + return handle(request); } diff --git a/src/lib/base44Config.ts b/src/lib/base44Config.ts index 1008a32..9ad1860 100644 --- a/src/lib/base44Config.ts +++ b/src/lib/base44Config.ts @@ -55,7 +55,9 @@ export const accessToken = () => required("BASE44_ACCESS_TOKEN"); * src/lib/embedSession.ts) and register the outbound webhook endpoint. Sent as * `api_key`, never `Bearer`. * - * Optional: without it the builder still works, and embedded apps load signed out. + * Optional: without it the builder still works and embedded apps load signed out, + * but the webhook receiver cannot start — the `@base44/platform` client it + * verifies with requires a key, though verifying never sends it. */ export const svcKey = () => required("BASE44_SVC_KEY"); @@ -79,7 +81,7 @@ export const platformHost = () => required("BASE44_PLATFORM_HOST").replace(/\/+$ * * `whpk_`-prefixed, whitespace- or comma-separated, printed by * `npm run webhook:register`. Required by the receiver: it verifies against - * these and nothing else — see src/lib/base44WebhookSignature.ts. + * these and nothing else — see src/lib/base44Webhooks.ts. * * **The one BASE44_* value that is not a secret.** It is a public key: it * verifies signatures and cannot produce them, so leaking it buys nothing. It diff --git a/src/lib/base44WebhookSignature.ts b/src/lib/base44WebhookSignature.ts deleted file mode 100644 index 023f09c..0000000 --- a/src/lib/base44WebhookSignature.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Verifies an inbound Base44 webhook: Standard Webhooks `v1a`, Ed25519. - * - * Nothing in a request is trustworthy until this returns ok — the URL is public. - * Verify the raw body text, never a re-serialized one: the signature covers the - * exact bytes. The header is a list, one entry per active key during a rotation. - */ - -import crypto from "node:crypto"; - -import { webhookPublicKeys } from "@/lib/base44Config"; - -const TOLERANCE_SECONDS = 300; -// DER SPKI header for a raw 32-byte Ed25519 public key. -const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); - -export type VerificationResult = - | { ok: true } - | { - ok: false; - reason: "missing_headers" | "timestamp_outside_tolerance" | "no_signature_entries" | "signature_mismatch"; - }; - -function toKey(wire: string): crypto.KeyObject { - const raw = Buffer.from(wire.replace(/^whpk_/, ""), "base64"); - // A truncated paste must throw: a silently wrong key reads as every event being forged. - if (raw.length !== 32) throw new Error(`Base44 public key decoded to ${raw.length} bytes, expected 32`); - return crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: "der", type: "spki" }); -} - -/** Throws when BASE44_WEBHOOK_PUBLIC_KEYS is missing or malformed — answer 500 so Base44 retries. */ -export function verifyWebhook(headers: Headers, rawBody: string): VerificationResult { - const id = headers.get("webhook-id"); - const timestamp = headers.get("webhook-timestamp"); - const header = headers.get("webhook-signature"); - if (!id || !timestamp || !header) return { ok: false, reason: "missing_headers" }; - // Negated so a non-numeric timestamp (NaN) is refused too. - if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= TOLERANCE_SECONDS)) { - return { ok: false, reason: "timestamp_outside_tolerance" }; - } - - const signatures = header - .split(" ") - .filter((entry) => entry.startsWith("v1a,")) - .map((entry) => Buffer.from(entry.slice(4), "base64")); - if (signatures.length === 0) return { ok: false, reason: "no_signature_entries" }; - - const keys = webhookPublicKeys().map(toKey); - const payload = Buffer.from(`${id}.${timestamp}.${rawBody}`, "utf8"); - const verified = signatures.some((signature) => keys.some((key) => crypto.verify(null, payload, key, signature))); - return verified ? { ok: true } : { ok: false, reason: "signature_mismatch" }; -} diff --git a/src/lib/base44Webhooks.ts b/src/lib/base44Webhooks.ts new file mode 100644 index 0000000..f9192fc --- /dev/null +++ b/src/lib/base44Webhooks.ts @@ -0,0 +1,24 @@ +/** + * Inbound Base44 webhooks, verified by `@base44/platform/server`. + * + * Nothing in a request is trustworthy until the SDK verifies it — the URL is + * public. It checks Standard Webhooks `v1a` (Ed25519) over the raw body, against + * the keys pinned in BASE44_WEBHOOK_PUBLIC_KEYS and nothing else, so it makes no + * network call. It parses nothing before the signature passes. + */ + +import { createPlatformServer, type VerifyEventOptions } from "@base44/platform/server"; + +import { webhookPublicKeys } from "@/lib/base44Config"; + +export const webhooks = createPlatformServer().webhooks; + +/** + * Read per call, so a rotated key deploys without a code change. Throws + * MissingConfigError when the keys are unset, and the SDK throws + * WebhookConfigurationError for a malformed one. Both mean nothing can be + * verified, so the receiver answers 500 and Base44 retries. + */ +export function verifyOptions(): VerifyEventOptions { + return { publicKeys: webhookPublicKeys() }; +}