diff --git a/.changeset/identity-keyed-text-bounds.md b/.changeset/identity-keyed-text-bounds.md new file mode 100644 index 0000000000..3b2e40240e --- /dev/null +++ b/.changeset/identity-keyed-text-bounds.md @@ -0,0 +1,48 @@ +--- +'@objectstack/platform-objects': minor +--- + +Declare sourced `maxLength` bounds on the thirteen unbounded keyed identity +columns, so their declared indexes can exist on MySQL + +`driver-sql` (since #11430) honours a keyed text-family field's declared +`maxLength`, emitting `varchar(maxLength)` instead of `TEXT` — but thirteen +identity columns declared no bound at all, so on MySQL every one of their +declared indexes was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB +column cannot be a key without a prefix length) and the objects landed +registered-but-broken. Measured on live MySQL 8.0.46 (`+08:00`, +`STRICT_TRANS_TABLES`): schema-sync failures drop **12/44 → 8/44** platform +objects and physically-present declared indexes rise **89/128 → 104/128**, +with the Postgres 16 control at 0 failures on both legs. `sys_session`, +`sys_api_key`, `sys_device_code` and `sys_oauth_consent` sync completely +clean — a MySQL stack can now enforce the session-token uniqueness its +sign-in path assumes. + +Every bound is derived from a named source, none guessed (maintainer ruling +on #11374, 2026-08-24 — route A; the full table with sources is in the PR): +better-auth 1.7.1's own MySQL schema mapping (`session.token` / +`verification.identifier` → 255), its device-authorization plugin's hard +runtime cap of 191 on both codes, IdP norms (`account_id` 256 = SAML Core +NameID cap, above OIDC Core's 255 `sub` cap), the landed bounds of referenced +or producing siblings (`client_id` × 4 → 255 from +`sys_oauth_application.client_id`; `provider_id` 255 from +`sys_sso_provider.provider_id`; `issuer` 2048 from `sys_sso_provider.issuer`), +and the in-repo producer (`sys_api_key.key` 64 = fixed sha-256 hex). + +This is an enforcement change on published objects — hence the minor grade: a +write wider than its column's new bound is now **refused** (measured: a +300-char `sys_session.token` insert fails `ER_DATA_TOO_LONG` on a strict +server, 0 rows; a 255-char one lands). Every bound admits everything its +upstream producer can write, so only values the producing contracts already +forbid are affected. + +Deliberately not bounded, per the ruling's escape clause: +`sys_verification.value` (better-auth's oauth-provider stores JSON +authorization-code payloads there — no defensible bound exists), and +`sys_import_job.created_by` (outside this card's identity surface). +`sys_account.issuer`'s 2048 exceeds the 768-char utf8mb4 key ceiling on +purpose — tighter would refuse SSO sign-ins that `sys_sso_provider`'s own +contract admits — so its `(issuer, account_id)` unique stays for #11627's +hash-shadow route, alongside the `maxLength: 1024` token columns. A new pin +test enumerates every keyed text-family identity column and names any future +unbounded arrival. diff --git a/packages/platform-objects/src/identity/identity-keyed-text-bounds.test.ts b/packages/platform-objects/src/identity/identity-keyed-text-bounds.test.ts new file mode 100644 index 0000000000..3809808f77 --- /dev/null +++ b/packages/platform-objects/src/identity/identity-keyed-text-bounds.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import * as Identity from './index'; + +/** + * #11374 — every text-family column a declared index keys on must declare a + * `maxLength`, because a bound is what lets the column be a key at all. + * + * ## Why this pin exists + * + * `driver-sql` emits a KEYED text-family column as `varchar(maxLength)` when + * the field declares a bound the dialect can key on, and leaves it `TEXT` + * otherwise. MySQL refuses a TEXT/BLOB column in a key without a prefix length + * (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded keyed text column means: + * `CREATE TABLE` succeeds, `ALTER TABLE … ADD [UNIQUE] INDEX` fails, and the + * object lands registered-but-broken with its declared uniqueness silently + * absent. Measured on live MySQL 8.0.46: 12 of 44 platform objects failed + * schema-sync this way — sys_session and sys_account among them, so a MySQL + * stack could not sign anyone in. + * + * The driver deliberately does NOT substitute a prefix index: measured on the + * same server, a prefix-UNIQUE index is stricter-and-different — it refused a + * second, genuinely distinct token that shared its first 191 characters + * (`ER_DUP_ENTRY`), i.e. a valid sign-in refused as a duplicate. So the bound + * has to live HERE, in the field declaration (maintainer ruling on #11374, + * 2026-08-24: route A). + * + * ## What a red on this file means + * + * A new keyed text-family field arrived without a `maxLength`. Do not silence + * the assertion — derive a bound from the value's producer (upstream + * better-auth schema/constraints, IdP norms, or the in-repo producer) and + * declare it, or, if the value source genuinely cannot be bounded (the + * `sys_verification.value` case below), extend the allowlist WITH a comment + * naming why and where the keyability debt is tracked. + * + * 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 + * column then stays TEXT and its index still cannot exist on MySQL. That debt + * is #11627's (hash-shadow keys), and this pin does not police it — it polices + * only "keyed text declares its bound". + */ + +const TEXT_FAMILY = new Set(['text', 'textarea', 'html', 'markdown']); + +/** + * Keyed text-family columns with NO defensible bound. Every entry must name + * why. Entries that stop matching a real keyed unbounded column fail the + * second test, so the list cannot rot. + */ +const UNBOUNDABLE: ReadonlySet = new Set([ + // better-auth's oauth-provider stores OIDC authorization-code payloads in + // `verification.value` as a JSON blob (see the index comment in + // sys-verification.object.ts), and upstream deliberately declares the field + // unindexed and unbounded — no bound exists that provably admits every value + // better-auth may write. Its ObjectStack-declared index therefore still + // cannot exist on MySQL; that keyability debt is tracked with #11627. + 'sys_verification.value', +]); + +type AnyObject = { + name: string; + fields: Record; + indexes?: Array<{ fields?: string[]; unique?: boolean }>; +}; + +const identityObjects: AnyObject[] = Object.values(Identity) + .map((v) => v as unknown as AnyObject) + .filter( + (v) => + !!v && + typeof v === 'object' && + typeof v.name === 'string' && + v.name.startsWith('sys_') && + !!v.fields, + ); + +function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unknown }> { + const keyed = new Set(); + for (const ix of o.indexes ?? []) for (const f of ix.fields ?? []) keyed.add(f); + return Object.entries(o.fields) + .filter(([name, def]) => keyed.has(name) && TEXT_FAMILY.has(def?.type ?? '')) + .map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength })); +} + +describe('identity keyed text-family columns declare their bound (#11374)', () => { + it('enumerates a real surface — the probe itself is not vacuous', () => { + // Positive control: if the export shape or field/index spelling changes so + // this file stops seeing columns, fail loudly instead of passing empty. + const all = identityObjects.flatMap(keyedTextColumns); + expect(identityObjects.length).toBeGreaterThanOrEqual(20); + expect(all.length).toBeGreaterThanOrEqual(30); + expect(all.map((c) => c.column)).toContain('sys_session.token'); + }); + + it('every keyed text-family column declares a positive integer maxLength, or is allowlisted by name', () => { + const offenders: string[] = []; + for (const o of identityObjects) { + for (const { column, maxLength } of keyedTextColumns(o)) { + if (UNBOUNDABLE.has(column)) continue; + const bounded = + typeof maxLength === 'number' && Number.isInteger(maxLength) && maxLength > 0; + if (!bounded) offenders.push(`${column} (maxLength: ${String(maxLength)})`); + } + } + expect( + offenders, + `keyed text-family column(s) without a declared maxLength — on MySQL their ` + + `declared index cannot be created and the object lands registered-but-broken. ` + + `Declare a sourced bound or extend UNBOUNDABLE with a named reason: ` + + offenders.join(', '), + ).toEqual([]); + }); + + it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', () => { + const real = new Map( + identityObjects.flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]), + ); + for (const entry of UNBOUNDABLE) { + expect(real.has(entry), `allowlist entry ${entry} is not a keyed text column any more — remove it`).toBe(true); + expect( + real.get(entry), + `allowlist entry ${entry} now declares a bound — remove it from UNBOUNDABLE`, + ).toBeUndefined(); + } + }); +}); diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index ca19cf36ef..f19824f910 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -147,6 +147,10 @@ export const SysAccount = ObjectSchema.create({ provider_id: Field.text({ label: 'Provider ID', required: true, + // [#11374] Transitive bound: SSO-registered providers are the widest + // producer, and sys_sso_provider.provider_id declares maxLength: 255; + // better-auth's built-in social providers are short fixed slugs. + maxLength: 255, description: 'OAuth provider identifier (google, github, etc.)', }), @@ -160,15 +164,33 @@ export const SysAccount = ObjectSchema.create({ // Deliberately NOT `required` even though better-auth always supplies it: a // NOT NULL column cannot be added to a table that already holds rows, and // schema sync runs before the backfill. + // [#11374] Bound = 2048, transitively from sys_sso_provider.issuer + // (maxLength: 2048, the landed contract for the widest producer): the SSO + // OIDC path writes the verified token's raw `iss` claim — or the provider's + // registered issuer — verbatim into this column, and SAML entityIDs are + // capped at 1024 by SAML metadata. Anything tighter would refuse a sign-in + // that sys_sso_provider's own contract admits. 2048 exceeds the 768-char + // utf8mb4 key-part ceiling, so this column deliberately stays TEXT and the + // (issuer, account_id) unique index still cannot exist on MySQL — that is + // #11627's hash-shadow-key territory, not a reason to guess a tighter + // number here. issuer: Field.text({ label: 'Issuer', required: false, + maxLength: 2048, description: 'Authority that vouched for the provider account id — an OIDC issuer, or local:… for providers without one', }), account_id: Field.text({ label: 'Provider Account ID', required: true, + // [#11374] Bound from the identity-provider norms for the two federated + // shapes this column stores: an OIDC `sub` MUST NOT exceed 255 ASCII + // chars (OIDC Core §2) and a SAML persistent/transient NameID MUST NOT + // exceed 256 chars (SAML Core 2.0 §8.3.7/§8.3.8) — 256 is the wider of + // the two, and comfortably above the 191 better-auth's own MySQL schema + // enforces on this column. + maxLength: 256, description: "User's ID in the provider's system", }), diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 4f0eb591f0..b97783734d 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -285,6 +285,11 @@ export const SysApiKey = ObjectSchema.create({ key: Field.text({ label: 'Hashed Key', required: true, + // [#11374] Exact producer bound: the only writer is + // `packages/core/src/security/api-key.ts` (`hashApiKey` — "sha256(raw) + // hex — store this in sys_api_key.key"), a fixed 64-hex-char digest. + // better-auth's apiKey plugin is not loaded, so no other producer exists. + maxLength: 64, hidden: true, readonly: true, internal: true, diff --git a/packages/platform-objects/src/identity/sys-device-code.object.ts b/packages/platform-objects/src/identity/sys-device-code.object.ts index dd36399db9..6378949816 100644 --- a/packages/platform-objects/src/identity/sys-device-code.object.ts +++ b/packages/platform-objects/src/identity/sys-device-code.object.ts @@ -74,6 +74,12 @@ export const SysDeviceCode = ObjectSchema.create({ device_code: Field.text({ label: 'Device Code', required: true, + // [#11374] Upstream hard cap: better-auth 1.7.1's device-authorization + // plugin refuses ANY generated code — custom generators included — longer + // than 191 chars at runtime (`validateGeneratedCode`), and its + // `deviceCodeLength` option schema is `max(191)` (default 40). Nothing + // the plugin can ever write exceeds this bound. + maxLength: 191, description: 'High-entropy token returned to the polling device', }), @@ -81,6 +87,9 @@ export const SysDeviceCode = ObjectSchema.create({ user_code: Field.text({ label: 'User Code', required: true, + // [#11374] Same upstream hard cap as device_code: `validateGeneratedCode` + // refuses > 191 chars and `userCodeLength` is `max(191)` (default 8). + maxLength: 191, description: 'Short user-facing code (e.g. ABCD-EFGH)', }), @@ -101,6 +110,12 @@ export const SysDeviceCode = ObjectSchema.create({ status: Field.text({ label: 'Status', required: true, + // [#11374] The value domain is the closed literal set the plugin's own + // routes write — 'pending' | 'approved' | 'denied', 8 chars at the + // widest. 64 follows the landed machine-vocabulary precedent + // (sys_session.revoke_reason, maxLength: 64) so a future status word can + // never be refused by the column. + maxLength: 64, description: "Current status: 'pending' | 'approved' | 'denied'", }), diff --git a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts index 1dc440b0d8..1235009a0d 100644 --- a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts @@ -55,6 +55,12 @@ export const SysOauthAccessToken = ObjectSchema.create({ client_id: Field.text({ label: 'Client ID', required: true, + // [#11374] Bound from the referenced column: this is a foreign key to + // sys_oauth_application.client_id, which declares maxLength: 255 (and + // upstream @better-auth/oauth-provider's oauthClient.clientId is a + // unique string — varchar(255) on MySQL). A referencing column takes the + // referenced column's bound. + maxLength: 255, description: 'Foreign key to sys_oauth_application.client_id', }), 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 22511f589b..7275fd7389 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 @@ -40,6 +40,12 @@ export const SysOauthClientResource = ObjectSchema.create({ client_id: Field.text({ label: 'Client ID', required: true, + // [#11374] Bound from the referenced column: this is a foreign key to + // sys_oauth_application.client_id, which declares maxLength: 255 (and + // upstream @better-auth/oauth-provider's oauthClient.clientId is a + // unique string — varchar(255) on MySQL). A referencing column takes the + // referenced column's bound. + maxLength: 255, description: 'Foreign key to sys_oauth_application.client_id', }), diff --git a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts index edefe5b9fc..814078785a 100644 --- a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts @@ -44,6 +44,12 @@ export const SysOauthConsent = ObjectSchema.create({ client_id: Field.text({ label: 'Client ID', required: true, + // [#11374] Bound from the referenced column: this is a foreign key to + // sys_oauth_application.client_id, which declares maxLength: 255 (and + // upstream @better-auth/oauth-provider's oauthClient.clientId is a + // unique string — varchar(255) on MySQL). A referencing column takes the + // referenced column's bound. + maxLength: 255, description: 'Foreign key to sys_oauth_application.client_id', }), diff --git a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts index 122d514b52..7de27548a8 100644 --- a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts @@ -54,6 +54,12 @@ export const SysOauthRefreshToken = ObjectSchema.create({ client_id: Field.text({ label: 'Client ID', required: true, + // [#11374] Bound from the referenced column: this is a foreign key to + // sys_oauth_application.client_id, which declares maxLength: 255 (and + // upstream @better-auth/oauth-provider's oauthClient.clientId is a + // unique string — varchar(255) on MySQL). A referencing column takes the + // referenced column's bound. + maxLength: 255, description: 'Foreign key to sys_oauth_application.client_id', }), diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index 8dba60de6e..b52866f9f1 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -250,6 +250,11 @@ export const SysSession = ObjectSchema.create({ token: Field.text({ label: 'Session Token', required: true, + // [#11374] Bound from better-auth 1.7.1's own MySQL schema: a unique + // string column is emitted as varchar(255) (get-migration.mjs), and the + // producer writes generateId(32) — 32 chars. 255 admits everything the + // upstream schema admits, and lets the unique index exist on MySQL. + maxLength: 255, hidden: true, readonly: true, internal: true, diff --git a/packages/platform-objects/src/identity/sys-verification.object.ts b/packages/platform-objects/src/identity/sys-verification.object.ts index 72b982b0f4..c9d3e79d7a 100644 --- a/packages/platform-objects/src/identity/sys-verification.object.ts +++ b/packages/platform-objects/src/identity/sys-verification.object.ts @@ -68,6 +68,14 @@ export const SysVerification = ObjectSchema.create({ identifier: Field.text({ label: 'Identifier', required: true, + // [#11374] Bound from better-auth 1.7.1's own MySQL schema: the + // verification model declares `identifier` with `index: true`, and the + // upstream migration emits an indexed string column as varchar(255) + // (get-migration.mjs) — every better-auth flow that writes this table, + // oauth-provider included, already lives inside 255 on upstream MySQL. + // `value` deliberately declares NO bound: better-auth stores JSON blobs + // there (see the index comment below), so no defensible bound exists. + maxLength: 255, description: 'Email address or phone number', }), },