Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 94 additions & 8 deletions content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -418,12 +418,15 @@ How the first administrator is created depends on the deployment's
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.
database there are no users yet — *fresh* meaning zero human `sys_user` rows,
not merely zero logins ([why that distinction decides
this](#a-seeded-directory-opens-no-bootstrap-window)). Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after the
first deploy, before sharing the URL; then create your real user accounts and
lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
Expand All @@ -439,7 +442,11 @@ OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.
completes — there is nothing to grant and nothing to click. That registration
is admitted by the first-account carve-out, so it works **only while the
deployment's user directory is still empty**; on a database that already
carries human rows it is refused, and the recovery is
[out of band](#a-seeded-directory-opens-no-bootstrap-window).

The rules below are enforced by the runtime, not advisory:

Expand Down Expand Up @@ -484,7 +491,86 @@ health surfaces; see

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
on empty development databases.
on empty development databases. That seed is hard-gated to
`NODE_ENV=development`: it is a development convenience, never a production
recovery path.

### A seeded directory opens no bootstrap window

Both paths above assume the deployment's **first login** is still ahead of it.
An app that declares people in `defineStack({ data })` — or any provisioning
job that writes `sys_user` rows — creates a **directory, not logins**: those
rows carry no `sys_account`, and the platform counts them as a populated
deployment. Measured on a real kernel with three human `sys_user` rows, zero
`sys_account` rows, outside development, under the default `invite_only`
audience posture:

| Operator action | Measured outcome |
|:---|:---|
| A seeded person registers with exactly their declared address | `403 SELF_REGISTRATION_CLOSED` |
| Anyone else registers | `403 SELF_REGISTRATION_CLOSED` |
| A seeded person signs in | `401 INVALID_EMAIL_OR_PASSWORD` |

The first-account carve-out counts **humans, not logins** — humans already
exist, so it does not fire, and the door stays shut by decision rather than by
oversight. No administrator exists to send an invitation. The deployment
cannot be recovered from inside, and it keeps looking healthy: its only symptom
is a 401 on credentials nobody holds.

**The runtime says so at boot.** When human `sys_user` rows exist and no
`sys_account` row does, `kernel:ready` reports at **error** level under the
grep token `no_sign_in_account_at_boot`, naming both the consequence and the
remedy. If you are handed a deployment nobody can sign in to, that line is the
first thing to look for — and note that it asks only whether *an* account row
exists, so a row you wrote yourself silences it whether or not anyone can
actually authenticate with it.

**The recovery is out of band, and it is one row.** Write a pending invitation
directly against the store the deployment reads, then have that person register
through the ordinary sign-up endpoint — the invitation carve-out admits that
one creation under every posture, so the `invite_only` default stays where it
is. One `sys_invitation` row:

| Column | Value |
|:---|:---|
| `email` | the address you intend to administer — one the directory does **not** already hold |
| `status` | `pending` |
| `expires_at` | any future timestamp |
| `inviter_id` | the `id` of any existing `sys_user` row |

Measured on that population: sign-up `200` and sign-in `200` for the invited
address, with no mail transport wired. On the `single` posture the next boot
then promotes that account — measured `adminPromoted: true`, with the grant
landing on the invited **account holder** and never on a credential-less
directory row. On a walled posture the invited address must also be declared in
`OS_PLATFORM_OWNER_EMAIL` **and** read verified: the operator-provisioning
verified stamp fires only for a bootstrap or admin/SCIM creation, never for an
invitation-admitted one, so that address needs a mailbox it can receive the
verification at.

**Invite an address the directory does not already hold.** A sign-up for an
address that already carries a `sys_user` row is refused
`422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`, invitation or not — the seeded
people get their logins from the recovered administrator afterwards, not from
this step.

**Two remedies that look like they work, measured:**

- **Opening the audience posture is not enough on its own.** With the posture
widened to `email_domain`, a seeded person's own registration answers `200`
and persists *nothing* — no new row, no account, and their sign-in is still
`401`. A fresh address does get an account, but every posture other than
`invite_only` forces email verification on, so its first sign-in is refused
`403 EMAIL_NOT_VERIFIED` until a mail transport delivers the link.
- **A hand-written credential row authenticates nothing.** The `sys_account`
row shape is public; the format of the secret stored in its `password`
column is the platform's own. A row carrying a plaintext password is refused
`401 INVALID_EMAIL_OR_PASSWORD` — while silencing the boot diagnostic above.

**The cheap prevention:** have whatever seeds the people directory seed their
logins too, or leave the directory empty until the first administrator exists.
A deployment that boots with zero human rows is the case both paths above
describe, and it needs none of this.

## Go-live

Expand Down
Loading