diff --git a/.env.example b/.env.example index ff9c9a4..a088c72 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ NEXTAUTH_SECRET="" # The deployment origin. Setting it *overrides* the origin NextAuth reads off the # request, so scope it to production only — a preview deploy that inherited # production's value would believe it is production and sign the user in there. -# Previews leave it unset and take their host from the request. See docs/deploy.md. +# Previews leave it unset and take their host from the request. See README.md → Deploy. NEXTAUTH_URL="http://localhost:3000" # Google OAuth client (Web application). Authorized redirect URI: # $NEXTAUTH_URL/api/auth/callback/google @@ -34,11 +34,11 @@ AUTH_REDIRECT_PROXY_URL="" # alias by reading Netlify's DEPLOY_PRIME_URL (baked at build time by next.config.ts), # because the request host a preview sees is the deploy permalink, not the alias its # users are on. Setting AUTH_URL yourself overrides that and takes precedence over -# NEXTAUTH_URL. See docs/deploy.md → Preview deployments. +# NEXTAUTH_URL. See README.md → Deploy. # AUTH_URL="" # --- Base44 app-factory bridge, server-only --- -# Without these the app runs fine; only the builder is unavailable, and it says +# Without these the app runs fine; only building apps is unavailable, and the UI says # so (the bridge answers 501 bridge_misconfigured). # # One Base44 account sits behind the whole integration: it owns every app your diff --git a/.npmrc b/.npmrc index d614eee..bbad20e 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ # Every dependency here is public and package-lock.json resolves them from npmjs, # so CI can install. Pinned because a developer machine's global ~/.npmrc may point # at a private registry, which npm would substitute for the lockfile host and then -# fail on. See docs/deploy.md. +# fail on. registry=https://registry.npmjs.org diff --git a/CLAUDE.md b/CLAUDE.md index 1cf2c04..0be6faf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,10 +3,10 @@ Reference implementation of a **white-label platform built on Base44**. Base44 is the backend and app-factory; this repo is the shell a company wraps around it. **Sunny** is the worked example — a work-management product (boards / groups / items / widgets) whose users can build their own apps via -Base44's builder, embedded here. +Base44, from a chat panel inside the product. -Read `README.md` first: it is the walkthrough, organised around the four boundaries this repo exists -to demonstrate. +Read `README.md` first: it lists the boundaries this repo exists to demonstrate and where each +lives in the code. ## What this is / isn't @@ -99,9 +99,6 @@ needs neither — it mints its own keypair. ## Docs -- `docs/base44-identity.md` — the one-account model, the access token, and keeping builders apart -- `docs/base44-platform-api.md` — the platform REST endpoints -- `docs/base44-built-apps.md` — builder instructions, skills, and the callback API -- `docs/sunny-platform-skill.md` — the skill text a built app reads, as a worked example -- `docs/base44-webhooks.md` — inbound events: registration, signature, delivery semantics -- `docs/deploy.md` — Netlify + Neon +The integration guide is on the docs site, starting at +https://docs.base44.com/developers/white-label/overview. `src/lib/sunny-platform-skill.md` is the +skill text a built app reads, installed in the Base44 workspace; `/api/sunny` is held to it. diff --git a/PREVIEW-DEPLOYS.md b/PREVIEW-DEPLOYS.md index 366a0ea..7d391a2 100644 --- a/PREVIEW-DEPLOYS.md +++ b/PREVIEW-DEPLOYS.md @@ -1,7 +1,7 @@ # Deploy previews: what this branch does, and what is still missing -Working note for the `feat/deploy-preview-auth` branch. The durable reference is -[`docs/deploy.md`](docs/deploy.md) → *Preview deployments*; this file is the status of the change +Working note for the `feat/deploy-preview-auth` branch. The deploy steps are +in the [README](README.md) → *Deploy*; this file is the status of the change and the list of things a human with Netlify, Neon and Google access still has to do. **State:** the code side is done and verified locally. The platform side — Netlify env vars, a @@ -30,7 +30,7 @@ A Netlify deploy preview gets a fresh URL per pull request | `scripts/auth-smoke.ts`, `base44-live-check.ts`, `base44-smoke.ts`, `entity-api-smoke.ts`, `marketplace-smoke.ts`, `sunny-api-smoke.ts` | Mint through that helper instead of calling `encode()` with the v4 cookie name | | `netlify.toml` | `[context.deploy-preview]` and `[context.branch-deploy]` build with `npm run build` — the database-free build — so no pull request can run a migration | | `next.config.ts`, `src/lib/auth.ts` | Pin a preview's origin to Netlify's `DEPLOY_PRIME_URL` (the deploy *alias*) instead of deriving it from the request host, which Netlify fills with the deploy *permalink*. `next.config.ts` bakes the value in because the function's runtime env does not carry Netlify's build variables | -| `.env.example`, `docs/deploy.md` | `AUTH_REDIRECT_PROXY_URL`, the shared-secret requirement, why `NEXTAUTH_URL` must be production-only, and the Neon-branch story | +| `.env.example`, `README.md` | `AUTH_REDIRECT_PROXY_URL`, the shared-secret requirement, why `NEXTAUTH_URL` must be production-only, and the Neon-branch story | Nothing about the app's own behaviour changed: same session shape, same `/api/auth/callback/google` path, so **the existing Google OAuth client needs no new entry**. @@ -164,11 +164,11 @@ curl -s -D- -o /dev/null -b /tmp/j -X POST --data "csrfToken=$T" "$P/api/auth/si that secret. If that is not acceptable, the alternative is a second Netlify site plus a second Google client acting as its own proxy for previews, at the cost of a separate secret to rotate. - **Built apps in a preview still call production's `/api/sunny`.** The `sunny-platform` skill - lives in the Base44 workspace and hardcodes one host (`docs/sunny-platform-skill.md`, + lives in the Base44 workspace and hardcodes one host (`src/lib/sunny-platform-skill.md`, ``), so an app in a preview reads and writes **production** rows — scoped to its viewer, but production data all the same. Making it follow the deployment means a - per-environment skill, or teaching the builder instructions to inject the shell's own origin. -- **Base44 credentials on previews or not.** Without them a preview says the builder is not + per-environment skill, or having `builderInstructions.ts` put the shell's own origin into the custom instructions. +- **Base44 credentials on previews or not.** Without them a preview says app building is not configured and everything else works. With them, previews share the production account and workspace, so apps built in a preview land in production's workspace — and their `AppOwnership` rows land in the preview's database branch, so production will not list them. Nothing per-user diff --git a/README.md b/README.md index 3767021..4fa75dd 100644 --- a/README.md +++ b/README.md @@ -1,323 +1,62 @@ # Build your own platform on Base44 **Base44** is an AI app builder: describe an app, get a real, deployed app. This repo shows how to -put that builder *inside your own product*, so your users build apps under your brand, your login -and your governance — and those apps can read and write your product's data. +put that inside *your own product*, so your users build apps under your brand, your login and your +governance, and those apps can read and write your product's data. -This README is a walkthrough. It has four steps, each one a boundary you have to get right, with -the working code in this repo next to it. Everything here is generic; **Sunny**, the -work-management app you'll see in the code, is just the example product being extended. +The integration guide is on the docs site: +[White label overview](https://docs.base44.com/developers/white-label/overview). This repo is the +code that goes with it, in two sizes. -This repository contains two references: +## Tiny Sunny and Sunny -- [`examples/white-label-minimal/`](examples/white-label-minimal/): the focused, local shared-account guide companion. Start here to learn the build loop without Sunny's database or sign-in. -- [`src/`](src/): the full Sunny product and the production-oriented integration described below — one Base44 account, with the shell keeping its builders apart. +- **[Tiny Sunny](examples/white-label-minimal/)** is the minimal version. A chat where you describe + an app, watch Base44 build it, answer its questions and see it in a preview. No database and no + sign-in. Start here to learn the build loop. +- **Sunny** (`src/`) is the full version: a work-management product (boards, groups, items, + widgets) with its own sign-in and Postgres database, whose users build apps that are embedded back + into it and read Sunny's data. It shows what production needs on top of the build loop. Live at + . -Live example: - ---- - -## The shape of the thing +## How Sunny is put together ``` YOUR PRODUCT (this repo) BASE44 ───────────────────────── ────── your users, your DB, your login │ - │ 1. one integration account, its access - │ token in env, server-side only - │ (no Base44 identity per user at all) + │ one integration account, its access + │ token in env, server-side only │ - │ 2. user describes an app + │ user describes an app ├──────────────────────────────────► createApp → build → deploy - │ (server-side allow-list + the app is owned by *the account*, - │ "is this app yours?" check) inside your workspace; *your* DB - │ records which user built it + │ (server-side allow-list + the app is owned by the account; + │ "is this app yours?" check) your DB records which user built it │ - │ 3. the built app runs + │ the built app runs │ ◄──────────────────────────────── it calls back into your data API - │ POST /api/sunny with a shared token, over CORS + │ POST /api/sunny with a viewer token, over CORS + │ + │ ◄──────────────────────────────── signed webhooks (app.deleted.v1) ``` -Five boundaries, five steps — four calling out to Base44, one where it calls back in: - -| Step | Boundary | Code | +| Boundary | Code | Docs | | --- | --- | --- | -| [1](#step-1--your-product-owns-its-users-and-data) | Your own auth and database. Your shell is **not** a Base44 app. | `src/lib/auth.ts`, `prisma/schema.prisma`, `src/lib/rls.ts` | -| [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/base44Webhooks.ts` | - ---- - -## Before you start - -You need, from Base44: - -| What | Why | -| --- | --- | -| A **white-label workspace** on the Builder plan or higher, used by this platform alone | All your users' apps live in it, so you have one place to govern, and listing apps means listing it | -| Its **workspace id** | Sent as `X-Active-Workspace-Id` on every platform call | -| An **integration account** with Editor or higher in that workspace, and its **personal access token** created *in* that workspace, with Full access to all apps | The one identity that builds everything — see [step 2](#step-2--one-base44-account-and-you-keep-your-users-apart) | -| Optionally a **workspace API key** (`b44k_…`), on a Business plan or higher | Registers the outbound webhook endpoint. Nothing else needs it | -| The **platform host** your workspace is served from | Base of every REST call | - -Those land in env as `BASE44_ACCESS_TOKEN`, `BASE44_ORG_ID`, `BASE44_PLATFORM_HOST` and -optionally `BASE44_SVC_KEY` (see `.env.example`). All server-only — -none of them may ever reach the browser. - ---- - -## Step 1 — Your product owns its users and data - -The first decision is the one people get wrong: **your platform shell is not a Base44 app.** It has -its own login, its own database, and it never uses `@base44/sdk` for its own data. Base44 is a -service you call, not the substrate you live in. - -In this repo that means NextAuth (Google) for sessions and Postgres + Prisma for data: - -```ts -// src/lib/auth.ts — the session, turned into an actor -const actor = await requireSessionUser(); // { email, role } — 401s if not signed in -``` - -Because the shell owns its data, it also owns row-level security. Every user-owned query goes -through one helper, and one module is allowed to run those queries: - -```ts -// src/lib/rls.ts -export const scopedWhere = (actor) => ({ createdBy: actor.email }); -``` - -No role widens that predicate — an `admin` is an ordinary reader of their own rows, so nobody's -dashboard fills up with someone else's boards, apps or widgets. - -Sharing, when you want it, is a property of the **row**, never of the caller. A board carries a -`visibility` flag, so reads use a second predicate built from the first: - -```ts -// src/lib/rls.ts — reads only -export const readWhere = (actor, model) => - model === "Board" ? { OR: [scopedWhere(actor), { visibility: "shared" }] } : scopedWhere(actor); -``` - -Writes stay on `scopedWhere()`. A shared board is readable by the workspace and writable by its -owner alone, which is why the two helpers exist instead of one: widening the owner predicate itself -would have made every shared board editable by everyone who could see it. - -That's the whole trick, and it's the single biggest correctness risk in a design like this — a -missing predicate is a data leak. This repo pins it down with an ESLint rule that bans by-id -`update`/`delete` (those can't carry an owner predicate) and a smoke test (`npm run rls:smoke`). - -**Takeaway:** decide up front that Base44 is downstream of your auth, not the other way around. - ---- - -## Step 2 — One Base44 account, and you keep your users apart - -Now the interesting part. When your user builds an app, **who owns it?** - -On Base44's side the answer is simple, and it is the model Base44 documents for white-label -integrations: **one Base44 account sits behind your whole integration.** It owns every app your -users build, and its personal access token authenticates every call you make. Your users have no -Base44 identity at all — Base44 sees one builder. - -```ts -// src/lib/base44Config.ts — the whole credential story -export const accessToken = () => required("BASE44_ACCESS_TOKEN"); // the account's PAT -export const orgId = () => required("BASE44_ORG_ID"); // the workspace it builds in -``` - -Two things about that token: create it **in your white-label workspace**, not the account's -personal one (or builds miss the design system, skills and plan you set up there), and give the -account **Editor or higher** — viewers and guests can't create apps, and deploy succeeds only for an -Editor. - -What that model hands *you* is the part people underestimate. Base44 puts it plainly: **keeping your -builders apart is yours to enforce.** Scoping each builder to their own apps, and hiding other -builders' apps, is entirely up to you. So the real work of step 2 is in your database: - -```ts -// src/lib/appOwnership.ts — the only record of who built what -export async function ownsApp(actor, appId) // the proxy's gate for every app-scoped action -export async function ownedAppIds(actor) // listApps = the workspace ∩ this -export async function recordOwnership(actor, appId) // written by the proxy, inside createApp -``` - -Three rules worth copying: - -- **Check before every upstream call.** The proxy in step 3 refuses any action that names an app - the caller didn't build — a 404, before Base44 is ever asked. Upstream the token owns every app, - so nothing there will stop a user driving someone else's. -- **Write ownership server-side, in the request that creates the app.** Never from the browser. A - client that could insert the row could claim any app in the workspace, so the generic entity API - refuses to create or update one. -- **No admin bypass.** Ownership matches on the caller's own email, like installs do. Reading another - user's rows through an admin session is one thing; handing them the controls of another user's - app is another. - -There's no "Connect" step, no per-user token to refresh, and offboarding a user is deleting their -rows: their apps stay in the account, and what they lose is the rows that let them reach those apps -through you. The same token signs a viewer into an embedded app. The workspace API key -(`BASE44_SVC_KEY`) is only for registering webhooks, which need a Business plan or higher. - -→ The model in full, the failure classes, and the containment rules: -**[docs/base44-identity.md](docs/base44-identity.md)** · Base44's own description: -[Tenancy and credentials](https://docs.base44.com/developers/white-label/tenancy-and-credentials) - ---- - -## Step 3 — Call Base44 from your server, behind an allow-list - -Your frontend must never hold a Base44 credential. So it calls *you*, and you call Base44: - -``` -browser → POST /api/base44/platform {action, …params} → your server → Base44 REST -``` - -`src/app/api/base44/platform/route.ts` is that proxy. Its design is a single table of allowed -actions — the caller names an action, never a URL: - -```ts -const OPS = { - listApps: { method: "GET", path: (p) => `/api/apps?…filter_mode=all_apps_workspace` }, - createApp: { method: "POST", path: () => "/api/apps", body: (p) => ({ … }) }, - sendMessage:{ method: "POST", path: (p) => `/api/apps/${p.appId}/chat/message`, … }, - … -}; -``` +| Your own auth and database. The shell is **not** a Base44 app, and it owns row-level security. | `src/lib/auth.ts`, `prisma/schema.prisma`, `src/lib/rls.ts` | | +| One Base44 account for the whole platform, and you keep your users apart. | `src/lib/base44Config.ts`, `src/lib/appOwnership.ts` | [Tenancy and credentials](https://docs.base44.com/developers/white-label/tenancy-and-credentials) | +| A server-side proxy with an allow-list of actions in front of Base44's REST API. | `src/app/api/base44/platform/route.ts` | [The build turn](https://docs.base44.com/developers/white-label/the-build-turn), [Common APIs](https://docs.base44.com/developers/white-label/common-apis) | +| Custom instructions and a skill that teach built apps about your data. | `src/lib/builderInstructions.ts`, `src/lib/sunny-platform-skill.md` | [Custom instructions](https://docs.base44.com/developers/white-label/custom-instructions), [Skills and MCP](https://docs.base44.com/developers/white-label/skills-and-mcp) | +| A public API the built apps call, as the person viewing them. | `src/app/api/sunny/route.ts`, `src/lib/embedSession.ts` | [Embed the app](https://docs.base44.com/developers/white-label/embed-the-app) | +| Signed inbound webhooks, so you learn about deleted apps. | `src/app/api/base44/webhooks/route.ts`, `src/lib/base44Webhooks.ts` | [Workspace webhooks](https://docs.base44.com/Enterprise/workspace-webhooks) | -Nine actions, and that's the whole surface. Why an allow-list and not a passthrough: the access -token can do anything the integration account can, to every app it owns, and Base44 enforces no -scopes on this REST surface. **Your allow-list is the actual limit.** Never let a caller supply a -path, a host, or a workspace id. +[CLAUDE.md](CLAUDE.md) has the conventions this repo holds itself to and where each boundary is +enforced. -Three things every request carries: +## Run it -```ts -headers: { - Authorization: `Bearer ${accessToken()}`, // who: the integration account, always - "X-Active-Workspace-Id": orgId(), // where: your governed workspace - "Content-Type": "application/json", -} -``` - -And because *who* is the same for every user, the proxy decides *whose* before it sends anything: - -```ts -if (!(await ownsEveryApp(actor, action, params))) return notFound(action); // 404, pre-upstream -``` - -Two more things people get bitten by: - -- **Timeouts.** `createApp`, `sendMessage` and `deployApp` block on an LLM build turn — ~30s is - normal. A 30s default timeout aborts working builds and blames the upstream. This repo uses 120s - for those actions and 30s for everything else. -- **Validate ids.** Anything interpolated into a path is checked against `/^[A-Za-z0-9_-]+$/`, or a - caller can escape the allow-listed path shape. - -→ Every endpoint, body, response and failure mode: **[docs/base44-platform-api.md](docs/base44-platform-api.md)** - ---- - -## Step 4 — Build an app, and let it talk to your data - -### 4a. Building - -Creating an app is one call from the browser (`src/lib/base44Platform.ts`), and one write the -server makes on its own: - -```ts -const app = await createApp({ prompt, name, customInstructions }); // create + first build turn - // (server records ownership here) -``` - -`custom_instructions` and `initial_message` both go in the **create** body. `initial_message` -starts the first build inside that same call, so patching instructions afterwards is too late. And -the server writes the `AppOwnership` row in that same request — never the browser, which could -otherwise claim any app. - -Then `getPreviewUrl` for an iframe preview (the preview token has a 300s TTL — never cache it) and -`deployApp` to publish. - -### 4b. Teaching the app about your data - -A built app doesn't know your product exists. Two mechanisms, and the split matters because -**every build turn pays for the instructions**: - -- **`custom_instructions`** (`src/lib/builderInstructions.ts`) — short, always loaded. Describes the - runtime (embedded in a sandboxed iframe, no login, short viewport) and *routes*: "load the - `sunny-platform` skill before writing code that touches this data." -- **A Base44 skill** (`docs/sunny-platform-skill.md`) — long, loaded on demand. The endpoint, - actions, schemas and gotchas. - -### 4c. The callback API - -The built app runs on its own Base44 origin with no session in your product. So your data API is -cross-origin and cookie-less, so the request has to carry its own identity. - -``` -POST https://your-host/api/sunny -Content-Type: application/json -Authorization: Bearer -{ "action": "listBoards" } -``` - -It has no actor of its own, so it must not reuse your session-based CRUD module, and must withhold -owner emails from responses. It gets an actor from a **viewer token**: the page embedding the app -mints one for whoever is signed in and posts it to the frame, and the app sends it as a bearer -token. That's what makes an installed app answer with the installer's rows rather than its author's. -Feed that subject the same predicates the rest of the product uses — the read one for reads, the -owner one for writes — and an app sees exactly what its viewer could already open in your UI, shared -rows included, while still writing only their own. -Treat the contract as frozen once apps are built against it: they're deployed code you don't -control. - -→ Instructions, skills, the callback contract and CORS: **[docs/base44-built-apps.md](docs/base44-built-apps.md)** - ---- - -## Step 5 — Let Base44 tell you what happened - -Steps 1–4 are all your shell calling out. This is the one direction where Base44 calls in, and the -reason it has to exist is deletion: `listApps` cannot report one — a trashed app just stops -appearing. That fixes the apps list on its own, but a pinned widget renders from its own stored URL -and is in no list, so without the event it frames a missing app indefinitely. - -The receiver handles one event, `app.deleted.v1`: it verifies the signature, looks up who built -`app_id` in your ownership table from step 2 — nothing in the payload names your user, since -upstream every app belongs to the one account — and deletes those users' pins for the app. Deleting is idempotent and a restore never brings pins back, -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`. `@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. - -Register once per environment: - -```bash -npm run webhook:register -- --url https://your-shell.example.com -``` - -It prints `BASE44_WEBHOOK_PUBLIC_KEYS` (a public key, not a secret). Registering mints the -workspace's first key, so on a first run the probe can't verify yet — deploy the key, then finish -with `npm run webhook:register -- --activate `. - -```bash -npm run webhook:smoke # the signature, case by case — no server, no database -``` - -→ Delivery semantics and the retry ladder: **[docs/base44-webhooks.md](docs/base44-webhooks.md)** - ---- - -## Run this repo +You need a Base44 white-label workspace, its id and platform host, and an integration account with +Editor or higher in it whose personal access token was created in that workspace. The +[setup checklist](https://docs.base44.com/developers/white-label/setup-checklist) covers it. ```bash cp .env.example .env # Postgres, Google OAuth, and the BASE44_* vars @@ -326,50 +65,56 @@ npm run db:migrate npm run dev ``` -Without the `BASE44_*` variables everything works except the builder: the bridge answers -`501 bridge_misconfigured` and the UI says the builder is not configured. That's on purpose — you -can explore the product before you have a workspace. +Without the `BASE44_*` variables everything works except building apps: the bridge answers +`501 bridge_misconfigured` and the UI says so. You can explore the product before you have a +workspace. Checks, each one asserting a boundary above: ```bash npm run typecheck npm run lint -npm run rls:smoke # step 1: the owner predicate, including the traps -npm run auth:smoke # step 1: session → actor -npm run entities:smoke # step 1: whitelisting, scoping, wire shape -npm run base44:smoke # steps 2–3: token containment, allow-list, the ownership gate -npm run sunny:smoke # step 4: the public contract, action by action -npm run webhook:smoke # step 5: the inbound signature, negative controls +npm run rls:smoke # the owner predicate, including the traps +npm run auth:smoke # session → actor +npm run entities:smoke # whitelisting, scoping, wire shape +npm run base44:smoke # token containment, allow-list, the ownership gate +npm run sunny:smoke # the public contract, action by action +npm run webhook:smoke # the inbound signature, negative controls ``` +The smoke suites write throwaway rows to `DATABASE_URL`. Don't point them at a database you care +about. + +## Deploy + +It runs on free plans: **Netlify** for the app and **Neon** for Postgres. `netlify.toml` already +holds the build settings; its build command runs `prisma migrate deploy`, so a deploy moves the +schema forward. + +- **Database.** `DATABASE_URL` is Neon's pooled host, `DIRECT_URL` the unpooled one (used by + migrations). Never point both at the pooled host. +- **Google OAuth.** One client per environment, with origin `https://` and redirect URI + `https:///api/auth/callback/google`. +- **Env vars.** As in `.env.example`. Scope `NEXTAUTH_URL` to production only. +- **`/api/sunny` is public** once deployed. It only answers viewer tokens signed with + `NEXTAUTH_SECRET`, so that secret protects it too. + +Deploy previews need a redirect proxy for Google sign-in and their own Neon branch, so they never +migrate production. [PREVIEW-DEPLOYS.md](PREVIEW-DEPLOYS.md) has the setup and what's still open. + ## Troubleshooting | Symptom | Likely cause | | --- | --- | -| `501 bridge_misconfigured` | A `BASE44_*` env var is missing, or Base44 answered `401` to the integration account's token (revoked, expired). A deployment problem, not a user one — the UI says the builder is unavailable | +| `501 bridge_misconfigured` | A `BASE44_*` env var is missing, or Base44 answered `401` to the integration account's token (revoked, expired) | | `404 not_found` on an app-scoped action | The caller has no `AppOwnership` row for that app. Built before ownership was tracked? Insert the row | | `403` on deploy | The integration account isn't an Editor in the workspace | | Builds ignore your design system or skills | The token was created in the account's personal workspace. Create it in the white-label workspace | | `sendMessage` times out at ~30s | Your own timeout, not Base44's. Build turns need ~120s | -| A new app doesn't appear in the list | Its `AppOwnership` row was not recorded — the server logs this loudly at create | - -## Where to read next - -- **[docs/base44-identity.md](docs/base44-identity.md)** — the one-account model, the access token, - and keeping your builders apart -- **[docs/base44-platform-api.md](docs/base44-platform-api.md)** — the REST endpoints, verbatim -- **[docs/base44-built-apps.md](docs/base44-built-apps.md)** — instructions, skills, and the - callback API -- **[docs/sunny-platform-skill.md](docs/sunny-platform-skill.md)** — the skill text a built app - reads, as a worked example of documenting your data model for a builder -- [docs/deploy.md](docs/deploy.md) — deploying to Netlify + Neon -- [CLAUDE.md](CLAUDE.md) — the conventions this repo holds itself to, and where each boundary - is enforced in the code +| A new app doesn't appear in the list | Its `AppOwnership` row was not recorded. The server logs this at create | ## Stack Next.js (App Router) · TypeScript · Tailwind 4 · Postgres + Prisma · NextAuth (Google only) · deployed on Netlify with Neon. Platform infrastructure (`src/lib`, `src/app`) is strict TypeScript; -the example product UI (`src/components`, `src/views`) is JSX with relaxed lint — it's the example, -not the lesson. +the example product UI (`src/components`, `src/views`) is JSX with relaxed lint. diff --git a/docs/base44-built-apps.md b/docs/base44-built-apps.md deleted file mode 100644 index 257456e..0000000 --- a/docs/base44-built-apps.md +++ /dev/null @@ -1,216 +0,0 @@ -# Teaching built apps about your platform - -Step 4 of the [README](../README.md) walkthrough: how an app your user just built learns that your -product exists, and how it reads and writes your data. - -Reference implementation: `src/lib/builderInstructions.ts` (always-on instructions), -`docs/sunny-platform-skill.md` (the on-demand skill), `src/app/api/sunny/route.ts` (the callback -API). - ---- - -## The situation - -A freshly built Base44 app is a normal Base44 app. It has its own entities, its own origin, and no -idea your platform exists. If your user asks it for "a chart of my overdue tasks", it will happily -invent an empty local entity and render nothing. - -So you have to supply two things: **instructions** (how this app should behave, and where the real -data lives) and **an API** (the data itself). - ---- - -## Two instruction channels, and why they're separate - -Every builder turn pays for the always-on instructions, so they have to stay small. Base44 gives you -a second channel that's loaded on demand. - -| | `custom_instructions` | Skill | -| --- | --- | --- | -| Where | The app's `custom_instructions` field, set at create | A workspace skill document the builder loads by name | -| Cost | Every turn | Only when loaded | -| Length | Keep it a page | As long as it needs to be | -| Content | Runtime constraints, visual direction, **a router** | The endpoint, actions, schemas, gotchas, an example | - -### The always-on part - -`src/lib/builderInstructions.ts` covers three things: - -1. **Context** — what your product is, and that this app was generated from inside it to extend it. -2. **Runtime constraints** — the ones the builder can't discover. In this repo: embedded in a - sandboxed iframe (`allow-scripts allow-same-origin allow-forms allow-popups`), user-resizable and - short by default, so design for a small viewport that scales up; no top-level navigation, no - downloads, no full-screen; `public_without_login`, so no sign-in UI to build — but the data is - still per-user, and the token for the current viewer comes from the embedding page; plus a line - of visual direction so widgets look like they belong. -3. **A route to the skill** — with explicit trigger words, and an instruction for when it can't be - loaded: - -> Read it before writing code whenever the request mentions … board(s), item(s), task(s), status, -> due date, widget(s), dashboard — or asks to read, write, sync, import, export, summarize or chart -> the user's real work data. Indicative, not exhaustive: if it plausibly touches their boards, load -> it. -> -> Cannot load it? Say so and ask. Guessing the API yields an app whose every request is a 401, and -> no amount of retrying fixes it. - -That last line matters more than it looks. The failure mode you're designing against is a *silent* -one — an app that renders an empty state instead of an error. - -### The on-demand part - -The skill is where the real content goes: endpoint, auth header, every action with its parameters -and response shape, the data model, the gotchas, and a copy-pasteable example. This repo's text is -in [sunny-platform-skill.md](sunny-platform-skill.md). - -Keep the skill's `name` in the frontmatter and the name your instructions reference in sync — the -router is a literal string match. - ---- - -## The callback API - -The built app runs on its own Base44 origin, in a user's browser, with **no session in your -product**. That fact drives every design decision: - -``` -POST https://your-host/api/sunny -Content-Type: application/json -Authorization: Bearer - -{ "action": "listBoards", ...params } -``` - -### The app has no session — so the page embedding it lends one - -A built app runs on its own origin, so its `fetch` to your API is cross-site and carries no cookie. -The endpoint cannot tell who is looking at it, and the shared token is no help: every app holds the -same copy, so it identifies nobody. - -Scoping to the app's **author** is the tempting fix and it is wrong. The moment an app can be -installed by someone else — a marketplace, a shared widget — answering with the author's rows is a -leak, and answering with nothing is a bug. - -What works is a **viewer token**. The page embedding the app does have a session, so it mints a -short-lived token for whoever is signed in and hands it to the frame over `postMessage`; the app -sends it as `Authorization: Bearer`. Its subject drives the same predicates the rest of the product -uses — the read one for reads, the owner one for writes — so an app installed by B answers with B's -view: B's rows, plus anything shared with the workspace, and writes to nothing but B's own. The install itself is the grant — -here, the row that pins an app to a dashboard — so uninstalling revokes. - -Three consequences for the endpoint: - -1. **Don't reuse your session-based CRUD module.** Every function there requires an actor; wiring an - actor-less caller into it is how you end up with a half-scoped query. Query the database directly - in this route, and keep it obviously separate. -2. **Withhold owner identity from responses.** Third-party apps call this endpoint — shipping a - `created_by` field hands every caller the email address of every user with data. -3. **Leaving the token unset must not leave the endpoint open.** Treat a missing token as a - misconfiguration, and compare tokens with a timing-safe comparison. - -Rows created through a call with **no** viewer token have no human owner. This repo stamps a -sentinel (`sunny-api@service.local`) rather than allowing a null: it matches no real user, so the -ordinary owner-scoped UI hides those rows exactly as an ownerless row would, and unlike an empty -string it's greppable. With a viewer token the row belongs to that person like any other. - -Two details worth copying: - -- **Pin both ends of the handshake to an origin.** Answer only the frame you embedded (compare - `event.source` to the iframe's `contentWindow`) and post the token to the app's exact origin, - never `*`. Otherwise any frame on the page can collect a token for an app it does not host. -- **Keep the token short-lived and app-bound.** Minutes, not hours, and naming the app it was minted - for, so a token that escapes one app is useless in another. - -The limit of this design: it only works while the app is embedded in one of your pages. An app -opened at its own public URL has no parent to ask, and needs a real authorization-code flow instead. - -### CORS is not optional - -Built apps are on other origins, so without these the whole thing is dead — preflight included: - -```ts -const CORS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", -}; -``` - -### Design the contract like a public API - -Once apps are built against it, they are deployed code you don't control. So: - -- **One POST endpoint, an `action` field, and a small closed set of actions.** Six here: - `listBoards`, `getBoard`, `listItems`, `createItem`, `updateItem`, `deleteItem`. Boards are - read-only on purpose — the platform owns its own top-level objects. -- **Whitelist writable fields.** Anything else in the payload is dropped, so a client can't write - platform-owned columns. A *wrong type* on a writable field, though, is a 400 — silence there just - hides bugs. -- **Cap the limits** (100 default, 500 max). -- **Errors are `{error}` with a 4xx/5xx status**, and the skill tells apps to surface them rather - than rendering an empty state. -- **Freeze the shapes.** This repo asserts the contract action by action (`npm run sunny:smoke`) - precisely because built apps depend on it. - -### An example of the client the builder writes - -```js -const API = "https://your-host/api/sunny"; - -// The app half of the handshake. The request carries no secret, so `*` is fine -// here; it is the *reply* that must be origin-pinned, and that is your code. -function getTokenFromParent() { - return new Promise((resolve, reject) => { - function onMessage(e) { - if (e.source !== window.parent) return; - const data = e.data || {}; - if (data.type === "sunny:auth:token") { - window.removeEventListener("message", onMessage); - resolve(data.token); - } - if (data.type === "sunny:auth:denied") { - window.removeEventListener("message", onMessage); - reject(new Error("Not installed")); - } - } - window.addEventListener("message", onMessage); - window.parent.postMessage({ type: "sunny:auth:request" }, "*"); - }); -} - -async function api(action, params = {}) { - const token = await getTokenFromParent(); - const res = await fetch(API, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, - body: JSON.stringify({ action, ...params }), - }); - const body = await res.json(); - if (!res.ok) throw new Error(body.error || `API ${res.status}`); - return body; -} -``` - ---- - -## Surfacing the app back in your product - -Building is only half of it — the app has to come home: - -- **Preview:** `getPreviewUrl` returns a sandbox URL whose token expires in 300s. Fetch it fresh - each time and render it in an iframe. This is why `prevent_iframe_embedding: false` is set at - create. -- **Ownership:** record `app_id → your user` locally when you create the app. Platform apps have no - per-your-user owner, so this local row is the only thing that makes "my apps" possible. If it - fails, the app is invisible in My Tools to everyone, its builder included — log it loudly. -- **Widgets:** in this repo an app can be pinned to a dashboard as a resizable widget (`height` in - px, `col_span` 1 or 2). Those dimensions are exactly what the always-on instructions describe, so - the generated UI fits the frame it will actually live in. - -## Rollout note - -The instruction text and the skill are read at build time, so changing them affects only apps built -afterwards. Already-built apps keep calling whatever endpoint they were written against — which -means an old endpoint and a new one can coexist indefinitely, and nothing forces a flip. Plan the -cutover as "point new builds at the new host, leave the old one up", not as a migration. diff --git a/docs/base44-identity.md b/docs/base44-identity.md deleted file mode 100644 index 80d226f..0000000 --- a/docs/base44-identity.md +++ /dev/null @@ -1,176 +0,0 @@ -# One Base44 account behind the whole platform - -How your platform authenticates to Base44, and whose job it is to keep your users apart. This is -step 2 of the [README](../README.md) walkthrough, in full. The canonical description is Base44's own: -[Tenancy and credentials](https://docs.base44.com/developers/white-label/tenancy-and-credentials). - -Reference implementation: `src/lib/base44Config.ts` (where the credential lives), -`src/app/api/base44/platform/route.ts` (where it is used) and `src/lib/appOwnership.ts` (who built -what). - ---- - -## The model - -**One Base44 account sits behind your whole integration.** It owns every app your builders create, -and its personal access token authenticates every call you make. - -Four parties, and only two of them exist on Base44's side: - -| Role | Who | Where they exist | -| --- | --- | --- | -| Base44 | The execution harness: builds, previews, deploys | — | -| Your platform | Sign-in, tenancy, billing, UI | Your database | -| A **builder** | Your user, describing and building apps through your UI | **Your database only** | -| An **end user** | Someone using a published app without building | Your database, plus an app user Base44 provisions for the embed | - -No Base44 identity exists for the builder at all. Base44 sees one account building everything, and -says so plainly: *keeping your builders apart is yours to enforce.* Scoping each builder to their -own apps, and hiding other builders' apps, is entirely up to you. - -That is the whole design. Everything below is what follows from it. - ---- - -## The credential - -A **personal access token** for the integration account, sent as `Authorization: Bearer ` on -every Apps API call, together with the workspace: - -```ts -headers: { - Authorization: `Bearer ${accessToken()}`, // WHO — always the integration account - "X-Active-Workspace-Id": orgId(), // WHERE — Base44 checks permissions against this - "Content-Type": "application/json", -} -``` - -Three rules for the token: - -- **Create it in your white-label workspace** (Builder plan or higher), not the account's personal - workspace, or your builds miss the design system, skills and plan you set up there. Give it Full - access to all apps: apps it builds later must be in reach too. -- **The account needs Editor or higher** in that workspace. Viewers and guests can't create apps, - and deploy only succeeds for an Editor. -- **It is the most sensitive value you hold.** It can do anything the account can, to every app - your platform has ever built. Server-only, never caller-supplied, never installed into a built app - (`APP_SECRETS` is the allow-list for that, and it does not contain it). - -It lands in env as `BASE44_ACCESS_TOKEN`, next to `BASE44_ORG_ID` (the workspace id), -and `BASE44_PLATFORM_HOST`. See `.env.example`. - -There is no per-user token to mint, store, refresh or revoke, and no "Connect" step in the UI. The -builder is either configured on a deployment or it is not; `POST /api/base44/platform -{action:"status"}` answers which, from config alone. - -The same token signs a viewer into an embedded app: provisioning an app user and minting an embed -sign-in token (`src/lib/embedSession.ts`) are allowed for anyone who can edit the app. - -### What the workspace key is still for - -`BASE44_SVC_KEY`, the `b44k_` workspace API key, is only for registering the outbound webhook -endpoint. Workspace keys and outbound webhooks need a Business plan or higher. It is optional: -without it everything works except `npm run webhook:register`. - ---- - -## Keeping builders apart - -Since upstream every app has the same owner, "which apps are mine?" and "may I edit this app?" are -questions only your database can answer. In this repo the answer is `AppOwnership`: one row per -(app, builder), and `src/lib/appOwnership.ts` is the module that reads it for authorization. - -The platform proxy enforces it in three places: - -``` -createApp → create upstream, then recordOwnership(actor, app.id) — same request -listApps → list the workspace upstream, keep only ids the caller owns -every other action → ownsApp(actor, appId) first; 404 otherwise, before any upstream call -``` - -The 404 is deliberate: the workspace is shared by every builder, and "that exists but isn't yours" -tells a caller more than "no such app". It is also the answer they would get for an id that was -never created, so the two cases stay indistinguishable. - -Two rules make this hold: - -**Ownership is written server-side, in the request that creates the app.** Never from the browser. -A client that could insert an `AppOwnership` row could claim any app in the workspace, so the generic -entity API lets a user read and delete their own rows ("forget this app") and refuses to create or -update one — `src/lib/entities.ts` marks every field read-only. - -**Reads have no admin bypass.** Like `AppInstall`, ownership matches on the caller's own email. An -admin's My apps page shows their own apps, not everyone's. Reading another user's rows through an -admin session is one thing; handing them the controls of another user's app is another. - -The same module answers for the embed and token routes (`/api/embed`, `/api/sunny/token`): an app -is reachable by its author *or* by someone who installed it, and nobody else. - -### Apps with no row - -An app in the workspace with no `AppOwnership` row — built before ownership was tracked, or in -Base44's own UI — is visible to nobody here, and no action reaches it. Recover one by -inserting its row for the right user. - ---- - -## Lifecycle - -``` -set up create the token in the white-label workspace (Editor+) → env → deploy - `status` answers 200 configured; the builder UI appears - -build browser → /api/base44/platform {action} → ownership check → Bearer → Base44 - every app lands in the one account, in your workspace - -offboard delete the user's AppOwnership (and AppInstall, Widget) rows - their apps stay in the account — reassign by inserting rows for someone else, or - delete the apps upstream; nothing upstream needs to change for the *user* - -rotate create a new token, deploy it, revoke the old one - no per-user state to migrate: every stored id is an app id, not a credential - -401 upstream the token was revoked or expired → 501 bridge_misconfigured, loudly logged - a deployment problem, not something a user can fix by retrying -``` - -### Failure classes - -| Failure | Meaning | Response | -| --- | --- | --- | -| Missing env var | Deployment isn't configured | `501 bridge_misconfigured`, before any call. The body never names the variable | -| Upstream `401` | Base44 refused the integration account's token | `501 bridge_misconfigured`, logged with the upstream detail. Same bucket, same UI state | -| Upstream `403` | The account's role — a non-Editor cannot deploy, a workspace isn't enabled for something | Passed through with the upstream detail | -| App-scoped call on an app the caller didn't build | Not theirs | `404 not_found`, before any upstream call | -| Network error, `5xx` | Upstream trouble | `502`, retry | - ---- - -## Containment rules - -What this repo holds itself to, asserted by `npm run base44:smoke`: - -1. **The token is read in one place** (`accessToken()` in `src/lib/base44Config.ts`) and used in one - place (the proxy's `send()`). No response body ever contains it, and no error body names the - variable. -2. **Every app-scoped action checks ownership before upstream.** For every action in `APP_SCOPED`. -3. **`listApps` answers with the caller's apps only, wherever they sit in the workspace.** -4. **`AppOwnership` cannot be created or updated through the entity API.** Only the proxy writes it. -5. **A foreign app is a 404 even on an unconfigured deployment** — the ownership gate runs before - config is read, so the two kinds of failure never blur. - -## Notes for your own port - -- The isolation lives entirely in your database. Whatever you call your ownership table, make it - the *only* thing your proxy consults, check it before every upstream call, and never let a client - write it. -- Give the platform a workspace of its own. Listing reads every app in it, so a workspace shared - with other tools makes every listing larger, though ownership still hides their apps. -- Don't filter a single upstream page. The workspace is shared by your users, so one page of it can - hold none of a user's apps while their older ones sit further down. This repo asks for one large - page and keeps reading while pages come back full (`pageOwnedApps()`), and keeps the listing as - the source rather than fetching each owned id, because the listing is what drops a trashed app. -- Offboarding a user does not touch Base44. Their apps remain in the account; what they lose is the - rows that let them reach those apps through you. -- If you ever need per-user Base44 identities again — real accounts, with Base44 enforcing the - split — that is a different model with a different set of endpoints, not a flag on this one. diff --git a/docs/base44-platform-api.md b/docs/base44-platform-api.md deleted file mode 100644 index 42b56ec..0000000 --- a/docs/base44-platform-api.md +++ /dev/null @@ -1,216 +0,0 @@ -# Calling Base44's platform REST API from your server - -Step 3 of the [README](../README.md) walkthrough: the endpoints that create and build *other* apps, -and the proxy you should put in front of them. - -Reference implementation: `src/app/api/base44/platform/route.ts` (server) and -`src/lib/base44Platform.ts` (browser client). - ---- - -## The shape - -``` -browser your server Base44 - │ POST /api/base44/platform │ │ - │ {action:"createApp", prompt} │ │ - ├───────────────────────────────►│ does this user own the app? │ - │ │ map action → method + path │ - │ ├─────────────────────────────►│ - │ │ Authorization: Bearer … │ - │ │ X-Active-Workspace-Id: … │ - │◄───────────────────────────────┤◄─────────────────────────────┤ -``` - -The browser names an **action**, never a URL. Two headers do all the authorization work: - -```ts -Authorization: `Bearer ${accessToken()}` // WHO — the one integration account, always -"X-Active-Workspace-Id": workspaceId // WHERE — your governed workspace -``` - -The Bearer token is the integration account's personal access token — the same one for every user -of your platform; the workspace header is what Base44 checks permissions against, so send both on -every call. Because the token is the same for everyone, *who may touch which app* is a question -your proxy answers before it calls upstream — see [base44-identity.md](base44-identity.md). - -## Why an allow-list, not a passthrough - -The access token can do anything the integration account can, to every app it owns, and Base44 -enforces no scopes on this REST surface. - -So the allow-list in your proxy — together with the ownership check in front of every app-scoped -action — is the *only* limit on what a compromised frontend can reach. Keep it tight: - -- never add a generic passthrough action; -- never let the caller supply a path, a host, or a workspace id (a request-controlled host on code - holding user credentials is an SSRF; a request-controlled workspace id defeats tenancy); -- validate every id you interpolate into a path against something like `/^[A-Za-z0-9_-]+$/`. - -The implementation is one table: - -```ts -type Op = { - method: string; - path: (p: Params) => string; - body?: (p: Params) => unknown; - headers?: (p: Params) => Record; - timeoutMs?: number; -}; - -const OPS: Record = { listApps: {…}, createApp: {…}, … }; -``` - ---- - -## The endpoints - -Base URL is your platform host. Every call carries the two headers above. - -### `GET /api/apps` — list - -``` -/api/apps?q={"app_type":{"$nin":["user_agent"]}} - &sort=-updated_date&limit=500&skip=0 - &filter_mode=all_apps_workspace -``` - -Returns a **bare array**, no total count. `filter_mode=all_apps_workspace` lists every app in the -workspace, not only the token's own — apps built under earlier identities still belong to your -users. The workspace is the boundary, so give your platform one of its own. - -Platform apps carry no per-your-user owner (they all belong to the integration account), so -filtering to "this user's apps" is a local join. This repo keeps an `AppOwnership` row per created -app and intersects server-side. It asks for a large page, so at its scale the whole workspace is one -snapshot, and keeps reading pages while they come back full — filtering a single page would hide a -user's older apps behind other users' newer ones. Base44 documents no maximum `limit`, so a page it -refuses as a bad request is retried once at 50, the size known to work. - -### `POST /api/apps` — create - -```jsonc -{ - "name": "Sprint burndown", // optional - "user_description": "", - "organization_id": "", - "public_settings": "public_without_login", - "custom_instructions": "", // persisted, applied every turn - "secrets": { // installed before the first build turn - "SOME_NAME": { "type": "value", "value": "" } // APP_SECRETS is empty today - }, - "initial_message": { "content": "" }, // create-only, starts the first build - "prevent_iframe_embedding": false // required for an embeddable preview -} -``` - -Three fields do different jobs and all must be set **here**: - -- `initial_message` kicks off the first build inside this same call — so this request blocks on an - LLM turn, and anything you patch afterwards misses that turn. -- `custom_instructions` is persisted on the app and re-applied on every later turn. -- `secrets` are written before that turn is scheduled, so the app never builds without its - credentials. `POST /api/apps/{id}/secrets` exists too, but it races the turn. Resolve the values - server-side from an allow-list — a caller that can send a *value* can write anything into an app. - Base44 exposes app secrets only to backend functions, never to the frontend bundle. - -The platform silently drops fields it doesn't accept, and a dropped `custom_instructions` is -invisible — the build just ignores it. Read it back off the response and log loudly if it didn't -stick. - -### `GET /api/apps/{appId}` — read one - -### `GET /api/apps/{appId}/chat/full-conversation?limit=100&skip=0` - -The full builder transcript, typically polled every few seconds while a build runs. Every tool -call's arguments and results come back with it — file contents included — so the body grows all -build long. Watch this one: a late-build read is much heavier than an early one, so if you see -timeouts here, raise the limit rather than treating it as an upstream fault. - -### `POST /api/apps/{appId}/chat/message` — build turn - -```json -{ "content": "add a filter by status" } -``` - -Blocks on an LLM turn (~30s is normal). The response means the message was accepted, not that the -build finished — poll the app and the conversation after. - -### `POST /api/apps/{appId}/chat/submit-tool-call-input` — resume a paused turn - -```json -{ - "tool_call_id": "toolu_…", - "action": "approved", // or "rejected" - "extra_user_input": {}, - "message_id": "…" -} -``` - -When a builder turn pauses on a `requires_user_input` tool call: `rejected` records the call as -stopped and the tool never runs; `approved` runs it with `extra_user_input` injected as its -`user_input` argument. - -Send an `X-Request-ID` that is **stable per logical submit** (this repo derives it from the tool call -id). A network-retried POST then dedupes instead of resuming — and charging for — the turn twice. - -### `GET /api/apps/{appId}/sandbox/preview-url` - -Boots or reuses a dev sandbox. The returned `preview_token` has a **300s TTL** — never cache it. - -### `POST /api/apps/{appId}/deploy` - -Empty body. Publishes the app. - ---- - -## Timeouts - -The single most common self-inflicted bug: a blanket 30s timeout on calls that wait on an LLM. - -| Action | Timeout here | Why | -| --- | --- | --- | -| `sendMessage`, `createApp`, `deployApp`, `submitToolCallInput` | 120s | A build turn measures ~28–30s against a live app; a 30s ceiling aborts working builds intermittently and reports them as upstream faults. Deploy bundles, so a large app can exceed 30s too | -| everything else | 30s | Plain CRUD | - -Keep them well under your function/platform ceiling (300s on Vercel), so a genuinely hung upstream -still fails rather than holding the function open. - -## Error handling - -| Upstream | Meaning | Response to your client | -| --- | --- | --- | -| `401` | Base44 refused the integration account's token — revoked or expired | `501 bridge_misconfigured`, logged loudly. A deployment problem, the same UI state as a missing variable | -| `403` | The account's role: a non-Editor cannot deploy, or the workspace isn't enabled for something | Pass the status through with the detail | -| other `4xx`/`5xx` | Upstream refusal | Pass the status through with a truncated detail | - -## Renaming an app - -`renameApp` sends **`PUT /api/apps/{id}`** with `{name}`. Not PATCH — that route 404s. -A one-field PUT merges rather than replacing; verified by diffing an app's fields -either side of the call. Re-check that if anything ever sends more than `name`. - -| Missing env var (thrown before any call) | Deployment isn't configured | `501 bridge_misconfigured` — **not** 400 and **not** 502. Echoing env var names into a response body is also worth avoiding | - -Client-side, `bridge_misconfigured` is one UI state ("the builder is not available here") — see -`isBuilderUnavailable()` in `src/lib/base44Platform.ts` — and `not_found` on an app-scoped action -means the caller does not own the app they named. - -Path builders read config (the workspace id), so a missing variable surfaces *inside* the -request-building step. Re-throw it rather than letting it masquerade as a 400 — otherwise you'll -hunt a caller bug that doesn't exist. - -## The browser client - -`src/lib/base44Platform.ts` is a thin wrapper; no credential is ever present in it. Worth copying: -it composes multi-call operations (create → file) in one place so callers can't get the order -wrong — ownership is recorded by the server inside the create, never from the browser — and it turns upstream error bodies into a typed error carrying `code` and -`status` so the UI can branch. - -```ts -const call = (action, params) => - fetch("/api/base44/platform", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action, ...params }), - }).then(…); -``` diff --git a/docs/base44-webhooks.md b/docs/base44-webhooks.md deleted file mode 100644 index e5b6412..0000000 --- a/docs/base44-webhooks.md +++ /dev/null @@ -1,86 +0,0 @@ -# Inbound webhooks: letting Base44 tell you what happened - -Boundary 5, and the only one where Base44 calls **in**. It exists for deletion: `listApps` cannot -report one, and a dashboard pin renders from its own stored URL, so without the event a pin frames a -deleted app indefinitely. - -## What the receiver does - -`POST /api/base44/webhooks` (`src/app/api/base44/webhooks/route.ts`): - -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 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 -that arrived on time. - -`app.deleted.v1` is **trash**, restorable for 30 days. The receiver leaves `AppOwnership` alone: a -trashed app already drops out of `listApps`, and a restored one reappears there by itself. - -## Registering the endpoint - -Once per environment, with a workspace key that has `outbound_webhooks:write`: - -```bash -npm run webhook:register -- --url https://your-shell.example.com -``` - -It subscribes to `app.deleted.v1`, registers, and prints `BASE44_WEBHOOK_PUBLIC_KEYS`. Registering is -what mints the workspace's first signing key, so on a first run the activation probe cannot verify -and the endpoint stays pending. Deploy the printed key, then: - -```bash -npm run webhook:register -- --activate -``` - -Check `activated` in the output, not the HTTP status — registration returns 201 either way. - -The endpoint must be public HTTPS. Base44 refuses `localhost` and private addresses and never -follows redirects. For local development, tunnel (`ngrok`, `cloudflared`). - -## Verifying a delivery - -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 + "." + -header = webhook-signature: v1a, [v1a, ...] -``` - -- Verify the body text as received. `JSON.parse` + `JSON.stringify` changes the bytes. -- The header is a list — two entries during a key rotation. -- Standard base64, not base64url. -- Timestamps older than 300 seconds are refused. - -Keys come from `BASE44_WEBHOOK_PUBLIC_KEYS` only, so verification makes no network call. Missing or -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) and the handler's status codes, against the SDK as installed. Run it -after bumping `@base44/platform`. - -## Delivery semantics - -- **At least once.** 8 attempts over ~27h35m, 15s timeout each. -- **No ordering guarantee.** A retry of an earlier event can land after a later one. -- **Status codes are instructions.** Non-2xx is retried, and enough failures auto-pause the endpoint - — so unhandled event types get 2xx. -- **No history replay.** A newly registered endpoint receives only new events. - -## Not handled - -- **Other users' rows.** A deleted app can still have a `MarketplaceListing` and `AppInstall` rows - granting others access. Whether installers are told, and whether a listing returns on restore, is - a product decision. -- **Telling the owner.** The pin just disappears on the next dashboard load. diff --git a/docs/deploy.md b/docs/deploy.md deleted file mode 100644 index 9d8492e..0000000 --- a/docs/deploy.md +++ /dev/null @@ -1,196 +0,0 @@ -# Deploying - -This runs on free plans: **Netlify** for the app + **Neon** for Postgres. Nothing in the stack needs -a paid feature. - -Two things to understand before you start: - -- **A deployment makes `/api/sunny` reachable from the internet.** It answers only requests - carrying a viewer token signed with `NEXTAUTH_SECRET` (see - [base44-built-apps.md](base44-built-apps.md)), so there is no shared secret to leak and no open - mode to forget to close — but a weak or reused `NEXTAUTH_SECRET` now compromises that API too. -- **Google's OAuth client names one exact host.** Wildcards are not allowed, so a preview - deployment's URL — a new one per pull request — can never be registered. Previews sign in by - borrowing production's callback through a redirect proxy. See - [Preview deployments](#preview-deployments) below. - ---- - -## 1. Create the database - -Neon (or any Postgres). `prisma/schema.prisma` declares both `url` and `directUrl`, but **one Neon -connection string is enough** — the two hosts differ only by a `-pooler` segment: - -``` -direct: postgresql://user:pw@ep-example-123456.us-east-2.aws.neon.tech/neondb?sslmode=require -pooled: postgresql://user:pw@ep-example-123456-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require -``` - -- `DATABASE_URL` — the **pooled** host, used by the app at runtime. Each serverless function opens - its own pool, so this is what stops connection exhaustion. -- `DIRECT_URL` — the **unpooled** host, used by `prisma migrate`. - -Pointing both at the direct host works at small scale. Pointing both at the *pooled* host is the one -combination to avoid: migrations misbehave through a transaction pooler. - -## 2. Create the Google OAuth client - -A Web application client in the Google Cloud console, with: - -- Authorized JavaScript origin: `https://` -- Authorized redirect URI: `https:///api/auth/callback/google` - -Use **two clients, one per environment** — localhost in your local `.env`, the deployed host in the -deploy environment. A single client would need both origins, and rotating one would break the other. - -Preview deployments need **no entry of their own**: that is what the redirect proxy is for, and -Google would not accept `https://*--.netlify.app` anyway. - -While the OAuth consent screen is in **Testing**, only the listed test users can sign in, and they -all see an "unverified app" warning. Publishing it is a separate decision. - -## 3. Connect the repo to Netlify - -`netlify.toml` is already in the repo and carries everything build-related: - -- **Build command** is `npm run build:deploy` — that is `prisma migrate deploy && next build`, so a - deploy is what moves the schema forward and you never reach the database from your laptop. - (`npm run build` stays database-free for local use, and `prisma generate` runs from `postinstall`.) -- **Node 24.** -- The Next.js runtime auto-installs on detection; do **not** add it as a plugin. -- `SECRETS_SCAN_OMIT_PATHS` excludes Turbopack's build cache under `.netlify/`, which records - resolved `process.env` reads and is never served. The omission is scoped to that path rather than - to key names, so the scanner still catches those keys if they ever reach real output. - -`prisma/schema.prisma` sets `binaryTargets = ["native", "rhel-openssl-3.0.x"]`: Netlify builds on -Ubuntu but runs functions on Amazon Linux, and without it the query engine is missing at runtime. - -## 4. Set the environment variables - -All server-side except the last, which is deliberately public. See `.env.example` for what each -one is. - -| Variable | Value | -| --- | --- | -| `DATABASE_URL` | Neon **pooled** URL | -| `DIRECT_URL` | Neon **unpooled** URL | -| `NEXTAUTH_SECRET` | `openssl rand -base64 32` | -| `NEXTAUTH_URL` | `https://` — the production domain, exactly. **Scope it to production**: it overrides the origin read off the request, so a preview holding it would sign users into production | -| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | the production OAuth client | -| `AUTH_REDIRECT_PROXY_URL` | `https:///api/auth` — only needed if you turn previews on; set it on production *and* the preview contexts | -| `BASE44_ACCESS_TOKEN` | the integration account's personal access token, created in the white-label workspace with Full access to all apps — without it the builder says it is not configured | -| `BASE44_SVC_KEY` | the `b44k_` workspace key, only for registering webhooks (Business plan or higher); optional | -| `BASE44_ORG_ID`, `BASE44_PLATFORM_HOST` | from your workspace | -| `NEXT_PUBLIC_BASE44_APP_HOST` | the host Base44 serves built apps from. The one public var — these URLs are built in the browser to be iframed. Unset, the UI shows no app previews | - -## 5. Verify - -```bash -curl -s https:///api/me # 401 {"error":"unauthenticated"} -curl -s https:///api/entities/Board # 401 -curl -s -X POST https:///api/sunny \ - -H 'content-type: application/json' -d '{"action":"listBoards"}' # 401 without the token -curl -s https:///api/auth/providers # callback URL matches the OAuth client -``` - -Then sign in through the browser. The smoke suites can be pointed at a deployment: - -```bash -NEXTAUTH_URL=https:// npm run sunny:smoke -``` - -…but they **write throwaway rows and clean up after themselves**, and `sunny:smoke` needs -`NEXTAUTH_SECRET` locally to match the deployment's — it mints its own viewer tokens. Don't run them -against anything you care about. - -The deployed database starts empty. Every route works; there is just nothing to list until you sign -in and create a board. - -## Preview deployments - -A preview gets a fresh URL per pull request (`deploy-preview-42--.netlify.app`), which is -exactly what neither Google nor Prisma is happy about. Both problems have a fix. - -### Sign-in: the redirect proxy - -Google matches redirect URIs exactly and allows no wildcard, so a preview's callback URL cannot be -registered. Instead **every deployment sends Google production's callback** and encodes its own -origin in the OAuth `state`; production notices a state that belongs elsewhere and forwards the -callback there. Auth.js calls this a redirect proxy, and `src/lib/auth.ts` turns it on whenever -`AUTH_REDIRECT_PROXY_URL` is set. - -The flow, for a preview at `https://deploy-preview-42--.netlify.app`: - -1. the preview sends the user to Google naming production's callback as the - `redirect_uri`, with `state` carrying its own callback URL, -2. Google returns the code to **production**, which decodes the state and 302s the whole callback to - the preview, -3. the preview verifies its own `state` cookie, exchanges the code (again naming production's - `redirect_uri`, which is what Google issued it for) and sets its own session. - -To set it up: - -| Netlify env var | Production | Deploy previews / branch deploys | -| --- | --- | --- | -| `AUTH_REDIRECT_PROXY_URL` | `https:///api/auth` | same value | -| `NEXTAUTH_SECRET` | your secret | **the same secret** | -| `NEXTAUTH_URL` | `https://` | **unset** | -| `GOOGLE_CLIENT_ID` / `_SECRET` | production client | same client | - -Three things are load-bearing: - -- **The shared `NEXTAUTH_SECRET`.** The state is encrypted with it, so only a deployment holding the - secret can name a forwarding target, and a preview with a different secret cannot complete the - handshake at all. It also means a preview can mint viewer tokens for `/api/sunny` that production - accepts — treat preview deploys as production-equivalent trust. -- **No `NEXTAUTH_URL` on previews.** It overrides the origin NextAuth reads from the request, so a - preview that inherits production's value believes it *is* production: it stops proxying and signs - the user into production instead. `trustHost: true` in `src/lib/auth.ts` is what lets a - deployment resolve its own origin at all; on a preview that origin is then pinned, below. -- **`AUTH_REDIRECT_PROXY_URL` on production too.** The deployment whose own origin matches that - URL is the one that forwards; without it, production treats a preview's callback as its own. - -- **The preview's origin comes from `DEPLOY_PRIME_URL`, not from the request.** Netlify answers the - deploy *alias* (`deploy-preview-42--.netlify.app`) but hands the server handler the deploy - *permalink* (`--.netlify.app`) in `host` / `x-forwarded-host`. Taken from the - request, a preview would name the permalink as its forwarding target while the browser — and so - the `state` cookie, which is host-only — sits on the alias. Sign-in then fails with - `InvalidCheck: state value could not be parsed`, which is also the message for a cookie that is - simply *absent*: `parseCookie` in `@auth/core` rewrites every cause into that one string. So - `src/lib/auth.ts` pins `AUTH_URL` to Netlify's own `DEPLOY_PRIME_URL`, which *is* the alias, - baked into the build by the `env` block in `next.config.ts` because the function's runtime - environment does not carry Netlify's build variables. - - A consequence worth knowing: a preview signs in on its alias URL only. Open the permalink - directly and the browser is back on a host the deployment does not claim. - -Local dev leaves `AUTH_REDIRECT_PROXY_URL` unset and uses the ordinary direct flow. - -### Builds: previews must not migrate - -Scope the Postgres vars to production only and a preview cannot build at all — `prisma generate` -fails schema validation in `postinstall`. Giving a preview production's `DATABASE_URL` is worse: -`build:deploy` is `prisma migrate deploy && next build`, so **every pull request would migrate -production**. - -So `netlify.toml` overrides the command for the preview contexts to plain `npm run build`, which -never touches the database, and you give the preview scope its own **Neon branch** for -`DATABASE_URL`/`DIRECT_URL`. A pull request that adds a migration therefore deploys against an -un-migrated preview database until you apply it to that branch yourself: - -```bash -DATABASE_URL= DIRECT_URL= npm run db:deploy -``` - -That is deliberate. The alternative — letting previews migrate — is a schema change from an -unreviewed branch running automatically, against a database shared by every other open pull -request. - -## Known rough edges - -- **Neon's free tier suspends on inactivity**, so the first request after a pause pays a cold start - of a second or two on top of the serverless cold start. -- **No connection-pool tuning.** Prisma opens a pool per function instance; the pooled URL is what - keeps that from exhausting connections. -- **`/api/sunny` has no rate limiting.** It is token-gated but otherwise open by URL, and it - writes. Worth adding before it matters. diff --git a/docs/p8-marketplace.md b/docs/p8-marketplace.md deleted file mode 100644 index 1b49dcb..0000000 --- a/docs/p8-marketplace.md +++ /dev/null @@ -1,245 +0,0 @@ -# The app market - -An app one person builds, another installs and runs against **their own** boards. - -## Why this needed nothing new in the security layer - -The hard part was already done. `/api/sunny` takes a viewer token -(`src/lib/appTokens.ts`), minted by the page embedding the app and handed to the frame -over `postMessage` (`src/lib/appFrameAuth.ts`). Its subject is the actor behind the -route's RLS predicates, so a market app — one deployment, one author, many installers -— reads whoever is looking at it: their own boards, plus any board marked `shared`, -and writes to nothing but their own rows. That is the whole reason a market is safe -here, and none of it is in this feature. - -What was missing was only the catalogue: a way to find an app you did not build. - -## Installing is not pinning - -The grant is an `AppInstall` row (`src/lib/appInstall.ts`). `/api/sunny/token` mints -for an app you have installed or authored, so installing is what lets an app read your -boards and uninstalling revokes. - -It used to be the `Widget` row. That held while every app was one you built — pinning -it to Home was the only reason to have it — and broke as soon as apps come from a -market: *I want this app* and *I want it on my home page* are different intents, and -welding them meant an app you open monthly had to sit on Home to work at all. So the -grant moved, and `Widget` went back to meaning only what its name says. - -The migration **backfills one install per already-pinned `(app, user)`**. Without it, -moving the predicate would revoke everyone at once. - -Two directions, and only one of them holds: - -* Unpinning is **not** uninstalling. Take it off Home, it still works — open it from - **Market → Installed**. -* Uninstalling **does** unpin. A widget with no grant behind it renders as a frame that - cannot read anything, which reads as broken rather than as revoked — so `uninstall()` - removes the widgets too. - -`npm run market:smoke` asserts both, including that a pin alone grants nothing. - -## One merge, two readings - -`src/lib/usableApps.ts` merges the two sources: - -* apps you **built** — the Base44 workspace, live, which is the truth for a slug, a - screenshot and whether the app is deployed; -* apps you **installed** — the listing snapshot, because the platform proxy answers - only for apps you built, so an installer cannot ask it about someone else's. - -Neither failing takes the other down: a Base44 outage should not hide your market apps. - -The **Add widget** picker reads both halves. **My apps** reads only the built half. The -merge stays in one place so the two surfaces cannot disagree about a URL or a -screenshot — only about which apps they have reason to show. - -That split is what lets *Publish* and *Edit in builder* sit unconditionally on a My apps -card: everything on that page is the user's own code, so neither control has a case to -hide for. - -## Publishing snapshots the app - -`app_slug` / `app_url` / `screenshot_url` are captured at publish time, not resolved -when someone views the listing. Forced by the ownership gate: the platform proxy -answers only to an app's author, so at install and render time nothing can ask the -platform where someone else's app lives. - -Two consequences: - -* **Publishing requires a deployed app.** The dialog says so, and the server refuses a - listing with no URL. Installers load the deployed build; a preview sandbox is - per-owner and boots on demand. -* **Listings go stale.** Re-publishing is what refreshes the snapshot. - -## `app_slug` is a switch, not decoration - -`DashboardWidgets` derives `https://preview--.…` when a widget has a slug, -because that address is stable across rebuilds while a stored sandbox URL is not. -Correct for an app you built; wrong for one you did not, since you have no slug you -can address. - -So a market app is pinned with **`app_slug: null`** and carries its listing's snapshot -URL, and the widget falls back to that. Get this wrong and Home renders -`https://preview--demo-quick-capture.…` — a real Base44 host, which answers -`{"error_type":"HTTPException","message":"App not found"}` inside the frame. It looks -like a broken install; it is a URL built by a convention that does not cover the case. - -## The one non-scoped read - -`listPublished()` queries `MarketplaceListing` with no owner predicate. It is the first -such query in the codebase, so `npm run market:smoke` points 31 assertions at it, and -section 2 exists solely to fence it in: published rows cross owners, drafts and -delisted rows do not, a card carries no field beyond the declared shape, and every -write is still owner-only. - -Three properties that are easy to get backwards, all asserted: - -* **A listing grants nothing.** Browsing does not install; the token is refused until - the app is pinned. -* **Delisting is discovery, not access.** Pulling a listing leaves existing installs - working — cutting an app off is the installer's call. Otherwise an author could break - everyone who came to depend on their app. -* **Install counts follow the pins**, so unpinning both decrements and revokes. - -## Getting an app *into* the market - -Three ways in, and the third is the one that matters: - -* **Market → Publish an app** — a picker of the apps you built. This is where the - question gets asked, so this is where the answer lives. -* **My apps → Publish** on any app you built. Labelled, not a bare storefront glyph: - an icon does not say "offer this to other people", and nobody hovers a control whose - meaning they have not already guessed. -* **From the chat.** Opening the builder from the market passes `origin: "market"`, and - the finished app's ready card offers **Add to the market** as its primary action — - deploy, then the listing dialog. - -That last one follows a pattern this codebase already had: `origin: "home-widget"` -makes the ready card offer *Add to my widgets*. **Where the builder was opened from is -already a statement of intent**, so it picks the destination instead of asking. Someone -who clicked "Build an app" inside the market is building it *for* the market. - -It is a default, not a fork. The other destinations stay one click away, and nobody is -asked to choose up front — at the start of a build you do not yet know whether the app -is any good, which is the worst possible moment to be asked where to file it. - -Note that "Publish" in the ready card already means `deployApp`. Hence **"Add to the -market"** for the listing, so the two are not the same word. - -## "My apps" and "Market": authorship is the line - -Both pages used to answer the same question. **Apps** listed everything you could open — -built and installed, as a filter — and the market listed those same apps again under -*Installed* and *Published by me*. Two of the market's three tabs were a second -rendering of two of the Apps page's three filters, so "where is the app I installed" had -two correct answers, which is the same as having none. - -The line that actually exists in the data is **authorship**: - -* **My apps** — code you wrote. Open it, rename it, edit it in the builder, publish it. - Every affordance on the page assumes you own the thing. -* **Market** — everything that crosses accounts. Browse and preview what other people - built, install it, and manage your own listings. Installed apps live here, under - *Installed*, because installing and uninstalling are one decision and belong in one - place: this is the page that granted somebody else's code access to your data, and it - is the page that takes it back. - -The route matches the name: `/apps`, which `?app=` deep links from the builder and the -widgets point at. `/MyTools` still redirects there — those are URLs people keep. - -The nav says **My apps**, not *Apps*. Beside *Market* the possessive is doing the work — -it puts the distinction in the label itself, which "Apps" versus "Market" never did. - -The Add-widget picker still shows **both** sources, grouped — installed apps first, then -yours, with headers only when both are present. Home is where you *use* things, so -authorship is not the question being asked there. Where an app came from is a property -of the group, not of each row, and a badge repeated down eight rows says the same thing -eight times. Installed goes first because it is the shorter list and the newer idea: a -picker that opens on thirteen of your own apps buries the two you just installed. - -## The market refreshes itself - -The builder is a side panel over whatever page you were on, so you can publish an app -from the chat while looking straight at the market grid behind it — and the grid has no -reason to refetch. The only fix was a manual reload, which reads as "it didn't work". - -`src/lib/marketEvents.ts` is a `market-changed` window event, the same shape as the -`widgets-updated` and `app-rebuilt` events this codebase already uses to coordinate -across surfaces. It is announced from **`PublishDialog`** rather than from each caller: -the builder, the My apps page and the market page all publish through that one component, -so a new caller gets the refresh for free instead of having to remember. Install and -uninstall announce it too. - -The My apps page listens as well, so a card's *In market* marker is right without a -reload. - -Related, and the same mistake in miniature: after listing an app the ready card used to -say *"Published — view in Apps"*, confirming a destination the user had not chosen. It -now says *"In the market — view it"*. - -## Who can delist what - -Only an author, and only their own listing. `unpublish()` scopes the update with -`ownerFields(actor)`, so another user's row is not merely hidden from the UI — it is -outside the `where`. The button renders only for `is_author`, and -`npm run market:smoke` asserts a non-author gets a 404. - -That is the *author* half. The **platform** half — Sunday delisting somebody else's app -because it misbehaves — does not exist yet and is listed under What is left. The two -are different powers and should not be the same code path: `unpublish` is personal by -construction, and widening it would be the wrong way to build a kill switch. - -## Card actions say what they do - -Three glyphs were doing work no glyph can do: - -* a **storefront icon** meant "offer this to everyone" — now a labelled *Publish*; -* a **grid icon** meant "put it on your home page" — now *Add to Home* / *On Home*; -* a **trash can** meant "revoke this app's access" — which is not deletion, and was in - the wrong place besides. - -Uninstall now lives on **Apps**, not in the market. A storefront is for getting things; -managing what you already have belongs next to the thing itself. That also makes the -two card kinds symmetrical: an app you built offers *Publish* and *Edit in builder*, an -app you installed offers *Uninstall*. - -## Where you meet it - -| | | -| --- | --- | -| **Market** in the top nav | the catalogue: Browse / Installed / Published by me | -| **Apps** in the top nav | everything you can open, filtered by source | -| Home | an App market card with a live count, and *Build your own* | -| Add widget | links to the market and the builder | -| Apps | a labelled *Publish* on each app you built | -| Market header | *Build an app* — the market is where you learn nobody built the thing you need | - -## Changes to existing files, and why - -* `src/lib/appFrameAuth.ts` — `originOf` now resolves against the current document. A - relative `src` used to throw, leaving `appOrigin` null and the listener unattached, - so the frame asked for a token and waited forever. Same-origin apps are legitimate. -* `src/lib/myWidgets.ts` — `addAppToMyWidgets(app, url)` takes an optional URL, because - a market app's URL cannot be resolved through the platform. -* `src/components/dashboard/DashboardWidgets.jsx` — falls back to the stored - `preview_url` when there is no slug. - -## What is left - -**Publish from chat.** The builder should offer to publish when an app is ready — a -tool widget with the assistant proposing the title and the one-liner. Wants P6's -`InvokeLLM`. - -**A review gate.** Nothing stands between `publish` and `published`. Third-party code -holding a viewer token needs at minimum an admin approval step, per-token rate limits, -and an admin kill switch for a listing. - -**Scopes.** An install is all-or-nothing: the app reads and writes everything the -viewer can. Per-action scopes would let a reporting widget be installed without write -access. That is a change to the token, not to the catalogue — `appTokens.ts` would -carry the grant and `/api/sunny` would check it per action. - -**Origin binding.** `appTokens.ts` records the `app` claim but does not enforce it, and -says so: a token that escaped one app would work in another. Binding it to the app's -origin and checking it against the request's `Origin` is the fix. diff --git a/docs/white-label-minimal-plan.md b/docs/white-label-minimal-plan.md deleted file mode 100644 index 3f86c62..0000000 --- a/docs/white-label-minimal-plan.md +++ /dev/null @@ -1,407 +0,0 @@ -# Lean White-Label Integration Example - -## Goal - -Create a small, self-contained Base44 white-label example that accompanies the -[white-label integration guide](https://base44-docs-white-label-rewrite.mintlify.site/white-label/overview). - -The example should make the integration understandable at a glance. A reader should be able to -clone the repository, configure Base44, run one command, and follow the complete build loop without -first learning the rest of Sunny. - -The existing Sunny application remains the complete, production-oriented reference. The new -example focuses only on the integration itself. - -## Decision - -Add a standalone package at: - -```text -examples/white-label-minimal/ -``` - -Keep it in this repository so it can share repository history, dependency installation, CI, and -maintenance. Keep its runtime code self-contained: it must not import implementation details from -the full Sunny application. This makes the example easy to read, copy, or extract into its own -repository later. - -Use the shared-account tenancy model in this minimal example. It is the shortest path through the -App Management API and keeps attention on the build loop. Clearly label it as a local learning -example, not a safe public multi-user deployment. Link readers to the full Sunny implementation, -which uses the same one-account model and adds the per-user ownership gate production needs. - -## Scope - -The example demonstrates only this flow: - -1. Create an app from an initial prompt. -2. Poll its build status and conversation. -3. Render questions from the agent. -4. Approve, reject, or answer those questions. -5. Open a fresh preview. -6. Deploy the app and show its published URL. - -The example also demonstrates these integration requirements: - -- Base44 credentials stay on the server. -- The workspace is selected explicitly on every applicable request. -- Custom instructions are present before the initial build begins. -- Long-running build calls use appropriate timeouts. -- Tool-call submissions use stable request IDs. -- Preview tokens are short-lived: never persist, cache, or log them; retain a URL only while displaying it. -- Conversation polling refreshes a bounded recent window and pages backward when needed, merging by message ID. - -## Non-goals - -Do not include: - -- Boards, tasks, analytics, widgets, or marketplace features. -- Sunny's entity compatibility layer or public data API. -- Prisma or another database. -- Google sign-in or a general-purpose authentication system. -- Service-user provisioning and token refresh. -- A component library, state-management library, or data-fetching framework. -- Multiple pages, themes, responsive layout work beyond basic usability, or product polish. -- Generic SDK abstractions intended to predict future use cases. - -## Simplicity requirements - -Code clarity is a feature of this example, not a cleanup task for later. - -### Prefer direct code - -- Keep one obvious path from a UI action to the local API route and then to Base44. -- Use platform `fetch`, React state, and small functions before adding dependencies. -- Prefer explicit action handlers over a generic request framework. -- Share code only when duplication would make the example harder to understand or easier to get - wrong. -- Keep types close to the code that consumes them unless several files genuinely share them. -- Use names from the integration guide so readers can move between prose and code without - translating concepts. - -### Keep abstractions earned - -- A single-caller helper or polling hook is appropriate when it isolates a named responsibility or a timing invariant; do not add speculative layers. -- Do not introduce repositories, services, factories, providers, registries, or dependency - injection. -- Keep the browser client thin and keep Base44 request construction in one server-only module. -- Use one API route with a closed action allowlist. Do not build a transparent proxy. -- Prefer a short switch statement over metadata-driven routing if the switch is easier to read. - -### Keep files focused - -- Aim for fewer than ten source files. -- Aim for components below 250 lines and other modules below 200 lines. -- Split a file only when the new boundary has a clear name and responsibility. -- Avoid barrel files and re-export layers. - -These are review signals, not mechanical limits. Exceeding one should require a concrete readability -reason in the pull request. - -### Make comments useful - -- Comment security boundaries, surprising API behavior, and timing constraints. -- Do not narrate syntax or repeat what a well-named function already says. -- Keep the most important explanations beside the relevant code, with deeper operational detail in - the README. - -### Keep the dependency budget small - -The package should depend only on Next.js, React, and React DOM unless a missing capability cannot -be implemented clearly with the platform. Any additional runtime dependency requires an explicit -reason in the pull request. - -## Proposed structure - -Start with this structure and combine files if the implementation is clearer with fewer boundaries: - -```text -examples/white-label-minimal/ -├── app/ -│ ├── api/base44/route.ts -│ ├── globals.css -│ ├── layout.tsx -│ └── page.tsx -├── components/ -│ ├── Builder.tsx -│ ├── useBuildPolling.ts -│ └── Question.tsx -├── lib/ -│ ├── base44/ -│ ├── server/ -│ ├── storage/ -│ ├── chat/ -│ └── types.ts -├── .env.example -├── README.md -├── package.json -└── tsconfig.json -``` - -Responsibilities: - -| File | Responsibility | -| --- | --- | -| `app/page.tsx` | Render the single example screen. | -| `components/Builder.tsx` | Own the build loop, polling, conversation, preview, and deploy actions. | -| `components/Question.tsx` | Render and submit `choice`, `input`, and `approval` questions. | -| `app/api/base44/route.ts` | Enforce the local origin boundary, validate action-specific payloads, and return safe errors. | -| `lib/base44/client.ts` | Hold credentials and make the allow-listed Base44 HTTP calls. | -| `lib/chat/builder-api.ts` | Provide small typed functions that call the local API route. | -| `lib/base44/custom-instructions.ts` | Keep the full per-app custom instructions visible and easy to find. | - -Avoid a separate types file unless shared types make at least three of these files clearer. - -## Decisions resolved before implementation - -- **Local access, no user authentication:** dev and start bind to `127.0.0.1`, on port - 3001 so Sunny can run alongside the example. The route requires a loopback Host, - an exactly matching Origin, JSON content type, and a bounded request body. This - is not a multi-user authorization system; every reachable app belongs to the shared account. -- **Credentials:** the integration account's personal access token as a Bearer token - (the same credential Sunny uses), plus the workspace header on every upstream call. -- **Pagination:** the API returns messages oldest first, but `skip` and `limit` - count backward from the newest message. Poll the latest 20, page backward until - overlapping known history, and replace matching IDs so changing tool statuses - are not frozen. Include any older unresolved tool call in the refreshed range. - Reject missing message IDs clearly instead of silently losing history. Offsets - are not a snapshot cursor: concurrent appends can require another refresh; - undo/edit flows and arbitrary historical rewrites are outside this example. -- **Polling lifecycle:** one refresh at a time; schedule after completion, abort on - unmount, and ignore stale results. Pause on read failure with Resume polling. - A small hook is allowed to keep these invariants out of the screen rendering. -- **Uncertain mutations:** do not automatically retry create, message, or deploy. - A network failure or timeout may leave upstream work running. Reconcile known - app/conversation/published state; if creation returned no ID, direct the reader - to the Base44 workspace before creating again. Tool retries retain the exact - decision and payload as well as a deterministic request ID; lock edited answers - while the outcome is uncertain. Disable duplicate submissions synchronously. -- **Package independence:** declare all dependencies locally, exclude the example - from root TypeScript checks, ignore nested build output, and document that - workspace installation still runs root Prisma generation. A copied package - must install, typecheck, and build without Sunny or Prisma. `.env.local` belongs - in the example directory. No environment values are required just to build. -- **Verification:** deterministic fixtures exercise all question kinds, malformed - and unknown questions, pagination overlap and updates, safe errors, request - validation, retry identity, and preview handling. Live smoke is explicit, never - deploys, and stops if the agent asks a question. Environment presence alone must - not trigger app creation during ordinary tests. - -API contract checked against the guide's Markdown pages on 2026-09-09: -[tenancy](https://base44-docs-white-label-rewrite.mintlify.site/white-label/tenancy-and-credentials), -[conversation](https://base44-docs-white-label-rewrite.mintlify.site/api-reference/read-conversation-messages), -[preview](https://base44-docs-white-label-rewrite.mintlify.site/api-reference/get-preview-url), -and [published URL](https://base44-docs-white-label-rewrite.mintlify.site/api-reference/get-published-url). -The guide describes `waiting_on.kind`; the published conversation schema currently -omits it. Use the guide plus Sunny's concrete question argument schemas, and show -unsupported payloads as diagnostics rather than guessing their meaning. - -## Delivery order - -Implement vertical slices: create → poll → conversation first; questions and -continuation second; preview and deliberate deployment third. The sections below -specify responsibilities, not a requirement to build every layer before trying a -complete path. Finish with documentation, deterministic checks, build isolation, -and the simplicity review. - -## Implementation phases - -### 1. Scaffold the package - -- Read the relevant Next.js 16 documentation in `node_modules/next/dist/docs/` before writing code. -- Add `examples/white-label-minimal` as an npm workspace. -- Add root scripts for `minimal:dev`, `minimal:build`, and `minimal:typecheck`. -- Add the smallest valid Next.js configuration and one unstyled page. -- Confirm that the existing Sunny development and build commands are unchanged. - -### 2. Implement the server-only Base44 client - -Support only these operations: - -- `createApp` -- `getApp` -- `getConversation` -- `sendMessage` -- `submitToolCallInput` -- `getPreviewUrl` -- `deployApp` -- `getPublishedUrl` - -The module must: - -- Read `BASE44_ACCESS_TOKEN`, `BASE44_WORKSPACE_ID`, and `BASE44_PLATFORM_HOST` only on the server. -- Send `X-Active-Workspace-Id` on applicable requests. -- Send `organization_id` when creating an app. -- Pass `initial_message` and `custom_instructions` in the create request. -- Use a longer timeout for create, message, tool submission, and deploy calls. -- Generate a stable `X-Request-ID` from the tool-call ID. -- Return small, actionable errors without leaking credentials or full upstream bodies. -- Never accept a host, workspace ID, or credential from the browser. - -Use the existing Sunny platform route as behavioral reference, but rewrite the minimal path directly -instead of copying its ownership, folder, and compatibility concerns. - -### 3. Add the closed local API route - -- Accept a small JSON body containing an action and its required parameters. -- Validate action-specific strings, IDs, booleans, input objects, and pagination bounds; reject unexpected fields. -- Reject unknown actions before making an upstream request. -- Call the matching function in `base44-server.ts`. -- Keep route handling readable as one switch statement. -- Normalize errors in one place. - -### 4. Add the browser client - -- Export one function per supported action. -- Call only `/api/base44`. -- Parse successful and failed JSON responses consistently. -- Carry a small typed error with status and message only if the UI needs both. -- Do not mirror the entire upstream API or expose a generic arbitrary-path method. - -### 5. Build the single-screen experience - -The page should contain: - -- A prompt field and submit button. -- A compact status indicator. -- The chronological conversation. -- Inline question controls when the agent pauses. -- A preview panel opened only on request. -- A deploy button and published link. - -Behavior: - -- The first prompt creates an app; later prompts update the same app. -- Poll quickly while the app is processing and slowly while it is idle. -- Stop polling after an error and surface a useful message. -- Refresh recent messages and backfill with newest-relative pagination as specified above. -- Disable the composer while a question is waiting. -- Support approval and rejection. -- Request a new preview URL whenever preview is opened or refreshed. -- Require an explicit user action before deployment. - -Use plain CSS and semantic HTML. The UI should be pleasant enough to understand, but visual polish -must not obscure or enlarge the integration code. - -### 6. Render agent questions - -Implement the three documented `waiting_on.kind` values: - -- `choice`: show the available options. -- `input`: show the requested fields. -- `approval`: show approve and reject actions. - -Keep parsing and rendering together unless separating a pure parser measurably improves readability. -Unknown tool calls should render as a small expandable diagnostic block rather than disappear. - -After a question is submitted, immediately refresh the app and conversation before normal polling -continues. - -### 7. Add custom instructions - -Keep the complete instruction string in `lib/base44/custom-instructions.ts`. It should explain only what a -generated app needs to know about the example platform and name any workspace skill it should use. - -Pass it during app creation, because the initial build starts in that request. Read -`custom_instructions` from the returned app and log a server-side warning if Base44 silently omitted -it. - -Do not add a Sunny data contract initially. That can be a separate follow-up example if the guide -needs to demonstrate generated apps calling back into a host platform. - -### 8. Document setup and safety - -The package README should include: - -- A three-minute local setup path. -- The required environment variables. -- The one command used to run the example. -- A diagram showing `browser -> local API route -> Base44`. -- A warning that the shared account owns every created app. -- A warning not to expose this package as an unrestricted public deployment. -- A link to Sunny's ownership gate for production multi-user tenancy. -- A guide-to-code table that points each documentation concept to one source file. - -The root README should distinguish the two references: - -- `src/`: full Sunny product and production-oriented service-user integration. -- `examples/white-label-minimal/`: focused, shared-account guide companion. - -### 9. Verify behavior - -Required checks: - -- Typecheck the root package and the example package. -- Build the root package and the example package. -- Verify that unsupported actions never reach Base44. -- Verify that browser-provided host, workspace, and credential fields are ignored or rejected. -- Verify stable request IDs for tool-call submissions. -- Verify approval and rejection payloads. -- Verify no-store response headers, no request/response body logging, and preview URL disposal on close, refresh, and expiration. -- Verify that the existing Sunny package still starts and builds normally. - -Add an optional live smoke script that runs only when Base44 environment variables are present. It -requires an explicit smoke command and may create an app, poll until the build settles, read the conversation, and request a preview. It -must not deploy by default. - -### 10. Perform a simplicity review - -Before considering the example complete, review it independently from functional correctness: - -- Can a reader locate credential handling, app creation, polling, question submission, preview, and - deployment in under a minute? -- Does every source file have one clear reason to exist? -- Can any wrapper, type, state variable, dependency, or comment be deleted without losing clarity or - safety? -- Are guide terms used consistently in file names, function names, and UI copy? -- Are security rules visible at the exact points where mistakes would otherwise be easy? -- Does the README explain setup without requiring knowledge of the full Sunny codebase? - -Prefer deleting code during this review. Do not add an abstraction merely to make the example look -architecturally complete. - -## Definition of done - -The example is complete when a new reader can: - -1. Find the integration code immediately. -2. Configure three Base44 environment values. -3. Start the example with one command. -4. Create an app and watch the build progress. -5. Answer or reject an agent question. -6. Open a fresh preview. -7. Deploy deliberately and open the published app. -8. Explain why credentials stay server-side and why this shared-account example is not sufficient - for an unrestricted multi-user production deployment. - -The implementation should feel smaller than the guide, not like a second product hidden inside the -repository. - -## Implementation notes - -- `server-only` is the one additional runtime dependency: it makes accidental browser - imports a build error. `tsx` and Playwright are development-only test tools. -- A polling hook and a conversation merge module exceed the initial source-file - target by two TypeScript files, with focused responsibilities and deterministic - tests. Component and module size targets remain intact. -- The preview URL uses `_preview_token`, verified against the public Base44 editor - implementation on 2026-09-09; the API reference omits the transport parameter. -- The package README is the setup and operational reference. Automated tests use - fixtures; live smoke remains an explicit opt-in with configured credentials. - -## Verification results (2026-09-09) - -- 18 deterministic server, conversation, and question-rendering checks passed. -- 8 Chromium browser checks passed against a production build, covering immutable - retries, duplicate clicks, input/rejection payloads, preview refresh/expiry, - deliberate deploy, uncertain creation, polling recovery/non-overlap, and the - real Next.js local-origin boundary. -- Example typecheck, production build, and scoped ESLint passed. -- Root typecheck passed. Sunny built and served HTTP 200 with temporary test values - for the missing NEXTAUTH_SECRET / Google auth configuration; no real sign-in - or database operation was exercised. -- A copy outside the repository installed, typechecked, and built without Sunny, - Prisma, or a configured environment file. -- Live Base44 smoke skipped because the example's three environment values are - not configured. Real account access, billing, and live beta API behavior remain - to be verified with that explicit smoke command. No real app was deployed. diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md index e46b7a2..b52c1f9 100644 --- a/examples/white-label-minimal/README.md +++ b/examples/white-label-minimal/README.md @@ -15,7 +15,7 @@ Every Base44 call is one function in [server/base44.ts](server/base44.ts). | --- | --- | | [The build turn](https://docs.base44.com/developers/white-label/the-build-turn) | `createApp`, `sendMessage`, `submitToolCallInput` | | Watch it build, live | `openLiveSession`, and the socket in [components/useLiveChat.ts](components/useLiveChat.ts) | -| Answer the builder's questions | [components/ToolQuestion.tsx](components/ToolQuestion.tsx): a choice, values to enter, or an approval | +| Answer the AI chat's questions | [components/ToolQuestion.tsx](components/ToolQuestion.tsx): a choice, values to enter, or an approval | | [Embed the app](https://docs.base44.com/developers/white-label/embed-the-app) | `getPreviewUrl` and [components/AppPreview.tsx](components/AppPreview.tsx) | | [Custom instructions](https://docs.base44.com/developers/white-label/custom-instructions) | `customInstructions`, sent with every new app | @@ -57,7 +57,7 @@ Open [Tiny Sunny](http://127.0.0.1:3001). - **Sign-in and app ownership.** Everyone sees every app. In production, sign your users in, record who created each app, and check it on every call: Base44 sees only the one account. -- **Publishing.** The builder is told it isn't available. +- **Publishing.** The AI chat is told it isn't available. - **Closing the live session.** It expires after an hour. ## Deploy to Netlify diff --git a/netlify.toml b/netlify.toml index 24a3483..66d3acf 100644 --- a/netlify.toml +++ b/netlify.toml @@ -2,7 +2,7 @@ # on detection — do not add it as a plugin here. # # `build:deploy` applies pending Prisma migrations before `next build`, so a -# deploy is what moves the schema forward. See docs/deploy.md. +# deploy is what moves the schema forward. See README.md → Deploy. [build] command = "npm run build:deploy" @@ -25,7 +25,7 @@ # `build:deploy` is `prisma migrate deploy && next build`, so a preview holding # production's DATABASE_URL would move production's schema forward on every pull # request. `npm run build` is the database-free build; point the preview scope at a -# Neon branch and apply schema changes to it yourself. See docs/deploy.md. +# Neon branch and apply schema changes to it yourself. See README.md → Deploy. [context.deploy-preview] command = "npm run build" diff --git a/scripts/base44-live-check.ts b/scripts/base44-live-check.ts index c500518..23d16cf 100644 --- a/scripts/base44-live-check.ts +++ b/scripts/base44-live-check.ts @@ -1,5 +1,5 @@ /** - * Manual live checklist for the Base44 bridge (docs/base44-identity.md). + * Manual live checklist for the Base44 bridge. * * This is NOT part of `npm test` and never will be: it **calls a live Base44 * workspace** with the integration account's real access token. It exists diff --git a/scripts/sunny-api-smoke.ts b/scripts/sunny-api-smoke.ts index 9be249d..a2f583d 100644 --- a/scripts/sunny-api-smoke.ts +++ b/scripts/sunny-api-smoke.ts @@ -2,7 +2,7 @@ * Contract test for /api/sunny — the public data API that externally-hosted built * apps call. Getting this *nearly* right silently breaks deployed apps, so every * assertion here is a statement about the wire contract in - * the `sunny-platform` skill (docs/sunny-platform-skill.md), not about this + * the `sunny-platform` skill (src/lib/sunny-platform-skill.md), not about this * implementation. * * What it pins down: diff --git a/src/app/api/base44/platform/route.ts b/src/app/api/base44/platform/route.ts index e6a1ff1..12acf7a 100644 --- a/src/app/api/base44/platform/route.ts +++ b/src/app/api/base44/platform/route.ts @@ -133,7 +133,7 @@ const OPS: Record = { method: "GET", path: (p) => `/api/apps/${str(p.appId)}`, }, - /** `name` only. PUT, not PATCH — see docs/base44-platform-api.md. */ + /** `name` only. PUT, not PATCH. */ renameApp: { method: "PUT", path: (p) => `/api/apps/${str(p.appId)}`, diff --git a/src/app/api/sunny/route.ts b/src/app/api/sunny/route.ts index b0dc9d9..4a162ef 100644 --- a/src/app/api/sunny/route.ts +++ b/src/app/api/sunny/route.ts @@ -2,7 +2,7 @@ * POST /api/sunny — the public data API for the apps this shell's users build. * * The spec is the `sunny-platform` skill the builder reads (see - * docs/sunny-platform-skill.md), not this file: built apps are written against + * src/lib/sunny-platform-skill.md), not this file: built apps are written against * that contract, so the actions, params, response keys and error strings all have * to match it. `npm run sunny:smoke` asserts that contract action by action. * diff --git a/src/components/AppBuilderSidebar.jsx b/src/components/AppBuilderSidebar.jsx index 57e2624..71ecbfc 100644 --- a/src/components/AppBuilderSidebar.jsx +++ b/src/components/AppBuilderSidebar.jsx @@ -813,7 +813,7 @@ export default function AppBuilderSidebar({ )} {builderReady === false && embedded && (
-

The app builder is not configured on this deployment.

+

App building isn't configured on this deployment.