From c75fa41dfe08a719a83e1e7777de0392ae40f975 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:52:04 +0000 Subject: [PATCH 1/3] fix(platform-objects)!: sys_oauth_application.type declares only web and native, the two values its writer produces The stored column declared web, native, useragentbased and public through the string shorthand. No writer produces the last two: the OAuth provider writes web or native (or null), and the generic data API cannot write the table. Rows are not rewritten; a legacy row still reads, lists and takes every write that leaves type out. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .../apps/translations/en.objects.generated.ts | 4 +- .../translations/es-ES.objects.generated.ts | 4 +- .../translations/ja-JP.objects.generated.ts | 4 +- .../translations/zh-CN.objects.generated.ts | 4 +- .../oauth-register-form-types.test.ts | 26 +- .../identity/sys-oauth-application.object.ts | 21 +- ...sys-oauth-application.type-options.test.ts | 78 +++++ .../src/oauth-application-type-column.test.ts | 277 ++++++++++++++++++ 8 files changed, 378 insertions(+), 40 deletions(-) create mode 100644 packages/platform-objects/src/identity/sys-oauth-application.type-options.test.ts create mode 100644 packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index eb51af14dd3..b1baa77789a 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -1389,9 +1389,7 @@ export const enObjects: NonNullable = { label: "Client Type", options: { web: "web", - native: "native", - useragentbased: "user-agent-based", - public: "public" + native: "native" } }, public: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index bbcd973e652..f0c497397c3 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -1389,9 +1389,7 @@ export const esESObjects: NonNullable = { label: "Tipo de cliente", options: { web: "Web", - native: "Nativa", - useragentbased: "Basada en agente de usuario", - public: "Público" + native: "Nativa" } }, public: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 9e36d181dae..cfdf472184f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -1389,9 +1389,7 @@ export const jaJPObjects: NonNullable = { label: "クライアントタイプ", options: { web: "Web", - native: "ネイティブ", - useragentbased: "ユーザーエージェントベース", - public: "公開" + native: "ネイティブ" } }, public: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 5326f33a279..efff1c4db7c 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -1389,9 +1389,7 @@ export const zhCNObjects: NonNullable = { label: "客户端类型", options: { web: "Web 应用", - native: "原生应用", - useragentbased: "基于用户代理", - public: "公开" + native: "原生应用" } }, public: { diff --git a/packages/platform-objects/src/identity/oauth-register-form-types.test.ts b/packages/platform-objects/src/identity/oauth-register-form-types.test.ts index 9cebb2019a1..6759d975a66 100644 --- a/packages/platform-objects/src/identity/oauth-register-form-types.test.ts +++ b/packages/platform-objects/src/identity/oauth-register-form-types.test.ts @@ -14,10 +14,8 @@ * has neither, so the route registers both as `web`. The form stops offering * them; the route still accepts them, so this is the form only. * - * The stored `type` column is a different surface and keeps all four options: - * its narrowing waits for the major version that refuses the two values at the - * route, so the pin below holds it as it is on purpose (including a declared - * value that differs from the one earlier provider lines wrote — see there). + * The stored `type` column is a different surface. It declares the same two + * values since #25053, and `sys-oauth-application.type-options.test.ts` pins it. * * The help text is not pinned word for word. What is pinned is what it names: * the scheme each type needs and the three native loopback hosts, in the source @@ -105,23 +103,3 @@ describe('[#24773] create_oauth_application — the redirect-URL help states the } }); }); - -describe('[#24773] the stored columns are not narrowed in this major', () => { - it('the stored `type` select still declares all four options, exactly as it is served today', () => { - const stored = (SysOauthApplication.fields as Record).type; - expect(stored?.type).toBe('select'); - const options = (stored?.options ?? []) as { label?: unknown }[]; - expect(options.map((o) => o?.label)).toEqual(['web', 'native', 'user-agent-based', 'public']); - // Measured, not intended: the column is declared through `Field.select`'s - // string-array shorthand, which snake-cases each value and drops the - // hyphens, so the third option's VALUE is `useragentbased`, not the - // `user-agent-based` an earlier provider line wrote. Recorded for the - // major version that narrows this column; this PR changes neither. - expect(optionValues(stored?.options)).toEqual(['web', 'native', 'useragentbased', 'public']); - }); - - it('the stored `public` column is still declared', () => { - const column = (SysOauthApplication.fields as Record).public; - expect(column?.type).toBe('boolean'); - }); -}); diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index dca7b45442d..2f185ac1644 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -156,9 +156,9 @@ export const SysOauthApplication = ObjectSchema.create({ // 400 before the provider runs; no type (absent or null) registers `web`. // Nothing on the server reads these options: the generic action door // refuses a `type: 'api'` action before its param contract runs. The - // stored `type` column below is not narrowed: 1.7.3 writes only `web` or - // `native` (null for a client-metadata-document client), an earlier - // provider line could write `user-agent-based`, and no in-tree reader branches on it. + // stored `type` column below declares the same two values [#25053]: + // 1.7.3 writes only `web` or `native` (null for a client-metadata-document + // client), and a row an earlier provider line stored as `user-agent-based` still reads. params: [ { name: 'name', label: 'Application Name', type: 'text', required: true }, { @@ -446,7 +446,20 @@ export const SysOauthApplication = ObjectSchema.create({ // (#3002). The column keeps its name and its data; the rename is absorbed // by the `applicationType: 'type'` mapping in plugin-auth's // `auth-schema-config.ts`. - type: Field.select(['web', 'native', 'user-agent-based', 'public'], { + // + // [#25053, ADR-0049 enforce-or-remove] The options are exactly the values + // the column's one writer produces. `@better-auth/oauth-provider` 1.7.3 + // writes `application_type` `web` or `native` on every creation and + // update path, and the generic data API cannot write this table + // (`apiMethods` is `get` / `list`). It can also write `null` (a + // client-metadata-document client that names no type), so the column + // stays optional. The former `useragentbased` and `public` options had no + // writer. Rows are not rewritten: a row an earlier provider line stored as + // `user-agent-based` (never a declared value, because the string shorthand + // strips hyphens) still reads and lists, and takes every write that leaves + // `type` out, because the record validator judges only the keys a write + // sends. Neither value has a hyphen, so the shorthand stores each as written. + type: Field.select(['web', 'native'], { label: 'Client Type', required: false, defaultValue: 'web', diff --git a/packages/platform-objects/src/identity/sys-oauth-application.type-options.test.ts b/packages/platform-objects/src/identity/sys-oauth-application.type-options.test.ts new file mode 100644 index 00000000000..c8bdc4da31b --- /dev/null +++ b/packages/platform-objects/src/identity/sys-oauth-application.type-options.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25053] `sys_oauth_application.type` declares exactly the application types + * its one writer produces: `web` and `native`. + * + * The column's only writer is `@better-auth/oauth-provider`, whose + * `applicationType` plugin-auth maps onto `type`. 1.7.3 writes + * `application_type` `web` or `native` on every creation and update path, and + * the generic data API cannot write this table (`apiMethods` is `get` / + * `list`). The column used to declare four options through the string + * shorthand, which strips hyphens, so it served `web`, `native`, + * `useragentbased` and `public`. No writer produces the last two (ADR-0049 + * enforce-or-remove), and the hyphenated `user-agent-based` an earlier + * provider line wrote was never one of them. + * + * What the narrowed declaration does to stored rows is pinned where a real + * engine runs, in plugin-auth's `oauth-application-type-column.test.ts`. This + * package cannot import `@objectstack/objectql`, which depends on it. + */ + +import { describe, expect, it } from 'vitest'; +import { SysOauthApplication } from './index.js'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; + +type Option = { label?: unknown; value?: unknown }; +type Column = { type?: string; required?: boolean; defaultValue?: unknown; options?: Option[] }; + +const fields = SysOauthApplication.fields as Record; +const column = fields.type; +const values = (options: unknown[] | undefined): unknown[] => + (options ?? []).map((o) => (typeof o === 'string' ? o : (o as Option)?.value)); + +const LOCALES: readonly (readonly [string, any])[] = [ + ['en', enObjects], + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +]; + +describe('[#25053] sys_oauth_application.type — the declared options are what the provider writes', () => { + it('declares exactly web and native, each value as written', () => { + expect(column?.type).toBe('select'); + expect(values(column?.options)).toEqual(['web', 'native']); + // The string shorthand keeps the written string as the label and derives + // the value from it; equal pairs say the derivation changed nothing. + for (const option of column?.options ?? []) { + expect(option.value, `option ${String(option.label)}`).toBe(option.label); + } + }); + + it('stays optional, with web as its default', () => { + // The provider can write null (a client-metadata-document client that + // names no type): a required column would make that a missing value. + expect(column?.required).toBe(false); + expect(column?.defaultValue).toBe('web'); + }); + + it('declares the same values the register form offers', () => { + const register = (SysOauthApplication.actions ?? []).find((a) => a.name === 'create_oauth_application'); + const param = ((register?.params ?? []) as { name?: string; options?: unknown[] }[]).find((p) => p.name === 'type'); + expect(values(param?.options)).toEqual(values(column?.options)); + }); + + it('every shipped locale translates exactly those two options', () => { + for (const [locale, bundle] of LOCALES) { + const options = bundle?.sys_oauth_application?.fields?.type?.options; + expect(Object.keys(options ?? {}), `${locale} type options`).toEqual(['web', 'native']); + } + }); + + it('keeps the `public` column', () => { + expect(fields.public?.type).toBe('boolean'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts b/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts new file mode 100644 index 00000000000..1f85917e9b0 --- /dev/null +++ b/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25053] What `sys_oauth_application.type`'s two declared values, `web` and + * `native`, mean for stored rows and for writes. + * + * `platform-objects` declares the column with exactly the values its one + * writer, `@better-auth/oauth-provider`, produces (its own + * `sys-oauth-application.type-options.test.ts` pins the declaration). The + * former `useragentbased` and `public` options had no writer, and a row an + * earlier provider line stored as `user-agent-based` never held a declared + * value. Rows are not rewritten, so this file pins the two halves of the + * narrowing on a real engine: + * + * - A stored row reads, lists and takes every write that leaves `type` out, + * whatever `type` it holds: the record validator judges only the keys a + * write sends. That covers the provider's own secret rotation and the + * enable/disable bridge's `disabled` write. + * - A write that SENDS a value outside the two is refused with + * `VALIDATION_FAILED` (400 at an HTTP boundary) and an `invalid_option` + * finding on `type`, and nothing is written. `null` is a blank value and is + * accepted. No door sends another value today (the provider writes `web`, + * `native` or `null`; the generic data API answers 405 on this table), so + * the refusal guards the declaration rather than a live caller. + * + * Driven against a REAL ObjectQL engine on SQLite with the auth manifest's own + * objects, and REAL better-auth with the OIDC provider on. Rows are seeded and + * read back at driver level, below every engine read mask. Writes go through + * the engine with the system context plugin-auth's adapter writes under. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { AuthManager } from './auth-manager'; +import { authIdentityObjects } from './manifest'; + +const SECRET = 'test-secret-at-least-32-chars-long-25053'; +const PASSWORD = 'S3cure!Passw0rd-25053-type'; +const EMAIL = 'owner.type.25053@example.com'; +const ORIGIN = 'http://localhost:3000'; +const CLIENTS = 'sys_oauth_application'; +const SYSTEM = { context: { isSystem: true } }; + +/** Stored values no declaration holds after #25053; the first was never declared. */ +const UNDECLARED = ['user-agent-based', 'useragentbased', 'public'] as const; +/** Every value a stored row may hold: the two declared ones, a blank, and the undeclared ones. */ +const STORED = ['web', 'native', null, ...UNDECLARED] as const; + +/** The three authorization objects a real sign-in reads (as `oauth-register-application-type.test.ts`). */ +const AUTHZ_OBJECTS = [ + { + name: 'sys_user_position', + label: 'User Position', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + position: { name: 'position', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, + { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, + { + name: 'sys_position', + label: 'Position', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + label: { name: 'label', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, +]; + +type Row = Record; + +let engine: ObjectQL; +let manager: AuthManager; +let cookie: string; +let ownerId: string; + +const driver = () => + (engine as unknown as { + getDriver(o: string): { + find(o: string, q: unknown): Promise; + create(o: string, d: Row): Promise; + }; + }).getDriver(CLIENTS); + +/** The stored row, read at DRIVER level. */ +async function stored(id: string): Promise { + const found = await driver().find(CLIENTS, { where: { id } }); + return ((Array.isArray(found) ? found : [found]).filter(Boolean) as Row[])[0]; +} + +const label = (value: string | null) => (value === null ? 'null' : value.replace(/-/g, '_')); + +/** Seed one client row at driver level, below the validator, as an earlier writer left it. */ +async function seed(tag: string, type: string | null): Promise<{ id: string; clientId: string }> { + const id = `oauthc_25053_${tag}`; + const clientId = `c25053-${tag}`; + const now = new Date().toISOString(); + await driver().create(CLIENTS, { + id, + name: `seeded ${tag}`, + client_id: clientId, + client_secret: `digest-${tag}`, + type, + user_id: ownerId, + redirect_uris: JSON.stringify(['https://app.example.com/callback']), + grant_types: JSON.stringify(['authorization_code', 'refresh_token']), + response_types: JSON.stringify(['code']), + token_endpoint_auth_method: 'client_secret_basic', + disabled: false, + created_at: now, + updated_at: now, + }); + expect((await stored(id))?.type, `seeded ${tag}`).toBe(type); + return { id, clientId }; +} + +/** The refusal a write sending an undeclared `type` gets, as an HTTP boundary answers it. */ +async function expectTypeRefused(write: () => Promise) { + const thrown = await write().then( + () => undefined, + (e: unknown) => e, + ); + expect(thrown, 'the write was accepted').toBeDefined(); + const answer = resolveThrownHttpError(thrown); + expect(answer.code).toBe('VALIDATION_FAILED'); + expect(answer.status).toBe(400); + expect(answer.details?.fields).toEqual([expect.objectContaining({ field: 'type', code: 'invalid_option' })]); +} + +beforeAll(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const object of authIdentityObjects) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + for (const object of AUTHZ_OBJECTS) { + engine.registry.registerObject(object as never, '@objectstack/plugin-security'); + } + await engine.syncSchemas(); + + manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine as never, + plugins: { oidcProvider: true }, + } as unknown as ConstructorParameters[0]); + + const direct = (path: string, body: unknown) => + manager.handleRequest( + new Request(`${ORIGIN}/api/v1/auth${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin: ORIGIN }, + body: JSON.stringify(body), + }), + ); + const signUp = await direct('/sign-up/email', { email: EMAIL, password: PASSWORD, name: 'Client Owner' }); + expect(signUp.status, `sign-up: ${await signUp.clone().text()}`).toBe(200); + const signIn = await direct('/sign-in/email', { email: EMAIL, password: PASSWORD }); + expect(signIn.status, `sign-in: ${await signIn.clone().text()}`).toBe(200); + ownerId = ((await signIn.clone().json()) as { user?: { id?: string } }).user?.id ?? ''; + expect(ownerId, 'sign-in names its user').not.toBe(''); + const staged = signIn.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0])); + if (!staged) throw new Error('sign-in staged no session cookie'); + cookie = staged.split(';')[0]; +}, 60_000); + +afterAll(async () => { + vi.restoreAllMocks(); + await (engine as unknown as { destroy?(): Promise })?.destroy?.(); +}); + +describe('[#25053] a stored row reads and lists whatever `type` it holds', () => { + it.each(STORED)('type %s: the record read and the list read return the stored value', async (type) => { + const { id, clientId } = await seed(`read_${label(type)}`, type); + const record = await engine.findOne(CLIENTS, { where: { id }, ...SYSTEM }); + expect(record?.type ?? null).toBe(type); + const listed = (await engine.find(CLIENTS, { where: { client_id: clientId }, ...SYSTEM })) as Row[]; + expect(listed.map((row) => row.type ?? null)).toEqual([type]); + }); + + it.each(STORED)('type %s: the provider reads the client back with its stored type', async (type) => { + const { clientId } = await seed(`provider_read_${label(type)}`, type); + const api = (await manager.getApi()) as unknown as { + getOAuthClient(input: unknown): Promise<{ client_id?: unknown; application_type?: unknown }>; + }; + const client = await api.getOAuthClient({ query: { client_id: clientId }, headers: new Headers({ cookie }) }); + expect(client.client_id).toBe(clientId); + expect(client.application_type ?? null).toBe(type); + }); +}); + +describe('[#25053] a stored row takes every write that leaves `type` out', () => { + it.each(STORED)('type %s: the enable/disable bridge\'s `disabled` write is accepted, the type unchanged', async (type) => { + const { id } = await seed(`disable_${label(type)}`, type); + await engine.update(CLIENTS, { id, disabled: true }, SYSTEM); + const row = await stored(id); + expect(row?.disabled).toBeTruthy(); + expect(row?.type).toBe(type); + }); + + it.each(STORED)('type %s: the provider rotates the client secret, the type unchanged', async (type) => { + const { id, clientId } = await seed(`rotate_${label(type)}`, type); + const api = (await manager.getApi()) as unknown as { + rotateClientSecret(input: unknown): Promise<{ client_secret?: unknown }>; + }; + const rotated = await api.rotateClientSecret({ body: { client_id: clientId }, headers: new Headers({ cookie }) }); + expect(typeof rotated.client_secret).toBe('string'); + const row = await stored(id); + expect(row?.client_secret).not.toBe(`digest-rotate_${label(type)}`); + expect(row?.type).toBe(type); + }); +}); + +describe('[#25053] a write that sends `type` is judged against web and native', () => { + const insertRow = (tag: string, type: unknown) => ({ + id: `oauthc_25053_${tag}`, + name: `inserted ${tag}`, + client_id: `c25053-${tag}`, + user_id: ownerId, + redirect_uris: JSON.stringify(['https://app.example.com/callback']), + token_endpoint_auth_method: 'client_secret_basic', + type, + }); + + it.each(UNDECLARED)('insert with type %s: refused, nothing stored', async (type) => { + const tag = `insert_${label(type)}`; + await expectTypeRefused(() => engine.insert(CLIENTS, insertRow(tag, type), SYSTEM)); + expect(await stored(`oauthc_25053_${tag}`)).toBeUndefined(); + }); + + it.each(UNDECLARED)('update to type %s: refused, the stored type unchanged', async (type) => { + const { id } = await seed(`update_${label(type)}`, 'web'); + await expectTypeRefused(() => engine.update(CLIENTS, { id, type }, SYSTEM)); + expect((await stored(id))?.type).toBe('web'); + }); + + it.each(['web', 'native'] as const)('insert and update with type %s: accepted and stored as written', async (type) => { + const tag = `insert_${type}`; + await engine.insert(CLIENTS, insertRow(tag, type), SYSTEM); + expect((await stored(`oauthc_25053_${tag}`))?.type).toBe(type); + const { id } = await seed(`update_${type}`, type === 'web' ? 'native' : 'web'); + await engine.update(CLIENTS, { id, type }, SYSTEM); + expect((await stored(id))?.type).toBe(type); + }); + + it('a null type is a blank value: accepted on insert and on update', async () => { + await engine.insert(CLIENTS, insertRow('insert_null', null), SYSTEM); + // The declared default may fill the blank on insert; the value is never refused. + expect(['web', null]).toContain((await stored('oauthc_25053_insert_null'))?.type ?? null); + const { id } = await seed('update_null', 'native'); + await engine.update(CLIENTS, { id, type: null }, SYSTEM); + expect((await stored(id))?.type ?? null).toBeNull(); + }); +}); From 2d158a9dd42953d0611f3fe5bd876f35e5a15cea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:55:01 +0000 Subject: [PATCH 2/3] test(plugin-auth): pin the provider's update path on a row holding an undeclared type; add the changeset Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .../25053-oauth-application-type-options.md | 18 +++++++++++ .../src/oauth-application-type-column.test.ts | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 .changeset/25053-oauth-application-type-options.md diff --git a/.changeset/25053-oauth-application-type-options.md b/.changeset/25053-oauth-application-type-options.md new file mode 100644 index 00000000000..8c1dae8d2a2 --- /dev/null +++ b/.changeset/25053-oauth-application-type-options.md @@ -0,0 +1,18 @@ +--- +'@objectstack/platform-objects': minor +--- + +fix(platform-objects)!: `sys_oauth_application.type` declares only `web` and `native`, the two application types its writer produces + +Clause-②: no (narrowing) — the stored column's declared options, which `GET /api/v1/meta/object/sys_oauth_application` serves and the record validator judges a write against, lose `useragentbased` and `public`, which no writer produces. + + + +**BREAKING** (a platform object's declared option set narrows), shipped as `minor` under the launch-window convention for breaking changes. + +The column's only writer is the OAuth provider (`@better-auth/oauth-provider`), whose `application_type` plugin-auth maps onto `type`. The provider writes `web` or `native` on every creation and update path, or no type for a client-metadata-document client that names none. The generic data API cannot write the table. The column declared four options, and the string shorthand that declared them strips hyphens, so it served `web`, `native`, `useragentbased` and `public`. No writer produces the last two, and the `user-agent-based` an earlier provider line stored was never one of them. + +- **FROM:** the `type` field of `sys_oauth_application` declared and served the options `web`, `native`, `useragentbased` and `public`. A write that sent `useragentbased` or `public` was accepted and stored. +- **TO:** it declares and serves `web` and `native`. A write that sends any other value, including `useragentbased` and `public`, is refused with `VALIDATION_FAILED` (`400` at an HTTP boundary) and an `invalid_option` finding on `type`. No door sends one: the provider sends only `web`, `native` or no type, and the generic data API answers `405` for every write to this table. +- **Unchanged:** the column stays optional and defaults to `web`, and a `null` type is a blank value. No stored row is rewritten. A row holding any type, `user-agent-based` included, still reads and lists through the data API and the provider. It takes every write that leaves `type` out, such as a secret rotation or enabling and disabling the application. The provider's own update path still refuses a row whose type is not `web` or `native`, as it did before. This entry supersedes the register-form entry's sentence that the stored column still lists all four values. +- **The fix:** nothing to change for a client the provider registered. A row an earlier provider line stored as `user-agent-based` keeps that value. To edit it through the provider's update path, set `application_type` (`web` or `native`) in the same update. diff --git a/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts b/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts index 1f85917e9b0..41a8e96c415 100644 --- a/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts +++ b/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts @@ -234,6 +234,37 @@ describe('[#25053] a stored row takes every write that leaves `type` out', () => }); }); +describe('[#25053] the provider\'s update path on a row holding an undeclared type', () => { + // The provider re-judges the stored client merged with the update, so it + // refuses an update that leaves such a type in place, before and after the + // narrowing alike; setting `application_type` in the same update is the + // remedy the changeset names, and the engine accepts the value it writes. + type UpdateApi = { updateOAuthClient(input: unknown): Promise<{ application_type?: unknown }> }; + + it.each(UNDECLARED)('type %s: an update that leaves it in place is refused by the provider, the row unchanged', async (type) => { + const { id, clientId } = await seed(`provider_update_${label(type)}`, type); + const api = (await manager.getApi()) as unknown as UpdateApi; + const refusal = await api + .updateOAuthClient({ body: { client_id: clientId, update: { client_name: 'renamed' } }, headers: new Headers({ cookie }) }) + .then(() => undefined, (e: unknown) => e as { body?: { error?: unknown } }); + expect(refusal?.body?.error).toBe('invalid_client_metadata'); + const row = await stored(id); + expect(row?.type).toBe(type); + expect(row?.name).toBe(`seeded provider_update_${label(type)}`); + }); + + it.each(UNDECLARED)('type %s: an update that also sets application_type native is accepted and stored', async (type) => { + const { id, clientId } = await seed(`provider_retype_${label(type)}`, type); + const api = (await manager.getApi()) as unknown as UpdateApi; + const updated = await api.updateOAuthClient({ + body: { client_id: clientId, update: { client_name: 'renamed', application_type: 'native' } }, + headers: new Headers({ cookie }), + }); + expect(updated.application_type).toBe('native'); + expect((await stored(id))?.type).toBe('native'); + }); +}); + describe('[#25053] a write that sends `type` is judged against web and native', () => { const insertRow = (tag: string, type: unknown) => ({ id: `oauthc_25053_${tag}`, From a72b7acf8fc300caa0466ec481711a79b7c06fd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 10:00:39 +0000 Subject: [PATCH 3/3] docs(changeset): correct the register-form note's stored-column sentence this release makes false, and re-point this entry's cross-reference Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .changeset/24773-oauth-form-types.md | 2 +- .changeset/25053-oauth-application-type-options.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/24773-oauth-form-types.md b/.changeset/24773-oauth-form-types.md index 2166d7c6890..9e5294cf0a4 100644 --- a/.changeset/24773-oauth-form-types.md +++ b/.changeset/24773-oauth-form-types.md @@ -10,6 +10,6 @@ Setup › OAuth Applications › Register offered four application types: Web, N - **Redirect URLs help.** It said "Must use https:// in production", which did not tell an admin that a local `http://localhost` callback needs the Native type. It now states the rule for each type: a Web application needs `https://` on a host other than localhost or another loopback address; a Native application may also use `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, on any port. - **Application Type help.** New: choose Native for a desktop or mobile app, or for an app in local development that redirects to `http://localhost`. -- **Unchanged.** The stored `sys_oauth_application.type` column still lists all four values, so a row written under an earlier provider line keeps its value. The `public` column is unchanged. +- **Unchanged.** A row written under an earlier provider line keeps its stored type, and the `public` column is unchanged; the stored column's options narrow to Web and Native in this release's `sys_oauth_application.type` entry. The zh-CN, ja-JP and es-ES translations carry the new help texts. diff --git a/.changeset/25053-oauth-application-type-options.md b/.changeset/25053-oauth-application-type-options.md index 8c1dae8d2a2..3b1b6a9c247 100644 --- a/.changeset/25053-oauth-application-type-options.md +++ b/.changeset/25053-oauth-application-type-options.md @@ -14,5 +14,5 @@ The column's only writer is the OAuth provider (`@better-auth/oauth-provider`), - **FROM:** the `type` field of `sys_oauth_application` declared and served the options `web`, `native`, `useragentbased` and `public`. A write that sent `useragentbased` or `public` was accepted and stored. - **TO:** it declares and serves `web` and `native`. A write that sends any other value, including `useragentbased` and `public`, is refused with `VALIDATION_FAILED` (`400` at an HTTP boundary) and an `invalid_option` finding on `type`. No door sends one: the provider sends only `web`, `native` or no type, and the generic data API answers `405` for every write to this table. -- **Unchanged:** the column stays optional and defaults to `web`, and a `null` type is a blank value. No stored row is rewritten. A row holding any type, `user-agent-based` included, still reads and lists through the data API and the provider. It takes every write that leaves `type` out, such as a secret rotation or enabling and disabling the application. The provider's own update path still refuses a row whose type is not `web` or `native`, as it did before. This entry supersedes the register-form entry's sentence that the stored column still lists all four values. +- **Unchanged:** the column stays optional and defaults to `web`, and a `null` type is a blank value. No stored row is rewritten. A row holding any type, `user-agent-based` included, still reads and lists through the data API and the provider. It takes every write that leaves `type` out, such as a secret rotation or enabling and disabling the application. The provider's own update path still refuses a row whose type is not `web` or `native`, as it did before. The register-form entry in this release refers to this entry for the stored column's options. - **The fix:** nothing to change for a client the provider registered. A row an earlier provider line stored as `user-agent-based` keeps that value. To edit it through the provider's update path, set `application_type` (`web` or `native`) in the same update.