From f9f809d24e3fe3c7e7fa6e3cc60ef23cea28a9a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 13:59:28 +0000 Subject: [PATCH 1/2] feat(client): add auth.setInitialPassword, binding the mounted set-initial-password route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../client-auth-set-initial-password.md | 13 +++++++++++ packages/client/src/index.ts | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .changeset/client-auth-set-initial-password.md diff --git a/.changeset/client-auth-set-initial-password.md b/.changeset/client-auth-set-initial-password.md new file mode 100644 index 0000000000..0a48c7ac03 --- /dev/null +++ b/.changeset/client-auth-set-initial-password.md @@ -0,0 +1,13 @@ +--- +"@objectstack/client": minor +--- + +**SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method. + +`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. + +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. + +**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. + +**Its `AUTH_ROUTE_LEDGER` row is deliberately not in this change**, and one consequence is visible in CI: with no exact row, `client-url-conformance.test.ts`'s final assertion sees this URL matched only by the dispatcher's `* /auth/**` wildcard family and fails, because that bound is ratcheted to zero on purpose. The row and this method have to arrive together — see the PR body. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index cb82b4de68..742a91c0bb 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2584,6 +2584,28 @@ export class ObjectStackClient { return res.json(); }, + /** + * Set a **first** local password for a signed-in user who has none yet — + * the SSO/social-onboarded account that has no `credential` row. + * + * This is NOT `changePassword`'s sibling-by-convenience: better-auth + * registers `setPassword` with no HTTP path of its own (server-only + * `auth.api.setPassword`), and ObjectStack's AuthPlugin mounts the wrapper + * this method targets. The route requires a valid session and REFUSES + * (409 `PASSWORD_ALREADY_SET`) when a credential already exists — in that + * case use `changePassword`, which verifies the current password. + * + * ObjectStack mount: POST /set-initial-password — `{ newPassword }`. + */ + setInitialPassword: async (req: { newPassword: string }) => { + const route = this.getRoute('auth'); + const res = await this.fetch(`${this.baseUrl}${route}/set-initial-password`, { + method: 'POST', + body: JSON.stringify(req), + }); + return res.json(); + }, + /** * Begin a change-email flow. better-auth sends a verification mail to * the new address; the change only takes effect after the user clicks From fbd9af2da4b8d16ca92a466899526cba906e99ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 15:22:45 +0000 Subject: [PATCH 2/2] feat(plugin-auth): ledger set-initial-password as an `sdk` objectstack mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../client-auth-set-initial-password.md | 3 +- .../src/auth-route-ledger.conformance.test.ts | 19 +++++++++--- .../plugin-auth/src/auth-route-ledger.ts | 31 +++++++++++++------ scripts/check-auth-mount-ledger.mjs | 25 +++++++-------- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/.changeset/client-auth-set-initial-password.md b/.changeset/client-auth-set-initial-password.md index 0a48c7ac03..275358633d 100644 --- a/.changeset/client-auth-set-initial-password.md +++ b/.changeset/client-auth-set-initial-password.md @@ -1,5 +1,6 @@ --- "@objectstack/client": minor +"@objectstack/plugin-auth": patch --- **SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method. @@ -10,4 +11,4 @@ The method is shaped exactly like its namespace siblings (`this.getRoute('auth') **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. -**Its `AUTH_ROUTE_LEDGER` row is deliberately not in this change**, and one consequence is visible in CI: with no exact row, `client-url-conformance.test.ts`'s final assertion sees this URL matched only by the dispatcher's `* /auth/**` wildcard family and fails, because that bound is ratcheted to zero on purpose. The row and this method have to arrive together — see the PR body. +**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. 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 d4c9cb0dff..d6ef1fef0d 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 @@ -176,7 +176,8 @@ describe('auth route ledger hygiene', () => { // 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 + // [#10534] Grew from 3 to 11, and to 12 with #10974/#10975. 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 @@ -191,10 +192,17 @@ describe('auth route ledger hygiene', () => { // are complements rather than duplicates, and both are worth keeping: this // pin is a reviewed, hand-written statement of what the objectstack-sourced // set IS, and the gate is a reading of what the plugin actually serves. - // 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). + // The ninth mount, `POST /api/v1/auth/set-initial-password`, was + // deliberately absent while its disposition was escalated on #10534 rather + // than guessed. It is present now, and it got here by ADDITION on this + // pin's own terms — not by loosening the assertion, deleting the pin, or + // computing the list. Both terms hold for it: `auth-plugin.ts` mounts it + // itself (a `rawApp.post` on the `${basePath}/set-initial-password` + // template, ahead of the catch-all), and the `live.has(route)` loop below + // holds it to the same proof as the other eleven — better-auth does not + // publish it. Its `sdk` disposition names `auth.setInitialPassword`, which + // exists in the same change (#10974 / #10975, combined by the maintainer + // ruling of 2026-08-23). const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort(); expect(own).toEqual([ 'GET /api/v1/auth/bootstrap-status', @@ -207,6 +215,7 @@ describe('auth route ledger hygiene', () => { '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/set-initial-password', 'POST /api/v1/auth/sys-oauth-application/register', ]); for (const route of own) { diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 123e1a87dc..e48823babc 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -178,6 +178,16 @@ 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' }, + // #10974 / #10975 — the ninth ObjectStack mount from the #10534 census, + // ledgered `sdk` on the maintainer's option-C ruling (2026-08-22) rather + // than on either word that was available before it. The two halves landed + // in ONE PR by the follow-up ruling of 2026-08-23: the row alone would have + // been the #3528 coverage lie, and the method alone matched its URL only + // through the dispatcher's `* /auth/**` family, which + // `client-url-conformance.test.ts` bounds at zero. Together they are one + // statement — the method exists, this row declares it, and the URL now + // resolves to an enumerated route. + { route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }, // ───────────────────────────────────────────────────────────────────── // #10534 — the remaining ObjectStack raw-app mounts, ledgered. // @@ -205,15 +215,18 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ // 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. + // NOT in this `server-only` batch — it is ledgered `sdk` with the other two + // ObjectStack mounts above (#10974 / #10975). It failed the test this batch + // passes: 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 + // read 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 have been a false declaration of intent to + // dodge a ratchet. It was escalated on #10534 rather than guessed, and the + // maintainer resolved the `gap` at its source instead of recording it: + // `auth.setInitialPassword` now exists, so `sdk` is the measurement. // // `requires` follows the add-member precedent: it names the better-auth // plugin the route's WORK needs, not whether the mount is conditional — diff --git a/scripts/check-auth-mount-ledger.mjs b/scripts/check-auth-mount-ledger.mjs index 496075b88c..fa0d1fbd9b 100644 --- a/scripts/check-auth-mount-ledger.mjs +++ b/scripts/check-auth-mount-ledger.mjs @@ -139,16 +139,12 @@ export const EXIT_NOT_MEASURED = 2; * entry and the gate then fails if the entry is still here. */ export const PENDING_DISPOSITION = [ - { - route: 'POST /api/v1/auth/set-initial-password', - issue: '#10975', - why: - 'Disposition escalated on #10534 rather than guessed: `server-only` would claim an intent ' + - "the route's own peer group contradicts (its three sibling URLs in the same createAuthClient " + - 'are all ledgered `sdk`), and `gap` is ratcheted to <= 0. Maintainer ruling 2026-08-22: ' + - 'option C -- add `auth.setInitialPassword` to ObjectStackClient (#10974), THEN ledger the ' + - 'row as `sdk` (#10975, blocked-by #10974). This entry is deleted by #10975.', - }, + // EMPTY, and that is the ratchet having come down rather than a list nobody + // uses. Its one entry -- `POST /api/v1/auth/set-initial-password`, granted by + // the maintainer ruling of 2026-08-22 -- was deleted when #10974/#10975 + // landed its disposition: an `sdk` row naming `auth.setInitialPassword`. The + // gate would fail (`resolved-pending`) if the entry had been left behind, so + // this deletion is the landing half of that ruling, not tidying. ]; /** Shrink-only. Raising it is a maintainer decision, not a repair. */ @@ -167,8 +163,8 @@ export const MIN_NOTE_CHARS = 60; * row in `AUTH_ROUTE_LEDGER`, which grows as routes are added and is no ratchet. * But both paths that touch PENDING_DISPOSITION expand a shrink-only exemption * list, and neither is the landing author's to take. Refusing them outright would - * be the stronger shape and would also be FALSE: the list has a legitimate entry, - * granted by a maintainer ruling. There is a real act here with a real owner, so + * be the stronger shape and would also be FALSE: the list HAS held a legitimate + * entry, granted by a maintainer ruling. There is a real act here with a real owner, so * the honest shape is to name the owner rather than to deny the act -- the same * reading `check-skills-token-ratchet.mjs` records for its published-catalog * ceiling (#10473). @@ -391,8 +387,9 @@ function dispositionDemand(route) { ' a PENDING_DISPOSITION entry naming that issue, and stays printed on every clean run.', '', ` ${RATCHET_AUTHORITY} -- adding \`${route}\` to PENDING_DISPOSITION is an EXEMPTION from this`, - ' gate, and it is not yours to grant yourself. That list is shrink-only, its one entry exists', - ' because a maintainer ruled on it (#10534, 2026-08-22), and an author who quietly adds their', + ' gate, and it is not yours to grant yourself. That list is shrink-only and is EMPTY today; the', + ' one entry it has ever held was there because a maintainer ruled on it (#10534, 2026-08-22)', + ' and came off when that disposition landed. An author who quietly adds their', ' own route has done the single thing that turns this gate into a parking space: the mount is', ' then "accounted for" by a line recording that nobody decided. Escalating costs a round; a', ' self-granted exemption costs the gate. There IS a legitimate act here -- it just has an',