Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
669bebd
chore(deps): lift the @better-auth/* family to an exact 1.7.3, one line
hotlong Sep 10, 2026
4c64350
wip(#17440): drop sys_account.issuer + unwire the boot backfill
hotlong Sep 10, 2026
2763ffa
feat(plugin-auth)!: the account-identity retirement preflight + the r…
hotlong Sep 10, 2026
8621497
test(plugin-auth): watch the #17440 preflight refuse, and pin the re-…
hotlong Sep 10, 2026
ee2cec1
feat(cli): os migrate account-issuer, and refuse the drop on a dirty …
hotlong Sep 10, 2026
6aed6ef
test(plugin-auth): adopt better-auth 1.7.3's own TOTP_ALREADY_ENABLED…
hotlong Sep 10, 2026
ebfdef3
chore: retire the remaining account-issuer surface
hotlong Sep 10, 2026
a80b2d8
test(gates): re-anchor check:vendor-export-contract's self-test on a …
hotlong Sep 10, 2026
f455026
chore: ADR-0087 ledger entry + the changeset for the account-issuer r…
hotlong Sep 10, 2026
17616ea
fix(plugin-auth): the collision key is JSON, never a raw U+0000 separ…
hotlong Sep 10, 2026
cec1012
test(plugin-auth): the existing-data upgrade, end to end over one rea…
hotlong Sep 10, 2026
e577e0e
fix(cli): keep the tracker id out of the operator-facing command desc…
hotlong Sep 10, 2026
c64a592
docs(audits): re-run the tenant-audit census after the backfill site …
hotlong Sep 11, 2026
48d6526
Merge origin/main into the account-issuer rollback branch
hotlong Sep 11, 2026
9afb0a3
docs(audits): re-run the tenant-audit census on the merged tree
hotlong Sep 11, 2026
a760606
fix(auth): repair the ten version stamps the 1.7.3 lift falsified
hotlong Sep 11, 2026
b57c135
chore(gates): ratchet DOWN the objectql-double-limit entry for the de…
hotlong Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .changeset/sys-account-issuer-retired.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered sys-account-issuer-retired -->

**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.
34 changes: 17 additions & 17 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ as an engine when it declares a write door whose first parameter is named
narrow one (`Partial<Pick<IDataEngine, …>>`) 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
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 |
Expand All @@ -224,12 +224,12 @@ 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-10 at `638d2b544`.
Measured on 2026-09-11 at `48d6526c7`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 562 |
| engine-shaped types recognised | 58 |
| engine-shaped types recognised | 59 |
| declared objects in the registry | 300 |
| same-named calls subtracted as non-engine | 137 |

Expand Down
15 changes: 7 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -52,12 +52,12 @@ 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-10 at `638d2b544`.
Measured on 2026-09-11 at `48d6526c7`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 562 |
| engine-shaped types recognised | 58 |
| engine-shaped types recognised | 59 |
| declared objects in the registry | 300 |
| same-named calls subtracted as non-engine | 137 |

Expand Down Expand Up @@ -89,7 +89,6 @@ Measured on 2026-09-10 at `638d2b544`.
| `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 |
Expand Down
27 changes: 17 additions & 10 deletions examples/app-showcase/src/security/demo-personas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
Expand Down
Loading
Loading