Skip to content

feat(platforms): build Base44 apps for your own users - #265

Draft
ozsay wants to merge 2 commits into
mainfrom
feat/platforms-identity
Draft

feat(platforms): build Base44 apps for your own users#265
ozsay wants to merge 2 commits into
mainfrom
feat/platforms-identity

Conversation

@ozsay

@ozsay ozsay commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Adds the platforms surface to @base44/sdk: a workspace-scoped client, a service principal per end user, and the partner-facing builder those principals drive.

Design doc: https://bo.wix.com/stash/b44-builder-as-a-service/ — §2, minus the webhook leg.

The problem

A platform that builds Base44 apps for its own users has two gaps. It has nobody to act as — its users have no Base44 account and should not need one, yet the apps they build have to belong to them or every app in the workspace is reachable by every user of the platform. And it has no way to drive a build: the existing chat endpoint answers only when the whole turn is done, minutes later, which a serverless partner cannot hold open and a partner UI cannot show progress from.

The whole thing

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
});

// Idempotent per (workspace, externalId) — call it on every request rather
// than tracking which of your users you have provisioned.
await base44.platforms.provisionPrincipal({ externalId: "user_42", displayName: "Dana" });

const builder = base44.asPrincipal("user_42").builder(appId);

// Returns as soon as the turn is accepted. A build takes minutes.
const { turnId } = await builder.sendMessage("add a footer");
const outcome = await builder.waitForTurn(turnId);

if (outcome.waitingOn) {
  await builder.respond({ ...outcome.waitingOn, approved: true });
}

And the app the principal built is reachable through the rest of the SDK as that principal:

const app = await base44.asPrincipal("user_42").forApp(appId);
await app.entities.Todo.list();     // as Dana, not as the workspace

Surface

createPlatformClient(config) Workspace-scoped client. Server-side only — it holds workspace keys
platforms.provisionPrincipal({ externalId, displayName?, role? }) Create or return a principal. Idempotent
platforms.deprovisionPrincipal(externalId) Offboard. Cuts live tokens off immediately
asPrincipal(externalId) A view of Base44 acting as that user
↳ builder(appId) The builder session for one app — reads and writes
↳ forApp(appId) A Base44Client for one app, as that principal
↳ getToken() / revokeToken() The raw token, for requests the SDK does not make for you
writes sendMessage · respond · cancel · createGrant · revokeGrant
reads getState · listMessages · getTurn · waitForTurn · subscribe · stream · streamText
createBuilderSession({ appId, getToken }) The browser's entry point. Returns the read half and nothing else

Two keys, and they stay apart

mintKey is the hot-path credential; provisionKey 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 the point rather than tidiness: with a provision-capable key on the hot path, a deprovisioned user is re-provisioned by the next request that mentions them — quietly undoing the offboarding.

A third client carries no credential at all. /oauth/token and /oauth/revoke authenticate the refresh token, not the caller, so presenting a workspace key there would send a long-lived secret somewhere that neither wants nor checks it. A fourth is the same story for builder calls, which ride the principal's rotating token rather than any static one.

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 and renewed through the refresh grant; concurrent callers for the same principal share one in-flight mint; and the refresh skew is clamped to half the lifetime, since an unclamped skew larger than the lifetime marks every token stale on arrival and turns the cache into a mint-per-request loop against that same budget.

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 → Base44 directly, keeping an open stream off a serverless function path; writes go browser → your server → Base44.

So there are two shapes rather than one shape with a comment:

// Your server — holds a credential that can start turns.
const grant = await base44.asPrincipal(userId).builder(appId).createGrant({ ttlSeconds: 900 });

// The browser — holds a grant. There is no sendMessage on this object.
const builder = createBuilderSession({ appId, getToken: () => fetchGrant(appId) });
const unsubscribe = builder.subscribe((event) => {  });

A leaked browser credential cannot spend the workspace's credits because there is nothing on it that spends. getToken is a getter rather than a string in both places: 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 — the habit the actors module already has internally, promoted to the public config.

The stream

The SDK's first SSE transport, and the one place §2 says to design rather than copy — both existing realtime modules are WebSocket.

Read with fetch, not EventSource. EventSource cannot set request headers, which is why the API also accepts a single-use ticket in the query string. That is 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 nothing 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, written once here instead of once per consumer:

  • subscribe(cb) returns an unsubscribe, matching every other realtime call in the package. It reconnects with jittered backoff and resumes from the last seq, so a deploy on either side costs no state. It retries only what a retry can fix — a 403 stops and reports rather than hammering.
  • stream() is the same events as an async iterable, for code shaped as a loop. Leaving the loop unsubscribes.
  • streamText() is the assistant's prose as text to append.

