diff --git a/.changeset/auth-mount-ledger-and-docs.md b/.changeset/auth-mount-ledger-and-docs.md
new file mode 100644
index 0000000000..7bb37b08f6
--- /dev/null
+++ b/.changeset/auth-mount-ledger-and-docs.md
@@ -0,0 +1,13 @@
+---
+"@objectstack/plugin-auth": patch
+---
+
+Ledger and document the ObjectStack-owned auth mounts that were in neither the route ledger nor the docs (#10534).
+
+`auth-plugin.ts` mounts 17 routes directly on the raw Hono app ahead of the better-auth catch-all. A census found **nine** of them in neither half of `auth-route-ledger.ts`, and **six** with no literal wire path anywhere in the hand-written docs — the state that let a mount and its documentation gap ship separately with nothing objecting.
+
+**Ledger:** eight mounts gain reviewed `source: 'objectstack'` rows — `/admin/import-users`, `/admin/oauth2/toggle-disabled`, `/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification`, `/admin/sso/verify-domain`, `/admin/unlock-user`, `/sys-oauth-application/register`. All are `disposition: 'server-only'`: each was measured to have zero `ObjectStackClient` callers and exactly one real caller that is a declarative metadata action target or a Console wizard. `POST /api/v1/auth/set-initial-password` is deliberately left unledgered and escalated rather than given a guessed disposition.
+
+**Docs:** `GET /api/v1/auth/bootstrap-status`, `POST /api/v1/auth/set-initial-password`, `POST /api/v1/auth/admin/unban-user`, `POST /api/v1/auth/admin/sso/register`, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` are now documented with their literal wire paths, including the opt-in `OS_SSO_DOMAIN_VERIFICATION` domain-verification flow and the asymmetric way its two halves report the switch being off.
+
+No route's mounting, behaviour or accept/reject set changes.
diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx
index a7aa962e34..a414bdd37a 100644
--- a/content/docs/permissions/authentication.mdx
+++ b/content/docs/permissions/authentication.mdx
@@ -245,6 +245,33 @@ const session = await client.auth.me();
console.log('Current user:', session.data.user);
```
+### First-run bootstrap status
+
+`GET /api/v1/auth/bootstrap-status` answers one question — does this environment
+have any user yet?
+
+```typescript
+const res = await fetch('http://localhost:3000/api/v1/auth/bootstrap-status');
+const { hasOwner } = await res.json(); // → { "hasOwner": true }
+```
+
+It is **public and unauthenticated** by design: a client has to be able to ask it
+*before* anyone has credentials. That is also why it returns nothing but the
+boolean — it is a routing signal, not an information endpoint. The Console's root
+route uses it to choose between `/login` (normal) and `/setup` (first-run owner
+creation); `client.auth.bootstrapStatus()` is the SDK method that builds it.
+
+It **fails open**: if no data engine is wired, or the count query throws, it
+answers `{ "hasOwner": true }` so a client falls through to the ordinary login
+flow rather than offering to create an owner on an environment that may already
+have one.
+
+
+This route is unauthenticated, so treat `hasOwner: false` as a hint to render a
+setup screen — never as authorization. First-run owner creation is enforced
+server-side by the sign-up path, not by this probe.
+
+
### Password Management
#### Request Password Reset
@@ -275,6 +302,35 @@ const response = await fetch('http://localhost:3000/api/v1/auth/reset-password',
});
```
+#### Setting a first local password
+
+`POST /api/v1/auth/set-initial-password` sets an **initial** local password for a
+signed-in user who has no credential account yet — the account was onboarded
+through SSO, through the cloud OAuth provider, or imported with
+`passwordPolicy: 'none'`. It gives that user an email/password way in to this
+environment without the SSO round-trip.
+
+```typescript
+const response = await fetch('http://localhost:3000/api/v1/auth/set-initial-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include', // a valid session identifies WHO is asking
+ body: JSON.stringify({ newPassword: 'firstLocalPassword123' })
+});
+```
+
+Two refusals are deliberate and are what separate this from a password reset:
+
+- **No session → refused.** The route never takes a user id from the body; the
+ session is the only thing that says whose password is being set.
+- **A credential already exists → refused.** Use
+ `POST /api/v1/auth/change-password` in that case, so the *current* password is
+ verified first. This endpoint is only for the no-password-yet state, which is
+ why it can accept a new password without one.
+
+The Console reaches it from **Profile → Password** when `hasLocalPassword()`
+reports no credential.
+
### Email Verification
#### Send Verification Email
@@ -899,7 +955,8 @@ every row through better-auth so the accounts are login-capable:
- `passwordPolicy: 'none'` — identity only: accounts are created without a
credential record. Users first sign in through a channel (phone OTP, magic
link, or a password-reset link) and the Console detects the missing password
- (`hasLocalPassword()`) and offers set-initial-password.
+ (`hasLocalPassword()`) and offers
+ [`POST /api/v1/auth/set-initial-password`](#setting-a-first-local-password).
- `mode: 'insert' | 'upsert'` with `matchBy: 'email' | 'phone'`. Upsert
updates only touch profile fields (`name`, `image`, `phone_number`, `role`)
— a re-imported file can never modify an existing user's email or reset
@@ -1002,11 +1059,13 @@ All endpoints are available under `/api/v1/auth/*`:
#### Session
- `GET /api/v1/auth/get-session` - Get current user session
+- `GET /api/v1/auth/bootstrap-status` - Public, unauthenticated first-run probe: `{ "hasOwner": boolean }`, telling a client whether this environment has any user yet ([details](#first-run-bootstrap-status))
#### Password Management
- `POST /api/v1/auth/request-password-reset` - Request password reset email
- `POST /api/v1/auth/reset-password` - Reset password with token
+- `POST /api/v1/auth/set-initial-password` - Set a **first** local password for a signed-in user who has no credential yet (SSO-onboarded accounts). Session required; refuses when a password already exists ([details](#setting-a-first-local-password))
#### Email Verification
@@ -1042,6 +1101,8 @@ All endpoints are available under `/api/v1/auth/*`:
- `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users)
- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `auto` (default, per-row invite-or-temporary) / `invite` / `temporary` / `none` password policy; ≤500 rows, dry-run supported)
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early
+- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions)
+- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in
#### Organization Membership (requires `plugins.organization`; platform-admin gated)
diff --git a/content/docs/permissions/sso.mdx b/content/docs/permissions/sso.mdx
index 5eb1fc115c..c2769438ee 100644
--- a/content/docs/permissions/sso.mdx
+++ b/content/docs/permissions/sso.mdx
@@ -125,6 +125,20 @@ packages that prefer wiring providers in code, or contributing them through
> below). The `oidcProviders` extension shown here remains the in-process path
> for framework/enterprise packages that prefer wiring providers in code.
+**Setup → SSO Providers → Register Provider** posts to the env-side bridge at
+`POST /api/v1/auth/admin/sso/register`, the OIDC counterpart of the SAML bridge
+[below](#enterprise-sso-saml-20). It takes the flat form fields of the
+[OIDC provider fields](#oidc-provider-fields) table, reshapes them for
+`@better-auth/sso`, and is gated on a **platform admin** (ADR-0068 D4) before it
+delegates — an organization owner or admin is not sufficient, because registering
+an identity provider decides how the whole environment authenticates.
+
+
+This is an ObjectStack mount, distinct from `@better-auth/sso`'s own
+`POST /api/v1/auth/sso/register`. The bridge exists so the no-code Setup form can
+post flat fields; both doors apply the platform-admin rule.
+
+
### Quick start — Okta
{/* os:check */}
@@ -212,6 +226,68 @@ const oidcProviders = [
*Either `discoveryUrl` or `authorizationUrl` + `tokenUrl` must be provided.
+### Domain verification (opt-in)
+
+A provider can claim an email **domain**, so that anyone signing in with an
+address at that domain is routed to it. Proving the claim is opt-in per
+environment (ADR-0024 ②) and off by default:
+
+```bash
+# Off by default. Turn on to require a DNS proof before a domain claim counts.
+OS_SSO_DOMAIN_VERIFICATION=true
+```
+
+With it on, **Setup → SSO Providers** exposes a two-step flow, one route per
+step. Both are platform-admin gated (ADR-0068 D4) and both take the provider the
+domain is being claimed for:
+
+1. `POST /api/v1/auth/admin/sso/request-domain-verification` — returns a DNS
+ **TXT** record to publish on the domain. Copy it into your DNS zone.
+2. `POST /api/v1/auth/admin/sso/verify-domain` — call once the record has
+ propagated. It re-checks DNS and marks the domain verified, or reports why it
+ could not.
+
+```typescript
+// Step 1 — ask for the TXT record to publish.
+// Body: { providerId, domain? }. `domain` only shapes the record NAME shown back
+// to you; omit it and you get the bare label to place on the zone yourself.
+const req = await fetch('http://localhost:3000/api/v1/auth/admin/sso/request-domain-verification', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ providerId: 'okta', domain: 'acme.example' })
+});
+// → { success: true, data: { providerId, domain, token,
+// dnsRecordType: 'TXT', dnsRecordName, dnsRecordValue } }
+
+// …publish dnsRecordName / dnsRecordValue, wait for DNS to propagate, then:
+
+// Step 2 — verify the claim. Body: { providerId }.
+const done = await fetch('http://localhost:3000/api/v1/auth/admin/sso/verify-domain', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ providerId: 'okta' })
+});
+// → { success: true, data: { providerId, verified: true, message } }
+```
+
+Both take `providerId` and refuse with **400 `INVALID_REQUEST`** when it is
+missing. Step 2 reports `NO_PENDING_VERIFICATION` if you call it before step 1,
+and `DOMAIN_VERIFICATION_FAILED` when the TXT record is not visible yet — retry
+after DNS propagates.
+
+
+**The mounts are unconditional; the switch controls the endpoint behind them.**
+Both routes exist whether or not `OS_SSO_DOMAIN_VERIFICATION` is set — and with
+it unset the two halves report that differently, so match on the code rather than
+the status: step 1 answers **400 `DOMAIN_VERIFICATION_DISABLED`**, step 2 passes
+the inner **404** through with an explanatory message. Either way an anonymous
+caller gets **401 `UNAUTHENTICATED`** and a signed-in non-platform-admin **403
+`PERMISSION_DENIED`** — identity is answered before capability, so a stranger
+cannot use these routes to probe which features an environment has enabled.
+
+
---
## Enterprise SSO (SAML 2.0)
diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts
index ac5941f4b9..9091824932 100644
--- a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts
+++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts
@@ -175,11 +175,32 @@ describe('auth route ledger hygiene', () => {
// the enumeration, which reads `.path`, never sees it either). Pinned so
// the `source` split stays honest rather than becoming a place to park a
// row that failed the upstream check.
+ //
+ // [#10534] Grew from 3 to 11. A census of `auth-plugin.ts` found 17 such
+ // mounts, of which nine were in NEITHER half of the ledger; eight are
+ // ledgered now. This pin is the thing that makes the enlarged set
+ // reviewable: an ObjectStack mount added or removed without a matching
+ // row fails HERE, naming the route, which is the closest mechanical check
+ // that exists today for the "mounted with no ledger row" state. It is not
+ // a substitute for the mount-vs-ledger gate #10534 proposes — this list
+ // is still hand-written, so it catches a row that disappears, not a mount
+ // that never got one. The ninth mount,
+ // `POST /api/v1/auth/set-initial-password`, is deliberately absent: its
+ // disposition is escalated on #10534 rather than guessed (see the ledger
+ // comment above these rows).
const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort();
expect(own).toEqual([
'GET /api/v1/auth/bootstrap-status',
'GET /api/v1/auth/config',
+ 'POST /api/v1/auth/admin/import-users',
+ 'POST /api/v1/auth/admin/oauth2/toggle-disabled',
+ 'POST /api/v1/auth/admin/sso/register',
+ 'POST /api/v1/auth/admin/sso/register-saml',
+ 'POST /api/v1/auth/admin/sso/request-domain-verification',
+ 'POST /api/v1/auth/admin/sso/verify-domain',
+ 'POST /api/v1/auth/admin/unlock-user',
'POST /api/v1/auth/organization/add-member',
+ 'POST /api/v1/auth/sys-oauth-application/register',
]);
for (const route of own) {
expect(live.has(route), `${route} should NOT come from better-auth`).toBe(false);
diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts
index c5ac8594ae..123e1a87dc 100644
--- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts
+++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts
@@ -178,6 +178,54 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
{ route: 'GET /api/v1/auth/oauth2/public-client', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.applications.getPublic', requires: 'oidcProvider' },
{ route: 'GET /api/v1/auth/bootstrap-status', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.bootstrapStatus' },
{ route: 'GET /api/v1/auth/config', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.getConfig' },
+ // ─────────────────────────────────────────────────────────────────────
+ // #10534 — the remaining ObjectStack raw-app mounts, ledgered.
+ //
+ // A census of `auth-plugin.ts` found 17 routes mounted directly on the raw
+ // Hono app ahead of the catch-all, and NINE of them appeared in neither half
+ // of this file: not in the reviewed rows, and not in
+ // BETTER_AUTH_MOUNTED_SURFACE either (correctly — the vendor does not serve
+ // these paths, so an exact-equality inventory of the vendor's table cannot
+ // and must not carry them). Unaccounted-for is the state that let #9941 and
+ // #10050 ship a mount and its documentation gap separately with nothing
+ // objecting, so the rows are written here rather than left implied.
+ //
+ // WHY `server-only` FOR ALL OF THEM, and how that was decided rather than
+ // defaulted. `server-only` means "deliberately not SDK surface", so it is a
+ // claim about intent and not a leftover bucket. It was tested per route by
+ // asking who actually builds the URL — measured, with a positive control
+ // proving the search fires (`bootstrap-status` → 2 hits, `sign-in/email` →
+ // 2, `get-session` → 6 in `packages/client/src`). Every route below came
+ // back with ZERO `ObjectStackClient` callers and exactly one real caller
+ // that is a DECLARATIVE metadata action target or a Console wizard — the
+ // `organization/add-member` precedent directly above. Their peer routes
+ // (`/admin/create-user`, `/admin/ban-user`, `/admin/set-user-password`) are
+ // uniformly SDK-absent too, so "the SDK deliberately does not cover
+ // platform-operator user administration" is the surface's actual shape, not
+ // an accommodation written to make a row fit.
+ //
+ // ⚠️ `POST /api/v1/auth/set-initial-password` is the ninth mount and is
+ // DELIBERATELY NOT LEDGERED HERE. It fails the test above in a way none of
+ // these do: its caller is `@object-ui/auth`'s `createAuthClient`, whose
+ // three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are
+ // ALL expressed on `ObjectStackClient` — and its own sibling branch in the
+ // same Console password card, `changePassword`, is ledgered `sdk`. That
+ // shape reads as `gap` ("should be in the SDK and is not"), not as
+ // `server-only`, and `gap` is ratcheted to zero by this file's conformance
+ // suite. Writing `server-only` there would be a false declaration of intent
+ // to dodge a ratchet. It is escalated on #10534 instead.
+ //
+ // `requires` follows the add-member precedent: it names the better-auth
+ // plugin the route's WORK needs, not whether the mount is conditional —
+ // every one of these is mounted unconditionally on the raw app.
+ { route: 'POST /api/v1/auth/admin/import-users', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — objectui app-shell\'s identity-import wizard (views/identityImport.ts) posts it directly from the Users list; platform-admin gated (ADR-0068), #2766 V2' },
+ { route: 'POST /api/v1/auth/admin/oauth2/toggle-disabled', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application disable/enable actions post it directly; ObjectStack mount closing a vendor gap (better-auth\'s /admin/oauth2/update-client strips `disabled` from its body schema), platform-admin gated (ADR-0068)' },
+ { route: 'POST /api/v1/auth/admin/sso/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register action posts flat form fields; ObjectStack bridge re-dispatching into @better-auth/sso /sso/register, platform-admin gated ahead of the delegation (ADR-0068 D4, #9653). Distinct path from the vendor\'s own /sso/register, which the catch-all serves' },
+ { route: 'POST /api/v1/auth/admin/sso/register-saml', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register_saml_provider action posts flat fields the bridge reshapes into better-auth\'s nested samlConfig; platform-admin gated (ADR-0068 D4, #9653), ADR-0069 P3' },
+ { route: 'POST /api/v1/auth/admin/sso/request-domain-verification', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider action posts it and renders the returned DNS TXT record; ObjectStack bridge over @better-auth/sso, additionally gated on the opt-in ssoDomainVerification switch (OS_SSO_DOMAIN_VERIFICATION) — off means the inner endpoint 404s, the mount itself is unconditional; platform-admin gated (ADR-0068 D4), ADR-0024 ②' },
+ { route: 'POST /api/v1/auth/admin/sso/verify-domain', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider action posts it after the DNS TXT record is published; same opt-in ssoDomainVerification switch and platform-admin gate as request-domain-verification (ADR-0068 D4), ADR-0024 ②' },
+ { route: 'POST /api/v1/auth/admin/unlock-user', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user unlock_user action posts it directly; clears a brute-force lockout (sys_user.locked_until / failed_login_count), a custom per-identity mechanism with no better-auth endpoint; platform-admin gated (ADR-0068), ADR-0069 D2' },
+ { route: 'POST /api/v1/auth/sys-oauth-application/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application create action posts it directly; session-required self-service wrapper over better-auth /oauth2/create-client that splits the Console\'s newline-separated redirect-URL textarea into the redirect_uris array the vendor schema requires' },
{ route: 'POST /api/v1/auth/organization/accept-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.accept', requires: 'organization' },
// #9941 — better-auth declares `addMember` with NO HTTP path (server-only
// `auth.api.addMember`; measured on the installed 1.7.1), so the catch-all