Skip to content

Commit d79c602

Browse files
os-warrenclaude
andauthored
fix(platform-objects): source sys_oauth_resource.identifier's bound from its producer — 1024 → 255, and the referring column with it (#12595)
* fix(platform-objects): source sys_oauth_resource.identifier's bound from its producer — 1024 -> 255, and the referring column with it sys_oauth_resource.identifier declared maxLength: 1024 citing no producer. better-auth 1.7.1 — the sole writer (managedBy: 'better-auth', protection.lock: 'full') — emits it as varchar(255) on MySQL, measured by running its own migration generator against live MySQL 8.0.46 and reading information_schema.COLUMNS as its own query. Both halves of the foreign key now declare 255, closing the silent register-then-never-authorize dead end: on PG/SQLite a 900-char identifier could be registered by the referent and then never granted, because the referrer (narrowed to 768 by #11701) refused it. Measured consequence: 255 x 4 = 1020 bytes is under the 3072-byte key-part ceiling, so sys_oauth_resource LEAVES the hash-shadow route — the varbinary(32) shadow column is gone and the UNIQUE index is carried directly on identifier with SUB_PART NULL. Also corrects the #11701 citation: upstream emits the referring column as varchar(191), not varchar(36) — the field.references arm never runs because resourceId participates in table-level indexes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * docs(changeset): answer the ADR-0087 ledger question for the OAuth bound narrowing no-migration-prescription: the change 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 schema sync applies the physical column change itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2db1293 commit d79c602

5 files changed

