Skip to content

Commit 3954fb7

Browse files
huangyiireneclaude
andauthored
feat(platform-objects): declare sourced maxLength bounds on the unbounded keyed identity columns (#11374 route A) (#11699)
* feat(platform-objects): declare sourced maxLength bounds on the unbounded keyed identity columns (#11374 route A) Maintainer ruling 2026-08-24 (A + C(hash), B rejected): declare maxLength on the identity columns whose declared indexes MySQL refuses today because the column is unbounded TEXT. Every bound is derived from a named source — better-auth 1.7.1's own schema/migration mapping, the plugin's hard runtime caps, IdP norms (OIDC Core sub <= 255, SAML Core NameID <= 256), the in-repo producer (sha-256 hex, 64), or the referenced/sibling column's landed bound. Measured on live MySQL 8.0.46 (+08:00, STRICT): syncSchema failures 12/44 -> 8/44, declared indexes physically present 89/128 -> 104/128; Postgres 16 control 0/44 both legs. sys_verification.value stays deliberately unbounded (better-auth stores JSON blobs there — no defensible bound exists); sys_account.issuer is bounded at 2048 (the landed sys_sso_provider.issuer contract) which exceeds the 768-char key ceiling, so its composite unique stays for #11627's hash-shadow route. A pin test enumerates every keyed text-family identity column and refuses new unbounded ones by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn * changeset for #11374 route A Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn * fix(platform-objects): keep the new pin test out of the TEST_DEBT ratchet — no type predicate against the typed export union Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c804f0c commit 3954fb7

11 files changed

Lines changed: 255 additions & 0 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
Declare sourced `maxLength` bounds on the thirteen unbounded keyed identity
6+
columns, so their declared indexes can exist on MySQL
7+
8+
`driver-sql` (since #11430) honours a keyed text-family field's declared
9+
`maxLength`, emitting `varchar(maxLength)` instead of `TEXT` — but thirteen
10+
identity columns declared no bound at all, so on MySQL every one of their
11+
declared indexes was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB
12+
column cannot be a key without a prefix length) and the objects landed
13+
registered-but-broken. Measured on live MySQL 8.0.46 (`+08:00`,
14+
`STRICT_TRANS_TABLES`): schema-sync failures drop **12/44 → 8/44** platform
15+
objects and physically-present declared indexes rise **89/128 → 104/128**,
16+
with the Postgres 16 control at 0 failures on both legs. `sys_session`,
17+
`sys_api_key`, `sys_device_code` and `sys_oauth_consent` sync completely
18+
clean — a MySQL stack can now enforce the session-token uniqueness its
19+
sign-in path assumes.
20+
21+
Every bound is derived from a named source, none guessed (maintainer ruling
22+
on #11374, 2026-08-24 — route A; the full table with sources is in the PR):
23+
better-auth 1.7.1's own MySQL schema mapping (`session.token` /
24+
`verification.identifier` → 255), its device-authorization plugin's hard
25+
runtime cap of 191 on both codes, IdP norms (`account_id` 256 = SAML Core
26+
NameID cap, above OIDC Core's 255 `sub` cap), the landed bounds of referenced
27+
or producing siblings (`client_id` × 4 → 255 from
28+
`sys_oauth_application.client_id`; `provider_id` 255 from
29+
`sys_sso_provider.provider_id`; `issuer` 2048 from `sys_sso_provider.issuer`),
30+
and the in-repo producer (`sys_api_key.key` 64 = fixed sha-256 hex).
31+
32+
This is an enforcement change on published objects — hence the minor grade: a
33+
write wider than its column's new bound is now **refused** (measured: a
34+
300-char `sys_session.token` insert fails `ER_DATA_TOO_LONG` on a strict
35+
server, 0 rows; a 255-char one lands). Every bound admits everything its
36+
upstream producer can write, so only values the producing contracts already
37+
forbid are affected.
38+
39+
Deliberately not bounded, per the ruling's escape clause:
40+
`sys_verification.value` (better-auth's oauth-provider stores JSON
41+
authorization-code payloads there — no defensible bound exists), and
42+
`sys_import_job.created_by` (outside this card's identity surface).
43+
`sys_account.issuer`'s 2048 exceeds the 768-char utf8mb4 key ceiling on
44+
purpose — tighter would refuse SSO sign-ins that `sys_sso_provider`'s own
45+
contract admits — so its `(issuer, account_id)` unique stays for #11627's
46+
hash-shadow route, alongside the `maxLength: 1024` token columns. A new pin
47+
test enumerates every keyed text-family identity column and names any future
48+
unbounded arrival.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import * as Identity from './index';
5+
6+
/**
7+
* #11374 — every text-family column a declared index keys on must declare a
8+
* `maxLength`, because a bound is what lets the column be a key at all.
9+
*
10+
* ## Why this pin exists
11+
*
12+
* `driver-sql` emits a KEYED text-family column as `varchar(maxLength)` when
13+
* the field declares a bound the dialect can key on, and leaves it `TEXT`
14+
* otherwise. MySQL refuses a TEXT/BLOB column in a key without a prefix length
15+
* (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded keyed text column means:
16+
* `CREATE TABLE` succeeds, `ALTER TABLE … ADD [UNIQUE] INDEX` fails, and the
17+
* object lands registered-but-broken with its declared uniqueness silently
18+
* absent. Measured on live MySQL 8.0.46: 12 of 44 platform objects failed
19+
* schema-sync this way — sys_session and sys_account among them, so a MySQL
20+
* stack could not sign anyone in.
21+
*
22+
* The driver deliberately does NOT substitute a prefix index: measured on the
23+
* same server, a prefix-UNIQUE index is stricter-and-different — it refused a
24+
* second, genuinely distinct token that shared its first 191 characters
25+
* (`ER_DUP_ENTRY`), i.e. a valid sign-in refused as a duplicate. So the bound
26+
* has to live HERE, in the field declaration (maintainer ruling on #11374,
27+
* 2026-08-24: route A).
28+
*
29+
* ## What a red on this file means
30+
*
31+
* A new keyed text-family field arrived without a `maxLength`. Do not silence
32+
* the assertion — derive a bound from the value's producer (upstream
33+
* better-auth schema/constraints, IdP norms, or the in-repo producer) and
34+
* declare it, or, if the value source genuinely cannot be bounded (the
35+
* `sys_verification.value` case below), extend the allowlist WITH a comment
36+
* naming why and where the keyability debt is tracked.
37+
*
38+
* A bound may legitimately exceed 768 chars (the utf8mb4 index-key ceiling —
39+
* e.g. `sys_account.issuer` at 2048, the oauth token columns at 1024): the
40+
* column then stays TEXT and its index still cannot exist on MySQL. That debt
41+
* is #11627's (hash-shadow keys), and this pin does not police it — it polices
42+
* only "keyed text declares its bound".
43+
*/
44+
45+
const TEXT_FAMILY = new Set(['text', 'textarea', 'html', 'markdown']);
46+
47+
/**
48+
* Keyed text-family columns with NO defensible bound. Every entry must name
49+
* why. Entries that stop matching a real keyed unbounded column fail the
50+
* second test, so the list cannot rot.
51+
*/
52+
const UNBOUNDABLE: ReadonlySet<string> = new Set([
53+
// better-auth's oauth-provider stores OIDC authorization-code payloads in
54+
// `verification.value` as a JSON blob (see the index comment in
55+
// sys-verification.object.ts), and upstream deliberately declares the field
56+
// unindexed and unbounded — no bound exists that provably admits every value
57+
// better-auth may write. Its ObjectStack-declared index therefore still
58+
// cannot exist on MySQL; that keyability debt is tracked with #11627.
59+
'sys_verification.value',
60+
]);
61+
62+
type AnyObject = {
63+
name: string;
64+
fields: Record<string, { type?: string; maxLength?: unknown }>;
65+
indexes?: Array<{ fields?: string[]; unique?: boolean }>;
66+
};
67+
68+
const identityObjects: AnyObject[] = Object.values(Identity)
69+
.map((v) => v as unknown as AnyObject)
70+
.filter(
71+
(v) =>
72+
!!v &&
73+
typeof v === 'object' &&
74+
typeof v.name === 'string' &&
75+
v.name.startsWith('sys_') &&
76+
!!v.fields,
77+
);
78+
79+
function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unknown }> {
80+
const keyed = new Set<string>();
81+
for (const ix of o.indexes ?? []) for (const f of ix.fields ?? []) keyed.add(f);
82+
return Object.entries(o.fields)
83+
.filter(([name, def]) => keyed.has(name) && TEXT_FAMILY.has(def?.type ?? ''))
84+
.map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength }));
85+
}
86+
87+
describe('identity keyed text-family columns declare their bound (#11374)', () => {
88+
it('enumerates a real surface — the probe itself is not vacuous', () => {
89+
// Positive control: if the export shape or field/index spelling changes so
90+
// this file stops seeing columns, fail loudly instead of passing empty.
91+
const all = identityObjects.flatMap(keyedTextColumns);
92+
expect(identityObjects.length).toBeGreaterThanOrEqual(20);
93+
expect(all.length).toBeGreaterThanOrEqual(30);
94+
expect(all.map((c) => c.column)).toContain('sys_session.token');
95+
});
96+
97+
it('every keyed text-family column declares a positive integer maxLength, or is allowlisted by name', () => {
98+
const offenders: string[] = [];
99+
for (const o of identityObjects) {
100+
for (const { column, maxLength } of keyedTextColumns(o)) {
101+
if (UNBOUNDABLE.has(column)) continue;
102+
const bounded =
103+
typeof maxLength === 'number' && Number.isInteger(maxLength) && maxLength > 0;
104+
if (!bounded) offenders.push(`${column} (maxLength: ${String(maxLength)})`);
105+
}
106+
}
107+
expect(
108+
offenders,
109+
`keyed text-family column(s) without a declared maxLength — on MySQL their ` +
110+
`declared index cannot be created and the object lands registered-but-broken. ` +
111+
`Declare a sourced bound or extend UNBOUNDABLE with a named reason: ` +
112+
offenders.join(', '),
113+
).toEqual([]);
114+
});
115+
116+
it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', () => {
117+
const real = new Map(
118+
identityObjects.flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]),
119+
);
120+
for (const entry of UNBOUNDABLE) {
121+
expect(real.has(entry), `allowlist entry ${entry} is not a keyed text column any more — remove it`).toBe(true);
122+
expect(
123+
real.get(entry),
124+
`allowlist entry ${entry} now declares a bound — remove it from UNBOUNDABLE`,
125+
).toBeUndefined();
126+
}
127+
});
128+
});

