Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .changeset/24773-oauth-form-types.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/platform-objects': patch
---
Expand All @@ -10,6 +10,6 @@

- **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.
18 changes: 18 additions & 0 deletions .changeset/25053-oauth-application-type-options.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. The one definition that changes is the option list of one field on `sys_oauth_application`, a platform object this package ships and owns, and what narrows is the set of values a write to that object's rows may send. Those rows are OAuth client registrations the OAuth provider writes, not metadata, and no ADR-0087 conversion reaches a stored data row. No writer sends either retired value, no stored row is rewritten, and a row holding any value reads and takes every write that leaves `type` out. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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. 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.
Original file line number Diff line number Diff line change
Expand Up @@ -1389,9 +1389,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Client Type",
options: {
web: "web",
native: "native",
useragentbased: "user-agent-based",
public: "public"
native: "native"
}
},
public: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1389,9 +1389,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Tipo de cliente",
options: {
web: "Web",
native: "Nativa",
useragentbased: "Basada en agente de usuario",
public: "Público"
native: "Nativa"
}
},
public: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1389,9 +1389,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "クライアントタイプ",
options: {
web: "Web",
native: "ネイティブ",
useragentbased: "ユーザーエージェントベース",
public: "公開"
native: "ネイティブ"
}
},
public: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1389,9 +1389,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "客户端类型",
options: {
web: "Web 应用",
native: "原生应用",
useragentbased: "基于用户代理",
public: "公开"
native: "原生应用"
}
},
public: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, { type?: string; options?: unknown[] }>).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<string, { type?: string }>).public;
expect(column?.type).toBe('boolean');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
{
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Column>;
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');
});
});
Loading
Loading