Skip to content
Draft
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
48 changes: 48 additions & 0 deletions .changeset/identity-keyed-text-bounds.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<string> = 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<string, { type?: string; maxLength?: unknown }>;
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<string>();
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();
}
});
});
22 changes: 22 additions & 0 deletions packages/platform-objects/src/identity/sys-account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.)',
}),

Expand All@@ -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",
}),

Expand Down
5 changes: 5 additions & 0 deletions packages/platform-objects/src/identity/sys-api-key.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
15 changes: 15 additions & 0 deletions packages/platform-objects/src/identity/sys-device-code.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,13 +74,22 @@ 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',
}),

/** Human-readable short code displayed to the user (e.g. ABCD-EFGH). */
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)',
}),

Expand All@@ -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'",
}),

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

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

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

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

Expand Down
5 changes: 5 additions & 0 deletions packages/platform-objects/src/identity/sys-session.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
}),
},
Expand Down
Loading