Bootstrapping
How an empty store goes from cold start to a functional system — config seeding, first-time user registration, and bot creation.
docs/server/30-bootstrapping.mdxOn this page
By this point the building blocks are familiar individually: principals, authentication, and authorization. This page assembles them into the bootstrap lifecycle — the sequence of events between "server process starts with an empty store" and "every actor in the system has the rows it needs to do work."
Three forces drive the bootstrap:
- Config seeding. Durable system principals and grant rows that exist before any request is served.
- Implicit user registration. A new
userprincipal is created the first time it authenticates. The very first such user can also inherit system ownership when a system principal is seeded. - Explicit bot creation.
botprincipals are created on demand by an authorized caller and receive their first local token in the same response.
Read the sections below in order — each one assumes the previous step has already happened.
Step 1 — Config Seeding (Pre-Request)
launchBubbleServer (used by both the bubble CLI
and the programmatic entry point) applies seeding before binding the
listen socket, so the very first request already sees the seeded
state. The seed config section drives it:
{
seed: {
principals: BubblePrincipalSeed[], // {principalId, kind, displayName?, email?, avatarUrl?, initialToken?}[]
authz: BubbleGrantSeed[], // {target, principalId, role}[]
threads: BubbleThreadSeed[]
}
}The launcher applies them in this order:
- Principal seed. Every entry in
seed.principalsis upserted viaBubbleStore.upsertPrincipal— re-running the same config on restart is a no-op. Each seeded principal is then grantedowneron its own principal target. Dev and fixture configs may seeduserandbotrows as well assystemrows. - Thread seed. Every entry in
seed.threadscreates an empty thread stream before grants and events are applied. - Grant seed. Every entry in
seed.authzis applied viaBubbleStore.grantRole, which is idempotent. - Thread event seed. Each thread's configured event log is appended after grants exist.
Seeded bot principals may include an initialToken object. The
launcher hashes that plaintext token into the local-token store so the
bot can authenticate immediately after startup. Because the plaintext
lives in config, use this only for local development, fixtures, or
secret-backed deployment config.
The dev config (examples/config/local-dev.json) demonstrates the typical
shape:
{
"seed": {
"principals": [{ "principalId": "root", "kind": "system", "displayName": "Development Root" }],
"authz": [{ "target": { "type": "system" }, "principalId": "root", "role": "owner" }],
"threads": []
}
}After step 1 has run on a fresh store, the grant table contains:
| target | principalId | role |
|---|---|---|
principalTarget('root') | root | owner |
systemTarget() | root | owner |
That second row is the lever. By the system inheritance
rule, root now satisfies every capability on every non-system
target — except the per-event-type events.* append capabilities,
which are non-inheritable. With a master-key authenticator that maps
incoming requests to root, a single key (held in
BUBBLE_MASTER_KEY) can drive the whole API.
Bubble has no other way to bootstrap authorization. The policy reads from the store and the store starts empty. A useful server needs at least one
seed.principalsentry plus a matchingseed.authzgrant — or a real authenticator that can register users on first login (the next step).
Step 2 — User Registration (First Authenticated Request)
Every HTTP request and WebSocket upgrade goes through this sequence:
- The dispatcher selects an authenticator and produces a
BubbleAuthContext(see Authentication). - The server checks whether
BubbleStore.getPrincipal(principalId)already returns a row. - If a row exists, registration is a no-op — the request continues with that row's profile information merged into the auth context.
- If no row exists and the auth context projects to
kind: 'user'(the default for Auth0 and master-key when nokindClaimis set), the server creates the user row and grants itowneron its own principal target. - If a row exists for any other kind (
bot,system) and thelocal-tokendriver authenticated the caller, the row is used as is. Local tokens never auto-register missing bots — a bot must be created explicitly (step 3 below).
There is no POST /v1/principals user-registration body. Users
register by authenticating, full stop.
The First-User-System-Owner Promotion
A subtle but useful piece of the registration path: after creating a
new user row, the server checks whether (a) this is the first user
row in the store and (b) at least one system principal exists. If
both are true, the user is granted owner on the system target.
In code (paraphrased from src/server/index.ts):
async function maybeGrantFirstRegisteredUserSystemOwner(store, principal) {
if (principal.kind !== 'user') return;
const principals = await store.listPrincipals();
const users = principals.filter((p) => p.kind === 'user');
if (users.length !== 1 || users[0]?.principalId !== principal.principalId) return;
const firstSystemPrincipal = principals.find((p) => p.kind === 'system');
if (!firstSystemPrincipal) return;
await store.grantRole(systemTarget(), principal.principalId, Role.Owner);
}The intent is straightforward: a fresh deployment with a seeded
system principal but no human owner can be claimed by the first
operator who logs in, without needing to mint a master key out of
band. After that user is in place, every subsequent user registers
silently with only their self-grant.
Step 3 — Bot Creation (On Demand)
bot principals are the only kind that go through an explicit
creation route. From the requirements:
- The caller must satisfy
principal.createon their own principal target. Self grants for users and system principals include this capability (their self-role isowner); bot self grants do not (their self-role isprincipal_manager). - The new principal's id must not appear in
ownerPrincipalIds— a bot cannot own its own principal target.
The route does five things in order, as a single logical transaction:
- Validates the body and capability.
- Calls
BubbleStore.createPrincipal({ principalId, kind: 'bot', displayName?, email?, avatarUrl? }). - Grants the bot
principal_manageron its own principal target — strictly narrower than the user/system self grant. A bot can manage its own profile and tokens, but cannot create more principals. - Generates a fresh
bubble_agent_…plaintext token, hashes it, and stores the hash viaBubbleStore.createPrincipalToken. The plaintext is returned in the response once and never persisted. - Grants
owneron the bot's principal target to the caller and to each entry inownerPrincipalIds(the caller is always implicitly added).
Worked example. Suppose user-alice is the first registered user
(she therefore holds owner on systemTarget() and on
principalTarget('user-alice')):
curl -X POST http://localhost:3000/v1/principals \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"principalId": "bot-research",
"kind": "bot",
"displayName": "Research Agent",
"ownerPrincipalIds": []
}'After the call returns successfully, the grant table contains the new rows:
| target | principalId | role |
|---|---|---|
principalTarget('bot-research') | bot-research | principal_manager |
principalTarget('bot-research') | user-alice | owner |
…and the response body holds the only copy of the plaintext token the caller will ever receive (see Principals → Bot Principals and Local Tokens).
Putting It All Together
A complete first-launch story:
- The operator boots
bubble --config ./examples/config/local-dev.json. Step 1 seeds therootsystem principal and the(system, root, owner)grant before the listener opens. - The operator hits
whoamiwith the master key. The dispatcher'smaster-keydriver resolves the request toroot. Step 2 findsrootalready exists and skips user registration. - A real human operator authenticates via Auth0 for the first time.
The dispatcher's
auth0driver resolves the request touser-alice. Step 2 creates the row, grants(principalTarget('user-alice'), user-alice, owner), and — because this is the first user androotexists — also grants(systemTarget(), user-alice, owner). Alice can now drive the whole API without the master key. - Alice creates
bot-researchthroughPOST /v1/principals. Step 3 creates the bot, mints its first local token, and grants AliceowneronprincipalTarget('bot-research'). Alice hands the plaintext token to the agent process. The agent's requests subsequently dispatch through thelocal-tokendriver.
The system is now fully bootstrapped: a system root for break-glass operations, a human owner for day-to-day administration, and an agent identity that can read and write threads it has been added to.
Where to go from here:
- HTTP Protocol — the REST surface every step above uses.
- WebSocket Protocol — the live frame contract for subscriptions and fire-and-forget appends.