Skip to content

Commit e1024ab

Browse files
committed
feat(cli,create-objectstack): retire the @better-auth/scim>better-call suppression with the pin; adapter transaction degrades declared (#3653)
Epic item 7, same stroke as the pin move: stable 1.7.1 peers better-call@1.4.0 exactly, so the rc-era allowedVersions key retires in its two declared places (SCAFFOLD_ALLOWED_PEER_VERSIONS and the blank template) and both presence ratchets flip to absence pins. The better-auth>better-sqlite3 and four @better-auth/utils entries stay — re-derived from the tree, their conditions are separate and unmet; the separation pin now guards that they survived the retirement. Adapter transactions: better-auth routes its own multi-writes (sign-up included) through adapter.transaction — measured: a fail-closed require:true 500'd every sign-up on the memory engine — so the implementation keeps the two declared degrades those flows always had (no-transaction-API engines run the callback directly; drivers without beginTransaction follow the engine's ADR-0119 D1 warn-once degrade). SQL drivers get real atomic transactions. Also: changeset (minor across the six touched published packages, level argued in the body), and two stale-prose corrections where files described the retired rc.1 storeSCIMToken wiring as current. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4533e02 commit e1024ab

8 files changed

Lines changed: 142 additions & 55 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/spec": minor
5+
"@objectstack/plugin-security": minor
6+
"@objectstack/cli": minor
7+
"create-objectstack": minor
8+
---
9+
10+
feat(auth): migrate `@better-auth/scim` from `1.7.0-rc.1` to stable `1.7.1` — the whole-model SCIM migration (#3653, epic #11632)
11+
12+
The stable line is the rc.2-lineage rewrite: the rc.1 `scimProvider` model,
13+
`/scim/generate-token` endpoint and `storeSCIMToken` option no longer exist,
14+
replaced by seven new models and a three-way connection contract. This lands
15+
the migration atomically:
16+
17+
- **Seven new platform objects** back the stable models —
18+
`sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`,
19+
`sys_scim_identity_tombstone`, `sys_scim_projection_grant`,
20+
`sys_scim_subject`, `sys_scim_user` — bridged via `AUTH_MODEL_TO_PROTOCOL`,
21+
registered in the platform-object-names registry, listed in
22+
`BETTER_AUTH_MANAGED_OBJECTS`, and column-pinned by the parity gate (whose
23+
`KNOWN_UNMAPPED_MODELS` shrinks to the empty set: the rc.1-era group
24+
provisioning gap — IdP `/Groups` pushes hitting tables that did not exist —
25+
is closed).
26+
- **SCIM connections stay runtime data.** The stable constructor is satisfied
27+
with an application-owned `authentication.verifyBearerToken` that resolves
28+
the connection from a row at request time — not static boot config, and not
29+
the upstream `managedConnections` catalog (deliberately not adopted).
30+
- **ObjectStack owns SCIM credentials outright** (stable upstream stores no
31+
credential at all): `sys_scim_connection_credential` plus
32+
`scim-connection-service.ts` mint/digest/verify. At rest only an
33+
HMAC-SHA-256 keyed by the deployment auth secret (base64url,
34+
domain-separated) is stored — at parity or better than the rc.1 unsalted
35+
SHA-256 — pinned by `credential-at-rest-posture.test.ts` including live
36+
401 paths for forged, revoked and expired bearers.
37+
- **The ObjectQL better-auth adapter gains native transactions**
38+
(`engine.transaction`, fail-closed on drivers without `beginTransaction`),
39+
which stable scim requires by assertion for atomic provisioning writes.
40+
- **Scaffold suppression retired**: the `@better-auth/scim>better-call`
41+
`allowedVersions` entry (CLI renderer + blank template) is gone — stable
42+
1.7.1 peers `better-call@1.4.0` exactly — and its presence ratchets flipped
43+
to absence pins. The `better-auth>better-sqlite3` and four
44+
`@better-auth/utils` entries stay; their retirement conditions are separate
45+
and unmet.
46+
- The pin resolves **1.7.1 exactly** (not `^1.7.1`): 1.7.2 peers
47+
`better-auth`/`@better-auth/core` at `^1.7.2`, which only the workspace
48+
overrides' silencing would "satisfy" while the family is 1.7.1. Floating is
49+
its own follow-up.
50+
51+
**Semver: minor, argued.** The rc.1 SCIM surface this replaces (generate-token
52+
endpoint, rc.1 bearer tokens, `sys_scim_provider` rows) changes incompatibly —
53+
but that surface is default-off (`OS_SCIM_ENABLED`), was shipped with a
54+
documented "do not let the IdP push groups" boundary, and the maintainer ruled
55+
(2026-08-25) that SCIM has no real customers and old data need not carry: the
56+
one binding constraint is that an existing system upgrades smoothly, which it
57+
does — every table the installed library can write exists at this version, and
58+
SCIM-disabled deployments see no behavior change. A major would move the whole
59+
fixed version group for a feature surface with zero consumers. Deployments
60+
that had SCIM enabled must mint new connection credentials (digests are not
61+
portable from rc.1 on any path — IdP token reissue is a migration-day
62+
operator action regardless of semver level). `sys_scim_provider` itself is
63+
NOT removed here; its retirement is tracked separately (#11757).

packages/cli/src/commands/init.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,11 @@ export const SCAFFOLD_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild'];
109109
* and on 12.11.1. So the upstream range is stale and 13 is right — widening
110110
* is the correct remedy, not pinning our own declaration back to 12.
111111
*
112-
* - `@better-auth/scim>better-call` — scim is held at `1.7.0-rc.1`
113-
* deliberately (stable 1.7.x ships a whole-model rewrite that is its own
114-
* migration), and the rc peers an exact `better-call@1.3.7` while
115-
* better-auth itself depends on 1.4.0. A better-auth plugin must share the
116-
* HOST's better-call instance, so the single 1.4.0 copy every install
117-
* already resolves is the correct tree, not a skew to repair.
118-
* ⚠️ This entry retires together with the SCIM rc pin — delete both at once.
119-
* Stable `@better-auth/scim@1.7.1` peers `better-call@1.4.0`, so the skew
120-
* this line covers is genuinely gone the moment the pin moves.
112+
* - RETIRED (#3653): `@better-auth/scim>better-call` — the rc.1-era scim pin
113+
* peered an exact `better-call@1.3.7` against the host's 1.4.0, and this
114+
* map declared that skew away. Stable `@better-auth/scim@1.7.1` (the pin
115+
* since #3653 landed) peers `better-call@1.4.0`, so the skew is genuinely
116+
* gone and the entry retired WITH the pin move, as its ratchet demanded.
121117
*
122118
* - `<four>@better-auth/utils` — `@better-auth/core`, `/oauth-provider`,
123119
* `/scim` and `/sso` each peer an EXACT `@better-auth/utils@0.4.2`, while a
@@ -167,7 +163,9 @@ export const SCAFFOLD_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild'];
167163
*/
168164
export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
169165
'better-auth>better-sqlite3': '13',
170-
'@better-auth/scim>better-call': '1.4.0',
166+
// '@better-auth/scim>better-call' retired with the scim rc pin (#3653) —
167+
// stable 1.7.1 peers better-call@1.4.0 exactly, the copy every install
168+
// already resolves. init.test.ts pins its ABSENCE now.
171169
'@better-auth/core>@better-auth/utils': '0.5.0',
172170
'@better-auth/oauth-provider>@better-auth/utils': '0.5.0',
173171
'@better-auth/scim>@better-auth/utils': '0.5.0',

packages/cli/test/init.test.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -333,13 +333,15 @@ describe('benign peer-skew declarations (#10326)', () => {
333333
expect(settings).toMatch(/^ {4}'better-auth>better-sqlite3': '13'$/m);
334334
});
335335

336-
it('accepts the single better-call copy @better-auth/scim resolves to', () => {
337-
// scim is held at 1.7.0-rc.1 on purpose; the rc peers an EXACT
338-
// `better-call@1.3.7` while better-auth depends on 1.4.0. A better-auth
339-
// plugin must share the HOST's better-call instance, so one 1.4.0 copy is
340-
// the correct tree. Retires with the scim rc pin.
341-
expect(SCAFFOLD_ALLOWED_PEER_VERSIONS['@better-auth/scim>better-call']).toBe('1.4.0');
342-
expect(settings).toMatch(/^ {4}'@better-auth\/scim>better-call': '1\.4\.0'$/m);
336+
it('keeps the retired @better-auth/scim>better-call suppression OUT (#3653)', () => {
337+
// FLIPPED from a presence ratchet when the scim pin moved off the rc.
338+
// Stable @better-auth/scim 1.7.1 peers better-call@1.4.0 exactly — the
339+
// copy every install already resolves — so the skew the entry declared
340+
// away no longer exists, and a suppression with no skew behind it would
341+
// hide the NEXT real better-call peer break from a newcomer's first
342+
// screen. Re-adding it needs a new measured skew, not a revert.
343+
expect(SCAFFOLD_ALLOWED_PEER_VERSIONS['@better-auth/scim>better-call']).toBeUndefined();
344+
expect(settings).not.toMatch(/'@better-auth\/scim>better-call'/);
343345
});
344346

345347
it.each([
@@ -384,14 +386,16 @@ describe('benign peer-skew declarations (#10326)', () => {
384386
]);
385387
});
386388

387-
it('keeps the @better-auth/utils widening separate from the retiring better-call pin', () => {
388-
// @better-auth/scim appears in TWO entries for two unrelated reasons, and
389-
// they retire on different days: the better-call one goes when scim leaves
390-
// the rc (stable 1.7.1 peers better-call 1.4.0), while the utils one
391-
// outlives it (stable 1.7.1 still peers @better-auth/utils 0.4.2). Deleting
392-
// both together — the obvious move when the rc pin lifts — would silently
393-
// put the utils report back on a newcomer's first screen.
394-
expect(SCAFFOLD_ALLOWED_PEER_VERSIONS['@better-auth/scim>better-call']).toBe('1.4.0');
389+
it('kept the @better-auth/utils widening when the better-call pin retired (#3653)', () => {
390+
// @better-auth/scim appeared in TWO entries for two unrelated reasons, and
391+
// they retire on different days — which HAPPENED: the better-call one went
392+
// with the rc pin (stable 1.7.1 peers better-call 1.4.0), while the utils
393+
// one outlives it (stable 1.7.1 still peers @better-auth/utils 0.4.2;
394+
// its own retirement key is the pnpm 10.31 floor). Deleting both together
395+
// — the obvious move when the rc pin lifted — would have silently put the
396+
// utils report back on a newcomer's first screen; this pin is what stops
397+
// that regression from ever landing quietly.
398+
expect(SCAFFOLD_ALLOWED_PEER_VERSIONS['@better-auth/scim>better-call']).toBeUndefined();
395399
expect(SCAFFOLD_ALLOWED_PEER_VERSIONS['@better-auth/scim>@better-auth/utils']).toBe('0.5.0');
396400
});
397401

packages/create-objectstack/src/template-consistency.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -502,14 +502,17 @@ describe('blank template peer-skew declarations (#10326)', () => {
502502
).toBe(true);
503503
});
504504

505-
it('declares the frozen @better-auth/scim > better-call peer', () => {
506-
// scim is held at 1.7.0-rc.1 deliberately; the rc peers an EXACT 1.3.7
507-
// while better-auth depends on 1.4.0. A better-auth plugin must share the
508-
// host's better-call instance, so the single 1.4.0 copy is correct.
505+
it('keeps the retired @better-auth/scim > better-call suppression OUT (#3653)', () => {
506+
// FLIPPED from a presence ratchet when the scim pin moved off the rc:
507+
// stable @better-auth/scim 1.7.1 peers better-call@1.4.0 exactly, so the
508+
// rc-era skew the entry declared away is gone. A suppression with no skew
509+
// behind it would hide the NEXT real better-call peer break from an
510+
// `npx create-objectstack` user's first screen — re-adding it needs a new
511+
// measured skew, not a revert.
509512
expect(
510-
/^\s*'@better-auth\/scim>better-call':\s*'1\.4\.0'\s*$/m.test(allowed),
511-
'allowedVersions must accept the single better-call 1.4.0 copy scim resolves to',
512-
).toBe(true);
513+
/'@better-auth\/scim>better-call'/.test(allowed),
514+
'the retired @better-auth/scim>better-call suppression must stay out of allowedVersions',
515+
).toBe(false);
513516
});
514517

515518
it.each([

packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,9 @@ allowBuilds:
4444
# sign-in and adapter find/update/delete — 1.7.1 behaves identically on
4545
# better-sqlite3 13.0.3 and on 12.11.1. The upstream range is stale.
4646
#
47-
# @better-auth/scim (held at a release candidate deliberately, not by
48-
# neglect) peers better-call at an exact 1.3.7, while better-auth itself
49-
# depends on 1.4.0. A better-auth plugin has to share the host's better-call
50-
# instance, so the single 1.4.0 copy is the correct resolution rather than a
51-
# skew to repair. This entry retires when SCIM moves off the rc: stable
52-
# @better-auth/scim 1.7.1 already peers better-call 1.4.0.
47+
# (The '@better-auth/scim>better-call' entry that used to sit here retired
48+
# with the scim release-candidate pin — stable @better-auth/scim 1.7.1 peers
49+
# better-call 1.4.0 exactly, so the skew it declared away is gone.)
5350
#
5451
# @better-auth/core, @better-auth/oauth-provider, @better-auth/scim and
5552
# @better-auth/sso each peer an exact @better-auth/utils 0.4.2, while the
@@ -75,7 +72,6 @@ allowBuilds:
7572
peerDependencyRules:
7673
allowedVersions:
7774
'better-auth>better-sqlite3': '13'
78-
'@better-auth/scim>better-call': '1.4.0'
7975
'@better-auth/core>@better-auth/utils': '0.5.0'
8076
'@better-auth/oauth-provider>@better-auth/utils': '0.5.0'
8177
'@better-auth/scim>@better-auth/utils': '0.5.0'

packages/platform-objects/src/identity/sys-scim-provider.object.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1313
* is OPEN (here, framework `plugin-auth`); enablement is entitlement-gated by
1414
* the cloud / EE license.
1515
*
16-
* `scim_token` holds the connection's bearer credential. With the plugin's
17-
* `storeSCIMToken: 'hashed'` (the default this env wires) it stores only a
18-
* HASH — the plaintext is returned exactly once at `/scim/generate-token`. Even
19-
* so, treat this object as sensitive: it is read-only over the generic data API
20-
* and the token is excluded from list views.
16+
* ⚠️ RC.1-ERA OBJECT — retirement tracked by #11757. The stable
17+
* `@better-auth/scim` line (#3653) derives no `scimProvider` model, exposes no
18+
* `/scim/generate-token`, and has no `storeSCIMToken` option, so nothing
19+
* writes rows here any more; SCIM connection credentials live in
20+
* `sys_scim_connection_credential` (ObjectStack-owned). The paragraphs below
21+
* describe the rc.1 behaviour this object was built for.
22+
*
23+
* `scim_token` holds the connection's bearer credential. With the rc.1
24+
* plugin's `storeSCIMToken: 'hashed'` (which this env wired) it stored only a
25+
* HASH — the plaintext was returned exactly once at `/scim/generate-token`.
26+
* Even so, treat this object as sensitive: it is read-only over the generic
27+
* data API and the token is excluded from list views.
2128
*
2229
* All mutations route through @better-auth/scim's endpoints under
2330
* `/api/v1/auth/scim/*` (generate-token / delete-provider-connection) and the

packages/plugins/plugin-auth/src/objectql-adapter.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -777,12 +777,24 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
777777
// engine call the raw methods below make inside the callback automatically
778778
// binds to the same connection/rollback scope — the trx adapter handed to
779779
// the callback is therefore the SAME wrapped adapter, captured at factory
780-
// time below. `require: true` fails CLOSED on a driver without
781-
// `beginTransaction` (#5696): a sequential fallback here would be exactly
782-
// the degraded posture upstream's assertion exists to refuse.
780+
// time below.
781+
//
782+
// ⚠️ Scope this honestly: better-auth routes its OWN multi-writes through
783+
// `adapter.transaction` too — sign-up (user + account) included, measured —
784+
// so this is the transaction path for EVERY better-auth flow, not a
785+
// scim-only seam, and it must keep the same degrade contract those flows
786+
// had under the factory's sequential fallback. Two declared degrades:
787+
// - an engine with no `transaction` API at all (test doubles, minimal
788+
// IDataEngine implementations) runs the callback directly — exactly the
789+
// factory's own `createAsIsTransaction` behaviour;
790+
// - a driver without `beginTransaction` follows the engine's OWN declared
791+
// contract (ADR-0119 D1): run directly, warn once (#4619). Every SQL
792+
// production driver has `beginTransaction`, so a real deployment's scim
793+
// provisioning is genuinely atomic; fail-closed here (`require: true`)
794+
// was measured to 500 every sign-up on the memory engine.
783795
let wrappedAdapter: unknown = null;
784796
const engineWithTx = rawDataEngine as unknown as {
785-
transaction<T>(
797+
transaction?<T>(
786798
cb: (trxCtx: unknown, info: unknown) => Promise<T>,
787799
baseContext?: unknown,
788800
opts?: { require?: boolean },
@@ -807,11 +819,12 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
807819
// rather than hand the callback a null adapter.
808820
throw new Error('[objectql-adapter] transaction requested before the adapter was constructed');
809821
}
810-
return engineWithTx.transaction(
811-
async () => cb(wrappedAdapter as never),
812-
undefined,
813-
{ require: true },
814-
);
822+
if (typeof engineWithTx.transaction !== 'function') {
823+
// Declared degrade #1 (see the #3653 note above): no transaction API
824+
// on this engine — run directly, as the factory fallback would.
825+
return cb(wrappedAdapter as never);
826+
}
827+
return engineWithTx.transaction(async () => cb(wrappedAdapter as never));
815828
},
816829
},
817830
adapter: () => withValidationErrorMapping({

packages/plugins/plugin-auth/src/sso-client-secret.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222
*
2323
* ## The seam
2424
* `@better-auth/sso` has NO secret-at-rest option, so there is no upstream
25-
* switch to flip: `SSOOptions` has no equivalent of
26-
* `scim({ storeSCIMToken: 'hashed' })`. Measured 2026-08-20 against the
25+
* switch to flip: `SSOOptions` has no equivalent of the rc.1-era
26+
* `scim({ storeSCIMToken: 'hashed' })` (an option stable scim has since
27+
* dropped entirely — ObjectStack owns SCIM credentials outright now, #3653,
28+
* which only sharpens the point that at-rest posture is ours to establish).
29+
* Measured 2026-08-20 against the
2730
* installed `@better-auth/sso@1.7.1` by enumerating the top-level members of
2831
* `SSOOptions` in the shipped `dist/index-CZytzKv6.d.mts` — `resolveUser`,
2932
* `guardProviderMutation`, `provisionUser`, `provisionUserOnEveryLogin`,

0 commit comments

Comments
 (0)