Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 66 additions & 0 deletions .changeset/oauth-resource-identifier-sourced-255.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) Narrows two field bounds on objects whose `protection.lock` is `full`; no metadata key is removed or renamed, so no authored metadata can name the discarded band and there is nothing for an upgrader to rewrite. The physical column change is applied by schema sync itself, and the discarded (255, 1024] band is unreachable — measured, the sole writer stores this identifier in varchar(255). -->

`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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),

Expand Down
18 changes: 15 additions & 3 deletions packages/platform-objects/src/platform-keyed-text-bounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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');
});

Expand Down
Loading