Skip to content

Commit d3bee87

Browse files
os-zhuangclaude
andauthored
feat(client,plugin-auth): bind set-initial-password into the SDK and ledger it as an sdk mount (#11360)
* feat(client): add auth.setInitialPassword, binding the mounted set-initial-password route `AuthPlugin` mounts `POST /api/v1/auth/set-initial-password` on the raw Hono app, but no `ObjectStackClient` method built the URL — measured zero for both `setInitialPassword` and `set-initial-password` across `packages/client/src`, against four sibling auth members returning non-zero on the same corpus. The method is shaped like its namespace siblings (`getConfig`, `changePassword`, `changeEmail`): `this.getRoute('auth')` + `this.fetch`, POST with a JSON body, returning the parsed envelope. The route's own accept/reject behaviour, admit set and server-side guards are untouched. This binds a client to an already-mounted route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR * feat(plugin-auth): ledger set-initial-password as an `sdk` objectstack mount Second half of the maintainer's option-C ruling, landed in the same PR as the first half by the follow-up ruling of 2026-08-23 (combine). `AUTH_ROUTE_LEDGER` gains the exact row for `POST /api/v1/auth/set-initial-password` — `family: 'objectstack-mount'`, `source: 'objectstack'`, `disposition: 'sdk'`, `client: 'auth.setInitialPassword'` — shaped like the two ObjectStack `sdk` mounts it sits beside. The `:171` pin (`the objectstack-mounted rows are the ones auth-plugin.ts serves itself`) goes 11 → 12 BY ADDITION: the assertion, the pin and the `live.has(route)` loop are untouched. Both of the pin's own terms hold for the new entry — auth-plugin.ts mounts it directly on the raw app ahead of the catch-all, and better-auth does not publish it. `scripts/check-auth-mount-ledger.mjs`'s PENDING_DISPOSITION entry for this route is deleted, which is that shrink-only ratchet coming down: the gate fails `resolved-pending` if an entry survives its disposition landing, and the entry said so itself. PENDING_MAX is NOT changed. ⛔ No guard was weakened to reach this: the `wildcardOnly` bound stays at 0, the method is not parked in `NON_HTTP`, `gap`/`mismatch` stay at 0, and the route's accept/reject behaviour, admit set and server-side guards are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 34f60b7 commit d3bee87

5 files changed

Lines changed: 83 additions & 28 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
**SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method.
7+
8+
`AuthPlugin` has mounted this route on the raw Hono app for as long as the SSO-onboarding flow has existed, but `packages/client/src` built the URL nowhere — measured zero for both `setInitialPassword` and `set-initial-password`, against four sibling auth members returning non-zero on the same corpus, so the absence was an absence and not a broken search. Its only caller was `@object-ui/auth`'s `createAuthClient`, whose three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are all expressed on `ObjectStackClient`, and whose sibling branch in the very same Console password card — `changePassword` — has been ledgered `sdk` throughout.
9+
10+
The method is shaped exactly like its namespace siblings (`this.getRoute('auth')` + `this.fetch`, `POST` with a JSON body, returning the parsed envelope), because the difference between it and `changePassword` is a **server-side** one and belongs there: better-auth registers `setPassword` with no HTTP path of its own (server-only `auth.api.setPassword`), so ObjectStack wraps it in an authenticated mount that requires a session and refuses with 409 `PASSWORD_ALREADY_SET` when a credential already exists. Callers that already have a password use `changePassword`, which verifies the current one.
11+
12+
**Nothing about the route's behaviour moves.** Its accept/reject logic, its admit set and its server-side guards are untouched — this is a client binding to an existing mount, not a widening of what the mount allows.
13+
14+
**Its `AUTH_ROUTE_LEDGER` row lands with it**, because the two halves are one statement and neither is true alone. `plugin-auth` gains `{ route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }` — the ninth mount of the #10534 census, whose disposition was escalated rather than guessed and which the maintainer ruled `sdk` (option C, 2026-08-22) and then ruled should land in one PR (2026-08-23). Without the row, the method's URL matched only the dispatcher's `* /auth/**` prefix family, and `client-url-conformance.test.ts` bounds wildcard-only matches at zero on purpose; with it, the same URL resolves to an enumerated route. The row also brings the `check:auth-mount-ledger` pending-disposition entry down — the exemption that carried this route while the question was open is deleted, which is that ratchet working rather than being relaxed.

packages/client/src/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2584,6 +2584,28 @@ export class ObjectStackClient {
25842584
return res.json();
25852585
},
25862586

2587+
/**
2588+
* Set a **first** local password for a signed-in user who has none yet —
2589+
* the SSO/social-onboarded account that has no `credential` row.
2590+
*
2591+
* This is NOT `changePassword`'s sibling-by-convenience: better-auth
2592+
* registers `setPassword` with no HTTP path of its own (server-only
2593+
* `auth.api.setPassword`), and ObjectStack's AuthPlugin mounts the wrapper
2594+
* this method targets. The route requires a valid session and REFUSES
2595+
* (409 `PASSWORD_ALREADY_SET`) when a credential already exists — in that
2596+
* case use `changePassword`, which verifies the current password.
2597+
*
2598+
* ObjectStack mount: POST /set-initial-password — `{ newPassword }`.
2599+
*/
2600+
setInitialPassword: async (req: { newPassword: string }) => {
2601+
const route = this.getRoute('auth');
2602+
const res = await this.fetch(`${this.baseUrl}${route}/set-initial-password`, {
2603+
method: 'POST',
2604+
body: JSON.stringify(req),
2605+
});
2606+
return res.json();
2607+
},
2608+
25872609
/**
25882610
* Begin a change-email flow. better-auth sends a verification mail to
25892611
* the new address; the change only takes effect after the user clicks

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ describe('auth route ledger hygiene', () => {
176176
// the `source` split stays honest rather than becoming a place to park a
177177
// row that failed the upstream check.
178178
//
179-
// [#10534] Grew from 3 to 11. A census of `auth-plugin.ts` found 17 such
179+
// [#10534] Grew from 3 to 11, and to 12 with #10974/#10975. A census of
180+
// `auth-plugin.ts` found 17 such
180181
// mounts, of which nine were in NEITHER half of the ledger; eight are
181182
// ledgered now. This pin is the thing that makes the enlarged set
182183
// reviewable: an ObjectStack mount added or removed without a matching
@@ -191,10 +192,17 @@ describe('auth route ledger hygiene', () => {
191192
// are complements rather than duplicates, and both are worth keeping: this
192193
// pin is a reviewed, hand-written statement of what the objectstack-sourced
193194
// set IS, and the gate is a reading of what the plugin actually serves.
194-
// The ninth mount,
195-
// `POST /api/v1/auth/set-initial-password`, is deliberately absent: its
196-
// disposition is escalated on #10534 rather than guessed (see the ledger
197-
// comment above these rows).
195+
// The ninth mount, `POST /api/v1/auth/set-initial-password`, was
196+
// deliberately absent while its disposition was escalated on #10534 rather
197+
// than guessed. It is present now, and it got here by ADDITION on this
198+
// pin's own terms — not by loosening the assertion, deleting the pin, or
199+
// computing the list. Both terms hold for it: `auth-plugin.ts` mounts it
200+
// itself (a `rawApp.post` on the `${basePath}/set-initial-password`
201+
// template, ahead of the catch-all), and the `live.has(route)` loop below
202+
// holds it to the same proof as the other eleven — better-auth does not
203+
// publish it. Its `sdk` disposition names `auth.setInitialPassword`, which
204+
// exists in the same change (#10974 / #10975, combined by the maintainer
205+
// ruling of 2026-08-23).
198206
const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort();
199207
expect(own).toEqual([
200208
'GET /api/v1/auth/bootstrap-status',
@@ -207,6 +215,7 @@ describe('auth route ledger hygiene', () => {
207215
'POST /api/v1/auth/admin/sso/verify-domain',
208216
'POST /api/v1/auth/admin/unlock-user',
209217
'POST /api/v1/auth/organization/add-member',
218+
'POST /api/v1/auth/set-initial-password',
210219
'POST /api/v1/auth/sys-oauth-application/register',
211220
]);
212221
for (const route of own) {

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

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,16 @@ 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+
// #10974 / #10975 — the ninth ObjectStack mount from the #10534 census,
182+
// ledgered `sdk` on the maintainer's option-C ruling (2026-08-22) rather
183+
// than on either word that was available before it. The two halves landed
184+
// in ONE PR by the follow-up ruling of 2026-08-23: the row alone would have
185+
// been the #3528 coverage lie, and the method alone matched its URL only
186+
// through the dispatcher's `* /auth/**` family, which
187+
// `client-url-conformance.test.ts` bounds at zero. Together they are one
188+
// statement — the method exists, this row declares it, and the URL now
189+
// resolves to an enumerated route.
190+
{ route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' },
181191
// ─────────────────────────────────────────────────────────────────────
182192
// #10534 — the remaining ObjectStack raw-app mounts, ledgered.
183193
//
@@ -205,15 +215,18 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
205215
// an accommodation written to make a row fit.
206216
//
207217
// ⚠️ `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.
218+
// NOT in this `server-only` batch — it is ledgered `sdk` with the other two
219+
// ObjectStack mounts above (#10974 / #10975). It failed the test this batch
220+
// passes: its caller is `@object-ui/auth`'s `createAuthClient`, whose three
221+
// other auth URLs (`/config`, `/get-session`, `/list-accounts`) are ALL
222+
// expressed on `ObjectStackClient` — and its own sibling branch in the same
223+
// Console password card, `changePassword`, is ledgered `sdk`. That shape
224+
// read as `gap` ("should be in the SDK and is not"), not as `server-only`,
225+
// and `gap` is ratcheted to zero by this file's conformance suite; writing
226+
// `server-only` there would have been a false declaration of intent to
227+
// dodge a ratchet. It was escalated on #10534 rather than guessed, and the
228+
// maintainer resolved the `gap` at its source instead of recording it:
229+
// `auth.setInitialPassword` now exists, so `sdk` is the measurement.
217230
//
218231
// `requires` follows the add-member precedent: it names the better-auth
219232
// plugin the route's WORK needs, not whether the mount is conditional —

scripts/check-auth-mount-ledger.mjs

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,12 @@ export const EXIT_NOT_MEASURED = 2;
139139
* entry and the gate then fails if the entry is still here.
140140
*/
141141
export const PENDING_DISPOSITION = [
142-
{
143-
route: 'POST /api/v1/auth/set-initial-password',
144-
issue: '#10975',
145-
why:
146-
'Disposition escalated on #10534 rather than guessed: `server-only` would claim an intent ' +
147-
"the route's own peer group contradicts (its three sibling URLs in the same createAuthClient " +
148-
'are all ledgered `sdk`), and `gap` is ratcheted to <= 0. Maintainer ruling 2026-08-22: ' +
149-
'option C -- add `auth.setInitialPassword` to ObjectStackClient (#10974), THEN ledger the ' +
150-
'row as `sdk` (#10975, blocked-by #10974). This entry is deleted by #10975.',
151-
},
142+
// EMPTY, and that is the ratchet having come down rather than a list nobody
143+
// uses. Its one entry -- `POST /api/v1/auth/set-initial-password`, granted by
144+
// the maintainer ruling of 2026-08-22 -- was deleted when #10974/#10975
145+
// landed its disposition: an `sdk` row naming `auth.setInitialPassword`. The
146+
// gate would fail (`resolved-pending`) if the entry had been left behind, so
147+
// this deletion is the landing half of that ruling, not tidying.
152148
];
153149

154150
/** Shrink-only. Raising it is a maintainer decision, not a repair. */
@@ -167,8 +163,8 @@ export const MIN_NOTE_CHARS = 60;
167163
* row in `AUTH_ROUTE_LEDGER`, which grows as routes are added and is no ratchet.
168164
* But both paths that touch PENDING_DISPOSITION expand a shrink-only exemption
169165
* list, and neither is the landing author's to take. Refusing them outright would
170-
* be the stronger shape and would also be FALSE: the list has a legitimate entry,
171-
* granted by a maintainer ruling. There is a real act here with a real owner, so
166+
* be the stronger shape and would also be FALSE: the list HAS held a legitimate
167+
* entry, granted by a maintainer ruling. There is a real act here with a real owner, so
172168
* the honest shape is to name the owner rather than to deny the act -- the same
173169
* reading `check-skills-token-ratchet.mjs` records for its published-catalog
174170
* ceiling (#10473).
@@ -391,8 +387,9 @@ function dispositionDemand(route) {
391387
' a PENDING_DISPOSITION entry naming that issue, and stays printed on every clean run.',
392388
'',
393389
` ${RATCHET_AUTHORITY} -- adding \`${route}\` to PENDING_DISPOSITION is an EXEMPTION from this`,
394-
' gate, and it is not yours to grant yourself. That list is shrink-only, its one entry exists',
395-
' because a maintainer ruled on it (#10534, 2026-08-22), and an author who quietly adds their',
390+
' gate, and it is not yours to grant yourself. That list is shrink-only and is EMPTY today; the',
391+
' one entry it has ever held was there because a maintainer ruled on it (#10534, 2026-08-22)',
392+
' and came off when that disposition landed. An author who quietly adds their',
396393
' own route has done the single thing that turns this gate into a parking space: the mount is',
397394
' then "accounted for" by a line recording that nobody decided. Escalating costs a round; a',
398395
' self-granted exemption costs the gate. There IS a legitimate act here -- it just has an',

0 commit comments

Comments
 (0)