That last one earns its place 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 an attempt to patch history.

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 rather than an open one whose data is unknown in every branch.

Notes for review

Two deviations from the doc, both deliberate.

§2.0 is marked NOT DECIDED and sketches identity provisionally. That decision has since landed, so this implements the real endpoints — POST /api/service/users, DELETE /api/service/users/{id}, POST /api/service/user-tokens, and the svc_delegate refresh/revoke grants — rather than the sketch. The doc's seam held: the factory signature is as proposed.

§2.1–2.3 sketch the build calls as platforms.sendBuildMessage(appId, …). Every route in the family is app-scoped (/api/v1/apps/{id}/build/*) 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. The vocabulary — event types, waitingOn.kind, the snapshot semantics — is the doc's unchanged.

waitForTurn is the answer webhooks would otherwise give. With §2.2's webhook leg out, a long-lived worker needs some way to await a turn that a 202 started. 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.

Mint never auto-provisions. An unknown principal is a 404, and that is load-bearing rather than an inconvenience: it is what makes a deprovision stick, and it is why provisionPrincipal is idempotent and meant to be called on every request.

Idempotency keys are never invented. A turn costs real credits, so supplying one is what makes a retry safe — but a key the SDK generated would differ on the retry and protect nothing. Without one the server names the turn, and you keep followability while losing retry safety.

cancel() returns nothing, rather than the endpoint's body. POST /build/cancel answers with state: "ready" | "processing" — the builder's internal status vocabulary, which the rest of this surface deliberately renames. The settled state arrives on the stream, or from getState(), in the public one. Worth fixing on the API side; wrapping it as-is would publish the internal names.

A defect caught in self-review, with a test. The first cut of forApp called setToken on every call. setToken is an identity change — it discards the in-flight me() other callers are awaiting and resets the analytics session — so with forApp documented as cheap-per-request, it quietly undid the client's own request de-duplication. It now applies only on an actual rotation.

Verification

  • 272 tests pass (49 new), tsc build clean, type tests clean, eslint clean
  • typedoc runs with 0 errors; its 9 warnings are all pre-existing (entities / actors / integrations)

The build tests cover the wire projections in both directions, the write bodies (including value omitted rather than nulled, because omitting it is how you decline), credential separation, path escaping, SSE frame parsing, keepalives, unknown-type drops, resume-from-seq after a dropped connection, a rejected credential not being retried, unsubscribe aborting the request in flight, and each snapshot-to-append rule.

Unrelated, but worth knowing: node_modules in the main checkout is missing partysocket, a declared dependency, so the suite fails at import there before any test runs. Stale install, not caused by this branch — npm ci fixes it.

Not in this PR

Webhooks (§2.2's registerWebhook / verifyWebhook), excluded by request — and NOT BUILT server-side, so there is nothing to wrap yet.

The chat adapter (§2.3's createBuilderBot, @base44/sdk/chat). It needs the chat package as a dependency, which is not something to add without asking. Its two genuinely hard parts are here already, because the doc is right that they belong in the module rather than in each adapter: stream() is the iterable form, and streamText() is the snapshot-to-append conversion. What is left is glue — platform adapters, thread state, and waitpoint cards.

createApp / deployApp, which the adapter sketch calls. Those belong to the apps surface rather than the build one, and the build API does not define them.

POST /build/tickets and the deprecated offset paging on /build/messages. The ticket exists for raw EventSource, which this SDK does not use; wrapping it would publish an endpoint the SDK's own guidance says to skip.

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.
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/sdk@0.8.44-pr.265.a1a63b2

Prefer not to change any import paths? Install using npm alias so your code still imports @base44/sdk:

npm i "@base44/sdk@npm:@base44-preview/sdk@0.8.44-pr.265.a1a63b2"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "@base44/sdk": "npm:@base44-preview/sdk@0.8.44-pr.265.a1a63b2"
  }
}

Preview published to npm registry — try new features instantly!

@github-actions github-actions Bot added the docs-draft PR has auto-drafted documentation suggestions label Aug 30, 2026
@ozsay ozsay changed the title feat(platforms): act as your own users, through service principals feat(platforms): build Base44 apps for your own users Aug 30, 2026
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.
@ozsay
ozsay force-pushed the feat/platforms-identity branch from 15601f3 to 890a2b5 Compare August 30, 2026 10:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-draft PR has auto-drafted documentation suggestions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant