feat(platforms): build Base44 apps for your own users - #265
Draft
ozsay wants to merge 2 commits into
Draft
Conversation
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.
🚀 Package Preview Available!Install this PR's preview build with npm: npm i @base44-preview/sdk@0.8.44-pr.265.a1a63b2Prefer not to change any import paths? Install using npm alias so your code still imports npm i "@base44/sdk@npm:@base44-preview/sdk@0.8.44-pr.265.a1a63b2"Or add it to your {
"dependencies": {
"@base44/sdk": "npm:@base44-preview/sdk@0.8.44-pr.265.a1a63b2"
}
}
Preview published to npm registry — try new features instantly! |
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
force-pushed
the
feat/platforms-identity
branch
from
August 30, 2026 10:21
15601f3 to
890a2b5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
And the app the principal built is reachable through the rest of the SDK as that principal:
Surface
createPlatformClient(config)platforms.provisionPrincipal({ externalId, displayName?, role? })platforms.deprovisionPrincipal(externalId)asPrincipal(externalId)↳ builder(appId)↳ forApp(appId)Base44Clientfor one app, as that principal↳ getToken()/revokeToken()sendMessage·respond·cancel·createGrant·revokeGrantgetState·listMessages·getTurn·waitForTurn·subscribe·stream·streamTextcreateBuilderSession({ appId, getToken })Two keys, and they stay apart
mintKeyis the hot-path credential;provisionKeycreates and removes principals. They get separate Axios clients, and the provision one is reachable only fromplatforms.*, 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/tokenand/oauth/revokeauthenticate 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:
A leaked browser credential cannot spend the workspace's credits because there is nothing on it that spends.
getTokenis 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, notEventSource.EventSourcecannot 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 withfetchkeeps the credential in anAuthorizationheader, 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, whereEventSourcedoes 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 lastseq, 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.updatedis 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, sostreamTextyields 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
BuilderEventbe a closed union that narrows ontyperather than an open one whosedataisunknownin 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 thesvc_delegaterefresh/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 bybuilder(appId)andplatformsstays what it was: workspace-level identity. The vocabulary — event types,waitingOn.kind, the snapshot semantics — is the doc's unchanged.waitForTurnis 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 onblockedas well asidleanderror— 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
provisionPrincipalis 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/cancelanswers withstate: "ready" | "processing"— the builder's internal status vocabulary, which the rest of this surface deliberately renames. The settled state arrives on the stream, or fromgetState(), 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
forAppcalledsetTokenon every call.setTokenis an identity change — it discards the in-flightme()other callers are awaiting and resets the analytics session — so withforAppdocumented as cheap-per-request, it quietly undid the client's own request de-duplication. It now applies only on an actual rotation.Verification
tscbuild clean, type tests clean,eslintcleantypedocruns 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
valueomitted rather than nulled, because omitting it is how you decline), credential separation, path escaping, SSE frame parsing, keepalives, unknown-type drops, resume-from-seqafter a dropped connection, a rejected credential not being retried, unsubscribe aborting the request in flight, and each snapshot-to-append rule.Not in this PR
Webhooks (§2.2's
registerWebhook/verifyWebhook), excluded by request — andNOT BUILTserver-side, so there is nothing to wrap yet.The chat adapter (§2.3's
createBuilderBot,@base44/sdk/chat). It needs thechatpackage 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, andstreamText()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/ticketsand the deprecatedoffsetpaging on/build/messages. The ticket exists for rawEventSource, which this SDK does not use; wrapping it would publish an endpoint the SDK's own guidance says to skip.