Bootstrapping

How an empty store goes from cold start to a functional system — config seeding, first-time user registration, and bot creation.

Running a serverdocs/server/30-bootstrapping.mdx
On 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:

  1. Config seeding. Durable system principals and grant rows that exist before any request is served.
  2. Implicit user registration. A new user principal is created the first time it authenticates. The very first such user can also inherit system ownership when a system principal is seeded.
  3. Explicit bot creation. bot principals 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:

ts
{
  seed: {
    principals: BubblePrincipalSeed[], // {principalId, kind, displayName?, email?, avatarUrl?, initialToken?}[]
    authz: BubbleGrantSeed[],          // {target, principalId, role}[]
    threads: BubbleThreadSeed[]
  }
}

The launcher applies them in this order:

  1. Principal seed. Every entry in seed.principals is upserted via BubbleStore.upsertPrincipal — re-running the same config on restart is a no-op. Each seeded principal is then granted owner on its own principal target. Dev and fixture configs may seed user and bot rows as well as system rows.
  2. Thread seed. Every entry in seed.threads creates an empty thread stream before grants and events are applied.
  3. Grant seed. Every entry in seed.authz is applied via BubbleStore.grantRole, which is idempotent.
  4. 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:

json
{
  "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:

targetprincipalIdrole
principalTarget('root')rootowner
systemTarget()rootowner

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.principals entry plus a matching seed.authz grant — 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:

  1. The dispatcher selects an authenticator and produces a BubbleAuthContext (see Authentication).
  2. The server checks whether BubbleStore.getPrincipal(principalId) already returns a row.
  3. If a row exists, registration is a no-op — the request continues with that row's profile information merged into the auth context.
  4. If no row exists and the auth context projects to kind: 'user' (the default for Auth0 and master-key when no kindClaim is set), the server creates the user row and grants it owner on its own principal target.
  5. If a row exists for any other kind (bot, system) and the local-token driver 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):

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.create on their own principal target. Self grants for users and system principals include this capability (their self-role is owner); bot self grants do not (their self-role is principal_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:

  1. Validates the body and capability.
  2. Calls BubbleStore.createPrincipal({ principalId, kind: 'bot', displayName?, email?, avatarUrl? }).
  3. Grants the bot principal_manager on 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.
  4. Generates a fresh bubble_agent_… plaintext token, hashes it, and stores the hash via BubbleStore.createPrincipalToken. The plaintext is returned in the response once and never persisted.
  5. Grants owner on the bot's principal target to the caller and to each entry in ownerPrincipalIds (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')):

bash
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:

targetprincipalIdrole
principalTarget('bot-research')bot-researchprincipal_manager
principalTarget('bot-research')user-aliceowner

…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:

  1. The operator boots bubble --config ./examples/config/local-dev.json. Step 1 seeds the root system principal and the (system, root, owner) grant before the listener opens.
  2. The operator hits whoami with the master key. The dispatcher's master-key driver resolves the request to root. Step 2 finds root already exists and skips user registration.
  3. A real human operator authenticates via Auth0 for the first time. The dispatcher's auth0 driver resolves the request to user-alice. Step 2 creates the row, grants (principalTarget('user-alice'), user-alice, owner), and — because this is the first user and root exists — also grants (systemTarget(), user-alice, owner). Alice can now drive the whole API without the master key.
  4. Alice creates bot-research through POST /v1/principals. Step 3 creates the bot, mints its first local token, and grants Alice owner on principalTarget('bot-research'). Alice hands the plaintext token to the agent process. The agent's requests subsequently dispatch through the local-token driver.

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: