Skip to content

Commit 163a162

Browse files
os-warrenclaude
andauthored
docs(plugin-auth): ledger and document the unaccounted ObjectStack auth mounts (#10660)
auth-plugin.ts mounts 17 routes directly on the raw Hono app ahead of the better-auth catch-all. Nine were in neither half of auth-route-ledger.ts and six carried no literal wire path in the hand-written docs. Ledger: eight gain reviewed `source: 'objectstack'` rows, all `server-only`, each measured to have zero ObjectStackClient callers and exactly one real caller (a declarative metadata action target or a Console wizard). The conformance pin over the objectstack-sourced set grows 3 -> 11 in the same edit, so the enlarged set stays reviewable. set-initial-password is deliberately left unledgered: its peer group makes it read as `gap`, not `server-only`, and `gap` is ratcheted to zero — so a disposition there would be a false declaration written to dodge a ratchet. Escalated on the issue instead. Docs: bootstrap-status, set-initial-password, admin/unban-user, admin/sso/register and the two admin/sso domain-verification routes now carry their literal wire paths. admin/sso/register was NOT documented before — the census's substring test had credited it to its longer sibling register-saml. No route's mounting, behaviour or accept/reject set changes. Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude <noreply@anthropic.com>
1 parent bcc5a27 commit 163a162

5 files changed

Lines changed: 220 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
Ledger and document the ObjectStack-owned auth mounts that were in neither the route ledger nor the docs (#10534).
6+
7+
`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.
8+
9+
**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.
10+
11+
**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.
12+
13+
No route's mounting, behaviour or accept/reject set changes.

content/docs/permissions/authentication.mdx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,33 @@ const session = await client.auth.me();
245245
console.log('Current user:', session.data.user);
246246
```
247247

248+
### First-run bootstrap status
249+
250+
`GET /api/v1/auth/bootstrap-status` answers one question — does this environment
251+
have any user yet?
252+
253+
```typescript
254+
const res = await fetch('http://localhost:3000/api/v1/auth/bootstrap-status');
255+
const { hasOwner } = await res.json(); // → { "hasOwner": true }
256+
```
257+
258+
It is **public and unauthenticated** by design: a client has to be able to ask it
259+
*before* anyone has credentials. That is also why it returns nothing but the
260+
boolean — it is a routing signal, not an information endpoint. The Console's root
261+
route uses it to choose between `/login` (normal) and `/setup` (first-run owner
262+
creation); `client.auth.bootstrapStatus()` is the SDK method that builds it.
263+
264+
It **fails open**: if no data engine is wired, or the count query throws, it
265+
answers `{ "hasOwner": true }` so a client falls through to the ordinary login
266+
flow rather than offering to create an owner on an environment that may already
267+
have one.
268+
269+
<Callout type="warn">
270+
This route is unauthenticated, so treat `hasOwner: false` as a hint to render a
271+
setup screen — never as authorization. First-run owner creation is enforced
272+
server-side by the sign-up path, not by this probe.
273+
</Callout>
274+
248275
### Password Management
249276

250277
#### Request Password Reset
@@ -275,6 +302,35 @@ const response = await fetch('http://localhost:3000/api/v1/auth/reset-password',
275302
});
276303
```
277304

305+
#### Setting a first local password
306+
307+
`POST /api/v1/auth/set-initial-password` sets an **initial** local password for a
308+
signed-in user who has no credential account yet — the account was onboarded
309+
through SSO, through the cloud OAuth provider, or imported with
310+
`passwordPolicy: 'none'`. It gives that user an email/password way in to this
311+
environment without the SSO round-trip.
312+
313+
```typescript
314+
const response = await fetch('http://localhost:3000/api/v1/auth/set-initial-password', {
315+
method: 'POST',
316+
headers: { 'Content-Type': 'application/json' },
317+
credentials: 'include', // a valid session identifies WHO is asking
318+
body: JSON.stringify({ newPassword: 'firstLocalPassword123' })
319+
});
320+
```
321+
322+
Two refusals are deliberate and are what separate this from a password reset:
323+
324+
- **No session → refused.** The route never takes a user id from the body; the
325+
session is the only thing that says whose password is being set.
326+
- **A credential already exists → refused.** Use
327+
`POST /api/v1/auth/change-password` in that case, so the *current* password is
328+
verified first. This endpoint is only for the no-password-yet state, which is
329+
why it can accept a new password without one.
330+
331+
The Console reaches it from **Profile → Password** when `hasLocalPassword()`
332+
reports no credential.
333+
278334
### Email Verification
279335

280336
#### Send Verification Email
@@ -899,7 +955,8 @@ every row through better-auth so the accounts are login-capable:
899955
- `passwordPolicy: 'none'` — identity only: accounts are created without a
900956
credential record. Users first sign in through a channel (phone OTP, magic
901957
link, or a password-reset link) and the Console detects the missing password
902-
(`hasLocalPassword()`) and offers set-initial-password.
958+
(`hasLocalPassword()`) and offers
959+
[`POST /api/v1/auth/set-initial-password`](#setting-a-first-local-password).
903960
- `mode: 'insert' | 'upsert'` with `matchBy: 'email' | 'phone'`. Upsert
904961
updates only touch profile fields (`name`, `image`, `phone_number`, `role`)
905962
— 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/*`:
10021059
#### Session
10031060

10041061
- `GET /api/v1/auth/get-session` - Get current user session
1062+
- `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))
10051063

10061064
#### Password Management
10071065

10081066
- `POST /api/v1/auth/request-password-reset` - Request password reset email
10091067
- `POST /api/v1/auth/reset-password` - Reset password with token
1068+
- `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))
10101069

