diff --git a/.changeset/oauth-resource-identifier-sourced-255.md b/.changeset/oauth-resource-identifier-sourced-255.md new file mode 100644 index 0000000000..a64c47db7a --- /dev/null +++ b/.changeset/oauth-resource-identifier-sourced-255.md @@ -0,0 +1,66 @@ +--- +"@objectstack/platform-objects": minor +--- + +fix(platform-objects): source `sys_oauth_resource.identifier`'s bound from its producer — 1024 → 255, and the referring column with it (#12313) + +**BREAKING** accept-set narrowing on two published objects, shipped as `minor` +under the repo's launch-window convention for breaking changes. + + + +`sys_oauth_resource.identifier` declared `maxLength: 1024`. That number cited no +producer — it arrived with the object wholesale (#3080) as generous slack for +"a URI". Every other bound in the #11374 family names where it came from; this +one had no comment at all. + +Measured, the producing contract cannot fill it. better-auth 1.7.1 is the sole +writer (`managedBy: 'better-auth'`, `protection.lock: 'full'`) and emits this +column as **`varchar(255)`** on MySQL: `oauthResource.identifier` is declared +`{ type: 'string', required: true, unique: true }`, and `getType` in +`better-auth/dist/db/get-migration.mjs` takes the `field.unique → 'varchar(255)'` +arm of its mysql string branch. Verified by running that generator against live +MySQL 8.0.46 and reading `information_schema.COLUMNS` as its own query: +`varchar(255)`, 1020 octets under utf8mb4. + +**The dead end this closes.** #11701 had already narrowed the REFERRING column +`sys_oauth_client_resource.resource_id` to 768 so its declared index could exist +on MySQL at all. The two halves of one foreign key then disagreed about what a +legitimate resource identifier is. On PostgreSQL or SQLite — neither of which +has MySQL's key-width ceiling — an operator could register a resource whose +`identifier` was 900 characters, because the referent's contract admitted it, +and then no client could ever be granted that resource, because the referrer +refused it. Registration succeeded, authorization failed forever, silently. +Both columns now declare **255**, so referent and referrer admit exactly the +same domain. + +**What the narrowing rejects.** Values in **(255, 768]** move from "the referrer +accepts" to "both refuse"; values in (768, 1024] were already refused by the +referrer and are now refused by the referent too. Nothing upstream can produce +either band — the sole writer stores the identifier in `varchar(255)`. + +**Hash-shadow outcome, measured rather than predicted.** 255 × 4 = 1020 bytes +sits under `SqlDriver.MAX_KEYABLE_VARCHAR_CHARS` (768 characters / 3072 bytes), +so `sys_oauth_resource` **LEAVES** the #11627/#12198 hash-shadow route it was on +at 1024. Both readings are from `information_schema` on live MySQL 8.0.46: + +| | before (1024) | after (255) | +|---|---|---| +| `identifier` physical | `text` (65535 octets) | `varchar(255)` (1020 octets) | +| shadow column | `uniq_sys_oauth_resource_identifier__hash varbinary(32)` present | **absent** | +| UNIQUE index keys on | the shadow column | `identifier` directly, `SUB_PART NULL` | + +The declared uniqueness is unchanged and still enforced over the full value — +the index is a direct full-value UNIQUE, not a prefix index. Deployments that +already synced this table on MySQL will see the shadow column dropped and the +UNIQUE index rebuilt directly on the narrowed column at the next schema sync. + +**A correction to the #11701 citation.** That comment stated upstream emits the +referring column as `varchar(36)` via `getType`'s `field.references` arm. It +does not: `resourceId` participates in table-level indexes, so `getType` +receives a `tableIndexStringLength` argument, which takes precedence over every +`field.*` arm, and `getDatabaseIndexStringLength` seeds its reduce at MySQL's +191-character default — measured, upstream emits **`varchar(191)`**. That 191 is +an artifact of upstream's index budget on upstream's own physical schema; +ObjectStack emits its own schema, so the referring column takes the REFERENT's +255, the same derivation `client_id` already uses. diff --git a/packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts b/packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts index efe3b9ffa2..10fb63bbb5 100644 --- a/packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts @@ -52,35 +52,45 @@ export const SysOauthClientResource = ObjectSchema.create({ resource_id: Field.text({ label: 'Resource ID', required: true, - // [#11701] Narrowed 1024 → 768 so the declared `[resource_id]` index can - // exist at all. At 1024 the column stays TEXT on MySQL and the index is - // refused (`ER_BLOB_KEY_WITHOUT_LENGTH`), taking the whole object's - // schema-sync down with it; 768 characters is the widest utf8mb4 value a - // MySQL key part can hold (768 × 4 = 3072 bytes, exactly the ceiling). + // [#12313] Narrowed 768 -> 255, taking the referent's sourced bound. // - // ⚠️ This is the one bound in the family that does NOT simply take its - // referenced column's width: `sys_oauth_resource.identifier` declares - // 1024. Narrowing below the referent is safe here because the - // (768, 1024] band holds nothing the PRODUCING contract can emit. The - // value is an RFC 8707 resource-indicator URI, and upstream better-auth - // 1.7.1 — the sole writer of this table (`managedBy: 'better-auth'`) — - // stores that same identifier in `oauthResource.identifier` as - // **varchar(255)** on MySQL (`better-auth/dist/db/get-migration.mjs`, - // `getType`: a unique string column → varchar(255)) and this referring - // column as varchar(36) (its `field.references` branch). A resource - // whose identifier exceeded 768 characters could never have been - // registered upstream in the first place. + // #11701 chose 768 as the SMALLEST narrowing that made the declared + // `[resource_id]` index expressible on MySQL at all (768 x 4 = 3072 + // bytes, exactly the utf8mb4 key-part ceiling; at 1024 the column stayed + // TEXT and the index was refused with `ER_BLOB_KEY_WITHOUT_LENGTH`, + // taking the object's whole schema-sync down). It deliberately did NOT + // source that number -- sourcing the referent was a separate ruling. + // That ruling landed: `sys_oauth_resource.identifier` is now 255, taken + // from better-auth 1.7.1's own varchar(255) emission, and a referencing + // column takes the referenced column's bound -- the same derivation + // `client_id` above uses. 255 is still <= 768, so the index stays + // expressible and this column keeps the live access path #11701 kept it + // for (`findOne({ clientId, resourceId })`). // - // 768 rather than upstream's 255 on purpose: it is the SMALLEST - // narrowing that makes the index expressible, so it rejects the least of - // the referent's declared domain. Guessing a tighter number to make a - // key fit is what `sys_account.issuer` refuses to do. + // What the narrowing REJECTS: values in (255, 768] moved from "the + // referrer accepts, the referent accepts" to "both refuse". Nothing + // legitimate lived there -- the sole writer cannot emit an identifier + // that long (see the referent's citation). // - // ⛔ Unlike `sys_verification.value`, this index is NOT removable: this - // is the FK side of `sys_oauth_resource.identifier` and upstream reads it - // as a predicate (`findOne({ clientId, resourceId })` on the client - // registration collision path), so it is a live access path. - maxLength: 768, + // ⚠️ Upstream's OWN referring column is narrower still, and is + // deliberately NOT copied. Measured by running better-auth 1.7.1's + // migration generator against live MySQL 8.0.46 and reading + // `information_schema.COLUMNS` as its own query, + // `oauthClientResource.resourceId` lands as **varchar(191)** -- NOT the + // varchar(36) that `getType`'s `field.references` arm would suggest. + // That arm never runs for this column: `resourceId` participates in + // table-level indexes, so `getType` receives a `tableIndexStringLength` + // argument, which takes precedence over every `field.*` arm, and + // `getDatabaseIndexStringLength` seeds its reduce at MySQL's + // 191-character default and can only shrink from there. + // + // 191 is therefore an artifact of upstream's index budget on upstream's + // own physical schema. ObjectStack emits its own schema and owns its own + // key budget, so this column inherits the REFERENT's 255 rather than + // upstream's key-budget rounding -- which is also what keeps the pair + // symmetric: referent and referrer now accept exactly the same domain, + // and the silent register-then-never-authorize dead-end is closed. + maxLength: 255, description: 'Foreign key to sys_oauth_resource.identifier', }), diff --git a/packages/platform-objects/src/identity/sys-oauth-resource-sourced-bounds.test.ts b/packages/platform-objects/src/identity/sys-oauth-resource-sourced-bounds.test.ts new file mode 100644 index 0000000000..1e71fd547b --- /dev/null +++ b/packages/platform-objects/src/identity/sys-oauth-resource-sourced-bounds.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #12313 — the OAuth resource-identifier pair declares a SOURCED bound, and + * the referrer declares the SAME one as the referent. + * + * ## The defect this pins + * + * `sys_oauth_resource.identifier` declared `maxLength: 1024`. That number cited + * no producer: it arrived with the object wholesale (#3080) as generous slack + * for "a URI". Meanwhile #11701 narrowed the REFERRING column + * `sys_oauth_client_resource.resource_id` to 768 so its declared index could + * exist on MySQL at all. The two halves of one foreign key then disagreed about + * what a legitimate resource identifier is, and only one of them was sourced. + * + * The user-visible shape of that disagreement is a SILENT dead end. On + * PostgreSQL or SQLite — neither of which has MySQL's key-width ceiling — an + * operator could register a resource whose `identifier` was 900 characters, + * because the referent's declared contract admitted it, and then no client + * could ever be granted that resource, because the referring column refused it. + * Registration succeeds; authorization fails forever; nothing says why. + * + * ## What the maintainer ruled (2026-08-26, verbatim 「同意」 on option B) + * + * Both columns narrow to **255**, each carrying a producer citation. 255 is not + * an alignment convenience — it is what the sole writer can physically store. + * better-auth 1.7.1 (`managedBy: 'better-auth'`, `protection.lock: 'full'`) + * emits `oauthResource.identifier` as `varchar(255)` on MySQL. Measured, not + * read: running better-auth's own migration generator against live MySQL 8.0.46 + * and reading `information_schema.COLUMNS` as its own query returns + * `varchar(255)`, 1020 octets under utf8mb4. + * + * Options A (768, unsourced alignment) and C (keep 1024 and merely document it) + * were weighed and rejected — A because 768 derives from nothing either, C + * because it preserves the dead end. + * + * ## Why the third test is the one that matters + * + * Pinning two integers pins two integers. The INVARIANT is that a referring + * column and its referent admit the same domain, because any gap between them + * is a register-then-never-authorize dead end by construction. That assertion + * is what a future re-narrowing of either column has to stay honest against — + * change one side alone and this file goes red naming the band that just became + * unreachable. + */ + +import { describe, it, expect } from 'vitest'; +import { SysOauthResource } from './sys-oauth-resource.object'; +import { SysOauthClientResource } from './sys-oauth-client-resource.object'; + +/** + * MySQL's utf8mb4 key-part ceiling in CHARACTERS (768 × 4 = 3072 bytes), the + * same constant `SqlDriver.MAX_KEYABLE_VARCHAR_CHARS` enforces. At or under it + * a bounded text column is emitted `varchar(n)` and keyed DIRECTLY; above it + * the column stays TEXT and a UNIQUE index has to be carried on #11627's + * hash-shadow column instead. + */ +const MAX_KEYABLE_CHARS = 768; + +/** What better-auth 1.7.1 physically emits for `oauthResource.identifier`. */ +const UPSTREAM_IDENTIFIER_CHARS = 255; + +const identifier = () => SysOauthResource.fields.identifier as { maxLength?: unknown }; +const resourceId = () => SysOauthClientResource.fields.resource_id as { maxLength?: unknown }; + +describe('#12313 — sys_oauth_resource.identifier and its referrer carry a sourced bound', () => { + it('reads the real declarations, not an empty probe', () => { + // Vacuity control: a renamed field or a changed export would otherwise let + // every assertion below pass over `undefined`. + expect(SysOauthResource.name).toBe('sys_oauth_resource'); + expect(SysOauthClientResource.name).toBe('sys_oauth_client_resource'); + expect(identifier()).toBeTypeOf('object'); + expect(resourceId()).toBeTypeOf('object'); + expect(SysOauthResource.indexes).toContainEqual({ fields: ['identifier'], unique: true }); + }); + + it('the referent declares the width its sole producer can store', () => { + expect( + identifier().maxLength, + 'sys_oauth_resource.identifier must declare the bound better-auth 1.7.1 actually emits ' + + '(varchar(255) on MySQL, from get-migration.mjs getType’s `field.unique` arm). A wider ' + + 'bound promises a width the only writer of this table cannot store; a narrower one ' + + 'rejects identifiers upstream can legitimately register. Change it only with a new ' + + 'producer measurement in the field’s own citation.', + ).toBe(UPSTREAM_IDENTIFIER_CHARS); + }); + + it('the referrer admits exactly the referent’s domain — no dead-end band', () => { + // THE invariant. A referring column narrower than its referent means values + // the referent accepts can be registered and then never linked; a wider one + // means the FK admits values that can never have a referent row. + expect( + resourceId().maxLength, + 'sys_oauth_client_resource.resource_id is the FK side of ' + + 'sys_oauth_resource.identifier, so it must admit exactly the same domain. Any gap ' + + 'between the two is a silent register-then-never-authorize dead end: the resource ' + + 'registers because the referent accepts the value, and no client can ever be granted ' + + 'it because the referrer refuses it (#12313). Narrow BOTH or neither.', + ).toBe(identifier().maxLength); + }); + + it('both bounds stay directly keyable, so neither column needs a hash shadow', () => { + for (const [column, def] of [ + ['sys_oauth_resource.identifier', identifier()], + ['sys_oauth_client_resource.resource_id', resourceId()], + ] as const) { + const n = def.maxLength; + expect(typeof n === 'number' && Number.isInteger(n) && n > 0 && n <= MAX_KEYABLE_CHARS, `${column} (maxLength: ${String(n)}) must be keyable at or under ${MAX_KEYABLE_CHARS} utf8mb4 characters`).toBe(true); + } + }); +}); diff --git a/packages/platform-objects/src/identity/sys-oauth-resource.object.ts b/packages/platform-objects/src/identity/sys-oauth-resource.object.ts index 1f3188faae..3f7735227c 100644 --- a/packages/platform-objects/src/identity/sys-oauth-resource.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-resource.object.ts @@ -43,7 +43,33 @@ export const SysOauthResource = ObjectSchema.create({ identifier: Field.text({ label: 'Identifier', required: true, - maxLength: 1024, + // [#12313] Narrowed 1024 -> 255, and sourced from the PRODUCER. The old + // 1024 cited nothing: it arrived with the object wholesale (#3080) as + // generous slack for "a URI", derived from no upstream contract. An + // uncited bound is the defect here, so a narrowing that landed without a + // citation would only reproduce it at a smaller number. + // + // better-auth 1.7.1 is the sole writer (`managedBy: 'better-auth'`, + // `protection.lock: 'full'`), and it emits this column as + // **varchar(255)** on MySQL: `oauthResource.identifier` is declared + // `{ type: 'string', required: true, unique: true }`, and `getType` in + // `better-auth/dist/db/get-migration.mjs` takes the `field.unique -> + // 'varchar(255)'` arm of its mysql string branch. `oauthResource` + // declares no table-level `indexes`, so the `tableIndexStringLength` + // argument that precedes that arm is undefined here. + // + // Measured, not just read: running that generator against live MySQL + // 8.0.46 (utf8mb4/InnoDB) and reading `information_schema.COLUMNS` as its + // own query gives `oauthResource.identifier = varchar(255)`, 1020 octets. + // So an identifier longer than 255 characters cannot be registered + // upstream at all, and the discarded (255, 1024] band held nothing the + // producing contract can emit. + // + // Physical consequence, stated rather than left to be discovered: 255 is + // at or under `SqlDriver.MAX_KEYABLE_VARCHAR_CHARS` (768), so the UNIQUE + // index below is now carried DIRECTLY on `varchar(255)` and this object + // LEAVES the #11627/#12198 hash-shadow route it was on at 1024. + maxLength: 255, description: 'Resource indicator URI presented in the RFC 8707 resource parameter', }), 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 6317aea798..112e89d83e 100644 --- a/packages/platform-objects/src/platform-keyed-text-bounds.test.ts +++ b/packages/platform-objects/src/platform-keyed-text-bounds.test.ts @@ -49,7 +49,8 @@ import * as PlatformObjects from './index'; * UNIQUE index is the only kind #11627's hash shadow can carry. * * A bound may legitimately exceed 768 chars (the utf8mb4 index-key ceiling — - * e.g. `sys_account.issuer` at 2048, the oauth token columns at 1024): the + * e.g. `sys_account.issuer` at 2048, the oauth TOKEN columns at 1024 — + * `sys_oauth_resource.identifier` is no longer among them, see #12313): the * column then stays TEXT and its index still cannot exist on MySQL directly. * That debt was #11627's, and #11627 discharged it for the UNIQUE half — such * an index is now carried on a hash-shadow column. The first `describe` below @@ -78,6 +79,15 @@ import * as PlatformObjects from './index'; * 1024 → 768 instead. See the field's own comment for the evidence that * nothing legitimate lives in the discarded band. * + * ⚠️ UPDATED by #12313: that bound is now **255**, not 768. #11701 picked + * 768 as the smallest narrowing that made the index expressible and left + * the number unsourced on purpose; #12313 sourced the REFERENT + * (`sys_oauth_resource.identifier`, 1024 → 255, from better-auth 1.7.1's + * own varchar(255) emission) and this column follows it, as a referencing + * column takes the referenced column's bound. 255 ≤ 768, so the #11701 + * rule below is still satisfied — it is the same disposition at a sourced + * number, not a different one. + * * The pin below is the executable form of "the class is closed": it does not * name those two, it enumerates the whole package, so a THIRD member arriving * later fails here rather than being found on a live MySQL months on. @@ -284,8 +294,10 @@ describe('platform non-unique text indexes are keyable on MySQL (#11701)', () => // comes back, it comes back with a live reader and a keyable bound, or it // fails here and in the rule below. expect(columns).not.toContain('sys_verification.value'); - // Kept: a live access path (FK side of sys_oauth_resource.identifier), - // narrowed 1024 → 768 so the index can exist at all. + // Kept: a live access path (FK side of sys_oauth_resource.identifier). + // #11701 narrowed it 1024 → 768 so the index could exist at all; #12313 + // narrowed it again 768 → 255 to follow the now-sourced referent. Still + // keyable, so it stays in this set and the rule below still holds it. expect(columns).toContain('sys_oauth_client_resource.resource_id'); });