diff --git a/.changeset/sys-account-issuer-retired.md b/.changeset/sys-account-issuer-retired.md new file mode 100644 index 0000000000..cdfcd0ba9d --- /dev/null +++ b/.changeset/sys-account-issuer-retired.md @@ -0,0 +1,94 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/plugin-auth": minor +"@objectstack/client": minor +"@objectstack/cli": minor +"@objectstack/spec": minor +--- + +feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + +**BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` +drops six published symbols. Shipped as `minor` under the launch-window convention +(`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this +banner plus the ADR-0087 disposition above). The hand-migration prescription is +registered under protocol major 18 as `sys-account-issuer-retired`. + +better-auth `1.7.3` removed the issuer-scoped account identity outright +(`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, +`accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the +`account.issuer` column and its unique index are gone from `get-tables`. There is no +drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is +the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + +## 迁移:FROM → TO + +| FROM | TO | the one-line fix | +|:--|:--|:--| +| `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | +| reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | +| `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | +| `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | +| `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | +| `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + +## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + +Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a +**narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in +`issuer` are legal under the old key and are ONE account under the new one. + +``` +os migrate account-issuer # read-only; exits non-zero when the drop must not proceed +# … take a backup (the operator's act, and the apply step's precondition) … +os migrate apply --allow-destructive +os migrate account-issuer # post-check: reads zero +``` + +The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a +plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and +lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration +without the constraint — and on such a database the drop does not fail loudly, it +degrades silently: the rows become indistinguishable and a sign-in can resolve onto the +wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight +and refuses the drop before writing any DDL. A read that throws, or a scan that +truncates, refuses too — an unread table is not a clean one. + +⛔ Colliding rows are never merged or dropped for you: which row survives is application +knowledge, and two different people can be behind one colliding key. Keep the row whose +provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + +The boot refusal is unchanged and needs no new machinery: a runtime already refuses to +start against unapplied destructive drift, naming the command to run, and never +auto-migrates. + +## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + +This is the one case `issuer` still discriminated. After the drop no column records which +IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one +had already issued to somebody else, the key resolves that sign-in onto the other +person's account. Under the old key that failed loudly (`unable_to_link_account`); under +the new one it is silent. + +⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still +bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account +bindings first; each user re-links on their next sign-in. + +## Why the column was a liability, not an asset + +A credential row whose `issuer` was not the local credential issuer was invisible to +`findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not +found" warn pointing at the `sys_user` row rather than at the account. **Four checklist +items had that recorded as a knownGap, each rediscovering it.** Its discriminating power +here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: +true }`, so `provider_id → issuer` is a function within an environment. + +## Also in this change + +`pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and +still resolves every named symbol — its self-test re-anchors from the now-retired +`@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two +deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it +is a generic driver capability serving five UNIQUE members of the >768-char class. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index afccd682f0..caa1d19199 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -72,7 +72,7 @@ as an engine when it declares a write door whose first parameter is named narrow one (`Partial>`) carry it. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 45 of them — better than a fifth +typed `any` have no type to read, and there are 44 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **67 of the 223 sites are spelled that way**. A +forwarding shim cannot, and **67 of the 222 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **223** | +| 175 write call sites | quoted in the merged changeset | **222** | | 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **149 of 223** decidable, **74** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 105 decidably elevated, 0 decidably not, 101 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **148 of 222** decidable, **74** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 104 decidably elevated, 0 decidably not, 101 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -160,18 +160,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 45 sites reached +one, and both are counted in the generated tables below: the 44 sites reached through an erased (`any`) receiver, and the 40 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -105 of 223 (47%) as decidably elevated, with 101 more whose elevation is a +104 of 222 (47%) as decidably elevated, with 101 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `9 / 223`, and say what it is**: the sites whose options argument was +⇒ **Cite `9 / 222`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -183,28 +183,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **223** | -| …whose object name is statically decidable | 149 | +| write call sites on the application surface | **222** | +| …whose object name is statically decidable | 148 | | …whose object name is chosen at run time | 74 | -| …against an object with tenancy ENABLED | 149 | +| …against an object with tenancy ENABLED | 148 | | …against an object that declares tenancy off | 0 | -| threading a tenant context | 139 | +| threading a tenant context | 138 | | PROVABLY carrying none (options read, no context key) | **17** | | …of those, against a decidably tenancy-enabled object | **9** | | options argument UNREADABLE — may or may not carry one | 67 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 105 | +| threading a decidably ELEVATED (`isSystem`) context | 104 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 101 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 178 | -| receiver erased, placed by the object NAME | 19 | +| receiver erased, placed by the object NAME | 18 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 | -| object name spelled inline | 109 | +| object name spelled inline | 108 | | object name spelled through a `const` | 40 | | object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 55 | @@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-11 at `06215b074`. +Measured on 2026-09-12 at `bbeb190f6`. | corpus scale (not enforced) | count | | :--- | ---: | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index ef136b4152..111af3963c 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 223 | -| Object name statically decidable | 149 | +| Write call sites | 222 | +| Object name statically decidable | 148 | | Object name chosen at run time | 74 | -| Against a tenancy-enabled object | 149 | +| Against a tenancy-enabled object | 148 | | Against an object declaring tenancy off | 0 | -| Threading a tenant context | 139 | +| Threading a tenant context | 138 | | Provably carrying none | 17 | | …and decidably tenancy-enabled | 9 | | Options argument unreadable | 67 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 105 | +| Threading a decidably elevated context | 104 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 101 | @@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-11 at `06215b074`. +Measured on 2026-09-12 at `bbeb190f6`. | corpus scale (not enforced) | count | | :--- | ---: | @@ -89,7 +89,6 @@ Measured on 2026-09-11 at `06215b074`. | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_user` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 | -| `packages/plugins/plugin-auth/src/backfill-account-issuer.ts` | `update` | `sys_account` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | diff --git a/examples/app-showcase/src/security/demo-personas.ts b/examples/app-showcase/src/security/demo-personas.ts index 11bf927224..ca8e8108a3 100644 --- a/examples/app-showcase/src/security/demo-personas.ts +++ b/examples/app-showcase/src/security/demo-personas.ts @@ -40,19 +40,26 @@ * distinct people, a submitter who is not an approver, an out-of-office * delegation decided under the delegate's own identity — was stuck on it, and * each rediscovered the same non-obvious cause: a password hash is not enough. - * better-auth 1.7 keys accounts on `(issuer, accountId)`, so a - * credential row whose `issuer` is not the local credential issuer is INVISIBLE - * to sign-in, which then fails `INVALID_EMAIL_OR_PASSWORD` behind a misleading - * "User not found" — pointing at the row, which is fine, instead of at the - * account, which is not. + * A persona needs a better-auth ACCOUNT row, minted the way better-auth mints + * its own. * * `seed-approval-demo.ts` now provisions the credential account too * (`ensureCredentialAccount`), through better-auth's own `$context` — its - * hasher, its `internalAdapter.createAccount`, and the issuer READ OFF the dev - * admin's own credential row rather than re-spelled here. Reading it is what - * keeps this app from carrying a second copy of a constant `plugin-auth` owns: - * whatever better-auth minted for the admin in THIS runtime is by construction - * the issuer a sign-in will look the personas up under. + * hasher and its `internalAdapter.createAccount` — rather than a hand-written + * `sys_account` insert that would have to reproduce better-auth's hash format + * and column mapping. + * + * ⚠️ [#17440] Between better-auth 1.7.0 and 1.7.2 there was a third thing to + * get right, and it was the one that bit: accounts keyed on + * `(issuer, accountId)`, so a credential row whose `issuer` was not the local + * credential issuer was INVISIBLE to sign-in, which then failed + * `INVALID_EMAIL_OR_PASSWORD` behind a misleading "User not found" — pointing + * at the `sys_user` row, which is fine, instead of at the account, which is + * not. Four checklist items rediscovered that independently. 1.7.3 removed the + * issuer-scoped identity outright and `sys_account.issuer` retired with it, so + * the key is `(provider_id, account_id)` and that entire class of silent + * lockout is gone. Kept here as the reason this file says "a password hash is + * not enough" at all. * * Both sign in with {@link DEMO_PERSONA_PASSWORD}. * diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index 446ef517f8..99ed7939e7 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -91,12 +91,14 @@ interface AuthContextLike { createAccount: (account: { userId: string; providerId: string; - issuer: string; /** * The STABLE better-auth 1.7 spelling. `1.7.0-rc.2` briefly called this * `providerAccountId` and stable 1.7.0 renamed it back (#3002) — under * the rc.2 spelling the account row is written with no account id and * the persona silently stays un-loginable. + * + * With `providerId` it is the whole account identity again since 1.7.3 + * (#17440). */ accountId: string; password: string; @@ -176,43 +178,6 @@ async function assignPositions( } } -/** - * Read the issuer better-auth actually minted for the DEV ADMIN's local - * password account, so a persona's credential row is stamped with the same one. - * - * ## Why this is read and not written - * - * better-auth 1.7 keys account identity on `(issuer, accountId)`: - * `findAccountByKey` looks a credential up under the issuer better-auth mints - * for itself, so a row carrying any other value — or none — is INVISIBLE and - * sign-in fails `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn that - * points at the `sys_user` row, which is fine, rather than at the account, which - * is not. That one field is the whole reason a hand-hashed password was never - * enough, and it is what four checklist items had recorded as a knownGap. - * - * The value is `plugin-auth`'s to own (`CREDENTIAL_ISSUER` in - * `backfill-account-issuer.ts`), and an example app re-spelling a platform - * constant is how the two start disagreeing. So it is DERIVED from the admin - * account this same runtime already minted: whatever better-auth used there is - * by construction what a sign-in will look these personas up under. - * - * Undefined when it cannot be derived — never a guess. `backfill-account-issuer` - * makes the same call for the same reason: "a wrong issuer is indistinguishable - * from a missing one at sign-in, and it also occupies the unique slot the - * correct row needs." - */ -async function credentialIssuerFromAdmin( - ctx: ApprovalDemoContext, - adminUserId: string, -): Promise { - const account = await findOne(ctx, 'sys_account', { - user_id: adminUserId, - provider_id: 'credential', - }); - const issuer = account?.issuer; - return typeof issuer === 'string' && issuer.length > 0 ? issuer : undefined; -} - /** * Give a provisioned persona a better-auth credential account, so it can * actually SIGN IN (#9308 fixture 1). @@ -231,7 +196,6 @@ async function credentialIssuerFromAdmin( async function ensureCredentialAccount( ctx: ApprovalDemoContext, userId: string, - issuer: string, password: string, ): Promise { const existing = await findOne(ctx, 'sys_account', { @@ -250,7 +214,6 @@ async function ensureCredentialAccount( await authCtx.internalAdapter.createAccount({ userId, providerId: 'credential', - issuer, accountId: userId, password: hashed, }); @@ -395,11 +358,16 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { if (auditorId) await assignPositions(ctx, auditorId, ['auditor'], organizationId, 'auditor'); // [#9308 fixture 1] Make both personas SIGN-INABLE. Provisioning them as - // rows was never the hard half — the credential account was, and the issuer - // is the one field that decides whether better-auth can find it. Derived - // once from the admin's own account and reused for both personas; when it - // cannot be derived, nothing is written (a wrong issuer is worse than an - // absent one — see `credentialIssuerFromAdmin`). + // rows was never the hard half — the credential account was. + // + // ⚠️ [#17440] It used to be harder still: better-auth 1.7 keyed accounts on + // `(issuer, accountId)`, so this seed had to READ the issuer better-auth + // minted for the dev admin and stamp the personas with the same one — a + // wrong value was invisible at sign-in and indistinguishable from a missing + // one. 1.7.3 rolled that model back and `sys_account.issuer` retired with + // it, so the derivation, its failure branch and the whole class of silent + // lockout behind it are gone. `(provider_id, account_id)` is the key, and + // `internalAdapter.createAccount` supplies both. // // This is what turns "a second user exists" into "a second user can act": // the per-group 会签 demo needs Ada to decide the `finance` group under her @@ -407,17 +375,9 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { // viewer gating needs Mei to look at her own pending request, and an // out-of-office delegation is only falsifiable when the delegate holds a // separate bearer token. - const credentialIssuer = await credentialIssuerFromAdmin(ctx, adminId); - if (!credentialIssuer) { - ctx.logger?.warn?.( - '[showcase] approval-demo: could not derive the credential issuer from the dev admin — ' - + 'demo personas stay un-loginable (sign in as the admin instead)', - ); - } else { - for (const personaId of [submitterId, auditorId]) { - if (personaId) { - await ensureCredentialAccount(ctx, personaId, credentialIssuer, DEMO_PERSONA_PASSWORD); - } + for (const personaId of [submitterId, auditorId]) { + if (personaId) { + await ensureCredentialAccount(ctx, personaId, DEMO_PERSONA_PASSWORD); } } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 15bb78fee7..abccede21e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -175,9 +175,9 @@ export const SCAFFOLD_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild']; * finding. * * ⚠️ These four do NOT retire with the SCIM rc pin, even though one of them - * names scim. Stable `@better-auth/scim@1.7.2` still peers - * `@better-auth/utils@0.4.2` EXACTLY — re-read off the installed package - * manifest on 2026-08-31 for #13940, unchanged from 1.7.1 — so this skew + * names scim. Stable `@better-auth/scim@1.7.3` still peers + * `@better-auth/utils@0.4.2` EXACTLY — re-read (2026-09-11) off the + * installed package manifest, unchanged from 1.7.1 and 1.7.2 — so this skew * outlives that pin. They retire * when the four packages accept 0.5.0 upstream, or when * `SCAFFOLD_PNPM_RANGE` reaches `>=10.31` — pnpm 10.31 changed peer @@ -506,10 +506,11 @@ export function renderPnpmWorkspaceYaml( '# (the copy @objectstack/cli declares for its own sqlite tooling). That', '# peer is optional and covers handing better-auth a raw better-sqlite3', '# `Database`; ObjectStack hands it an ObjectQL adapter instead, so', - '# nothing here goes down that path. Re-measured on better-auth 1.7.2:', - '# no file in the published package references better-sqlite3 at all —', - '# it only accepts a Database you construct — so there is no call site', - '# that could depend on what changed between 12 and 13. The upstream', + '# nothing here goes down that path. Re-measured 2026-09-11 on', + '# better-auth 1.7.3: nothing in the published package references', + '# better-sqlite3 except that peer declaration itself — it only accepts', + '# a Database you construct — so there is no call site that could', + '# depend on what changed between 12 and 13. The upstream', '# range is stale; pinning back to 12 would just install a second,', '# unused native copy.', '#', diff --git a/packages/cli/src/commands/migrate/account-issuer.ts b/packages/cli/src/commands/migrate/account-issuer.ts new file mode 100644 index 0000000000..44706fd059 --- /dev/null +++ b/packages/cli/src/commands/migrate/account-issuer.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Command, Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { + printHeader, + printSuccess, + printWarning, + printError, + printInfo, + printStep, + createTimer, + emitJson, + errorCodeFields, +} from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; + +/** + * `os migrate account-issuer` — the PLAN leg of the `sys_account.issuer` + * retirement (#17440). + * + * ## Where this sits in the ceremony, and why it is not a fourth one + * + * ADR-0131 D10 fixes the shape: *plan → backup → apply → post-check, per-table, + * idempotent + resumable, with a boot refusal behind it, never an automatic + * boot step* — and says in the same breath that it *"reuses the ADR-0120 D4 + * migration ceremony where it exists (index and column changes) rather than + * inventing a second one."* + * + * A column drop plus an index re-key is exactly what ADR-0120 D4 covers, and + * this repository already ships all four legs of it for that class: + * + * | leg | what already runs it | + * |:-----------|:-----------------------------------------------------------------------| + * | plan | `os migrate plan` reports the drop as destructive drift. **This command adds the row-level pre-flight D4 requires beside it.** | + * | backup | the operator's act and the apply step's stated precondition — the platform never takes one for them | + * | apply | `os migrate apply --allow-destructive`, which now REFUSES this particular drop while the pre-flight is dirty | + * | post-check | re-run this command; it reads zero and the drop is authorised | + * | boot refusal | already shipped: `runArtifactBootMigrationGate` fails the boot on unapplied destructive drift, naming the command to run (`os serve` never auto-migrates) | + * + * ⇒ So this file is deliberately the smallest thing that was missing: the + * read-only duplicate pre-flight ADR-0120 D4 asks for on an index change, for + * a key that is being NARROWED. Inventing an `os migrate account-issuer + * --apply` that dropped the column itself would be the second ceremony D10 + * forbids, and it would drop the column outside the drift reconciler that owns + * every other column drop. + * + * ## Why read-only, with no repair arm at all + * + * Which row survives a collision is application knowledge — two different + * people can be behind those two rows. So this inventories and prescribes, in + * the `os migrate duplicates` tradition: *"never renumbers, deduplicates or + * rewrites anything."* + * + * ## No `sys_migration` flag, deliberately + * + * `os migrate summary-nulls` documents the rule: a deployment flag nothing + * reads is a fact nothing reads. The consumer of this verdict is the destructive + * drift gate in `os migrate apply`, which re-runs the probe against the live + * database at the moment it matters rather than trusting a row written earlier — + * and a row that says "clean on Tuesday" authorises nothing on Thursday. + */ +export default class MigrateAccountIssuer extends Command { + // The tracker id stays in this comment and out of the string below: a + // command description reaches operators, who have no tracker to resolve + // `#NNNN` against (`check:doc-authoring`). This command is #17440's. + static override description = + 'Pre-flight the retirement of sys_account.issuer: report every (provider_id, account_id) ' + + 'key held by more than one row — the class that is legal under the retired (issuer, account_id) key ' + + 'and is ONE account under the key better-auth 1.7.3 restored. Read-only; exits non-zero when the ' + + 'drop must not proceed.'; + + static override examples = [ + '$ os migrate account-issuer', + '$ os migrate account-issuer --json', + '$ os migrate account-issuer --max-records 1000000', + '$ os migrate account-issuer --database-url postgres://…', + ]; + + static override flags = { + 'database-url': Flags.string({ + description: 'Database URL to inspect (defaults to $OS_DATABASE_URL / the project DB)', + env: 'OS_DATABASE_URL', + }), + 'max-records': Flags.integer({ + description: + 'Row cap for the scan. Reaching it REFUSES rather than reporting a partial scan as clean.', + }), + json: Flags.boolean({ description: 'Output the report as JSON' }), + }; + + async run(): Promise { + const { flags } = await this.parse(MigrateAccountIssuer); + const timer = createTimer(); + + if (!flags.json) printHeader('Migrate · account-issuer pre-flight'); + + let stack; + try { + // Read-only boot, the `os migrate duplicates` shape: `deferSchemaDdl` + // holds back create-table/add-column DDL and suppresses the artifact + // seed, `readOnlyProbe` refuses to bring a missing sqlite file into + // existence. This command cannot change the install it describes. + stack = await bootSchemaStack({ + jsonOutput: flags.json, + ...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}), + deferSchemaDdl: true, + readOnlyProbe: true, + }); + } catch (error: any) { + if (flags.json) { + await emitJson({ error: 'boot_failed', detail: error?.message ?? String(error) }, 1, { compact: true }); + return; + } + printError(error?.message ?? String(error)); + this.exit(1); + return; + } + + try { + const { probeAccountIdentityCollisions, formatAccountIdentityPreflightReport } = + await import('@objectstack/plugin-auth'); + + const engine = (stack.kernel as { getService?: (n: string) => unknown }).getService?.call( + stack.kernel, + 'objectql', + ); + + if (!flags.json) printStep('Scanning sys_account…'); + const report = await probeAccountIdentityCollisions(engine as never, { + ...(flags['max-records'] != null ? { max: flags['max-records'] } : {}), + }); + + if (flags.json) { + await emitJson({ database: stack.dbLabel, ...report, duration: timer.elapsed() }); + if (!report.ok) this.exit(1); + return; + } + + printInfo(`Database: ${chalk.white(stack.dbLabel)}`); + console.log(''); + console.log(formatAccountIdentityPreflightReport(report)); + console.log(''); + + if (report.ok) { + printSuccess( + 'Pre-flight clean. Take a backup, then run "os migrate apply --allow-destructive" to drop the column.', + ); + console.log(chalk.dim(` ${timer.display()}`)); + return; + } + + printWarning( + 'REFUSED — sys_account.issuer must NOT be dropped on this database yet. Resolve the rows above ' + + '(keep the row whose provider account is live, delete the rest so a fresh sign-in re-links), ' + + 'then re-run this command as the post-check.', + ); + printWarning( + 'Nothing is merged or deleted for you: which row survives is application knowledge, and two ' + + 'different people can be behind one colliding key.', + ); + this.exit(1); + } catch (error: any) { + // A refusal from the probe itself (unreadable table, truncated scan) + // lands here and stays a refusal — it is never softened into a clean run. + if (flags.json) { + await emitJson({ error: error?.message ?? String(error), ...errorCodeFields(error) }, 1, { compact: true }); + return; + } + printError(error?.message ?? String(error)); + this.exit(1); + } finally { + await stack.shutdown(); + } + } +} diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 31275cc5d3..8e3b022798 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -324,6 +324,81 @@ export default class MigrateApply extends Command { return; } + // ── [#17440] REFUSE THE sys_account.issuer DROP ON A DIRTY TABLE ── + // + // Same placement rule as the gate above, for the same reason: BELOW the + // report (the operator sees the plan), ABOVE the confirmation prompt (an + // operator is never asked to confirm a run this command has already + // decided to refuse) and ABOVE both writes. + // + // Account uniqueness moved from `(issuer, account_id)` to + // `(provider_id, account_id)` when better-auth 1.7.3 rolled the + // issuer-scoped identity back. That is a NARROWER key, so rows that were + // legal under the old one can be one account under the new one. + // + // ⛔ The detection mechanism is deliberately not "drop it and let the + // constraint fail". On this table it would not fail: `syncDeclaredIndexes` + // logs a plain UNIQUE whose CREATE fails on existing duplicates and lets + // the boot continue (#14902 / #15479), so a database holding the class + // carries no such constraint to violate. The drop would simply make the + // rows indistinguishable and let a sign-in resolve onto the wrong user's + // account — silently. Hence a row-level pre-flight, run against the live + // database at the moment it matters. + const dropsAccountIssuer = intended.some( + (d) => d.op?.type === 'drop_column' && d.table === 'sys_account' && d.op.column === 'issuer', + ); + if (dropsAccountIssuer) { + const { probeAccountIdentityCollisions, formatAccountIdentityPreflightReport } = + await import('@objectstack/plugin-auth'); + const engine = (stack.kernel as { getService?: (n: string) => unknown }).getService?.call( + stack.kernel, + 'objectql', + ); + let preflight; + try { + preflight = await probeAccountIdentityCollisions(engine as never); + } catch (e: any) { + // A pre-flight that could not read is not a pre-flight that passed. + const detail = e?.message ?? String(e); + if (flags.json) { + await emitJson({ + database: stack.dbLabel, created: [], applied: [], skipped: drift, pending, + message: 'refused_account_issuer_preflight_unreadable', detail, + }, 0, { compact: true }); + return; + } + printError( + `Refusing to drop sys_account.issuer: the retirement pre-flight could not read the table. ${detail}`, + ); + this.exit(1); + return; + } + if (!preflight.ok) { + if (flags.json) { + await emitJson({ + database: stack.dbLabel, created: [], applied: [], skipped: drift, pending, + message: 'refused_account_issuer_collisions', preflight, + }, 0, { compact: true }); + return; + } + console.log(''); + console.log(formatAccountIdentityPreflightReport(preflight)); + console.log(''); + printError( + 'Refusing to drop sys_account.issuer: dropping it would make the rows above ' + + 'indistinguishable, and a sign-in could resolve onto the wrong account. Resolve them, then ' + + 're-run "os migrate account-issuer" as the post-check before applying.', + ); + this.exit(1); + return; + } + if (!flags.json) { + printSuccess( + `sys_account.issuer retirement pre-flight: clean over ${preflight.scanned} row(s).`, + ); + } + } + const totalIntended = intended.length + pending.length; if (totalIntended === 0) { if (flags.json) { await emitJson({ applied: [], skipped: deferred, created: [], message: 'nothing_safe_to_apply' }, 0, { compact: true }); return; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c1f56dd83a..1269d214d7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -31,6 +31,10 @@ export { default as MigrateResumeCommand } from './commands/migrate/resume.js'; // #4556: rewrite the legacy `'system'` sentinel in // `sys_metadata_history.recorded_by` to NULL, through the same journal. export { default as MigrateRecordedByCommand } from './commands/migrate/recorded-by.js'; +// #17440: the read-only row pre-flight ADR-0120 D4 requires before +// `sys_account.issuer` is dropped and account identity re-keys onto +// (provider_id, account_id). +export { default as MigrateAccountIssuerCommand } from './commands/migrate/account-issuer.js'; // ─── Environments topic subcommands ───────────────────────────────── export { default as EnvironmentsListCommand } from './commands/environments/list.js'; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a2631376b7..fa47e56119 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3712,7 +3712,10 @@ export class ObjectStackClient { * wrong-but-plausible answer, silently. `list-members` reads * `ctx.query.organizationId` and its rows carry the identical shape * ({@link OrganizationMemberWithUserWire}), so only the addressing moved. - * Measured against better-auth 1.7.2 over a real `AuthManager` + `SqlDriver`. + * Measured 2026-09-09 (#16761) against the then-installed better-auth + * 1.7.2, over a real `AuthManager` + `SqlDriver`. The drive is what made + * this reading, so it is anchored rather than restamped: the family has + * since lifted to 1.7.3 and this drive has not been re-run against it. * * What an existing caller sees change, all of it measured on the same drive: * @@ -4747,12 +4750,14 @@ export class ObjectStackClient { return { accounts: accounts as Array<{ id: string; providerId: string; - /** Authority that vouched for `accountId` — an OIDC issuer, or `local:…`. */ - issuer: string; /** - * The user's id at the provider. `1.7.0-rc.2` briefly published this - * as `providerAccountId`; stable 1.7 answers with `accountId` again - * (#3002), which is what this route returns today. + * The user's id at the provider. With `providerId` it is the WHOLE + * account identity: better-auth 1.7.3 rolled the issuer-scoped key + * back, so the `issuer` this route used to return is gone (#17440). + * + * `1.7.0-rc.2` briefly published this field as `providerAccountId`; + * stable 1.7 answers with `accountId` again (#3002), which is what + * this route returns today. */ accountId: string; createdAt?: string; diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index e71082aa68..16591714ad 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -406,10 +406,6 @@ export const enObjects: NonNullable = { label: "Provider ID", help: "OAuth provider identifier (google, github, etc.)" }, - issuer: { - label: "Issuer", - help: "Authority that vouched for the provider account id — an OIDC issuer, or local:… for providers without one" - }, account_id: { label: "Provider Account ID", help: "User's ID in the provider's system" diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 4e11a53ecd..432b9d7182 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -406,10 +406,6 @@ export const esESObjects: NonNullable = { label: "ID del proveedor", help: "Identificador del proveedor OAuth (google, github, etc.)." }, - issuer: { - label: "Emisor", - help: "Autoridad que avaló el id de cuenta del proveedor: un emisor OIDC, o local:… para proveedores que no tienen uno" - }, account_id: { label: "ID de cuenta del proveedor", help: "ID del usuario en el sistema del proveedor." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index f1edefb714..537f0c79de 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -406,10 +406,6 @@ export const jaJPObjects: NonNullable = { label: "プロバイダー ID", help: "OAuth プロバイダー識別子(google、github など)" }, - issuer: { - label: "発行者", - help: "プロバイダーのアカウント ID を保証した発行主体 — OIDC の issuer、または issuer を持たないプロバイダーの場合は local:…" - }, account_id: { label: "プロバイダーアカウント ID", help: "プロバイダーシステム内のユーザー ID" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 8acbb30403..7e9b0c312d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -406,10 +406,6 @@ export const zhCNObjects: NonNullable = { label: "提供方 ID", help: "OAuth 提供方标识(google、github 等)" }, - issuer: { - label: "颁发者", - help: "为该提供方账号 ID 背书的权威方 —— OIDC 的 issuer,或对自身没有 issuer 的提供方使用 local:…" - }, account_id: { label: "提供方账号 ID", help: "用户在该提供方系统中的 ID" diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index f19824f910..786dd6a956 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -154,33 +154,6 @@ export const SysAccount = ObjectSchema.create({ description: 'OAuth provider identifier (google, github, etc.)', }), - // better-auth 1.7 keys account identity on (issuer, account_id) rather than - // on the provider id alone: the issuer names the authority that vouched for - // that id — an OIDC `iss` claim for federated logins, or a synthetic - // `local:credential` / `local:oauth:` for providers that have - // none. better-auth writes it on every new account; rows created before the - // 1.7 upgrade are stamped at boot by the auth plugin's issuer backfill. - // - // Deliberately NOT `required` even though better-auth always supplies it: a - // NOT NULL column cannot be added to a table that already holds rows, and - // schema sync runs before the backfill. - // [#11374] Bound = 2048, transitively from sys_sso_provider.issuer - // (maxLength: 2048, the landed contract for the widest producer): the SSO - // OIDC path writes the verified token's raw `iss` claim — or the provider's - // registered issuer — verbatim into this column, and SAML entityIDs are - // capped at 1024 by SAML metadata. Anything tighter would refuse a sign-in - // that sys_sso_provider's own contract admits. 2048 exceeds the 768-char - // utf8mb4 key-part ceiling, so this column deliberately stays TEXT and the - // (issuer, account_id) unique index still cannot exist on MySQL — that is - // #11627's hash-shadow-key territory, not a reason to guess a tighter - // number here. - issuer: Field.text({ - label: 'Issuer', - required: false, - maxLength: 2048, - description: 'Authority that vouched for the provider account id — an OIDC issuer, or local:… for providers without one', - }), - account_id: Field.text({ label: 'Provider Account ID', required: true, @@ -327,11 +300,19 @@ export const SysAccount = ObjectSchema.create({ indexes: [ { fields: ['user_id'], unique: false }, + // #17440 — the ONLY account identity key. better-auth 1.7.3 rolled the + // issuer-scoped identity back (better-auth/better-auth#10909): + // `AccountKey` is `(providerId, accountId)` again and `account.issuer` is + // gone from its `get-tables`, so this pair is what `findAccountByKey` + // resolves on and what the physical table must enforce. + // + // ⚠️ It is NARROWER than the pair it replaces. Two rows sharing + // (provider_id, account_id) and differing only in the retired `issuer` + // were legal under the old key and collide under this one — which is why + // the column drop is gated behind the `os migrate` preflight + // (`sys-account-issuer-retirement`), never behind this constraint failing + // mid-apply. { fields: ['provider_id', 'account_id'], unique: true }, - // better-auth 1.7 resolves accounts by (issuer, accountId) and - // declares that pair unique on its own `account` table — mirror it here so - // the physical table enforces the same identity key the auth code assumes. - { fields: ['issuer', 'account_id'], unique: true }, ], enable: { diff --git a/packages/platform-objects/src/platform-keyed-text-bounds.test.ts b/packages/platform-objects/src/platform-keyed-text-bounds.test.ts index f2e3e91460..18148d0a1e 100644 --- a/packages/platform-objects/src/platform-keyed-text-bounds.test.ts +++ b/packages/platform-objects/src/platform-keyed-text-bounds.test.ts @@ -36,9 +36,15 @@ import * as PlatformObjects from './index'; * EXPRESSIBLE after #11627 (it moves onto a SHA-256 hash-shadow column) while a * non-unique one is not: hashing destroys the ordering and prefix structure an * access path is for, so there is no fallback and the column itself must be - * keyable. `sys_account.issuer` (bounded at 2048) is the live illustration that - * the two rules are independent — it passes the gate and is out of this - * describe's scope because its index is unique. + * keyable. `sys_oauth_access_token.token` (bounded at 1024) is the live + * illustration that the two rules are independent — it passes the gate and is + * out of this describe's scope because its index is unique. + * + * ⚠️ [#17440] That illustration used to be `sys_account.issuer` (bounded at + * 2048), which retired with better-auth's account-issuer rollback. The + * replacement is a MEASURED live member of the same class, not a plausible + * name: an example this header cites has to keep passing the gate and keep + * being out of scope, or the paragraph quietly stops describing anything. * * The gate deliberately does not fold this in; its header says so. */ diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 4c15dce945..cea37b9e32 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -26,10 +26,10 @@ "gen:test-typecheck-debt": "tsx ../../../scripts/check-test-typecheck.mts --update --package packages/plugins/plugin-auth --project tsconfig.test.json" }, "dependencies": { - "@better-auth/core": "1.7.2", - "@better-auth/oauth-provider": "1.7.2", - "@better-auth/scim": "1.7.2", - "@better-auth/sso": "1.7.2", + "@better-auth/core": "1.7.3", + "@better-auth/oauth-provider": "1.7.3", + "@better-auth/scim": "1.7.3", + "@better-auth/sso": "1.7.3", "@noble/hashes": "^2.3.0", "@objectstack/core": "workspace:*", "@objectstack/platform-objects": "workspace:*", @@ -37,7 +37,7 @@ "@objectstack/service-messaging": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "better-auth": "1.7.2", + "better-auth": "1.7.3", "jose": "^6.2.8" }, "devDependencies": { diff --git a/packages/plugins/plugin-auth/src/account-identity-preflight.test.ts b/packages/plugins/plugin-auth/src/account-identity-preflight.test.ts new file mode 100644 index 0000000000..b196d6dc88 --- /dev/null +++ b/packages/plugins/plugin-auth/src/account-identity-preflight.test.ts @@ -0,0 +1,449 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17440] The preflight that guards the retirement of `sys_account.issuer`, + * and the answer to the one case that column still discriminated. + * + * ## Why the collision fixture registers an INDEX-LESS `sys_account` + * + * This is the load-bearing decision in the file, and it is not a convenience. + * + * `sys_account` has declared `{ fields: ['provider_id', 'account_id'], unique: + * true }` since the object was created. Where that index is PHYSICALLY present + * the collision class cannot be inserted at all — which the `PREMISE` case + * below proves by trying, against the REAL object, and watching the driver + * refuse. + * + * So the only population that can hold the class is a deployment carrying the + * declaration without the constraint. That population is real and reachable: + * `syncDeclaredIndexes` logs a plain UNIQUE whose CREATE fails on existing + * duplicates onto the durability channel and lets the boot continue + * (#14902 / #15479), deliberately, so one dirty table cannot take a deployment + * down. `SYS_ACCOUNT_NO_UNIQUE` below is that deployment, spelled as a fixture + * — the same shape with the unique index absent. + * + * ⇒ The two cases are a pair. The premise case says "where the index exists, + * this class cannot arrive"; the refusal cases say "where it does not, the + * preflight finds it and stops". Either alone would be misleading. + * + * ## What "watched refusing" means here + * + * Every refusal case asserts the ADR-0112 envelope (`code` + `status`) and the + * substance of the message, never a bare `toThrow()`. A bare throw assertion + * would pass on an unrelated `TypeError` from a fixture that never reached the + * probe — which is exactly how a preflight gets believed without ever running. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + probeAccountIdentityCollisions, + assertNoAccountIdentityCollisions, + formatAccountIdentityPreflightReport, + refuseIssuerRepointWithLiveBindings, + AccountIdentityPreflightRefusal, + SYS_ACCOUNT_OBJECT, +} from './account-identity-preflight.js'; +import { authIdentityObjects } from './manifest.js'; + +const SYSTEM = { context: { isSystem: true } } as never; + +/** + * `sys_account` as a deployment whose declared `(provider_id, account_id)` + * UNIQUE was never physically created — the #14902 / #15479 population. Only + * the columns the probe reads are spelled. + */ +const SYS_ACCOUNT_NO_UNIQUE = { + name: 'sys_account', + label: 'Account', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + provider_id: { name: 'provider_id', type: 'text' as const }, + account_id: { name: 'account_id', type: 'text' as const }, + issuer: { name: 'issuer', type: 'text' as const }, + user_id: { name: 'user_id', type: 'text' as const }, + }, +}; + +/** `sys_sso_provider`, index-free, for the re-point guard's fixtures. */ +const SYS_SSO_PROVIDER_FIXTURE = { + name: 'sys_sso_provider', + label: 'SSO Provider', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + provider_id: { name: 'provider_id', type: 'text' as const }, + issuer: { name: 'issuer', type: 'text' as const }, + }, +}; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + const engine = engines.pop(); + try { + await (engine as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +async function bootEngine(objects: unknown[]): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), + true, + ); + await engine.init(); + for (const object of objects) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + return engine; +} + +/** The index-less deployment: the only one that can hold the collision class. */ +const bootDirtyCapable = () => + bootEngine([SYS_ACCOUNT_NO_UNIQUE, SYS_SSO_PROVIDER_FIXTURE]); + +const insertAccount = ( + engine: ObjectQL, + row: { id: string; provider_id: string; account_id: string; issuer?: string | null; user_id: string }, +) => engine.insert(SYS_ACCOUNT_OBJECT, row as never, SYSTEM); + +describe('#17440 PREMISE — where the declared unique EXISTS, the class cannot arrive', () => { + it('the retired column is gone from the real sys_account', async () => { + const engine = await bootEngine(authIdentityObjects); + // The retirement itself, pinned at the object: a write naming `issuer` is + // refused as an undeclared field. This is what makes the index-less + // fixture above a MODEL of an existing deployment rather than a copy of + // the current object. + await expect( + engine.insert( + SYS_ACCOUNT_OBJECT, + { id: 'acc_0', provider_id: 'okta', account_id: 'sub-0', issuer: 'https://a.example', user_id: 'usr_a' } as never, + SYSTEM, + ), + ).rejects.toThrow(/Unknown field 'issuer' on object 'sys_account'/); + }); + + it('the real sys_account refuses the second row of a collision', async () => { + // The real object, unique index and all — the platform's own declaration, + // which has carried (provider_id, account_id) UNIQUE since the object was + // created and therefore long before `issuer` ever arrived. + const engine = await bootEngine(authIdentityObjects); + await engine.insert( + SYS_ACCOUNT_OBJECT, + { id: 'acc_1', provider_id: 'okta', account_id: 'sub-1', user_id: 'usr_a' } as never, + SYSTEM, + ); + + // Same (provider_id, account_id) — the pair that WAS separable by issuer. + await expect( + engine.insert( + SYS_ACCOUNT_OBJECT, + { id: 'acc_2', provider_id: 'okta', account_id: 'sub-1', user_id: 'usr_b' } as never, + SYSTEM, + ), + ).rejects.toThrow(); + + // The control on that rejection: a DIFFERENT account_id under the same + // provider inserts fine, so the refusal above is the unique index and not + // a broken fixture. + await expect( + engine.insert( + SYS_ACCOUNT_OBJECT, + { id: 'acc_3', provider_id: 'okta', account_id: 'sub-2', user_id: 'usr_b' } as never, + SYSTEM, + ), + ).resolves.toBeTruthy(); + }); +}); + +describe('#17440 the preflight REFUSES on the collision class', () => { + it('refuses, naming the rows, when one key is held by two rows differing only in issuer', async () => { + const engine = await bootDirtyCapable(); + await insertAccount(engine, { id: 'acc_1', provider_id: 'okta', account_id: 'sub-1', issuer: 'https://old.example', user_id: 'usr_alice' }); + await insertAccount(engine, { id: 'acc_2', provider_id: 'okta', account_id: 'sub-1', issuer: 'https://new.example', user_id: 'usr_bob' }); + await insertAccount(engine, { id: 'acc_3', provider_id: 'credential', account_id: 'usr_alice', issuer: 'local:credential', user_id: 'usr_alice' }); + + const report = await probeAccountIdentityCollisions(engine as never); + + expect(report.scanned, 'every row was read').toBe(3); + expect(report.ok, 'the database is NOT clean').toBe(false); + expect(report.collisions).toHaveLength(1); + expect(report.collisions[0]).toMatchObject({ + providerId: 'okta', + accountId: 'sub-1', + issuers: ['https://new.example', 'https://old.example'], + rowIds: ['acc_1', 'acc_2'], + userIds: ['usr_alice', 'usr_bob'], + crossUser: true, + }); + expect(report.crossUser, 'the collision spans two people').toBe(1); + + // The refusal itself — watched, with its envelope. + let refusal: AccountIdentityPreflightRefusal | undefined; + try { + assertNoAccountIdentityCollisions(report); + } catch (e) { + refusal = e as AccountIdentityPreflightRefusal; + } + expect(refusal, 'assertNoAccountIdentityCollisions REFUSED').toBeInstanceOf( + AccountIdentityPreflightRefusal, + ); + expect(refusal!.code).toBe('RESOURCE_CONFLICT'); + expect(refusal!.status).toBe(409); + expect(refusal!.message).toContain('held by'); + expect(refusal!.message, 'the cross-user danger is named, not just counted').toContain( + 'sign in and resolve to the other one', + ); + expect(refusal!.message, 'nothing is repaired for the operator').toContain( + 'Nothing is merged or deleted for you', + ); + + // The operator-facing report carries the identifying detail. + const text = formatAccountIdentityPreflightReport(report); + expect(text).toContain('okta / sub-1'); + expect(text).toContain('acc_1, acc_2'); + expect(text).toContain('CROSS-USER'); + }); + + it('flags a same-user collision WITHOUT the cross-user marker', async () => { + const engine = await bootDirtyCapable(); + await insertAccount(engine, { id: 'acc_1', provider_id: 'okta', account_id: 'sub-1', issuer: 'https://old.example', user_id: 'usr_alice' }); + await insertAccount(engine, { id: 'acc_2', provider_id: 'okta', account_id: 'sub-1', issuer: 'https://new.example', user_id: 'usr_alice' }); + + const report = await probeAccountIdentityCollisions(engine as never); + expect(report.ok).toBe(false); + expect(report.collisions).toHaveLength(1); + expect(report.collisions[0]!.crossUser, 'one person, two rows — dedupable, not a takeover').toBe(false); + expect(report.crossUser).toBe(0); + expect(() => assertNoAccountIdentityCollisions(report)).toThrow(AccountIdentityPreflightRefusal); + }); + + /** + * The control on every zero above. Without it "refuses" could mean "refuses + * whatever it is handed", and a preflight that always refuses is as useless + * as one that never does. + */ + it('CONTROL — a clean table passes, and the same rows minus the duplicate stop being a finding', async () => { + const engine = await bootDirtyCapable(); + await insertAccount(engine, { id: 'acc_1', provider_id: 'okta', account_id: 'sub-1', issuer: 'https://old.example', user_id: 'usr_alice' }); + await insertAccount(engine, { id: 'acc_2', provider_id: 'okta', account_id: 'sub-2', issuer: 'https://new.example', user_id: 'usr_bob' }); + await insertAccount(engine, { id: 'acc_3', provider_id: 'credential', account_id: 'usr_alice', issuer: null, user_id: 'usr_alice' }); + + const report = await probeAccountIdentityCollisions(engine as never); + expect(report.scanned).toBe(3); + expect(report.keys, 'three distinct keys').toBe(3); + expect(report.ok).toBe(true); + expect(report.collisions).toEqual([]); + expect(() => assertNoAccountIdentityCollisions(report)).not.toThrow(); + expect(formatAccountIdentityPreflightReport(report)).toContain('safe to drop'); + }); + + it('CONTROL — an empty table is clean, and says how much it read', async () => { + const engine = await bootDirtyCapable(); + const report = await probeAccountIdentityCollisions(engine as never); + expect(report).toMatchObject({ scanned: 0, keys: 0, ok: true, crossUser: 0 }); + }); +}); + +describe('#17440 the preflight refuses what it CANNOT read — an unread table is not a clean one', () => { + it('a read that throws refuses instead of reporting zero rows', async () => { + const broken = { + find: async () => { + throw new Error('connection reset by peer'); + }, + }; + let refusal: AccountIdentityPreflightRefusal | undefined; + try { + await probeAccountIdentityCollisions(broken as never); + } catch (e) { + refusal = e as AccountIdentityPreflightRefusal; + } + expect(refusal).toBeInstanceOf(AccountIdentityPreflightRefusal); + expect(refusal!.code).toBe('RESOURCE_CONFLICT'); + expect(refusal!.status).toBe(409); + expect(refusal!.message).toContain('Cannot enumerate sys_account'); + expect(refusal!.message, 'the underlying cause survives').toContain('connection reset by peer'); + expect(refusal!.message).toContain('Refusing rather than reporting an unread table as clean'); + }); + + it('a walk stopped by its row cap refuses instead of reporting a partial scan as clean', async () => { + const engine = await bootDirtyCapable(); + for (let i = 0; i < 5; i++) { + await insertAccount(engine, { id: `acc_${i}`, provider_id: 'credential', account_id: `u${i}`, issuer: 'local:credential', user_id: `u${i}` }); + } + + // Cap below the row count: the tail is unread, and the tail is exactly + // where the class could be hiding. + let refusal: AccountIdentityPreflightRefusal | undefined; + try { + await probeAccountIdentityCollisions(engine as never, { max: 2 }); + } catch (e) { + refusal = e as AccountIdentityPreflightRefusal; + } + expect(refusal).toBeInstanceOf(AccountIdentityPreflightRefusal); + expect(refusal!.message).toContain('without reaching the end of the table'); + expect(refusal!.message).toContain('--max-records'); + + // CONTROL — the same table under a cap that DOES reach the end is clean. + // Without this the refusal above could be "any cap refuses". + const ok = await probeAccountIdentityCollisions(engine as never, { max: 50 }); + expect(ok).toMatchObject({ scanned: 5, ok: true }); + }); + + it('an engine that answers a non-array refuses rather than reading it as empty', async () => { + const enveloped = { find: async () => ({ records: [] }) }; + await expect(probeAccountIdentityCollisions(enveloped as never)).rejects.toThrow( + /not an array/, + ); + }); + + it('no engine at all refuses', async () => { + await expect(probeAccountIdentityCollisions(undefined as never)).rejects.toThrow( + /no readable ObjectQL engine/, + ); + }); +}); + +/** + * ## The re-pointed-provider answer, pinned + * + * **A `provider_id` re-pointed at a different IdP must have its account + * bindings rebuilt. No key separates them, and after the column drop nothing + * can.** + * + * The first case pins WHY (the key genuinely cannot tell the two apart); the + * rest pin the enforcement that follows from it. + */ +describe('#17440 re-pointed provider — the key cannot separate old bindings from new', () => { + it('THE ANSWER: two rows under one provider_id differing only in issuer are ONE key', async () => { + const engine = await bootDirtyCapable(); + // The shape a re-point leaves behind: `okta` pointed at IdP-A when Alice + // linked, at IdP-B when Bob did, and IdP-B minted a `sub` IdP-A had + // already issued to somebody else. + await insertAccount(engine, { id: 'acc_alice', provider_id: 'okta', account_id: 'sub-7', issuer: 'https://idp-a.example', user_id: 'usr_alice' }); + await insertAccount(engine, { id: 'acc_bob', provider_id: 'okta', account_id: 'sub-7', issuer: 'https://idp-b.example', user_id: 'usr_bob' }); + + const report = await probeAccountIdentityCollisions(engine as never); + + // ONE key, TWO rows, TWO issuers, TWO people. The retired column is the + // only thing that ever told them apart, and it is the column being dropped. + expect(report.keys, 'the two rows share a single (provider_id, account_id)').toBe(1); + expect(report.collisions).toHaveLength(1); + expect(report.collisions[0]!.issuers).toEqual(['https://idp-a.example', 'https://idp-b.example']); + expect(report.collisions[0]!.crossUser, 'a sign-in here resolves to the wrong person').toBe(true); + + // ⇒ so the migration refuses, and the remedy is a rebuild of the bindings + // rather than a key that separates them. + expect(() => assertNoAccountIdentityCollisions(report)).toThrow( + /delete the rest so a fresh sign-in re-links/, + ); + }); + + it('the guard REFUSES an issuer change while accounts are still bound to that provider', async () => { + const engine = await bootDirtyCapable(); + await engine.insert( + 'sys_sso_provider', + { id: 'sso_1', provider_id: 'okta', issuer: 'https://idp-a.example' } as never, + SYSTEM, + ); + await insertAccount(engine, { id: 'acc_alice', provider_id: 'okta', account_id: 'sub-7', issuer: 'https://idp-a.example', user_id: 'usr_alice' }); + + let refusal: AccountIdentityPreflightRefusal | undefined; + try { + await refuseIssuerRepointWithLiveBindings( + engine as never, + 'sys_sso_provider', + { id: 'sso_1', provider_id: 'okta', issuer: 'https://idp-a.example' }, + { issuer: 'https://idp-b.example' }, + ); + } catch (e) { + refusal = e as AccountIdentityPreflightRefusal; + } + expect(refusal, 'the re-point was REFUSED').toBeInstanceOf(AccountIdentityPreflightRefusal); + expect(refusal!.code).toBe('RESOURCE_CONFLICT'); + expect(refusal!.status).toBe(409); + expect(refusal!.message).toContain('cannot be re-pointed'); + expect(refusal!.message, 'the bound rows are named').toContain('acc_alice'); + expect(refusal!.message, 'the remedy is the rebuild, not a key').toContain( + 'Delete this provider\'s account bindings first', + ); + }); + + it('CONTROL — the same re-point is ALLOWED once nothing is bound to that provider', async () => { + const engine = await bootDirtyCapable(); + await engine.insert( + 'sys_sso_provider', + { id: 'sso_1', provider_id: 'okta', issuer: 'https://idp-a.example' } as never, + SYSTEM, + ); + // A binding under a DIFFERENT provider must not hold `okta` hostage. + await insertAccount(engine, { id: 'acc_other', provider_id: 'entra', account_id: 'sub-9', issuer: 'https://idp-c.example', user_id: 'usr_carol' }); + + await expect( + refuseIssuerRepointWithLiveBindings( + engine as never, + 'sys_sso_provider', + { id: 'sso_1', provider_id: 'okta', issuer: 'https://idp-a.example' }, + { issuer: 'https://idp-b.example' }, + ), + ).resolves.toBeUndefined(); + }); + + it('CONTROL — a write that does not MOVE the issuer is not a re-point', async () => { + const engine = await bootDirtyCapable(); + await insertAccount(engine, { id: 'acc_alice', provider_id: 'okta', account_id: 'sub-7', issuer: 'https://idp-a.example', user_id: 'usr_alice' }); + const existing = { id: 'sso_1', provider_id: 'okta', issuer: 'https://idp-a.example' }; + + // Same value rewritten. + await expect( + refuseIssuerRepointWithLiveBindings(engine as never, 'sys_sso_provider', existing, { + issuer: 'https://idp-a.example', + }), + ).resolves.toBeUndefined(); + // A patch that never mentions `issuer` at all. + await expect( + refuseIssuerRepointWithLiveBindings(engine as never, 'sys_sso_provider', existing, { + domain: 'acme.com', + }), + ).resolves.toBeUndefined(); + // A different object entirely. + await expect( + refuseIssuerRepointWithLiveBindings(engine as never, 'sys_user', existing, { + issuer: 'https://idp-b.example', + }), + ).resolves.toBeUndefined(); + }); + + it('the guard refuses when it cannot READ the bindings — never re-points blind', async () => { + const broken = { + find: async () => { + throw new Error('table is locked'); + }, + }; + let refusal: AccountIdentityPreflightRefusal | undefined; + try { + await refuseIssuerRepointWithLiveBindings( + broken as never, + 'sys_sso_provider', + { id: 'sso_1', provider_id: 'okta', issuer: 'https://idp-a.example' }, + { issuer: 'https://idp-b.example' }, + ); + } catch (e) { + refusal = e as AccountIdentityPreflightRefusal; + } + expect(refusal).toBeInstanceOf(AccountIdentityPreflightRefusal); + expect(refusal!.status).toBe(409); + expect(refusal!.message).toContain('Cannot check the account bindings'); + expect(refusal!.message).toContain('table is locked'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/account-identity-preflight.ts b/packages/plugins/plugin-auth/src/account-identity-preflight.ts new file mode 100644 index 0000000000..52e3127d37 --- /dev/null +++ b/packages/plugins/plugin-auth/src/account-identity-preflight.ts @@ -0,0 +1,412 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { keysetWalk } from '@objectstack/types'; + +/** + * #17440 — the read-only preflight that must refuse BEFORE `sys_account.issuer` + * is dropped. + * + * ## What changes, and why a column drop needs a preflight at all + * + * better-auth 1.7.3 removed the issuer-scoped account identity outright + * (better-auth/better-auth#10909): `AccountKey` is `(providerId, accountId)` + * again. So account uniqueness moves from `(issuer, account_id)` to + * `(provider_id, account_id)` — a NARROWER key. Two rows sharing + * `provider_id` + `account_id` and differing only in `issuer` are legal under + * the old key and are ONE account under the new one. + * + * ⛔ The detection mechanism is never "add the constraint and see what + * explodes". An operator whose identity table half-migrated at 2am is the worst + * outcome this change can produce, and on this particular table the explosion + * would not even happen — see the next section. So the class is measured first, + * on the rows, and a dirty read REFUSES. + * + * ## Why this cannot lean on the declared index, and why the drop is silent + * + * `sys_account` has declared `{ fields: ['provider_id', 'account_id'], unique: + * true }` since the object was created — it long predates `issuer`, which + * arrived only with the 1.7.0-rc.2 bump. On a deployment where that index is + * PHYSICALLY present the collision class is refused at write time and this + * probe reads zero. + * + * ⚠️ "Declared" is not "present". `syncDeclaredIndexes` logs a plain UNIQUE + * whose CREATE fails on existing duplicates onto the durability channel and + * lets the boot continue (#14902 / #15479) — deliberately, so one dirty table + * cannot take a deployment down. A database that ever held duplicates therefore + * carries the declaration and not the constraint, and can still hold the class + * today. + * + * On such a database the column drop does not blow up. It degrades silently: + * the rows become indistinguishable, `findAccountByKey` resolves whichever one + * the driver hands back first, and a sign-in can land on the wrong user's + * account. That is strictly worse than a failed apply, and it is the reason + * this probe reads ROWS and never the index declaration. + * + * ## Refusal discipline + * + * Two failures are deliberately NOT reported as "clean", because a preflight + * that cannot see is not a preflight that found nothing: + * + * 1. **A read that throws refuses.** The retired `backfill-account-issuer.ts` + * wrapped its reads in `try { … } catch { return [] }` — correct for an + * idempotent best-effort stamping pass that runs again next boot, and + * exactly wrong here, where the answer authorises an irreversible drop. + * 2. **A truncated walk refuses.** An unenumerated tail is not zero rows. + * + * ## What it deliberately does NOT do + * + * It repairs nothing. Which row survives a collision is application knowledge — + * two humans may be behind those two rows — so this inventories and prescribes, + * and the operator resolves and re-runs. ⛔ No row is ever merged or deleted + * here. + */ + +/** The object this probe reads. Never written. */ +export const SYS_ACCOUNT_OBJECT = 'sys_account'; + +/** Default rows scanned before the walk gives up and reports truncation. */ +export const DEFAULT_ACCOUNT_SCAN_MAX = 200_000; + +/** Rows per page. Identity tables are narrow; this keeps one page small. */ +const PAGE_SIZE = 500; + +const SYSTEM_CTX = { isSystem: true } as const; + +/** The engine surface this probe needs — read-only by construction. */ +export interface AccountIdentityReadEngine { + find( + object: string, + query: Record, + options?: Record, + ): Promise; +} + +/** One `(provider_id, account_id)` key held by more than one row. */ +export interface AccountIdentityCollision { + providerId: string; + accountId: string; + /** + * The distinct `issuer` values under this key, sorted. `null` is spelled + * `'(none)'` so an operator reading the report can tell an absent issuer from + * an empty string. + */ + issuers: string[]; + /** Row ids, sorted — the identifying detail an operator acts on. */ + rowIds: string[]; + /** Distinct `user_id` values under this key, sorted. */ + userIds: string[]; + /** + * True when the colliding rows point at MORE THAN ONE user. This is the + * dangerous shape: after the drop, one of those people signs in and resolves + * to the other one's account. + */ + crossUser: boolean; +} + +export interface AccountIdentityPreflightReport { + /** Rows read. */ + scanned: number; + /** Distinct `(provider_id, account_id)` keys seen. */ + keys: number; + /** Every key held by more than one row, sorted by provider then account id. */ + collisions: AccountIdentityCollision[]; + /** How many of {@link collisions} span more than one user. */ + crossUser: number; + /** True when the drop is safe to proceed with on this database. */ + ok: boolean; +} + +/** + * A refusal. Carries an ADR-0112 envelope (`code` + `status`) so a route or a + * command surfacing it says the same thing either way. + */ +export class AccountIdentityPreflightRefusal extends Error { + readonly code = 'RESOURCE_CONFLICT'; + readonly status = 409; + readonly report: AccountIdentityPreflightReport | undefined; + + constructor(message: string, report?: AccountIdentityPreflightReport) { + super(message); + this.name = 'AccountIdentityPreflightRefusal'; + this.report = report; + } +} + +export interface AccountIdentityPreflightOptions { + /** Stop after this many rows and refuse rather than report a partial scan. */ + max?: number; +} + +const issuerLabel = (v: unknown): string => + v === null || v === undefined || v === '' ? '(none)' : String(v); + +/** + * A `find()` answers a bare array — measured across this package's fourteen + * read sites in #15597, and refused by the engine itself since #15823. ⛔ No + * `{ records }` limb here: absorbing a shape the contract forbids is how a + * corrupted read becomes an empty one, and an empty read is the answer that + * authorises the drop. + */ +function rowsOf(r: unknown, what: string): Array> { + if (Array.isArray(r)) return r as Array>; + throw new AccountIdentityPreflightRefusal( + `Reading ${what} answered ${r === null ? 'null' : typeof r}, not an array. ` + + 'Refusing rather than reading an uninterpretable answer as an empty one.', + ); +} + +/** + * Read every `sys_account` row and report the keys that more than one row + * holds. + * + * ⛔ Throws {@link AccountIdentityPreflightRefusal} when the table cannot be + * enumerated — a read that fails and a table that is empty are different + * answers, and only one of them authorises a drop. + */ +export async function probeAccountIdentityCollisions( + engine: AccountIdentityReadEngine, + options: AccountIdentityPreflightOptions = {}, +): Promise { + if (!engine || typeof engine.find !== 'function') { + throw new AccountIdentityPreflightRefusal( + `Cannot enumerate ${SYS_ACCOUNT_OBJECT}: no readable ObjectQL engine. ` + + 'Refusing rather than reporting an unread table as clean.', + ); + } + + const walk = keysetWalk>( + async (q) => { + let page: unknown; + try { + page = await engine.find( + SYS_ACCOUNT_OBJECT, + { + where: q.where ?? {}, + orderBy: q.orderBy, + limit: q.limit, + fields: ['id', 'provider_id', 'account_id', 'issuer', 'user_id'], + }, + { context: SYSTEM_CTX }, + ); + } catch (e) { + // Rule 1 of this file's refusal discipline. A swallowed read here + // would report an unreadable table as a clean one and authorise the + // drop on it. + throw new AccountIdentityPreflightRefusal( + `Cannot enumerate ${SYS_ACCOUNT_OBJECT}: ${(e as Error)?.message ?? String(e)}. ` + + 'Refusing rather than reporting an unread table as clean.', + ); + } + return rowsOf(page, SYS_ACCOUNT_OBJECT); + }, + { pageSize: PAGE_SIZE, max: options.max ?? DEFAULT_ACCOUNT_SCAN_MAX }, + ); + + const groups = new Map>>(); + for await (const page of walk.pages()) { + for (const row of page) { + const providerId = String(row.provider_id ?? ''); + const accountId = String(row.account_id ?? ''); + // The composite key is JSON, not a delimited string. A delimiter has to + // be a byte the values cannot contain, and the usual answer -- a raw + // U+0000 -- is a byte `check:nul-bytes` refuses on sight, for the reason + // its header gives: it renders as NOTHING, so a load-bearing separator + // reads in grep and in review as an empty string. JSON.stringify has no + // such ambiguity and no such byte. + const key = JSON.stringify([providerId, accountId]); + const bucket = groups.get(key); + if (bucket) bucket.push(row); + else groups.set(key, [row]); + } + } + + if (walk.truncated) { + // Rule 2. The tail we did not read may hold the whole class. + throw new AccountIdentityPreflightRefusal( + `Scan of ${SYS_ACCOUNT_OBJECT} stopped at ${walk.scanned} row(s) without reaching the end of the ` + + 'table, so the rows it did not read cannot be reported as absent. Re-run with a higher ' + + 'row cap (--max-records). Refusing rather than reporting a partial scan as clean.', + ); + } + + const collisions: AccountIdentityCollision[] = []; + for (const [key, rows] of groups) { + if (rows.length < 2) continue; + const [providerId, accountId] = JSON.parse(key) as [string, string]; + const userIds = [...new Set(rows.map((r) => String(r.user_id ?? '')))].sort(); + collisions.push({ + providerId: providerId ?? '', + accountId: accountId ?? '', + issuers: [...new Set(rows.map((r) => issuerLabel(r.issuer)))].sort(), + rowIds: rows.map((r) => String(r.id ?? '')).sort(), + userIds, + crossUser: userIds.length > 1, + }); + } + collisions.sort( + (a, b) => a.providerId.localeCompare(b.providerId) || a.accountId.localeCompare(b.accountId), + ); + + return { + scanned: walk.scanned, + keys: groups.size, + collisions, + crossUser: collisions.filter((c) => c.crossUser).length, + ok: collisions.length === 0, + }; +} + +/** + * The refusal half. Separated from the probe so a caller can render the report + * first and refuse after — the operator sees WHAT was found, not only that + * something was. + */ +export function assertNoAccountIdentityCollisions( + report: AccountIdentityPreflightReport, +): void { + if (report.ok) return; + const crossUser = report.crossUser > 0 + ? ` ${report.crossUser} of them span more than one user, so after the drop one of those people ` + + 'would sign in and resolve to the other one\'s account.' + : ''; + throw new AccountIdentityPreflightRefusal( + `${report.collisions.length} (provider_id, account_id) key(s) in ${SYS_ACCOUNT_OBJECT} are held by ` + + `more than one row.${crossUser} Dropping sys_account.issuer would make those rows ` + + 'indistinguishable. Resolve them — keep the row whose provider account is live and delete the ' + + 'rest so a fresh sign-in re-links — then re-run this preflight. ' + + '⛔ Nothing is merged or deleted for you: which row survives is application knowledge.', + report, + ); +} + +/** Operator-facing text. One line per collision, capped so a report stays readable. */ +export function formatAccountIdentityPreflightReport( + report: AccountIdentityPreflightReport, + limit = 50, +): string { + const lines: string[] = []; + lines.push( + `${SYS_ACCOUNT_OBJECT}: ${report.scanned} row(s) scanned, ${report.keys} distinct ` + + '(provider_id, account_id) key(s).', + ); + if (report.ok) { + lines.push('No key is held by more than one row — sys_account.issuer is safe to drop.'); + return lines.join('\n'); + } + lines.push( + `${report.collisions.length} colliding key(s), ${report.crossUser} of them spanning more than one user:`, + ); + for (const c of report.collisions.slice(0, limit)) { + lines.push( + ` ${c.providerId} / ${c.accountId} — ${c.rowIds.length} rows ` + + `[${c.rowIds.join(', ')}], issuers [${c.issuers.join(', ')}], ` + + `users [${c.userIds.join(', ')}]${c.crossUser ? ' ⚠️ CROSS-USER' : ''}`, + ); + } + if (report.collisions.length > limit) { + lines.push(` … and ${report.collisions.length - limit} more.`); + } + return lines.join('\n'); +} + +/** + * The one case where the retired `issuer` still discriminated: a `provider_id` + * whose registration is RE-POINTED at a different IdP. + * + * ## The answer this change commits to + * + * **Re-pointing a `provider_id` at a different IdP forces a rebuild of that + * provider's account bindings. No key separates them, and after the column drop + * nothing can.** + * + * `sys_sso_provider` declares `{ fields: ['provider_id'], unique: true }`, so + * within one environment `provider_id → issuer` is a function and + * `(provider_id, account_id)` determines exactly what `(issuer, account_id)` + * determined — for as long as that function holds. Re-pointing breaks it: rows + * written under the old IdP and rows written under the new one share one + * `provider_id`, and the subject namespaces behind them are unrelated. If the + * new IdP mints a `sub` the old one had already issued to somebody else, the + * new key resolves that sign-in onto the OTHER person's account row. + * + * ⚠️ Under the old key that shape failed LOUDLY: `findAccountByKey` missed the + * old row, better-auth tried to insert a new one, and the long-standing + * `(provider_id, account_id)` unique refused it — the user saw + * `unable_to_link_account`. Under the new key the same shape resolves silently + * onto the wrong account. The narrowing turns a loud refusal into a quiet + * cross-user sign-in, which is why this is answered here rather than left to a + * constraint. + * + * ## Why it is enforced at the re-point, not at sign-in + * + * After the drop there is no column recording which IdP vouched for a row, so + * no runtime check can tell an old binding from a new one. The last moment at + * which the distinction still exists is the write that changes the issuer. So + * that write is what refuses: {@link refuseIssuerRepointWithLiveBindings} sits + * on the `sys_sso_provider` update door and declines an issuer change while + * accounts are still bound to that `provider_id`. The operator deletes the + * stale bindings — a fresh sign-in re-links each user under the new IdP — and + * re-points. + */ +export interface RepointGuardEngine extends AccountIdentityReadEngine {} + +/** How many bound accounts the guard names before it stops listing. */ +const REPOINT_SAMPLE = 5; + +/** + * Refuse an `issuer` change on a `sys_sso_provider` row while `sys_account` + * rows are still bound to its `provider_id`. + * + * A no-op for every other object, for a patch that does not move `issuer`, and + * for a re-point of a provider nobody has signed in through yet. + * + * ⛔ Read failures refuse, for the same reason the probe's do: "I could not + * check" must never be spelled the same way as "there is nothing to check" + * when what follows is irreversible. + */ +export async function refuseIssuerRepointWithLiveBindings( + engine: RepointGuardEngine, + objectName: string, + existing: Record | null | undefined, + patch: Record, +): Promise { + if (objectName !== 'sys_sso_provider') return; + if (!existing || !patch || !Object.prototype.hasOwnProperty.call(patch, 'issuer')) return; + + const before = existing.issuer; + const after = patch.issuer; + // Only a real change is a re-point. A rewrite of the same value is not. + if (String(before ?? '') === String(after ?? '')) return; + + const providerId = String(existing.provider_id ?? ''); + if (!providerId) return; + + let bound: Array>; + try { + bound = rowsOf( + await engine.find( + SYS_ACCOUNT_OBJECT, + { where: { provider_id: providerId }, limit: REPOINT_SAMPLE + 1, fields: ['id', 'user_id'] }, + { context: SYSTEM_CTX }, + ), + `${SYS_ACCOUNT_OBJECT} bindings of "${providerId}"`, + ); + } catch (e) { + throw new AccountIdentityPreflightRefusal( + `Cannot check the account bindings of SSO provider "${providerId}" before re-pointing it: ` + + `${(e as Error)?.message ?? String(e)}. Refusing the issuer change rather than making it blind.`, + ); + } + + if (bound.length === 0) return; + + const shown = bound.slice(0, REPOINT_SAMPLE).map((r) => String(r.id ?? '')); + const more = bound.length > REPOINT_SAMPLE ? ` (and more)` : ''; + throw new AccountIdentityPreflightRefusal( + `SSO provider "${providerId}" cannot be re-pointed from issuer "${issuerLabel(before)}" to ` + + `"${issuerLabel(after)}" while ${SYS_ACCOUNT_OBJECT} rows are still bound to it ` + + `[${shown.join(', ')}]${more}. Account identity is (provider_id, account_id) — no column ` + + 'records which IdP vouched for a row, so a subject the new IdP issues can land on an account ' + + 'the old one created for a different person. Delete this provider\'s account bindings first; ' + + 'each user re-links on their next sign-in.', + ); +} diff --git a/packages/plugins/plugin-auth/src/account-issuer-parity.test.ts b/packages/plugins/plugin-auth/src/account-issuer-parity.test.ts deleted file mode 100644 index fa3dba8366..0000000000 --- a/packages/plugins/plugin-auth/src/account-issuer-parity.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { - socialProviderList, - socialProviders as socialProviderFactories, -} from '@better-auth/core/social-providers'; -import { backfillAccountIssuer, oauthIssuerFor } from './backfill-account-issuer.js'; - -/** - * Account-issuer parity gate. - * - * better-auth 1.7 keys every account on `(issuer, accountId)`, and the - * issuer is the PROVIDER's to declare: `resolveOAuthAccountKey` takes - * `provider.accountIssuer` when there is one and synthesizes - * `local:oauth:` only when there is not. A boot-time backfill that stamps a - * different value than sign-in looks the row up under does not merely fail to - * help — it hides the account and parks it on the `(provider_id, account_id)` - * unique slot the correct row needs, which is how a working Google login turned - * into `?error=unable_to_link_account`. - * - * So this gate asserts the two agree, provider by provider, against better-auth's - * OWN factory list. A dependency bump that gives another provider an issuer of - * its own (or takes one away) turns this red instead of silently mis-stamping - * that provider's users on the next boot. - */ - -/** Enough options to construct every factory; unused keys are ignored. */ -const PROBE_OPTIONS = { - clientId: 'probe-client-id', - clientSecret: 'probe-client-secret', - tenantId: 'probe-tenant', - region: 'us-east-1', - userPoolId: 'pool-1', - domain: 'probe.example.com', - issuer: 'https://probe.example.com', -} as const; - -interface ProbedProvider { - id: string; - accountIssuer: unknown; -} - -function instantiateAll(): ProbedProvider[] { - const probed: ProbedProvider[] = []; - const broken: string[] = []; - for (const id of socialProviderList) { - const factory = (socialProviderFactories as any)[id]; - try { - const provider = factory(PROBE_OPTIONS as any); - probed.push({ id: provider.id, accountIssuer: provider.accountIssuer }); - } catch (e) { - broken.push(`${id}: ${(e as Error)?.message}`); - } - } - // A factory this probe can no longer construct is a provider the gate stops - // covering — fail loudly rather than shrink the checked set in silence. - expect(broken, 'social provider factories the parity probe could not construct').toEqual([]); - return probed; -} - -/** What better-auth will key an account by, per `resolveOAuthAccountKey`. */ -function issuerBetterAuthWillUse(provider: ProbedProvider): string | 'per-login' { - const declared = provider.accountIssuer; - if (declared === undefined) return oauthIssuerFor(provider.id); - if (typeof declared === 'string') return declared; - return 'per-login'; -} - -/** Minimal ObjectQL stand-in — one account row, `update` patches in place. */ -function makeQl(row: Record) { - const tables: Record = { sys_account: [row] }; - return { - tables, - update: async (object: string, data: any) => { - const target = (tables[object] ?? []).find((r) => r.id === data.id); - if (target) Object.assign(target, data); - return target; - }, - find: async (object: string, query: any) => { - const where = query?.where ?? {}; - return (tables[object] ?? []).filter((r) => - Object.entries(where).every(([field, value]) => - { if (field.startsWith('$')) throw new Error(`fake driver: unsupported operator ${field}`); return value === null ? r[field] == null : r[field] === value; }, - ), - ); - }, - }; -} - -describe('account issuer parity — the backfill stamps what sign-in looks up', () => { - it('covers every social provider better-auth ships', () => { - const probed = instantiateAll(); - expect(probed.length).toBe(socialProviderList.length); - }); - - it('agrees with better-auth on the issuer for every provider that has a fixed one', async () => { - const mismatches: Array<{ provider: string; betterAuth: string; backfill: string | null }> = []; - - for (const provider of instantiateAll()) { - const expected = issuerBetterAuthWillUse(provider); - if (expected === 'per-login') continue; - - const ql = makeQl({ - id: 'a1', - provider_id: provider.id, - account_id: 'subject-1', - issuer: null, - }); - await backfillAccountIssuer(ql, { socialProviders: [provider] }); - - const stamped = ql.tables.sys_account[0].issuer ?? null; - if (stamped !== expected) { - mismatches.push({ provider: provider.id, betterAuth: expected, backfill: stamped }); - } - } - - expect(mismatches, 'providers whose backfilled issuer differs from the one sign-in resolves').toEqual([]); - }); - - it('never invents an issuer for a provider that resolves one per login', async () => { - for (const provider of instantiateAll()) { - if (issuerBetterAuthWillUse(provider) !== 'per-login') continue; - - const ql = makeQl({ - id: 'a1', - provider_id: provider.id, - account_id: 'subject-1', - issuer: null, - }); - const res = await backfillAccountIssuer(ql, { socialProviders: [provider] }); - - expect(ql.tables.sys_account[0].issuer, `${provider.id} must stay unstamped`).toBeNull(); - expect(res.unresolved).toEqual([{ providerId: provider.id, count: 1 }]); - } - }); - - it('repairs a synthetic stamp on every provider that declares a real issuer', async () => { - const declaring = instantiateAll().filter((p) => typeof p.accountIssuer === 'string'); - // Guards the guard: if better-auth ever ships none of these, the repair - // case above proves nothing and this gate has quietly stopped testing. - expect(declaring.length).toBeGreaterThan(0); - - for (const provider of declaring) { - const ql = makeQl({ - id: 'a1', - provider_id: provider.id, - account_id: 'subject-1', - issuer: oauthIssuerFor(provider.id), - }); - const res = await backfillAccountIssuer(ql, { socialProviders: [provider] }); - - expect(res.repaired, `${provider.id} mis-stamp should be repaired`).toBe(1); - expect(ql.tables.sys_account[0].issuer).toBe(provider.accountIssuer); - } - }); - - it('google — the provider this defect shipped on — resolves to its real issuer', () => { - const google = (socialProviderFactories as any).google(PROBE_OPTIONS as any); - expect(google.accountIssuer).toBe('https://accounts.google.com'); - expect(oauthIssuerFor('google')).toBe('local:oauth:google'); - }); -}); diff --git a/packages/plugins/plugin-auth/src/account-issuer-upgrade-path.test.ts b/packages/plugins/plugin-auth/src/account-issuer-upgrade-path.test.ts new file mode 100644 index 0000000000..2de292d6e8 --- /dev/null +++ b/packages/plugins/plugin-auth/src/account-issuer-upgrade-path.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17440] The EXISTING-DATA upgrade, driven end to end over one real database. + * + * ## What "existing data" means here, precisely + * + * A deployment that ran better-auth 1.7.0–1.7.2 has a `sys_account` table with + * an `issuer` COLUMN carrying values. After this change the platform no longer + * declares that column — but the physical column does not vanish when the code + * ships. Dropping it is a DESTRUCTIVE change, and `os migrate apply` skips + * destructive work without `--allow-destructive`, so between the deploy and the + * operator's migration window every such deployment runs the new code against + * the OLD physical table. + * + * ⇒ That window is the state this file pins, and it is the one nothing else + * covers: `showcase-demo-personas-loginable.dogfood.test.ts` proves a FRESH + * install signs in, and a fresh install never has the column. + * + * ## How it is built, and why two engines over one file + * + * One SQLite FILE, two engines: + * + * • engine A registers a `sys_account` that still DECLARES `issuer` — the + * pre-upgrade shape. A real `AuthManager` signs a user up through the real + * HTTP route, so the password hash is better-auth's own, and the row is then + * stamped with the issuer a 1.7.2 runtime wrote — read off the derivation + * this branch retires, 2026-09-10, and ⛔ NOT re-measured: 1.7.2 is a + * version this tree no longer installs, which is the whole premise here. + * • engine B, on the SAME file, registers the objects as they ship TODAY. It + * is the upgraded deployment: new code, old table. + * + * The alternative — hand-writing a password hash — would have pinned this + * suite's idea of better-auth's hash format rather than better-auth's. + * + * ⛔ Nothing here asserts a status alone: a sign-in is judged by the session it + * installs, because a 200 that resolves to nobody is what a broken sign-in + * looks like from the outside. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AuthManager } from './auth-manager.js'; +import { authIdentityObjects } from './manifest.js'; +import { probeAccountIdentityCollisions, assertNoAccountIdentityCollisions } from './account-identity-preflight.js'; + +const SECRET = 'test-secret-at-least-32-chars-long-17440'; +const BASE = 'http://localhost:3000'; +const AUTH_BASE = '/api/v1/auth'; +const EMAIL = 'legacy.account@example.com'; +const PASSWORD = 'S3cure!Passw0rd-17440'; +const SYSTEM = { context: { isSystem: true } } as never; + +/** The value a 1.7.2 runtime stamped on a local password account. */ +const LEGACY_CREDENTIAL_ISSUER = 'local:credential'; + +const engines: ObjectQL[] = []; +const dirs: string[] = []; +afterEach(async () => { + while (engines.length) { + const e = engines.pop(); + try { + await (e as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } + while (dirs.length) { + try { + rmSync(dirs.pop()!, { recursive: true, force: true }); + } catch { + /* noop */ + } + } +}); + +function newDbFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-17440-upgrade-')); + dirs.push(dir); + return join(dir, 'identity.sqlite'); +} + +async function bootEngine(filename: string, objects: unknown[]): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const object of objects) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + return engine; +} + +/** + * The objects as they shipped BEFORE this change: today's set, with + * `sys_account` swapped for one that still declares `issuer`. Built by + * TRANSFORMING the live object rather than by re-spelling it, so the pre-upgrade + * fixture cannot drift away from the shape it is supposed to be one field from. + */ +function preUpgradeObjects(): unknown[] { + return authIdentityObjects.map((object: any) => { + if (object?.name !== 'sys_account') return object; + return { + ...object, + fields: { + ...object.fields, + issuer: { name: 'issuer', type: 'text', label: 'Issuer', maxLength: 2048 }, + }, + }; + }); +} + +const manager = (engine: ObjectQL) => + new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never } as never); + +const post = (m: AuthManager, path: string, body: unknown) => + m.handleRequest( + new Request(`${BASE}${AUTH_BASE}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify(body), + }), + ); + +const cookieHeader = (res: Response): string => + (res.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).join('; '); + +/** Who does this session resolve to? A token alone proves only that a route answered. */ +async function principalFor(m: AuthManager, cookie: string): Promise { + const res = await m.handleRequest( + new Request(`${BASE}${AUTH_BASE}/get-session`, { headers: { cookie, origin: BASE } }), + ); + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as { user?: { id?: unknown } } | null; + const id = body?.user?.id; + return typeof id === 'string' && id ? id : null; +} + +const rowsOf = (r: unknown): Array> => (Array.isArray(r) ? r : []); + +/** + * Build the pre-upgrade database: a real account minted by better-auth, then + * stamped with the issuer a 1.7.2 runtime wrote beside it. + */ +async function arrangeLegacyDatabase(file: string): Promise<{ userId: string }> { + const legacy = await bootEngine(file, preUpgradeObjects()); + const legacyManager = manager(legacy); + + const signedUp = await post(legacyManager, '/sign-up/email', { + email: EMAIL, + password: PASSWORD, + name: 'Legacy Account', + }); + expect(signedUp.status, `sign-up: ${await signedUp.clone().text()}`).toBeLessThan(300); + + const users = rowsOf(await legacy.find('sys_user', { where: { email: EMAIL }, limit: 1 }, SYSTEM)); + const userId = String(users[0]?.id ?? ''); + expect(userId, 'the sign-up produced a sys_user row').toBeTruthy(); + + const accounts = rowsOf( + await legacy.find('sys_account', { where: { user_id: userId }, limit: 5 }, SYSTEM), + ); + expect(accounts.length, 'the sign-up produced exactly one account row').toBe(1); + + // Stamp it the way a 1.7.2 runtime did. On the pre-upgrade object this is an + // ordinary declared write; it is what makes the row "existing data" rather + // than a row this test merely says is old. + await legacy.update('sys_account', { id: accounts[0]!.id, issuer: LEGACY_CREDENTIAL_ISSUER }, SYSTEM); + const stamped = rowsOf( + await legacy.find('sys_account', { where: { user_id: userId }, limit: 1 }, SYSTEM), + ); + expect( + stamped[0]?.issuer, + 'PREMISE: the pre-upgrade database really carries a populated issuer column', + ).toBe(LEGACY_CREDENTIAL_ISSUER); + + return { userId }; +} + +describe('#17440 existing-data upgrade — new code against the OLD physical table', () => { + it('a 1.7.2-era account still SIGNS IN over the real auth route after the column is undeclared', async () => { + const file = newDbFile(); + const { userId } = await arrangeLegacyDatabase(file); + + // The upgraded deployment: today's objects, yesterday's table. + const upgraded = await bootEngine(file, authIdentityObjects); + const upgradedManager = manager(upgraded); + + const signedIn = await post(upgradedManager, '/sign-in/email', { email: EMAIL, password: PASSWORD }); + expect(signedIn.status, `sign-in after upgrade: ${await signedIn.clone().text()}`).toBeLessThan(300); + + // Ends at the principal, never at the status. + expect( + await principalFor(upgradedManager, cookieHeader(signedIn)), + "the session resolves to the legacy account's own user", + ).toBe(userId); + }); + + it('the undeclared column is NOT silently dropped by schema sync — the drop stays the operator\'s deliberate act', async () => { + const file = newDbFile(); + await arrangeLegacyDatabase(file); + + const upgraded = await bootEngine(file, authIdentityObjects); + + // Read the physical table, not the metadata: booting the new code must not + // have destroyed a column that still holds data. ADR-0131 D10's whole + // posture is that a destructive change is an operator ceremony, never a + // boot step, and this is that posture measured on the identity table. + const driver: any = (upgraded as any).drivers?.values?.().next?.().value + ?? (upgraded as any).defaultDriver + ?? (upgraded as any).driver; + const knex = driver?.knex ?? driver?.db ?? driver?.client; + expect(typeof knex, 'reached the driver\'s SQL handle').toBe('function'); + const columns = await knex.raw('PRAGMA table_info(sys_account)'); + const names = (Array.isArray(columns) ? columns : columns?.rows ?? []).map((c: any) => String(c.name)); + expect(names, 'CONTROL: the PRAGMA really read this table').toContain('provider_id'); + expect(names, 'the physical column survived the upgrade boot').toContain('issuer'); + }); + + it('the pre-flight reads CLEAN on that database, which is what authorises the drop', async () => { + const file = newDbFile(); + await arrangeLegacyDatabase(file); + const upgraded = await bootEngine(file, authIdentityObjects); + + const report = await probeAccountIdentityCollisions(upgraded as never); + expect(report.scanned, 'the one legacy account was read').toBe(1); + expect(report.ok).toBe(true); + expect(() => assertNoAccountIdentityCollisions(report)).not.toThrow(); + }); + + it('and sign-in still works once the column is actually GONE — the far side of the ceremony', async () => { + const file = newDbFile(); + const { userId } = await arrangeLegacyDatabase(file); + + const upgraded = await bootEngine(file, authIdentityObjects); + const driver: any = (upgraded as any).drivers?.values?.().next?.().value + ?? (upgraded as any).defaultDriver + ?? (upgraded as any).driver; + const knex = driver?.knex ?? driver?.db ?? driver?.client; + expect(typeof knex, 'reached the driver\'s SQL handle').toBe('function'); + + // What `os migrate apply --allow-destructive` does, after the pre-flight + // has read clean. + await knex.raw('ALTER TABLE sys_account DROP COLUMN issuer'); + const after = await knex.raw('PRAGMA table_info(sys_account)'); + const names = (Array.isArray(after) ? after : after?.rows ?? []).map((c: any) => String(c.name)); + // ⛔ The control is not optional here: `not.toContain` passes VACUOUSLY on + // an empty array, so a PRAGMA that read nothing would read as "the column + // is gone" — the one reading this case must never produce by accident. + expect(names, 'CONTROL: the PRAGMA really read this table').toContain('provider_id'); + expect(names, 'PREMISE: the column is really gone').not.toContain('issuer'); + + // A fresh manager on the migrated table — the state every deployment ends in. + const migratedManager = manager(upgraded); + const signedIn = await post(migratedManager, '/sign-in/email', { email: EMAIL, password: PASSWORD }); + expect(signedIn.status, `sign-in after the drop: ${await signedIn.clone().text()}`).toBeLessThan(300); + expect( + await principalFor(migratedManager, cookieHeader(signedIn)), + 'the same user, on the far side of the migration', + ).toBe(userId); + }); +}); diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 67e14b5d30..63d6cb472b 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -62,16 +62,14 @@ export interface AuthContextLike { userId: string; providerId: string; /** - * better-auth 1.7 keys accounts on (issuer, accountId) and requires - * both. A local password account carries the synthetic issuer - * better-auth mints for itself, `local:credential` — write anything else - * and the row exists but no sign-in ever finds it. - * * `accountId` is the STABLE 1.7 spelling. `1.7.0-rc.2` briefly called it * `providerAccountId` and stable 1.7.0 renamed it back (#3002); the * rc.2 spelling here would have created accounts with no account id. + * + * With `providerId` it is the WHOLE account identity again since 1.7.3 + * (#17440): the `issuer` this signature used to require was deleted + * upstream together with the model it keyed. */ - issuer: string; accountId: string; password: string; }): Promise; @@ -172,7 +170,6 @@ export interface EndpointResult { }; } -import { CREDENTIAL_ISSUER } from './backfill-account-issuer.js'; import { generatePlaceholderEmail } from './placeholder-email.js'; import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js'; import { resolveDefaultOrgId } from './tenancy-service.js'; @@ -666,7 +663,6 @@ export async function runAdminSetUserPassword( await authCtx.internalAdapter.createAccount({ userId, providerId: 'credential', - issuer: CREDENTIAL_ISSUER, accountId: userId, password: hashed, }); diff --git a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts index 40a9307df3..5880a0f42d 100644 --- a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts +++ b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts @@ -865,10 +865,13 @@ describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () * recipient-keyed engine, not by asserting it about this send alone. * * Two branches, because a magic link is BOTH a sign-in for an existing account - * and a sign-up for a new address (measured in the installed better-auth - * 1.7.2: `/sign-in/magic-link` sends without looking the address up, and - * `/magic-link/verify` creates the user unless `disableSignUp`). Branch 2 is - * therefore a real state here, not a theoretical one. + * and a sign-up for a new address (re-measured 2026-09-11 in the installed + * better-auth 1.7.3, unchanged from 1.7.2: the `/sign-in/magic-link` handler + * takes `email` straight off the body, stores a verification value and calls + * `sendMagicLink` — no user lookup on that path at all — while + * `/magic-link/verify` reads `findUserByEmail` and, finding nobody, calls + * `createUser` unless `disableSignUp`). Branch 2 is therefore a real state + * here, not a theoretical one. * * ⛔ The request rung is kept, not replaced. Ruling D (#14788, 2026-09-03, * maintainer verbatim 「同意」) reads `sys_user.locale` when set → the @@ -998,9 +1001,10 @@ describe("#15106 — the magic link reads the recipient's own sys_user.locale", }); it('matches the address better-auth itself will resolve the link with — lowercased', async () => { - // ⚠️ Measured against the installed better-auth 1.7.2, not assumed: - // `signInMagicLinkBodySchema` applies no case transform, so a typed - // `Ada@Example.com` arrives here verbatim, while + // ⚠️ Measured against the installed better-auth 1.7.3 (2026-09-11), not + // assumed, and unchanged from the 1.7.2 reading this replaces: + // `signInMagicLinkBodySchema` declares `email: z.email()` with no case + // transform, so a typed `Ada@Example.com` arrives here verbatim, while // `internalAdapter.findUserByEmail` — what `/magic-link/verify` resolves // this very link with — matches on `email.toLowerCase()`. The column must // be read for the row the link will sign into. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 564c3b9213..aa02c7021e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -311,10 +311,10 @@ describe('AuthManager', () => { expect(capturedConfig.account.fields).toEqual(expect.objectContaining({ userId: 'user_id', providerId: 'provider_id', - // 1.7 identity key: (issuer, accountId). The id field kept the - // `account_id` column across the rc.2 `providerAccountId` detour and - // the stable 1.7 rename back (#3002). - issuer: 'issuer', + // [#17440] Identity key: (providerId, accountId) — 1.7.3 rolled the + // issuer-scoped key back. The id field kept the `account_id` column + // across the rc.2 `providerAccountId` detour and the stable 1.7 + // rename back (#3002). accountId: 'account_id', accessToken: 'access_token', refreshToken: 'refresh_token', diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 36154af319..3febea9b1e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3885,7 +3885,10 @@ export class AuthManager { // // Handing better-auth no `database` makes it build its own in-memory store // in `getBaseAdapter`, and that store is keyed by the schema KEY while - // every read resolves by `modelName`. Measured on better-auth 1.7.2: + // every read resolves by `modelName`. Re-measured 2026-09-11 on the + // installed better-auth 1.7.3, unchanged from the 1.7.2 reading it replaces + // (`db/adapter-base.mjs` builds the store by schema KEY; + // `@better-auth/memory-adapter` throws on the lookup, naming the model): // // getAuthTables(options) -> { oauthResource: { modelName: 'sys_oauth_resource' }, … } // its memoryDB -> { oauthResource: [] } // keyed by KEY diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index eb7f1d4b5c..6564c23a47 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -44,7 +44,6 @@ import { import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runAttributedToUser } from './auth-actor-attribution.js'; import type { AuthEventAuditSurface } from './auth-session-audit.js'; -import type { ResolvedSocialProvider } from './backfill-account-issuer.js'; import { createTenancyService, type TenancyService } from './tenancy-service.js'; import { backfillMemberships, @@ -356,35 +355,6 @@ export class AuthPlugin implements Plugin { } } - /** - * The social providers better-auth built for this runtime, straight off its - * own context — each one carrying the `accountIssuer` it will key its - * accounts by. Read rather than reconstructed: reconstructing it from the - * configured ids is exactly the guess that mis-stamped Google links. - * - * Returns `undefined` when the instance cannot be reached (auth not built - * yet, or a host-supplied instance that exposes no context) — the backfill - * then falls back to the id-derived issuers and reports what it cannot - * resolve, which is the pre-existing behaviour, not a new failure. - */ - private async resolveInstantiatedSocialProviders( - ctx: PluginContext, - ): Promise { - try { - const auth = await this.authManager?.getAuthInstance(); - const context = await (auth as any)?.$context; - const providers = context?.socialProviders; - if (!Array.isArray(providers)) return undefined; - return providers.filter((p: any) => typeof p?.id === 'string' && p.id); - } catch (e) { - ctx.logger.warn?.( - '[auth] could not read better-auth\'s instantiated social providers — account issuers fall back to the id-derived values', - { error: (e as Error)?.message }, - ); - return undefined; - } - } - async init(ctx: PluginContext): Promise { ctx.logger.info('Initializing Auth Plugin...'); @@ -1086,38 +1056,6 @@ export class AuthPlugin implements Plugin { await this.maybeSeedDevAdmin(ctx); }); - // better-auth 1.7 resolves every account by (issuer, accountId). - // Rows written before the upgrade have no issuer and are therefore - // invisible to sign-in, so stamp them once at boot. Idempotent: a database - // whose rows already carry the right issuer costs one empty query. - // - // The providers are handed over as better-auth INSTANTIATED them, because - // the issuer is theirs to declare and only they know it — Google names - // `https://accounts.google.com`, GitHub names nothing and takes the - // synthetic fallback. Deriving it from the configured ids instead is what - // stamped Google links with a value sign-in never looks them up under. - ctx.hook('kernel:ready', async () => { - try { - const ql = ctx.getService('objectql'); - if (!ql) return; - const { backfillAccountIssuer } = await import('./backfill-account-issuer.js'); - await backfillAccountIssuer(ql, { - logger: ctx.logger, - socialProviders: await this.resolveInstantiatedSocialProviders(ctx), - socialProviderIds: Object.keys(this.configuredSocialProviders ?? {}), - oidcProviderIssuers: Object.fromEntries( - (this.options.oidcProviders ?? []) - .filter((p): p is typeof p & { issuer: string } => typeof p.issuer === 'string' && !!p.issuer) - .map((p) => [p.providerId, p.issuer]), - ), - }); - } catch (e) { - ctx.logger.warn?.('[auth] account issuer backfill failed', { - error: (e as Error).message, - }); - } - }); - // [#8317] The one-off half of the ruling: rows written BEFORE the // canonicalisation hook above — or written outside ObjectQL entirely by an // operator SQL fix-up, an import or a SCIM group remap — still carry a diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index 19fadbd48b..dd33520b32 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -87,7 +87,6 @@ export const AUTH_SESSION_CONFIG = { * |:--------------------------|:-------------------------------| * | userId | user_id | * | providerId | provider_id | - * | issuer | issuer | * | accountId | account_id | * | accessToken | access_token | * | refreshToken | refresh_token | @@ -97,11 +96,16 @@ export const AUTH_SESSION_CONFIG = { * | createdAt | created_at | * | updatedAt | updated_at | * - * better-auth 1.7 restructured account identity by adding a REQUIRED `issuer` - * naming the authority that vouched for the account id. Every account lookup - * keys on (issuer, accountId) — `findAccountByKey` / `findAccountOwnerByKey` - * filter on `issuer` — so an unmapped or unstamped `issuer` means sign-in - * finds no account at all. + * Account identity keys on **(providerId, accountId)** — `findAccountByKey` / + * `findAccountOwnerByKey` filter on exactly that pair, and `sys_account` + * declares it UNIQUE. + * + * ⚠️ 1.7.0–1.7.2 briefly keyed on `(issuer, accountId)` and carried a REQUIRED + * `account.issuer`; 1.7.3 removed that model outright + * (better-auth/better-auth#10909) and #17440 adopted the rollback rather than + * owning a fork of it. So there is no `issuer` row in the table above and none + * in the map below: an entry for a column better-auth no longer declares maps + * nothing, and the column it named is gone from `sys_account`. * * ⚠️ THE FIELD NAME FLIP-FLOPPED ACROSS THE 1.7 PRE-RELEASES, so read it off * the installed version, never off memory. `1.7.0-rc.2` renamed `accountId` → @@ -115,19 +119,13 @@ export const AUTH_SESSION_CONFIG = { * `better-auth-schema-parity.test.ts` is the gate that catches exactly this. * * `accountId` keeps the existing `account_id` column: same value throughout - * the rename round-trip, so no data ever moved. `issuer` is a new column, - * stamped on legacy rows by backfillAccountIssuer() at boot (see - * backfill-account-issuer.ts) with the synthetic issuers better-auth mints - * itself: `local:credential` for password accounts and - * `local:oauth:` for OAuth providers that carry no issuer of - * their own. + * the rename round-trip, so no data ever moved. */ export const AUTH_ACCOUNT_CONFIG = { modelName: SystemObjectName.ACCOUNT, // 'sys_account' fields: { userId: 'user_id', providerId: 'provider_id', - issuer: 'issuer', accountId: 'account_id', accessToken: 'access_token', refreshToken: 'refresh_token', @@ -953,11 +951,12 @@ export const buildOidcProviderPluginSchema = buildOauthProviderPluginSchema; // NOTE: there is intentionally no scim mapping constant here, and no // `buildScimPluginSchema()`. `@better-auth/scim` hardcodes its models and exposes -// NO `schema` option — still true of the installed stable `@better-auth/scim@1.7.2` -// (`SCIMOptions` declares no `schema` / `modelName` / `fields` member at all — its -// six members are connections, authentication, managedConnections, identity, -// projection, compatibility; measured 2026-08-19 on the rc, re-measured -// 2026-08-27 on stable 1.7.1, and again 2026-08-31 on 1.7.2 for #13940), so +// NO `schema` option — still true of the installed stable `@better-auth/scim@1.7.3` +// (re-read 2026-09-11: `SCIMOptions` declares no `schema` / `modelName` / `fields` +// member at all — its six members are still connections, authentication, +// managedConnections, identity, projection, compatibility; measured 2026-08-19 on +// the rc, re-measured 2026-08-27 on stable 1.7.1, again 2026-08-31 on 1.7.2 for +// #13940, and again on 1.7.3 with the family lift), so // there is nowhere to hand one. This is no longer true // of `@better-auth/sso@1.7.1`, which now accepts one (#8224) — for scim, and for // scim alone, the ADAPTER layer is the only available route. diff --git a/packages/plugins/plugin-auth/src/backfill-account-issuer.test.ts b/packages/plugins/plugin-auth/src/backfill-account-issuer.test.ts deleted file mode 100644 index 7679bc9af0..0000000000 --- a/packages/plugins/plugin-auth/src/backfill-account-issuer.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { - backfillAccountIssuer, - CREDENTIAL_ISSUER, - oauthIssuerFor, -} from './backfill-account-issuer.js'; - -/** - * Minimal ObjectQL stand-in: `find` answers from the seeded tables honouring - * the `{ field: value }` where-shape the helper uses, `update` patches the row - * in place so idempotence can be observed across runs. - */ -function makeQl(tables: Record) { - const update = vi.fn(async (object: string, data: any) => { - const row = (tables[object] ?? []).find((r) => r.id === data.id); - if (row) Object.assign(row, data); - return row; - }); - return { - tables, - update, - find: vi.fn(async (object: string, query: any) => { - const rows = tables[object] ?? []; - const where = query?.where ?? {}; - return rows.filter((row) => - Object.entries(where).every(([field, value]) => - { if (field.startsWith('$')) throw new Error(`fake driver: unsupported operator ${field}`); return value === null ? row[field] == null : row[field] === value; }, - ), - ); - }), - }; -} - -const logger = () => ({ info: vi.fn(), warn: vi.fn() }); - -describe('backfillAccountIssuer (better-auth 1.7 account identity)', () => { - it('stamps password accounts with better-auth\'s local:credential issuer', async () => { - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'credential', account_id: 'u1', issuer: null }], - }); - - const res = await backfillAccountIssuer(ql); - - expect(res).toMatchObject({ scanned: 1, stamped: 1, unresolved: [] }); - expect(ql.tables.sys_account[0].issuer).toBe(CREDENTIAL_ISSUER); - expect(CREDENTIAL_ISSUER).toBe('local:credential'); - }); - - it('stamps a provider that declares no issuer with the synthetic local:oauth one', async () => { - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'github', account_id: '4242', issuer: null }], - }); - - const res = await backfillAccountIssuer(ql, { socialProviderIds: ['github', 'google'] }); - - expect(res.stamped).toBe(1); - expect(ql.tables.sys_account[0].issuer).toBe('local:oauth:github'); - expect(oauthIssuerFor('github')).toBe('local:oauth:github'); - }); - - it('stamps a provider that declares its own issuer with THAT issuer', async () => { - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'google', account_id: 'sub-1', issuer: null }], - }); - - const res = await backfillAccountIssuer(ql, { - socialProviders: [{ id: 'google', accountIssuer: 'https://accounts.google.com' }], - }); - - expect(res.stamped).toBe(1); - expect(ql.tables.sys_account[0].issuer).toBe('https://accounts.google.com'); - }); - - it('leaves a per-login issuer underivable rather than synthesizing one', async () => { - // Microsoft Entra reads the tenant's `iss` off each login's profile, so - // there is no boot-time answer — and a guess would be the whole bug. - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'microsoft', account_id: 'oid-1', issuer: null }], - }); - - const res = await backfillAccountIssuer(ql, { - socialProviders: [{ id: 'microsoft', accountIssuer: ({ profile }: any) => profile.iss }], - socialProviderIds: ['microsoft'], - }); - - expect(res).toMatchObject({ scanned: 1, stamped: 0, repaired: 0 }); - expect(res.unresolved).toEqual([{ providerId: 'microsoft', count: 1 }]); - expect(ql.tables.sys_account[0].issuer).toBeNull(); - }); - - it('uses the registered SSO provider\'s real issuer for federated accounts', async () => { - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'okta-prod', account_id: '00u1', issuer: null }], - sys_sso_provider: [{ id: 'p1', provider_id: 'okta-prod', issuer: 'https://acme.okta.com' }], - }); - - const res = await backfillAccountIssuer(ql); - - expect(res.stamped).toBe(1); - expect(ql.tables.sys_account[0].issuer).toBe('https://acme.okta.com'); - }); - - it('uses an explicitly configured generic-oauth issuer', async () => { - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'keycloak', account_id: 'k1', issuer: null }], - }); - - const res = await backfillAccountIssuer(ql, { - oidcProviderIssuers: { keycloak: 'https://id.acme.test/realms/main' }, - }); - - expect(res.stamped).toBe(1); - expect(ql.tables.sys_account[0].issuer).toBe('https://id.acme.test/realms/main'); - }); - - it('leaves an underivable issuer NULL and reports it instead of guessing', async () => { - const log = logger(); - const ql = makeQl({ - // Provider is neither credential, nor configured, nor a registered SSO - // provider — its issuer is the IdP's own `iss` and cannot be synthesized. - sys_account: [ - { id: 'a1', provider_id: 'legacy-idp', account_id: 'x1', issuer: null }, - { id: 'a2', provider_id: 'legacy-idp', account_id: 'x2', issuer: null }, - ], - }); - - const res = await backfillAccountIssuer(ql, { logger: log }); - - expect(res).toMatchObject({ scanned: 2, stamped: 0 }); - expect(res.unresolved).toEqual([{ providerId: 'legacy-idp', count: 2 }]); - expect(ql.tables.sys_account[0].issuer).toBeNull(); - expect(log.warn).toHaveBeenCalled(); - }); - - it('treats an empty-string issuer as unstamped', async () => { - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'credential', account_id: 'u1', issuer: '' }], - }); - - expect((await backfillAccountIssuer(ql)).stamped).toBe(1); - expect(ql.tables.sys_account[0].issuer).toBe(CREDENTIAL_ISSUER); - }); - - it('is idempotent — a second pass touches nothing', async () => { - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'credential', account_id: 'u1', issuer: null }], - }); - - await backfillAccountIssuer(ql); - ql.update.mockClear(); - const second = await backfillAccountIssuer(ql); - - expect(second).toMatchObject({ scanned: 0, stamped: 0 }); - expect(ql.update).not.toHaveBeenCalled(); - }); - - it('reports a row whose update fails rather than counting it as stamped', async () => { - const log = logger(); - const ql = makeQl({ - sys_account: [{ id: 'a1', provider_id: 'credential', account_id: 'u1', issuer: null }], - }); - ql.update.mockRejectedValueOnce(new Error('unique constraint')); - - const res = await backfillAccountIssuer(ql, { logger: log }); - - expect(res).toMatchObject({ scanned: 1, stamped: 0 }); - expect(res.unresolved).toEqual([{ providerId: 'credential', count: 1 }]); - expect(log.warn).toHaveBeenCalled(); - }); - - it('no-ops on an engine that cannot query', async () => { - await expect(backfillAccountIssuer(undefined)).resolves.toMatchObject({ scanned: 0, stamped: 0 }); - await expect(backfillAccountIssuer({} as any)).resolves.toMatchObject({ scanned: 0, stamped: 0 }); - }); -}); - -/** - * The shipped defect: an earlier pass stamped `local:oauth:google` on links - * better-auth resolves under `https://accounts.google.com`. The row went - * invisible at sign-in, the callback fell through to "link this provider", and - * the insert hit the `(provider_id, account_id)` unique index the invisible row - * was holding — `?error=unable_to_link_account`. - */ -describe('backfillAccountIssuer — repairing a mis-stamped synthetic issuer', () => { - it('re-stamps a synthetic issuer with the one its provider declares', async () => { - const log = logger(); - const ql = makeQl({ - sys_account: [ - { id: 'a1', provider_id: 'google', account_id: 'sub-1', issuer: 'local:oauth:google' }, - ], - }); - - const res = await backfillAccountIssuer(ql, { - logger: log, - socialProviders: [{ id: 'google', accountIssuer: 'https://accounts.google.com' }], - }); - - expect(res).toMatchObject({ scanned: 1, stamped: 0, repaired: 1, unresolved: [] }); - expect(ql.tables.sys_account[0].issuer).toBe('https://accounts.google.com'); - expect(log.info).toHaveBeenCalled(); - }); - - it('is idempotent — the repaired row is not touched again', async () => { - const ql = makeQl({ - sys_account: [ - { id: 'a1', provider_id: 'google', account_id: 'sub-1', issuer: 'local:oauth:google' }, - ], - }); - const opts = { socialProviders: [{ id: 'google', accountIssuer: 'https://accounts.google.com' }] }; - - await backfillAccountIssuer(ql, opts); - ql.update.mockClear(); - const second = await backfillAccountIssuer(ql, opts); - - expect(second).toMatchObject({ scanned: 0, repaired: 0 }); - expect(ql.update).not.toHaveBeenCalled(); - }); - - it('leaves the synthetic issuer alone when it IS what the provider mints', async () => { - const ql = makeQl({ - sys_account: [ - { id: 'a1', provider_id: 'github', account_id: '4242', issuer: 'local:oauth:github' }, - ], - }); - - const res = await backfillAccountIssuer(ql, { socialProviders: [{ id: 'github' }] }); - - expect(res).toMatchObject({ scanned: 0, stamped: 0, repaired: 0 }); - expect(ql.tables.sys_account[0].issuer).toBe('local:oauth:github'); - }); - - it('rewrites ONLY the synthetic value — a row already holding a real issuer stands', async () => { - const ql = makeQl({ - sys_account: [ - { id: 'a1', provider_id: 'google', account_id: 'sub-1', issuer: 'https://accounts.google.com' }, - { id: 'a2', provider_id: 'google', account_id: 'sub-2', issuer: 'https://some-other.example' }, - ], - }); - - const res = await backfillAccountIssuer(ql, { - socialProviders: [{ id: 'google', accountIssuer: 'https://accounts.google.com' }], - }); - - expect(res).toMatchObject({ scanned: 0, repaired: 0 }); - expect(ql.tables.sys_account[1].issuer).toBe('https://some-other.example'); - }); - - it('reports a repair the database refuses instead of counting it', async () => { - // A duplicate row can already occupy (issuer, account_id) on a deployment - // that acquired one before the unique index existed. - const log = logger(); - const ql = makeQl({ - sys_account: [ - { id: 'a1', provider_id: 'google', account_id: 'sub-1', issuer: 'local:oauth:google' }, - ], - }); - ql.update.mockRejectedValueOnce(new Error('unique constraint')); - - const res = await backfillAccountIssuer(ql, { - logger: log, - socialProviders: [{ id: 'google', accountIssuer: 'https://accounts.google.com' }], - }); - - expect(res).toMatchObject({ scanned: 1, repaired: 0 }); - expect(res.unresolved).toEqual([{ providerId: 'google', count: 1 }]); - expect(log.warn).toHaveBeenCalled(); - }); -}); diff --git a/packages/plugins/plugin-auth/src/backfill-account-issuer.ts b/packages/plugins/plugin-auth/src/backfill-account-issuer.ts deleted file mode 100644 index 376a1a2a22..0000000000 --- a/packages/plugins/plugin-auth/src/backfill-account-issuer.ts +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { createLocalAccountIssuer, createOAuthAccountIssuer } from '@better-auth/core/db'; - -/** - * backfillAccountIssuer — stamp `sys_account.issuer` on rows written before - * better-auth 1.7. - * - * 1.7 restructured account identity: every account carries a REQUIRED `issuer` - * naming the authority that vouched for that id. Sign-in resolves accounts with - * `findAccountByKey({ issuer, accountId })`, so a row whose `issuer` is - * NULL is invisible to better-auth — the user's password or social link simply - * stops resolving. This helper closes that gap at boot, once, in place. - * - * What an issuer looks like is not ours to invent — better-auth mints it: - * - password accounts → `local:credential` - * - social providers → whatever the PROVIDER declares. A provider that - * names its own authority carries it (`google` → - * `https://accounts.google.com`, `apple` → `https://appleid.apple.com`); - * only a provider that declares none falls back to better-auth's synthetic - * `local:oauth:`. This is the same either/or better-auth itself - * applies in `resolveOAuthAccountKey`, which is why the resolved providers - * are READ (`socialProviders`) rather than derived from their ids. - * - federated OIDC / SAML → the IdP's real `iss`, which for registered SSO - * providers this environment already stores on `sys_sso_provider.issuer` - * - * Rows whose issuer cannot be DERIVED are deliberately left alone rather than - * stamped with a guess: a wrong issuer is indistinguishable from a missing one - * at sign-in, and it also occupies the (provider_id, account_id) unique slot - * that the correct row needs. They are reported instead, with the provider ids - * an operator needs to resolve them. - * - * That hazard is not hypothetical — it shipped. An earlier revision synthesized - * `local:oauth:` for EVERY social provider, so a Google link written before - * the 1.7 upgrade was stamped `local:oauth:google` while sign-in looked it up - * under `https://accounts.google.com`. The row was invisible, the callback fell - * through to "link this provider", and the insert collided with that very row on - * the `(provider_id, account_id)` unique index — surfacing to the user as - * `?error=unable_to_link_account`. Correcting the derivation alone does not - * repair a database already stamped that way, so the pass also RE-STAMPS rows - * carrying the synthetic issuer for a provider that declares a real one. - * - * Idempotent: it only ever touches rows whose issuer is missing or provably - * wrong, so re-running it after a partial pass (or on every boot) converges and - * then does nothing. - */ - -interface BackfillLogger { - info: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; -} - -/** - * The shape of a social provider better-auth has already INSTANTIATED - * (`authContext.socialProviders`). `accountIssuer` is the authority the - * provider names for itself: a string for the providers that have one, a - * function of the OAuth response for the ones that read it per login - * (Microsoft Entra takes the tenant's `iss` from the profile), and absent for - * the providers that have none. - */ -export interface ResolvedSocialProvider { - id: string; - accountIssuer?: unknown; -} - -export interface BackfillAccountIssuerOptions { - logger?: BackfillLogger; - /** - * The social providers better-auth instantiated. PREFER this over - * `socialProviderIds`: the issuer is read from the provider, so a provider - * that names its own authority is stamped with it instead of the synthetic - * fallback. - */ - socialProviders?: readonly ResolvedSocialProvider[]; - /** - * Provider ids wired as better-auth `socialProviders` (google, github, …), - * for callers that cannot reach the resolved providers. - * - * Ids alone cannot say whether a provider declares an issuer, so this path - * can only synthesize `local:oauth:` — correct for the providers that - * declare none, WRONG for the ones that do. Ids covered by `socialProviders` - * are resolved from there; the rest are synthesized, as before. - */ - socialProviderIds?: readonly string[]; - /** - * providerId → issuer for `genericOAuth` providers that declare one in - * config. Providers that only carry a `discoveryUrl` resolve their issuer at - * runtime, so they are not derivable here and are reported instead. - */ - oidcProviderIssuers?: Readonly>; - /** Safety valve for very large tables; rows beyond it are left for the next boot. */ - limit?: number; -} - -export interface BackfillAccountIssuerResult { - /** Rows found whose issuer is missing or provably wrong. */ - scanned: number; - /** Rows stamped with a derived issuer. */ - stamped: number; - /** - * Rows whose synthetic `local:oauth:` issuer was corrected to the real - * one their provider declares (counted in `scanned`, not in `stamped`). - */ - repaired: number; - /** Provider ids whose issuer could not be derived, with their row counts. */ - unresolved: Array<{ providerId: string; count: number }>; -} - -const SYSTEM_CTX = { isSystem: true }; - -/** The issuer better-auth stamps on local email+password accounts. */ -export const CREDENTIAL_ISSUER = createLocalAccountIssuer('credential'); - -/** The issuer better-auth synthesizes for an OAuth provider that declares none. */ -export function oauthIssuerFor(providerId: string): string { - return createOAuthAccountIssuer(providerId); -} - -async function tryFind(ql: any, object: string, where: any, limit: number): Promise { - try { - return await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); - } catch { - return []; - } -} - -export async function backfillAccountIssuer( - ql: any, - options: BackfillAccountIssuerOptions = {}, -): Promise { - const limit = options.limit ?? 5000; - const logger = options.logger; - const result: BackfillAccountIssuerResult = { scanned: 0, stamped: 0, repaired: 0, unresolved: [] }; - if (!ql || typeof ql.find !== 'function' || typeof ql.update !== 'function') return result; - - // ── Which issuer each provider actually mints ──────────────────────────── - // - // Read from the instantiated providers where they are available; a provider - // whose `accountIssuer` is a FUNCTION reads the authority off each login's - // OAuth response (Entra takes the tenant `iss` from the profile), so it is - // not derivable at boot and is deliberately left to the unresolved report - // rather than guessed. - const socialIssuers = new Map(); - const perLoginIssuers = new Set(); - for (const provider of options.socialProviders ?? []) { - const id = provider?.id; - if (typeof id !== 'string' || !id) continue; - const declared = provider.accountIssuer; - if (typeof declared === 'function') perLoginIssuers.add(id); - else if (typeof declared === 'string' && declared.trim()) socialIssuers.set(id, declared); - else socialIssuers.set(id, oauthIssuerFor(id)); - } - // Ids named without a resolved provider behind them keep the historical - // synthetic fallback — see the `socialProviderIds` doc comment for why that - // is a second best. - for (const id of options.socialProviderIds ?? []) { - if (!socialIssuers.has(id) && !perLoginIssuers.has(id)) socialIssuers.set(id, oauthIssuerFor(id)); - } - - const configuredIssuers = options.oidcProviderIssuers ?? {}; - - // Registered SSO providers carry the IdP's real `iss` — the one issuer that - // cannot be synthesized. Fetched once and indexed by provider id. - const ssoIssuers = new Map(); - for (const provider of await tryFind(ql, 'sys_sso_provider', {}, limit)) { - const providerId = provider?.provider_id; - const issuer = provider?.issuer; - if (typeof providerId === 'string' && typeof issuer === 'string' && issuer) { - ssoIssuers.set(providerId, issuer); - } - } - - const resolve = (providerId: string): string | undefined => { - if (providerId === 'credential') return CREDENTIAL_ISSUER; - if (ssoIssuers.has(providerId)) return ssoIssuers.get(providerId); - if (configuredIssuers[providerId]) return configuredIssuers[providerId]; - if (socialIssuers.has(providerId)) return socialIssuers.get(providerId); - return undefined; - }; - - // A driver that stores "no value" as '' rather than NULL is just as invisible - // to better-auth, so both shapes are collected. - const unstamped = [ - ...(await tryFind(ql, 'sys_account', { issuer: null }, limit)), - ...(await tryFind(ql, 'sys_account', { issuer: '' }, limit)), - ].filter((r) => r?.id && !r.issuer); - - // Rows an earlier pass stamped `local:oauth:` for a provider that turns - // out to name a real authority are exactly as unresolvable at sign-in as - // unstamped ones, and they hold the unique slot the correct row needs. Only - // this one wrong-by-construction value is rewritten: a row already carrying - // the real issuer, or any other value, is left alone. - const misstamped: any[] = []; - for (const [providerId, realIssuer] of socialIssuers) { - const synthetic = oauthIssuerFor(providerId); - if (realIssuer === synthetic) continue; - for (const row of await tryFind( - ql, - 'sys_account', - { provider_id: providerId, issuer: synthetic }, - limit, - )) { - if (row?.id && row.issuer === synthetic) misstamped.push(row); - } - } - - const pending = [...unstamped, ...misstamped]; - result.scanned = pending.length; - if (pending.length === 0) return result; - - const unresolved = new Map(); - for (const row of pending) { - const providerId = typeof row.provider_id === 'string' ? row.provider_id : ''; - const issuer = providerId ? resolve(providerId) : undefined; - if (!issuer) { - unresolved.set(providerId || '(none)', (unresolved.get(providerId || '(none)') ?? 0) + 1); - continue; - } - if (issuer === row.issuer) continue; - // Read before the write: an engine is free to hand back (or patch in - // place) the row it just updated, and this must classify the row as it - // was, not as it became. - const wasMisstamped = Boolean(row.issuer); - try { - await ql.update('sys_account', { id: row.id, issuer }, { context: SYSTEM_CTX }); - if (wasMisstamped) result.repaired++; - else result.stamped++; - } catch (e: any) { - unresolved.set(providerId, (unresolved.get(providerId) ?? 0) + 1); - logger?.warn('[auth] could not stamp sys_account.issuer', { - accountId: row.id, - providerId, - error: e?.message ?? String(e), - }); - } - } - - result.unresolved = Array.from(unresolved, ([providerId, count]) => ({ providerId, count })); - - if (result.stamped > 0) { - logger?.info( - `[auth] stamped issuer on ${result.stamped} pre-1.7 sys_account row(s) — better-auth 1.7 resolves accounts by (issuer, account_id)`, - ); - } - if (result.repaired > 0) { - logger?.info( - `[auth] corrected the issuer on ${result.repaired} sys_account row(s) stamped with the synthetic local:oauth: value for a provider that declares its own — those links resolve at sign-in again`, - ); - } - if (result.unresolved.length > 0) { - logger?.warn( - '[auth] some sys_account rows have no issuer and none could be derived — better-auth 1.7 cannot resolve them, so those users cannot sign in through that provider until the row is stamped with the IdP\'s issuer (its OIDC `iss`) or removed so a fresh login can re-link', - { providers: result.unresolved }, - ); - } - - return result; -} diff --git a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts index 4051f5fe33..fcba18dd43 100644 --- a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts +++ b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts @@ -58,7 +58,7 @@ import { probeHumanUsersPresence, probeSignInAccountsPresence } from './boot-sig import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js'; import { loadPhoneSmsTemplateBody, seedPhoneSmsTemplates } from './phone-sms-texts.js'; import { resolveDefaultOrgId } from './tenancy-service.js'; -import { backfillAccountIssuer } from './backfill-account-issuer.js'; +import { probeAccountIdentityCollisions } from './account-identity-preflight.js'; import { canonicalizeStoredMemberRoles } from './member-role-canonical.js'; const SECRET = 'test-secret-at-least-32-chars-long-15597'; @@ -230,8 +230,13 @@ const BLOCKS: Array<{ empty: (e) => (e as never as { find: Function }).find('sys_member', { where: { user_id: 'ghost' }, limit: 1 }, { context: SYSTEM_CTX }), }, { - id: 'B9 backfillAccountIssuer.tryFind', - site: 'backfill-account-issuer.ts', + // [#17440] `backfill-account-issuer.ts` retired with the issuer column it + // stamped. The `sys_account` read it measured did not: the successor site + // is the retirement preflight, which reads the same object and — being the + // answer that authorises an irreversible drop — REFUSES a non-array rather + // than absorbing it. Same measurement, higher stakes. + id: 'B9 probeAccountIdentityCollisions', + site: 'account-identity-preflight.ts', populated: (e) => (e as never as { find: Function }).find('sys_account', { where: { provider_id: 'credential' }, limit: 5000 }, { context: SYSTEM_CTX }), empty: (e) => (e as never as { find: Function }).find('sys_account', { where: { provider_id: 'ghost' }, limit: 5000 }, { context: SYSTEM_CTX }), }, @@ -418,11 +423,11 @@ describe('#15597 — the blocks driven through their real production entry point expect(await resolveDefaultOrgId(engine)).toBe('org_1'); }); - it('backfillAccountIssuer and canonicalizeStoredMemberRoles scan the bare array (B9, B10)', async () => { + it('probeAccountIdentityCollisions and canonicalizeStoredMemberRoles scan the bare array (B9, B10)', async () => { const engine = await bootEngine(); await seedAll(engine); - const backfill = await backfillAccountIssuer(engine); - expect(backfill.scanned).toBeGreaterThan(0); + const probe = await probeAccountIdentityCollisions(engine as never); + expect(probe.scanned).toBeGreaterThan(0); const canon = await canonicalizeStoredMemberRoles(engine); // One membership row was seeded, and the scan saw it — the count comes // straight off the array the removed limb used to normalise. diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index 45076f9676..e1612f1b72 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -11,10 +11,12 @@ export * from './auth-plugin.js'; export * from './auth-manager.js'; export * from './ensure-default-organization.js'; -// better-auth 1.7 account-identity backfill. Exported because a host that -// upgrades outside this plugin's boot path (a migration job, the cloud control -// plane) needs to stamp `sys_account.issuer` on its own schedule. -export * from './backfill-account-issuer.js'; +// #17440 — the read-only preflight that guards the retirement of +// `sys_account.issuer`, plus the re-point guard that answers the one case the +// retired column still discriminated. Exported because a host that upgrades +// outside this plugin's boot path (a migration job, the cloud control plane) +// has to run the probe on its own schedule. +export * from './account-identity-preflight.js'; export * from './set-initial-password.js'; export * from './admin-user-endpoints.js'; export * from './admin-ban-endpoints.js'; diff --git a/packages/plugins/plugin-auth/src/list-user-invitations-verification.ts b/packages/plugins/plugin-auth/src/list-user-invitations-verification.ts index 9c78867440..a761e4ed63 100644 --- a/packages/plugins/plugin-auth/src/list-user-invitations-verification.ts +++ b/packages/plugins/plugin-auth/src/list-user-invitations-verification.ts @@ -7,8 +7,9 @@ * * ## The defect, and where it is minted * - * Not here: it comes out of the pinned vendor. Measured against the installed - * better-auth `1.7.2`, `dist/plugins/organization/routes/crud-invites.mjs`: + * Not here: it comes out of the pinned vendor. Re-measured 2026-09-11 against + * the installed better-auth `1.7.3` (unchanged from the `1.7.2` reading this + * replaces), `dist/plugins/organization/routes/crud-invites.mjs`: * the three id-addressed routes ask * `shouldRequireVerifiedEmailForInvitationIdAction({ organizationOptions, … })`, * whose first line is diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts index 3a5e93f37c..9cbbf7a1d0 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts @@ -86,16 +86,20 @@ describe('AUTH_*_CONFIG schema mappings', () => { }); }); - // better-auth 1.7 added the required `issuer`. Lookups key on - // (issuer, accountId), so a missing mapping here reads as "no such account" - // on every sign-in. The id field's NAME flip-flopped mid pre-release — - // `1.7.0-rc.2` called it `providerAccountId`, stable 1.7 calls it - // `accountId` again (#3002) — which is why it is pinned here. + // Lookups key on (providerId, accountId), so a missing mapping for either + // reads as "no such account" on every sign-in. The id field's NAME + // flip-flopped mid pre-release — `1.7.0-rc.2` called it + // `providerAccountId`, stable 1.7 calls it `accountId` again (#3002) — + // which is why it is pinned here. + // + // [#17440] `issuer` is deliberately ABSENT: 1.7.3 removed the column and the + // key it served. `toEqual` (not `objectContaining`) is what makes that + // absence an assertion — a stray mapping for a column better-auth no longer + // declares would fail here rather than sit inert. it('should map account camelCase fields to snake_case', () => { expect(AUTH_ACCOUNT_CONFIG.fields).toEqual({ userId: 'user_id', providerId: 'provider_id', - issuer: 'issuer', accountId: 'account_id', accessToken: 'access_token', refreshToken: 'refresh_token', diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 3107bd0c1a..1b1d1b5ed1 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -7,6 +7,7 @@ import { SystemObjectName } from '@objectstack/spec/system'; import { resolveAttributedUserId } from './auth-actor-attribution.js'; import { inScimRequestScope } from './scim-connection-service.js'; import { adoptExistingMembership } from './adopt-membership.js'; +import { refuseIssuerRepointWithLiveBindings } from './account-identity-preflight.js'; import { filterRevokedSessionRows, hideRevokedSessionRow, @@ -980,6 +981,18 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { // would encrypt at registration and then write cleartext back on the // first config edit, leaving a column that only LOOKS protected. liftClientSecretForWrite(objectName, patch); + // [#17440] Write door #2 again, for a different column. Account + // identity is `(provider_id, account_id)` since better-auth 1.7.3, and + // no column records which IdP vouched for a row — so re-pointing a + // provider at a new issuer while accounts are still bound to it can + // hand the new IdP's subject the old IdP's account. This is the LAST + // moment at which the two are still distinguishable. + await refuseIssuerRepointWithLiveBindings( + dataEngine as never, + objectName, + record as Record, + patch as Record, + ); const result = await dataEngine.update(objectName, { ...patch, id: record.id }); // [#16231] The payload carries the resolved `id` and no `where`, so // the engine dispatches `by-id` and answers the record or `null`. The @@ -1006,6 +1019,14 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { // be the one path that writes the secret back in cleartext. liftClientSecretForWrite(objectName, patch); for (const record of records) { + // [#17440] Same rule on the bulk door — a re-point smuggled through + // `updateMany` is the same re-point. + await refuseIssuerRepointWithLiveBindings( + dataEngine as never, + objectName, + record as Record, + patch as Record, + ); await dataEngine.update(objectName, { ...patch, id: record.id }); } return records.length; diff --git a/packages/plugins/plugin-auth/src/scim-connection-service.ts b/packages/plugins/plugin-auth/src/scim-connection-service.ts index 6e09a852b6..b388b4dec9 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-service.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-service.ts @@ -52,11 +52,15 @@ import { AsyncLocalStorage } from 'node:async_hooks'; * writes. An `enterWith` marks only the async resource it runs in and that * resource's descendants; the vendor awaits the verifier from the endpoint's * own frame and resumes the handler from a continuation captured before the - * verifier ran. Measured on `@better-auth/scim` 1.7.2: zero - * `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`, - * `inScimRequestScope()` false inside every identity write. `run(...)` has a - * callback boundary; every `als.run` the vendor performs underneath nests - * inside it. Pinned at run time by `scim-transaction-scope.test.ts`. + * verifier ran. Measured 2026-09-02 (#14624) on the then-installed + * `@better-auth/scim` 1.7.2: zero `engine.transaction` calls across + * `POST /Users` + `PATCH /Users/{id}`, `inScimRequestScope()` false inside + * every identity write. ⛔ Deliberately anchored rather than restamped to the + * lifted family: that reading is an ABLATION of the design that was REJECTED, + * and re-running it would mean re-breaking the scope to watch it fail. What + * ships is pinned at run time against whatever version is installed, by + * `scim-transaction-scope.test.ts`. `run(...)` has a callback boundary; every + * `als.run` the vendor performs underneath nests inside it. * * Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a * REAL engine transaction (the atomicity upstream's diff --git a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts index c27ebf78ae..1f40dffecb 100644 --- a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts +++ b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts @@ -75,7 +75,6 @@ import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin- import { registerIdentityWriteGuard, registerManagedUpdateWhitelist } from './identity-write-guard.js'; import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js'; import { SCIM_DEACTIVATION_BAN_REASON } from './user-ban-write.js'; -import { CREDENTIAL_ISSUER } from './backfill-account-issuer.js'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -304,7 +303,6 @@ async function attachPassword(h: Harness, user: Provisioned): Promise { await ctx.internalAdapter.createAccount({ userId: user.userId, providerId: 'credential', - issuer: CREDENTIAL_ISSUER, accountId: user.userId, password: hashed, }); diff --git a/packages/plugins/plugin-auth/src/two-factor-reenrollment-verified-reset.test.ts b/packages/plugins/plugin-auth/src/two-factor-reenrollment-verified-reset.test.ts index a9761e4911..ace0fb22b7 100644 --- a/packages/plugins/plugin-auth/src/two-factor-reenrollment-verified-reset.test.ts +++ b/packages/plugins/plugin-auth/src/two-factor-reenrollment-verified-reset.test.ts @@ -24,6 +24,27 @@ // end to end, a legitimate rotation completes end to end, and the backup codes // the enable response issues still complete a sign-in. // +// ## [#17440] What the better-auth 1.7.3 lift moved +// +// 1.7.3 added the same gate one layer up, in `dist/plugins/two-factor`: +// `/two-factor/enable` now throws `TOTP_ALREADY_ENABLED` when a two-factor row +// exists with `verified !== false`. So point ② above is no longer true of a +// CONFIRMED account — the endpoint refuses instead of answering 200 — and the +// inheritance defect cannot be reproduced through this route at all. +// +// Two consequences, both pinned below rather than assumed: +// +// • the re-enrollment legs now assert the UPSTREAM refusal envelope, plus +// the property behind it (nothing was rotated behind the refusal), and the +// #10700 inertness assertion moves to the unconfirmed path, which is the +// one upstream's gate still admits; +// • ⭐ upstream's gate READS `verified` — the exact field #10700 was about — +// so `two-factor-reenrollment-verified-reset.ts` is what keeps that gate's +// input truthful. ⛔ It is not dead code superseded by the vendor. +// +// The rotation still-works leg moved with it: rotation now goes +// `/two-factor/disable` → `enable` → confirm. +// // Real better-auth pipeline throughout, following // `two-factor-rotated-token-echo.test.ts`: requests go in as `Request` objects // through `AuthManager.handleRequest`, the secrets are the ones better-auth @@ -281,8 +302,29 @@ describe('#10700 — first enrollment still completes (the still-works floor)', }); // ─────────────────────────────────────────────────────────────────────────── -describe('#10700 — a re-enrolled secret is inert until it is confirmed', () => { - it('the flag describes the STORED secret, and the challenge refuses the unconfirmed one', async () => { +describe('#10700 — the inheritance path is now REFUSED at the route (better-auth 1.7.3)', () => { + /** + * [#17440] What changed under the family lift, and why this is a stronger + * pin rather than a weaker one. + * + * better-auth 1.7.3 added the gate #10700 was about, one layer above ours + * (`dist/plugins/two-factor/index.mjs`): + * + * if (existingTwoFactor && existingTwoFactor.verified !== false) + * throw APIError.from("BAD_REQUEST", TOTP_ALREADY_ENABLED); + * + * So a second `/two-factor/enable` on a CONFIRMED account no longer reaches + * the handler that rewrote the secret — the inheritance defect cannot be + * reproduced through this route at all. 1.7.2 had no such code + * (`TOTP_ALREADY_ENABLED` appears in 0 files there and 5 in 1.7.3, measured + * against a control code present in both). + * + * ⭐ That gate READS `verified`, which is precisely the field #10700 was + * about. Our data-layer reset (`two-factor-reenrollment-verified-reset.ts`) + * is therefore not dead code superseded by upstream — it is what keeps the + * input to upstream's new gate truthful. ⛔ Do not delete it as unreachable. + */ + it('a re-enroll on a confirmed account is refused, and nothing is rotated behind the refusal', async () => { const { engine, manager, userId, firstSecret, sessionCookie } = await arrangeConfirmedEnrolment(); const reenrolled = await post( @@ -291,62 +333,127 @@ describe('#10700 — a re-enrolled secret is inert until it is confirmed', () => { password: PASSWORD }, { cookie: sessionCookie }, ); - expect(reenrolled.status, `two-factor/enable (re-enroll): ${await reenrolled.clone().text()}`).toBe(200); - const secondSecret = await secretFromEnableResponse(reenrolled); - expect( - secondSecret.equals(firstSecret), - 'the premise: re-enrolling hands out a DIFFERENT secret', - ).toBe(false); + // ADR-0112: code AND status, never a status alone. + expect(await refusal(reenrolled)).toEqual({ status: 400, code: 'TOTP_ALREADY_ENABLED' }); - // ① The flag. Against the unfixed handler this read `true` — inherited - // from the enrollment that confirmed the PREVIOUS secret. - await expectVerified( - manager, - engine, - userId, - false, - 'verified must describe the secret stored beside it, not the enrollment before it', - ); + // The refusal is not a partial write: the flag still describes the stored + // secret, and the stored secret is still the confirmed one. + await expectVerified(manager, engine, userId, true, 'a refused re-enroll must not clear the flag'); - // ② What the challenge accepts — the half the flag alone cannot show. const { cookie, methods } = await beginChallenge(manager); + expect(methods, 'the confirmed factor is still offered').toContain('totp'); + const completed = await post(manager, '/two-factor/verify-totp', { code: totp(firstSecret) }, { cookie }); + expect(completed.status, `verify-totp (original secret after a refused re-enroll): ${await completed.clone().text()}`).toBe(200); expect( - methods, - 'the challenge must not offer a factor nobody has confirmed', - ).not.toContain('totp'); - - const withNewSecret = await post(manager, '/two-factor/verify-totp', { code: totp(secondSecret) }, { cookie }); - // ADR-0112: code AND status. Against the unfixed handler this was a 200 - // that installed a full session for a secret no one had ever confirmed. - expect(await refusal(withNewSecret)).toEqual({ status: 400, code: 'TOTP_NOT_ENABLED' }); - expect( - await principalFor(manager, { cookie: cookieHeader(withNewSecret) }), - 'a refused challenge must install nobody', - ).toBeNull(); + await principalFor(manager, { cookie: cookieHeader(completed) }), + 'the original confirmed secret still signs the user in', + ).toBe(userId); }); - it('confirming the re-enrolled secret makes it — and only it — live at the challenge', async () => { - const { engine, manager, userId, firstSecret, sessionCookie } = await arrangeConfirmedEnrolment(); + /** + * The complement, and the reason the refusal above is a gate rather than a + * blanket ban: an UNCONFIRMED enrollment is still re-enrollable + * (`verified !== false` is the condition), and on that path the #10700 + * property is asserted exactly as before — a secret nobody confirmed is + * inert at the challenge. + */ + it('an UNCONFIRMED enrollment can still be re-enrolled, and the new secret stays inert', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + const signedUp = await post(manager, '/sign-up/email', { + email: EMAIL, + password: PASSWORD, + name: 'Re-enrolling User', + }); + expect(signedUp.status, `sign-up: ${await signedUp.clone().text()}`).toBe(200); + const userId = userIdFor(engine, EMAIL); + + const first = await post(manager, '/two-factor/enable', { password: PASSWORD }, { cookie: cookieHeader(signedUp) }); + expect(first.status, `two-factor/enable (first): ${await first.clone().text()}`).toBe(200); + const firstSecret = await secretFromEnableResponse(first); + await expectVerified(manager, engine, userId, false, 'a fresh enrollment must not read as confirmed'); + + // Allowed: `verified === false`, so upstream's gate does not fire. + const second = await post(manager, '/two-factor/enable', { password: PASSWORD }, { cookie: cookieHeader(signedUp) }); + expect(second.status, `two-factor/enable (re-enroll, unconfirmed): ${await second.clone().text()}`).toBe(200); + const secondSecret = await secretFromEnableResponse(second); + expect( + secondSecret.equals(firstSecret), + 'the premise: re-enrolling hands out a DIFFERENT secret', + ).toBe(false); - const reenrolled = await post( + await expectVerified(manager, engine, userId, false, 'and it still reads as unconfirmed'); + + // What the RUNTIME does with it — the half the flag alone cannot show. + // An unconfirmed factor gates nothing, so a sign-in does not stop at a + // challenge at all: there is no live factor to challenge with. + const signedIn = await post(manager, '/sign-in/email', { email: EMAIL, password: PASSWORD }); + expect(signedIn.status, `sign-in: ${await signedIn.clone().text()}`).toBe(200); + const body = (await signedIn.clone().json()) as { twoFactorRedirect?: boolean }; + expect( + body.twoFactorRedirect, + 'an unconfirmed factor must not gate sign-in — it is inert, not half-live', + ).not.toBe(true); + + // And the re-enrollment really REPLACED the stored secret rather than + // adding a second live one — the half a flag assertion cannot show. The + // superseded secret no longer confirms; the current one does. + const sessionForConfirm = cookieHeader(signedIn); + const withOldSecret = await post( manager, - '/two-factor/enable', - { password: PASSWORD }, - { cookie: sessionCookie }, + '/two-factor/verify-totp', + { code: totp(firstSecret) }, + { cookie: sessionForConfirm }, ); - expect(reenrolled.status).toBe(200); - const secondSecret = await secretFromEnableResponse(reenrolled); + expect(await refusal(withOldSecret)).toEqual({ status: 401, code: 'INVALID_CODE' }); + await expectVerified(manager, engine, userId, false, 'a refused confirmation must not flip the flag'); - // The still-works leg for rotation: the confirmation step is reachable - // with the session the caller already holds. An implementation that just - // refused `enable` never gets here. - const confirmed = await post( + const withNewSecret = await post( manager, '/two-factor/verify-totp', { code: totp(secondSecret) }, - { cookie: sessionCookie }, + { cookie: sessionForConfirm }, ); - expect(confirmed.status, `verify-totp (re-enrol confirmation): ${await confirmed.clone().text()}`).toBe(200); + expect(withNewSecret.status, `verify-totp (current secret): ${await withNewSecret.clone().text()}`).toBe(200); + await expectVerified(manager, engine, userId, true, 'the CURRENT secret is the one that confirms'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +/** + * The still-works floor for ROTATION. [#17440] Rotation now goes through + * `/two-factor/disable` first, because 1.7.3 refuses `enable` on a confirmed + * factor — so this leg moved rather than disappeared. It stays load-bearing + * for the same reason it always was: an implementation that simply refused + * every re-enrollment would satisfy the refusal pins above while breaking + * every legitimate secret rotation. + */ +describe('#10700 — a legitimate rotation still completes, end to end', () => { + it('disable → enable → confirm makes the NEW secret live and the old one dead', async () => { + const { engine, manager, userId, firstSecret, sessionCookie } = await arrangeConfirmedEnrolment(); + + const disabled = await post(manager, '/two-factor/disable', { password: PASSWORD }, { cookie: sessionCookie }); + expect(disabled.status, `two-factor/disable: ${await disabled.clone().text()}`).toBe(200); + + // `disable` ends the session it was called with (better-auth deletes it on + // the way out), so the rotation continues under the cookie IT installed — + // reusing the stale one answers 401 and would read as "rotation is broken". + const rotatingCookie = cookieHeader(disabled) || sessionCookie; + expect(rotatingCookie, 'disable installed no usable session cookie').toContain('session_token='); + + const reenrolled = await post(manager, '/two-factor/enable', { password: PASSWORD }, { cookie: rotatingCookie }); + expect(reenrolled.status, `two-factor/enable (after disable): ${await reenrolled.clone().text()}`).toBe(200); + const secondSecret = await secretFromEnableResponse(reenrolled); + expect(secondSecret.equals(firstSecret), 'rotation hands out a DIFFERENT secret').toBe(false); + + // The backup codes this response issues are the recovery floor — the same + // floor the old re-enrollment window pinned, on the path that still exists. + const { backupCodes } = (await reenrolled.clone().json()) as { backupCodes: string[] }; + expect(Array.isArray(backupCodes) && backupCodes.length > 0, 'rotation must issue backup codes').toBe(true); + + const confirmed = await post(manager, '/two-factor/verify-totp', { code: totp(secondSecret) }, { cookie: rotatingCookie }); + expect(confirmed.status, `verify-totp (rotation confirmation): ${await confirmed.clone().text()}`).toBe(200); await expectVerified(manager, engine, userId, true, 'confirming a rotation must flip the flag back'); const { cookie, methods } = await beginChallenge(manager); @@ -360,63 +467,17 @@ describe('#10700 — a re-enrolled secret is inert until it is confirmed', () => // an implementation that never rotated anything. const stale = await beginChallenge(manager); const withOldSecret = await post(manager, '/two-factor/verify-totp', { code: totp(firstSecret) }, { cookie: stale.cookie }); - // Measured, and deliberately a DIFFERENT envelope from the one above: a - // superseded secret is now merely a wrong code (`401 INVALID_CODE`, from - // `verify-two-factor.mjs`'s `invalid()`), whereas an unconfirmed factor is - // refused by the gate before any code is checked (`400 TOTP_NOT_ENABLED`). - // Asserting the pair keeps "refused" from collapsing into one status. expect(await refusal(withOldSecret)).toEqual({ status: 401, code: 'INVALID_CODE' }); expect(await principalFor(manager, { cookie: cookieHeader(withOldSecret) })).toBeNull(); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// The availability window, pinned as it ACTUALLY is rather than as one would -// like it to be. `/two-factor/enable` rewrites the account's single -// `sys_two_factor` row unconditionally, so the previously confirmed secret -// stops working the moment the call returns — that is true before this change -// and after it, and this fix does not claim otherwise. What the fix changes is -// WHERE the caller finds out: with a live session in hand rather than at the -// next sign-in with none. These two pins hold the floor that does exist, so a -// later change that quietly removes the recovery path turns red here. -describe('#10700 — the window between re-enrolling and confirming', () => { - it('the superseded secret is gone from the challenge, and the fresh backup codes are the way back in', async () => { - const { manager, userId, firstSecret, sessionCookie } = await arrangeConfirmedEnrolment(); - const reenrolled = await post( - manager, - '/two-factor/enable', - { password: PASSWORD }, - { cookie: sessionCookie }, - ); - expect(reenrolled.status).toBe(200); - const { backupCodes } = (await reenrolled.clone().json()) as { backupCodes: string[] }; - expect(Array.isArray(backupCodes) && backupCodes.length > 0, 're-enrolling must issue backup codes').toBe(true); - - // The superseded secret: refused, and refused for the reason the flag - // gives — the factor is unconfirmed, not merely mistyped. - const stale = await beginChallenge(manager); - const withOldSecret = await post( - manager, - '/two-factor/verify-totp', - { code: totp(firstSecret) }, - { cookie: stale.cookie }, - ); - expect(await refusal(withOldSecret)).toEqual({ status: 400, code: 'TOTP_NOT_ENABLED' }); - - // The floor: the caller is not locked out. The codes THIS response handed - // over complete the sign-in. + // The recovery floor, driven: the codes THIS rotation handed over complete + // a sign-in. const recovery = await beginChallenge(manager); - const rescued = await post( - manager, - '/two-factor/verify-backup-code', - { code: backupCodes[0] }, - { cookie: recovery.cookie }, - ); + const rescued = await post(manager, '/two-factor/verify-backup-code', { code: backupCodes[0] }, { cookie: recovery.cookie }); expect(rescued.status, `verify-backup-code: ${await rescued.clone().text()}`).toBe(200); expect( await principalFor(manager, { cookie: cookieHeader(rescued) }), - 'the backup code issued by the re-enrollment must sign the user in', + 'the backup code issued by the rotation must sign the user in', ).toBe(userId); }); }); diff --git a/packages/plugins/plugin-auth/tsup.config.ts b/packages/plugins/plugin-auth/tsup.config.ts index ccc92cd7e9..7f9b16a0f5 100644 --- a/packages/plugins/plugin-auth/tsup.config.ts +++ b/packages/plugins/plugin-auth/tsup.config.ts @@ -9,8 +9,8 @@ import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.m * * `src/index.ts` is the full plugin: `export *` over ~20 modules, several of * which take a **value** import on the better-auth family - * (`objectql-adapter.ts` → `better-auth/adapters`, `backfill-account-issuer.ts` - * → `@better-auth/core/db`). Loading the root therefore eagerly evaluates + * (`objectql-adapter.ts` → `better-auth/adapters`). Loading the root therefore + * eagerly evaluates * `better-auth` + `@better-auth/{core,oauth-provider,scim,sso}` + `jose` + * `@noble/hashes` + `@objectstack/rest` + `@objectstack/platform-objects`. * diff --git a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts index 909dc0d7f9..e1f6f4d7f1 100644 --- a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts @@ -22,19 +22,38 @@ // // ## The non-obvious half this file pins // -// A password hash is not enough. better-auth 1.7 keys account identity on -// `(issuer, accountId)`, so a credential row whose `issuer` is not the -// local credential issuer is invisible to `findAccountByKey` — sign-in then -// fails `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn that points -// at the `sys_user` row, which is fine, instead of at the account, which is not. -// Four checklist items had that recorded as a knownGap, each rediscovering it. -// -// So this file asserts the ISSUER explicitly and then asserts the thing the -// issuer exists for: a real sign-in over the real HTTP auth route, and a real +// A password hash is not enough: a persona needs a better-auth ACCOUNT row that +// `findAccountByKey` can actually resolve. This file asserts that the account +// exists under the key sign-in resolves on, and then asserts the thing the +// account exists for: a real sign-in over the real HTTP auth route, and a real // authenticated request driven with the returned token. Either half alone can // pass while the feature is broken — a correct-looking account nobody can use, // or a token minted for an identity that turns out to be the admin. // +// ## [#17440] Why the ISSUER assertion went away, recorded rather than deleted +// +// This file used to assert `sys_account.issuer` explicitly, and it said why: +// +// > better-auth 1.7 keys account identity on `(issuer, accountId)`, so a +// > credential row whose `issuer` is not the local credential issuer is +// > invisible to `findAccountByKey` — sign-in then fails +// > `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn that points at +// > the `sys_user` row, which is fine, instead of at the account, which is not. +// > Four checklist items had that recorded as a knownGap, each rediscovering it. +// +// better-auth 1.7.3 removed the issuer-scoped account identity outright +// (better-auth/better-auth#10909) and `sys_account.issuer` retired with it, so +// that assertion no longer names a field — and the trap it guarded no longer +// exists to be rediscovered a fifth time. +// +// ⛔ The assertion is REPLACED, not dropped. Its job was "the account is +// resolvable under the key sign-in uses", and the key is now +// `(provider_id, account_id)` — so that is what the case below asserts, with +// the admin's own better-auth-minted account as the same positive control the +// issuer case carried. The half that never depended on the key — a real +// sign-in over the real HTTP auth route, driven with the returned token — is +// untouched and is exactly as valuable as before. +// // ## Why this boot passes `onEnable` // // The persona bootstrap is an `onEnable` → `kernel:bootstrapped` hook, and @@ -99,22 +118,33 @@ describe('showcase demo personas are real logins (#9308 fixture 1)', () => { } }); - it('each persona holds a credential account stamped with the SAME issuer better-auth minted for the admin', async () => { + it('each persona holds a credential account resolvable under the SAME key better-auth uses for the admin', async () => { const adminAccount = await credentialAccountOf(adminId); - const adminIssuer = adminAccount?.issuer; - // The control: better-auth really does stamp an issuer on the account it - // creates itself. Without this the assertions below could both be - // `undefined === undefined` and read as agreement. - expect(typeof adminIssuer, 'better-auth stamped an issuer on the dev admin credential').toBe('string'); - expect(String(adminIssuer).length, 'and it is not empty').toBeGreaterThan(0); + // The control: better-auth really does write an account of this shape for + // the identity it creates itself. Without it the assertions below could + // all be `undefined === undefined` and read as agreement. + expect(adminAccount, 'better-auth wrote a credential account for the dev admin').toBeTruthy(); + expect(adminAccount?.provider_id, "and it is keyed under the 'credential' provider").toBe('credential'); + expect( + String(adminAccount?.account_id ?? ''), + "and its account id is the admin's own user id — the second half of the key", + ).toBe(adminId); + + // [#17440] The retired column, asserted ABSENT. Without this the case + // would still pass on a runtime that kept writing a field the platform no + // longer declares, which is the shape a half-finished retirement takes. + expect( + Object.prototype.hasOwnProperty.call(adminAccount ?? {}, 'issuer'), + 'sys_account.issuer retired with better-auth 1.7.3 — nothing should still be writing it', + ).toBe(false); for (const persona of [PHONE_DEMO_USER, AUDITOR_DEMO_USER]) { const account = await credentialAccountOf(persona.id); expect(account, `${persona.email} holds a credential account`).toBeTruthy(); expect( - account?.issuer, - `${persona.email}'s credential issuer must equal the admin's — a different value is invisible to better-auth's findAccountByKey and sign-in fails INVALID_EMAIL_OR_PASSWORD`, - ).toBe(adminIssuer); + account?.provider_id, + `${persona.email}'s account must sit under the same provider the admin's does, or findAccountByKey never sees it`, + ).toBe(adminAccount?.provider_id); expect(account?.account_id, `${persona.email}'s account is keyed to its own user id`).toBe(persona.id); } }); diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts new file mode 100644 index 0000000000..206b7b012a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'sys-account-issuer-retired', + surface: '`sys_account.issuer` — the column, its `{ fields: [\'issuer\', \'account_id\'], unique: ' + + 'true }` index, its label in the four generated translation bundles, and the ' + + '`@objectstack/plugin-auth` symbols that existed only to serve it ' + + '(`backfillAccountIssuer`, `CREDENTIAL_ISSUER`, `oauthIssuerFor`, `ResolvedSocialProvider`, ' + + '`BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult`). The ' + + '`accounts.list()` client type loses `issuer` with the route that stopped returning it.', + replacement: 'nothing — account identity is `(provider_id, account_id)`, which `sys_account` has ' + + 'declared UNIQUE since the object was created. A caller that read `account.issuer` reads ' + + 'nothing in its place: the authority is `sys_sso_provider.issuer`, resolved through the ' + + 'account\'s `provider_id`, which is unique per environment. A host that called ' + + '`backfillAccountIssuer` on its own schedule deletes the call; there is no successor pass. ' + + 'Existing deployments run the ceremony below before the column is dropped.', + reason: + 'better-auth 1.7.3 removed the issuer-scoped account identity outright ' + + '(better-auth/better-auth#10909): `createLocalAccountIssuer` is deleted, `accountSchema.issuer` ' + + 'is gone, `AccountKey` is `(providerId, accountId)` again, and the `account.issuer` column ' + + 'and its unique index are gone from `get-tables`. There is no drop-in replacement. ' + + 'Maintainer ruling 2026-09-10 on #16629: adopt the rollback rather than own a fork of an ' + + 'identity model the vendor abandoned — a permanent fork on the authentication library was ' + + 'refused, and staying pinned was refused as the durable answer (#16186 was the stopgap and ' + + 'has done its job). The column was a net liability in its own right: a credential row whose ' + + '`issuer` was not the local credential issuer was invisible to `findAccountByKey`, so ' + + 'sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the ' + + '`sys_user` row rather than at the account — four checklist items rediscovered that ' + + 'independently. Its discriminating power here was near zero: `sys_sso_provider` declares ' + + '`{ fields: [\'provider_id\'], unique: true }`, so `provider_id → issuer` is a function ' + + 'within an environment.', + acceptanceCriteria: + 'BEFORE the column is dropped, `os migrate account-issuer` reads zero on the deployment: no ' + + '`(provider_id, account_id)` key is held by more than one row. That pre-flight reads ROWS, ' + + 'never the index declaration, because `syncDeclaredIndexes` logs a plain UNIQUE whose CREATE ' + + 'failed on existing duplicates and lets the boot continue (#14902 / #15479) — so a database ' + + 'can carry the declaration without the constraint, and on such a database the drop degrades ' + + 'SILENTLY rather than failing. A dirty read refuses; so does a read that throws or a scan ' + + 'that truncates. `os migrate apply --allow-destructive` re-runs the same pre-flight and ' + + 'refuses the drop before writing any DDL; the boot refusal on unapplied destructive drift is ' + + 'unchanged, so a runtime never auto-migrates. Colliding rows are resolved by the operator — ' + + 'keep the row whose provider account is live, delete the rest so a fresh sign-in re-links — ' + + 'never merged or dropped by the platform. AFTER the drop, a fresh install and an ' + + 'existing-data upgrade both sign in over the real auth route. A `provider_id` re-pointed at a ' + + 'different IdP must have its account bindings REBUILT: no column records which IdP vouched ' + + 'for a row, so the key cannot separate the old IdP\'s subjects from the new one\'s, and the ' + + '`sys_sso_provider` update door refuses an issuer change while accounts are still bound to ' + + 'that provider.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 58618e4fbc..9c70fcfc79 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9949,6 +9949,53 @@ const step18: MigrationStep = { + 'parse-and-refuse accepts and rejects exactly the same sets before and after, ' + 'and no stored metadata or document needs editing.', }, + { + id: 'sys-account-issuer-retired', + surface: '`sys_account.issuer` — the column, its `{ fields: [\'issuer\', \'account_id\'], unique: ' + + 'true }` index, its label in the four generated translation bundles, and the ' + + '`@objectstack/plugin-auth` symbols that existed only to serve it ' + + '(`backfillAccountIssuer`, `CREDENTIAL_ISSUER`, `oauthIssuerFor`, `ResolvedSocialProvider`, ' + + '`BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult`). The ' + + '`accounts.list()` client type loses `issuer` with the route that stopped returning it.', + replacement: 'nothing — account identity is `(provider_id, account_id)`, which `sys_account` has ' + + 'declared UNIQUE since the object was created. A caller that read `account.issuer` reads ' + + 'nothing in its place: the authority is `sys_sso_provider.issuer`, resolved through the ' + + 'account\'s `provider_id`, which is unique per environment. A host that called ' + + '`backfillAccountIssuer` on its own schedule deletes the call; there is no successor pass. ' + + 'Existing deployments run the ceremony below before the column is dropped.', + reason: + 'better-auth 1.7.3 removed the issuer-scoped account identity outright ' + + '(better-auth/better-auth#10909): `createLocalAccountIssuer` is deleted, `accountSchema.issuer` ' + + 'is gone, `AccountKey` is `(providerId, accountId)` again, and the `account.issuer` column ' + + 'and its unique index are gone from `get-tables`. There is no drop-in replacement. ' + + 'Maintainer ruling 2026-09-10 on #16629: adopt the rollback rather than own a fork of an ' + + 'identity model the vendor abandoned — a permanent fork on the authentication library was ' + + 'refused, and staying pinned was refused as the durable answer (#16186 was the stopgap and ' + + 'has done its job). The column was a net liability in its own right: a credential row whose ' + + '`issuer` was not the local credential issuer was invisible to `findAccountByKey`, so ' + + 'sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the ' + + '`sys_user` row rather than at the account — four checklist items rediscovered that ' + + 'independently. Its discriminating power here was near zero: `sys_sso_provider` declares ' + + '`{ fields: [\'provider_id\'], unique: true }`, so `provider_id → issuer` is a function ' + + 'within an environment.', + acceptanceCriteria: + 'BEFORE the column is dropped, `os migrate account-issuer` reads zero on the deployment: no ' + + '`(provider_id, account_id)` key is held by more than one row. That pre-flight reads ROWS, ' + + 'never the index declaration, because `syncDeclaredIndexes` logs a plain UNIQUE whose CREATE ' + + 'failed on existing duplicates and lets the boot continue (#14902 / #15479) — so a database ' + + 'can carry the declaration without the constraint, and on such a database the drop degrades ' + + 'SILENTLY rather than failing. A dirty read refuses; so does a read that throws or a scan ' + + 'that truncates. `os migrate apply --allow-destructive` re-runs the same pre-flight and ' + + 'refuses the drop before writing any DDL; the boot refusal on unapplied destructive drift is ' + + 'unchanged, so a runtime never auto-migrates. Colliding rows are resolved by the operator — ' + + 'keep the row whose provider account is live, delete the rest so a fresh sign-in re-links — ' + + 'never merged or dropped by the platform. AFTER the drop, a fresh install and an ' + + 'existing-data upgrade both sign in over the real auth route. A `provider_id` re-pointed at a ' + + 'different IdP must have its account bindings REBUILT: no column records which IdP vouched ' + + 'for a row, so the key cannot separate the old IdP\'s subjects from the new one\'s, and the ' + + '`sys_sso_provider` update door refuses an issuer change while accounts are still bound to ' + + 'that provider.', + }, { id: 'system-cache-durations-unit-in-key', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35e8a287fa..5295584b84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,17 +10,17 @@ overrides: tar@>=2.0.0 <8.0.0: ^7.5.11 form-data@<5.0.0: '>=4.0.6' undici@>=7.23.0 <8.0.0: ^7.29.0 - better-auth@<2.0.0: 1.7.2 - '@better-auth/core@<2.0.0': 1.7.2 - '@better-auth/scim@<2.0.0': 1.7.2 - '@better-auth/oauth-provider@<2.0.0': 1.7.2 - '@better-auth/sso@<2.0.0': 1.7.2 - '@better-auth/drizzle-adapter@<2.0.0': 1.7.2 - '@better-auth/kysely-adapter@<2.0.0': 1.7.2 - '@better-auth/memory-adapter@<2.0.0': 1.7.2 - '@better-auth/mongo-adapter@<2.0.0': 1.7.2 - '@better-auth/prisma-adapter@<2.0.0': 1.7.2 - '@better-auth/telemetry@<2.0.0': 1.7.2 + better-auth@<2.0.0: 1.7.3 + '@better-auth/core@<2.0.0': 1.7.3 + '@better-auth/scim@<2.0.0': 1.7.3 + '@better-auth/oauth-provider@<2.0.0': 1.7.3 + '@better-auth/sso@<2.0.0': 1.7.3 + '@better-auth/drizzle-adapter@<2.0.0': 1.7.3 + '@better-auth/kysely-adapter@<2.0.0': 1.7.3 + '@better-auth/memory-adapter@<2.0.0': 1.7.3 + '@better-auth/mongo-adapter@<2.0.0': 1.7.3 + '@better-auth/prisma-adapter@<2.0.0': 1.7.3 + '@better-auth/telemetry@<2.0.0': 1.7.3 uuid@<12.0.0: ^11.1.1 postcss@<9.0.0: ^8.5.10 cookie@<0.8.0: ^0.7.0 @@ -198,7 +198,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) + version: 1.30.0(zod@4.6.1) '@objectstack/cloud-connection': specifier: workspace:* version: link:../../packages/cloud-connection @@ -765,7 +765,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) + version: 1.30.0(zod@4.6.1) '@objectstack/core': specifier: workspace:* version: link:../../core @@ -1607,17 +1607,17 @@ importers: packages/plugins/plugin-auth: dependencies: '@better-auth/core': - specifier: 1.7.2 - version: 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + specifier: 1.7.3 + version: 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/oauth-provider': - specifier: 1.7.2 - version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3)) + specifier: 1.7.3 + version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.6.1)) '@better-auth/scim': - specifier: 1.7.2 - version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3)) + specifier: 1.7.3 + version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.6.1)) '@better-auth/sso': - specifier: 1.7.2 - version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3)) + specifier: 1.7.3 + version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.6.1)) '@noble/hashes': specifier: ^2.3.0 version: 2.3.0 @@ -1640,8 +1640,8 @@ importers: specifier: workspace:* version: link:../../types better-auth: - specifier: 1.7.2 - version: 1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11) + specifier: 1.7.3 + version: 1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11) jose: specifier: ^6.2.8 version: 6.2.8 @@ -3209,8 +3209,8 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@better-auth/core@1.7.2': - resolution: {integrity: sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==} + '@better-auth/core@1.7.3': + resolution: {integrity: sha512-JdP7lOkyE83jgjn7RilJj1XvZ7n2JjRsErKJuaXchjyuNo6cf1iVd3GtbhAtiUyJJkWdk8yL+LaUBKT80H0zLA==} peerDependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -3226,55 +3226,55 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.7.2': - resolution: {integrity: sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg==} + '@better-auth/drizzle-adapter@1.7.3': + resolution: {integrity: sha512-S+nQRlxbUhkR43LrSv8c98ZvOvmv3nrtOnHkiZXkdDkr60PWp7maC2cqgzZ2C9exCC1a+4TugJlx2jRL+r+/9A==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.7.2': - resolution: {integrity: sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ==} + '@better-auth/kysely-adapter@1.7.3': + resolution: {integrity: sha512-UIsyJMIrjUnT+yTaS6dkCxYYmtPwxFHxwSJ8+CLty2II5w9BewlDxDA0/QzhoL/InYCPxQ5Y6xIgLHZG1dhwRA==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.7.2': - resolution: {integrity: sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA==} + '@better-auth/memory-adapter@1.7.3': + resolution: {integrity: sha512-WdLANFY/QWC3G351RCzxU+Y9YlW+BQ1oG9NwBTSOUWQw5rZ87ws+weU8tvAMO7sQ4C9gKIlkOKBKUKXrSt91Tw==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.7.2': - resolution: {integrity: sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA==} + '@better-auth/mongo-adapter@1.7.3': + resolution: {integrity: sha512-YL9m01tNogmFmRvOWJ46M9WwE6HirCXHDell29mtsQB/Qs1TPNLrgj7ybGMMGhQuyj6S218+8Wbls8m9s+i8RQ==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/oauth-provider@1.7.2': - resolution: {integrity: sha512-td7FnUz3lLKXFXN+0RbZe3ygaHqxpRqDG+gxbfSZbztbVfG7vuZtR4ba3uccsodAw7anchhIg2xwVP1/zlcxcw==} + '@better-auth/oauth-provider@1.7.3': + resolution: {integrity: sha512-Eoajj68F1ETmp4NBM98tgj1Uiip4cZiFqFsijAWIvOEQTejr/67PWK1/92N8PiNdLf03ezUWU538W93rd9fyIw==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.2 + better-auth: 1.7.3 better-call: 1.4.0 - '@better-auth/prisma-adapter@1.7.2': - resolution: {integrity: sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ==} + '@better-auth/prisma-adapter@1.7.3': + resolution: {integrity: sha512-TJ/DhlU7oLzrC626/1wfYA1Pl+lVsXa/zXBZ8d7Rlc2YO5fGd5fsAYJdRrYMyA51iZEo+rWLXMhZ0cez1BamsQ==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3284,27 +3284,27 @@ packages: prisma: optional: true - '@better-auth/scim@1.7.2': - resolution: {integrity: sha512-RBwVN189w37xwK0n+mRgARTmJFCriOsMVCbe7Bzeyak6esSg9kTGeCUmGtfgwOKJ3jJWQkeAQdWw7VRJ/HOG6w==} + '@better-auth/scim@1.7.3': + resolution: {integrity: sha512-1/L7zIEUAb1jxlzWKkqbDAD4Jz7u83yZg15TSaIaKcFe1dpCdNxkzQRrjbPLUfnVtBh/vnk10uLxVFCtbMdrdg==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 - better-auth: 1.7.2 + better-auth: 1.7.3 better-call: 1.4.0 - '@better-auth/sso@1.7.2': - resolution: {integrity: sha512-8tmkAGdcVu8Tr/+LPfSRgs/5a8YE1uU8OeaN5mAzsSdMWR4iwZdwQlJJmU8f9qZyIpNL/B6mIfDc5vaVUy1ECQ==} + '@better-auth/sso@1.7.3': + resolution: {integrity: sha512-inxETRuOfyy8m0uEJSfi9uhScp4RkQ7ZYI+GRj+Jtz6ml/p5wTzNOjYZsG43TroUTDfrElF6CYQON5/Xk1XdmA==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.2 + better-auth: 1.7.3 better-call: 1.4.0 - '@better-auth/telemetry@1.7.2': - resolution: {integrity: sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ==} + '@better-auth/telemetry@1.7.3': + resolution: {integrity: sha512-aixgHbJhGvS8PRczX/LR3murYyBnIvOGmJw37ZZBMg6ZtLR/UBJAkufbDi1HYn5THSuTJ3D5DtY85Ahh/ABQtw==} peerDependencies: - '@better-auth/core': 1.7.2 + '@better-auth/core': 1.7.3 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -5718,8 +5718,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - better-auth@1.7.2: - resolution: {integrity: sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A==} + better-auth@1.7.3: + resolution: {integrity: sha512-8xGp68JQ+l36kniDEgP8bP99TLi1GdEv0NTEUBkyqnYnus/cgFUZseUfqUHKzr2BAsg2O6aD88I9f0U68shanQ==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -9341,6 +9341,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.1: + resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9726,86 +9729,86 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0)': + '@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.4.3) + better-call: 1.4.0(zod@4.6.1) jose: 6.2.8 kysely: 0.29.4 nanostores: 1.4.0 - zod: 4.4.3 + zod: 4.6.1 optionalDependencies: '@cloudflare/workers-types': 4.20260520.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/kysely-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.4)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.4 - '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(mongodb@7.5.0(socks@2.8.9))': + '@better-auth/mongo-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(mongodb@7.5.0(socks@2.8.9))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 optionalDependencies: mongodb: 7.5.0(socks@2.8.9) - '@better-auth/oauth-provider@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))': + '@better-auth/oauth-provider@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.6.1))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11) - better-call: 1.4.0(zod@4.4.3) + better-auth: 1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11) + better-call: 1.4.0(zod@4.6.1) jose: 6.2.8 - zod: 4.4.3 + zod: 4.6.1 - '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/scim@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))': + '@better-auth/scim@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.6.1))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 '@noble/hashes': 2.3.0 - better-auth: 1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11) - better-call: 1.4.0(zod@4.4.3) - zod: 4.4.3 + better-auth: 1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11) + better-call: 1.4.0(zod@4.6.1) + zod: 4.6.1 - '@better-auth/sso@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))': + '@better-auth/sso@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11))(better-call@1.4.0(zod@4.6.1))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@xmldom/xmldom': 0.9.12 - better-auth: 1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11) - better-call: 1.4.0(zod@4.4.3) + better-auth: 1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11) + better-call: 1.4.0(zod@4.6.1) fast-xml-parser: 5.10.1 jose: 6.2.8 samlify: 2.13.1 tldts: 7.4.9 - zod: 4.4.3 + zod: 4.6.1 - '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -10577,6 +10580,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.30.0(zod@4.6.1)': + dependencies: + '@hono/node-server': 2.0.12(hono@4.13.7) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.1(express@5.2.1) + hono: 4.13.7 + jose: 6.2.7 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.1 + zod-to-json-schema: 3.25.2(zod@4.6.1) + transitivePeerDependencies: + - supports-color + '@mongodb-js/saslprep@1.4.12': dependencies: sparse-bitfield: 3.0.3 @@ -11985,25 +12010,25 @@ snapshots: baseline-browser-mapping@2.11.14: {} - better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11): + better-auth@1.7.3(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.11): dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) - '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.4) - '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(mongodb@7.5.0(socks@2.8.9)) - '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(mongodb@7.5.0(socks@2.8.9)) + '@better-auth/prisma-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.1))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.3.0 '@noble/hashes': 2.3.0 - better-call: 1.4.0(zod@4.4.3) + better-call: 1.4.0(zod@4.6.1) defu: 6.1.7 jose: 6.2.8 kysely: 0.29.4 nanostores: 1.4.0 - zod: 4.4.3 + zod: 4.6.1 optionalDependencies: better-sqlite3: 12.11.1 mongodb: 7.5.0(socks@2.8.9) @@ -12018,14 +12043,14 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.4.0(zod@4.4.3): + better-call@1.4.0(zod@4.6.1): dependencies: '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1 rou3: 0.9.2 set-cookie-parser: 3.1.2 optionalDependencies: - zod: 4.4.3 + zod: 4.6.1 better-sqlite3@12.11.1: dependencies: @@ -16058,6 +16083,12 @@ snapshots: dependencies: zod: 4.4.3 + zod-to-json-schema@3.25.2(zod@4.6.1): + dependencies: + zod: 4.6.1 + zod@4.4.3: {} + zod@4.6.1: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d75051dd62..c554761e93 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -183,7 +183,7 @@ overrides: # `createOAuthAccountIssuer` and the whole `account.issuer` column from # `@better-auth/core/db` (better-auth/better-auth#10909 rolled the # issuer-scoped account identity back), and `@objectstack/plugin-auth` - # statically imports both names. A caret cannot express "the export surface + # statically imported both names. A caret cannot express "the export surface # we compile against", so `^` here means the tested version and the shipped # version are free to differ — which is exactly what happened: this # lockfile held 1.7.2 and every CI job was green while every consumer of @@ -191,14 +191,21 @@ overrides: # all. The selectors keep their `<2.0.0` major boundary, so lifting the # family later is still a target-only edit. # + # The target IS 1.7.3 since #17440: the platform adopted the vendor's + # rollback rather than owning a fork of a model its author abandoned, so + # nothing here imports the two deleted names any more and `sys_account` + # no longer carries the column they served. What did NOT change is the rule + # above — the exact target stays exact for the reason 1.7.3 demonstrated, + # and 1.7.4 (published) is the next reviewed one-line lift, not a float. + # # These targets are held EQUAL to the ranges `@objectstack/plugin-auth` # declares (`scripts/check-override-consistency.mjs` cross-checks that the # declared range admits the target; `pnpm check:vendor-export-contract` # requires the declared range to be exact and to export what we import). # Move all eleven together, in one commit, or better-auth init throws and # every auth endpoint 500s. - 'better-auth@<2.0.0': '1.7.2' - '@better-auth/core@<2.0.0': '1.7.2' + 'better-auth@<2.0.0': '1.7.3' + '@better-auth/core@<2.0.0': '1.7.3' # scim carries the family's target — see the @better-auth/scim note # above. It was held at 1.7.1 EXACT, one deliberate step behind `^1.7.1`, # because `^1.7.1` then resolved scim to 1.7.2 while the installed family @@ -229,15 +236,15 @@ overrides: # a lift whose new version drops a symbol `plugin-auth` imports. # What stays true: the whole family still moves as ONE line, and a bump that # moves scim alone is still the mistake the #3653 ruling named. - '@better-auth/scim@<2.0.0': '1.7.2' - '@better-auth/oauth-provider@<2.0.0': '1.7.2' - '@better-auth/sso@<2.0.0': '1.7.2' - '@better-auth/drizzle-adapter@<2.0.0': '1.7.2' - '@better-auth/kysely-adapter@<2.0.0': '1.7.2' - '@better-auth/memory-adapter@<2.0.0': '1.7.2' - '@better-auth/mongo-adapter@<2.0.0': '1.7.2' - '@better-auth/prisma-adapter@<2.0.0': '1.7.2' - '@better-auth/telemetry@<2.0.0': '1.7.2' + '@better-auth/scim@<2.0.0': '1.7.3' + '@better-auth/oauth-provider@<2.0.0': '1.7.3' + '@better-auth/sso@<2.0.0': '1.7.3' + '@better-auth/drizzle-adapter@<2.0.0': '1.7.3' + '@better-auth/kysely-adapter@<2.0.0': '1.7.3' + '@better-auth/memory-adapter@<2.0.0': '1.7.3' + '@better-auth/mongo-adapter@<2.0.0': '1.7.3' + '@better-auth/prisma-adapter@<2.0.0': '1.7.3' + '@better-auth/telemetry@<2.0.0': '1.7.3' 'uuid@<12.0.0': '^11.1.1' 'postcss@<9.0.0': '^8.5.10' 'cookie@<0.8.0': '^0.7.0' diff --git a/scripts/check-vendor-export-contract.mjs b/scripts/check-vendor-export-contract.mjs index 3592757cd0..febf7b1961 100644 --- a/scripts/check-vendor-export-contract.mjs +++ b/scripts/check-vendor-export-contract.mjs @@ -519,22 +519,53 @@ function selfTest() { eq('1.7.2 admits only itself', semver.satisfies('1.7.3', '1.7.2'), false); // ── the repo's own state, read through the same collector ───────────────── + // + // [#17440] This case used to be anchored on `@better-auth/core/db` naming + // `createLocalAccountIssuer` — the #16186 defect itself — and carried the + // instruction "if the durable fix landed, retire this case with it". The + // durable fix HAS landed: the platform adopted better-auth's rollback, the + // two deleted names are imported nowhere, and the family sits on an exact + // 1.7.3. + // + // ⛔ Retiring the SPECIMEN is not retiring the case. What this case exists + // to catch is a collector that has silently stopped reaching publishable + // source — at which point the whole gate passes over nothing, exactly the + // way #16186 passed over nothing for three releases. So it re-anchors on a + // LIVE edge instead of being deleted, and it still asserts a NAMED symbol + // rather than merely the specifier: an edge with an empty symbol set proves + // the import was found but not parsed. const { edges, declarations } = collectVendorEdges(); - const core = edges.get('@better-auth/core'); - if (!core || !core.has('@better-auth/core/db')) { + const root = edges.get('better-auth'); + if (!root || !root.has('better-auth/adapters')) { failures.push( - 'the collector no longer sees the @better-auth/core/db edge in publishable source — ' + + 'the collector no longer sees the better-auth/adapters edge in publishable source — ' + 'either the import moved (update this case) or the scan stopped reaching plugin-auth.', ); } else { - const symbols = [...core.get('@better-auth/core/db')].sort(); - if (!symbols.includes('createLocalAccountIssuer')) { + const symbols = [...root.get('better-auth/adapters')].sort(); + if (!symbols.includes('createAdapterFactory')) { failures.push( - 'the @better-auth/core/db edge no longer names createLocalAccountIssuer — if the durable ' + - 'fix landed, retire this case with it.', + 'the better-auth/adapters edge no longer names createAdapterFactory — if that import ' + + 'genuinely moved, re-anchor this case on another LIVE value import from a governed ' + + 'vendor. ⛔ Never delete it: an unanchored collector is a gate over nothing.', ); } } + // The retired specimen, asserted GONE. A stray re-introduction of either + // deleted name would not load at all on the pinned 1.7.3, so it is worth one + // line here rather than a runtime discovery. + for (const [pkg, specs] of edges) { + for (const [spec, syms] of specs) { + for (const dead of ['createLocalAccountIssuer', 'createOAuthAccountIssuer']) { + if (syms.has(dead)) { + failures.push( + `publishable source imports ${dead} from ${spec} (${pkg}) — better-auth deleted that ` + + 'export in 1.7.3 and #17440 retired our use of it; on the pinned line it cannot resolve.', + ); + } + } + } + } if (!declarations.has('@better-auth/core')) { failures.push('no publishable manifest declares @better-auth/core — the collector lost the declaration side.'); } diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 7f59dc3c5d..d5860a293f 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -528,9 +528,6 @@ "packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts": { "wrong": 1 }, - "packages/plugins/plugin-auth/src/account-issuer-parity.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-auth/src/admin-revoke-user-session-match-guard.test.ts": { "wrong": 1 },