Lines changed: 255 additions & 30 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
---
4+
5+
fix(platform-objects): source `sys_oauth_resource.identifier`'s bound from its producer — 1024 → 255, and the referring column with it (#12313)
6+
7+
**BREAKING** accept-set narrowing on two published objects, shipped as `minor`
8+
under the repo's launch-window convention for breaking changes.
9+
10+
<!-- 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). -->
11+
12+
`sys_oauth_resource.identifier` declared `maxLength: 1024`. That number cited no
13+
producer — it arrived with the object wholesale (#3080) as generous slack for
14+
"a URI". Every other bound in the #11374 family names where it came from; this
15+
one had no comment at all.
16+
17+
Measured, the producing contract cannot fill it. better-auth 1.7.1 is the sole
18+
writer (`managedBy: 'better-auth'`, `protection.lock: 'full'`) and emits this
19+
column as **`varchar(255)`** on MySQL: `oauthResource.identifier` is declared
20+
`{ type: 'string', required: true, unique: true }`, and `getType` in
21+
`better-auth/dist/db/get-migration.mjs` takes the `field.unique → 'varchar(255)'`
22+
arm of its mysql string branch. Verified by running that generator against live
23+
MySQL 8.0.46 and reading `information_schema.COLUMNS` as its own query:
24+
`varchar(255)`, 1020 octets under utf8mb4.
25+
26+
**The dead end this closes.** #11701 had already narrowed the REFERRING column
27+
`sys_oauth_client_resource.resource_id` to 768 so its declared index could exist
28+
on MySQL at all. The two halves of one foreign key then disagreed about what a
29+
legitimate resource identifier is. On PostgreSQL or SQLite — neither of which
30+
has MySQL's key-width ceiling — an operator could register a resource whose
31+
`identifier` was 900 characters, because the referent's contract admitted it,
32+
and then no client could ever be granted that resource, because the referrer
33+
refused it. Registration succeeded, authorization failed forever, silently.
34+
Both columns now declare **255**, so referent and referrer admit exactly the
35+
same domain.
36+
37+
**What the narrowing rejects.** Values in **(255, 768]** move from "the referrer
38+
accepts" to "both refuse"; values in (768, 1024] were already refused by the
39+
referrer and are now refused by the referent too. Nothing upstream can produce
40+
either band — the sole writer stores the identifier in `varchar(255)`.
41+
42+
**Hash-shadow outcome, measured rather than predicted.** 255 × 4 = 1020 bytes
43+
sits under `SqlDriver.MAX_KEYABLE_VARCHAR_CHARS` (768 characters / 3072 bytes),
44+
so `sys_oauth_resource` **LEAVES** the #11627/#12198 hash-shadow route it was on
45+
at 1024. Both readings are from `information_schema` on live MySQL 8.0.46:
46+
47+
| | before (1024) | after (255) |
48+
|---|---|---|
49+
| `identifier` physical | `text` (65535 octets) | `varchar(255)` (1020 octets) |
50+
| shadow column | `uniq_sys_oauth_resource_identifier__hash varbinary(32)` present | **absent** |
51+
| UNIQUE index keys on | the shadow column | `identifier` directly, `SUB_PART NULL` |
52+
53+
The declared uniqueness is unchanged and still enforced over the full value —
54+
the index is a direct full-value UNIQUE, not a prefix index. Deployments that
55+
already synced this table on MySQL will see the shadow column dropped and the
56+
UNIQUE index rebuilt directly on the narrowed column at the next schema sync.
57+
58+
**A correction to the #11701 citation.** That comment stated upstream emits the
59+
referring column as `varchar(36)` via `getType`'s `field.references` arm. It
60+
does not: `resourceId` participates in table-level indexes, so `getType`
61+
receives a `tableIndexStringLength` argument, which takes precedence over every
62+
`field.*` arm, and `getDatabaseIndexStringLength` seeds its reduce at MySQL's
63+
191-character default — measured, upstream emits **`varchar(191)`**. That 191 is
64+
an artifact of upstream's index budget on upstream's own physical schema;
65+
ObjectStack emits its own schema, so the referring column takes the REFERENT's
66+
255, the same derivation `client_id` already uses.

packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -52,35 +52,45 @@ export const SysOauthClientResource = ObjectSchema.create({
5252
resource_id: Field.text({
5353
label: 'Resource ID',
5454
required: true,
55-
// [#11701] Narrowed 1024 → 768 so the declared `[resource_id]` index can
56-
// exist at all. At 1024 the column stays TEXT on MySQL and the index is
57-
// refused (`ER_BLOB_KEY_WITHOUT_LENGTH`), taking the whole object's
58-
// schema-sync down with it; 768 characters is the widest utf8mb4 value a
59-
// MySQL key part can hold (768 × 4 = 3072 bytes, exactly the ceiling).
55+
// [#12313] Narrowed 768 -> 255, taking the referent's sourced bound.
6056
//
61-
// ⚠️ This is the one bound in the family that does NOT simply take its
62-
// referenced column's width: `sys_oauth_resource.identifier` declares
63-
// 1024. Narrowing below the referent is safe here because the
64-
// (768, 1024] band holds nothing the PRODUCING contract can emit. The
65-
// value is an RFC 8707 resource-indicator URI, and upstream better-auth
66-
// 1.7.1 — the sole writer of this table (`managedBy: 'better-auth'`) —
67-
// stores that same identifier in `oauthResource.identifier` as
68-
// **varchar(255)** on MySQL (`better-auth/dist/db/get-migration.mjs`,
69-
// `getType`: a unique string column → varchar(255)) and this referring
70-
// column as varchar(36) (its `field.references` branch). A resource
71-
// whose identifier exceeded 768 characters could never have been
72-
// registered upstream in the first place.
57+
// #11701 chose 768 as the SMALLEST narrowing that made the declared
58+
// `[resource_id]` index expressible on MySQL at all (768 x 4 = 3072
59+
// bytes, exactly the utf8mb4 key-part ceiling; at 1024 the column stayed
60+
// TEXT and the index was refused with `ER_BLOB_KEY_WITHOUT_LENGTH`,
61+
// taking the object's whole schema-sync down). It deliberately did NOT
62+
// source that number -- sourcing the referent was a separate ruling.
63+
// That ruling landed: `sys_oauth_resource.identifier` is now 255, taken
64+
// from better-auth 1.7.1's own varchar(255) emission, and a referencing
65+
// column takes the referenced column's bound -- the same derivation
66+
// `client_id` above uses. 255 is still <= 768, so the index stays
67+
// expressible and this column keeps the live access path #11701 kept it
68+
// for (`findOne({ clientId, resourceId })`).
7369
//
74-
// 768 rather than upstream's 255 on purpose: it is the SMALLEST
75-
// narrowing that makes the index expressible, so it rejects the least of
76-
// the referent's declared domain. Guessing a tighter number to make a
77-
// key fit is what `sys_account.issuer` refuses to do.
70+
// What the narrowing REJECTS: values in (255, 768] moved from "the
71+
// referrer accepts, the referent accepts" to "both refuse". Nothing
72+
// legitimate lived there -- the sole writer cannot emit an identifier
73+
// that long (see the referent's citation).
7874
//
79-
// ⛔ Unlike `sys_verification.value`, this index is NOT removable: this
80-
// is the FK side of `sys_oauth_resource.identifier` and upstream reads it
81-
// as a predicate (`findOne({ clientId, resourceId })` on the client
82-
// registration collision path), so it is a live access path.
83-
maxLength: 768,
75+
// ⚠️ Upstream's OWN referring column is narrower still, and is
76+
// deliberately NOT copied. Measured by running better-auth 1.7.1's
77+
// migration generator against live MySQL 8.0.46 and reading
78+
// `information_schema.COLUMNS` as its own query,
79+
// `oauthClientResource.resourceId` lands as **varchar(191)** -- NOT the
80+
// varchar(36) that `getType`'s `field.references` arm would suggest.
81+
// That arm never runs for this column: `resourceId` participates in
82+
// table-level indexes, so `getType` receives a `tableIndexStringLength`
83+
// argument, which takes precedence over every `field.*` arm, and
84+
// `getDatabaseIndexStringLength` seeds its reduce at MySQL's
85+
// 191-character default and can only shrink from there.
86+
//
87+
// 191 is therefore an artifact of upstream's index budget on upstream's
88+
// own physical schema. ObjectStack emits its own schema and owns its own
89+
// key budget, so this column inherits the REFERENT's 255 rather than
90+
// upstream's key-budget rounding -- which is also what keeps the pair
91+
// symmetric: referent and referrer now accept exactly the same domain,
92+
// and the silent register-then-never-authorize dead-end is closed.
93+
maxLength: 255,
8494
description: 'Foreign key to sys_oauth_resource.identifier',
8595
}),
8696

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #12313 — the OAuth resource-identifier pair declares a SOURCED bound, and
5+
* the referrer declares the SAME one as the referent.
6+
*
7+
* ## The defect this pins
8+
*
9+
* `sys_oauth_resource.identifier` declared `maxLength: 1024`. That number cited
10+
* no producer: it arrived with the object wholesale (#3080) as generous slack
11+
* for "a URI". Meanwhile #11701 narrowed the REFERRING column
12+
* `sys_oauth_client_resource.resource_id` to 768 so its declared index could
13+
* exist on MySQL at all. The two halves of one foreign key then disagreed about
14+
* what a legitimate resource identifier is, and only one of them was sourced.
15+
*
16+
* The user-visible shape of that disagreement is a SILENT dead end. On
17+
* PostgreSQL or SQLite — neither of which has MySQL's key-width ceiling — an
18+
* operator could register a resource whose `identifier` was 900 characters,
19+
* because the referent's declared contract admitted it, and then no client
20+
* could ever be granted that resource, because the referring column refused it.
21+
* Registration succeeds; authorization fails forever; nothing says why.
22+
*
23+
* ## What the maintainer ruled (2026-08-26, verbatim 「同意」 on option B)
24+
*
25+
* Both columns narrow to **255**, each carrying a producer citation. 255 is not
26+
* an alignment convenience — it is what the sole writer can physically store.
27+
* better-auth 1.7.1 (`managedBy: 'better-auth'`, `protection.lock: 'full'`)
28+
* emits `oauthResource.identifier` as `varchar(255)` on MySQL. Measured, not
29+
* read: running better-auth's own migration generator against live MySQL 8.0.46
30+
* and reading `information_schema.COLUMNS` as its own query returns
31+
* `varchar(255)`, 1020 octets under utf8mb4.
32+
*
33+
* Options A (768, unsourced alignment) and C (keep 1024 and merely document it)
34+
* were weighed and rejected — A because 768 derives from nothing either, C
35+
* because it preserves the dead end.
36+
*
37+
* ## Why the third test is the one that matters
38+
*
39+
* Pinning two integers pins two integers. The INVARIANT is that a referring
40+
* column and its referent admit the same domain, because any gap between them
41+
* is a register-then-never-authorize dead end by construction. That assertion
42+
* is what a future re-narrowing of either column has to stay honest against —
43+
* change one side alone and this file goes red naming the band that just became
44+
* unreachable.
45+
*/
46+
47+
import { describe, it, expect } from 'vitest';
48+
import { SysOauthResource } from './sys-oauth-resource.object';
49+
import { SysOauthClientResource } from './sys-oauth-client-resource.object';
50+
51+
/**
52+
* MySQL's utf8mb4 key-part ceiling in CHARACTERS (768 × 4 = 3072 bytes), the
53+
* same constant `SqlDriver.MAX_KEYABLE_VARCHAR_CHARS` enforces. At or under it
54+
* a bounded text column is emitted `varchar(n)` and keyed DIRECTLY; above it
55+
* the column stays TEXT and a UNIQUE index has to be carried on #11627's
56+
* hash-shadow column instead.
57+
*/
58+
const MAX_KEYABLE_CHARS = 768;
59+
60+
/** What better-auth 1.7.1 physically emits for `oauthResource.identifier`. */
61+
const UPSTREAM_IDENTIFIER_CHARS = 255;
62+
63+
const identifier = () => SysOauthResource.fields.identifier as { maxLength?: unknown };
64+
const resourceId = () => SysOauthClientResource.fields.resource_id as { maxLength?: unknown };
65+
66+
describe('#12313 — sys_oauth_resource.identifier and its referrer carry a sourced bound', () => {
67+
it('reads the real declarations, not an empty probe', () => {
68+
// Vacuity control: a renamed field or a changed export would otherwise let
69+
// every assertion below pass over `undefined`.
70+
expect(SysOauthResource.name).toBe('sys_oauth_resource');
71+
expect(SysOauthClientResource.name).toBe('sys_oauth_client_resource');
72+
expect(identifier()).toBeTypeOf('object');
73+
expect(resourceId()).toBeTypeOf('object');
74+
expect(SysOauthResource.indexes).toContainEqual({ fields: ['identifier'], unique: true });
75+
});
76+
77+
it('the referent declares the width its sole producer can store', () => {
78+
expect(
79+
identifier().maxLength,
80+
'sys_oauth_resource.identifier must declare the bound better-auth 1.7.1 actually emits ' +
81+
'(varchar(255) on MySQL, from get-migration.mjs getType’s `field.unique` arm). A wider ' +
82+
'bound promises a width the only writer of this table cannot store; a narrower one ' +
83+
'rejects identifiers upstream can legitimately register. Change it only with a new ' +
84+
'producer measurement in the field’s own citation.',
85+
).toBe(UPSTREAM_IDENTIFIER_CHARS);
86+
});
87+
88+
it('the referrer admits exactly the referent’s domain — no dead-end band', () => {
89+
// THE invariant. A referring column narrower than its referent means values
90+
// the referent accepts can be registered and then never linked; a wider one
91+
// means the FK admits values that can never have a referent row.
92+
expect(
93+
resourceId().maxLength,
94+
'sys_oauth_client_resource.resource_id is the FK side of ' +
95+
'sys_oauth_resource.identifier, so it must admit exactly the same domain. Any gap ' +
96+
'between the two is a silent register-then-never-authorize dead end: the resource ' +
97+
'registers because the referent accepts the value, and no client can ever be granted ' +
98+
'it because the referrer refuses it (#12313). Narrow BOTH or neither.',
99+
).toBe(identifier().maxLength);
100+
});
101+
102+
it('both bounds stay directly keyable, so neither column needs a hash shadow', () => {
103+
for (const [column, def] of [
104+
['sys_oauth_resource.identifier', identifier()],
105+
['sys_oauth_client_resource.resource_id', resourceId()],
106+
] as const) {
107+
const n = def.maxLength;
108+
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);
109+
}
110+
});
111+
});

packages/platform-objects/src/identity/sys-oauth-resource.object.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,33 @@ export const SysOauthResource = ObjectSchema.create({
4343
identifier: Field.text({
4444
label: 'Identifier',
4545
required: true,
46-
maxLength: 1024,
46+
// [#12313] Narrowed 1024 -> 255, and sourced from the PRODUCER. The old
47+
// 1024 cited nothing: it arrived with the object wholesale (#3080) as
48+
// generous slack for "a URI", derived from no upstream contract. An
49+
// uncited bound is the defect here, so a narrowing that landed without a
50+
// citation would only reproduce it at a smaller number.
51+
//
52+
// better-auth 1.7.1 is the sole writer (`managedBy: 'better-auth'`,
53+
// `protection.lock: 'full'`), and it emits this column as
54+
// **varchar(255)** on MySQL: `oauthResource.identifier` is declared
55+
// `{ type: 'string', required: true, unique: true }`, and `getType` in
56+
// `better-auth/dist/db/get-migration.mjs` takes the `field.unique ->
57+
// 'varchar(255)'` arm of its mysql string branch. `oauthResource`
58+
// declares no table-level `indexes`, so the `tableIndexStringLength`
59+
// argument that precedes that arm is undefined here.
60+
//
61+
// Measured, not just read: running that generator against live MySQL
62+
// 8.0.46 (utf8mb4/InnoDB) and reading `information_schema.COLUMNS` as its
63+
// own query gives `oauthResource.identifier = varchar(255)`, 1020 octets.
64+
// So an identifier longer than 255 characters cannot be registered
65+
// upstream at all, and the discarded (255, 1024] band held nothing the
66+
// producing contract can emit.
67+
//
68+
// Physical consequence, stated rather than left to be discovered: 255 is
69+
// at or under `SqlDriver.MAX_KEYABLE_VARCHAR_CHARS` (768), so the UNIQUE
70+
// index below is now carried DIRECTLY on `varchar(255)` and this object
71+
// LEAVES the #11627/#12198 hash-shadow route it was on at 1024.
72+
maxLength: 255,
4773
description: 'Resource indicator URI presented in the RFC 8707 resource parameter',
4874
}),
4975

packages/platform-objects/src/platform-keyed-text-bounds.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ import * as PlatformObjects from './index';
4949
* UNIQUE index is the only kind #11627's hash shadow can carry.
5050
*
5151
* A bound may legitimately exceed 768 chars (the utf8mb4 index-key ceiling —
52-
* e.g. `sys_account.issuer` at 2048, the oauth token columns at 1024): the
52+
* e.g. `sys_account.issuer` at 2048, the oauth TOKEN columns at 1024 —
53+
* `sys_oauth_resource.identifier` is no longer among them, see #12313): the
5354
* column then stays TEXT and its index still cannot exist on MySQL directly.
5455
* That debt was #11627's, and #11627 discharged it for the UNIQUE half — such
5556
* an index is now carried on a hash-shadow column. The first `describe` below
@@ -78,6 +79,15 @@ import * as PlatformObjects from './index';
7879
* 1024 → 768 instead. See the field's own comment for the evidence that
7980
* nothing legitimate lives in the discarded band.
8081
*
82+
* ⚠️ UPDATED by #12313: that bound is now **255**, not 768. #11701 picked
83+
* 768 as the smallest narrowing that made the index expressible and left
84+
* the number unsourced on purpose; #12313 sourced the REFERENT
85+
* (`sys_oauth_resource.identifier`, 1024 → 255, from better-auth 1.7.1's
86+
* own varchar(255) emission) and this column follows it, as a referencing
87+
* column takes the referenced column's bound. 255 ≤ 768, so the #11701
88+
* rule below is still satisfied — it is the same disposition at a sourced
89+
* number, not a different one.
90+
*
8191
* The pin below is the executable form of "the class is closed": it does not
8292
* name those two, it enumerates the whole package, so a THIRD member arriving
8393
* 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)', () =>
284294
// comes back, it comes back with a live reader and a keyable bound, or it
285295
// fails here and in the rule below.
286296
expect(columns).not.toContain('sys_verification.value');
287-
// Kept: a live access path (FK side of sys_oauth_resource.identifier),
288-
// narrowed 1024 → 768 so the index can exist at all.
297+
// Kept: a live access path (FK side of sys_oauth_resource.identifier).
298+
// #11701 narrowed it 1024 → 768 so the index could exist at all; #12313
299+
// narrowed it again 768 → 255 to follow the now-sourced referent. Still
300+
// keyable, so it stays in this set and the rule below still holds it.
289301
expect(columns).toContain('sys_oauth_client_resource.resource_id');
290302
});
291303

0 commit comments

Comments
 (0)