packages/platform-objects/src/identity/sys-account.object.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ export const SysAccount = ObjectSchema.create({
147147
provider_id: Field.text({
148148
label: 'Provider ID',
149149
required: true,
150+
// [#11374] Transitive bound: SSO-registered providers are the widest
151+
// producer, and sys_sso_provider.provider_id declares maxLength: 255;
152+
// better-auth's built-in social providers are short fixed slugs.
153+
maxLength: 255,
150154
description: 'OAuth provider identifier (google, github, etc.)',
151155
}),
152156

@@ -160,15 +164,33 @@ export const SysAccount = ObjectSchema.create({
160164
// Deliberately NOT `required` even though better-auth always supplies it: a
161165
// NOT NULL column cannot be added to a table that already holds rows, and
162166
// schema sync runs before the backfill.
167+
// [#11374] Bound = 2048, transitively from sys_sso_provider.issuer
168+
// (maxLength: 2048, the landed contract for the widest producer): the SSO
169+
// OIDC path writes the verified token's raw `iss` claim — or the provider's
170+
// registered issuer — verbatim into this column, and SAML entityIDs are
171+
// capped at 1024 by SAML metadata. Anything tighter would refuse a sign-in
172+
// that sys_sso_provider's own contract admits. 2048 exceeds the 768-char
173+
// utf8mb4 key-part ceiling, so this column deliberately stays TEXT and the
174+
// (issuer, account_id) unique index still cannot exist on MySQL — that is
175+
// #11627's hash-shadow-key territory, not a reason to guess a tighter
176+
// number here.
163177
issuer: Field.text({
164178
label: 'Issuer',
165179
required: false,
180+
maxLength: 2048,
166181
description: 'Authority that vouched for the provider account id — an OIDC issuer, or local:… for providers without one',
167182
}),
168183

169184
account_id: Field.text({
170185
label: 'Provider Account ID',
171186
required: true,
187+
// [#11374] Bound from the identity-provider norms for the two federated
188+
// shapes this column stores: an OIDC `sub` MUST NOT exceed 255 ASCII
189+
// chars (OIDC Core §2) and a SAML persistent/transient NameID MUST NOT
190+
// exceed 256 chars (SAML Core 2.0 §8.3.7/§8.3.8) — 256 is the wider of
191+
// the two, and comfortably above the 191 better-auth's own MySQL schema
192+
// enforces on this column.
193+
maxLength: 256,
172194
description: "User's ID in the provider's system",
173195
}),
174196

packages/platform-objects/src/identity/sys-api-key.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,11 @@ export const SysApiKey = ObjectSchema.create({
285285
key: Field.text({
286286
label: 'Hashed Key',
287287
required: true,
288+
// [#11374] Exact producer bound: the only writer is
289+
// `packages/core/src/security/api-key.ts` (`hashApiKey` — "sha256(raw)
290+
// hex — store this in sys_api_key.key"), a fixed 64-hex-char digest.
291+
// better-auth's apiKey plugin is not loaded, so no other producer exists.
292+
maxLength: 64,
288293
hidden: true,
289294
readonly: true,
290295
internal: true,

packages/platform-objects/src/identity/sys-device-code.object.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,22 @@ export const SysDeviceCode = ObjectSchema.create({
7474
device_code: Field.text({
7575
label: 'Device Code',
7676
required: true,
77+
// [#11374] Upstream hard cap: better-auth 1.7.1's device-authorization
78+
// plugin refuses ANY generated code — custom generators included — longer
79+
// than 191 chars at runtime (`validateGeneratedCode`), and its
80+
// `deviceCodeLength` option schema is `max(191)` (default 40). Nothing
81+
// the plugin can ever write exceeds this bound.
82+
maxLength: 191,
7783
description: 'High-entropy token returned to the polling device',
7884
}),
7985

8086
/** Human-readable short code displayed to the user (e.g. ABCD-EFGH). */
8187
user_code: Field.text({
8288
label: 'User Code',
8389
required: true,
90+
// [#11374] Same upstream hard cap as device_code: `validateGeneratedCode`
91+
// refuses > 191 chars and `userCodeLength` is `max(191)` (default 8).
92+
maxLength: 191,
8493
description: 'Short user-facing code (e.g. ABCD-EFGH)',
8594
}),
8695

@@ -101,6 +110,12 @@ export const SysDeviceCode = ObjectSchema.create({
101110
status: Field.text({
102111
label: 'Status',
103112
required: true,
113+
// [#11374] The value domain is the closed literal set the plugin's own
114+
// routes write — 'pending' | 'approved' | 'denied', 8 chars at the
115+
// widest. 64 follows the landed machine-vocabulary precedent
116+
// (sys_session.revoke_reason, maxLength: 64) so a future status word can
117+
// never be refused by the column.
118+
maxLength: 64,
104119
description: "Current status: 'pending' | 'approved' | 'denied'",
105120
}),
106121

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export const SysOauthAccessToken = ObjectSchema.create({
5555
client_id: Field.text({
5656
label: 'Client ID',
5757
required: true,
58+
// [#11374] Bound from the referenced column: this is a foreign key to
59+
// sys_oauth_application.client_id, which declares maxLength: 255 (and
60+
// upstream @better-auth/oauth-provider's oauthClient.clientId is a
61+
// unique string — varchar(255) on MySQL). A referencing column takes the
62+
// referenced column's bound.
63+
maxLength: 255,
5864
description: 'Foreign key to sys_oauth_application.client_id',
5965
}),
6066

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export const SysOauthClientResource = ObjectSchema.create({
4040
client_id: Field.text({
4141
label: 'Client ID',
4242
required: true,
43+
// [#11374] Bound from the referenced column: this is a foreign key to
44+
// sys_oauth_application.client_id, which declares maxLength: 255 (and
45+
// upstream @better-auth/oauth-provider's oauthClient.clientId is a
46+
// unique string — varchar(255) on MySQL). A referencing column takes the
47+
// referenced column's bound.
48+
maxLength: 255,
4349
description: 'Foreign key to sys_oauth_application.client_id',
4450
}),
4551

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export const SysOauthConsent = ObjectSchema.create({
4444
client_id: Field.text({
4545
label: 'Client ID',
4646
required: true,
47+
// [#11374] Bound from the referenced column: this is a foreign key to
48+
// sys_oauth_application.client_id, which declares maxLength: 255 (and
49+
// upstream @better-auth/oauth-provider's oauthClient.clientId is a
50+
// unique string — varchar(255) on MySQL). A referencing column takes the
51+
// referenced column's bound.
52+
maxLength: 255,
4753
description: 'Foreign key to sys_oauth_application.client_id',
4854
}),
4955

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export const SysOauthRefreshToken = ObjectSchema.create({
5454
client_id: Field.text({
5555
label: 'Client ID',
5656
required: true,
57+
// [#11374] Bound from the referenced column: this is a foreign key to
58+
// sys_oauth_application.client_id, which declares maxLength: 255 (and
59+
// upstream @better-auth/oauth-provider's oauthClient.clientId is a
60+
// unique string — varchar(255) on MySQL). A referencing column takes the
61+
// referenced column's bound.
62+
maxLength: 255,
5763
description: 'Foreign key to sys_oauth_application.client_id',
5864
}),
5965

packages/platform-objects/src/identity/sys-session.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,11 @@ export const SysSession = ObjectSchema.create({
250250
token: Field.text({
251251
label: 'Session Token',
252252
required: true,
253+
// [#11374] Bound from better-auth 1.7.1's own MySQL schema: a unique
254+
// string column is emitted as varchar(255) (get-migration.mjs), and the
255+
// producer writes generateId(32) — 32 chars. 255 admits everything the
256+
// upstream schema admits, and lets the unique index exist on MySQL.
257+
maxLength: 255,
253258
hidden: true,
254259
readonly: true,
255260
internal: true,

0 commit comments

Comments
 (0)