From 1ee3c16317106ee7bfd74cb49b082e53f47f7ab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:12:03 +0000 Subject: [PATCH] docs(self-hosting): name the measured recovery for a directory-seeded deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page told an operator on a walled posture to "have each of those people register with exactly that address". Measured on a real kernel (three human `sys_user` rows, zero `sys_account` rows, outside development, default `invite_only` audience posture), that registration is refused `403 SELF_REGISTRATION_CLOSED`: the first-account carve-out counts humans, not logins, and humans already exist. So the published recovery path was the one that is shut, and the deployment cannot be recovered from inside. Both first-boot paths now say what "fresh" means, and a new subsection states the dead end, points at the boot diagnostic (`no_sign_in_account_at_boot`), and names the measured way out: write one pending `sys_invitation` row against the store for an address the directory does not already hold, then have that person register normally — measured sign-up 200 and sign-in 200 with no mail transport, and on `single` the next boot promotes that account holder (`adminPromoted: true`) rather than a credential-less directory row. Two plausible-looking remedies are documented as measured failures: opening the audience posture answers 200 while persisting nothing for an address the directory already holds, and forces email verification on every other one; and a hand-written `sys_account` row with a plaintext password authenticates nothing while silencing the boot diagnostic. The dev-admin seed is described as development-only, as it is gated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- content/docs/deployment/self-hosting.mdx | 102 +++++++++++++++++++++-- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index ea4d8daf62..88e19ea799 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -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 @@ -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: @@ -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