From 7bc6da52741593d0180fd57cc804184388029232 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 30 Aug 2026 12:23:15 +0300 Subject: [PATCH 1/2] feat(platforms): act as your own users, through service principals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A platform building Base44 apps for its users has nobody to act as. Its users have no Base44 account and should not need one, but the apps they build have to belong to them rather than to one shared workspace identity — otherwise every app in the workspace is reachable by every user of the platform. This adds the identity half of the platforms surface: a workspace-scoped client, and a service principal per end user. ## A third client factory `createClient()` is scoped to one app and one user; `createClientFromRequest()` runs inside a Base44-hosted function. Neither covers a platform, which is scoped to a *workspace* and has many identities. So `createPlatformClient()` is a sibling of both rather than a new idea: const base44 = createPlatformClient({ mintKey, provisionKey }); await base44.platforms.provisionPrincipal({ externalId: "user_42", displayName: "Dana", }); const asDana = base44.asPrincipal("user_42"); const app = await asDana.forApp(appId); await app.entities.Todo.list(); // as Dana, not as the workspace `asPrincipal` mirrors `asServiceRole` — the same SDK, different permissions — but parameterised, because a platform has many identities rather than one privileged one. `forApp` is what makes that real today: it hands back an ordinary `Base44Client`, so every existing module works unchanged. ## Two keys, and they stay apart `mintKey` (`user_tokens:mint`) is the hot-path credential; `provisionKey` (`service_users:provision`) creates and removes principals. They get separate Axios clients and the provision one is reachable only from `platforms.*`, so no request path can create a principal. That separation is what makes deprovisioning stick: with a provision-capable key on the hot path, a removed user is re-provisioned by the next request that mentions them. A third client carries no credential at all. `/oauth/token` and `/oauth/revoke` authenticate the refresh token rather than the caller, so presenting a workspace key there would be sending a long-lived secret somewhere that neither wants nor checks it. ## Caching is the feature, not an optimization Minting is rate-limited **per workspace**. A platform that mints per request spends one shared budget on behalf of every user at once and starts failing under exactly the load it was built for. So a vended token is held for its hour, renewed through the refresh grant when it lapses, and concurrent callers for the same principal share one in-flight mint — without that last part, N requests arriving for a user whose token just expired each fire their own. The refresh skew is clamped to half the token's lifetime. A skew that exceeded it would mark every token stale on arrival and turn the cache into a mint-per-request loop against that same shared budget. `forApp` re-applies a token only when it actually rotated. `setToken` is an identity change — it drops the in-flight `me()` other callers are awaiting and resets the analytics session — so doing it per request would undo work the client does on the caller's behalf. ## Notes Mint never auto-provisions; an unknown principal is a 404. That is load bearing rather than an inconvenience, and it is why `provisionPrincipal` is idempotent and meant to be called on every request. 23 tests covering the key separation, path escaping, the cache, single-flight, renewal, the mint fallback when a refresh is rejected, and revocation. --- src/index.ts | 18 ++ src/modules/platforms.ts | 70 ++++++ src/modules/platforms.types.ts | 144 ++++++++++++ src/platform-client.ts | 160 +++++++++++++ src/platform-client.types.ts | 150 ++++++++++++ src/utils/principal-tokens.ts | 176 ++++++++++++++ tests/unit/platforms.test.ts | 411 +++++++++++++++++++++++++++++++++ 7 files changed, 1129 insertions(+) create mode 100644 src/modules/platforms.ts create mode 100644 src/modules/platforms.types.ts create mode 100644 src/platform-client.ts create mode 100644 src/platform-client.types.ts create mode 100644 src/utils/principal-tokens.ts create mode 100644 tests/unit/platforms.test.ts diff --git a/src/index.ts b/src/index.ts index 6886671..6eb84fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,12 @@ import { type CreateClientConfig, type CreateClientOptions, } from "./client.js"; +import { + createPlatformClient, + type CreatePlatformClientConfig, + type PlatformClient, + type PrincipalClient, +} from "./platform-client.js"; import { Base44Error, type Base44ErrorJSON } from "./utils/axios-client.js"; import { getAccessToken, @@ -16,6 +22,7 @@ import { export { createClient, createClientFromRequest, + createPlatformClient, Base44Error, getAccessToken, saveAccessToken, @@ -28,6 +35,9 @@ export type { CreateClientConfig, CreateClientOptions, Base44ErrorJSON, + CreatePlatformClientConfig, + PlatformClient, + PrincipalClient, }; export * from "./types.js"; @@ -120,6 +130,14 @@ export type { export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; +export type { + PlatformsModule, + PrincipalRole, + ProvisionPrincipalParams, + ServicePrincipal, + DeprovisionResult, +} from "./modules/platforms.types.js"; + export { Actor, type Conn } from "./actor.js"; export type { diff --git a/src/modules/platforms.ts b/src/modules/platforms.ts new file mode 100644 index 0000000..27468ab --- /dev/null +++ b/src/modules/platforms.ts @@ -0,0 +1,70 @@ +import type { AxiosInstance } from "axios"; +import type { + DeprovisionResult, + PlatformsModule, + ProvisionPrincipalParams, + ServicePrincipal, +} from "./platforms.types.js"; + +/** The wire shape of a principal, which is snake_case. */ +interface ServiceUserResponse { + service_external_id: string; + user_id: string; + email: string; + role: string; + created: boolean; +} + +interface ServiceUserDeprovisionResponse { + service_external_id: string; + removed: boolean; +} + +/** + * Creates the platforms module. + * + * Takes the *provision* client specifically, not the mint client. The two keys + * are separated so that no code path holding the hot-path key can create or + * destroy principals, and passing one client here is what keeps that true. + * + * @param axios - An Axios instance carrying the `service_users:provision` key. + * @returns The platforms module. + * @internal + */ +export function createPlatformsModule(axios: AxiosInstance): PlatformsModule { + return { + async provisionPrincipal( + params: ProvisionPrincipalParams + ): Promise { + const body: Record = { + service_external_id: params.externalId, + }; + // Omitted rather than sent as null: the server applies its own defaults + // for both, and `role` in particular is clamped to a ceiling. + if (params.displayName !== undefined) body.display_name = params.displayName; + if (params.role !== undefined) body.role = params.role; + + const response: ServiceUserResponse = await axios.post( + "/api/service/users", + body + ); + return { + externalId: response.service_external_id, + userId: response.user_id, + email: response.email, + role: response.role, + created: response.created, + }; + }, + + async deprovisionPrincipal(externalId: string): Promise { + const response: ServiceUserDeprovisionResponse = await axios.delete( + `/api/service/users/${encodeURIComponent(externalId)}` + ); + return { + externalId: response.service_external_id, + removed: response.removed, + }; + }, + }; +} diff --git a/src/modules/platforms.types.ts b/src/modules/platforms.types.ts new file mode 100644 index 0000000..0970f94 --- /dev/null +++ b/src/modules/platforms.types.ts @@ -0,0 +1,144 @@ +/** + * The role a service principal holds in the workspace. + * + * Capped on the server: a principal is never an owner or an admin, whatever a + * caller asks for. Passing anything outside this set is clamped down rather than + * rejected, so a typo cannot quietly grant more than intended. + */ +export type PrincipalRole = "editor" | "viewer"; + +/** + * Parameters for provisioning a service principal. + */ +export interface ProvisionPrincipalParams { + /** + * Your own identifier for the person this principal acts for — whatever your + * platform already calls them (`"user_42"`, a UUID, a tenant-scoped handle). + * + * It is opaque to Base44 and is the only thing that addresses this principal + * afterwards, so it must be stable for the life of the account. Never an + * email, and never an SSO identity. + */ + externalId: string; + /** + * A human-readable name, shown wherever the principal appears in Base44. + * + * Cosmetic only: nothing resolves a principal by name. + */ + displayName?: string; + /** + * The workspace role to create the principal with. + * + * @defaultValue `"editor"` + */ + role?: PrincipalRole; +} + +/** + * A provisioned service principal. + */ +export interface ServicePrincipal { + /** The identifier you provisioned it under. */ + externalId: string; + /** Base44's own user id for the principal. */ + userId: string; + /** + * The synthetic address Base44 generated for it. + * + * In a reserved, non-routable domain that can never receive mail — a + * principal is a robot identity, not a person with an inbox. + */ + email: string; + /** The role it actually holds, after the server's ceiling is applied. */ + role: string; + /** + * Whether this call created the principal. + * + * `false` means it already existed and was returned unchanged. Provisioning is + * idempotent, so this is informational — not an error to handle. + */ + created: boolean; +} + +/** + * The outcome of deprovisioning a principal. + */ +export interface DeprovisionResult { + /** The identifier that was addressed. */ + externalId: string; + /** + * Whether this call actually tore a principal down. + * + * `false` means nothing matched. Repeating a deprovision is safe and still + * succeeds, so a `false` on the *first* call is the interesting one: it means + * the id is wrong and some live principal is still holding vended tokens. + */ + removed: boolean; +} + +/** + * Platform-level operations, scoped to a workspace rather than to one app. + * + * Reached through {@link createPlatformClient | createPlatformClient()}, which is + * the only client that holds workspace keys. This module manages *who* your + * platform acts as; {@link PlatformClient.asPrincipal | asPrincipal()} is how you + * then act as one. + */ +export interface PlatformsModule { + /** + * Creates a service principal for one of your users, or returns the existing one. + * + * Idempotent per `(workspace, externalId)`, so the intended use is to call it + * on every request that needs a principal rather than tracking which of your + * users you have provisioned. The second call is a plain lookup. + * + * The principal is created with **no credential of its own** — nothing can log + * in as it. The only way to act as it is + * {@link PlatformClient.asPrincipal | asPrincipal()}, which needs your + * workspace's mint key. + * + * Requires the `service_users:provision` key. + * + * @param params - The principal to provision. + * @returns The principal, whether it was just created or already existed. + * + * @throws {Base44Error} 409 if the generated address collides with an existing + * account, 403 if service principals are not enabled for the workspace. + * + * @example + * ```typescript + * // Safe to call on every request — the second call is just a lookup. + * const principal = await base44.platforms.provisionPrincipal({ + * externalId: 'user_42', + * displayName: 'Dana', + * }); + * + * const asDana = base44.asPrincipal('user_42'); + * ``` + */ + provisionPrincipal(params: ProvisionPrincipalParams): Promise; + + /** + * Removes a service principal from the workspace. + * + * This is the offboarding lever, and it bites immediately: every vended token + * re-checks the workspace membership on each request, so tokens already handed + * out stop working now rather than when they expire. + * + * Requires the `service_users:provision` key — which is why that key should not + * be reachable from a request path. + * + * Deprovisioning does not delete the apps the principal built; it owns them, + * and they outlive it. + * + * @param externalId - The identifier the principal was provisioned under. + * @returns Whether a principal was actually torn down. + * + * @example + * ```typescript + * // A user closed their account. + * await base44.platforms.deprovisionPrincipal('user_42'); + * ``` + */ + deprovisionPrincipal(externalId: string): Promise; +} diff --git a/src/platform-client.ts b/src/platform-client.ts new file mode 100644 index 0000000..1074d9f --- /dev/null +++ b/src/platform-client.ts @@ -0,0 +1,160 @@ +import { createAxiosClient } from "./utils/axios-client.js"; +import { createPlatformsModule } from "./modules/platforms.js"; +import { createPrincipalTokenStore } from "./utils/principal-tokens.js"; +import { createClient } from "./client.js"; +import type { Base44Client } from "./client.types.js"; +import type { + CreatePlatformClientConfig, + PlatformClient, + PrincipalClient, +} from "./platform-client.types.js"; + +// Re-export platform client types +export type { CreatePlatformClientConfig, PlatformClient, PrincipalClient }; + +/** + * Creates a workspace-scoped Base44 client, for platforms that build apps on + * behalf of their own users. + * + * This is the third client factory, and it exists for the one case neither of + * the others covers. {@linkcode createClient | createClient()} is scoped to one + * app and one user; {@linkcode createClientFromRequest | createClientFromRequest()} + * runs inside a Base44-hosted function. A platform is scoped to a **workspace**: + * it has many users, none of whom have a Base44 account, and it needs each of + * them to own the apps they build. + * + * The model is a *service principal* — a synthetic member of your workspace, + * created with no credential of its own, that you act as. Your users never see + * Base44; you keep your own accounts and map each one to a principal. + * + * Two steps, and the first is idempotent: + * + * 1. {@linkcode PlatformsModule.provisionPrincipal | provisionPrincipal()} to + * make sure a principal exists for your user. + * 2. {@linkcode PlatformClient.asPrincipal | asPrincipal()} to act as them. + * + * **Server-side only.** This client holds workspace API keys, which authorize + * every app in the workspace. Never construct one in a browser, and never send + * either key to one — a browser gets a short-lived token vended *for it*, which + * is what {@linkcode PrincipalClient.getToken | getToken()} is for. + * + * @param config - Configuration object for the platform client. + * @returns A configured platform client. + * + * @example + * ```typescript + * import { createPlatformClient } from '@base44/sdk'; + * + * const base44 = createPlatformClient({ + * mintKey: process.env.BASE44_MINT_KEY, // user_tokens:mint + * provisionKey: process.env.BASE44_PROVISION_KEY, // service_users:provision + * }); + * + * // On a request from one of your users: + * await base44.platforms.provisionPrincipal({ + * externalId: 'user_42', + * displayName: 'Dana', + * }); + * + * const asDana = base44.asPrincipal('user_42'); + * const app = await asDana.forApp(appId); + * const todos = await app.entities.Todo.list(); + * ``` + */ +export function createPlatformClient( + config: CreatePlatformClientConfig +): PlatformClient { + const { + serverUrl = "https://base44.app", + mintKey, + provisionKey = mintKey, + options, + } = config; + + // Three clients, because they carry three different credentials — and the + // separation is the security property, not tidiness. Nothing reachable from a + // request should be able to create a principal, and nothing at all should + // present a workspace key to an endpoint that authenticates a refresh token. + const mintAxios = createAxiosClient({ + baseURL: serverUrl, + token: mintKey, + onError: options?.onError, + }); + + const provisionAxios = createAxiosClient({ + baseURL: serverUrl, + token: provisionKey, + onError: options?.onError, + }); + + const oauthAxios = createAxiosClient({ + baseURL: serverUrl, + onError: options?.onError, + }); + + const tokens = createPrincipalTokenStore({ mintAxios, oauthAxios }); + const platforms = createPlatformsModule(provisionAxios); + + // One principal handle per external id, and one app client per (principal, + // app). Both are addressed by stable ids and both wrap cached state, so + // rebuilding them per request would throw away the token cache that makes the + // mint rate limit survivable. + const principals = new Map(); + + const buildPrincipal = (externalId: string): PrincipalClient => { + // The token each client was last given, so a rotation can be detected. Held + // beside the client rather than read back off it because a client does not + // expose its credential. + const apps = new Map(); + + const getToken = async () => (await tokens.get(externalId)).accessToken; + + return { + externalId, + + getToken, + + async forApp(appId: string): Promise { + const token = await getToken(); + const held = apps.get(appId); + if (held) { + // Only on an actual rotation. `setToken` treats the call as an + // identity change — it discards the in-flight `me()` other callers are + // awaiting and resets the analytics session — so applying it on every + // request would undo work the client does on the caller's behalf. + if (held.token !== token) { + held.client.setToken(token); + held.token = token; + } + return held.client; + } + const client = createClient({ appId, serverUrl, token, options }); + apps.set(appId, { client, token }); + return client; + }, + + async revokeToken() { + await tokens.revoke(externalId); + for (const { client } of apps.values()) client.cleanup(); + apps.clear(); + }, + }; + }; + + return { + platforms, + + asPrincipal(externalId: string): PrincipalClient { + let principal = principals.get(externalId); + if (!principal) { + principal = buildPrincipal(externalId); + principals.set(externalId, principal); + } + return principal; + }, + + getConfig() { + return { serverUrl }; + }, + }; +} diff --git a/src/platform-client.types.ts b/src/platform-client.types.ts new file mode 100644 index 0000000..dc92f7e --- /dev/null +++ b/src/platform-client.types.ts @@ -0,0 +1,150 @@ +import type { Base44Client, CreateClientOptions } from "./client.types.js"; +import type { PlatformsModule } from "./modules/platforms.types.js"; + +/** + * Configuration for creating a Base44 platform client. + */ +export interface CreatePlatformClientConfig { + /** + * The workspace API key used to vend tokens, holding the `user_tokens:mint` scope. + * + * This is the hot-path key: it is presented on every request that needs to act + * as one of your users. A mint-only key can vend tokens for principals that + * already exist but cannot *create* one, which is what stops it from being an + * impersonate-anyone primitive if it leaks. + */ + mintKey: string; + /** + * The workspace API key used to create and remove principals, holding the + * `service_users:provision` scope. + * + * Keep it separate from `mintKey`, and keep it off any code path a request can + * reach. That separation is what makes deprovisioning stick: with a + * provision-capable key on the hot path, a removed user can be re-provisioned + * by the next request that mentions them, quietly undoing the offboarding. + * + * @defaultValue `mintKey`, for a single-key deployment. Workable, but weaker + * for the reason above. + */ + provisionKey?: string; + /** + * The Base44 server URL. + * + * @defaultValue `"https://base44.app"` + */ + serverUrl?: string; + /** + * Additional client options. + */ + options?: CreateClientOptions; +} + +/** + * A view of Base44 that acts as one of your users. + * + * Obtained from {@link PlatformClient.asPrincipal | asPrincipal()}. Holding one + * costs nothing — no token is vended until you ask for something. + */ +export interface PrincipalClient { + /** The identifier this principal was provisioned under. */ + readonly externalId: string; + + /** + * A Base44 client for one app, acting as this principal. + * + * This is the bridge to the rest of the SDK: the returned client is an ordinary + * {@link Base44Client}, so `entities`, `agents`, `functions` and the rest work + * exactly as documented — scoped to what this principal may see, which is + * usually far less than a service role. + * + * Cheap to call repeatedly. The token behind it is cached and renewed for you, + * and the same app gets the same client back, so a long-lived worker can hold + * one and a serverless handler can ask for one per request. + * + * @param appId - The app to act on. + * @returns A client scoped to that app, authenticated as this principal. + * + * @example + * ```typescript + * const asDana = base44.asPrincipal('user_42'); + * const app = await asDana.forApp(appId); + * + * // Every module, with Dana's permissions rather than the workspace's. + * const todos = await app.entities.Todo.list(); + * ``` + */ + forApp(appId: string): Promise; + + /** + * The raw access token for this principal, minting or renewing as needed. + * + * Most code should use {@link PrincipalClient.forApp | forApp()} instead. Reach + * for this when you need to authenticate a request the SDK does not make for + * you. + * + * Do not cache what this returns — it is already cached, and holding a copy is + * how a caller ends up using a token the store has since replaced. + * + * @returns A currently-valid access token. + * + * @throws {Base44Error} 404 if no principal matches, which is what an + * un-provisioned `externalId` looks like: minting never creates one. + */ + getToken(): Promise; + + /** + * Forgets this principal's cached token and revokes its refresh token. + * + * Use when a user signs out of *your* platform. It does not remove the + * principal — the apps it built belong to it, and signing out should not hand + * them to the workspace owner. Removing it is + * {@link PlatformsModule.deprovisionPrincipal | deprovisionPrincipal()}. + * + * Only the refresh token can be revoked; a live access token remains valid for + * the rest of its hour. Deprovisioning is the lever that cuts one off + * immediately. + */ + revokeToken(): Promise; +} + +/** + * A workspace-scoped Base44 client, for platforms that build apps on behalf of + * their own users. + * + * The third client factory, beside {@link createClient | createClient()} (one + * app, one user) and {@link createClientFromRequest | createClientFromRequest()} + * (inside a Base44 function). This one is scoped to a *workspace*: it manages the + * identities your users act as, and hands you a client per identity. + */ +export interface PlatformClient { + /** {@link PlatformsModule | Platforms module} for managing service principals. */ + platforms: PlatformsModule; + + /** + * Acts as one of your users. + * + * Mirrors `base44.asServiceRole` in shape — the same SDK, different + * permissions — but parameterised, because a platform has many identities + * rather than one privileged one. + * + * The principal must already exist; + * {@link PlatformsModule.provisionPrincipal | provisionPrincipal()} is + * idempotent and safe to call first on every request. + * + * @param externalId - The identifier the principal was provisioned under. + * @returns A view of Base44 that acts as that principal. + * + * @example + * ```typescript + * await base44.platforms.provisionPrincipal({ externalId: 'user_42' }); + * const asDana = base44.asPrincipal('user_42'); + * ``` + */ + asPrincipal(externalId: string): PrincipalClient; + + /** + * Gets the current client configuration. + * @internal + */ + getConfig(): { serverUrl: string }; +} diff --git a/src/utils/principal-tokens.ts b/src/utils/principal-tokens.ts new file mode 100644 index 0000000..a6b0bee --- /dev/null +++ b/src/utils/principal-tokens.ts @@ -0,0 +1,176 @@ +import type { AxiosInstance } from "axios"; + +/** + * The OAuth client id every service-principal token is issued under. + * + * Deliberately not one of Base44's MCP client prefixes — a token whose client id + * starts with one of those is rejected everywhere except `/mcp`. + */ +export const SERVICE_CLIENT_ID = "svc_delegate"; + +/** + * Re-mint this long before a token's stated expiry. + * + * Access tokens are vended with an explicit one-hour lifetime, so this is a few + * percent of the token's life — enough that a slow call cannot land after the + * token it was authorized with has expired. + */ +export const REFRESH_SKEW_MS = 5 * 60 * 1000; + +/** A vended access token and what is needed to renew it. */ +export interface VendedToken { + accessToken: string; + refreshToken?: string; + /** Epoch milliseconds at which the access token stops being accepted. */ + expiresAt: number; +} + +/** The wire shape of an OAuth 2.0 token response. */ +interface OAuth2TokenResponse { + access_token: string; + token_type?: string; + expires_in: number; + scope?: string; + refresh_token?: string | null; +} + +interface TokenStoreConfig { + /** Carries the `user_tokens:mint` key. Used for minting and nothing else. */ + mintAxios: AxiosInstance; + /** + * Carries no credential at all. + * + * `/oauth/token` and `/oauth/revoke` authenticate the *refresh token*, not the + * caller, so presenting a workspace key there would be sending a + * long-lived secret somewhere it is neither wanted nor checked. + */ + oauthAxios: AxiosInstance; + refreshSkewMs?: number; +} + +/** + * Caches one vended token per principal, and renews it before it lapses. + * + * Caching is not an optimization here. Minting is rate-limited **per workspace**, + * so a platform that mints on every request spends one shared budget on behalf of + * every user at once and starts failing under exactly the load it was built for. + * A vended token is good for an hour; this holds it for that hour. + * + * @internal + */ +export function createPrincipalTokenStore({ + mintAxios, + oauthAxios, + refreshSkewMs = REFRESH_SKEW_MS, +}: TokenStoreConfig) { + const cache = new Map(); + // One entry per principal currently being fetched. Without this, N concurrent + // requests for a user whose token just lapsed each fire their own mint — a + // self-inflicted burst against the very limit the cache exists to respect. + const inFlight = new Map>(); + + const toVended = (response: OAuth2TokenResponse): VendedToken => { + const lifetimeMs = Math.max(Number(response.expires_in) || 0, 0) * 1000; + // Never let the skew consume the whole lifetime: a token considered stale on + // arrival would mint again on the next call, and again, turning the cache + // into a mint-per-request loop against a shared budget. + const skew = Math.min(refreshSkewMs, lifetimeMs / 2); + return { + accessToken: response.access_token, + refreshToken: response.refresh_token ?? undefined, + expiresAt: Date.now() + lifetimeMs - skew, + }; + }; + + const mint = async (externalId: string): Promise => { + const response: OAuth2TokenResponse = await mintAxios.post( + "/api/service/user-tokens", + { service_external_id: externalId } + ); + return toVended(response); + }; + + const renew = async (refreshToken: string): Promise => { + const response: OAuth2TokenResponse = await oauthAxios.post( + "/oauth/token", + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: SERVICE_CLIENT_ID, + }), + { headers: { "Content-Type": "application/x-www-form-urlencoded" } } + ); + return toVended(response); + }; + + const fetchToken = async (externalId: string): Promise => { + const held = cache.get(externalId); + if (held?.refreshToken) { + try { + return await renew(held.refreshToken); + } catch { + // A refresh token can be revoked, expired, or invalidated by a role + // change. Minting is the recovery, and it re-checks everything the + // refresh would have — so falling through is not a way around a + // revocation, it just costs one extra round trip. + } + } + return mint(externalId); + }; + + return { + /** + * A currently-valid token for the principal, minting or renewing only when + * the held one is spent. + */ + async get(externalId: string): Promise { + const held = cache.get(externalId); + if (held && held.expiresAt > Date.now()) return held; + + const pending = inFlight.get(externalId); + if (pending) return pending; + + const request = fetchToken(externalId) + .then((token) => { + cache.set(externalId, token); + return token; + }) + .finally(() => { + inFlight.delete(externalId); + }); + + inFlight.set(externalId, request); + return request; + }, + + /** + * Drops the held token and asks Base44 to revoke its refresh token. + * + * Only the refresh half is revocable — the access token is self-contained and + * stays valid until it expires. To cut a principal off *now*, deprovision it: + * the workspace membership is re-checked on every request. + */ + async revoke(externalId: string): Promise { + const held = cache.get(externalId); + cache.delete(externalId); + if (!held?.refreshToken) return; + // Best effort: a failed revoke must not leave a caller unable to forget a + // principal locally. + try { + await oauthAxios.post( + "/oauth/revoke", + new URLSearchParams({ + token: held.refreshToken, + client_id: SERVICE_CLIENT_ID, + }), + { headers: { "Content-Type": "application/x-www-form-urlencoded" } } + ); + } catch { + /* the local record is already gone, which is the part that matters */ + } + }, + }; +} + +/** @internal */ +export type PrincipalTokenStore = ReturnType; diff --git a/tests/unit/platforms.test.ts b/tests/unit/platforms.test.ts new file mode 100644 index 0000000..cb173e0 --- /dev/null +++ b/tests/unit/platforms.test.ts @@ -0,0 +1,411 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import nock from "nock"; +import { createPlatformClient } from "../../src/index.ts"; + +describe("Platform client — identity", () => { + const serverUrl = "https://base44.app"; + const mintKey = "b44k_mint_key"; + const provisionKey = "b44k_provision_key"; + const externalId = "user_42"; + let base44: ReturnType; + let scope: nock.Scope; + + const principalBody = { + service_external_id: externalId, + user_id: "u_1", + email: "sunny-abc@org.svc.base44.invalid", + role: "editor", + created: true, + }; + + const tokenBody = (accessToken: string, refreshToken?: string | null) => ({ + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + scope: "apps:read apps:write offline", + refresh_token: refreshToken === undefined ? "refresh-1" : refreshToken, + }); + + beforeEach(() => { + base44 = createPlatformClient({ serverUrl, mintKey, provisionKey }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe("provisioning", () => { + test("provisionPrincipal posts the snake_case body and returns camelCase", async () => { + scope + .post("/api/service/users", { + service_external_id: externalId, + display_name: "Dana", + role: "editor", + }) + .reply(200, principalBody); + + const principal = await base44.platforms.provisionPrincipal({ + externalId, + displayName: "Dana", + role: "editor", + }); + + expect(principal).toEqual({ + externalId, + userId: "u_1", + email: "sunny-abc@org.svc.base44.invalid", + role: "editor", + created: true, + }); + expect(scope.isDone()).toBe(true); + }); + + test("omits display name and role rather than sending nulls, so server defaults apply", async () => { + // `role` in particular is clamped server-side; sending an explicit null + // would be asking for a role rather than accepting the default. + scope + .post("/api/service/users", (body) => { + expect(body).toEqual({ service_external_id: externalId }); + return true; + }) + .reply(200, principalBody); + + await base44.platforms.provisionPrincipal({ externalId }); + expect(scope.isDone()).toBe(true); + }); + + test("provisioning presents the PROVISION key, never the mint key", async () => { + scope + .post("/api/service/users") + .matchHeader("Authorization", `Bearer ${provisionKey}`) + .reply(200, principalBody); + + await base44.platforms.provisionPrincipal({ externalId }); + expect(scope.isDone()).toBe(true); + }); + + test("deprovisionPrincipal escapes the id into the path", async () => { + // A principal id is caller-supplied. Unescaped, one containing a slash or + // a query character addresses a different route entirely. + scope + .delete(`/api/service/users/${encodeURIComponent("user 42/../admin")}`) + .reply(200, { + service_external_id: "user 42/../admin", + removed: true, + }); + + const result = await base44.platforms.deprovisionPrincipal( + "user 42/../admin" + ); + + expect(result).toEqual({ externalId: "user 42/../admin", removed: true }); + expect(scope.isDone()).toBe(true); + }); + + test("a repeat deprovision reports removed: false rather than failing", async () => { + scope + .delete(`/api/service/users/${externalId}`) + .reply(200, { service_external_id: externalId, removed: false }); + + await expect( + base44.platforms.deprovisionPrincipal(externalId) + ).resolves.toEqual({ externalId, removed: false }); + }); + }); + + describe("acting as a principal", () => { + test("getToken mints with the MINT key and returns the access token", async () => { + scope + .post("/api/service/user-tokens", { service_external_id: externalId }) + .matchHeader("Authorization", `Bearer ${mintKey}`) + .reply(200, tokenBody("access-1")); + + const token = await base44.asPrincipal(externalId).getToken(); + + expect(token).toBe("access-1"); + expect(scope.isDone()).toBe(true); + }); + + test("a vended token is reused rather than re-minted", async () => { + // The whole point of the cache: minting is rate-limited per WORKSPACE, so + // a platform that mints per request spends one shared budget for all of + // its users at once. + scope + .post("/api/service/user-tokens") + .once() + .reply(200, tokenBody("access-1")); + + const asDana = base44.asPrincipal(externalId); + expect(await asDana.getToken()).toBe("access-1"); + expect(await asDana.getToken()).toBe("access-1"); + expect(await asDana.getToken()).toBe("access-1"); + + expect(scope.isDone()).toBe(true); + expect(nock.pendingMocks()).toEqual([]); + }); + + test("concurrent first calls share one mint", async () => { + // Without single-flight, N requests arriving for a user whose token just + // lapsed each fire their own mint — a burst against the exact limit the + // cache exists to respect. + scope + .post("/api/service/user-tokens") + .once() + .delay(20) + .reply(200, tokenBody("access-1")); + + const asDana = base44.asPrincipal(externalId); + const tokens = await Promise.all([ + asDana.getToken(), + asDana.getToken(), + asDana.getToken(), + ]); + + expect(tokens).toEqual(["access-1", "access-1", "access-1"]); + expect(nock.pendingMocks()).toEqual([]); + }); + + test("asPrincipal returns the same handle, so the cache is not thrown away", async () => { + expect(base44.asPrincipal(externalId)).toBe( + base44.asPrincipal(externalId) + ); + expect(base44.asPrincipal("other")).not.toBe( + base44.asPrincipal(externalId) + ); + }); + + test("distinct principals get distinct tokens", async () => { + scope + .post("/api/service/user-tokens", { service_external_id: "user_1" }) + .reply(200, tokenBody("access-1")); + scope + .post("/api/service/user-tokens", { service_external_id: "user_2" }) + .reply(200, tokenBody("access-2")); + + expect(await base44.asPrincipal("user_1").getToken()).toBe("access-1"); + expect(await base44.asPrincipal("user_2").getToken()).toBe("access-2"); + expect(scope.isDone()).toBe(true); + }); + + test("an expired token is renewed with the refresh token, not re-minted", async () => { + // expires_in below the skew means the token is stale the moment it lands, + // which is how this exercises renewal without waiting an hour. + scope + .post("/api/service/user-tokens") + .reply(200, { ...tokenBody("access-1"), expires_in: 0 }); + + const asDana = base44.asPrincipal(externalId); + expect(await asDana.getToken()).toBe("access-1"); + + scope + .post("/oauth/token", (body) => { + const params = new URLSearchParams(body as string); + expect(params.get("grant_type")).toBe("refresh_token"); + expect(params.get("refresh_token")).toBe("refresh-1"); + expect(params.get("client_id")).toBe("svc_delegate"); + return true; + }) + .reply(200, tokenBody("access-2", "refresh-2")); + + expect(await asDana.getToken()).toBe("access-2"); + expect(scope.isDone()).toBe(true); + }); + + test("the refresh call carries no workspace key", async () => { + // /oauth/token authenticates the refresh token, not the caller. Presenting + // a workspace key there sends a long-lived secret somewhere that neither + // wants nor checks it. + scope + .post("/api/service/user-tokens") + .reply(200, { ...tokenBody("access-1"), expires_in: 0 }); + + const asDana = base44.asPrincipal(externalId); + await asDana.getToken(); + + scope + .post("/oauth/token") + .matchHeader("Authorization", (value) => value === undefined) + .reply(200, tokenBody("access-2")); + + expect(await asDana.getToken()).toBe("access-2"); + expect(scope.isDone()).toBe(true); + }); + + test("a rejected refresh falls back to minting", async () => { + scope + .post("/api/service/user-tokens") + .reply(200, { ...tokenBody("access-1"), expires_in: 0 }); + + const asDana = base44.asPrincipal(externalId); + await asDana.getToken(); + + scope.post("/oauth/token").reply(400, { detail: "invalid_grant" }); + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-3")); + + expect(await asDana.getToken()).toBe("access-3"); + expect(scope.isDone()).toBe(true); + }); + + test("a 404 from mint surfaces — it never auto-provisions", async () => { + // This is what an un-provisioned externalId looks like, and it is load + // bearing: if mint created principals, deprovisioning would not stick. + scope.post("/api/service/user-tokens").reply(404, { + detail: "No service principal matches the provided service_external_id", + code: "NOT_FOUND", + }); + + await expect( + base44.asPrincipal("never-provisioned").getToken() + ).rejects.toMatchObject({ name: "Base44Error", status: 404 }); + }); + + test("a failed mint is not cached, so the next call retries", async () => { + scope.post("/api/service/user-tokens").reply(500, { detail: "boom" }); + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-1")); + + const asDana = base44.asPrincipal(externalId); + await expect(asDana.getToken()).rejects.toMatchObject({ status: 500 }); + expect(await asDana.getToken()).toBe("access-1"); + expect(scope.isDone()).toBe(true); + }); + }); + + describe("forApp", () => { + test("returns an app client authenticated as the principal", async () => { + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-1")); + + const app = await base44.asPrincipal(externalId).forApp("app_1"); + + scope + .get("/api/apps/app_1/entities/Todo") + .matchHeader("Authorization", "Bearer access-1") + .matchHeader("X-App-Id", "app_1") + .reply(200, [{ id: "t1" }]); + + await expect(app.entities.Todo.list()).resolves.toEqual([{ id: "t1" }]); + expect(scope.isDone()).toBe(true); + }); + + test("the same app returns the same client, carrying a renewed token", async () => { + scope + .post("/api/service/user-tokens") + .reply(200, { ...tokenBody("access-1"), expires_in: 0 }); + + const asDana = base44.asPrincipal(externalId); + const first = await asDana.forApp("app_1"); + + scope.post("/oauth/token").reply(200, tokenBody("access-2", "refresh-2")); + const second = await asDana.forApp("app_1"); + + expect(second).toBe(first); + + scope + .get("/api/apps/app_1/entities/Todo") + .matchHeader("Authorization", "Bearer access-2") + .reply(200, []); + + await second.entities.Todo.list(); + expect(scope.isDone()).toBe(true); + }); + + test("an unchanged token is not re-applied to the client", async () => { + // `setToken` is an identity change: it drops the in-flight `me()` other + // callers are awaiting and resets the analytics session. `forApp` is + // documented as cheap to call per request, so it must stay a no-op while + // the token holds. + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-1")); + + const asDana = base44.asPrincipal(externalId); + const client = await asDana.forApp("app_1"); + + let applied = 0; + const realSetToken = client.setToken.bind(client); + client.setToken = (token: string) => { + applied++; + realSetToken(token); + }; + + await asDana.forApp("app_1"); + await asDana.forApp("app_1"); + + expect(applied).toBe(0); + }); + + test("different apps get different clients", async () => { + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-1")); + + const asDana = base44.asPrincipal(externalId); + const first = await asDana.forApp("app_1"); + const second = await asDana.forApp("app_2"); + + // Compared through `Object.is` and the config rather than by passing the + // clients to a matcher: a failing matcher serializes them, and reading + // every property means reading `asServiceRole`, which throws by design. + expect(Object.is(first, second)).toBe(false); + expect(first.getConfig().appId).toBe("app_1"); + expect(second.getConfig().appId).toBe("app_2"); + }); + }); + + describe("revoking", () => { + test("revokeToken posts the refresh token to /oauth/revoke and forgets it", async () => { + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-1")); + + const asDana = base44.asPrincipal(externalId); + await asDana.getToken(); + + scope + .post("/oauth/revoke", (body) => { + const params = new URLSearchParams(body as string); + expect(params.get("token")).toBe("refresh-1"); + expect(params.get("client_id")).toBe("svc_delegate"); + return true; + }) + .reply(200, {}); + + await asDana.revokeToken(); + + // Forgotten, so the next call mints again rather than reusing. + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-9")); + expect(await asDana.getToken()).toBe("access-9"); + expect(scope.isDone()).toBe(true); + }); + + test("a failed revoke still forgets the token locally", async () => { + // Otherwise a caller cannot forget a principal when Base44 is unreachable, + // which is exactly when they most want to. + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-1")); + + const asDana = base44.asPrincipal(externalId); + await asDana.getToken(); + + scope.post("/oauth/revoke").reply(500, { detail: "boom" }); + await expect(asDana.revokeToken()).resolves.toBeUndefined(); + + scope.post("/api/service/user-tokens").reply(200, tokenBody("access-9")); + expect(await asDana.getToken()).toBe("access-9"); + }); + + test("revoking without a vended token does not call out", async () => { + await expect( + base44.asPrincipal("never-used").revokeToken() + ).resolves.toBeUndefined(); + expect(nock.isDone()).toBe(true); + }); + }); + + describe("configuration", () => { + test("provisionKey defaults to mintKey for a single-key deployment", async () => { + const single = createPlatformClient({ serverUrl, mintKey }); + + scope + .post("/api/service/users") + .matchHeader("Authorization", `Bearer ${mintKey}`) + .reply(200, principalBody); + + await single.platforms.provisionPrincipal({ externalId }); + expect(scope.isDone()).toBe(true); + }); + }); +}); From 890a2b5ddce19bddfc9de2a2020a414dc37440ec Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 30 Aug 2026 13:15:14 +0300 Subject: [PATCH 2/2] feat(platforms): drive a build, and watch it happen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity settled who a platform acts as. This is what it acts *on*: the partner-facing builder, as one session per app. const builder = base44.asPrincipal("user_42").builder(appId); const { turnId } = await builder.sendMessage("add a footer"); const outcome = await builder.waitForTurn(turnId); if (outcome.waitingOn) { await builder.respond({ ...outcome.waitingOn, approved: true }); } ## Every write returns before the turn does A build takes minutes, and the existing chat endpoint answers only when the whole turn is done — which a serverless partner cannot hold and a partner UI cannot show progress from. So `sendMessage`, `respond` and `cancel` return as soon as the turn is accepted, and the turn arrives over a stream. That leaves "how do I know it finished?", and without webhooks there is one honest answer for a long-lived worker: `waitForTurn(turnId)`. It watches the stream rather than polling, and asks after the turn once on the way in, so a turn that ended between the write and the wait still resolves. It settles on `blocked` as well as `idle` and `error` — a build that ran out of credits has stopped, and waiting for it to finish would wait forever. ## The stream The SDK's first SSE transport, and the first place with no precedent to copy: both realtime modules here are WebSocket. Read with `fetch` rather than `EventSource`. `EventSource` cannot set headers, which is why the API also takes a single-use ticket in the query string — a workaround for a browser limitation, not a shape to build on. Reading with `fetch` keeps the credential in an `Authorization` header, so the ticket exchange never has to happen and no credential lands in a referrer or a proxy log. It also works in Node, Deno and Bun, where `EventSource` does not. Three accessors over one subscription, so the seam is written once here instead of once per consumer: - `subscribe(cb)` returns an unsubscribe, matching every other realtime call in the package. It reconnects and resumes from the last `seq`, so a deploy on either side costs no state. - `stream()` is the same events as an async iterable, for code shaped as a loop. - `streamText()` is the assistant's prose as text to *append*. That last one exists because `message.updated` is a snapshot, not a delta: the builder flushes the whole in-progress message on every tick. Yielding it into a chat UI that appends would repeat the message on every tick, so `streamText` yields what each snapshot added. A snapshot that is not an extension means the message was rewritten — a retry, an edit — and a chat surface has already made what it printed immutable, so that becomes a new paragraph rather than a patch. Unrecognised event types are dropped at the boundary. That is the contract's own rule for clients, applied once here, and it is what lets `BuilderEvent` be a closed union that narrows on `type` instead of an open one whose `data` is `unknown` in every branch. ## A grant is read-only, and the types say so The asymmetry is the design, and it is what an integrator gets wrong first: reads go browser to Base44 directly, keeping an open stream off a serverless function path; writes go browser to partner server to Base44. So there are two shapes rather than one with a comment. `asPrincipal(id).builder(appId)` holds a credential that can start turns and returns a `BuilderSession`. `createBuilderSession({ appId, getToken })` is the browser's entry point, holds a grant, and returns a `BuilderSessionReader` — with no `sendMessage` on it to reach for. A leaked browser credential cannot spend the workspace's credits because there is nothing on it that spends. `getToken` is a getter, not a string, for both: a grant expires inside a single build and a principal's token lives an hour, so it is re-read on every request and every reconnect. That is the habit the actors module already has internally. ## Notes Bound to the app once. The doc sketched these as `platforms.sendBuildMessage(appId, …)`; every route in the family is app-scoped, and a build flow names the same app six times, so the id is bound once by `builder(appId)` and `platforms` stays what it was — workspace-level identity. `cancel()` returns nothing rather than the endpoint's body, which is the builder's internal status vocabulary that the rest of this surface deliberately renames. The settled state arrives on the stream, in the public one. Idempotency keys are never invented. A turn costs credits and a key the SDK generated would differ on the retry, protecting nothing — so supplying one is what makes a retry safe, and without one the server names the turn. 26 tests covering the projections, the write bodies, credential separation, frame parsing, keepalives, unknown-type drops, resume-after-drop, a rejected credential not being retried, unsubscribe, and the snapshot-to-append rules. --- src/builder-session.ts | 103 ++++++ src/index.ts | 30 ++ src/modules/builder.ts | 611 +++++++++++++++++++++++++++++++++ src/modules/builder.types.ts | 618 +++++++++++++++++++++++++++++++++ src/platform-client.ts | 37 +- src/platform-client.types.ts | 45 ++- src/utils/sse.ts | 91 +++++ tests/unit/builder.test.ts | 646 +++++++++++++++++++++++++++++++++++ 8 files changed, 2176 insertions(+), 5 deletions(-) create mode 100644 src/builder-session.ts create mode 100644 src/modules/builder.ts create mode 100644 src/modules/builder.types.ts create mode 100644 src/utils/sse.ts create mode 100644 tests/unit/builder.test.ts diff --git a/src/builder-session.ts b/src/builder-session.ts new file mode 100644 index 0000000..8c3d13b --- /dev/null +++ b/src/builder-session.ts @@ -0,0 +1,103 @@ +import { createAxiosClient } from "./utils/axios-client.js"; +import { createBuilderSessionReader } from "./modules/builder.js"; +import type { BuilderSessionReader } from "./modules/builder.types.js"; +import type { CreateClientOptions } from "./client.types.js"; + +/** + * Configuration for reading a builder session with a grant. + */ +export interface CreateBuilderSessionConfig { + /** The app being built. A builder session *is* an app — there is nothing separate to open. */ + appId: string; + /** + * The Base44 server URL. + * + * @defaultValue `"https://base44.app"` + */ + serverUrl?: string; + /** + * A grant token. + * + * Use {@link CreateBuilderSessionConfig.getToken | getToken} instead for anything + * that outlives one grant, which most builds do. + */ + token?: string; + /** + * Called for a grant before every request and every stream (re)connect. + * + * The shape to prefer. A grant is short-lived on purpose, so a build routinely + * outlasts the one it started with; re-reading the credential rather than + * capturing it is what makes a refresh invisible to the caller, and it is the + * habit the SDK's actors module already has internally. + */ + getToken?: () => string | Promise | undefined; + /** Additional client options. */ + options?: CreateClientOptions; +} + +/** + * Reads one builder session with a grant. + * + * The browser's entry point. A grant is read-only, scoped to one session and + * short-lived, so this returns the read half of a builder session and nothing else + * — the writes live on the server, where the credential that can start a turn + * belongs. + * + * That asymmetry is the design rather than a limitation, and it is the thing an + * integrator gets wrong first: reads go browser to Base44 directly, keeping an + * open stream off your serverless function path, while writes go browser to your + * server to Base44. Because a grant cannot send, no configuration lets a leaked + * browser credential spend your workspace's credits. + * + * Mint the grant on your server with + * {@link BuilderSession.createGrant | createGrant()}. + * + * @param config - The app, and how to get a grant for it. + * @returns The read-only session. + * + * @example + * ```typescript + * import { createBuilderSession } from '@base44/sdk'; + * + * const builder = createBuilderSession({ + * appId, + * // Re-read on every reconnect, so a build outliving its grant just works. + * getToken: () => + * fetch('/api/base44/grant', { method: 'POST', body: JSON.stringify({ appId }) }) + * .then((response) => response.json()) + * .then((grant) => grant.token), + * }); + * + * const unsubscribe = builder.subscribe((event) => { + * switch (event.type) { + * case 'message.updated': upsertMessage(event.data); break; // by messageId + * case 'state.changed': setStatus(event.data); break; + * case 'turn.finished': markDone(event.turnId); break; + * } + * }); + * + * // Sending goes to YOUR server, which holds the write credential. + * await fetch('/api/base44/message', { method: 'POST', body: … }); + * ``` + */ +export function createBuilderSession( + config: CreateBuilderSessionConfig +): BuilderSessionReader { + const { + appId, + serverUrl = "https://base44.app", + token, + getToken, + options, + } = config; + + return createBuilderSessionReader({ + axios: createAxiosClient({ + baseURL: `${serverUrl}/api`, + onError: options?.onError, + }), + appId, + serverUrl, + getToken: getToken ?? (() => token), + }); +} diff --git a/src/index.ts b/src/index.ts index 6eb84fd..b2061b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,10 @@ import { type PlatformClient, type PrincipalClient, } from "./platform-client.js"; +import { + createBuilderSession, + type CreateBuilderSessionConfig, +} from "./builder-session.js"; import { Base44Error, type Base44ErrorJSON } from "./utils/axios-client.js"; import { getAccessToken, @@ -23,6 +27,7 @@ export { createClient, createClientFromRequest, createPlatformClient, + createBuilderSession, Base44Error, getAccessToken, saveAccessToken, @@ -38,6 +43,7 @@ export type { CreatePlatformClientConfig, PlatformClient, PrincipalClient, + CreateBuilderSessionConfig, }; export * from "./types.js"; @@ -138,6 +144,30 @@ export type { DeprovisionResult, } from "./modules/platforms.types.js"; +export type { + BuilderEvent, + BuilderEventType, + BuilderGrant, + BuilderMessage, + BuilderMessagePage, + BuilderResponse, + BuilderSession, + BuilderSessionReader, + BuilderState, + BuilderStatus, + BuilderToolCall, + BuilderTurn, + BuilderTurnRef, + BuilderWaitingKind, + BuilderWaitingOn, + CreateBuilderGrantOptions, + ListBuilderMessagesOptions, + RespondToBuilderOptions, + SendBuilderMessageOptions, + SubscribeToBuilderOptions, + WaitForTurnOptions, +} from "./modules/builder.types.js"; + export { Actor, type Conn } from "./actor.js"; export type { diff --git a/src/modules/builder.ts b/src/modules/builder.ts new file mode 100644 index 0000000..64e4e0f --- /dev/null +++ b/src/modules/builder.ts @@ -0,0 +1,611 @@ +import type { AxiosInstance } from "axios"; +import { Base44Error } from "../utils/axios-client.js"; +import { readSseFrames } from "../utils/sse.js"; +import type { + BuilderEvent, + BuilderEventType, + BuilderGrant, + BuilderMessage, + BuilderMessagePage, + BuilderResponse, + BuilderSession, + BuilderSessionReader, + BuilderState, + BuilderStatus, + BuilderToolCall, + BuilderTurn, + BuilderTurnRef, + BuilderWaitingKind, + CreateBuilderGrantOptions, + ListBuilderMessagesOptions, + RespondToBuilderOptions, + SendBuilderMessageOptions, + SubscribeToBuilderOptions, + WaitForTurnOptions, +} from "./builder.types.js"; + +/** What the build module needs to talk to one app's session. @internal */ +export interface BuilderSessionDeps { + /** An Axios client based at `${serverUrl}/api`. */ + axios: AxiosInstance; + /** The app whose builder session this is. */ + appId: string; + /** Used to build the absolute stream URL, which `fetch` needs. */ + serverUrl: string; + /** + * Re-read before every request and every stream (re)connect. + * + * A getter rather than a string because both credentials that reach here + * rotate: a grant expires inside a single build, and a principal's access + * token lives an hour. Capturing either would work right up until the first + * build long enough to matter. + * + * Omitted when the Axios client already carries a static credential. + */ + getToken?: () => string | Promise | undefined; +} + +const KNOWN_EVENT_TYPES: ReadonlySet = new Set([ + "state.changed", + "turn.started", + "turn.finished", + "message.updated", + "error", + "conversation.reset", + "files.changed", +]); + +// A turn in one of these has stopped. `blocked` counts: it is out of credits, +// which resumes on a top-up rather than on anything a caller can await. +const SETTLED_STATUSES: ReadonlySet = new Set(["idle", "error", "blocked"]); + +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; + +// Retry only what a retry can fix. Everything else — a rejected credential, a +// session the server will not serve — would fail identically forever, and +// hammering it is worse than surfacing it. +const isRetryableStatus = (status: number): boolean => + status === 408 || status === 429 || status >= 500; + +const asRecord = (value: unknown): Record => + value && typeof value === "object" ? (value as Record) : {}; + +const asString = (value: unknown): string | undefined => + typeof value === "string" && value ? value : undefined; + +function toState(raw: unknown): BuilderState { + const source = asRecord(raw); + const state: BuilderState = { status: source.status as BuilderStatus }; + const waiting = asRecord(source.waiting_on); + const waitpointId = asString(waiting.waitpoint_id); + if (waitpointId) { + const toolName = asString(waiting.tool_name); + state.waitingOn = { + kind: waiting.kind as BuilderWaitingKind, + waitpointId, + ...(toolName ? { toolName } : {}), + }; + } + const reason = asString(source.reason); + if (reason) state.reason = reason; + const turnId = asString(source.turn_id); + if (turnId) state.turnId = turnId; + const errorSource = asString(source.error_source); + if (errorSource) state.errorSource = errorSource; + const detail = asString(source.detail); + if (detail) state.detail = detail; + return state; +} + +function toToolCall(raw: unknown): BuilderToolCall { + const source = asRecord(raw); + return { + id: asString(source.id) ?? "", + name: asString(source.name) ?? "", + status: asString(source.status) ?? "", + requiresUserInput: Boolean(source.requires_user_input), + waitingOnKind: (source.waiting_on_kind as BuilderToolCall["waitingOnKind"]) ?? null, + // Kept as the raw string the model produced. It arrives mid-generation, so + // it is routinely incomplete JSON and parsing it here would throw on the + // ticks that matter most. + arguments: asString(source.arguments) ?? "", + display: (source.display as BuilderToolCall["display"]) ?? null, + }; +} + +function toMessage(raw: unknown): BuilderMessage { + const source = asRecord(raw); + const toolCalls = source.tool_calls; + return { + messageId: asString(source.message_id) ?? "", + role: source.role as BuilderMessage["role"], + content: asString(source.content) ?? "", + toolCalls: Array.isArray(toolCalls) ? toolCalls.map(toToolCall) : [], + }; +} + +/** + * Projects one SSE frame onto a typed event, or drops it. + * + * Unrecognised types are dropped here rather than passed through. The contract + * requires clients to ignore them, so doing it once at the boundary is what lets + * {@link BuilderEvent} stay a closed union that narrows on `type` — the alternative + * is an open union whose `data` is `unknown` in every branch. + */ +function toBuildEvent(data: string, frameId?: string): BuilderEvent | null { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + const source = asRecord(parsed); + const type = asString(source.type); + if (!type || !KNOWN_EVENT_TYPES.has(type)) return null; + + const base = { seq: asString(source.seq) ?? frameId ?? "" }; + const turnId = asString(source.turn_id); + if (type === "message.updated") { + return { ...base, turnId, type, data: toMessage(source.data) }; + } + if (type === "conversation.reset" || type === "files.changed") { + return { ...base, turnId, type, data: {} }; + } + return { + ...base, + turnId, + type: type as "state.changed" | "turn.started" | "turn.finished" | "error", + data: toState(source.data), + }; +} + +async function streamFailure(response: Response): Promise { + const body = await response.text().catch(() => ""); + let detail = body; + try { + detail = asString(asRecord(JSON.parse(body)).detail) ?? body; + } catch { + /* a non-JSON error body is still the best message available */ + } + return new Base44Error( + detail || `Build stream failed with ${response.status}`, + response.status, + "BUILDER_STREAM_FAILED", + body, + undefined + ); +} + +/** Every route in the family hangs off this. */ +const builderPath = (appId: string) => `/v1/apps/${encodeURIComponent(appId)}/build`; + +/** + * The credential, resolved per call. + * + * Empty when there is no getter, which leaves whatever the Axios client already + * carries in place. + */ +async function bearer( + getToken: BuilderSessionDeps["getToken"] +): Promise> { + const token = getToken ? await getToken() : undefined; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + function done() { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + } + }); +} + +/** + * The read half of a builder session — what a grant can do. + * + * @param deps - Transport, app id and credential. + * @returns The read-only session. + * @internal + */ +export function createBuilderSessionReader( + deps: BuilderSessionDeps +): BuilderSessionReader { + const { axios, appId, getToken } = deps; + const path = builderPath(appId); + const eventsUrl = `${deps.serverUrl.replace(/\/+$/, "")}/api${path}/events`; + const authHeaders = () => bearer(getToken); + + const getState = async (): Promise => { + const response = await axios.get(`${path}/state`, { + headers: await authHeaders(), + }); + return toState(asRecord(response).state); + }; + + const getTurn = async (turnId: string): Promise => { + const response = asRecord( + await axios.get(`${path}/turns/${encodeURIComponent(turnId)}`, { + headers: await authHeaders(), + }) + ); + return { + turnId: asString(response.turn_id) ?? turnId, + live: Boolean(response.live), + state: toState(response.state), + }; + }; + + const listMessages = async ( + options: ListBuilderMessagesOptions = {} + ): Promise => { + const params: Record = {}; + if (options.after !== undefined) params.after = options.after; + if (options.limit !== undefined) params.limit = options.limit; + const response = asRecord( + await axios.get(`${path}/messages`, { + params, + headers: await authHeaders(), + }) + ); + const messages = response.messages; + return { + messages: Array.isArray(messages) ? messages.map(toMessage) : [], + nextAfter: asString(response.next_after) ?? null, + }; + }; + + const subscribe = ( + onEvent: (event: BuilderEvent) => void, + options: SubscribeToBuilderOptions = {} + ): (() => void) => { + const controller = new AbortController(); + let cursor = options.lastEventId; + let stopped = false; + + const stop = () => { + if (stopped) return; + stopped = true; + controller.abort(); + }; + options.signal?.addEventListener("abort", stop, { once: true }); + + const fail = (error: Error) => { + stop(); + options.onError?.(error); + }; + + void (async () => { + let attempt = 0; + while (!stopped) { + try { + const headers: Record = { + Accept: "text/event-stream", + ...(await authHeaders()), + }; + // Resuming from the cursor is what makes a dropped connection cost + // nothing: the server replays what was missed before any live event. + // Without it every reconnect would replay the whole retained window. + if (cursor) headers["Last-Event-ID"] = cursor; + + const response = await globalThis.fetch(eventsUrl, { + method: "GET", + headers, + signal: controller.signal, + cache: "no-store", + }); + if (!response.ok || !response.body) throw await streamFailure(response); + + attempt = 0; + for await (const frame of readSseFrames(response.body)) { + if (stopped) return; + if (frame.id) cursor = frame.id; + const event = toBuildEvent(frame.data, frame.id); + if (event) onEvent(event); + } + // The body ended without an error. A drained deploy looks exactly like + // this, so it is a reconnect rather than a completion — a build has no + // end the transport knows about. + } catch (error) { + if (stopped) return; + if (error instanceof Base44Error && !isRetryableStatus(error.status)) { + fail(error); + return; + } + } + if (stopped) return; + const backoff = Math.min( + RECONNECT_MAX_MS, + RECONNECT_BASE_MS * 2 ** attempt++ + ); + // Jittered, so a fleet of partner workers reconnecting after one outage + // does not arrive together. + await delay(backoff * (0.5 + Math.random() / 2), controller.signal); + } + })(); + + return stop; + }; + + async function* iterate( + options: SubscribeToBuilderOptions = {} + ): AsyncGenerator { + const queue: BuilderEvent[] = []; + // Held in an object because the generator reads what the callback writes, + // and control-flow narrowing does not follow a closure assignment. + const shared: { failure: Error | null; ended: boolean } = { + failure: null, + ended: false, + }; + let wake: (() => void) | null = null; + + const unsubscribe = subscribe( + (event) => { + queue.push(event); + wake?.(); + }, + { + ...options, + onError: (error) => { + shared.failure = error; + shared.ended = true; + wake?.(); + }, + } + ); + + try { + for (;;) { + while (queue.length) yield queue.shift()!; + if (shared.failure) throw shared.failure; + if (shared.ended) return; + await new Promise((resolve) => { + wake = resolve; + }); + wake = null; + } + } finally { + unsubscribe(); + } + } + + const stream = ( + options?: SubscribeToBuilderOptions + ): AsyncIterable => ({ + [Symbol.asyncIterator]: () => iterate(options), + }); + + async function* iterateText( + options?: SubscribeToBuilderOptions + ): AsyncGenerator { + const sent = new Map(); + for await (const event of stream(options)) { + switch (event.type) { + case "message.updated": { + if (event.data.role !== "assistant") break; + const previous = sent.get(event.data.messageId) ?? ""; + const current = event.data.content; + if (current === previous) break; + sent.set(event.data.messageId, current); + // Streaming text only ever extends. A snapshot that is not an + // extension means the message was rewritten — a retry, an edit — and a + // chat surface has already made what it printed immutable, so the + // honest rendering is a new paragraph rather than a patch. + yield current.startsWith(previous) + ? current.slice(previous.length) + : `\n\n${current}`; + break; + } + case "turn.finished": + case "error": + return; + case "turn.started": + case "state.changed": + // `blocked` ends it too. No `turn.finished` follows a turn that ran + // out of credits, so waiting for one would hang the loop. + if (event.data.status === "blocked") return; + break; + default: + break; + } + } + } + + const streamText = ( + options?: SubscribeToBuilderOptions + ): AsyncIterable => ({ + [Symbol.asyncIterator]: () => iterateText(options), + }); + + const waitForTurn = ( + turnId: string, + options: WaitForTurnOptions = {} + ): Promise => + new Promise((resolve, reject) => { + let settled = false; + const finish = (act: () => void) => { + if (settled) return; + settled = true; + unsubscribe(); + act(); + }; + + const unsubscribe = subscribe( + (event) => { + // Only the events whose payload is a state can settle a turn. Written + // as the positive set so the union narrows to that arm. + if ( + event.type !== "turn.started" && + event.type !== "turn.finished" && + event.type !== "state.changed" && + event.type !== "error" + ) { + return; + } + if (event.turnId !== turnId && event.data.turnId !== turnId) return; + if (SETTLED_STATUSES.has(event.data.status)) { + finish(() => resolve(event.data)); + } + }, + { signal: options.signal, onError: (error) => finish(() => reject(error)) } + ); + + options.signal?.addEventListener( + "abort", + () => + finish(() => + reject( + options.signal?.reason instanceof Error + ? options.signal.reason + : new Error("Waiting for the build turn was aborted") + ) + ), + { once: true } + ); + + // The turn may already have ended — between the write returning and this + // call, or before a resumed process got here — and a finished turn emits + // nothing more to wait for. Asked after subscribing, so the transition + // cannot fall between the two. + getTurn(turnId).then( + (turn) => { + if (!turn.live && SETTLED_STATUSES.has(turn.state.status)) { + finish(() => resolve(turn.state)); + } + }, + (error: unknown) => { + // 404 means the turn is not in the retained window, which a running + // turn also looks like right after it starts. Keep waiting. + if (!(error instanceof Base44Error) || error.status !== 404) { + finish(() => reject(error as Error)); + } + } + ); + }); + + return { + appId, + getState, + listMessages, + getTurn, + waitForTurn, + subscribe, + stream, + streamText, + }; +} + +/** + * A builder session with the writes, for a credential that may start turns. + * + * @param deps - Transport, app id and credential. + * @returns The full session. + * @internal + */ +export function createBuilderSessionModule(deps: BuilderSessionDeps): BuilderSession { + const { axios, appId, getToken, serverUrl } = deps; + const path = builderPath(appId); + const reader = createBuilderSessionReader(deps); + const authHeaders = () => bearer(getToken); + + const writeHeaders = async ( + idempotencyKey?: string + ): Promise> => ({ + ...(await authHeaders()), + ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), + }); + + // The server answers with the path, not a URL. Joining it here means a partner + // can hand the result to a client that is not this SDK without knowing which + // host minted it. + const absolute = (url: string | undefined): string => { + if (!url) return ""; + if (/^https?:\/\//i.test(url)) return url; + if (!/^https?:\/\//i.test(serverUrl)) return url; + return new URL(url, serverUrl).toString(); + }; + + return { + ...reader, + + async sendMessage( + content: string, + options: SendBuilderMessageOptions = {} + ): Promise { + const body: Record = { content }; + if (options.fileUrls !== undefined) body.file_urls = options.fileUrls; + const response = asRecord( + await axios.post(`${path}/messages`, body, { + headers: await writeHeaders(options.idempotencyKey), + }) + ); + return { + sessionId: asString(response.session_id) ?? appId, + turnId: asString(response.turn_id) ?? "", + }; + }, + + async respond( + response: BuilderResponse, + options: RespondToBuilderOptions = {} + ): Promise { + const body: Record = { + kind: response.kind, + waitpoint_id: response.waitpointId, + }; + if (response.kind === "approval") { + body.approved = response.approved; + } else if (response.value !== undefined) { + // Omitted rather than sent as null: leaving it out is how you decline, + // and an explicit null would be a second way to say the same thing. + body.value = response.value; + } + const result = asRecord( + await axios.post(`${path}/responses`, body, { + headers: await writeHeaders(options.idempotencyKey), + }) + ); + return { + sessionId: asString(result.session_id) ?? appId, + turnId: asString(result.turn_id) ?? "", + }; + }, + + async cancel(): Promise { + await axios.post(`${path}/cancel`, undefined, { + headers: await authHeaders(), + }); + }, + + async createGrant(options: CreateBuilderGrantOptions = {}): Promise { + const body: Record = {}; + if (options.ttlSeconds !== undefined) { + body.token_ttl_seconds = options.ttlSeconds; + } + if (options.subject !== undefined) body.subject = options.subject; + const response = asRecord( + await axios.post(`${path}/grants`, body, { + headers: await authHeaders(), + }) + ); + return { + sessionId: asString(response.session_id) ?? appId, + grantId: asString(response.grant_id) ?? "", + token: asString(response.token) ?? "", + expiresIn: Number(response.expires_in), + expiresAt: asString(response.expires_at) ?? "", + eventsUrl: absolute(asString(response.events_url)), + }; + }, + + async revokeGrant(grantId: string): Promise { + await axios.delete(`${path}/grants/${encodeURIComponent(grantId)}`, { + headers: await authHeaders(), + }); + }, + }; +} diff --git a/src/modules/builder.types.ts b/src/modules/builder.types.ts new file mode 100644 index 0000000..c8833ce --- /dev/null +++ b/src/modules/builder.types.ts @@ -0,0 +1,618 @@ +/** + * Where a build turn stands. + * + * `blocked` is not a failure and not a question: the turn stopped on a condition + * no answer clears — being out of credits is the one that exists today — and it + * resumes once that condition does. `reason` says which. + */ +export type BuilderStatus = "idle" | "running" | "waiting" | "blocked" | "error"; + +/** + * Why a turn is suspended, and therefore what to render. + * + * All three are answerable with {@link BuilderSession.respond | respond()}, and each + * wants a different affordance: a form, a picker, an approve/reject. Being out of + * credits is deliberately *not* one of these — it is `blocked`, because no + * response resolves it. + */ +export type BuilderWaitingKind = "input" | "choice" | "approval"; + +/** + * The waitpoint holding a turn open. + */ +export interface BuilderWaitingOn { + /** What kind of answer resolves it. */ + kind: BuilderWaitingKind; + /** The id to answer with. It is the tool call's id, and it goes stale when the turn moves on. */ + waitpointId: string; + /** The tool that asked, when the server names one. Display only. */ + toolName?: string; +} + +/** + * A builder session's current state. + */ +export interface BuilderState { + /** Where the turn stands. */ + status: BuilderStatus; + /** Present only while `status` is `"waiting"`. */ + waitingOn?: BuilderWaitingOn; + /** Why a `blocked` turn is blocked. `"quota"` is out of credits. */ + reason?: string; + /** The turn this state belongs to, so a state arriving after a reconnect can be told from a stale one. */ + turnId?: string; + /** Where an `error` came from. Diagnostic, not a code to branch on. */ + errorSource?: string; + /** + * What went wrong, in prose. + * + * Carried by an `error` raised after the write already returned — a turn that + * dies outside a request has no response to fail, so without this the stream + * would simply stop, which is indistinguishable from a slow build. + */ + detail?: string; +} + +/** + * One tool call inside a streamed message. + */ +export interface BuilderToolCall { + /** The call's id. When this call is a waitpoint, this is the `waitpointId` to answer with. */ + id: string; + /** The tool's name. */ + name: string; + /** The call's lifecycle state, as the builder reports it. */ + status: string; + /** Whether this call is waiting on a person. */ + requiresUserInput: boolean; + /** What kind of answer it wants, when it is waiting. */ + waitingOnKind: BuilderWaitingKind | null; + /** + * The call's arguments, as the raw JSON string the model produced. + * + * A string rather than a parsed object because it arrives mid-generation and + * is therefore often incomplete JSON — parsing it before the call settles + * throws. Published at all because an interrupt is otherwise unanswerable: + * the questions to pick between, the secrets to fill in and the packages to + * approve all live here. + * + * Empty when the tool's display projection withholds its details. + */ + arguments: string; + /** How the builder itself renders this call, when it says. `null` when it does not. */ + display: Record | null; +} + +/** + * One message in a build conversation. + * + * The same shape from the stream and from {@link BuilderSessionReader.listMessages | listMessages()}, + * so reconciling after an outage cannot produce a different transcript than the + * one that was streamed. + */ +export interface BuilderMessage { + /** The message's id, and the key it is replaced under. */ + messageId: string; + /** Who wrote it. A partner's own user messages come back on the stream too. */ + role: "assistant" | "user"; + /** The message text. */ + content: string; + /** The tool calls this message carries. */ + toolCalls: BuilderToolCall[]; +} + +/** + * The event types a builder session emits. + */ +export type BuilderEventType = + | "state.changed" + | "turn.started" + | "turn.finished" + | "message.updated" + | "error" + | "conversation.reset" + | "files.changed"; + +/** Fields every event carries. */ +interface BuilderEventBase { + /** + * The journal sequence. Monotonic per session, and the resume cursor — the SDK + * tracks it for you across reconnects. + */ + seq: string; + /** The turn this event belongs to. Absent on the two out-of-turn directives. */ + turnId?: string; +} + +/** + * One event from a builder session. + * + * A discriminated union: switch on `type` and `data` narrows with it. + * + * **`message.updated` is a snapshot, not a delta.** The builder flushes the whole + * in-progress assistant message on every tick, so the contract is last-write-wins + * per `messageId` — replace what you hold, never append. Text still arrives + * progressively; there is simply no delta event. + * {@link BuilderSessionReader.streamText | streamText()} is the append-shaped view, + * for UIs that want one. + */ +export type BuilderEvent = + | ({ + /** A turn started, finished, changed state, or failed. */ + type: "turn.started" | "turn.finished" | "state.changed" | "error"; + data: BuilderState; + } & BuilderEventBase) + | ({ + /** A message was written or rewritten. Replace what you hold for this `messageId`. */ + type: "message.updated"; + data: BuilderMessage; + } & BuilderEventBase) + | ({ + /** + * The conversation was rewritten underneath you (a checkpoint restore, a + * branch sync), or the app's files changed outside a turn. Both carry no + * data: the only useful response is to re-read. + */ + type: "conversation.reset" | "files.changed"; + data: Record; + } & BuilderEventBase); + +/** + * An answer to a waitpoint. + * + * Discriminated on `kind`, which is checked against the *live* waitpoint: sending + * an approval to a question is a 409 rather than a silent coercion. `waitpointId` + * comes from {@link BuilderWaitingOn.waitpointId}, and a stale one is also a 409 — + * so read the current state rather than remembering an id across turns. + */ +export type BuilderResponse = + | { + /** Answering an approval: a decision, and nothing else. */ + kind: "approval"; + /** The waitpoint to answer. */ + waitpointId: string; + /** Whether to proceed. */ + approved: boolean; + } + | { + /** Answering a question or a picker: the answer itself. */ + kind: "input" | "choice"; + /** The waitpoint to answer. */ + waitpointId: string; + /** + * The answer. + * + * Omitting it *declines* the question, which is the only sensible reading + * of "no answer" — not a way to send an empty one. + */ + value?: Record; + }; + +/** Options for {@link BuilderSession.sendMessage | sendMessage()}. */ +export interface SendBuilderMessageOptions { + /** Attachments, as URLs the builder can fetch. */ + fileUrls?: string[]; + /** + * A key that makes a retry safe. + * + * A turn costs real credits, and retrying a 202 that never arrived is the + * normal reaction to a timeout — so name the turn and a retry rejoins it + * instead of buying a second one. It becomes the `turnId`. + * + * Without one the server assigns an id and you keep followability but lose + * retry safety. The SDK does not invent one, because a key it generated would + * differ on the retry and protect nothing. + */ + idempotencyKey?: string; +} + +/** Options for {@link BuilderSession.respond | respond()}. */ +export interface RespondToBuilderOptions { + /** See {@link SendBuilderMessageOptions.idempotencyKey}. */ + idempotencyKey?: string; +} + +/** The turn a write started. */ +export interface BuilderTurnRef { + /** The session the turn runs in. Always the app id. */ + sessionId: string; + /** + * The turn's id. + * + * Carried on every event the turn emits, and the id + * {@link BuilderSessionReader.getTurn | getTurn()} and + * {@link BuilderSessionReader.waitForTurn | waitForTurn()} take. + */ + turnId: string; +} + +/** One turn's outcome. */ +export interface BuilderTurn { + /** The turn asked about. */ + turnId: string; + /** Whether this is the turn running right now, rather than a settled one read back from the journal. */ + live: boolean; + /** The state the turn is in, or the last one it reached. */ + state: BuilderState; +} + +/** Options for {@link BuilderSession.createGrant | createGrant()}. */ +export interface CreateBuilderGrantOptions { + /** + * How long the grant lives. + * + * Short on purpose. Minting is the steady state, not a one-off, and a build + * routinely outlives one grant. + * + * @defaultValue `900` (15 minutes), server-side. Maximum `3600`. + */ + ttlSeconds?: number; + /** + * Your own identifier for whoever the grant is for. + * + * Opaque, never interpreted, and read on no authorization path — it rides the + * grant only so your logs and ours line up. + */ + subject?: string; +} + +/** + * A read-only credential for one builder session. + * + * The only Base44 credential that should ever reach a browser: it cannot send a + * message, answer a waitpoint, cancel a turn, or touch any other app. That + * asymmetry is the design — reads go browser to Base44 directly, writes go + * through your server — and it is why a leaked grant cannot spend your credits. + */ +export interface BuilderGrant { + /** The session it reads. Always the app id. */ + sessionId: string; + /** The grant's id, for {@link BuilderSession.revokeGrant | revokeGrant()}. */ + grantId: string; + /** The token itself. Hand this to the browser; hand it nothing else. */ + token: string; + /** Seconds until it expires. Re-mint before then rather than after. */ + expiresIn: number; + /** When it expires, as an ISO timestamp. */ + expiresAt: string; + /** The stream endpoint, absolute. Useful for a client that is not this SDK. */ + eventsUrl: string; +} + +/** Options for {@link BuilderSessionReader.listMessages | listMessages()}. */ +export interface ListBuilderMessagesOptions { + /** + * The cursor from a previous page's `nextAfter`. + * + * A timestamp seek rather than an offset, so a message appended while you walk + * cannot shift the pages beneath you. + */ + after?: string; + /** + * How many rows to read. + * + * Internal messages are dropped from the rendered page, so a page can hold + * fewer than this and still have a next cursor. Follow `nextAfter` rather than + * counting. + * + * @defaultValue `50`, server-side. Maximum `200`. + */ + limit?: number; +} + +/** One page of conversation history. */ +export interface BuilderMessagePage { + /** The page, oldest first. */ + messages: BuilderMessage[]; + /** The cursor for the next page, or `null` at the end of the history. */ + nextAfter: string | null; +} + +/** Options for the subscription forms. */ +export interface SubscribeToBuilderOptions { + /** + * Where to resume from. + * + * Only needed to resume across *process* restarts: within one subscription the + * SDK tracks the cursor itself, so a dropped connection already resumes where + * it left off. Persist the `seq` of the last event you handled and pass it here + * to pick a build back up after a deploy. + * + * Omitted, the stream replays the whole retained window — which converges, + * since messages are last-write-wins, but re-delivers everything first. + */ + lastEventId?: string; + /** + * Called when the stream cannot continue. + * + * Reconnects are handled for you and are not reported here. This fires only + * when the SDK gives up: a credential the server rejects, a session it will not + * serve, or an abort. The subscription is over by the time it runs. + */ + onError?: (error: Error) => void; + /** Stops the subscription when aborted, the same as calling the returned unsubscribe. */ + signal?: AbortSignal; +} + +/** Options for {@link BuilderSessionReader.waitForTurn | waitForTurn()}. */ +export interface WaitForTurnOptions { + /** Gives up waiting when aborted. The turn itself is unaffected — use {@link BuilderSession.cancel | cancel()} to stop it. */ + signal?: AbortSignal; +} + +/** + * A builder session, read-only. + * + * What a grant can do. Obtained from + * {@link createBuilderSession | createBuilderSession()}, which is the browser's entry + * point; the server-side {@link BuilderSession} adds the writes on top. + */ +export interface BuilderSessionReader { + /** The app this session builds. A builder session *is* an app — there is no separate session to open. */ + readonly appId: string; + + /** + * The session's current state. + * + * The supported polling floor, for a client that cannot hold a connection open. + * Anything that can should {@link BuilderSessionReader.subscribe | subscribe()} + * instead — this answers from the app itself on every call. + * + * @returns Where the build stands right now. + */ + getState(): Promise; + + /** + * Reads conversation history, newest page first and oldest-first within a page. + * + * For reconciling after an outage longer than the stream's replay window. A + * client that stayed connected has already been told everything this returns. + * + * @param options - Cursor and page size. + * @returns One page, plus the cursor to follow it with. + * + * @example + * ```typescript + * let after: string | null | undefined = undefined; + * do { + * const page = await builder.listMessages({ after }); + * render(page.messages); + * after = page.nextAfter; + * } while (after); + * ``` + */ + listMessages(options?: ListBuilderMessagesOptions): Promise; + + /** + * One turn's outcome, so a write's `turnId` is followable without the stream. + * + * A live turn is answered from the app; a finished one from the journal. A turn + * older than the journal's retention window is a 404 rather than a guess. + * + * @param turnId - The turn to read. + * @returns The turn's state, and whether it is still running. + * + * @throws {Base44Error} 404 if the turn is not in the retained window. + */ + getTurn(turnId: string): Promise; + + /** + * Waits for a turn to stop, and resolves with the state it stopped in. + * + * The answer for a long-lived worker: a write returns as soon as the turn is + * accepted, and this is how you await the result without holding an HTTP + * request open through the whole build. A serverless function should not use + * it — it cannot outlive the turn. + * + * Watches the stream rather than polling, and checks the turn once on the way + * in, so a turn that finished between the write and this call still resolves. + * + * Resolves on `idle`, `error` *and* `blocked` — a build that ran out of credits + * has stopped, and waiting for it to finish would wait forever. + * + * @param turnId - The turn to wait for, from {@link BuilderTurnRef.turnId}. + * @param options - An abort signal. + * @returns The state the turn came to rest in. + * + * @example + * ```typescript + * const { turnId } = await builder.sendMessage('add a footer'); + * const outcome = await builder.waitForTurn(turnId); + * if (outcome.status === 'waiting') await answer(outcome.waitingOn); + * ``` + */ + waitForTurn(turnId: string, options?: WaitForTurnOptions): Promise; + + /** + * Streams the build, calling back on every event. + * + * The primary form, and the same shape as every other realtime call in the + * package: a callback in, an unsubscribe out. Reconnects and resumes on its + * own — a dropped connection replays what it missed rather than losing it, so + * a deploy on either side does not cost you state. + * + * Event types the SDK does not recognise are dropped rather than passed + * through. That is the contract's own rule for clients, applied once here + * instead of in every partner's switch; a new event type reaches you after an + * SDK upgrade. + * + * @param onEvent - Called with each event, in order. + * @param options - Resume point, error callback, abort signal. + * @returns Call it to stop. Idempotent. + * + * @example + * ```typescript + * const unsubscribe = builder.subscribe((event) => { + * switch (event.type) { + * case 'message.updated': upsert(event.data); break; // by messageId + * case 'state.changed': setStatus(event.data); break; + * case 'turn.finished': markDone(event.turnId); break; + * case 'conversation.reset': refetchHistory(); break; + * } + * }); + * + * // Later: + * unsubscribe(); + * ``` + */ + subscribe( + onEvent: (event: BuilderEvent) => void, + options?: SubscribeToBuilderOptions + ): () => void; + + /** + * The same stream, as an async iterable. + * + * The second accessor over one subscription, for code shaped as a loop rather + * than a callback. Leaving the loop — `break`, `return`, or a throw — + * unsubscribes. + * + * Events queue while the body of your loop is awaiting, so a slow consumer + * falls behind rather than dropping events. Keep the body fast, or take a copy + * and hand it off. + * + * @param options - Resume point and abort signal. + * @returns Every event, in order, until you stop iterating. + * + * @example + * ```typescript + * for await (const event of builder.stream()) { + * if (event.type === 'turn.finished') break; + * } + * ``` + */ + stream(options?: SubscribeToBuilderOptions): AsyncIterable; + + /** + * The assistant's prose, as text to append. + * + * `message.updated` is a snapshot, so rendering it into a chat UI that appends + * would repeat the whole message on every tick. This yields only what each + * snapshot *added*, which is what a streaming chat surface takes. + * + * A snapshot that is not an extension of what came before means the message was + * rewritten — a retry, an edit — so a paragraph break is emitted and the new + * text follows, rather than trying to patch history a chat platform has already + * made immutable. + * + * Ends when the turn does. Tool calls and waitpoints are not text and are not + * yielded: read them from {@link BuilderSessionReader.subscribe | subscribe()} or + * {@link BuilderSessionReader.getState | getState()} and render them as whatever + * your surface calls a card. + * + * @param options - Resume point and abort signal. + * @returns Text to append, in order, until the turn ends. + * + * @example + * ```typescript + * for await (const chunk of builder.streamText()) { + * process.stdout.write(chunk); + * } + * ``` + */ + streamText(options?: SubscribeToBuilderOptions): AsyncIterable; +} + +/** + * A builder session, with the writes. + * + * What a principal's own credential can do, and therefore what belongs on your + * server. Obtained from {@link PrincipalClient.builder | asPrincipal(id).builder(appId)}. + * + * Every write returns as soon as the turn is *accepted*, not when it is done: a + * build takes minutes and no caller should hold a request open through one. The + * turn's progress arrives on the stream, and + * {@link BuilderSessionReader.waitForTurn | waitForTurn()} is how a long-lived + * worker awaits the end of it. + */ +export interface BuilderSession extends BuilderSessionReader { + /** + * Starts a build turn. + * + * @param content - What to tell the builder. + * @param options - Attachments and an idempotency key. + * @returns The turn's id, immediately. The turn itself has not run yet. + * + * @throws {Base44Error} 409 if a turn is already running, or if a waitpoint is + * unanswered — the two have different remedies, so they are told apart. + * + * @example + * ```typescript + * const { turnId } = await builder.sendMessage('add a footer', { + * idempotencyKey: requestId, + * }); + * ``` + */ + sendMessage( + content: string, + options?: SendBuilderMessageOptions + ): Promise; + + /** + * Answers the waitpoint holding the turn open, and resumes it. + * + * @param response - The answer, discriminated on the waitpoint's kind. + * @param options - An idempotency key. + * @returns The resumed turn's id. + * + * @throws {Base44Error} 409 if the session is not waiting, if the id names a + * waitpoint that has moved on, or if the kind does not match the live one. + * + * @example + * ```typescript + * const { waitingOn } = await builder.getState(); + * if (waitingOn?.kind === 'approval') { + * await builder.respond({ ...waitingOn, approved: true }); + * } + * ``` + */ + respond( + response: BuilderResponse, + options?: RespondToBuilderOptions + ): Promise; + + /** + * Stops the running turn. + * + * Unlike the other writes this one is not deferred: stopping is fast and a + * caller needs to know it landed, so resolving *is* the confirmation. + * + * Returns nothing rather than the state. The endpoint answers with the + * builder's own internal status vocabulary, which the rest of this surface + * deliberately renames, and the settled state arrives on the stream — or from + * {@link BuilderSessionReader.getState | getState()} — in the public one. + */ + cancel(): Promise; + + /** + * Mints a read-only grant for this session. + * + * This is what a browser gets. It reads one session and can write nothing, so + * the stream can go browser-to-Base44 directly — off your serverless function + * path, where holding an SSE connection open is a problem — while every write + * still goes through your server. + * + * @param options - TTL and an opaque subject. + * @returns The grant, including the token to hand over. + * + * @example + * ```typescript + * // POST /api/base44/grant, on your server + * const userId = await requireSession(req); + * const grant = await base44.asPrincipal(userId).builder(appId) + * .createGrant({ ttlSeconds: 900 }); + * return Response.json(grant); + * ``` + */ + createGrant(options?: CreateBuilderGrantOptions): Promise; + + /** + * Withdraws a grant before it expires. + * + * Idempotent, and says nothing about whether the id was real — confirming which + * ids exist would leak the grants of every session in the workspace. + * + * @param grantId - The grant to revoke. + */ + revokeGrant(grantId: string): Promise; +} diff --git a/src/platform-client.ts b/src/platform-client.ts index 1074d9f..fc3f11c 100644 --- a/src/platform-client.ts +++ b/src/platform-client.ts @@ -1,8 +1,10 @@ import { createAxiosClient } from "./utils/axios-client.js"; import { createPlatformsModule } from "./modules/platforms.js"; +import { createBuilderSessionModule } from "./modules/builder.js"; import { createPrincipalTokenStore } from "./utils/principal-tokens.js"; import { createClient } from "./client.js"; import type { Base44Client } from "./client.types.js"; +import type { BuilderSession } from "./modules/builder.types.js"; import type { CreatePlatformClientConfig, PlatformClient, @@ -35,8 +37,12 @@ export type { CreatePlatformClientConfig, PlatformClient, PrincipalClient }; * * **Server-side only.** This client holds workspace API keys, which authorize * every app in the workspace. Never construct one in a browser, and never send - * either key to one — a browser gets a short-lived token vended *for it*, which - * is what {@linkcode PrincipalClient.getToken | getToken()} is for. + * either key to one. + * + * Nor a principal's own token: it can start builds and spend credits. The one + * credential meant for a browser is a *grant* — + * {@linkcode BuilderSession.createGrant | createGrant()} — which reads one builder + * session, expires in minutes, and cannot write. * * @param config - Configuration object for the platform client. * @returns A configured platform client. @@ -57,6 +63,13 @@ export type { CreatePlatformClientConfig, PlatformClient, PrincipalClient }; * }); * * const asDana = base44.asPrincipal('user_42'); + * + * // Drive the builder as them, and watch it happen. + * const builder = asDana.builder(appId); + * const { turnId } = await builder.sendMessage('add a footer'); + * const outcome = await builder.waitForTurn(turnId); + * + * // Or use the rest of the SDK as them. * const app = await asDana.forApp(appId); * const todos = await app.entities.Todo.list(); * ``` @@ -92,6 +105,14 @@ export function createPlatformClient( onError: options?.onError, }); + // A fourth, and for the same reason as the third: it carries no static + // credential. Every builder call is made as a *principal*, whose token rotates, + // so the token goes on the request rather than into the client. + const builderAxios = createAxiosClient({ + baseURL: `${serverUrl}/api`, + onError: options?.onError, + }); + const tokens = createPrincipalTokenStore({ mintAxios, oauthAxios }); const platforms = createPlatformsModule(provisionAxios); @@ -114,6 +135,18 @@ export function createPlatformClient( getToken, + // Not cached, unlike `forApp`. A builder session is a handful of closures + // over the token store rather than a client with sockets and an analytics + // session, so there is nothing to reuse and nothing to leak. + builder(appId: string): BuilderSession { + return createBuilderSessionModule({ + axios: builderAxios, + appId, + serverUrl, + getToken, + }); + }, + async forApp(appId: string): Promise { const token = await getToken(); const held = apps.get(appId); diff --git a/src/platform-client.types.ts b/src/platform-client.types.ts index dc92f7e..3583258 100644 --- a/src/platform-client.types.ts +++ b/src/platform-client.types.ts @@ -1,4 +1,5 @@ import type { Base44Client, CreateClientOptions } from "./client.types.js"; +import type { BuilderSession } from "./modules/builder.types.js"; import type { PlatformsModule } from "./modules/platforms.types.js"; /** @@ -49,6 +50,39 @@ export interface PrincipalClient { /** The identifier this principal was provisioned under. */ readonly externalId: string; + /** + * The builder session for one app, driven as this principal. + * + * This is the partner-facing builder: send a message, answer what the agent + * asks, stop a turn, and watch the whole thing happen over one resumable + * stream. Every write returns as soon as the turn is *accepted*, because a + * build takes minutes and no caller should hold a request open through one. + * + * The principal must be able to edit the app, which it is when it built the + * app itself. A workspace that is not enrolled as a platform gets 404 from + * every route here — deliberately indistinguishable from "no such app", so an + * unenrolled caller cannot tell the surface exists. + * + * Cheap to call: the session is a handle, not a connection, and it holds no + * credential of its own — the principal's token is re-read per request, so one + * held across a long build keeps working as the token rotates underneath it. + * + * @param appId - The app to build. + * @returns The session, ready to read and write. + * + * @example + * ```typescript + * const builder = base44.asPrincipal('user_42').builder(appId); + * + * const { turnId } = await builder.sendMessage('add a footer'); + * const outcome = await builder.waitForTurn(turnId); + * + * // The browser gets a read-only grant, and nothing else. + * const grant = await builder.createGrant({ ttlSeconds: 900 }); + * ``` + */ + builder(appId: string): BuilderSession; + /** * A Base44 client for one app, acting as this principal. * @@ -78,9 +112,14 @@ export interface PrincipalClient { /** * The raw access token for this principal, minting or renewing as needed. * - * Most code should use {@link PrincipalClient.forApp | forApp()} instead. Reach - * for this when you need to authenticate a request the SDK does not make for - * you. + * Most code should use {@link PrincipalClient.forApp | forApp()} or + * {@link PrincipalClient.builder | builder()} instead. Reach for this when you need + * to authenticate a request the SDK does not make for you. + * + * **Not the thing to send to a browser.** This token can start builds and spend + * your credits. What a browser gets is a grant from + * {@link BuilderSession.createGrant | createGrant()}, which reads one session and + * writes nothing. * * Do not cache what this returns — it is already cached, and holding a copy is * how a caller ends up using a token the store has since replaced. diff --git a/src/utils/sse.ts b/src/utils/sse.ts new file mode 100644 index 0000000..6883e2a --- /dev/null +++ b/src/utils/sse.ts @@ -0,0 +1,91 @@ +/** + * A Server-Sent Events reader over `fetch`. + * + * The SDK's first streaming transport, and deliberately not `EventSource`. + * `EventSource` cannot set request headers, which is why the build API also + * accepts a single-use ticket in the query string — a workaround for a browser + * limitation, not a shape to build on. Reading the stream with `fetch` lets the + * credential stay in an `Authorization` header, where it does not land in + * referrers, proxy logs or browser history, and the ticket exchange never has to + * happen. `fetch` with a streaming body works in browsers, Node 18+, Deno and + * Bun; `EventSource` is browser-only. + * + * @internal + */ + +/** One dispatched SSE event. */ +export interface SseFrame { + /** The `id:` field — the resume cursor a client echoes back as `Last-Event-ID`. */ + id?: string; + /** The `event:` field, naming the event type. */ + event?: string; + /** The `data:` field. Multiple `data:` lines are joined with newlines, per spec. */ + data: string; +} + +// A blank line ends an event. Any of the three terminators the spec allows may +// arrive, and a CRLF can be split across two network chunks — matching on the +// accumulated buffer rather than per chunk is what makes that harmless. +const FRAME_BOUNDARY = /\r\n\r\n|\n\n|\r\r/; +const LINE_BREAK = /\r\n|\n|\r/; + +function parseFrame(block: string): SseFrame | null { + const data: string[] = []; + let id: string | undefined; + let event: string | undefined; + + for (const line of block.split(LINE_BREAK)) { + // A line starting with a colon is a comment. That is what a keepalive is, + // so this is the branch that keeps an idle build from looking like an event. + if (!line || line.startsWith(":")) continue; + const separator = line.indexOf(":"); + const field = separator === -1 ? line : line.slice(0, separator); + let value = separator === -1 ? "" : line.slice(separator + 1); + if (value.startsWith(" ")) value = value.slice(1); + + if (field === "data") data.push(value); + else if (field === "event") event = value; + else if (field === "id") id = value; + } + + // No payload, nothing to dispatch. The build stream always sends `data:`, so + // this only drops frames that carry a bare `id:` or an unknown field. + return data.length ? { id, event, data: data.join("\n") } : null; +} + +/** + * Yields SSE frames from a fetch response body until the stream ends. + * + * @param stream - The response body. + * @returns Each dispatched event, in order. + * @internal + */ +export async function* readSseFrames( + stream: ReadableStream +): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + for (;;) { + const match = FRAME_BOUNDARY.exec(buffer); + if (!match) break; + const block = buffer.slice(0, match.index); + buffer = buffer.slice(match.index + match[0].length); + const frame = parseFrame(block); + if (frame) yield frame; + } + } + } finally { + // Abandoning the iterator mid-stream (a `break` in the consumer, or an + // unsubscribe) has to release the underlying connection, or a long-lived + // process leaks one socket per build it stopped watching. + await reader.cancel().catch(() => {}); + } +} diff --git a/tests/unit/builder.test.ts b/tests/unit/builder.test.ts new file mode 100644 index 0000000..ab0f613 --- /dev/null +++ b/tests/unit/builder.test.ts @@ -0,0 +1,646 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import nock from "nock"; +import { createBuilderSession, createPlatformClient } from "../../src/index.ts"; +import type { BuilderEvent } from "../../src/index.ts"; + +const serverUrl = "https://base44.app"; +const appId = "app_1"; +const externalId = "user_42"; +const accessToken = "vended-access-token"; + +/** A stream that stays open until the test closes it, so nothing reconnects mid-assertion. */ +function openStream() { + let controller!: ReadableStreamDefaultController; + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + return { + response: new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + push: (chunk: string) => controller.enqueue(encoder.encode(chunk)), + close: () => controller.close(), + }; +} + +function frame(event: string, payload: Record, seq: string) { + const data = JSON.stringify({ type: event, seq, ...payload }); + return `id: ${seq}\nevent: ${event}\ndata: ${data}\n\n`; +} + +/** Resolves once `count` events have arrived. */ +function collector(count: number) { + const events: BuilderEvent[] = []; + let settle!: () => void; + const reached = new Promise((resolve) => { + settle = resolve; + }); + return { + events, + reached, + onEvent: (event: BuilderEvent) => { + events.push(event); + if (events.length >= count) settle(); + }, + }; +} + +describe("Builder sessions", () => { + let base44: ReturnType; + let scope: nock.Scope; + let fetches: { url: string; init: RequestInit }[]; + const realFetch = globalThis.fetch; + + beforeEach(() => { + base44 = createPlatformClient({ + serverUrl, + mintKey: "b44k_mint_key", + provisionKey: "b44k_provision_key", + }); + scope = nock(serverUrl); + fetches = []; + // The token every build call rides on. Minting is cached, so one is enough + // for a whole test. + scope.post("/api/service/user-tokens").reply(200, { + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + refresh_token: null, + }); + }); + + afterEach(() => { + nock.cleanAll(); + globalThis.fetch = realFetch; + vi.restoreAllMocks(); + }); + + const builder = () => base44.asPrincipal(externalId).builder(appId); + + /** Serves one response per subscribe attempt, recording what was asked for. */ + function stubStream(...responses: Response[]) { + let call = 0; + globalThis.fetch = vi.fn(async (url: unknown, init: unknown) => { + fetches.push({ url: String(url), init: init as RequestInit }); + const response = responses[Math.min(call, responses.length - 1)]; + call += 1; + return response; + }) as unknown as typeof fetch; + } + + describe("writes", () => { + test("sendMessage starts a turn and returns its id without waiting for it", async () => { + scope + .post(`/api/v1/apps/${appId}/build/messages`, { + content: "add a footer", + file_urls: ["https://cdn.example/logo.png"], + }) + .reply(202, { session_id: appId, turn_id: "turn_1" }); + + const turn = await builder().sendMessage("add a footer", { + fileUrls: ["https://cdn.example/logo.png"], + }); + + expect(turn).toEqual({ sessionId: appId, turnId: "turn_1" }); + expect(scope.isDone()).toBe(true); + }); + + test("an idempotency key rides the header, and is never invented", async () => { + const sent: (string | undefined)[] = []; + scope + .post(`/api/v1/apps/${appId}/build/messages`) + .twice() + .reply(202, function () { + sent.push(this.req.headers["idempotency-key"]); + return { session_id: appId, turn_id: "turn_1" }; + }); + + await builder().sendMessage("hello"); + await builder().sendMessage("hello", { idempotencyKey: "req_7" }); + + // A key the SDK generated would differ on the retry and protect nothing, + // so no key means no header and the server names the turn instead. + expect(sent).toEqual([undefined, "req_7"]); + }); + + test("every builder call presents the principal's vended token, not a workspace key", async () => { + let authorization: string | undefined; + scope.post(`/api/v1/apps/${appId}/build/messages`).reply(202, function () { + authorization = this.req.headers.authorization; + return { session_id: appId, turn_id: "turn_1" }; + }); + + await builder().sendMessage("hello"); + expect(authorization).toBe(`Bearer ${accessToken}`); + }); + + test("respond sends an approval as a decision", async () => { + scope + .post(`/api/v1/apps/${appId}/build/responses`, { + kind: "approval", + waitpoint_id: "call_9", + approved: true, + }) + .reply(202, { session_id: appId, turn_id: "turn_2" }); + + await builder().respond({ + kind: "approval", + waitpointId: "call_9", + approved: true, + }); + expect(scope.isDone()).toBe(true); + }); + + test("respond omits value rather than sending null, because omitting it declines", async () => { + scope + .post(`/api/v1/apps/${appId}/build/responses`, (body) => { + expect(body).toEqual({ kind: "input", waitpoint_id: "call_9" }); + return true; + }) + .reply(202, { session_id: appId, turn_id: "turn_3" }); + + await builder().respond({ kind: "input", waitpointId: "call_9" }); + expect(scope.isDone()).toBe(true); + }); + + test("cancel resolves once the stop has landed", async () => { + scope + .post(`/api/v1/apps/${appId}/build/cancel`) + .reply(200, { session_id: appId, state: "ready" }); + + await expect(builder().cancel()).resolves.toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + test("createGrant returns an absolute events URL, so it is usable as handed over", async () => { + scope + .post(`/api/v1/apps/${appId}/build/grants`, { token_ttl_seconds: 900 }) + .reply(201, { + session_id: appId, + grant_id: "g_1", + token: "grant-token", + expires_in: 900, + expires_at: "2026-08-30T12:00:00Z", + events_url: `/api/v1/apps/${appId}/build/events`, + }); + + const grant = await builder().createGrant({ ttlSeconds: 900 }); + + expect(grant).toEqual({ + sessionId: appId, + grantId: "g_1", + token: "grant-token", + expiresIn: 900, + expiresAt: "2026-08-30T12:00:00Z", + eventsUrl: `${serverUrl}/api/v1/apps/${appId}/build/events`, + }); + }); + + test("revokeGrant escapes a caller-supplied id into the path", async () => { + scope + .delete(`/api/v1/apps/${appId}/build/grants/a%2Fb`) + .reply(204); + + await builder().revokeGrant("a/b"); + expect(scope.isDone()).toBe(true); + }); + }); + + describe("reads", () => { + test("getState projects a waitpoint into the public vocabulary", async () => { + scope.get(`/api/v1/apps/${appId}/build/state`).reply(200, { + session_id: appId, + app_id: appId, + state: { + status: "waiting", + turn_id: "turn_1", + waiting_on: { + kind: "choice", + waitpoint_id: "call_9", + tool_name: "ask_user", + }, + }, + }); + + await expect(builder().getState()).resolves.toEqual({ + status: "waiting", + turnId: "turn_1", + waitingOn: { + kind: "choice", + waitpointId: "call_9", + toolName: "ask_user", + }, + }); + }); + + test("out of credits reads as blocked with a reason, not as a waitpoint", async () => { + scope.get(`/api/v1/apps/${appId}/build/state`).reply(200, { + session_id: appId, + state: { status: "blocked", reason: "quota", turn_id: "turn_1" }, + }); + + const state = await builder().getState(); + expect(state).toEqual({ + status: "blocked", + reason: "quota", + turnId: "turn_1", + }); + expect(state.waitingOn).toBeUndefined(); + }); + + test("listMessages projects tool calls and hands back the cursor", async () => { + scope + .get(`/api/v1/apps/${appId}/build/messages`) + .query({ limit: 2 }) + .reply(200, { + session_id: appId, + messages: [ + { + message_id: "m_1", + role: "user", + content: "build me a shop", + tool_calls: [], + }, + { + message_id: "m_2", + role: "assistant", + content: "on it", + tool_calls: [ + { + id: "call_9", + name: "ask_user", + status: "waiting_for_user_input", + requires_user_input: true, + waiting_on_kind: "choice", + arguments: '{"questions":[', + display: { title: "Pick one" }, + }, + ], + }, + ], + next_after: "cursor-1", + }); + + const page = await builder().listMessages({ limit: 2 }); + + expect(page.nextAfter).toBe("cursor-1"); + expect(page.messages[1]).toEqual({ + messageId: "m_2", + role: "assistant", + content: "on it", + toolCalls: [ + { + id: "call_9", + name: "ask_user", + status: "waiting_for_user_input", + requiresUserInput: true, + waitingOnKind: "choice", + // Left as the raw string: mid-generation arguments are routinely + // incomplete JSON, and parsing here would throw on exactly the ticks + // a partner needs. + arguments: '{"questions":[', + display: { title: "Pick one" }, + }, + ], + }); + }); + + test("getTurn reports whether the turn is still live", async () => { + scope.get(`/api/v1/apps/${appId}/build/turns/turn_1`).reply(200, { + session_id: appId, + turn_id: "turn_1", + live: false, + state: { status: "idle", turn_id: "turn_1" }, + }); + + await expect(builder().getTurn("turn_1")).resolves.toEqual({ + turnId: "turn_1", + live: false, + state: { status: "idle", turnId: "turn_1" }, + }); + }); + }); + + describe("streaming", () => { + test("subscribe delivers typed events and ignores keepalives", async () => { + const stream = openStream(); + stubStream(stream.response); + const seen = collector(2); + + const unsubscribe = builder().subscribe(seen.onEvent); + stream.push(": keepalive\n\n"); + stream.push( + frame("message.updated", { + turn_id: "turn_1", + data: { message_id: "m_1", role: "assistant", content: "hi", tool_calls: [] }, + }, "10") + ); + stream.push( + frame("turn.finished", { + turn_id: "turn_1", + data: { status: "idle", turn_id: "turn_1" }, + }, "11") + ); + await seen.reached; + unsubscribe(); + + expect(seen.events).toEqual([ + { + seq: "10", + turnId: "turn_1", + type: "message.updated", + data: { messageId: "m_1", role: "assistant", content: "hi", toolCalls: [] }, + }, + { + seq: "11", + turnId: "turn_1", + type: "turn.finished", + data: { status: "idle", turnId: "turn_1" }, + }, + ]); + }); + + test("an unrecognised event type is dropped rather than passed through", async () => { + const stream = openStream(); + stubStream(stream.response); + const seen = collector(1); + + const unsubscribe = builder().subscribe(seen.onEvent); + stream.push(frame("build.teleported", { data: { anything: true } }, "20")); + stream.push( + frame("state.changed", { data: { status: "running", turn_id: "turn_1" } }, "21") + ); + await seen.reached; + unsubscribe(); + + expect(seen.events).toHaveLength(1); + expect(seen.events[0].type).toBe("state.changed"); + }); + + test("a dropped connection resumes from the last event rather than replaying everything", async () => { + const first = openStream(); + const second = openStream(); + stubStream(first.response, second.response); + const seen = collector(2); + + const unsubscribe = builder().subscribe(seen.onEvent); + first.push( + frame("state.changed", { data: { status: "running", turn_id: "turn_1" } }, "30") + ); + // The body ends without an error, which is what a drained deploy looks + // like — a reconnect, not a completion. + await new Promise((resolve) => setTimeout(resolve, 50)); + first.close(); + second.push( + frame("turn.finished", { data: { status: "idle", turn_id: "turn_1" } }, "31") + ); + await seen.reached; + unsubscribe(); + + expect(fetches).toHaveLength(2); + const resumed = fetches[1].init.headers as Record; + expect(resumed["Last-Event-ID"]).toBe("30"); + expect((fetches[0].init.headers as Record)["Last-Event-ID"]).toBeUndefined(); + }); + + test("a rejected credential stops the subscription instead of hammering the server", async () => { + stubStream( + new Response(JSON.stringify({ detail: "This grant has been revoked" }), { + status: 403, + }) + ); + let failure: Error | undefined; + + builder().subscribe(() => {}, { onError: (error) => (failure = error) }); + await vi.waitFor(() => expect(failure).toBeDefined()); + + expect(failure).toMatchObject({ status: 403, message: "This grant has been revoked" }); + // One attempt. A 403 fails identically forever. + expect(fetches).toHaveLength(1); + }); + + test("unsubscribing aborts the request in flight", async () => { + const stream = openStream(); + stubStream(stream.response); + + const unsubscribe = builder().subscribe(() => {}); + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + const signal = fetches[0].init.signal as AbortSignal; + expect(signal.aborted).toBe(false); + + unsubscribe(); + unsubscribe(); // idempotent + expect(signal.aborted).toBe(true); + }); + + test("stream() iterates the same events, and leaving the loop unsubscribes", async () => { + const stream = openStream(); + stubStream(stream.response); + + const collected: string[] = []; + const iterating = (async () => { + for await (const event of builder().stream()) { + collected.push(event.type); + if (event.type === "turn.finished") break; + } + })(); + + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + stream.push(frame("turn.started", { data: { status: "running" } }, "40")); + stream.push(frame("turn.finished", { data: { status: "idle" } }, "41")); + await iterating; + + expect(collected).toEqual(["turn.started", "turn.finished"]); + expect((fetches[0].init.signal as AbortSignal).aborted).toBe(true); + }); + }); + + describe("streamText", () => { + const snapshot = (content: string, seq: string, messageId = "m_1") => + frame( + "message.updated", + { data: { message_id: messageId, role: "assistant", content, tool_calls: [] } }, + seq + ); + + test("yields what each snapshot added, not the snapshot", async () => { + const stream = openStream(); + stubStream(stream.response); + + const chunks: string[] = []; + const reading = (async () => { + for await (const text of builder().streamText()) chunks.push(text); + })(); + + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + stream.push(snapshot("Building", "50")); + stream.push(snapshot("Building the", "51")); + stream.push(snapshot("Building the footer.", "52")); + stream.push(frame("turn.finished", { data: { status: "idle" } }, "53")); + await reading; + + expect(chunks).toEqual(["Building", " the", " footer."]); + expect(chunks.join("")).toBe("Building the footer."); + }); + + test("a rewritten message starts a new paragraph rather than patching history", async () => { + const stream = openStream(); + stubStream(stream.response); + + const chunks: string[] = []; + const reading = (async () => { + for await (const text of builder().streamText()) chunks.push(text); + })(); + + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + stream.push(snapshot("Adding a footer", "60")); + // Not an extension: the message was retried, and a chat surface has + // already printed what came before. + stream.push(snapshot("Let me try that again.", "61")); + stream.push(frame("turn.finished", { data: { status: "idle" } }, "62")); + await reading; + + expect(chunks).toEqual(["Adding a footer", "\n\nLet me try that again."]); + }); + + test("ends when the build runs out of credits, which never sends turn.finished", async () => { + const stream = openStream(); + stubStream(stream.response); + + const chunks: string[] = []; + const reading = (async () => { + for await (const text of builder().streamText()) chunks.push(text); + })(); + + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + stream.push(snapshot("Starting", "70")); + stream.push( + frame("state.changed", { data: { status: "blocked", reason: "quota" } }, "71") + ); + await reading; + + expect(chunks).toEqual(["Starting"]); + }); + }); + + describe("waitForTurn", () => { + test("resolves on the turn's own finish, ignoring another turn's", async () => { + const stream = openStream(); + stubStream(stream.response); + scope + .get(`/api/v1/apps/${appId}/build/turns/turn_2`) + .reply(404, { detail: "No such turn in the retained window" }); + + const waiting = builder().waitForTurn("turn_2"); + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + + stream.push( + frame("turn.finished", { turn_id: "turn_1", data: { status: "idle", turn_id: "turn_1" } }, "80") + ); + stream.push( + frame("turn.finished", { turn_id: "turn_2", data: { status: "idle", turn_id: "turn_2" } }, "81") + ); + + await expect(waiting).resolves.toEqual({ status: "idle", turnId: "turn_2" }); + expect((fetches[0].init.signal as AbortSignal).aborted).toBe(true); + }); + + test("resolves for a turn that already finished before anyone waited on it", async () => { + const stream = openStream(); + stubStream(stream.response); + scope.get(`/api/v1/apps/${appId}/build/turns/turn_1`).reply(200, { + turn_id: "turn_1", + live: false, + state: { status: "idle", turn_id: "turn_1" }, + }); + + await expect(builder().waitForTurn("turn_1")).resolves.toEqual({ + status: "idle", + turnId: "turn_1", + }); + }); + + test("resolves on blocked, because a build out of credits is not going to finish", async () => { + const stream = openStream(); + stubStream(stream.response); + scope.get(`/api/v1/apps/${appId}/build/turns/turn_1`).reply(200, { + turn_id: "turn_1", + live: true, + state: { status: "running", turn_id: "turn_1" }, + }); + + const waiting = builder().waitForTurn("turn_1"); + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + stream.push( + frame( + "state.changed", + { turn_id: "turn_1", data: { status: "blocked", reason: "quota", turn_id: "turn_1" } }, + "90" + ) + ); + + await expect(waiting).resolves.toEqual({ + status: "blocked", + reason: "quota", + turnId: "turn_1", + }); + }); + }); + + describe("createBuilderSession — the browser half", () => { + test("carries the grant, and offers no way to spend credits with it", async () => { + const stream = openStream(); + stubStream(stream.response); + + const session = createBuilderSession({ + appId, + serverUrl, + getToken: () => "grant-token", + }); + + // The asymmetry is the design: a grant reads, and writes go through the + // partner's own server. There is nothing here to call. + expect("sendMessage" in session).toBe(false); + expect("createGrant" in session).toBe(false); + expect("cancel" in session).toBe(false); + + const unsubscribe = session.subscribe(() => {}); + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + unsubscribe(); + + const headers = fetches[0].init.headers as Record; + expect(headers.Authorization).toBe("Bearer grant-token"); + expect(fetches[0].url).toBe(`${serverUrl}/api/v1/apps/${appId}/build/events`); + }); + + test("re-reads the grant on every reconnect, so a build outliving one just works", async () => { + const first = openStream(); + const second = openStream(); + stubStream(first.response, second.response); + const grants = ["grant-1", "grant-2"]; + + const session = createBuilderSession({ + appId, + serverUrl, + getToken: () => grants.shift() ?? "grant-2", + }); + + const unsubscribe = session.subscribe(() => {}); + await vi.waitFor(() => expect(fetches).toHaveLength(1)); + first.close(); + await vi.waitFor(() => expect(fetches).toHaveLength(2), { timeout: 5_000 }); + unsubscribe(); + + expect((fetches[0].init.headers as Record).Authorization).toBe( + "Bearer grant-1" + ); + expect((fetches[1].init.headers as Record).Authorization).toBe( + "Bearer grant-2" + ); + }); + }); +});