Skip to content

Commit a8ceb57

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14299-platform-eval-stub-removal
2 parents 49732ca + 2aa8456 commit a8ceb57

20 files changed

Lines changed: 1709 additions & 56 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `DataEvent` names the organization the record belongs to, so a tenant-scoped consumer can tell whose event it is
6+
7+
The realtime `DataEvent` payload (`@objectstack/spec/api`, the body of every
8+
`data.record.created` / `data.record.updated` / `data.record.deleted` event)
9+
gains an optional `organizationId`: the organization the record belongs to.
10+
Until now the event carried the object name, the record id and the row body,
11+
and nothing that named the tenant — so a consumer that fans events out per
12+
organization (a webhook subscription, a per-organization realtime subscriber)
13+
had no term to discriminate on short of reading the row body, which is absent
14+
on delete events and is not the consumer's to read.
15+
16+
What a consumer may assume:
17+
18+
- **Present** — exactly that organization, never a guess: the organization the
19+
record belongs to, not the caller's active organization standing in for it.
20+
- **Absent** — the record belongs to no organization. That is every event on a
21+
`single`-posture deployment (no organization wall, nothing stamps the
22+
column) and an organization-less, environment-wide row under a walled
23+
posture. Read it as "not behind any organization wall", never as "unknown,
24+
look it up".
25+
26+
Declared = enforced: the key is optional and nothing else. No default
27+
fabricates a tenant; `null` and the empty string are refused with a located
28+
issue, so "no organization" has exactly one spelling — the key is absent.
29+
30+
Additive and shape-preserving: every event that parsed before parses
31+
identically, and no producer emits the key yet — the ObjectQL engine's publish
32+
site is a separate change that follows this contract. The bulk
33+
`BulkDataEvent` (`data.records.*`) is deliberately untouched: a predicate
34+
write's affected set is its own contract with its own tenant question.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.

