Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/client-auth-set-initial-password.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@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.

`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 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.
22 changes: 22 additions & 0 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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',
Expand All@@ -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) {
Expand Down
31 changes: 22 additions & 9 deletions packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
//
Expand DownExpand Up@@ -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 —
Expand Down
25 changes: 11 additions & 14 deletions scripts/check-auth-mount-ledger.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. */
Expand All@@ -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).
Expand DownExpand Up@@ -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',
Expand Down
Loading