10111070
#### Email Verification
10121071

@@ -1042,6 +1101,8 @@ All endpoints are available under `/api/v1/auth/*`:
10421101
- `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users)
10431102
- `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)
10441103
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early
1104+
- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions)
1105+
- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in
10451106

10461107
#### Organization Membership (requires `plugins.organization`; platform-admin gated)
10471108

content/docs/permissions/sso.mdx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ packages that prefer wiring providers in code, or contributing them through
125125
> below). The `oidcProviders` extension shown here remains the in-process path
126126
> for framework/enterprise packages that prefer wiring providers in code.
127127
128+
**Setup → SSO Providers → Register Provider** posts to the env-side bridge at
129+
`POST /api/v1/auth/admin/sso/register`, the OIDC counterpart of the SAML bridge
130+
[below](#enterprise-sso-saml-20). It takes the flat form fields of the
131+
[OIDC provider fields](#oidc-provider-fields) table, reshapes them for
132+
`@better-auth/sso`, and is gated on a **platform admin** (ADR-0068 D4) before it
133+
delegates — an organization owner or admin is not sufficient, because registering
134+
an identity provider decides how the whole environment authenticates.
135+
136+
<Callout type="info">
137+
This is an ObjectStack mount, distinct from `@better-auth/sso`'s own
138+
`POST /api/v1/auth/sso/register`. The bridge exists so the no-code Setup form can
139+
post flat fields; both doors apply the platform-admin rule.
140+
</Callout>
141+
128142
### Quick start — Okta
129143

130144
{/* os:check */}
@@ -212,6 +226,68 @@ const oidcProviders = [
212226

213227
*Either `discoveryUrl` or `authorizationUrl` + `tokenUrl` must be provided.
214228

229+
### Domain verification (opt-in)
230+
231+
A provider can claim an email **domain**, so that anyone signing in with an
232+
address at that domain is routed to it. Proving the claim is opt-in per
233+
environment (ADR-0024 ②) and off by default:
234+
235+
```bash
236+
# Off by default. Turn on to require a DNS proof before a domain claim counts.
237+
OS_SSO_DOMAIN_VERIFICATION=true
238+
```
239+
240+
With it on, **Setup → SSO Providers** exposes a two-step flow, one route per
241+
step. Both are platform-admin gated (ADR-0068 D4) and both take the provider the
242+
domain is being claimed for:
243+
244+
1. `POST /api/v1/auth/admin/sso/request-domain-verification` — returns a DNS
245+
**TXT** record to publish on the domain. Copy it into your DNS zone.
246+
2. `POST /api/v1/auth/admin/sso/verify-domain` — call once the record has
247+
propagated. It re-checks DNS and marks the domain verified, or reports why it
248+
could not.
249+
250+
```typescript
251+
// Step 1 — ask for the TXT record to publish.
252+
// Body: { providerId, domain? }. `domain` only shapes the record NAME shown back
253+
// to you; omit it and you get the bare label to place on the zone yourself.
254+
const req = await fetch('http://localhost:3000/api/v1/auth/admin/sso/request-domain-verification', {
255+
method: 'POST',
256+
headers: { 'Content-Type': 'application/json' },
257+
credentials: 'include',
258+
body: JSON.stringify({ providerId: 'okta', domain: 'acme.example' })
259+
});
260+
// → { success: true, data: { providerId, domain, token,
261+
// dnsRecordType: 'TXT', dnsRecordName, dnsRecordValue } }
262+
263+
// …publish dnsRecordName / dnsRecordValue, wait for DNS to propagate, then:
264+
265+
// Step 2 — verify the claim. Body: { providerId }.
266+
const done = await fetch('http://localhost:3000/api/v1/auth/admin/sso/verify-domain', {
267+
method: 'POST',
268+
headers: { 'Content-Type': 'application/json' },
269+
credentials: 'include',
270+
body: JSON.stringify({ providerId: 'okta' })
271+
});
272+
// → { success: true, data: { providerId, verified: true, message } }
273+
```
274+
275+
Both take `providerId` and refuse with **400 `INVALID_REQUEST`** when it is
276+
missing. Step 2 reports `NO_PENDING_VERIFICATION` if you call it before step 1,
277+
and `DOMAIN_VERIFICATION_FAILED` when the TXT record is not visible yet — retry
278+
after DNS propagates.
279+
280+
<Callout type="info">
281+
**The mounts are unconditional; the switch controls the endpoint behind them.**
282+
Both routes exist whether or not `OS_SSO_DOMAIN_VERIFICATION` is set — and with
283+
it unset the two halves report that differently, so match on the code rather than
284+
the status: step 1 answers **400 `DOMAIN_VERIFICATION_DISABLED`**, step 2 passes
285+
the inner **404** through with an explanatory message. Either way an anonymous
286+
caller gets **401 `UNAUTHENTICATED`** and a signed-in non-platform-admin **403
287+
`PERMISSION_DENIED`** — identity is answered before capability, so a stranger
288+
cannot use these routes to probe which features an environment has enabled.
289+
</Callout>
290+
215291
---
216292

217293
## Enterprise SSO (SAML 2.0)

packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,32 @@ describe('auth route ledger hygiene', () => {
175175
// the enumeration, which reads `.path`, never sees it either). Pinned so
176176
// the `source` split stays honest rather than becoming a place to park a
177177
// row that failed the upstream check.
178+
//
179+
// [#10534] Grew from 3 to 11. A census of `auth-plugin.ts` found 17 such
180+
// mounts, of which nine were in NEITHER half of the ledger; eight are
181+
// ledgered now. This pin is the thing that makes the enlarged set
182+
// reviewable: an ObjectStack mount added or removed without a matching
183+
// row fails HERE, naming the route, which is the closest mechanical check
184+
// that exists today for the "mounted with no ledger row" state. It is not
185+
// a substitute for the mount-vs-ledger gate #10534 proposes — this list
186+
// is still hand-written, so it catches a row that disappears, not a mount
187+
// that never got one. The ninth mount,
188+
// `POST /api/v1/auth/set-initial-password`, is deliberately absent: its
189+
// disposition is escalated on #10534 rather than guessed (see the ledger
190+
// comment above these rows).
178191
const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort();
179192
expect(own).toEqual([
180193
'GET /api/v1/auth/bootstrap-status',
181194
'GET /api/v1/auth/config',
195+
'POST /api/v1/auth/admin/import-users',
196+
'POST /api/v1/auth/admin/oauth2/toggle-disabled',
197+
'POST /api/v1/auth/admin/sso/register',
198+
'POST /api/v1/auth/admin/sso/register-saml',
199+
'POST /api/v1/auth/admin/sso/request-domain-verification',
200+
'POST /api/v1/auth/admin/sso/verify-domain',
201+
'POST /api/v1/auth/admin/unlock-user',
182202
'POST /api/v1/auth/organization/add-member',
203+
'POST /api/v1/auth/sys-oauth-application/register',
183204
]);
184205
for (const route of own) {
185206
expect(live.has(route), `${route} should NOT come from better-auth`).toBe(false);

packages/plugins/plugin-auth/src/auth-route-ledger.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,54 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
178178
{ route: 'GET /api/v1/auth/oauth2/public-client', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.applications.getPublic', requires: 'oidcProvider' },
179179
{ route: 'GET /api/v1/auth/bootstrap-status', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.bootstrapStatus' },
180180
{ route: 'GET /api/v1/auth/config', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.getConfig' },
181+
// ─────────────────────────────────────────────────────────────────────
182+
// #10534 — the remaining ObjectStack raw-app mounts, ledgered.
183+
//
184+
// A census of `auth-plugin.ts` found 17 routes mounted directly on the raw
185+
// Hono app ahead of the catch-all, and NINE of them appeared in neither half
186+
// of this file: not in the reviewed rows, and not in
187+
// BETTER_AUTH_MOUNTED_SURFACE either (correctly — the vendor does not serve
188+
// these paths, so an exact-equality inventory of the vendor's table cannot
189+
// and must not carry them). Unaccounted-for is the state that let #9941 and
190+
// #10050 ship a mount and its documentation gap separately with nothing
191+
// objecting, so the rows are written here rather than left implied.
192+
//
193+
// WHY `server-only` FOR ALL OF THEM, and how that was decided rather than
194+
// defaulted. `server-only` means "deliberately not SDK surface", so it is a
195+
// claim about intent and not a leftover bucket. It was tested per route by
196+
// asking who actually builds the URL — measured, with a positive control
197+
// proving the search fires (`bootstrap-status` → 2 hits, `sign-in/email` →
198+
// 2, `get-session` → 6 in `packages/client/src`). Every route below came
199+
// back with ZERO `ObjectStackClient` callers and exactly one real caller
200+
// that is a DECLARATIVE metadata action target or a Console wizard — the
201+
// `organization/add-member` precedent directly above. Their peer routes
202+
// (`/admin/create-user`, `/admin/ban-user`, `/admin/set-user-password`) are
203+
// uniformly SDK-absent too, so "the SDK deliberately does not cover
204+
// platform-operator user administration" is the surface's actual shape, not
205+
// an accommodation written to make a row fit.
206+
//
207+
// ⚠️ `POST /api/v1/auth/set-initial-password` is the ninth mount and is
208+
// DELIBERATELY NOT LEDGERED HERE. It fails the test above in a way none of
209+
// these do: its caller is `@object-ui/auth`'s `createAuthClient`, whose
210+
// three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are
211+
// ALL expressed on `ObjectStackClient` — and its own sibling branch in the
212+
// same Console password card, `changePassword`, is ledgered `sdk`. That
213+
// shape reads as `gap` ("should be in the SDK and is not"), not as
214+
// `server-only`, and `gap` is ratcheted to zero by this file's conformance
215+
// suite. Writing `server-only` there would be a false declaration of intent
216+
// to dodge a ratchet. It is escalated on #10534 instead.
217+
//
218+
// `requires` follows the add-member precedent: it names the better-auth
219+
// plugin the route's WORK needs, not whether the mount is conditional —
220+
// every one of these is mounted unconditionally on the raw app.
221+
{ 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' },
222+
{ 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)' },
223+
{ 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' },
224+
{ 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' },
225+
{ 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 ②' },
226+
{ 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 ②' },
227+
{ 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' },
228+
{ 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' },
181229
{ route: 'POST /api/v1/auth/organization/accept-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.accept', requires: 'organization' },
182230
// #9941 — better-auth declares `addMember` with NO HTTP path (server-only
183231
// `auth.api.addMember`; measured on the installed 1.7.1), so the catch-all

0 commit comments

Comments
 (0)