From 4885b4dcfdce9758cf3a150bfe6e1e5db13f4861 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 11:32:45 +0300 Subject: [PATCH 01/28] Tiny Sunny: slim the integration files so they read easily - api-handler: one action table replaces the switch; unread fields are rejected - client and identity share one fetch helper (http.ts) - static preview derivation and probe move to static-preview.ts; the probe cache is now module-level, so it survives across requests - smaller config check, named page size, README copy list updated Co-Authored-By: Claude Opus 5.5 --- examples/white-label-minimal/README.md | 4 +- .../white-label-minimal/lib/base44/client.ts | 302 ++++++------------ .../white-label-minimal/lib/base44/config.ts | 10 +- .../white-label-minimal/lib/base44/http.ts | 43 +++ .../lib/base44/identity.ts | 130 +++----- .../lib/base44/static-preview.ts | 25 ++ .../lib/server/api-handler.ts | 268 +++++++--------- .../lib/storage/app-list.ts | 8 +- .../lib/storage/app-repository.ts | 3 +- examples/white-label-minimal/lib/types.ts | 3 + 10 files changed, 357 insertions(+), 439 deletions(-) create mode 100644 examples/white-label-minimal/lib/base44/http.ts create mode 100644 examples/white-label-minimal/lib/base44/static-preview.ts diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md index 2e45a1e..6b72e75 100644 --- a/examples/white-label-minimal/README.md +++ b/examples/white-label-minimal/README.md @@ -41,8 +41,8 @@ and workspace are available here too. Start with [lib/base44/client.ts](lib/base44/client.ts). It contains the Base44 endpoints and request payloads for creation, conversation, tool answers, preview, -and publishing. Copy it with `lib/base44/config.ts`, `lib/base44/error.ts`, -`lib/base44/custom-instructions.ts`, and `lib/types.ts`. It uses `fetch` and `server-only`, with +and publishing. Copy it with `lib/base44/http.ts`, `lib/base44/static-preview.ts`, +`lib/base44/config.ts`, `lib/base44/error.ts`, `lib/base44/custom-instructions.ts`, and `lib/types.ts`. It uses `fetch` and `server-only`, with no Sunny imports. Set `BASE44_PLATFORM_HOST` to your Base44 HTTPS origin and adapt `lib/base44/custom-instructions.ts` to your product. New apps use the first 80 characters of the prompt as their initial name. diff --git a/examples/white-label-minimal/lib/base44/client.ts b/examples/white-label-minimal/lib/base44/client.ts index e58517c..ba512d4 100644 --- a/examples/white-label-minimal/lib/base44/client.ts +++ b/examples/white-label-minimal/lib/base44/client.ts @@ -1,219 +1,125 @@ import "server-only"; -import { customInstructions } from "./custom-instructions"; import type { App, Message, ToolInput } from "../types"; +import { customInstructions } from "./custom-instructions"; import { Base44Error } from "./error"; -import { getBase44Config } from "./config"; +import { base44Fetch } from "./http"; +import { staticPreviewUrl } from "./static-preview"; -export function createBase44Client(accessToken: string) { - async function request( - path: string, - { - body, - timeout = 30_000, - headers, - }: { - body?: object; - timeout?: number; - headers?: Record; - } = {}, - ) { - const { host } = getBase44Config(); - let response: Response; - try { - response = await fetch(`${host}${path}`, { - method: body ? "POST" : "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - ...headers, - }, - body: body ? JSON.stringify(body) : undefined, - cache: "no-store", - redirect: "error", - signal: AbortSignal.timeout(timeout), - }); - } catch { - // Never include fetch errors: they can contain a URL or upstream credentials. - throw new Base44Error( - "Base44 did not return a response. The operation may still be running.", - 504, - ); - } - if (!response.ok) { - const hint = - response.status === 401 - ? "Reconnect your workspace." - : response.status === 403 - ? "Check the account’s workspace access." - : response.status === 429 - ? "Rate limit reached. Wait before trying again." - : response.status === 409 - ? "The app may not build yet. Ask the agent to fix it." - : "Refresh the app state before repeating an operation."; - throw new Base44Error(`Base44 returned ${response.status}. ${hint}`, response.status); - } - const text = await response.text(); - if (!text) return null; - try { - return JSON.parse(text); - } catch { - throw new Base44Error("Base44 returned an unreadable response. The outcome is uncertain."); - } - } +const hints: Record = { + 401: "Reconnect your workspace.", + 403: "Check the account’s workspace access.", + 409: "The app may not build yet. Ask the agent to fix it.", + 429: "Rate limit reached. Wait before trying again.", +}; - // Whether a derived static preview URL actually serves a build. Cached - // because getApp is polled every two seconds while an app is building. - const probes = new Map(); - const probeTtl = 60_000; - async function servesABuild(url: string) { - const cached = probes.get(url); - if (cached && Date.now() - cached.at < probeTtl) return cached.ok; - let ok = false; - try { - const response = await fetch(url, { - redirect: "follow", - headers: { Accept: "text/html" }, - signal: AbortSignal.timeout(5_000), - }); - // An app with no build yet answers with a JSON error, which an iframe - // would render to the user as raw JSON. - ok = response.ok && (response.headers.get("content-type") || "").includes("text/html"); - } catch { - ok = false; - } - probes.set(url, { ok, at: Date.now() }); - return ok; - } +export function createBase44Client(accessToken: string) { + const request = (path: string, options: Parameters[1] = {}) => + base44Fetch(path, { + ...options, + headers: { Authorization: `Bearer ${accessToken}`, ...options.headers }, + errorFor: (status) => new Base44Error( + `Base44 returned ${status}. ${hints[status] ?? "Refresh the app state before repeating an operation."}`, + status, + ), + }); + const post = (path: string, body: object, headers?: Record) => + request(path, { body, headers, timeout: 120_000 }).then(() => ({})); - function staticPreviewUrl(slug: App["slug"]) { - const domain = process.env.BASE44_STATIC_PREVIEW_DOMAIN; - if (!domain || !slug || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(slug) || - !/^[a-z0-9.-]+$/i.test(domain)) return undefined; - return `https://preview--${slug}.${domain}`; + // Return only the fields the browser needs; never pass the raw Base44 app through. + async function toApp(app: App): Promise { + if (typeof app?.id !== "string") throw new Base44Error("Base44 returned an app without an ID."); + const { id, name, slug, preview_screenshot_url, logo_url, user_description, status } = app; + return { + id, name, slug, preview_screenshot_url, logo_url, user_description, + static_preview_url: await staticPreviewUrl(slug), + status: status && { state: status.state, error_source: status.error_source }, + }; } - // The static preview URL is derived here rather than returned by Base44, so - // it can name a build that does not exist. Verify the guess before handing - // it to a browser that would point an iframe at it. - async function appSummary(value: App): Promise { - if (!value || typeof value.id !== "string") - throw new Base44Error("Base44 returned an app without an ID."); - const derived = staticPreviewUrl(value.slug); + function toMessage(m: Message): Message { + if (typeof m?.id !== "string" || !m.id) throw new Base44Error("A conversation message has no stable ID."); return { - id: value.id, - name: value.name, - slug: value.slug, - static_preview_url: derived && (await servesABuild(derived)) ? derived : undefined, - preview_screenshot_url: value.preview_screenshot_url, - logo_url: value.logo_url, - user_description: value.user_description, - status: value.status - ? { state: value.status.state, error_source: value.status.error_source } - : undefined, + id: m.id, + role: m.role, + content: m.content, + hidden: m.hidden, + tool_calls: m.tool_calls?.map((t) => ({ + id: t.id, + name: t.name, + status: t.status, + waiting_on: t.waiting_on, + arguments_string: t.arguments_string, + results: typeof t.results === "string" ? t.results : undefined, + })), }; } - async function createApp(prompt: string) { - const app = await request("/api/apps", { - body: { - name: prompt.trim().slice(0, 80), - user_description: prompt, - initial_message: { content: prompt }, - custom_instructions: customInstructions, - prevent_iframe_embedding: false, - }, - timeout: 120_000, - }); - return appSummary(app); - } + return { + createApp: async (prompt: string) => + toApp(await request("/api/apps", { + body: { + name: prompt.trim().slice(0, 80), + user_description: prompt, + initial_message: { content: prompt }, + custom_instructions: customInstructions, + prevent_iframe_embedding: false, + }, + timeout: 120_000, + })), - const getApp = async (id: string) => appSummary(await request(`/api/apps/${id}`)); - async function getConversation(id: string, skip: number) { - const data = await request(`/api/apps/${id}/chat/full-conversation?limit=20&skip=${skip}`, { - timeout: 60_000, - }); - if (!data || typeof data !== "object") - throw new Base44Error("Unexpected conversation response."); - if (data.messages != null && !Array.isArray(data.messages)) - throw new Base44Error("Unexpected conversation response."); - const messages: Message[] = (data.messages ?? []).map((m: Message) => { - if (!m || typeof m.id !== "string" || !m.id) - throw new Base44Error("A conversation message has no stable ID."); - return { - id: m.id, - role: m.role, - content: m.content, - hidden: m.hidden, - tool_calls: m.tool_calls?.map((t) => ({ - id: t.id, - name: t.name, - status: t.status, - waiting_on: t.waiting_on, - arguments_string: t.arguments_string, - results: typeof t.results === "string" ? t.results : undefined, - })), - }; - }); - return { messages }; - } - const sendMessage = (id: string, content: string) => - request(`/api/apps/${id}/chat/message`, { body: { content }, timeout: 120_000 }).then( - () => ({}), - ); - const submitToolCallInput = (p: ToolInput) => - request(`/api/apps/${p.appId}/chat/submit-tool-call-input`, { - body: { + getApp: async (id: string) => toApp(await request(`/api/apps/${id}`)), + + // skip counts backward from the newest message. + async getConversation(id: string, skip: number) { + const data = await request(`/api/apps/${id}/chat/full-conversation?limit=20&skip=${skip}`, { timeout: 60_000 }); + const messages = data?.messages ?? []; + if (!Array.isArray(messages)) throw new Base44Error("Unexpected conversation response."); + return { messages: messages.map(toMessage) }; + }, + + sendMessage: (id: string, content: string) => post(`/api/apps/${id}/chat/message`, { content }), + + // A fixed request ID lets Base44 deduplicate retries of the same answer. + submitToolCallInput: (p: ToolInput) => + post(`/api/apps/${p.appId}/chat/submit-tool-call-input`, { tool_call_id: p.toolCallId, message_id: p.messageId, action: p.approve ? "approved" : "rejected", extra_user_input: p.extraUserInput, - }, - timeout: 120_000, - headers: { "X-Request-ID": `submit-${p.toolCallId}` }, - }).then(() => ({})); - const deployApp = (id: string) => - request(`/api/apps/${id}/deploy`, { body: {}, timeout: 120_000 }).then(() => ({})); + }, { "X-Request-ID": `submit-${p.toolCallId}` }), - function httpsUrl(raw: unknown) { - if (typeof raw !== "string" || !raw) - throw new Base44Error("Base44 has not returned a URL yet. Try again shortly."); - let url: URL; - try { - url = new URL(raw.includes("://") ? raw : `https://${raw}`); - } catch { - throw new Base44Error("Base44 returned an invalid URL."); - } - if (url.protocol !== "https:" || url.username || url.password) - throw new Base44Error("Base44 returned an unsafe URL."); - return url; - } - async function getPreviewUrl(id: string) { - const data = await request(`/api/apps/${id}/sandbox/preview-url`, { timeout: 120_000 }); - const url = httpsUrl(data?.preview_url); - if (data.preview_token) url.searchParams.set("_preview_token", data.preview_token); - url.searchParams.set("server_url", url.origin); - url.searchParams.set("hide_badge", "true"); - url.searchParams.set("analytics-enable", "false"); - return { url: url.href }; - } - async function getPublishedUrl(id: string) { - try { - const data = await request(`/api/apps/platform/${id}/published-url`); - return { url: httpsUrl(data?.url).href }; - } catch (error) { - if (error instanceof Base44Error && error.status === 404) return { url: null }; - throw error; - } - } + deployApp: (id: string) => post(`/api/apps/${id}/deploy`, {}), - return { - createApp, - getApp, - getConversation, - sendMessage, - submitToolCallInput, - getPreviewUrl, - deployApp, - getPublishedUrl, + async getPreviewUrl(id: string) { + const data = await request(`/api/apps/${id}/sandbox/preview-url`, { timeout: 120_000 }); + const url = httpsUrl(data?.preview_url); + if (data.preview_token) url.searchParams.set("_preview_token", data.preview_token); + url.searchParams.set("server_url", url.origin); + url.searchParams.set("hide_badge", "true"); + url.searchParams.set("analytics-enable", "false"); + return { url: url.href }; + }, + + async getPublishedUrl(id: string) { + try { + const data = await request(`/api/apps/platform/${id}/published-url`); + return { url: httpsUrl(data?.url).href }; + } catch (error) { + if (error instanceof Base44Error && error.status === 404) return { url: null }; + throw error; + } + }, }; } + +function httpsUrl(raw: unknown) { + if (typeof raw !== "string" || !raw) throw new Base44Error("Base44 has not returned a URL yet. Try again shortly."); + let url: URL; + try { + url = new URL(raw.includes("://") ? raw : `https://${raw}`); + } catch { + throw new Base44Error("Base44 returned an invalid URL."); + } + if (url.protocol !== "https:" || url.username || url.password) throw new Base44Error("Base44 returned an unsafe URL."); + return url; +} diff --git a/examples/white-label-minimal/lib/base44/config.ts b/examples/white-label-minimal/lib/base44/config.ts index bd551e7..6c4e113 100644 --- a/examples/white-label-minimal/lib/base44/config.ts +++ b/examples/white-label-minimal/lib/base44/config.ts @@ -8,14 +8,8 @@ export function getBase44Config() { } catch { throw new Base44Error("The workspace connection is not configured.", 503); } - if ( - host.protocol !== "https:" || - host.username || - host.password || - host.pathname !== "/" || - host.search || - host.hash - ) { + // A bare origin only: no credentials, path, query, or fragment. + if (host.protocol !== "https:" || host.href !== `${host.origin}/`) { throw new Base44Error("The workspace connection requires an HTTPS platform origin.", 503); } return { host: host.origin }; diff --git a/examples/white-label-minimal/lib/base44/http.ts b/examples/white-label-minimal/lib/base44/http.ts new file mode 100644 index 0000000..b78106b --- /dev/null +++ b/examples/white-label-minimal/lib/base44/http.ts @@ -0,0 +1,43 @@ +import "server-only"; +import { Base44Error } from "./error"; +import { getBase44Config } from "./config"; + +type Options = { + body?: object | URLSearchParams; + headers?: Record; + timeout?: number; + errorFor?: (status: number) => Base44Error; +}; + +/** POST when there is a body, GET otherwise. Every failure is a Base44Error that is safe to show. */ +export async function base44Fetch(path: string, { body, headers, timeout = 30_000, errorFor }: Options = {}) { + const { host } = getBase44Config(); + const form = body instanceof URLSearchParams; + let response: Response; + try { + response = await fetch(`${host}${path}`, { + method: body ? "POST" : "GET", + headers: { + "Content-Type": form ? "application/x-www-form-urlencoded" : "application/json", + ...headers, + }, + body: form ? body : body && JSON.stringify(body), + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(timeout), + }); + } catch { + // Never include fetch errors: they can contain a URL or upstream credentials. + throw new Base44Error("Base44 did not return a response. The operation may still be running.", 504); + } + if (!response.ok) { + throw errorFor?.(response.status) ?? new Base44Error(`Base44 returned ${response.status}.`, response.status); + } + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + throw new Base44Error("Base44 returned an unreadable response. The outcome is uncertain."); + } +} diff --git a/examples/white-label-minimal/lib/base44/identity.ts b/examples/white-label-minimal/lib/base44/identity.ts index 8c79b84..eefd98f 100644 --- a/examples/white-label-minimal/lib/base44/identity.ts +++ b/examples/white-label-minimal/lib/base44/identity.ts @@ -2,9 +2,12 @@ import "server-only"; import { createHash } from "node:crypto"; import type { Base44Link } from "../../generated/prisma"; import { prisma } from "../storage/db"; -import { Base44Error } from "./error"; -import { getBase44Config } from "./config"; import { withIdentityLock } from "../storage/identity-repository"; +import { Base44Error } from "./error"; +import { base44Fetch } from "./http"; + +// Each Sunny user gets one Base44 service user. We keep its tokens server-side +// and refresh the access token shortly before it expires. export function getLink(email: string) { return prisma.base44Link.findUnique({ where: { appUserEmail: email.toLowerCase() } }); @@ -18,84 +21,30 @@ export function linkStatus(link: Base44Link | null) { }; } -function required(name: string) { - const value = process.env[name]?.trim(); - if (!value) throw new Base44Error(`Configure ${name} on the server.`, 503); - return value; -} - -async function request(path: string, body: object | URLSearchParams, key?: string) { - const { host } = getBase44Config(); - let response: Response; - try { - response = await fetch(`${host}${path}`, { - method: "POST", - headers: { - "Content-Type": body instanceof URLSearchParams - ? "application/x-www-form-urlencoded" : "application/json", - ...(key ? { Authorization: key } : {}), - }, - body: body instanceof URLSearchParams ? body : JSON.stringify(body), - cache: "no-store", - redirect: "error", - signal: AbortSignal.timeout(30_000), - }); - } catch { - throw new Base44Error("Could not reach Base44. Please try again.", 504); - } - if (!response.ok) { - const expired = path === "/oauth/token" && [400, 401, 403].includes(response.status); - throw new Base44Error( - expired ? "Reconnect your workspace to continue." : `Base44 returned ${response.status}. Please try again.`, - expired ? 428 : 502, - ); - } - try { - return await response.json(); - } catch { - throw new Base44Error("Base44 returned an unreadable identity response."); - } -} - -function tokenFields(data: Record | null, previousRefreshToken?: string | null) { - if (!data) throw new Base44Error("Base44 returned invalid credentials."); - const refreshToken = data.refresh_token ?? previousRefreshToken; - const expiresIn = Number(data.expires_in); - if (typeof data.access_token !== "string" || !data.access_token || - typeof refreshToken !== "string" || !refreshToken || - !Number.isFinite(expiresIn) || expiresIn <= 0) { - throw new Base44Error("Base44 returned invalid credentials."); - } - return { - accessToken: data.access_token, - refreshToken, - expiresAt: new Date(Date.now() + expiresIn * 1000), - }; -} - export async function connect(email: string) { const appUserEmail = email.toLowerCase(); const organizationId = required("BASE44_ORG_ID"); const key = required("BASE44_SVC_KEY"); const existing = await getLink(appUserEmail); + // Deterministic, so reconnecting finds the same service user and its apps. const digest = createHash("sha256").update(`${organizationId}:${appUserEmail}`).digest("hex"); const serviceExternalId = existing?.serviceExternalId ?? `sunny-${digest.slice(0, 32)}`; - const principal = await request("/api/service/users", { + + const principal = await post("/api/service/users", { service_external_id: serviceExternalId, display_name: `Builder ${serviceExternalId.slice(-8)}`, }, process.env.BASE44_PROVISION_KEY?.trim() || key); - const domain = typeof principal?.email === "string" ? principal.email.split("@")[1] : ""; - if (!domain || !(domain === "svc.base44.invalid" || domain.endsWith(".svc.base44.invalid"))) + const base44UserEmail = principal?.email; + if (typeof base44UserEmail !== "string" || !/@([^@]+\.)?svc\.base44\.invalid$/.test(base44UserEmail)) throw new Base44Error("Base44 returned an invalid service-user identity."); - const tokens = await request("/api/service/user-tokens", { - service_external_id: serviceExternalId, - }, key); + + const tokens = await post("/api/service/user-tokens", { service_external_id: serviceExternalId }, key); const record = { ...tokenFields(tokens), status: "linked" as const, organizationId, serviceExternalId, - base44UserEmail: principal.email, + base44UserEmail, principalProvisioned: true, }; return linkStatus(await prisma.base44Link.upsert({ @@ -105,17 +54,17 @@ export async function connect(email: string) { })); } -export async function getBase44AccessToken(email: string): Promise { +export function getBase44AccessToken(email: string): Promise { const appUserEmail = email.toLowerCase(); + // The row lock stops two requests from spending the same refresh token. return withIdentityLock(appUserEmail, async (identities) => { const link = await identities.findUnique({ where: { appUserEmail } }); if (link?.status !== "linked" || !link.accessToken) throw new Base44Error("Connect your workspace to start building.", 428); - if (link.expiresAt && link.expiresAt.getTime() > Date.now() + 60_000) - return link.accessToken; - if (!link.refreshToken) - throw new Base44Error("Reconnect your workspace to continue.", 428); - const tokens = await request("/oauth/token", new URLSearchParams({ + if (link.expiresAt && link.expiresAt.getTime() > Date.now() + 60_000) return link.accessToken; + if (!link.refreshToken) throw new Base44Error("Reconnect your workspace to continue.", 428); + + const tokens = await post("/oauth/token", new URLSearchParams({ grant_type: "refresh_token", client_id: "svc_delegate", refresh_token: link.refreshToken, @@ -131,17 +80,9 @@ export async function getBase44AccessToken(email: string): Promise { export async function disconnect(email: string) { const link = await getLink(email); if (link?.refreshToken) { - const { host } = getBase44Config(); - try { - await fetch(`${host}/oauth/revoke`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ token: link.refreshToken, client_id: "svc_delegate" }), - redirect: "error", - signal: AbortSignal.timeout(15_000), - }); - } catch { - } + // Best effort: the local tokens are cleared even if revocation fails. + await post("/oauth/revoke", new URLSearchParams({ token: link.refreshToken, client_id: "svc_delegate" })) + .catch(() => {}); } await prisma.base44Link.updateMany({ where: { appUserEmail: email.toLowerCase() }, @@ -149,3 +90,30 @@ export async function disconnect(email: string) { }); return linkStatus(null); } + +function required(name: string) { + const value = process.env[name]?.trim(); + if (!value) throw new Base44Error(`Configure ${name} on the server.`, 503); + return value; +} + +function post(path: string, body: object | URLSearchParams, key?: string) { + return base44Fetch(path, { + body, + headers: key ? { Authorization: key } : {}, + // A rejected refresh token means the user must reconnect. + errorFor: (status) => path === "/oauth/token" && [400, 401, 403].includes(status) + ? new Base44Error("Reconnect your workspace to continue.", 428) + : new Base44Error(`Base44 returned ${status}. Please try again.`, 502), + }); +} + +function tokenFields(data: Record | null, previousRefreshToken?: string | null) { + const accessToken = data?.access_token; + const refreshToken = data?.refresh_token ?? previousRefreshToken; + const expiresIn = Number(data?.expires_in); + if (typeof accessToken !== "string" || !accessToken || typeof refreshToken !== "string" || !refreshToken || + !Number.isFinite(expiresIn) || expiresIn <= 0) + throw new Base44Error("Base44 returned invalid credentials."); + return { accessToken, refreshToken, expiresAt: new Date(Date.now() + expiresIn * 1000) }; +} diff --git a/examples/white-label-minimal/lib/base44/static-preview.ts b/examples/white-label-minimal/lib/base44/static-preview.ts new file mode 100644 index 0000000..31a9689 --- /dev/null +++ b/examples/white-label-minimal/lib/base44/static-preview.ts @@ -0,0 +1,25 @@ +import "server-only"; + +// Base44 does not return a static preview URL; we derive it from the slug. +// The derived URL can name a build that does not exist yet, and the host then +// answers with a JSON error that an iframe would show as raw JSON. So we probe +// it first, and cache the result because getApp is polled every two seconds. +const probes = new Map(); + +export async function staticPreviewUrl(slug: string | null | undefined) { + const domain = process.env.BASE44_STATIC_PREVIEW_DOMAIN; + if (!domain || !/^[a-z0-9.-]+$/i.test(domain)) return undefined; + if (!slug || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(slug)) return undefined; + const url = `https://preview--${slug}.${domain}`; + return (await servesHtml(url)) ? url : undefined; +} + +async function servesHtml(url: string) { + const cached = probes.get(url); + if (cached && Date.now() - cached.at < 60_000) return cached.ok; + const ok = await fetch(url, { headers: { Accept: "text/html" }, signal: AbortSignal.timeout(5_000) }) + .then((r) => r.ok && (r.headers.get("content-type") ?? "").includes("text/html")) + .catch(() => false); + probes.set(url, { ok, at: Date.now() }); + return ok; +} diff --git a/examples/white-label-minimal/lib/server/api-handler.ts b/examples/white-label-minimal/lib/server/api-handler.ts index 5f5f24e..1e1cfa5 100644 --- a/examples/white-label-minimal/lib/server/api-handler.ts +++ b/examples/white-label-minimal/lib/server/api-handler.ts @@ -1,161 +1,54 @@ import { Base44Error } from "../base44/error"; import type { AppClient } from "../types"; -const headers = { "Cache-Control": "no-store, private", "Referrer-Policy": "no-referrer" }; -const bad = (message: string, status = 400): never => { - throw new Base44Error(message, status); +type Body = ReturnType; + +// The browser may call only these actions. Each one validates and returns the +// arguments for the AppClient method of the same name. +const actions: Record unknown[]> = { + listApps: (b) => [b.skip()], + createApp: (b) => [b.text("prompt")], + removeApp: (b) => [b.id("appId")], + getApp: (b) => [b.id("appId")], + getConversation: (b) => [b.id("appId"), b.skip()], + sendMessage: (b) => [b.id("appId"), b.text("content")], + submitToolCallInput: (b) => [{ + appId: b.id("appId"), + toolCallId: b.id("toolCallId"), + messageId: b.id("messageId"), + approve: b.boolean("approve"), + extraUserInput: b.object("extraUserInput"), + }], + getPreviewUrl: (b) => [b.id("appId")], + deployApp: (b) => [b.id("appId")], + getPublishedUrl: (b) => [b.id("appId")], }; +const headers = { "Cache-Control": "no-store, private", "Referrer-Policy": "no-referrer" }; + export function createHandler(resolveClient: () => Promise) { return async function POST(request: Request) { + // After dispatch, a failure may still have changed something upstream. let dispatched = false; try { - const host = request.headers.get("host"); - const origin = request.headers.get("origin"); - const hostedOrigin = process.env.BUILDER_ORIGIN; - if (hostedOrigin) { - const allowed = new URL(hostedOrigin); - if ( - allowed.protocol !== "https:" || - allowed.origin !== hostedOrigin || - host !== allowed.host || - origin !== hostedOrigin - ) - bad("This request origin is not allowed.", 403); - } else if ( - !host || - !/^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(host) || - origin !== `http://${host}` - ) { - bad("This request origin is not allowed.", 403); - } + checkOrigin(request); const client = await resolveClient(); - if (request.headers.get("content-type")?.split(";")[0].trim() !== "application/json") - bad("Send application/json.", 415); - const reader = request.body?.getReader(); - if (!reader) return bad("A JSON body is required."); - const chunks: Uint8Array[] = []; - let size = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > 64_000) { - await reader.cancel(); - bad("Request body is too large.", 413); - } - chunks.push(value); - } - let p: Record; - try { - p = JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch { - return Response.json( - { error: "Invalid JSON.", outcome: "not_started" }, - { status: 400, headers }, - ); - } - if (!p || typeof p !== "object" || Array.isArray(p)) bad("Expected a JSON object."); - const fields = (...allowed: string[]) => { - if (Object.keys(p).some((k) => !["action", ...allowed].includes(k))) - bad("Unexpected request field."); - }; - const string = (key: string, max = 16_000): string => { - const v = p[key]; - if (typeof v !== "string" || !v.trim() || v.length > max) return bad(`Invalid ${key}.`); - return v; - }; - const id = (key: string) => { - const v = string(key, 200); - if (!/^[A-Za-z0-9_-]+$/.test(v)) bad(`Invalid ${key}.`); - return v; - }; - const skip = () => { - const value = p.skip ?? 0; - if ( - typeof value !== "number" || - !Number.isSafeInteger(value) || - value < 0 || - value > 100_000 - ) - return bad("Invalid skip."); - return value; - }; - function execute( - operation: (...args: Args) => Promise, - ...args: Args - ) { - dispatched = true; - return operation(...args); - } - let result; - if (p.appId !== undefined) await client.authorize(id("appId")); - switch (p.action) { - case "listApps": { - fields("skip"); - result = await execute(client.listApps, skip()); - break; - } - case "createApp": - fields("prompt"); - result = await execute(client.createApp, string("prompt")); - break; - case "removeApp": - fields("appId"); - result = await execute(client.removeApp, id("appId")); - break; - case "getApp": - fields("appId"); - result = await execute(client.getApp, id("appId")); - break; - case "getConversation": { - fields("appId", "skip"); - result = await execute(client.getConversation, id("appId"), skip()); - break; - } - case "sendMessage": - fields("appId", "content"); - result = await execute(client.sendMessage, id("appId"), string("content")); - break; - case "submitToolCallInput": { - fields("appId", "toolCallId", "messageId", "approve", "extraUserInput"); - if ( - typeof p.approve !== "boolean" || - !p.extraUserInput || - typeof p.extraUserInput !== "object" || - Array.isArray(p.extraUserInput) - ) - bad("Invalid tool-call decision or input."); - result = await execute(client.submitToolCallInput, { - appId: id("appId"), - toolCallId: id("toolCallId"), - messageId: id("messageId"), - approve: p.approve as boolean, - extraUserInput: p.extraUserInput as Record, - }); - break; - } - case "getPreviewUrl": - fields("appId"); - result = await execute(client.getPreviewUrl, id("appId")); - break; - case "deployApp": - fields("appId"); - result = await execute(client.deployApp, id("appId")); - break; - case "getPublishedUrl": - fields("appId"); - result = await execute(client.getPublishedUrl, id("appId")); - break; - default: - bad("Unsupported action."); - } - return Response.json(result, { headers }); + const json = await readJson(request); + if (json.appId !== undefined) await client.authorize(validId(json.appId, "appId")); + + const name = String(json.action); + if (!Object.hasOwn(actions, name)) fail("Unsupported action."); + const body = bodyReader(json); + const args = actions[name](body); + if (body.hasUnreadFields()) fail("Unexpected request field."); + + dispatched = true; + const method = client[name as keyof AppClient] as (...args: unknown[]) => Promise; + return Response.json(await method(...args), { headers }); } catch (error) { - const safe = - error instanceof Base44Error - ? error - : new Base44Error("The local request failed. Check configuration and refresh app state."); + const safe = error instanceof Base44Error + ? error + : new Base44Error("The local request failed. Check configuration and refresh app state."); return Response.json( { error: safe.message, outcome: dispatched ? "unknown" : "not_started" }, { status: safe.status, headers }, @@ -163,3 +56,84 @@ export function createHandler(resolveClient: () => Promise) { } }; } + +function fail(message: string, status = 400): never { + throw new Base44Error(message, status); +} + +// Only same-origin calls: BUILDER_ORIGIN when hosted, a loopback host locally. +function checkOrigin(request: Request) { + const host = request.headers.get("host"); + const origin = request.headers.get("origin"); + const hosted = process.env.BUILDER_ORIGIN; + let ok: boolean; + if (hosted) { + const url = new URL(hosted); + ok = url.protocol === "https:" && url.origin === hosted && host === url.host && origin === hosted; + } else { + ok = !!host && /^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(host) && origin === `http://${host}`; + } + if (!ok) fail("This request origin is not allowed.", 403); +} + +async function readJson(request: Request): Promise> { + if (request.headers.get("content-type")?.split(";")[0].trim() !== "application/json") + fail("Send application/json.", 415); + // Read in chunks so an oversized body is rejected without buffering all of it. + const reader = request.body?.getReader(); + if (!reader) fail("A JSON body is required."); + const chunks: Uint8Array[] = []; + let size = 0; + for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) { + size += chunk.value.byteLength; + if (size > 64_000) { + await reader.cancel(); + fail("Request body is too large.", 413); + } + chunks.push(chunk.value); + } + let json; + try { + json = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + fail("Invalid JSON."); + } + if (!json || typeof json !== "object" || Array.isArray(json)) fail("Expected a JSON object."); + return json; +} + +function validId(value: unknown, key: string) { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(value)) fail(`Invalid ${key}.`); + return value; +} + +// Validates fields by name and remembers which ones were read, so any extra +// field in the request is rejected. +function bodyReader(json: Record) { + const read = new Set(["action"]); + const get = (key: string) => (read.add(key), json[key]); + return { + id: (key: string) => validId(get(key), key), + text(key: string) { + const value = get(key); + if (typeof value !== "string" || !value.trim() || value.length > 16_000) fail(`Invalid ${key}.`); + return value; + }, + skip() { + const value = get("skip") ?? 0; + if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > 100_000) fail("Invalid skip."); + return value as number; + }, + boolean(key: string) { + const value = get(key); + if (typeof value !== "boolean") fail(`Invalid ${key}.`); + return value; + }, + object(key: string) { + const value = get(key); + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`Invalid ${key}.`); + return value as Record; + }, + hasUnreadFields: () => Object.keys(json).some((key) => !read.has(key)), + }; +} diff --git a/examples/white-label-minimal/lib/storage/app-list.ts b/examples/white-label-minimal/lib/storage/app-list.ts index 247e1c4..8fd90ae 100644 --- a/examples/white-label-minimal/lib/storage/app-list.ts +++ b/examples/white-label-minimal/lib/storage/app-list.ts @@ -1,12 +1,16 @@ import type { App } from "../types"; import { Base44Error } from "../base44/error"; +export const PAGE_SIZE = 12; + +// rows holds one extra row so we know whether another page exists. +// Apps deleted in Base44 (404) are dropped without shifting pagination. export async function resolveAppPage( rows: { appId: string }[], skip: number, getApp: (id: string) => Promise, ) { - const page = rows.slice(0, 12); + const page = rows.slice(0, PAGE_SIZE); const results = await Promise.all( page.map(async (row) => { try { @@ -19,7 +23,7 @@ export async function resolveAppPage( ); return { apps: results.filter((app): app is App => app !== null), - hasMore: rows.length > 12, + hasMore: rows.length > PAGE_SIZE, nextSkip: skip + page.length, }; } diff --git a/examples/white-label-minimal/lib/storage/app-repository.ts b/examples/white-label-minimal/lib/storage/app-repository.ts index a2e19b0..3446fa8 100644 --- a/examples/white-label-minimal/lib/storage/app-repository.ts +++ b/examples/white-label-minimal/lib/storage/app-repository.ts @@ -2,6 +2,7 @@ import "server-only"; import { prisma } from "./db"; import type { App } from "../types"; import { Base44Error } from "../base44/error"; +import { PAGE_SIZE } from "./app-list"; export function createAppRepository(actor: { email: string }) { const owner = { createdBy: actor.email }; @@ -29,7 +30,7 @@ export function createAppRepository(actor: { email: string }) { where: owner, orderBy: { createdAt: "desc" }, skip, - take: 13, + take: PAGE_SIZE + 1, select: { appId: true }, }); }, diff --git a/examples/white-label-minimal/lib/types.ts b/examples/white-label-minimal/lib/types.ts index 8942a85..7f8f50f 100644 --- a/examples/white-label-minimal/lib/types.ts +++ b/examples/white-label-minimal/lib/types.ts @@ -8,6 +8,7 @@ export type App = { user_description?: string; status?: { state?: string; error_source?: string }; }; + export type ToolCall = { id?: string | null; name?: string | null; @@ -16,6 +17,7 @@ export type ToolCall = { arguments_string?: string | null; results?: string | null; }; + export type Message = { id: string; role?: string | null; @@ -23,6 +25,7 @@ export type Message = { hidden?: boolean | null; tool_calls?: ToolCall[] | null; }; + export type ToolInput = { appId: string; toolCallId: string; From b264f0aadc9f584b9b9f3967646e582fe67572fd Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 16:01:40 +0300 Subject: [PATCH 02/28] Tiny Sunny: simplify the UI components - Workspace: derive the active name from the stage app, drop an unused ref, plain async load, connect call moves to builder-api - Question: one buildPayload helper (null while empty) and a toggle helper - Builder: one composerDisabled flag for the guard and the composer - Base44Preview: drop the onStatusChange prop nobody passed - remove unused CSS rules and an unreachable ToolActivity branch Co-Authored-By: Claude Opus 5.5 --- examples/white-label-minimal/app/globals.css | 21 -- .../components/Base44Preview.tsx | 5 +- .../components/Builder.tsx | 35 +-- .../components/PreviewFrame.tsx | 5 +- .../components/Question.tsx | 100 +++----- .../components/ToolActivity.tsx | 6 +- .../components/Workspace.tsx | 224 ++++++++---------- .../lib/chat/builder-api.ts | 11 +- .../lib/chat/conversation.ts | 2 +- 9 files changed, 152 insertions(+), 257 deletions(-) diff --git a/examples/white-label-minimal/app/globals.css b/examples/white-label-minimal/app/globals.css index ada2421..da3b6ca 100644 --- a/examples/white-label-minimal/app/globals.css +++ b/examples/white-label-minimal/app/globals.css @@ -147,23 +147,6 @@ h1 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 20px; } -.app-thumbnail { - display: flex; - width: 100%; - aspect-ratio: 4 / 3; - border: 0; - border-radius: 0; - padding: 0; - background: #edf0f4; - color: #c0c7d2; - font-size: 54px; - font-weight: 400; -} -.app-thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; -} .empty-state { min-height: 330px; display: flex; @@ -183,10 +166,6 @@ h1 { font-size: 13px; margin-bottom: 24px; } -.load-more { - display: flex; - margin: 30px auto; -} .error, .builder aside { background: #fff2e7; diff --git a/examples/white-label-minimal/components/Base44Preview.tsx b/examples/white-label-minimal/components/Base44Preview.tsx index c479f63..82aaa83 100644 --- a/examples/white-label-minimal/components/Base44Preview.tsx +++ b/examples/white-label-minimal/components/Base44Preview.tsx @@ -7,7 +7,6 @@ export type Base44PreviewProps = { title: string; live?: boolean; showControls?: boolean; - onStatusChange?: (status: string) => void; staticUrl?: string; screenshotUrl?: string; loadPreview: (appId: string) => Promise<{ url: string }>; @@ -17,7 +16,7 @@ export default function Base44Preview(props: Base44PreviewProps) { return ; } -function PreviewSession({ appId, live = false, title, staticUrl, screenshotUrl, loadPreview, showControls = true, onStatusChange }: Base44PreviewProps) { +function PreviewSession({ appId, live = false, title, staticUrl, screenshotUrl, loadPreview, showControls = true }: Base44PreviewProps) { const loadPreviewRef = useRef(loadPreview); useEffect(() => { loadPreviewRef.current = loadPreview; }, [loadPreview]); const frame = useRef(null); @@ -86,8 +85,6 @@ function PreviewSession({ appId, live = false, title, staticUrl, screenshotUrl, window.removeEventListener("message", recover); }; }, [appId, live, attempt]); - const status = error ? "Preview unavailable" : live ? loaded ? "Live build" : "Starting live…" : "Last build"; - useEffect(() => { onStatusChange?.(status); }, [onStatusChange, status]); const retry = () => { setUrl(""); setLoaded(false); setError(""); setAttempt(value => value + 1); }; const frameProps = { referrerPolicy: "no-referrer" as const, sandbox: "allow-scripts allow-same-origin allow-forms allow-popups" }; return
diff --git a/examples/white-label-minimal/components/Builder.tsx b/examples/white-label-minimal/components/Builder.tsx index 565269e..780188d 100644 --- a/examples/white-label-minimal/components/Builder.tsx +++ b/examples/white-label-minimal/components/Builder.tsx @@ -1,10 +1,10 @@ "use client"; -import { hasCompletedBuild } from "../lib/chat/build-readiness"; -import type { App, ToolInput } from "../lib/types"; import { useEffect, useMemo, useRef, useState } from "react"; -import * as api from "../lib/chat/builder-api"; import { ArrowRight, Check, Loader2 } from "lucide-react"; +import * as api from "../lib/chat/builder-api"; +import { hasCompletedBuild } from "../lib/chat/build-readiness"; import { mergeOptimisticMessages, type OptimisticMessage } from "../lib/chat/optimistic-messages"; +import type { App, ToolInput } from "../lib/types"; import BuilderChat from "./BuilderChat"; import { useBuildPolling } from "./useBuildPolling"; @@ -27,16 +27,13 @@ export default function Builder({ const [optimistic, setOptimistic] = useState([]); const lock = useRef(false); const { app, messages, error: pollingError, loading, refresh, resume } = useBuildPolling(appId); - const displayedMessages = useMemo( - () => mergeOptimisticMessages(messages, optimistic), [messages, optimistic], - ); + const displayedMessages = useMemo(() => mergeOptimisticMessages(messages, optimistic), [messages, optimistic]); useEffect(() => { if (app) onUpdated?.(app); }, [app, onUpdated]); - const waiting = messages.some((m) => - m.tool_calls?.some((t) => t.status === "waiting_for_user_input"), - ); + const waiting = messages.some((m) => m.tool_calls?.some((t) => t.status === "waiting_for_user_input")); const processing = app?.status?.state === "processing"; + const composerDisabled = loading || !!busy || waiting || processing || !!pollingError || creationUncertain; // A prompt/answer invalidates the previous ready state before polling catches up. // Preview and deploy operations themselves should keep the card mounted. const submittingBuild = busy === "Sending prompt…" || busy === "Answering question…"; @@ -44,16 +41,7 @@ export default function Builder({ !waiting && !pollingError && !submittingBuild && hasCompletedBuild(messages); async function send(prompt: string) { - if ( - lock.current || - loading || - !prompt.trim() || - waiting || - processing || - pollingError || - creationUncertain - ) - return false; + if (lock.current || composerDisabled || !prompt.trim()) return false; lock.current = true; const optimisticId = `local:${crypto.randomUUID()}`; setOptimistic((current) => [...current, { @@ -161,7 +149,7 @@ export default function Builder({ busy={!!busy} processing={processing} waiting={waiting} - disabled={loading || !!busy || waiting || processing || !!pollingError || creationUncertain} + disabled={composerDisabled} questionsDisabled={!!busy || !!pollingError} onSend={send} onAnswer={answer} @@ -182,12 +170,7 @@ export default function Builder({ {(busy || waiting || processing || pollingError) && (
{(busy || processing) && } - {busy || - (pollingError - ? "Connection paused" - : waiting - ? "Waiting for your answer" - : "Building…")} + {busy || (pollingError ? "Connection paused" : waiting ? "Waiting for your answer" : "Building…")}
)} diff --git a/examples/white-label-minimal/components/PreviewFrame.tsx b/examples/white-label-minimal/components/PreviewFrame.tsx index 32d7a16..7baefed 100644 --- a/examples/white-label-minimal/components/PreviewFrame.tsx +++ b/examples/white-label-minimal/components/PreviewFrame.tsx @@ -2,15 +2,14 @@ import { getPreviewUrl } from "../lib/chat/builder-api"; import type { App } from "../lib/types"; -import Base44Preview, { type Base44PreviewProps } from "./Base44Preview"; +import Base44Preview from "./Base44Preview"; -export default function PreviewFrame({ app, live = false, title, showControls, onStatusChange }: { app: App; live?: boolean; title: string } & Pick) { +export default function PreviewFrame({ app, live = false, title, showControls }: { app: App; live?: boolean; title: string; showControls?: boolean }) { return ) => { if (typeof q.question !== "string" || !Array.isArray(q.options)) throw Error(); const options = q.options.map((o) => (typeof o === "string" ? o : o?.label)); @@ -27,17 +23,10 @@ export function parseQuestion( }); return { kind: "choice", choices }; } - if ( - tool.waiting_on?.kind === "input" && - Array.isArray(args.secrets_schema) && - args.secrets_schema.length - ) { + if (tool.waiting_on?.kind === "input" && Array.isArray(args.secrets_schema) && args.secrets_schema.length) { const fields = args.secrets_schema.map((s: Record) => { if (typeof s.secretName !== "string" || !s.secretName) throw Error(); - return { - name: s.secretName, - description: typeof s.description === "string" ? s.description : "", - }; + return { name: s.secretName, description: typeof s.description === "string" ? s.description : "" }; }); return { kind: "input", fields }; } @@ -47,6 +36,26 @@ export function parseQuestion( return { kind: "unknown" }; } +/** The answer to send, or null while nothing has been filled in. */ +function buildPayload(question: ParsedQuestion, selected: Record, values: Record) { + if (question.kind === "choice") { + const answers = question.choices + .map((_, i) => ({ question_index: i, selected_labels: selected[i] || [], custom_text: values[i] || "" })) + .filter((a) => a.selected_labels.length || a.custom_text.trim()); + return answers.length ? { answers } : null; + } + if (question.kind === "input") { + const filled = question.fields.filter((f) => values[f.name]?.trim()); + return filled.length ? { secrets: Object.fromEntries(filled.map((f) => [f.name, values[f.name]])) } : null; + } + return {}; +} + +function toggle(current: string[] = [], label: string, multi: boolean) { + if (!multi) return [label]; + return current.includes(label) ? current.filter((x) => x !== label) : [...current, label]; +} + export default function Question({ tool, messageId, @@ -89,43 +98,14 @@ export default function Question({ setBusy(false); } } - const answers = - question.kind === "choice" - ? question.choices - .map((_, i) => ({ - question_index: i, - selected_labels: selected[i] || [], - custom_text: values[i] || "", - })) - .filter((a) => a.selected_labels.length || a.custom_text.trim()) - : []; - const secrets = - question.kind === "input" - ? Object.fromEntries( - question.fields - .filter((f) => values[f.name]?.trim()) - .map((f) => [f.name, values[f.name]]), - ) - : {}; - const payload = - question.kind === "choice" ? { answers } : question.kind === "input" ? { secrets } : {}; - const empty = - question.kind === "choice" - ? !answers.length - : question.kind === "input" - ? !Object.keys(secrets).length - : false; + const payload = buildPayload(question, selected, values); return (
{tool.name || "Agent action"}{" "} {submitted ? "Answer sent" : tool.status} {(question.kind === "unknown" || question.kind === "approval") && (
- - {question.kind === "unknown" - ? "Unsupported question / tool details" - : "Review proposed action"} - + {question.kind === "unknown" ? "Unsupported question / tool details" : "Review proposed action"}
{tool.arguments_string || "No arguments provided."}
)} @@ -142,16 +122,7 @@ export default function Question({ type={q.multi ? "checkbox" : "radio"} name={`${tool.id}-${i}`} checked={(selected[i] || []).includes(label)} - onChange={() => - setSelected((s) => ({ - ...s, - [i]: q.multi - ? (s[i] || []).includes(label) - ? s[i].filter((x) => x !== label) - : [...(s[i] || []), label] - : [label], - })) - } + onChange={() => setSelected((s) => ({ ...s, [i]: toggle(s[i], label, q.multi) }))} /> {label} @@ -180,7 +151,7 @@ export default function Question({ ))}
{question.kind !== "unknown" && ( - )} @@ -190,18 +161,13 @@ export default function Question({
{attempt && !busy && ( - )} {busy &&

Sending answer…

} {error && ( -

- {error} Resume polling to check the outcome, or retry the original answer. -

+

{error} Resume polling to check the outcome, or retry the original answer.

)} )} diff --git a/examples/white-label-minimal/components/ToolActivity.tsx b/examples/white-label-minimal/components/ToolActivity.tsx index 574d99d..8cb2765 100644 --- a/examples/white-label-minimal/components/ToolActivity.tsx +++ b/examples/white-label-minimal/components/ToolActivity.tsx @@ -31,11 +31,7 @@ export default function ToolActivity({ tool }: { tool: ToolCall }) { {tool.results && (
Result -
-            {typeof tool.results === "string"
-              ? tool.results
-              : JSON.stringify(tool.results, null, 2)}
-          
+
{tool.results}
)} diff --git a/examples/white-label-minimal/components/Workspace.tsx b/examples/white-label-minimal/components/Workspace.tsx index 27fd70d..22549ea 100644 --- a/examples/white-label-minimal/components/Workspace.tsx +++ b/examples/white-label-minimal/components/Workspace.tsx @@ -11,47 +11,63 @@ import AppWidget from "./AppWidget"; import PreviewFrame from "./PreviewFrame"; export default function Workspace({ name }: { name: string }) { - const [removing, setRemoving] = useState(null); - const editorPanel = useRef(null); - const assistantButton = useRef(null); - const closeButton = useRef(null); - const backButton = useRef(null); const [apps, setApps] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [needsConnection, setNeedsConnection] = useState(false); + const [removing, setRemoving] = useState(null); + const [inApp, setInApp] = useState(false); // What the stage shows. Kept apart from `editor` so an app created mid-chat // can appear beside the conversation without remounting the Builder. const [stageApp, setStageApp] = useState(null); - const [editor, setEditor] = useState<{ app: App | null; version: number }>({ - app: null, - version: 0, - }); - const [inApp, setInApp] = useState(false); - function closeAssistant() { - setMobileEditorOpen(false); - assistantButton.current?.focus(); - } - const [activeName, setActiveName] = useState(""); + // Bumping `version` remounts the Builder with a fresh conversation. + const [editor, setEditor] = useState<{ app: App | null; version: number }>({ app: null, version: 0 }); const [mobileEditorOpen, setMobileEditorOpen] = useState(false); + const editorPanel = useRef(null); + const assistantButton = useRef(null); + const closeButton = useRef(null); + const activeName = stageApp?.name || ""; + useEffect(() => { - if (mobileEditorOpen && window.matchMedia("(max-width: 760px)").matches) - closeButton.current?.focus(); + if (mobileEditorOpen && window.matchMedia("(max-width: 760px)").matches) closeButton.current?.focus(); }, [mobileEditorOpen]); useEffect(() => { if (editor.version > 0 && !editor.app) editorPanel.current?.querySelector(".composer textarea")?.focus(); }, [editor.version, editor.app]); + + const load = useCallback(async () => { + try { + const all: App[] = []; + for (let skip = 0; ; ) { + const page = await api.listApps(skip); + all.push(...page.apps); + if (!page.hasMore) break; + if (page.nextSkip <= skip) throw new Error("Could not load the next page of apps."); + skip = page.nextSkip; + } + setApps(all); + setError(""); + setNeedsConnection(false); + } catch (err) { + if (err instanceof api.ApiError && err.status === 428) setNeedsConnection(true); + else setError(err instanceof Error ? err.message : "Could not load your apps."); + } finally { + setLoading(false); + } + }, []); + useEffect(() => { + void load(); + }, [load]); + + // useState setters are stable, so this callback is too. const updateApp = useCallback((app: App) => { - setActiveName(app.name || ""); setApps((current) => current.map((item) => (item.id === app.id ? app : item))); setStageApp((current) => (current?.id === app.id ? app : current)); setEditor((current) => (current.app?.id === app.id ? { ...current, app } : current)); - // useState setters are stable, so this stays a stable callback; naming them - // is what lets the React Compiler keep the memoization. - }, [setActiveName, setApps, setStageApp, setEditor]); + }, [setApps, setStageApp, setEditor]); + function openApp(app: App | null = null) { - setActiveName(app?.name || ""); setStageApp(app); setEditor((current) => ({ app, version: current.version + 1 })); setInApp(true); @@ -61,35 +77,21 @@ export default function Workspace({ name }: { name: string }) { setInApp(false); setMobileEditorOpen(false); setStageApp(null); - setActiveName(""); setEditor((current) => ({ app: null, version: current.version + 1 })); } - const load = useCallback(() => { - return (async () => { - const allApps: App[] = []; - let skip = 0; - while (true) { - const page = await api.listApps(skip); - allApps.push(...page.apps); - if (!page.hasMore) return allApps; - if (page.nextSkip <= skip) throw new Error("Could not load the next page of apps."); - skip = page.nextSkip; - } - })() - .then((result) => { - setError(""); - setApps(result); - setNeedsConnection(false); - }) - .catch((err) => { - if (err instanceof api.ApiError && err.status === 428) setNeedsConnection(true); - else setError(err instanceof Error ? err.message : "Could not load your apps."); - }) - .finally(() => setLoading(false)); - }, []); - useEffect(() => { - void load(); - }, [load]); + function closeAssistant() { + setMobileEditorOpen(false); + assistantButton.current?.focus(); + } + function onCreated(app: App) { + setStageApp(app); + setApps((current) => [app, ...current]); + // Adopt the app without bumping the version: the conversation that just + // created it has to survive. + setEditor((current) => ({ ...current, app })); + setInApp(true); + } + async function removeApp(app: App) { if (removing) return; setRemoving(app.id); @@ -97,8 +99,6 @@ export default function Workspace({ name }: { name: string }) { try { await api.removeApp(app.id); setApps((current) => current.filter((item) => item.id !== app.id)); - // No guard for "was the open app removed": the cards only exist on the - // home page, and stageApp is only set once you are inside an app. } catch (err) { setError(err instanceof Error ? err.message : "Could not remove the app."); } finally { @@ -109,12 +109,7 @@ export default function Workspace({ name }: { name: string }) { setLoading(true); setError(""); try { - const response = await fetch("/api/base44/connection", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "connect" }), - }); - if (!response.ok) throw new Error("Could not connect your workspace. Please try again."); + await api.connectWorkspace(); setNeedsConnection(false); await load(); } catch (err) { @@ -122,6 +117,7 @@ export default function Workspace({ name }: { name: string }) { setLoading(false); } } + return (
@@ -133,11 +129,7 @@ export default function Workspace({ name }: { name: string }) { New app {name} -
@@ -149,9 +141,7 @@ export default function Workspace({ name }: { name: string }) { {error && (

{error}

- +
)} {needsConnection ? ( @@ -195,19 +185,14 @@ export default function Workspace({ name }: { name: string }) { ) : (
- {activeName || "New app"}
{stageApp ? ( - + ) : (
Describe what you want to build. The preview appears here once the app exists. @@ -218,62 +203,45 @@ export default function Workspace({ name }: { name: string }) { )} -
{ - if (e.key === "Escape") closeAssistant(); - }} - > -
-
- {editor.app ? ( - - - ) : ( - - )} - {activeName || "Build an app"} -
-
- -
-
- { - setActiveName(app.name || ""); - setStageApp(app); - setApps((current) => [app, ...current]); - // Adopt the app without bumping the version: the conversation - // that just created it has to survive. - setEditor((current) => ({ ...current, app })); - setInApp(true); - }} - /> + className="mobile-assistant" + onClick={() => setMobileEditorOpen(true)} + aria-expanded={mobileEditorOpen} + aria-controls="app-editor" + > + Assistant + +
e.key === "Escape" && closeAssistant()} + > +
+
+ {editor.app ? ( + + + ) : ( + + )} + {activeName || "Build an app"} +
+
+ +
+
+
diff --git a/examples/white-label-minimal/lib/chat/builder-api.ts b/examples/white-label-minimal/lib/chat/builder-api.ts index 0eb0dbd..cf235fa 100644 --- a/examples/white-label-minimal/lib/chat/builder-api.ts +++ b/examples/white-label-minimal/lib/chat/builder-api.ts @@ -54,7 +54,14 @@ export const getPreviewUrl = (appId: string) => call<{ url: string }>("getPrevie export const deployApp = (appId: string) => call("deployApp", { appId }); export const getPublishedUrl = (appId: string) => call<{ url: string | null }>("getPublishedUrl", { appId }); - export const listApps = (skip = 0) => call("listApps", { skip }); - export const removeApp = (appId: string) => call("removeApp", { appId }); + +export async function connectWorkspace() { + const response = await fetch("/api/base44/connection", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "connect" }), + }); + if (!response.ok) throw new ApiError("Could not connect your workspace. Please try again.", response.status); +} diff --git a/examples/white-label-minimal/lib/chat/conversation.ts b/examples/white-label-minimal/lib/chat/conversation.ts index 52d6eec..ded2808 100644 --- a/examples/white-label-minimal/lib/chat/conversation.ts +++ b/examples/white-label-minimal/lib/chat/conversation.ts @@ -1,6 +1,6 @@ import type { Message } from "../types"; -export const isPending = (m: Message) => +const isPending = (m: Message) => m.tool_calls?.some((t) => t.status === "running" || t.status === "waiting_for_user_input"); // skip counts backward from the newest message, not forward from the oldest. From 87af46ace5b21459364c33842872d18fa31bb9ac Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 16:07:04 +0300 Subject: [PATCH 03/28] Tiny Sunny: make client.ts a pure 1:1 Base44 wrapper - encode app IDs in every path instead of relying on the handler's check - status hints move to http.ts, next to the transport - static preview is Tiny's own derivation, so it moves to app-service - share the conversation page size with the browser paging helper - link the Base44 OpenAPI spec from the client and the README Co-Authored-By: Claude Opus 5.5 --- examples/white-label-minimal/README.md | 7 +- .../white-label-minimal/lib/base44/client.ts | 96 +++++++++---------- .../white-label-minimal/lib/base44/http.ts | 12 ++- .../lib/chat/conversation.ts | 6 +- .../lib/server/app-service.ts | 16 ++-- .../lib/{base44 => server}/static-preview.ts | 7 +- .../white-label-minimal/tests/server.test.ts | 32 +++---- 7 files changed, 89 insertions(+), 87 deletions(-) rename examples/white-label-minimal/lib/{base44 => server}/static-preview.ts (82%) diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md index 6b72e75..7e0e121 100644 --- a/examples/white-label-minimal/README.md +++ b/examples/white-label-minimal/README.md @@ -41,8 +41,9 @@ and workspace are available here too. Start with [lib/base44/client.ts](lib/base44/client.ts). It contains the Base44 endpoints and request payloads for creation, conversation, tool answers, preview, -and publishing. Copy it with `lib/base44/http.ts`, `lib/base44/static-preview.ts`, -`lib/base44/config.ts`, `lib/base44/error.ts`, `lib/base44/custom-instructions.ts`, and `lib/types.ts`. It uses `fetch` and `server-only`, with +and publishing. Copy it with `lib/base44/http.ts`, `lib/base44/config.ts`, `lib/base44/error.ts`, +`lib/base44/custom-instructions.ts`, and `lib/types.ts`. Every endpoint it calls is described in +Base44's [OpenAPI spec](https://app.base44.com/api/openapi.json), if you prefer to generate a typed client. It uses `fetch` and `server-only`, with no Sunny imports. Set `BASE44_PLATFORM_HOST` to your Base44 HTTPS origin and adapt `lib/base44/custom-instructions.ts` to your product. New apps use the first 80 characters of the prompt as their initial name. @@ -106,7 +107,7 @@ Browser tests use a separate fixture app with mocked Base44 responses. Browsing uses `https://preview--{slug}.{BASE44_STATIC_PREVIEW_DOMAIN}`. Set `BASE44_STATIC_PREVIEW_DOMAIN` to the preview hosting domain confirmed for your -Base44 environment. Without it, browsing uses screenshots or placeholders. This URL is derived by Tiny, not returned by Base44. +Base44 environment. Without it, browsing uses screenshots or placeholders. This URL is derived by Tiny in `lib/server/static-preview.ts`, not returned by Base44. Apps without a slug show a screenshot or placeholder; browsing never starts a sandbox. Editing requests a live preview and keeps static visible until the live iframe loads. A load event only controls the visual transition; it does not verify app health. diff --git a/examples/white-label-minimal/lib/base44/client.ts b/examples/white-label-minimal/lib/base44/client.ts index ba512d4..00fe46f 100644 --- a/examples/white-label-minimal/lib/base44/client.ts +++ b/examples/white-label-minimal/lib/base44/client.ts @@ -1,58 +1,19 @@ import "server-only"; +import { CONVERSATION_PAGE_SIZE } from "../chat/conversation"; import type { App, Message, ToolInput } from "../types"; import { customInstructions } from "./custom-instructions"; import { Base44Error } from "./error"; import { base44Fetch } from "./http"; -import { staticPreviewUrl } from "./static-preview"; - -const hints: Record = { - 401: "Reconnect your workspace.", - 403: "Check the account’s workspace access.", - 409: "The app may not build yet. Ask the agent to fix it.", - 429: "Rate limit reached. Wait before trying again.", -}; +// The Base44 endpoints Tiny uses, called as one builder's service user. +// Full reference: https://app.base44.com/api/openapi.json export function createBase44Client(accessToken: string) { const request = (path: string, options: Parameters[1] = {}) => - base44Fetch(path, { - ...options, - headers: { Authorization: `Bearer ${accessToken}`, ...options.headers }, - errorFor: (status) => new Base44Error( - `Base44 returned ${status}. ${hints[status] ?? "Refresh the app state before repeating an operation."}`, - status, - ), - }); + base44Fetch(path, { ...options, headers: { Authorization: `Bearer ${accessToken}`, ...options.headers } }); + // Writes return nothing to the browser; Tiny re-reads state by polling instead. const post = (path: string, body: object, headers?: Record) => request(path, { body, headers, timeout: 120_000 }).then(() => ({})); - - // Return only the fields the browser needs; never pass the raw Base44 app through. - async function toApp(app: App): Promise { - if (typeof app?.id !== "string") throw new Base44Error("Base44 returned an app without an ID."); - const { id, name, slug, preview_screenshot_url, logo_url, user_description, status } = app; - return { - id, name, slug, preview_screenshot_url, logo_url, user_description, - static_preview_url: await staticPreviewUrl(slug), - status: status && { state: status.state, error_source: status.error_source }, - }; - } - - function toMessage(m: Message): Message { - if (typeof m?.id !== "string" || !m.id) throw new Base44Error("A conversation message has no stable ID."); - return { - id: m.id, - role: m.role, - content: m.content, - hidden: m.hidden, - tool_calls: m.tool_calls?.map((t) => ({ - id: t.id, - name: t.name, - status: t.status, - waiting_on: t.waiting_on, - arguments_string: t.arguments_string, - results: typeof t.results === "string" ? t.results : undefined, - })), - }; - } + const app = (id: string) => `/api/apps/${encodeURIComponent(id)}`; return { createApp: async (prompt: string) => @@ -67,31 +28,33 @@ export function createBase44Client(accessToken: string) { timeout: 120_000, })), - getApp: async (id: string) => toApp(await request(`/api/apps/${id}`)), + getApp: async (id: string) => toApp(await request(app(id))), // skip counts backward from the newest message. async getConversation(id: string, skip: number) { - const data = await request(`/api/apps/${id}/chat/full-conversation?limit=20&skip=${skip}`, { timeout: 60_000 }); + const data = await request(`${app(id)}/chat/full-conversation?limit=${CONVERSATION_PAGE_SIZE}&skip=${skip}`, { + timeout: 60_000, + }); const messages = data?.messages ?? []; if (!Array.isArray(messages)) throw new Base44Error("Unexpected conversation response."); return { messages: messages.map(toMessage) }; }, - sendMessage: (id: string, content: string) => post(`/api/apps/${id}/chat/message`, { content }), + sendMessage: (id: string, content: string) => post(`${app(id)}/chat/message`, { content }), // A fixed request ID lets Base44 deduplicate retries of the same answer. submitToolCallInput: (p: ToolInput) => - post(`/api/apps/${p.appId}/chat/submit-tool-call-input`, { + post(`${app(p.appId)}/chat/submit-tool-call-input`, { tool_call_id: p.toolCallId, message_id: p.messageId, action: p.approve ? "approved" : "rejected", extra_user_input: p.extraUserInput, }, { "X-Request-ID": `submit-${p.toolCallId}` }), - deployApp: (id: string) => post(`/api/apps/${id}/deploy`, {}), + deployApp: (id: string) => post(`${app(id)}/deploy`, {}), async getPreviewUrl(id: string) { - const data = await request(`/api/apps/${id}/sandbox/preview-url`, { timeout: 120_000 }); + const data = await request(`${app(id)}/sandbox/preview-url`, { timeout: 120_000 }); const url = httpsUrl(data?.preview_url); if (data.preview_token) url.searchParams.set("_preview_token", data.preview_token); url.searchParams.set("server_url", url.origin); @@ -102,7 +65,7 @@ export function createBase44Client(accessToken: string) { async getPublishedUrl(id: string) { try { - const data = await request(`/api/apps/platform/${id}/published-url`); + const data = await request(`/api/apps/platform/${encodeURIComponent(id)}/published-url`); return { url: httpsUrl(data?.url).href }; } catch (error) { if (error instanceof Base44Error && error.status === 404) return { url: null }; @@ -112,6 +75,35 @@ export function createBase44Client(accessToken: string) { }; } +// Copy only the fields the browser needs; never pass a raw Base44 object through. +function toApp(app: App): App { + if (typeof app?.id !== "string") throw new Base44Error("Base44 returned an app without an ID."); + const { id, name, slug, preview_screenshot_url, logo_url, user_description, status } = app; + return { + id, name, slug, preview_screenshot_url, logo_url, user_description, + status: status && { state: status.state, error_source: status.error_source }, + }; +} + +function toMessage(m: Message): Message { + if (typeof m?.id !== "string" || !m.id) throw new Base44Error("A conversation message has no stable ID."); + return { + id: m.id, + role: m.role, + content: m.content, + hidden: m.hidden, + tool_calls: m.tool_calls?.map((t) => ({ + id: t.id, + name: t.name, + status: t.status, + waiting_on: t.waiting_on, + arguments_string: t.arguments_string, + results: typeof t.results === "string" ? t.results : undefined, + })), + }; +} + +// Accept only HTTPS URLs without embedded credentials. function httpsUrl(raw: unknown) { if (typeof raw !== "string" || !raw) throw new Base44Error("Base44 has not returned a URL yet. Try again shortly."); let url: URL; diff --git a/examples/white-label-minimal/lib/base44/http.ts b/examples/white-label-minimal/lib/base44/http.ts index b78106b..67c617a 100644 --- a/examples/white-label-minimal/lib/base44/http.ts +++ b/examples/white-label-minimal/lib/base44/http.ts @@ -2,6 +2,14 @@ import "server-only"; import { Base44Error } from "./error"; import { getBase44Config } from "./config"; +// What a Base44 status means for the person using the app. +const hints: Record = { + 401: "Reconnect your workspace.", + 403: "Check the account’s workspace access.", + 409: "The app is not ready for this yet. Ask the agent to fix it.", + 429: "Rate limit reached. Wait before trying again.", +}; + type Options = { body?: object | URLSearchParams; headers?: Record; @@ -31,7 +39,9 @@ export async function base44Fetch(path: string, { body, headers, timeout = 30_00 throw new Base44Error("Base44 did not return a response. The operation may still be running.", 504); } if (!response.ok) { - throw errorFor?.(response.status) ?? new Base44Error(`Base44 returned ${response.status}.`, response.status); + const { status } = response; + const hint = hints[status] ?? "Refresh the app state before repeating an operation."; + throw errorFor?.(status) ?? new Base44Error(`Base44 returned ${status}. ${hint}`, status); } const text = await response.text(); if (!text) return null; diff --git a/examples/white-label-minimal/lib/chat/conversation.ts b/examples/white-label-minimal/lib/chat/conversation.ts index ded2808..10e0797 100644 --- a/examples/white-label-minimal/lib/chat/conversation.ts +++ b/examples/white-label-minimal/lib/chat/conversation.ts @@ -3,6 +3,8 @@ import type { Message } from "../types"; const isPending = (m: Message) => m.tool_calls?.some((t) => t.status === "running" || t.status === "waiting_for_user_input"); +export const CONVERSATION_PAGE_SIZE = 20; + // skip counts backward from the newest message, not forward from the oldest. export async function refreshConversation( previous: Message[], @@ -11,7 +13,7 @@ export async function refreshConversation( const known = new Set(previous.map((m) => m.id)); const oldestPending = previous.find(isPending)?.id; let fetched: Message[] = []; - for (let skip = 0; skip <= 100_000; skip += 20) { + for (let skip = 0; skip <= 100_000; skip += CONVERSATION_PAGE_SIZE) { const { messages } = await read(skip); if (messages.some((m) => !m.id)) throw new Error("Conversation messages need stable IDs."); // Keep the fresher copy when concurrent appends shift page boundaries. @@ -20,7 +22,7 @@ export async function refreshConversation( const reachedHistory = oldestPending ? fetched.some((m) => m.id === oldestPending) : messages.some((m) => known.has(m.id)); - if (messages.length < 20 || reachedHistory) { + if (messages.length < CONVERSATION_PAGE_SIZE || reachedHistory) { const updates = new Map(fetched.map((m) => [m.id, m])); return [ ...previous.map((m) => updates.get(m.id) ?? m), diff --git a/examples/white-label-minimal/lib/server/app-service.ts b/examples/white-label-minimal/lib/server/app-service.ts index a8f7097..a9b2d3e 100644 --- a/examples/white-label-minimal/lib/server/app-service.ts +++ b/examples/white-label-minimal/lib/server/app-service.ts @@ -1,27 +1,29 @@ import "server-only"; import { requireUser } from "./auth"; +import { withStaticPreview } from "./static-preview"; import { getBase44AccessToken } from "../base44/identity"; +import { createBase44Client } from "../base44/client"; import { createAppRepository } from "../storage/app-repository"; import { resolveAppPage } from "../storage/app-list"; -import { createBase44Client } from "../base44/client"; import type { AppClient } from "../types"; +// Where Tiny plugs into Base44: the signed-in user, their Base44 token, and +// the apps they own. export async function getAppClient(): Promise { const actor = await requireUser(); - const token = await getBase44AccessToken(actor.email); - const client = createBase44Client(token); + const client = createBase44Client(await getBase44AccessToken(actor.email)); const apps = createAppRepository(actor); + const getApp = async (id: string) => withStaticPreview(await client.getApp(id)); return { ...client, + getApp, authorize: apps.authorize, removeApp: apps.remove, async createApp(prompt) { const app = await client.createApp(prompt); await apps.save(app); - return app; - }, - async listApps(skip) { - return resolveAppPage(await apps.list(skip), skip, client.getApp); + return withStaticPreview(app); }, + listApps: async (skip) => resolveAppPage(await apps.list(skip), skip, getApp), }; } diff --git a/examples/white-label-minimal/lib/base44/static-preview.ts b/examples/white-label-minimal/lib/server/static-preview.ts similarity index 82% rename from examples/white-label-minimal/lib/base44/static-preview.ts rename to examples/white-label-minimal/lib/server/static-preview.ts index 31a9689..d5a443e 100644 --- a/examples/white-label-minimal/lib/base44/static-preview.ts +++ b/examples/white-label-minimal/lib/server/static-preview.ts @@ -1,4 +1,5 @@ import "server-only"; +import type { App } from "../types"; // Base44 does not return a static preview URL; we derive it from the slug. // The derived URL can name a build that does not exist yet, and the host then @@ -6,7 +7,11 @@ import "server-only"; // it first, and cache the result because getApp is polled every two seconds. const probes = new Map(); -export async function staticPreviewUrl(slug: string | null | undefined) { +export async function withStaticPreview(app: App): Promise { + return { ...app, static_preview_url: await staticPreviewUrl(app.slug) }; +} + +async function staticPreviewUrl(slug: string | null | undefined) { const domain = process.env.BASE44_STATIC_PREVIEW_DOMAIN; if (!domain || !/^[a-z0-9.-]+$/i.test(domain)) return undefined; if (!slug || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(slug)) return undefined; diff --git a/examples/white-label-minimal/tests/server.test.ts b/examples/white-label-minimal/tests/server.test.ts index b93966e..b890ce3 100644 --- a/examples/white-label-minimal/tests/server.test.ts +++ b/examples/white-label-minimal/tests/server.test.ts @@ -159,34 +159,24 @@ test('remove requires ownership and never calls the upstream app deletion API', assert.equal(calls.length, 0); }); - test('a static preview is only offered once the derived URL serves a build', async () => { +test('a static preview is only offered once the derived URL serves a build', async () => { // The URL is derived from the slug, not returned by Base44, so it can name a // build that does not exist; the host then answers with a JSON error that an // iframe would render as raw JSON. Hence the probe. - process.env.BASE44_ORG_ID = 'workspace_1'; - process.env.BASE44_PLATFORM_HOST = 'https://platform.example'; + const { withStaticPreview } = await import('../lib/server/static-preview'); process.env.BASE44_STATIC_PREVIEW_DOMAIN = 'preview.example'; const derived = 'https://preview--my-app.preview.example'; - const serve = (body: string, contentType: string, status = 200) => { - const calls: string[] = []; - globalThis.fetch = async (url) => { - const href = String(url); - calls.push(href); - if (href.startsWith(derived)) return new Response(body, { status, headers: { 'content-type': contentType } }); - return Response.json({ id: 'app_1', slug: 'my-app' }); - }; - return calls; + const probed: string[] = []; + globalThis.fetch = async (url) => { + probed.push(String(url)); + return new Response('App', { headers: { 'content-type': 'text/html' } }); }; - - const built = serve('App', 'text/html'); - assert.equal((await (await request({ action: 'getApp', appId: 'app_1' })).json()).static_preview_url, derived); - assert.ok(built.includes('https://platform.example/api/apps/app_1')); - assert.ok(built.some(url => url.startsWith(derived)), 'the derived URL is probed'); + assert.equal((await withStaticPreview({ id: 'app_1', slug: 'my-app' })).static_preview_url, derived); + assert.deepEqual(probed, [derived]); // A different slug, so the probe cache cannot answer for it. - globalThis.fetch = async (url) => String(url).includes('preview--other') - ? new Response('{"message":"Preview not available yet"}', { status: 404, headers: { 'content-type': 'application/json' } }) - : Response.json({ id: 'app_2', slug: 'other' }); - assert.equal((await (await request({ action: 'getApp', appId: 'app_2' })).json()).static_preview_url, undefined); + globalThis.fetch = async () => + new Response('{"message":"Preview not available yet"}', { status: 404, headers: { 'content-type': 'application/json' } }); + assert.equal((await withStaticPreview({ id: 'app_2', slug: 'other' })).static_preview_url, undefined); }); From 6ce286c6a3c2e7409a6a4f02c69d723cf29ec88d Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 17:10:23 +0300 Subject: [PATCH 04/28] Tiny Sunny: re-mint service-user tokens instead of refreshing them - the stored token is a cache: reuse it until a minute before expiry, then mint a new one with the workspace key (same approach as main Sunny's remint) - drops the OAuth refresh flow and the row lock (identity-repository.ts) - one workspace key; .env.example and README name the two scopes it needs - connection status returns only { linked } - Disconnect button in the topbar; disconnect still revokes the refresh token and keeps the service user so reconnecting restores the same apps - README: mint rate limit and encrypt-at-rest note Co-Authored-By: Claude Opus 5.5 --- examples/white-label-minimal/.env.example | 1 + examples/white-label-minimal/README.md | 11 +- .../app/api/base44/connection/route.ts | 4 +- .../components/Workspace.tsx | 20 ++- .../lib/base44/identity.ts | 120 ++++++++---------- .../lib/chat/builder-api.ts | 6 +- .../lib/storage/identity-repository.ts | 13 -- .../tests/identity.test.ts | 49 +++---- 8 files changed, 103 insertions(+), 121 deletions(-) delete mode 100644 examples/white-label-minimal/lib/storage/identity-repository.ts diff --git a/examples/white-label-minimal/.env.example b/examples/white-label-minimal/.env.example index abc2a87..6d24078 100644 --- a/examples/white-label-minimal/.env.example +++ b/examples/white-label-minimal/.env.example @@ -3,6 +3,7 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= DATABASE_URL= DIRECT_URL= +# Workspace API key with the service_users:provision and user_tokens:mint scopes. BASE44_SVC_KEY= BASE44_ORG_ID= # Set to your Base44 HTTPS origin. diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md index 7e0e121..a0088ba 100644 --- a/examples/white-label-minimal/README.md +++ b/examples/white-label-minimal/README.md @@ -58,8 +58,8 @@ components/Builder.tsx → lib/chat/builder-api.ts → app/api/base44/route.ts [lib/server/app-service.ts](lib/server/app-service.ts) is where your application plugs in: - [server/auth.ts](lib/server/auth.ts) supplies the verified user from your session. -- [base44/identity.ts](lib/base44/identity.ts) provisions a service user, mints its token, - and refreshes it near expiry. +- [base44/identity.ts](lib/base44/identity.ts) provisions a service user and mints a 1-hour + token for it, reusing the stored token until it expires. - [storage/app-repository.ts](lib/storage/app-repository.ts) saves ownership and scopes app access to that user. [prisma/schema.prisma](prisma/schema.prisma) defines identity links and app ownership. @@ -67,8 +67,11 @@ components/Builder.tsx → lib/chat/builder-api.ts → app/api/base44/route.ts on install and build; run `npm run db:generate` from the example after schema edits. Google login and Base44 connection are separate. `/api/base44/connection` -provisions a service principal using the workspace key. Builder requests use its -stored token; they do not provision identities or send credentials to the browser. +connects or disconnects a builder. Connecting provisions a service user with the workspace key, +which needs the `service_users:provision` and `user_tokens:mint` scopes. Builder requests use the +stored token and never send credentials to the browser. Minting is rate-limited per workspace, +so reuse each token for its lifetime rather than minting per request. Tokens are stored in plain +text here; encrypt them at rest in production. The API handler validates requests and checks app ownership before app operations. For the chat UI, copy `components/`, `lib/chat/builder-api.ts`, `lib/chat/conversation.ts`, diff --git a/examples/white-label-minimal/app/api/base44/connection/route.ts b/examples/white-label-minimal/app/api/base44/connection/route.ts index afe3caa..12874dc 100644 --- a/examples/white-label-minimal/app/api/base44/connection/route.ts +++ b/examples/white-label-minimal/app/api/base44/connection/route.ts @@ -1,5 +1,5 @@ import { requireUser } from "../../../../lib/server/auth"; -import { connect, disconnect, getLink, linkStatus } from "../../../../lib/base44/identity"; +import { connect, disconnect, getConnection } from "../../../../lib/base44/identity"; import { Base44Error } from "../../../../lib/base44/error"; const headers = { "Cache-Control": "no-store, private" }; @@ -18,7 +18,7 @@ export async function POST(request: Request) { let result; switch (payload?.action) { case "status": - result = linkStatus(await getLink(actor.email)); + result = await getConnection(actor.email); break; case "connect": result = await connect(actor.email); diff --git a/examples/white-label-minimal/components/Workspace.tsx b/examples/white-label-minimal/components/Workspace.tsx index 22549ea..ef4d0df 100644 --- a/examples/white-label-minimal/components/Workspace.tsx +++ b/examples/white-label-minimal/components/Workspace.tsx @@ -3,7 +3,7 @@ import type { App } from "../lib/types"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { signOut } from "next-auth/react"; -import { ArrowLeft, Grid2X2, Loader2, LogOut, MessageSquare, Plus, Sparkles, X } from "lucide-react"; +import { ArrowLeft, Grid2X2, Loader2, LogOut, MessageSquare, Plus, Sparkles, Unplug, X } from "lucide-react"; import TinySunnyLogo from "./TinySunnyLogo"; import * as api from "../lib/chat/builder-api"; import Builder from "./Builder"; @@ -109,7 +109,7 @@ export default function Workspace({ name }: { name: string }) { setLoading(true); setError(""); try { - await api.connectWorkspace(); + await api.setConnection("connect"); setNeedsConnection(false); await load(); } catch (err) { @@ -117,6 +117,17 @@ export default function Workspace({ name }: { name: string }) { setLoading(false); } } + async function disconnect() { + setError(""); + try { + await api.setConnection("disconnect"); + backToApps(); + setApps([]); + setNeedsConnection(true); + } catch (err) { + setError((err as Error).message); + } + } return (
@@ -129,6 +140,11 @@ export default function Workspace({ name }: { name: string }) { New app {name} + {!needsConnection && !loading && ( + + )} diff --git a/examples/white-label-minimal/lib/base44/identity.ts b/examples/white-label-minimal/lib/base44/identity.ts index eefd98f..284c914 100644 --- a/examples/white-label-minimal/lib/base44/identity.ts +++ b/examples/white-label-minimal/lib/base44/identity.ts @@ -1,119 +1,111 @@ import "server-only"; import { createHash } from "node:crypto"; -import type { Base44Link } from "../../generated/prisma"; import { prisma } from "../storage/db"; -import { withIdentityLock } from "../storage/identity-repository"; import { Base44Error } from "./error"; import { base44Fetch } from "./http"; -// Each Sunny user gets one Base44 service user. We keep its tokens server-side -// and refresh the access token shortly before it expires. +// Each Tiny user gets one Base44 service user, created with the workspace key. +// Base44 calls use a 1-hour token minted for that service user. Minting is +// rate-limited per workspace, so the token is stored and reused until it expires. -export function getLink(email: string) { - return prisma.base44Link.findUnique({ where: { appUserEmail: email.toLowerCase() } }); -} - -export function linkStatus(link: Base44Link | null) { - return { - linked: link?.status === "linked" && Boolean(link.accessToken), - base44_user_email: link?.base44UserEmail ?? null, - organization_id: link?.organizationId ?? null, - }; +export async function getConnection(email: string) { + const link = await prisma.base44Link.findUnique({ where: { appUserEmail: email.toLowerCase() } }); + return { linked: link?.status === "linked" }; } export async function connect(email: string) { const appUserEmail = email.toLowerCase(); const organizationId = required("BASE44_ORG_ID"); - const key = required("BASE44_SVC_KEY"); - const existing = await getLink(appUserEmail); - // Deterministic, so reconnecting finds the same service user and its apps. + const existing = await prisma.base44Link.findUnique({ where: { appUserEmail } }); + // Never change this format: existing apps belong to the service user it names. const digest = createHash("sha256").update(`${organizationId}:${appUserEmail}`).digest("hex"); const serviceExternalId = existing?.serviceExternalId ?? `sunny-${digest.slice(0, 32)}`; + // Idempotent: returns the existing service user when there is one. const principal = await post("/api/service/users", { service_external_id: serviceExternalId, display_name: `Builder ${serviceExternalId.slice(-8)}`, - }, process.env.BASE44_PROVISION_KEY?.trim() || key); + }); const base44UserEmail = principal?.email; if (typeof base44UserEmail !== "string" || !/@([^@]+\.)?svc\.base44\.invalid$/.test(base44UserEmail)) throw new Base44Error("Base44 returned an invalid service-user identity."); - const tokens = await post("/api/service/user-tokens", { service_external_id: serviceExternalId }, key); const record = { - ...tokenFields(tokens), + ...(await mint(serviceExternalId)), status: "linked" as const, organizationId, serviceExternalId, base44UserEmail, principalProvisioned: true, }; - return linkStatus(await prisma.base44Link.upsert({ + await prisma.base44Link.upsert({ where: { appUserEmail }, create: { appUserEmail, createdBy: appUserEmail, ...record }, update: record, - })); + }); + return { linked: true }; } -export function getBase44AccessToken(email: string): Promise { +export async function getBase44AccessToken(email: string): Promise { const appUserEmail = email.toLowerCase(); - // The row lock stops two requests from spending the same refresh token. - return withIdentityLock(appUserEmail, async (identities) => { - const link = await identities.findUnique({ where: { appUserEmail } }); - if (link?.status !== "linked" || !link.accessToken) - throw new Base44Error("Connect your workspace to start building.", 428); - if (link.expiresAt && link.expiresAt.getTime() > Date.now() + 60_000) return link.accessToken; - if (!link.refreshToken) throw new Base44Error("Reconnect your workspace to continue.", 428); + const link = await prisma.base44Link.findUnique({ where: { appUserEmail } }); + if (link?.status !== "linked" || !link.serviceExternalId) + throw new Base44Error("Connect your workspace to start building.", 428); + if (link.accessToken && link.expiresAt && link.expiresAt.getTime() > Date.now() + 60_000) + return link.accessToken; - const tokens = await post("/oauth/token", new URLSearchParams({ - grant_type: "refresh_token", - client_id: "svc_delegate", - refresh_token: link.refreshToken, - })); - const refreshed = await identities.update({ - where: { appUserEmail }, - data: tokenFields(tokens, link.refreshToken), - }); - return refreshed.accessToken!; - }); + const tokens = await mint(link.serviceExternalId); + await prisma.base44Link.update({ where: { appUserEmail }, data: tokens }); + return tokens.accessToken; } +// Keeps the service user, so reconnecting brings back the same apps. export async function disconnect(email: string) { - const link = await getLink(email); + const appUserEmail = email.toLowerCase(); + const link = await prisma.base44Link.findUnique({ where: { appUserEmail } }); if (link?.refreshToken) { // Best effort: the local tokens are cleared even if revocation fails. - await post("/oauth/revoke", new URLSearchParams({ token: link.refreshToken, client_id: "svc_delegate" })) - .catch(() => {}); + await base44Fetch("/oauth/revoke", { + body: new URLSearchParams({ token: link.refreshToken, client_id: "svc_delegate" }), + }).catch(() => {}); } await prisma.base44Link.updateMany({ - where: { appUserEmail: email.toLowerCase() }, + where: { appUserEmail }, data: { status: "pending", accessToken: null, refreshToken: null, expiresAt: null }, }); - return linkStatus(null); + return { linked: false }; } -function required(name: string) { - const value = process.env[name]?.trim(); - if (!value) throw new Base44Error(`Configure ${name} on the server.`, 503); - return value; +async function mint(serviceExternalId: string) { + const data = await post("/api/service/user-tokens", { service_external_id: serviceExternalId }, (status) => + // 429 is the workspace-wide mint limit and 408 a timeout: try again later. + // Any other 4xx means the service user or key no longer works: reconnect. + status >= 400 && status < 500 && status !== 429 && status !== 408 + ? new Base44Error("Reconnect your workspace to continue.", 428) + : undefined); + const expiresIn = Number(data?.expires_in); + if (typeof data?.access_token !== "string" || !data.access_token || !(expiresIn > 0) || !Number.isFinite(expiresIn)) + throw new Base44Error("Base44 returned invalid credentials."); + return { + accessToken: data.access_token as string, + // Unused here, but main Sunny shares this row and revokes it on disconnect. + refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : null, + expiresAt: new Date(Date.now() + expiresIn * 1000), + }; } -function post(path: string, body: object | URLSearchParams, key?: string) { +// Calls authenticated with the workspace key. It needs the service_users:provision +// and user_tokens:mint scopes. +function post(path: string, body: object, errorFor?: (status: number) => Base44Error | undefined) { return base44Fetch(path, { body, - headers: key ? { Authorization: key } : {}, - // A rejected refresh token means the user must reconnect. - errorFor: (status) => path === "/oauth/token" && [400, 401, 403].includes(status) - ? new Base44Error("Reconnect your workspace to continue.", 428) - : new Base44Error(`Base44 returned ${status}. Please try again.`, 502), + headers: { Authorization: required("BASE44_SVC_KEY") }, + errorFor: (status) => errorFor?.(status) ?? new Base44Error(`Base44 returned ${status}. Please try again.`, 502), }); } -function tokenFields(data: Record | null, previousRefreshToken?: string | null) { - const accessToken = data?.access_token; - const refreshToken = data?.refresh_token ?? previousRefreshToken; - const expiresIn = Number(data?.expires_in); - if (typeof accessToken !== "string" || !accessToken || typeof refreshToken !== "string" || !refreshToken || - !Number.isFinite(expiresIn) || expiresIn <= 0) - throw new Base44Error("Base44 returned invalid credentials."); - return { accessToken, refreshToken, expiresAt: new Date(Date.now() + expiresIn * 1000) }; +function required(name: string) { + const value = process.env[name]?.trim(); + if (!value) throw new Base44Error(`Configure ${name} on the server.`, 503); + return value; } diff --git a/examples/white-label-minimal/lib/chat/builder-api.ts b/examples/white-label-minimal/lib/chat/builder-api.ts index cf235fa..915a4b4 100644 --- a/examples/white-label-minimal/lib/chat/builder-api.ts +++ b/examples/white-label-minimal/lib/chat/builder-api.ts @@ -57,11 +57,11 @@ export const getPublishedUrl = (appId: string) => export const listApps = (skip = 0) => call("listApps", { skip }); export const removeApp = (appId: string) => call("removeApp", { appId }); -export async function connectWorkspace() { +export async function setConnection(action: "connect" | "disconnect") { const response = await fetch("/api/base44/connection", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "connect" }), + body: JSON.stringify({ action }), }); - if (!response.ok) throw new ApiError("Could not connect your workspace. Please try again.", response.status); + if (!response.ok) throw new ApiError(`Could not ${action} your workspace. Please try again.`, response.status); } diff --git a/examples/white-label-minimal/lib/storage/identity-repository.ts b/examples/white-label-minimal/lib/storage/identity-repository.ts deleted file mode 100644 index d163992..0000000 --- a/examples/white-label-minimal/lib/storage/identity-repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import "server-only"; -import type { Prisma } from "../../generated/prisma"; -import { prisma } from "./db"; - -export function withIdentityLock( - email: string, - operation: (identities: Prisma.TransactionClient["base44Link"]) => Promise, -) { - return prisma.$transaction(async (db) => { - await db.$queryRaw`SELECT id FROM base44_links WHERE app_user_email = ${email} FOR UPDATE`; - return operation(db.base44Link); - }, { timeout: 40_000 }); -} diff --git a/examples/white-label-minimal/tests/identity.test.ts b/examples/white-label-minimal/tests/identity.test.ts index 96f23be..c32e874 100644 --- a/examples/white-label-minimal/tests/identity.test.ts +++ b/examples/white-label-minimal/tests/identity.test.ts @@ -6,16 +6,10 @@ import { connect, disconnect, getBase44AccessToken } from "../lib/base44/identit const originalFetch = globalThis.fetch; const originalEnv = { ...process.env }; -const originalTransaction = prisma.$transaction; -const originalMethods = { - findUnique: prisma.base44Link.findUnique, - upsert: prisma.base44Link.upsert, - updateMany: prisma.base44Link.updateMany, -}; +const originalMethods = { ...prisma.base44Link }; afterEach(() => { globalThis.fetch = originalFetch; process.env = { ...originalEnv }; - prisma.$transaction = originalTransaction; Object.assign(prisma.base44Link, originalMethods); }); @@ -29,19 +23,8 @@ function setup(overrides = {}) { ...overrides, }; const calls: { url: string; init?: RequestInit }[] = []; - const db = { - $queryRaw: async () => [], - base44Link: { - findUnique: async () => row, - update: async ({ data }: any) => (row = { ...row, ...data }), - }, - }; - prisma.$transaction = (async (fn: any) => fn(db)) as any; - prisma.base44Link.findUnique = db.base44Link.findUnique as any; - prisma.base44Link.upsert = (async ({ update }: any) => (row = { ...row, ...update })) as any; - prisma.base44Link.updateMany = (async ({ data }: any) => { - row = { ...row, ...data }; return { count: 1 }; - }) as any; + const save = async ({ data, update }: any) => (row = { ...row, ...(data ?? update) }); + Object.assign(prisma.base44Link, { findUnique: async () => row, update: save, upsert: save, updateMany: save }); globalThis.fetch = async (url, init) => { calls.push({ url: String(url), init }); return Response.json(String(url).endsWith("/api/service/users") @@ -51,7 +34,7 @@ function setup(overrides = {}) { return { calls, row: () => row }; } -test("connect provisions then mints with workspace credentials and preserves the stored identity", async () => { +test("connect provisions then mints with the workspace key and keeps the stored identity", async () => { const state = setup(); const result = await connect("BUILDER@example.com"); assert.deepEqual(state.calls.map(c => new URL(c.url).pathname), ["/api/service/users", "/api/service/user-tokens"]); @@ -59,8 +42,8 @@ test("connect provisions then mints with workspace credentials and preserves the assert.equal(new Headers(call.init?.headers).get("Authorization"), "workspace-key"); assert.equal(JSON.parse(String(call.init?.body)).service_external_id, "existing-id"); } - assert.equal(result.linked, true); - assert.equal(JSON.stringify(result).includes("new-access"), false); + assert.deepEqual(result, { linked: true }); + assert.equal(state.row().accessToken, "new-access"); }); test("new identities retain the deterministic ID used by existing apps", async () => { @@ -76,32 +59,32 @@ test("unexpired tokens are reused without an upstream request", async () => { assert.equal(state.calls.length, 0); }); -test("refresh uses the OAuth exchange and persists rotated credentials and lifetime", async () => { +test("an expiring token is re-minted with the workspace key and stored", async () => { const state = setup(); assert.equal(await getBase44AccessToken("builder@example.com"), "new-access"); - assert.equal(state.calls[0].url, "https://base44.example/oauth/token"); - assert.equal(new Headers(state.calls[0].init?.headers).has("Authorization"), false); - assert.equal(String(state.calls[0].init?.body), "grant_type=refresh_token&client_id=svc_delegate&refresh_token=old-refresh"); - assert.equal(state.row().refreshToken, "new-refresh"); + assert.equal(state.calls[0].url, "https://base44.example/api/service/user-tokens"); + assert.equal(new Headers(state.calls[0].init?.headers).get("Authorization"), "workspace-key"); + assert.equal(state.row().accessToken, "new-access"); assert.ok(state.row().expiresAt.getTime() > Date.now() + 3500_000); }); -test("refresh failures preserve stored credentials and never expose upstream bodies", async () => { +test("mint failures keep stored credentials and never expose upstream bodies", async () => { const state = setup(); - for (const status of [400, 429, 503]) { + for (const status of [404, 429, 503]) { globalThis.fetch = async () => new Response("secret-canary", { status }); await assert.rejects(getBase44AccessToken("builder@example.com"), (error: any) => { - assert.equal(error.status, status === 400 ? 428 : 502); + assert.equal(error.status, status === 404 ? 428 : 502); assert.equal(error.message.includes("secret-canary"), false); return true; }); - assert.equal(state.row().refreshToken, "old-refresh"); + assert.equal(state.row().accessToken, "old-access"); } }); -test("disconnect clears tokens but retains identity mapping", async () => { +test("disconnect revokes and clears tokens but keeps the identity mapping", async () => { const state = setup(); await disconnect("builder@example.com"); + assert.equal(state.calls[0].url, "https://base44.example/oauth/revoke"); assert.equal(state.row().serviceExternalId, "existing-id"); assert.equal(state.row().accessToken, null); assert.equal(state.row().refreshToken, null); From 037756bf23bc06fd48302a2cad815948f7f54bd1 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 17:16:38 +0300 Subject: [PATCH 05/28] Tiny Sunny: use the documented single-account model Base44's tenancy docs describe one integration account that owns every app, called with its personal access token. Tiny now follows that model: - every call sends Authorization: Bearer and X-Active-Workspace-Id; createApp sends organization_id so apps use the workspace's design system - client.ts is a plain module object; no per-user client - removes service users: identity.ts, the connection route, the Base44Link model, the connect/disconnect UI and its tests - builder isolation stays where the docs put it: Tiny's ownership table, checked by the API handler before every app call - README and .env.example describe BASE44_ACCESS_TOKEN and BASE44_WORKSPACE_ID Co-Authored-By: Claude Opus 5.5 --- examples/white-label-minimal/.env.example | 7 +- examples/white-label-minimal/README.md | 40 +++--- .../app/api/base44/connection/route.ts | 41 ------ .../components/Workspace.tsx | 49 +------ .../white-label-minimal/lib/base44/client.ts | 124 +++++++++--------- .../white-label-minimal/lib/base44/config.ts | 11 +- .../white-label-minimal/lib/base44/http.ts | 26 ++-- .../lib/base44/identity.ts | 111 ---------------- .../lib/chat/builder-api.ts | 9 -- .../lib/server/app-service.ts | 18 ++- examples/white-label-minimal/package.json | 2 +- .../white-label-minimal/prisma/schema.prisma | 29 ---- .../tests/identity.test.ts | 92 ------------- .../white-label-minimal/tests/server.test.ts | 16 +-- 14 files changed, 128 insertions(+), 447 deletions(-) delete mode 100644 examples/white-label-minimal/app/api/base44/connection/route.ts delete mode 100644 examples/white-label-minimal/lib/base44/identity.ts delete mode 100644 examples/white-label-minimal/tests/identity.test.ts diff --git a/examples/white-label-minimal/.env.example b/examples/white-label-minimal/.env.example index 6d24078..a1458d0 100644 --- a/examples/white-label-minimal/.env.example +++ b/examples/white-label-minimal/.env.example @@ -3,9 +3,10 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= DATABASE_URL= DIRECT_URL= -# Workspace API key with the service_users:provision and user_tokens:mint scopes. -BASE44_SVC_KEY= -BASE44_ORG_ID= +# Personal access token of the integration account, created in your enterprise +# workspace with access to all apps and full permission. +BASE44_ACCESS_TOKEN= +BASE44_WORKSPACE_ID= # Set to your Base44 HTTPS origin. BASE44_PLATFORM_HOST= # Set NEXTAUTH_URL explicitly, including in local development. diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md index a0088ba..a315fad 100644 --- a/examples/white-label-minimal/README.md +++ b/examples/white-label-minimal/README.md @@ -1,12 +1,13 @@ # Tiny Sunny -A minimal Base44 integration: sign in, connect a workspace, create an app, -chat with the builder, answer its questions, preview, and publish. +A minimal Base44 integration: sign in, create an app, chat with the builder, +answer its questions, preview, and publish. -This example uses the [service-user tenancy model](https://base44-docs-white-label-rewrite.mintlify.site/white-label/tenancy-and-credentials#service-users). -Each builder gets a Base44 service user that owns their apps. Your backend provisions -that identity using a workspace API key, then uses the service user's access token -to create and manage apps. Both credentials stay on the server. +It follows Base44's [tenancy model](https://docs.base44.com/developers/white-label/tenancy-and-credentials): +one Base44 account owns every app, and your server calls Base44 with that account's +personal access token. Base44 cannot tell your builders apart, so Tiny keeps them +apart itself: it records which builder owns each app and checks that on every call. +The token never reaches the browser. ## Run locally @@ -18,22 +19,22 @@ cp examples/white-label-minimal/.env.example examples/white-label-minimal/.env.l ``` Fill in `.env.local` with Sunny's Google OAuth credentials, Auth.js secret, -migrated Prisma database, and Base44 workspace service key. Keep `NEXTAUTH_URL` -set to `http://127.0.0.1:3001`, then run: +migrated Prisma database, and the integration account's +[personal access token](https://docs.base44.com/Workspaces/Personal-access-tokens) and workspace ID. +Create the token in your enterprise workspace, with access to all apps and full permission. +Keep `NEXTAUTH_URL` set to `http://127.0.0.1:3001`, then run: ```sh npm run minimal:dev ``` -Open [Tiny Sunny](http://127.0.0.1:3001), sign in, and select **Connect workspace**. -Apps are saved in Sunny's ownership table, so existing apps in the same database -and workspace are available here too. +Open [Tiny Sunny](http://127.0.0.1:3001) and sign in. ## Copy the integration | Folder | Responsibility | | --- | --- | -| `lib/base44/` | Base44 API calls, identity lifecycle, and custom instructions | +| `lib/base44/` | Base44 API calls, credentials, and custom instructions | | `lib/server/` | Authentication and request handling | | `lib/storage/` | Database access and app ownership | | `lib/chat/` | Browser API calls and conversation helpers | @@ -58,21 +59,16 @@ components/Builder.tsx → lib/chat/builder-api.ts → app/api/base44/route.ts [lib/server/app-service.ts](lib/server/app-service.ts) is where your application plugs in: - [server/auth.ts](lib/server/auth.ts) supplies the verified user from your session. -- [base44/identity.ts](lib/base44/identity.ts) provisions a service user and mints a 1-hour - token for it, reusing the stored token until it expires. - [storage/app-repository.ts](lib/storage/app-repository.ts) saves ownership and scopes app access to that user. -[prisma/schema.prisma](prisma/schema.prisma) defines identity links and app ownership. +[prisma/schema.prisma](prisma/schema.prisma) defines app ownership. [lib/storage/db.ts](lib/storage/db.ts) connects through `DATABASE_URL`. The Prisma client is generated on install and build; run `npm run db:generate` from the example after schema edits. -Google login and Base44 connection are separate. `/api/base44/connection` -connects or disconnects a builder. Connecting provisions a service user with the workspace key, -which needs the `service_users:provision` and `user_tokens:mint` scopes. Builder requests use the -stored token and never send credentials to the browser. Minting is rate-limited per workspace, -so reuse each token for its lifetime rather than minting per request. Tokens are stored in plain -text here; encrypt them at rest in production. -The API handler validates requests and checks app ownership before app operations. +Every Base44 call sends `Authorization: Bearer ` and `X-Active-Workspace-Id`, and +new apps are created with `organization_id`, so they use your workspace's design system, +skills, and plan. The API handler validates each request and checks that the signed-in +builder owns the app before any app operation. For the chat UI, copy `components/`, `lib/chat/builder-api.ts`, `lib/chat/conversation.ts`, and `lib/chat/assistant-messages.ts`. The chat uses assistant-ui's external-store runtime diff --git a/examples/white-label-minimal/app/api/base44/connection/route.ts b/examples/white-label-minimal/app/api/base44/connection/route.ts deleted file mode 100644 index 12874dc..0000000 --- a/examples/white-label-minimal/app/api/base44/connection/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { requireUser } from "../../../../lib/server/auth"; -import { connect, disconnect, getConnection } from "../../../../lib/base44/identity"; -import { Base44Error } from "../../../../lib/base44/error"; - -const headers = { "Cache-Control": "no-store, private" }; - -export async function POST(request: Request) { - const origin = request.headers.get("origin"); - const allowed = process.env.BUILDER_ORIGIN || new URL(request.url).origin; - if (origin !== allowed) - return Response.json( - { error: "This request origin is not allowed." }, - { status: 403, headers }, - ); - try { - const actor = await requireUser(); - const payload = await request.json().catch(() => null); - let result; - switch (payload?.action) { - case "status": - result = await getConnection(actor.email); - break; - case "connect": - result = await connect(actor.email); - break; - case "disconnect": - result = await disconnect(actor.email); - break; - default: - return Response.json({ error: "Invalid connection action." }, { status: 400, headers }); - } - return Response.json(result, { headers }); - } catch (error) { - if (error instanceof Base44Error) - return Response.json({ error: error.message }, { status: error.status, headers }); - return Response.json( - { error: "Could not update your Base44 connection." }, - { status: 500, headers }, - ); - } -} diff --git a/examples/white-label-minimal/components/Workspace.tsx b/examples/white-label-minimal/components/Workspace.tsx index ef4d0df..fdd3a2b 100644 --- a/examples/white-label-minimal/components/Workspace.tsx +++ b/examples/white-label-minimal/components/Workspace.tsx @@ -3,7 +3,7 @@ import type { App } from "../lib/types"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { signOut } from "next-auth/react"; -import { ArrowLeft, Grid2X2, Loader2, LogOut, MessageSquare, Plus, Sparkles, Unplug, X } from "lucide-react"; +import { ArrowLeft, Grid2X2, Loader2, LogOut, MessageSquare, Plus, Sparkles, X } from "lucide-react"; import TinySunnyLogo from "./TinySunnyLogo"; import * as api from "../lib/chat/builder-api"; import Builder from "./Builder"; @@ -14,7 +14,6 @@ export default function Workspace({ name }: { name: string }) { const [apps, setApps] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [needsConnection, setNeedsConnection] = useState(false); const [removing, setRemoving] = useState(null); const [inApp, setInApp] = useState(false); // What the stage shows. Kept apart from `editor` so an app created mid-chat @@ -48,10 +47,8 @@ export default function Workspace({ name }: { name: string }) { } setApps(all); setError(""); - setNeedsConnection(false); } catch (err) { - if (err instanceof api.ApiError && err.status === 428) setNeedsConnection(true); - else setError(err instanceof Error ? err.message : "Could not load your apps."); + setError(err instanceof Error ? err.message : "Could not load your apps."); } finally { setLoading(false); } @@ -105,30 +102,6 @@ export default function Workspace({ name }: { name: string }) { setRemoving(null); } } - async function connect() { - setLoading(true); - setError(""); - try { - await api.setConnection("connect"); - setNeedsConnection(false); - await load(); - } catch (err) { - setError((err as Error).message); - setLoading(false); - } - } - async function disconnect() { - setError(""); - try { - await api.setConnection("disconnect"); - backToApps(); - setApps([]); - setNeedsConnection(true); - } catch (err) { - setError((err as Error).message); - } - } - return (
@@ -136,15 +109,10 @@ export default function Workspace({ name }: { name: string }) {
- {name} - {!needsConnection && !loading && ( - - )} @@ -160,16 +128,7 @@ export default function Workspace({ name }: { name: string }) {
)} - {needsConnection ? ( -
- -

Your ideas start here

-

Connect your workspace to build your first app.

- -
- ) : loading && !apps.length ? ( + {loading && !apps.length ? (

Loading your apps…

diff --git a/examples/white-label-minimal/lib/base44/client.ts b/examples/white-label-minimal/lib/base44/client.ts index 00fe46f..2c51136 100644 --- a/examples/white-label-minimal/lib/base44/client.ts +++ b/examples/white-label-minimal/lib/base44/client.ts @@ -1,79 +1,79 @@ import "server-only"; import { CONVERSATION_PAGE_SIZE } from "../chat/conversation"; import type { App, Message, ToolInput } from "../types"; +import { getBase44Config } from "./config"; import { customInstructions } from "./custom-instructions"; import { Base44Error } from "./error"; -import { base44Fetch } from "./http"; +import { base44Fetch as request } from "./http"; -// The Base44 endpoints Tiny uses, called as one builder's service user. -// Full reference: https://app.base44.com/api/openapi.json -export function createBase44Client(accessToken: string) { - const request = (path: string, options: Parameters[1] = {}) => - base44Fetch(path, { ...options, headers: { Authorization: `Bearer ${accessToken}`, ...options.headers } }); - // Writes return nothing to the browser; Tiny re-reads state by polling instead. - const post = (path: string, body: object, headers?: Record) => - request(path, { body, headers, timeout: 120_000 }).then(() => ({})); - const app = (id: string) => `/api/apps/${encodeURIComponent(id)}`; +// Writes return nothing to the browser; Tiny re-reads state by polling instead. +const post = (path: string, body: object, headers?: Record) => + request(path, { body, headers, timeout: 120_000 }).then(() => ({})); +const app = (id: string) => `/api/apps/${encodeURIComponent(id)}`; - return { - createApp: async (prompt: string) => - toApp(await request("/api/apps", { - body: { - name: prompt.trim().slice(0, 80), - user_description: prompt, - initial_message: { content: prompt }, - custom_instructions: customInstructions, - prevent_iframe_embedding: false, - }, - timeout: 120_000, - })), +// The Base44 endpoints Tiny uses. Every app belongs to the integration account, +// so the caller must check which builder owns an app before calling these. +// Full reference: https://app.base44.com/api/openapi.json +export const base44 = { + createApp: async (prompt: string) => + toApp(await request("/api/apps", { + body: { + // Without it the app lands in the account's personal workspace. + organization_id: getBase44Config().workspaceId, + name: prompt.trim().slice(0, 80), + user_description: prompt, + initial_message: { content: prompt }, + custom_instructions: customInstructions, + prevent_iframe_embedding: false, + }, + timeout: 120_000, + })), - getApp: async (id: string) => toApp(await request(app(id))), + getApp: async (id: string) => toApp(await request(app(id))), - // skip counts backward from the newest message. - async getConversation(id: string, skip: number) { - const data = await request(`${app(id)}/chat/full-conversation?limit=${CONVERSATION_PAGE_SIZE}&skip=${skip}`, { - timeout: 60_000, - }); - const messages = data?.messages ?? []; - if (!Array.isArray(messages)) throw new Base44Error("Unexpected conversation response."); - return { messages: messages.map(toMessage) }; - }, + // skip counts backward from the newest message. + async getConversation(id: string, skip: number) { + const data = await request(`${app(id)}/chat/full-conversation?limit=${CONVERSATION_PAGE_SIZE}&skip=${skip}`, { + timeout: 60_000, + }); + const messages = data?.messages ?? []; + if (!Array.isArray(messages)) throw new Base44Error("Unexpected conversation response."); + return { messages: messages.map(toMessage) }; + }, - sendMessage: (id: string, content: string) => post(`${app(id)}/chat/message`, { content }), + sendMessage: (id: string, content: string) => post(`${app(id)}/chat/message`, { content }), - // A fixed request ID lets Base44 deduplicate retries of the same answer. - submitToolCallInput: (p: ToolInput) => - post(`${app(p.appId)}/chat/submit-tool-call-input`, { - tool_call_id: p.toolCallId, - message_id: p.messageId, - action: p.approve ? "approved" : "rejected", - extra_user_input: p.extraUserInput, - }, { "X-Request-ID": `submit-${p.toolCallId}` }), + // A fixed request ID lets Base44 deduplicate retries of the same answer. + submitToolCallInput: (p: ToolInput) => + post(`${app(p.appId)}/chat/submit-tool-call-input`, { + tool_call_id: p.toolCallId, + message_id: p.messageId, + action: p.approve ? "approved" : "rejected", + extra_user_input: p.extraUserInput, + }, { "X-Request-ID": `submit-${p.toolCallId}` }), - deployApp: (id: string) => post(`${app(id)}/deploy`, {}), + deployApp: (id: string) => post(`${app(id)}/deploy`, {}), - async getPreviewUrl(id: string) { - const data = await request(`${app(id)}/sandbox/preview-url`, { timeout: 120_000 }); - const url = httpsUrl(data?.preview_url); - if (data.preview_token) url.searchParams.set("_preview_token", data.preview_token); - url.searchParams.set("server_url", url.origin); - url.searchParams.set("hide_badge", "true"); - url.searchParams.set("analytics-enable", "false"); - return { url: url.href }; - }, + async getPreviewUrl(id: string) { + const data = await request(`${app(id)}/sandbox/preview-url`, { timeout: 120_000 }); + const url = httpsUrl(data?.preview_url); + if (data.preview_token) url.searchParams.set("_preview_token", data.preview_token); + url.searchParams.set("server_url", url.origin); + url.searchParams.set("hide_badge", "true"); + url.searchParams.set("analytics-enable", "false"); + return { url: url.href }; + }, - async getPublishedUrl(id: string) { - try { - const data = await request(`/api/apps/platform/${encodeURIComponent(id)}/published-url`); - return { url: httpsUrl(data?.url).href }; - } catch (error) { - if (error instanceof Base44Error && error.status === 404) return { url: null }; - throw error; - } - }, - }; -} + async getPublishedUrl(id: string) { + try { + const data = await request(`/api/apps/platform/${encodeURIComponent(id)}/published-url`); + return { url: httpsUrl(data?.url).href }; + } catch (error) { + if (error instanceof Base44Error && error.status === 404) return { url: null }; + throw error; + } + }, +}; // Copy only the fields the browser needs; never pass a raw Base44 object through. function toApp(app: App): App { diff --git a/examples/white-label-minimal/lib/base44/config.ts b/examples/white-label-minimal/lib/base44/config.ts index 6c4e113..9bb2a77 100644 --- a/examples/white-label-minimal/lib/base44/config.ts +++ b/examples/white-label-minimal/lib/base44/config.ts @@ -1,16 +1,21 @@ import "server-only"; import { Base44Error } from "./error"; +// One Base44 account owns every app. Its personal access token must come from +// your enterprise workspace, with access to all apps and full permission. export function getBase44Config() { + const token = process.env.BASE44_ACCESS_TOKEN?.trim(); + const workspaceId = process.env.BASE44_WORKSPACE_ID?.trim(); let host: URL; try { host = new URL(process.env.BASE44_PLATFORM_HOST || ""); } catch { - throw new Base44Error("The workspace connection is not configured.", 503); + throw new Base44Error("The Base44 connection is not configured.", 503); } // A bare origin only: no credentials, path, query, or fragment. if (host.protocol !== "https:" || host.href !== `${host.origin}/`) { - throw new Base44Error("The workspace connection requires an HTTPS platform origin.", 503); + throw new Base44Error("The Base44 connection requires an HTTPS platform origin.", 503); } - return { host: host.origin }; + if (!token || !workspaceId) throw new Base44Error("The Base44 connection is not configured.", 503); + return { host: host.origin, token, workspaceId }; } diff --git a/examples/white-label-minimal/lib/base44/http.ts b/examples/white-label-minimal/lib/base44/http.ts index 67c617a..cc100ee 100644 --- a/examples/white-label-minimal/lib/base44/http.ts +++ b/examples/white-label-minimal/lib/base44/http.ts @@ -4,32 +4,36 @@ import { getBase44Config } from "./config"; // What a Base44 status means for the person using the app. const hints: Record = { - 401: "Reconnect your workspace.", - 403: "Check the account’s workspace access.", + 401: "Check the server's Base44 access token.", + 403: "Check the access token's workspace permissions.", 409: "The app is not ready for this yet. Ask the agent to fix it.", 429: "Rate limit reached. Wait before trying again.", }; type Options = { - body?: object | URLSearchParams; + body?: object; headers?: Record; timeout?: number; - errorFor?: (status: number) => Base44Error; }; -/** POST when there is a body, GET otherwise. Every failure is a Base44Error that is safe to show. */ -export async function base44Fetch(path: string, { body, headers, timeout = 30_000, errorFor }: Options = {}) { - const { host } = getBase44Config(); - const form = body instanceof URLSearchParams; +/** + * Calls Base44 as the integration account. POST when there is a body, GET otherwise. + * Every failure is a Base44Error that is safe to show. + */ +export async function base44Fetch(path: string, { body, headers, timeout = 30_000 }: Options = {}) { + const { host, token, workspaceId } = getBase44Config(); let response: Response; try { response = await fetch(`${host}${path}`, { method: body ? "POST" : "GET", headers: { - "Content-Type": form ? "application/x-www-form-urlencoded" : "application/json", + Authorization: `Bearer ${token}`, + // Capability checks read the active workspace, not the request body. + "X-Active-Workspace-Id": workspaceId, + "Content-Type": "application/json", ...headers, }, - body: form ? body : body && JSON.stringify(body), + body: body && JSON.stringify(body), cache: "no-store", redirect: "error", signal: AbortSignal.timeout(timeout), @@ -41,7 +45,7 @@ export async function base44Fetch(path: string, { body, headers, timeout = 30_00 if (!response.ok) { const { status } = response; const hint = hints[status] ?? "Refresh the app state before repeating an operation."; - throw errorFor?.(status) ?? new Base44Error(`Base44 returned ${status}. ${hint}`, status); + throw new Base44Error(`Base44 returned ${status}. ${hint}`, status); } const text = await response.text(); if (!text) return null; diff --git a/examples/white-label-minimal/lib/base44/identity.ts b/examples/white-label-minimal/lib/base44/identity.ts deleted file mode 100644 index 284c914..0000000 --- a/examples/white-label-minimal/lib/base44/identity.ts +++ /dev/null @@ -1,111 +0,0 @@ -import "server-only"; -import { createHash } from "node:crypto"; -import { prisma } from "../storage/db"; -import { Base44Error } from "./error"; -import { base44Fetch } from "./http"; - -// Each Tiny user gets one Base44 service user, created with the workspace key. -// Base44 calls use a 1-hour token minted for that service user. Minting is -// rate-limited per workspace, so the token is stored and reused until it expires. - -export async function getConnection(email: string) { - const link = await prisma.base44Link.findUnique({ where: { appUserEmail: email.toLowerCase() } }); - return { linked: link?.status === "linked" }; -} - -export async function connect(email: string) { - const appUserEmail = email.toLowerCase(); - const organizationId = required("BASE44_ORG_ID"); - const existing = await prisma.base44Link.findUnique({ where: { appUserEmail } }); - // Never change this format: existing apps belong to the service user it names. - const digest = createHash("sha256").update(`${organizationId}:${appUserEmail}`).digest("hex"); - const serviceExternalId = existing?.serviceExternalId ?? `sunny-${digest.slice(0, 32)}`; - - // Idempotent: returns the existing service user when there is one. - const principal = await post("/api/service/users", { - service_external_id: serviceExternalId, - display_name: `Builder ${serviceExternalId.slice(-8)}`, - }); - const base44UserEmail = principal?.email; - if (typeof base44UserEmail !== "string" || !/@([^@]+\.)?svc\.base44\.invalid$/.test(base44UserEmail)) - throw new Base44Error("Base44 returned an invalid service-user identity."); - - const record = { - ...(await mint(serviceExternalId)), - status: "linked" as const, - organizationId, - serviceExternalId, - base44UserEmail, - principalProvisioned: true, - }; - await prisma.base44Link.upsert({ - where: { appUserEmail }, - create: { appUserEmail, createdBy: appUserEmail, ...record }, - update: record, - }); - return { linked: true }; -} - -export async function getBase44AccessToken(email: string): Promise { - const appUserEmail = email.toLowerCase(); - const link = await prisma.base44Link.findUnique({ where: { appUserEmail } }); - if (link?.status !== "linked" || !link.serviceExternalId) - throw new Base44Error("Connect your workspace to start building.", 428); - if (link.accessToken && link.expiresAt && link.expiresAt.getTime() > Date.now() + 60_000) - return link.accessToken; - - const tokens = await mint(link.serviceExternalId); - await prisma.base44Link.update({ where: { appUserEmail }, data: tokens }); - return tokens.accessToken; -} - -// Keeps the service user, so reconnecting brings back the same apps. -export async function disconnect(email: string) { - const appUserEmail = email.toLowerCase(); - const link = await prisma.base44Link.findUnique({ where: { appUserEmail } }); - if (link?.refreshToken) { - // Best effort: the local tokens are cleared even if revocation fails. - await base44Fetch("/oauth/revoke", { - body: new URLSearchParams({ token: link.refreshToken, client_id: "svc_delegate" }), - }).catch(() => {}); - } - await prisma.base44Link.updateMany({ - where: { appUserEmail }, - data: { status: "pending", accessToken: null, refreshToken: null, expiresAt: null }, - }); - return { linked: false }; -} - -async function mint(serviceExternalId: string) { - const data = await post("/api/service/user-tokens", { service_external_id: serviceExternalId }, (status) => - // 429 is the workspace-wide mint limit and 408 a timeout: try again later. - // Any other 4xx means the service user or key no longer works: reconnect. - status >= 400 && status < 500 && status !== 429 && status !== 408 - ? new Base44Error("Reconnect your workspace to continue.", 428) - : undefined); - const expiresIn = Number(data?.expires_in); - if (typeof data?.access_token !== "string" || !data.access_token || !(expiresIn > 0) || !Number.isFinite(expiresIn)) - throw new Base44Error("Base44 returned invalid credentials."); - return { - accessToken: data.access_token as string, - // Unused here, but main Sunny shares this row and revokes it on disconnect. - refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : null, - expiresAt: new Date(Date.now() + expiresIn * 1000), - }; -} - -// Calls authenticated with the workspace key. It needs the service_users:provision -// and user_tokens:mint scopes. -function post(path: string, body: object, errorFor?: (status: number) => Base44Error | undefined) { - return base44Fetch(path, { - body, - headers: { Authorization: required("BASE44_SVC_KEY") }, - errorFor: (status) => errorFor?.(status) ?? new Base44Error(`Base44 returned ${status}. Please try again.`, 502), - }); -} - -function required(name: string) { - const value = process.env[name]?.trim(); - if (!value) throw new Base44Error(`Configure ${name} on the server.`, 503); - return value; -} diff --git a/examples/white-label-minimal/lib/chat/builder-api.ts b/examples/white-label-minimal/lib/chat/builder-api.ts index 915a4b4..adc62ec 100644 --- a/examples/white-label-minimal/lib/chat/builder-api.ts +++ b/examples/white-label-minimal/lib/chat/builder-api.ts @@ -56,12 +56,3 @@ export const getPublishedUrl = (appId: string) => call<{ url: string | null }>("getPublishedUrl", { appId }); export const listApps = (skip = 0) => call("listApps", { skip }); export const removeApp = (appId: string) => call("removeApp", { appId }); - -export async function setConnection(action: "connect" | "disconnect") { - const response = await fetch("/api/base44/connection", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), - }); - if (!response.ok) throw new ApiError(`Could not ${action} your workspace. Please try again.`, response.status); -} diff --git a/examples/white-label-minimal/lib/server/app-service.ts b/examples/white-label-minimal/lib/server/app-service.ts index a9b2d3e..417b385 100644 --- a/examples/white-label-minimal/lib/server/app-service.ts +++ b/examples/white-label-minimal/lib/server/app-service.ts @@ -1,26 +1,24 @@ import "server-only"; import { requireUser } from "./auth"; import { withStaticPreview } from "./static-preview"; -import { getBase44AccessToken } from "../base44/identity"; -import { createBase44Client } from "../base44/client"; +import { base44 } from "../base44/client"; import { createAppRepository } from "../storage/app-repository"; import { resolveAppPage } from "../storage/app-list"; import type { AppClient } from "../types"; -// Where Tiny plugs into Base44: the signed-in user, their Base44 token, and -// the apps they own. +// Where Tiny plugs into Base44. Base44 sees one account behind every builder, +// so this is where builders are kept apart: every app call is checked against +// the signed-in user's ownership rows (see api-handler.ts). export async function getAppClient(): Promise { - const actor = await requireUser(); - const client = createBase44Client(await getBase44AccessToken(actor.email)); - const apps = createAppRepository(actor); - const getApp = async (id: string) => withStaticPreview(await client.getApp(id)); + const apps = createAppRepository(await requireUser()); + const getApp = async (id: string) => withStaticPreview(await base44.getApp(id)); return { - ...client, + ...base44, getApp, authorize: apps.authorize, removeApp: apps.remove, async createApp(prompt) { - const app = await client.createApp(prompt); + const app = await base44.createApp(prompt); await apps.save(app); return withStaticPreview(app); }, diff --git a/examples/white-label-minimal/package.json b/examples/white-label-minimal/package.json index e937cf2..31a057f 100644 --- a/examples/white-label-minimal/package.json +++ b/examples/white-label-minimal/package.json @@ -7,7 +7,7 @@ "build": "npm run db:generate && next build", "start": "next start --hostname 127.0.0.1 --port 3001", "typecheck": "next typegen && tsc --noEmit", - "test": "tsx --conditions=react-server --test tests/server.test.ts tests/identity.test.ts && tsx --test tests/conversation.test.ts tests/questions.test.tsx", + "test": "tsx --conditions=react-server --test tests/server.test.ts && tsx --test tests/conversation.test.ts tests/questions.test.tsx", "test:browser": "playwright test", "db:generate": "prisma generate", "postinstall": "npm run db:generate" diff --git a/examples/white-label-minimal/prisma/schema.prisma b/examples/white-label-minimal/prisma/schema.prisma index cb0915b..b207b79 100644 --- a/examples/white-label-minimal/prisma/schema.prisma +++ b/examples/white-label-minimal/prisma/schema.prisma @@ -9,11 +9,6 @@ datasource db { url = env("DATABASE_URL") } -enum Base44LinkStatus { - pending - linked -} - model AppOwnership { id String @id @default(cuid()) appId String @map("app_id") @@ -27,27 +22,3 @@ model AppOwnership { @@index([createdBy]) @@map("app_ownerships") } - -model Base44Link { - id String @id @default(cuid()) - - appUserEmail String @unique @map("app_user_email") - - serviceExternalId String? @unique @map("service_external_id") - - status Base44LinkStatus @default(pending) - accessToken String? @map("access_token") - refreshToken String? @map("refresh_token") - expiresAt DateTime? @map("expires_at") - - organizationId String? @map("organization_id") - base44UserEmail String? @map("base44_user_email") - principalProvisioned Boolean @default(false) @map("principal_provisioned") - - createdBy String @map("created_by") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@index([createdBy]) - @@map("base44_links") -} diff --git a/examples/white-label-minimal/tests/identity.test.ts b/examples/white-label-minimal/tests/identity.test.ts deleted file mode 100644 index c32e874..0000000 --- a/examples/white-label-minimal/tests/identity.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import assert from "node:assert/strict"; -import { afterEach, test } from "node:test"; -import { createHash } from "node:crypto"; -import { prisma } from "../lib/storage/db"; -import { connect, disconnect, getBase44AccessToken } from "../lib/base44/identity"; - -const originalFetch = globalThis.fetch; -const originalEnv = { ...process.env }; -const originalMethods = { ...prisma.base44Link }; -afterEach(() => { - globalThis.fetch = originalFetch; - process.env = { ...originalEnv }; - Object.assign(prisma.base44Link, originalMethods); -}); - -function setup(overrides = {}) { - process.env.BASE44_PLATFORM_HOST = "https://base44.example"; - process.env.BASE44_ORG_ID = "workspace"; - process.env.BASE44_SVC_KEY = "workspace-key"; - let row: any = { - appUserEmail: "builder@example.com", status: "linked", accessToken: "old-access", - refreshToken: "old-refresh", expiresAt: new Date(0), serviceExternalId: "existing-id", - ...overrides, - }; - const calls: { url: string; init?: RequestInit }[] = []; - const save = async ({ data, update }: any) => (row = { ...row, ...(data ?? update) }); - Object.assign(prisma.base44Link, { findUnique: async () => row, update: save, upsert: save, updateMany: save }); - globalThis.fetch = async (url, init) => { - calls.push({ url: String(url), init }); - return Response.json(String(url).endsWith("/api/service/users") - ? { email: "builder@workspace.svc.base44.invalid" } - : { access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }); - }; - return { calls, row: () => row }; -} - -test("connect provisions then mints with the workspace key and keeps the stored identity", async () => { - const state = setup(); - const result = await connect("BUILDER@example.com"); - assert.deepEqual(state.calls.map(c => new URL(c.url).pathname), ["/api/service/users", "/api/service/user-tokens"]); - for (const call of state.calls) { - assert.equal(new Headers(call.init?.headers).get("Authorization"), "workspace-key"); - assert.equal(JSON.parse(String(call.init?.body)).service_external_id, "existing-id"); - } - assert.deepEqual(result, { linked: true }); - assert.equal(state.row().accessToken, "new-access"); -}); - -test("new identities retain the deterministic ID used by existing apps", async () => { - const state = setup({ serviceExternalId: null }); - await connect("BUILDER@example.com"); - const digest = createHash("sha256").update("workspace:builder@example.com").digest("hex"); - assert.equal(state.row().serviceExternalId, `sunny-${digest.slice(0, 32)}`); -}); - -test("unexpired tokens are reused without an upstream request", async () => { - const state = setup({ expiresAt: new Date(Date.now() + 120_000) }); - assert.equal(await getBase44AccessToken("builder@example.com"), "old-access"); - assert.equal(state.calls.length, 0); -}); - -test("an expiring token is re-minted with the workspace key and stored", async () => { - const state = setup(); - assert.equal(await getBase44AccessToken("builder@example.com"), "new-access"); - assert.equal(state.calls[0].url, "https://base44.example/api/service/user-tokens"); - assert.equal(new Headers(state.calls[0].init?.headers).get("Authorization"), "workspace-key"); - assert.equal(state.row().accessToken, "new-access"); - assert.ok(state.row().expiresAt.getTime() > Date.now() + 3500_000); -}); - -test("mint failures keep stored credentials and never expose upstream bodies", async () => { - const state = setup(); - for (const status of [404, 429, 503]) { - globalThis.fetch = async () => new Response("secret-canary", { status }); - await assert.rejects(getBase44AccessToken("builder@example.com"), (error: any) => { - assert.equal(error.status, status === 404 ? 428 : 502); - assert.equal(error.message.includes("secret-canary"), false); - return true; - }); - assert.equal(state.row().accessToken, "old-access"); - } -}); - -test("disconnect revokes and clears tokens but keeps the identity mapping", async () => { - const state = setup(); - await disconnect("builder@example.com"); - assert.equal(state.calls[0].url, "https://base44.example/oauth/revoke"); - assert.equal(state.row().serviceExternalId, "existing-id"); - assert.equal(state.row().accessToken, null); - assert.equal(state.row().refreshToken, null); - assert.equal(state.row().status, "pending"); -}); diff --git a/examples/white-label-minimal/tests/server.test.ts b/examples/white-label-minimal/tests/server.test.ts index b890ce3..b19e31e 100644 --- a/examples/white-label-minimal/tests/server.test.ts +++ b/examples/white-label-minimal/tests/server.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { createHandler } from '../lib/server/api-handler'; -import { createBase44Client } from '../lib/base44/client'; +import { base44 } from '../lib/base44/client'; import { Base44Error } from '../lib/base44/error'; let signedIn = true; const POST = createHandler(async () => { if (!signedIn) throw new Base44Error('Sign in to continue.', 401); - return { ...createBase44Client('user-token-canary'), authorize: async (id: string) => { + return { ...base44, authorize: async (id: string) => { if (id === 'other_app') throw new Base44Error('App not found.', 404); }, removeApp: async () => ({}), listApps: async () => ({ apps: [], hasMore: false, nextSkip: 0 }) }; }); @@ -16,8 +16,9 @@ const originalFetch = globalThis.fetch; const originalEnv = { ...process.env }; afterEach(() => { signedIn = true; globalThis.fetch = originalFetch; process.env = { ...originalEnv }; }); function setup(reply: unknown = { id: 'app_1' }, status = 200) { - process.env.BASE44_ORG_ID = 'workspace_1'; process.env.BASE44_PLATFORM_HOST = 'https://platform.example'; + process.env.BASE44_ACCESS_TOKEN = 'pat-canary'; + process.env.BASE44_WORKSPACE_ID = 'workspace_1'; const calls: { url: string; init?: RequestInit }[] = []; globalThis.fetch = async (url, init) => { calls.push({ url: String(url), init }); @@ -52,16 +53,15 @@ test('rejects foreign origins, nonlocal hosts, non-JSON and oversized bodies', a assert.equal((await request({ action: 'createApp', prompt: 'x'.repeat(65000) })).status, 413); assert.equal(calls.length, 0); }); -test('creation uses the user token without organization routing', async () => { +test('creation authenticates as the integration account in the configured workspace', async () => { const calls = setup({ id: 'app_1', name: 'Build a reading list', user_description: 'Build a reading list', custom_instructions: customInstructions, api_key: 'should-not-return' }); - delete process.env.BASE44_ORG_ID; const response = await request({ action: 'createApp', prompt: 'Build a reading list' }); assert.deepEqual(await response.json(), { id: 'app_1', name: 'Build a reading list', user_description: 'Build a reading list' }); const init = calls[0].init!; - assert.equal(new Headers(init.headers).get('authorization'), 'Bearer user-token-canary'); - assert.equal(new Headers(init.headers).get('X-Active-Workspace-Id'), null); + assert.equal(new Headers(init.headers).get('authorization'), 'Bearer pat-canary'); + assert.equal(new Headers(init.headers).get('X-Active-Workspace-Id'), 'workspace_1'); const body = JSON.parse(String(init.body)); - assert.equal(Object.hasOwn(body, 'organization_id'), false); + assert.equal(body.organization_id, 'workspace_1'); assert.equal(body.name, 'Build a reading list'); assert.equal(body.initial_message.content, 'Build a reading list'); assert.equal(body.custom_instructions, customInstructions); From 90ae557a4d8cd7b8bc59d818aeebbb75fcf55bf0 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 17:21:36 +0300 Subject: [PATCH 06/28] Tiny Sunny: shorter origin check and body read in the API handler - origin check is one rule: BUILDER_ORIGIN when hosted, a loopback Host locally (refuses DNS-rebinding pages; request.url is not used because Next rewrites it) - read the body with request.text() plus a size check instead of a chunked reader - log unexpected errors before replacing them with a safe message Co-Authored-By: Claude Opus 5.5 --- .../lib/server/api-handler.ts | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/examples/white-label-minimal/lib/server/api-handler.ts b/examples/white-label-minimal/lib/server/api-handler.ts index 1e1cfa5..113ac06 100644 --- a/examples/white-label-minimal/lib/server/api-handler.ts +++ b/examples/white-label-minimal/lib/server/api-handler.ts @@ -46,6 +46,7 @@ export function createHandler(resolveClient: () => Promise) { const method = client[name as keyof AppClient] as (...args: unknown[]) => Promise; return Response.json(await method(...args), { headers }); } catch (error) { + if (!(error instanceof Base44Error)) console.error("[api/base44]", error); const safe = error instanceof Base44Error ? error : new Base44Error("The local request failed. Check configuration and refresh app state."); @@ -61,40 +62,24 @@ function fail(message: string, status = 400): never { throw new Base44Error(message, status); } -// Only same-origin calls: BUILDER_ORIGIN when hosted, a loopback host locally. +// Only Tiny's own pages may call this: BUILDER_ORIGIN when hosted, a loopback +// host locally. (A DNS-rebinding page would send its own name as Host, so any +// other host is refused.) request.url is not used because Next rewrites it. function checkOrigin(request: Request) { - const host = request.headers.get("host"); - const origin = request.headers.get("origin"); - const hosted = process.env.BUILDER_ORIGIN; - let ok: boolean; - if (hosted) { - const url = new URL(hosted); - ok = url.protocol === "https:" && url.origin === hosted && host === url.host && origin === hosted; - } else { - ok = !!host && /^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(host) && origin === `http://${host}`; - } - if (!ok) fail("This request origin is not allowed.", 403); + const host = request.headers.get("host") ?? ""; + const allowed = process.env.BUILDER_ORIGIN || + (/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host) ? `http://${host}` : null); + if (!allowed || request.headers.get("origin") !== allowed) fail("This request origin is not allowed.", 403); } async function readJson(request: Request): Promise> { if (request.headers.get("content-type")?.split(";")[0].trim() !== "application/json") fail("Send application/json.", 415); - // Read in chunks so an oversized body is rejected without buffering all of it. - const reader = request.body?.getReader(); - if (!reader) fail("A JSON body is required."); - const chunks: Uint8Array[] = []; - let size = 0; - for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) { - size += chunk.value.byteLength; - if (size > 64_000) { - await reader.cancel(); - fail("Request body is too large.", 413); - } - chunks.push(chunk.value); - } + const text = await request.text(); + if (text.length > 64_000) fail("Request body is too large.", 413); let json; try { - json = JSON.parse(Buffer.concat(chunks).toString("utf8")); + json = JSON.parse(text); } catch { fail("Invalid JSON."); } From ff5236e24a057a96829d6dd3760c8c001989f5be Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 17:25:37 +0300 Subject: [PATCH 07/28] Tiny Sunny: render chat parts with assistant-ui's component API - ThreadPrimitive.Messages with UserMessage / AssistantMessage, and MessagePrimitive.Parts components={{ Text, tools: { Fallback } }} instead of one render function with role and part-type branches - tool parts read appId and onAnswer from a small context, because assistant-ui renders part components itself - answers still go through onAnswer, not assistant-ui's resume(), so failures reach the Question and its retry-the-original-answer flow keeps working Co-Authored-By: Claude Opus 5.5 --- .../components/BuilderChat.tsx | 191 ++++++++---------- 1 file changed, 86 insertions(+), 105 deletions(-) diff --git a/examples/white-label-minimal/components/BuilderChat.tsx b/examples/white-label-minimal/components/BuilderChat.tsx index bbd9f00..9313685 100644 --- a/examples/white-label-minimal/components/BuilderChat.tsx +++ b/examples/white-label-minimal/components/BuilderChat.tsx @@ -1,12 +1,15 @@ "use client"; -import { useMemo, type ReactNode } from "react"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; import { AssistantRuntimeProvider, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, + useAuiState, useExternalStoreRuntime, + type TextMessagePartComponent, + type ToolCallMessagePartComponent, } from "@assistant-ui/react"; import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown"; import { Bot, Loader2, Send, User } from "lucide-react"; @@ -15,19 +18,7 @@ import type { Message, ToolCall, ToolInput } from "../lib/types"; import Question from "./Question"; import ToolActivity from "./ToolActivity"; -export default function BuilderChat({ - appId, - messages, - loading, - busy, - processing, - waiting, - disabled, - questionsDisabled, - onSend, - onAnswer, - children, -}: { +type Props = { appId: string | null; messages: Message[]; loading: boolean; @@ -39,7 +30,13 @@ export default function BuilderChat({ onSend: (text: string) => Promise; onAnswer: (input: ToolInput) => Promise; children: ReactNode; -}) { +}; + +// What tool parts need from the chat. A context rather than props, because +// assistant-ui renders part components itself. +const ChatContext = createContext | null>(null); + +export default function BuilderChat({ appId, messages, loading, busy, processing, waiting, disabled, questionsDisabled, onSend, onAnswer, children }: Props) { const visibleMessages = useMemo(() => messages.filter((m) => !m.hidden), [messages]); const runtime = useExternalStoreRuntime({ messages: visibleMessages, @@ -48,9 +45,7 @@ export default function BuilderChat({ isDisabled: disabled, isSendDisabled: disabled, onNew: async (message) => { - const text = message.content - .flatMap((part) => (part.type === "text" ? [part.text] : [])) - .join("\n"); + const text = message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"); // Restore the draft that assistant-ui cleared before sending. if (!(await onSend(text))) runtime.thread.composer.setText(text); }, @@ -58,95 +53,81 @@ export default function BuilderChat({ return ( - - - {loading && ( -
-
- )} - {!loading && !visibleMessages.length && - (appId ? ( -

No messages yet.

- ) : ( -
-

Build an app

+ + + + {loading && ( +
+
- ))} - - {({ message }) => message.content.length === 0 ? null : ( - - - {message.role === "user" ? : } - -
- - {({ part }) => { - if (part.type === "text") - return ( -
- {message.role === "user" ? ( -

{part.text}

- ) : ( - - )} -
- ); - if (part.type !== "tool-call") return null; - const { tool, messageId } = part.artifact as { - tool: ToolCall; - messageId: string; - }; - return (tool.status === "waiting_for_user_input" || tool.waiting_on?.kind) && - appId ? ( - - ) : ( - - ); - }} -
-
-
)} -
- {children} -
- - - - {busy ? : } - - {waiting && Answer or reject the waiting question to continue.} - -
+ {!loading && !visibleMessages.length && + (appId ?

No messages yet.

:

Build an app

)} + + {children} + + + + + {busy ? : } + + {waiting && Answer or reject the waiting question to continue.} + + +
); } + +function UserMessage() { + return ( + + +
+ +
+
+ ); +} + +function AssistantMessage() { + // Hide the empty placeholder assistant-ui adds while a run is in progress. + const hasContent = useAuiState((s) => s.message.content.length > 0); + if (!hasContent) return null; + return ( + + +
+ +
+
+ ); +} + +const PlainText: TextMessagePartComponent = ({ text }) =>

{text}

; +const MarkdownText: TextMessagePartComponent = () =>
; + +// Every Base44 tool call: a question while it waits for the builder, activity otherwise. +const ToolPart: ToolCallMessagePartComponent = ({ toolCallId, artifact }) => { + const chat = useContext(ChatContext)!; + const { tool, messageId } = artifact as { tool: ToolCall; messageId: string }; + const asking = tool.status === "waiting_for_user_input" || tool.waiting_on?.kind; + if (!asking || !chat.appId) return ; + return ( + + ); +}; From 2a53c97d9a859900200c23433fc3fb19fe374037 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 18:15:32 +0300 Subject: [PATCH 08/28] Tiny Sunny: say why a live-updates session was refused Every 401/403 used to read "needs apps:watch", which hid the other causes. Base44's error code now picks the message: missing scope, sockets not enabled for the workspace, or a key that cannot watch the app. A missing BASE44_SVC_KEY is reported before any request is made. Co-Authored-By: Claude Opus 5.5 --- .../lib/base44/socket-session.ts | 22 ++++++++++++++----- .../white-label-minimal/tests/server.test.ts | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/examples/white-label-minimal/lib/base44/socket-session.ts b/examples/white-label-minimal/lib/base44/socket-session.ts index 3be572f..4f845a2 100644 --- a/examples/white-label-minimal/lib/base44/socket-session.ts +++ b/examples/white-label-minimal/lib/base44/socket-session.ts @@ -2,19 +2,31 @@ import "server-only"; import { Base44Error } from "./error"; import { getBase44Config } from "./config"; +// Base44's reasons for refusing a session, as setup steps for the operator. +const problems: Record = { + scope_required: "Live updates need a workspace key with the apps:watch scope.", + whitelabel_sockets_disabled: "Live updates are not enabled for this Base44 workspace yet.", + app_not_allowed: "The workspace key cannot watch this app. Use a key from the app's workspace.", +}; + // Opens a read-only live-updates session for one app. The browser gets only the -// session token; the workspace key (it needs the apps:watch scope) stays here. +// session token; the workspace key stays here. It must belong to the same +// workspace as the apps and hold the apps:watch scope. export async function openSocketSession(appId: string) { const { host } = getBase44Config(); + const key = process.env.BASE44_SVC_KEY?.trim(); + if (!key) throw new Base44Error("Live updates need BASE44_SVC_KEY on the server.", 503); const response = await fetch(`${host}/api/service/socket-sessions`, { method: "POST", - headers: { Authorization: `Bearer ${process.env.BASE44_SVC_KEY}`, "Content-Type": "application/json" }, + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify({ app_ids: [appId] }), cache: "no-store", }); - if (response.status === 401 || response.status === 403) - throw new Base44Error("Live updates need a workspace key with the apps:watch scope.", 503); - if (!response.ok) throw new Base44Error(`Base44 returned ${response.status} opening live updates.`); + if (!response.ok) { + const code = await response.json().then((body) => body?.error?.code, () => undefined); + if (response.status === 401) throw new Base44Error("Live updates: Base44 rejected BASE44_SVC_KEY.", 503); + throw new Base44Error(problems[code] ?? `Live updates unavailable: Base44 returned ${response.status}.`, 503); + } const { socket_url, session_token } = await response.json(); return { serverUrl: new URL(socket_url).origin, sessionToken: String(session_token) }; } diff --git a/examples/white-label-minimal/tests/server.test.ts b/examples/white-label-minimal/tests/server.test.ts index be2d297..1c87fe2 100644 --- a/examples/white-label-minimal/tests/server.test.ts +++ b/examples/white-label-minimal/tests/server.test.ts @@ -20,6 +20,7 @@ function setup(reply: unknown = { id: 'app_1' }, status = 200) { process.env.BASE44_PLATFORM_HOST = 'https://platform.example'; process.env.BASE44_ACCESS_TOKEN = 'pat-canary'; process.env.BASE44_WORKSPACE_ID = 'workspace_1'; + process.env.BASE44_SVC_KEY = 'b44k_workspace'; const calls: { url: string; init?: RequestInit }[] = []; globalThis.fetch = async (url, init) => { calls.push({ url: String(url), init }); From d64e57060f676726acdc39da8dfd3f64b348f3ac Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 4 Oct 2026 18:19:48 +0300 Subject: [PATCH 09/28] Tiny Sunny: follow the docs' build turn, add Publish - client.ts is ordered by the six steps of "The build turn", one commented section per step with a link to the docs - Publish button on the ready card: deploy, then read back the published URL and link to it (step 6), with a browser test - remove the unused REST conversation read (getConversation and the backward pager); the chat arrives over the live-updates socket since #50 - README opens with a table from each docs page to the file that implements it Co-Authored-By: Claude Opus 5.5 --- examples/white-label-minimal/README.md | 21 ++++++- examples/white-label-minimal/app/globals.css | 6 +- .../components/Builder.tsx | 26 +++++++- .../white-label-minimal/lib/base44/client.ts | 58 ++++++------------ .../lib/chat/builder-api.ts | 4 +- .../lib/chat/conversation.ts | 34 ----------- .../lib/server/api-handler.ts | 1 - examples/white-label-minimal/lib/types.ts | 1 - examples/white-label-minimal/package.json | 2 +- .../white-label-minimal/tests/browser.spec.ts | 22 +++++++ .../tests/conversation.test.ts | 60 ------------------- .../white-label-minimal/tests/server.test.ts | 11 ++-- 12 files changed, 95 insertions(+), 151 deletions(-) delete mode 100644 examples/white-label-minimal/lib/chat/conversation.ts delete mode 100644 examples/white-label-minimal/tests/conversation.test.ts diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md index 4553717..c8d8256 100644 --- a/examples/white-label-minimal/README.md +++ b/examples/white-label-minimal/README.md @@ -9,6 +9,23 @@ personal access token. Base44 cannot tell your builders apart, so Tiny keeps the apart itself: it records which builder owns each app and checks that on every call. The token never reaches the browser. +## From the docs to the code + +If you have read the [white label docs](https://docs.base44.com/developers/white-label/overview), +each part of them lives in one place here: + +| Docs | Code | +| --- | --- | +| [Tenancy and credentials](https://docs.base44.com/developers/white-label/tenancy-and-credentials) | [lib/base44/config.ts](lib/base44/config.ts), [lib/base44/http.ts](lib/base44/http.ts) | +| Keeping builders apart | [lib/server/api-handler.ts](lib/server/api-handler.ts), [lib/storage/app-repository.ts](lib/storage/app-repository.ts) | +| [The build turn](https://docs.base44.com/developers/white-label/the-build-turn), steps 1–6 | [lib/base44/client.ts](lib/base44/client.ts), one commented section per step | +| Watch it build (live, instead of polling) | [lib/base44/socket-session.ts](lib/base44/socket-session.ts), [lib/chat/live-updates.ts](lib/chat/live-updates.ts) | +| Answer the agent's questions | [components/Question.tsx](components/Question.tsx), one UI per `waiting_on.kind` | +| Publish the app | `publish()` in [components/Builder.tsx](components/Builder.tsx) | +| [Custom instructions](https://docs.base44.com/developers/white-label/custom-instructions) | [lib/base44/custom-instructions.ts](lib/base44/custom-instructions.ts) | + +Everything else is Tiny's own product: sign-in, the app list, and the chat UI. + ## Run locally Use Node.js 24. From the repository root: @@ -105,8 +122,8 @@ Kept out to stay minimal; add them in production: - The session is not closed on leave; it expires after an hour. Workspace keys have a limit on open sessions, so close it (`DELETE /api/service/socket-sessions/{id}`) when the builder unmounts. -- The chat shows the last 50 messages. Load older ones over HTTP - (`getConversation`) if you need full history. +- The chat shows the last 50 messages. Load older ones with + [Read conversation messages](https://docs.base44.com/api-reference/read-conversation-messages) if you need full history. - Generated images stay as placeholders until the next reconnect (`image.resolved`). ## Deploy to Netlify diff --git a/examples/white-label-minimal/app/globals.css b/examples/white-label-minimal/app/globals.css index da3b6ca..5a81462 100644 --- a/examples/white-label-minimal/app/globals.css +++ b/examples/white-label-minimal/app/globals.css @@ -43,8 +43,10 @@ textarea, a { font: inherit; } -button { +button, +a.button { display: inline-flex; + text-decoration: none; align-items: center; justify-content: center; gap: 8px; @@ -405,7 +407,7 @@ iframe { .composer > small { width: 100%; } .delivery { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; margin: 16px 0 8px 34px; padding: 14px 16px; border: 1px solid var(--border); background: white; border-radius: 8px; } .delivery strong { font-size: 13px; font-weight: 600; } -.delivery button { align-self: stretch; font-size: 12px; padding: 9px 12px; border-radius: 4px; } +.delivery button, .delivery .button { align-self: stretch; font-size: 12px; padding: 9px 12px; border-radius: 4px; } .ready-badge { display: inline-flex; align-items: center; gap: 5px; margin: 0; padding: 3px 8px 3px 6px; border-radius: 999px; background: var(--ready-surface); color: var(--ready-ink); font-size: 11px; font-weight: 500; line-height: 1; } .build-progress { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin: 12px 0 12px 34px; } .builder > details, .builder > aside { max-height: 35%; overflow: auto; flex-shrink: 0; margin: 12px; } diff --git a/examples/white-label-minimal/components/Builder.tsx b/examples/white-label-minimal/components/Builder.tsx index 035ea2a..b45796e 100644 --- a/examples/white-label-minimal/components/Builder.tsx +++ b/examples/white-label-minimal/components/Builder.tsx @@ -1,6 +1,6 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { ArrowRight, Check, Loader2 } from "lucide-react"; +import { ArrowRight, Check, ExternalLink, Loader2 } from "lucide-react"; import * as api from "../lib/chat/builder-api"; import { hasCompletedBuild } from "../lib/chat/build-readiness"; import { mergeOptimisticMessages, type OptimisticMessage } from "../lib/chat/optimistic-messages"; @@ -25,6 +25,7 @@ export default function Builder({ const [creationUncertain, setCreationUncertain] = useState(false); const [resumeId, setResumeId] = useState(""); const [optimistic, setOptimistic] = useState([]); + const [publishedUrl, setPublishedUrl] = useState(null); const lock = useRef(false); const { app, messages, error: socketError, loading, refresh, resume } = useBuilderSocket(appId); const displayedMessages = useMemo(() => mergeOptimisticMessages(messages, optimistic), [messages, optimistic]); @@ -71,6 +72,22 @@ export default function Builder({ setBusy(""); } } + // Step 6 of the build turn: deploy, then read back the address to link to. + async function publish() { + if (lock.current || !appId) return; + lock.current = true; + setBusy("Publishing…"); + setError(""); + try { + await api.deployApp(appId); + setPublishedUrl((await api.getPublishedUrl(appId)).url); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not publish the app."); + } finally { + lock.current = false; + setBusy(""); + } + } async function answer(input: ToolInput) { if (lock.current) throw new Error("Another action is still running."); lock.current = true; @@ -160,6 +177,13 @@ export default function Builder({