content/docs/references/api/events.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const result = BulkDataEventSchema.parse(data);
5757
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` || Event type |
5858
| **object** | `string` || Object name |
5959
| **recordId** | `string` || Record ID |
60+
| **organizationId** | `string` | optional | Organization the record belongs to (its organization_id), so a tenant-scoped consumer can discriminate the event's tenant without reading the record body. Absent when the record belongs to no organization: every event on a single-posture deployment (no organization wall, nothing stamps the column), and a row that carries no organization under a walled posture (environment-wide, or an object outside the wall) — read absence as "not behind any organization wall", never as "unknown". Present = exactly that organization; never fabricated, and the empty string is refused. |
6061
| **changes** | `Record<string, any>` | optional | Changed fields |
6162
| **before** | `Record<string, any>` | optional | Before state |
6263
| **after** | `Record<string, any>` | optional | After state |

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
923923
*/
924924
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';
925925

926+
/**
927+
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
928+
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
929+
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
930+
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
931+
* `scimRequestScope`; see `handleRequest`.
932+
*/
933+
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';
934+
935+
function isScimProtocolPath(endpointPath: string | undefined): boolean {
936+
return (
937+
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
938+
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
939+
);
940+
}
941+
926942
/**
927943
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
928944
*
@@ -3266,17 +3282,22 @@ export class AuthManager {
32663282
if (enabled.scim) {
32673283
await this.addOptionalPlugin(plugins, 'scim', async () => {
32683284
const { scim } = await import('@better-auth/scim');
3269-
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
3285+
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
32703286
const secret = this.resolveAuthSecret();
32713287
return scim({
32723288
connections: [],
32733289
authentication: {
32743290
verifyBearerToken: async (input) => {
3275-
// Mark the remainder of this request's async chain as a SCIM
3276-
// protocol request, so the adapter runs its provisioning writes
3277-
// inside a REAL engine transaction (see scimRequestScope's
3278-
// rationale in scim-connection-service.ts).
3279-
scimRequestScope.enterWith({ scim: true });
3291+
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
3292+
// scope that makes the adapter open a REAL engine transaction is
3293+
// opened by `handleRequest` with `run(...)` around the whole
3294+
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
3295+
// stamped from this callback and never reached the writes: an
3296+
// `enterWith` marks only the async resource it runs in and that
3297+
// resource's descendants, and the vendor resumes the endpoint
3298+
// handler from a continuation captured BEFORE this verifier ran
3299+
// (measured on 1.7.2 — zero engine transactions across a SCIM
3300+
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
32803301
const engine = this.config.dataEngine;
32813302
if (!engine) return null; // no store to verify against — fail closed
32823303
return verifyScimBearerToken(engine as never, secret, input.token);
@@ -4740,10 +4761,32 @@ export class AuthManager {
47404761
// is left with an identity that still occupies the org roster and can no
47414762
// longer sign in. Nothing tells the operator, and there is no way back.
47424763
const endpointPath = this.betterAuthEndpointPath(request);
4764+
4765+
// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
4766+
// `scimRequestScope`, which is what makes the adapter's `transaction`
4767+
// config open a REAL engine transaction around the vendor's provisioning
4768+
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
4769+
// HERE, with `run(...)` around the whole request, for the same reason the
4770+
// actor-attribution scope above is: `run` has a callback boundary that
4771+
// every `als.run` the vendor performs underneath nests inside. The stamp
4772+
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
4773+
// callback, and it never reached the writes — the vendor resumes the
4774+
// endpoint handler from a continuation captured before the verifier ran
4775+
// (measured on 1.7.2: zero `engine.transaction` calls across
4776+
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
4777+
// so it is exactly as narrow as before — SCIM protocol requests only; the
4778+
// non-SCIM flows keep their sequential posture, which the scoping note
4779+
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
4780+
const runRequest = isScimProtocolPath(endpointPath)
4781+
? async (): Promise<Response> => {
4782+
const { scimRequestScope } = await import('./scim-connection-service.js');
4783+
return scimRequestScope.run({ scim: true }, runHandler);
4784+
}
4785+
: runHandler;
47434786
const vendorResponse =
47444787
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
4745-
? await this.runSubjectErasureAtomically(runHandler)
4746-
: await runHandler();
4788+
? await this.runSubjectErasureAtomically(runRequest)
4789+
: await runRequest();
47474790

47484791
// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
47494792
// caller through the vendor's `adminMiddleware`
@@ -4925,15 +4968,15 @@ export class AuthManager {
49254968
* so it never half-lands: the account stays enabled and nothing is
49264969
* skipped silently.
49274970
*
4928-
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
4971+
* What ALSO rolls back: the vendor runs this callback inside
49294972
* `runWithTransaction`, which on this adapter is a real engine transaction
4930-
* only while `scimRequestScope` is set — and that scope, stamped inside
4931-
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
4932-
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
4973+
* while `scimRequestScope` is set — and `handleRequest` opens that scope
4974+
* around every SCIM protocol request (#14522; it was once stamped inside
4975+
* `verifyBearerToken` with `enterWith` and never reached the writes). So
49334976
* the vendor's own `scimUser.active = false` write, made before this
4934-
* callback, survives a refusal and the SCIM resource reads inactive while
4935-
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
4936-
* residual so its fix flips the pin deliberately.
4977+
* callback, is rolled back with the refusal, and the SCIM resource keeps
4978+
* reading `active: true` for the account that stayed enabled — pinned by
4979+
* the #14360 suite's face (c).
49374980
*
49384981
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
49394982
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
@@ -4946,8 +4989,8 @@ export class AuthManager {
49464989
*
49474990
* Every read and write goes through `context.database` — the adapter the
49484991
* vendor bound to its transaction — never through an `internalAdapter`
4949-
* resolved outside it, so the moment #14522 makes that transaction real,
4950-
* the ban commits or rolls back with the SCIM mutation it belongs to.
4992+
* resolved outside it, so the ban commits or rolls back with the SCIM
4993+
* mutation it belongs to.
49514994
*/
49524995
private async reconcileScimUserLifecycle(
49534996
state: SCIMIdentityState,

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
791791
// Core better-auth flows never had native DB transactions here (the factory
792792
// default is the sequential as-is fallback), so they KEEP that historical
793793
// posture; the real transaction opens exactly where upstream's assertion
794-
// demands it — inside an authenticated SCIM protocol request, marked by the
795-
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
794+
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
795+
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
796+
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
797+
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
798+
// marks only the async resource it runs in and that resource's descendants,
799+
// and the vendor resumes the endpoint handler from a continuation captured
800+
// before the verifier ran — measured on 1.7.2 as zero engine transactions
801+
// across POST + PATCH /Users while the mount-time assertion stayed green.
802+
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
803+
// a SCIM mutation observed to call `engine.transaction`). Remaining
796804
// declared degrades on that path: an engine with no `transaction` API runs
797805
// the callback directly, and a driver without `beginTransaction` follows
798806
// the engine's ADR-0119 D1 warn-once degrade.

packages/plugins/plugin-auth/src/scim-connection-service.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';
4242

4343
/**
4444
* Request-scoped marker: "the current async chain is a SCIM protocol
45-
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
46-
* first application code every authenticated SCIM request runs) via
47-
* `enterWith`, so it holds for the remainder of that request's async chain —
48-
* including the provisioning writes the plugin performs afterwards.
45+
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
46+
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
47+
* for that request's whole async chain — the endpoint handler and the
48+
* provisioning writes the plugin performs inside it.
49+
*
50+
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
51+
* that is where it used to be stamped, and the store never reached the
52+
* writes. An `enterWith` marks only the async resource it runs in and that
53+
* resource's descendants; the vendor awaits the verifier from the endpoint's
54+
* own frame and resumes the handler from a continuation captured before the
55+
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
56+
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
57+
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
58+
* callback boundary; every `als.run` the vendor performs underneath nests
59+
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
4960
*
5061
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
5162
* REAL engine transaction (the atomicity upstream's

packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
475475
expect(row?.ban_reason ?? null).toBeNull();
476476
await expectSignInAccepted(h, owner.email);
477477

478-
// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
479-
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
480-
// callback inside what it believes is a transaction, survives the
481-
// refusal, because the adapter's #3653 SCIM transaction scoping never
482-
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
483-
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
484-
// the SCIM resource reports `active: false` while the account is still
485-
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
486-
// that is the whole reason it is asserted rather than left unread.
487-
expect(await scimActive(h, owner.scimId)).toBe(false);
478+
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
479+
// the callback inside its transaction, is rolled back WITH the refusal:
480+
// the adapter's #3653 SCIM transaction scoping opens a real engine
481+
// transaction now that the scope is opened at `handleRequest` (it was
482+
// stamped with `enterWith` inside `verifyBearerToken` and never reached
483+
// the writes — measured as 0 `engine.transaction` calls across POST +
484+
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
485+
// the account that stayed enabled. This line read `false` on purpose
486+
// while that residual was open and was flipped DELIBERATELY with the fix;
487+
// the positive control below is the genuine `false`.
488+
expect(await scimActive(h, owner.scimId)).toBe(true);
488489
}, 60_000);
489490

490491
it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {

0 commit comments

Comments
 (0)