Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

---

Expand Down Expand Up @@ -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.
Expand Down
17 changes: 11 additions & 6 deletions docs/base44-webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 + "." + <raw request body>
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions examples/white-label-minimal/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
6 changes: 4 additions & 2 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
94 changes: 25 additions & 69 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading