Skip to content

Commit ba244f1

Browse files
fix(platform-objects)!: sys_oauth_application.type declares only web and native, the two application types its writer produces (#25099)
Fixes #25053 Clause-②: no (narrowing): the declared option set that `/meta` serves and the write path judges loses `useragentbased` and `public`, which no writer produces. It owes a `CONTRACT_REVIEW_TIER` review before the queue. `sys_oauth_application.type` now declares exactly the two application types its one writer produces, `web` and `native`, under ADR-0049 enforce-or-remove. No stored row is rewritten, and every door that answers today for a row holding any type answers the same after the change (M1). One BREAKING `minor` changeset for `@objectstack/platform-objects`. ## What changes `packages/platform-objects/src/identity/sys-oauth-application.object.ts`, the stored `type` column's declaration and two comments: | | Before (`a39389eb5`) | After | |:--|:--|:--| | declaration | `Field.select(['web', 'native', 'user-agent-based', 'public'], ...)` | `Field.select(['web', 'native'], ...)` | | served options (`GET /api/v1/meta/object/sys_oauth_application`) | `web`, `native`, `useragentbased` (label `user-agent-based`), `public` | `web`, `native` | | `required` / `defaultValue` | `false` / `web` | unchanged | - The values are spelled so that the string shorthand's hyphen stripping (#25013, still open, not touched here) cannot change them: neither has a hyphen, and the new pin asserts each option's value equals its label. - The register form's own `type` param already offers only `web` and `native` (half 2b of #24773). It is not changed. - The action's code comment said the stored column "is not narrowed". It now says the column declares the same two values. The field gains a comment stating the writer readings and the legacy-row behaviour below. - The four translation bundles drop the two option keys, regenerated by `node scripts/check-i18n-bundles.mjs --write --filter=platform-objects` (translated values for `web` and `native` kept by merge mode). ## Measured first ### M1: every door, before and after, over rows holding each type Real composition: `bootShowcase` (the dogfood harness, `OS_OIDC_PROVIDER_ENABLED=true`), signed in as the seeded platform admin. Six rows were seeded at driver level, owned by the admin, holding `type` `web`, `native`, `user-agent-based`, `null`, `useragentbased` and `public`. The probe was a throwaway file in `packages/qa/dogfood/test`, run once before the change (closure built at `a39389eb5`) and once after (`platform-objects` rebuilt), and is not in this PR. | Door | Before | After | |:--|:--|:--| | `GET /api/v1/meta/object/sys_oauth_application`, the `type` options | 4 options (above) | `web`, `native` | | `GET /api/v1/data/sys_oauth_application` (list) | 200; all six rows listed, each with its stored type | identical | | `GET /api/v1/data/sys_oauth_application/:id` | 200 for all six, stored type returned | identical | | `GET /api/v1/auth/oauth2/get-client` | 200 for all six; `application_type` is the stored value (absent for `null`) | identical | | `POST /api/v1/data/sys_oauth_application`, `PATCH .../:id`, `POST .../import` | 405 for every value | identical | | `POST /api/v1/auth/oauth2/update-client` (rename only) | `web`, `native`, `null`: 200. `user-agent-based`, `useragentbased`, `public`: 400 `invalid_client_metadata` from the provider, row unchanged | identical | | `PATCH /api/v1/auth/admin/oauth2/update-client` | 404 for all (not mounted at this boot) | identical | | `POST /api/v1/auth/admin/oauth2/toggle-disabled` | 200 for all six; `disabled` set, type unchanged | identical | | `POST /api/v1/auth/oauth2/client/rotate-secret` | 200 for all six; secret changed, type unchanged | identical | | engine insert / update SENDING `type` (system context, as plugin-auth's adapter writes) | `web`, `native`, `null`, `useragentbased`, `public` accepted; `user-agent-based` refused `VALIDATION_FAILED` / `invalid_option` | `web`, `native`, `null` accepted; `user-agent-based`, `useragentbased`, `public` refused `VALIDATION_FAILED` / `invalid_option` | | engine update of another field on each row | accepted for all six, type unchanged | identical | So the only movements are the served option list and the engine's verdict on a write that sends `useragentbased` or `public`. No door sends either value: the provider sends `web`, `native` or no type, and the generic data API answers 405. H4 holds, so this is `done`, not `needs_decision`: no door that works today with such a row refuses it after, and no mapping of `user-agent-based` is needed. ### M2: the writers (H1) Re-measured on `origin/main` from source and the installed dist: - `@better-auth/oauth-provider` 1.7.3 (`packages/plugins/plugin-auth/package.json`, exact pin). Every creation path goes through `persistOAuthClientRegistration` and `checkOAuthClient`, which refuses any `application_type` other than `web` / `native` (`dist/authorize-9whjxVLJ.mjs:1770`). The default is `web`, or `null` for a client-metadata-document registration that names none (`:1956`). Both update paths declare `application_type` as `z.enum(["web", "native"])` (`:2885`, `:2923`) and re-run `checkOAuthClient` over the stored client merged with the update (`:2420`), which is the 400 in M1. - The mapping: `applicationType: 'type'` in `auth-schema-config.ts:489`. The generic data API: `apiMethods: ['get', 'list']` (`sys-oauth-application.object.ts:657`), measured as 405 above. - The client-metadata-document seam (`registerClientMetadataDocument`) is exported only from `@better-auth/oauth-provider/internal`, and nothing in `plugin-auth/src` imports it (`grep -r` for the function and the subpath: 0 hits), so the `null` writer is not reachable in this composition (see Acceptance notes). ### M3: `null` and `required` (H3) `type` is `required: false` today (M1's `/meta` reading) and stays so. A `null` row reads and lists (M1), and an engine update that sends `null` is accepted and stores `null`, before and after. The new pin asserts `required: false`. ### M4: the console Read from objectui at the pinned `.objectui-sha` `cf153eb2e`: `SelectCellRenderer` (`packages/fields/src/index.tsx`) renders a value that matches no option as a humanized label of the raw value, and an empty value as the empty placeholder. A `user-agent-based` row matched no option before (the option value was `useragentbased`) and matches none after, so it renders the same. Only a `useragentbased` or `public` row, which no writer produces, now renders humanized instead of with an option label. The register form carries its own options (unchanged), and the record has no edit door (405). ## Tests - `packages/platform-objects/src/identity/sys-oauth-application.type-options.test.ts` (new, 5 tests): the options are exactly `web` and `native`, each value equal to its label; `required: false` and default `web`; the register form offers the same values; all four bundles translate exactly those two; the `public` column is kept. - `packages/platform-objects/src/identity/oauth-register-form-types.test.ts`: half 2b's describe that pinned the four options is removed (its header now points at the new file). - `packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts` (new, 39 tests), on a real ObjectQL engine over SQLite with the auth manifest's objects and real better-auth with the OIDC provider: - a row holding each of `web`, `native`, `null`, `user-agent-based`, `useragentbased`, `public` reads and lists with its stored value, and the provider's `getOAuthClient` returns it; - the bridge's `disabled` write and the provider's `rotateClientSecret` are accepted on every such row, the type unchanged; - the provider's `updateOAuthClient` refuses a rename that leaves an undeclared type in place (`invalid_client_metadata`, row unchanged), and accepts one that also sets `application_type: 'native'`, storing `native` (the remedy the changeset names); - an insert or update SENDING `user-agent-based`, `useragentbased` or `public` is refused: `resolveThrownHttpError` gives `status` 400 and `code` `VALIDATION_FAILED`, `details.fields` holds one `invalid_option` on `type`, nothing is written; `web`, `native` and `null` are accepted. - These pins live in `plugin-auth` rather than beside the `platform-objects` identity tests because `platform-objects` cannot import `@objectstack/objectql`, which depends on it (`plugin.test.ts` records the cycle). The new file imports `platform-objects` through its `dist`, a pair already in `KNOWN_UNALIASED_TEST_IMPORTS`. Results (os-verify-lock `VERDICT command-exit 0` on each): - `platform-objects` at `2d158a9dd`: `Test Files 84 passed (84)`, `Tests 1242 passed (1242)`. Typecheck exit 0, `check:test-typecheck: OK`, 2 pinned signatures held; the new test is in the `tsconfig.test.json` program (`--listFilesOnly`: 1 hit). - `plugin-auth` at `2d158a9dd`: the full suite `Test Files 178 passed (178)`, `Tests 3415 passed | 10 skipped (3425)`. Typecheck exit 0, `check:test-typecheck: OK`, 23 pinned signatures held; the new file is in the program (1 hit). - After merging `origin/main` `4feccc268` (head `17458f50d`): the ablation's control legs below re-ran both new files green (5/5, 39/39), and `check:i18n` stayed in sync. ## Ablation (one-shot, no permanent file) One leg restored the retired options: `scripts/ablation-replace.mjs --hold` replaced the declaration with `Field.select(['web', 'native', 'user-agent-based', 'public'], ...)` (anchor 1 to 0, blob `2f185ac16441` to `1af41cbfc3ae`), inside a runner with its own EXIT/INT/TERM restore trap. Predicted before the run: leg 1 turns 2 of 5 red, leg 2 turns 4 of 39 red. - **Leg 1, source:** `sys-oauth-application.type-options.test.ts`: `Tests 2 failed | 3 passed (5)`. Red: the option set (`expected [ 'web', 'native', …(2) ] to deeply equal [ 'web', 'native' ]`) and the form parity. Green as predicted: the default, the bundles (not regenerated), the `public` column. - **Leg 2, dist:** `platform-objects` rebuilt; `ablation-dist-preflight.mjs` found the marker in 4 built files (its dist reading passed; its tree reading refused because the dist spelling uses double quotes and no `--source-marker` was passed, exit 3). Then `oauth-application-type-column.test.ts`: `Tests 4 failed | 35 passed (39)`. Red: insert and update with `useragentbased` and with `public`, accepted again. `user-agent-based` stayed refused, because the shorthand still strips it. - **Restore:** blob `2f185ac16441` equals `HEAD`, `git diff HEAD` empty; rebuilt; the preflight `--absent` found the marker in none of 66 built files and the tree clean; both files green (5/5, 39/39); `git status --porcelain` empty. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the final head `17458f50d` derived 69 commands (9 paths, 467 changed lines, under the 3000 threshold). Each ran after a whole-workspace build (`turbo run build --filter=!@objectstack/docs`, 76 tasks), as `eval cmd > log 2>&1; status=$?`, recorded byte for byte with `:: exit N`. - All 69 exit 0. `--ran`: `Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.` - Verdicts among them: `check-adr-0087-registration --base origin/main`: `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`, read as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`. `check-changeset-no-major`: no `major` bump. `check-empty-changeset`: no merge-base changeset modified or deleted at `17458f50d`; at `a72b7acf8` it exits 1 by design, naming only `.changeset/24773-oauth-form-types.md` (the deliberate correction above). `check:i18n`: all 10 packages in sync. Also green: `check:cross-package-test-inputs`, `check:dual-build-cjs-loads`, `check:nul-bytes`, `check:issue-citations`, `check:doc-authoring`, `check:test-source-alias`, `check:engine-double-contract`, `check:type-check-debt`. - The five roster families whose roster sits under one of these paths, run besides: `check-changeset-fixed`, `check:auth-mount-ledger`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, all exit 0. - Not run locally: `test:repo` (CI's `Test Core`); the type-check lanes and CI-only jobs the derivation lists outside the 69; `check:dev-prereqs` and `check:skill-frame-freshness` (no `packages/spec` build input or frame file touched); `check-platform-checklist --changed-from` (no exported symbol renamed or removed). Patch round 1 (`a72b7acf8`, two `.changeset` lines only): the same 69 commands re-derived and re-run after a workspace build; 68 exit 0 and `check-empty-changeset --base origin/main` exits 1 as above. `--ran`: `Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.` ## Dogfood The `Clause-②` line carries `(narrowing)`, so the full battery ran once on the final head `17458f50d`, as CI spells it: the closure built (`turbo run build --filter=!@objectstack/docs`, 76 tasks, a superset of `@objectstack/dogfood^...`), then `pnpm --filter @objectstack/dogfood test` under the lock, with no `OS_TEST_SHARD` and no path. - `os-verify-lock: VERDICT command-exit 0 · held the lock 1130s (18m50s)` (shared-box seconds). - `Test Files 286 passed | 3 skipped (289)`, `Tests 2387 passed | 18 skipped (2405)`. No failure. - The derivation notes that `Dogfood Regression Gate` does not run on a PR or in the merge queue, so this is the only pre-merge reading of it. The head merges `origin/main` `4feccc268`. `main` has since moved to `1862d8d13` (two commits in `packages/spec`, `driver-turso` and `service-datasource`), none of which touches a path of this diff, so the head was not re-merged before opening. Patch round 1 re-ran the full battery on `a72b7acf8`: `os-verify-lock: VERDICT command-exit 0 · held the lock 1284s (21m24s)`, `Test Files 286 passed | 3 skipped (289)`, `Tests 2387 passed | 18 skipped (2405)`. The head still merges `origin/main` `4feccc268`; `main` is at `8f59304dd`, with no path of this diff moved. ## Acceptance notes - **A pending changeset sentence this change made false, corrected here (patch round 1, `a72b7acf8`).** `.changeset/24773-oauth-form-types.md` (half 2b, still pending) said: "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." That bullet now reads: "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." No other line of that note changed. This PR's own changeset now says "The register-form entry in this release refers to this entry for the stored column's options." where it said it superseded that sentence. `Check Changeset` is red by design: `check-empty-changeset`'s DELIBERATE CORRECTION class, naming only `.changeset/24773-oauth-form-types.md`, for the seat's same-head contract review. - **A `null` the provider sends on insert is stored as `web`.** Measured in M1: an engine insert with `type: null` stores `web` (the declared default fills the blank), while an update that sends `null` stores `null`. The provider sends `null` only for a client-metadata-document registration, whose seam this composition does not wire. Unchanged by this PR; noted, not filed. - **Legacy rows and the provider's update path.** A row holding `user-agent-based` (or any type other than `web` / `native`) is refused by the provider's own update path unless the update also sets `application_type`, before and after this change (M1, and pinned). No deployment's data is in reach to count such rows. - #25013 (the shorthand that strips hyphens) remains open; this PR only spells its values so the shorthand cannot change them. --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69712d5 commit ba244f1

10 files changed

Lines changed: 428 additions & 41 deletions

‎.changeset/24773-oauth-form-types.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ Setup › OAuth Applications › Register offered four application types: Web, N
1010

1111
- **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.
1212
- **Application Type help.** New: choose Native for a desktop or mobile app, or for an app in local development that redirects to `http://localhost`.
13-
- **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.
13+
- **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.
1414

1515
The zh-CN, ja-JP and es-ES translations carry the new help texts.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
fix(platform-objects)!: `sys_oauth_application.type` declares only `web` and `native`, the two application types its writer produces
6+
7+
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.
8+
9+
<!-- 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). -->
10+
11+
**BREAKING** (a platform object's declared option set narrows), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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.
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
- **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.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,9 +1389,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
13891389
label: "Client Type",
13901390
options: {
13911391
web: "web",
1392-
native: "native",
1393-
useragentbased: "user-agent-based",
1394-
public: "public"
1392+
native: "native"
13951393
}
13961394
},
13971395
public: {

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,9 +1389,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
13891389
label: "Tipo de cliente",
13901390
options: {
13911391
web: "Web",
1392-
native: "Nativa",
1393-
useragentbased: "Basada en agente de usuario",
1394-
public: "Público"
1392+
native: "Nativa"
13951393
}
13961394
},
13971395
public: {

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,9 +1389,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
13891389
label: "クライアントタイプ",
13901390
options: {
13911391
web: "Web",
1392-
native: "ネイティブ",
1393-
useragentbased: "ユーザーエージェントベース",
1394-
public: "公開"
1392+
native: "ネイティブ"
13951393
}
13961394
},
13971395
public: {

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,9 +1389,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
13891389
label: "客户端类型",
13901390
options: {
13911391
web: "Web 应用",
1392-
native: "原生应用",
1393-
useragentbased: "基于用户代理",
1394-
public: "公开"
1392+
native: "原生应用"
13951393
}
13961394
},
13971395
public: {

‎packages/platform-objects/src/identity/oauth-register-form-types.test.ts‎

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@
1414
* has neither, so the route registers both as `web`. The form stops offering
1515
* them; the route still accepts them, so this is the form only.
1616
*
17-
* The stored `type` column is a different surface and keeps all four options:
18-
* its narrowing waits for the major version that refuses the two values at the
19-
* route, so the pin below holds it as it is on purpose (including a declared
20-
* value that differs from the one earlier provider lines wrote — see there).
17+
* The stored `type` column is a different surface. It declares the same two
18+
* values since #25053, and `sys-oauth-application.type-options.test.ts` pins it.
2119
*
2220
* The help text is not pinned word for word. What is pinned is what it names:
2321
* 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
105103
}
106104
});
107105
});
108-
109-
describe('[#24773] the stored columns are not narrowed in this major', () => {
110-
it('the stored `type` select still declares all four options, exactly as it is served today', () => {
111-
const stored = (SysOauthApplication.fields as Record<string, { type?: string; options?: unknown[] }>).type;
112-
expect(stored?.type).toBe('select');
113-
const options = (stored?.options ?? []) as { label?: unknown }[];
114-
expect(options.map((o) => o?.label)).toEqual(['web', 'native', 'user-agent-based', 'public']);
115-
// Measured, not intended: the column is declared through `Field.select`'s
116-
// string-array shorthand, which snake-cases each value and drops the
117-
// hyphens, so the third option's VALUE is `useragentbased`, not the
118-
// `user-agent-based` an earlier provider line wrote. Recorded for the
119-
// major version that narrows this column; this PR changes neither.
120-
expect(optionValues(stored?.options)).toEqual(['web', 'native', 'useragentbased', 'public']);
121-
});
122-
123-
it('the stored `public` column is still declared', () => {
124-
const column = (SysOauthApplication.fields as Record<string, { type?: string }>).public;
125-
expect(column?.type).toBe('boolean');
126-
});
127-
});

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,9 @@ export const SysOauthApplication = ObjectSchema.create({
156156
// 400 before the provider runs; no type (absent or null) registers `web`.
157157
// Nothing on the server reads these options: the generic action door
158158
// refuses a `type: 'api'` action before its param contract runs. The
159-
// stored `type` column below is not narrowed: 1.7.3 writes only `web` or
160-
// `native` (null for a client-metadata-document client), an earlier
161-
// provider line could write `user-agent-based`, and no in-tree reader branches on it.
159+
// stored `type` column below declares the same two values [#25053]:
160+
// 1.7.3 writes only `web` or `native` (null for a client-metadata-document
161+
// client), and a row an earlier provider line stored as `user-agent-based` still reads.
162162
params: [
163163
{ name: 'name', label: 'Application Name', type: 'text', required: true },
164164
{
@@ -446,7 +446,20 @@ export const SysOauthApplication = ObjectSchema.create({
446446
// (#3002). The column keeps its name and its data; the rename is absorbed
447447
// by the `applicationType: 'type'` mapping in plugin-auth's
448448
// `auth-schema-config.ts`.
449-
type: Field.select(['web', 'native', 'user-agent-based', 'public'], {
449+
//
450+
// [#25053, ADR-0049 enforce-or-remove] The options are exactly the values
451+
// the column's one writer produces. `@better-auth/oauth-provider` 1.7.3
452+
// writes `application_type` `web` or `native` on every creation and
453+
// update path, and the generic data API cannot write this table
454+
// (`apiMethods` is `get` / `list`). It can also write `null` (a
455+
// client-metadata-document client that names no type), so the column
456+
// stays optional. The former `useragentbased` and `public` options had no
457+
// writer. Rows are not rewritten: a row an earlier provider line stored as
458+
// `user-agent-based` (never a declared value, because the string shorthand
459+
// strips hyphens) still reads and lists, and takes every write that leaves
460+
// `type` out, because the record validator judges only the keys a write
461+
// sends. Neither value has a hyphen, so the shorthand stores each as written.
462+
type: Field.select(['web', 'native'], {
450463
label: 'Client Type',
451464
required: false,
452465
defaultValue: 'web',
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25053] `sys_oauth_application.type` declares exactly the application types
5+
* its one writer produces: `web` and `native`.
6+
*
7+
* The column's only writer is `@better-auth/oauth-provider`, whose
8+
* `applicationType` plugin-auth maps onto `type`. 1.7.3 writes
9+
* `application_type` `web` or `native` on every creation and update path, and
10+
* the generic data API cannot write this table (`apiMethods` is `get` /
11+
* `list`). The column used to declare four options through the string
12+
* shorthand, which strips hyphens, so it served `web`, `native`,
13+
* `useragentbased` and `public`. No writer produces the last two (ADR-0049
14+
* enforce-or-remove), and the hyphenated `user-agent-based` an earlier
15+
* provider line wrote was never one of them.
16+
*
17+
* What the narrowed declaration does to stored rows is pinned where a real
18+
* engine runs, in plugin-auth's `oauth-application-type-column.test.ts`. This
19+
* package cannot import `@objectstack/objectql`, which depends on it.
20+
*/
21+
22+
import { describe, expect, it } from 'vitest';
23+
import { SysOauthApplication } from './index.js';
24+
import { enObjects } from '../apps/translations/en.objects.generated.js';
25+
import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js';
26+
import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js';
27+
import { esESObjects } from '../apps/translations/es-ES.objects.generated.js';
28+
29+
type Option = { label?: unknown; value?: unknown };
30+
type Column = { type?: string; required?: boolean; defaultValue?: unknown; options?: Option[] };
31+
32+
const fields = SysOauthApplication.fields as Record<string, Column>;
33+
const column = fields.type;
34+
const values = (options: unknown[] | undefined): unknown[] =>
35+
(options ?? []).map((o) => (typeof o === 'string' ? o : (o as Option)?.value));
36+
37+
const LOCALES: readonly (readonly [string, any])[] = [
38+
['en', enObjects],
39+
['zh-CN', zhCNObjects],
40+
['ja-JP', jaJPObjects],
41+
['es-ES', esESObjects],
42+
];
43+
44+
describe('[#25053] sys_oauth_application.type — the declared options are what the provider writes', () => {
45+
it('declares exactly web and native, each value as written', () => {
46+
expect(column?.type).toBe('select');
47+
expect(values(column?.options)).toEqual(['web', 'native']);
48+
// The string shorthand keeps the written string as the label and derives
49+
// the value from it; equal pairs say the derivation changed nothing.
50+
for (const option of column?.options ?? []) {
51+
expect(option.value, `option ${String(option.label)}`).toBe(option.label);
52+
}
53+
});
54+
55+
it('stays optional, with web as its default', () => {
56+
// The provider can write null (a client-metadata-document client that
57+
// names no type): a required column would make that a missing value.
58+
expect(column?.required).toBe(false);
59+
expect(column?.defaultValue).toBe('web');
60+
});
61+
62+
it('declares the same values the register form offers', () => {
63+
const register = (SysOauthApplication.actions ?? []).find((a) => a.name === 'create_oauth_application');
64+
const param = ((register?.params ?? []) as { name?: string; options?: unknown[] }[]).find((p) => p.name === 'type');
65+
expect(values(param?.options)).toEqual(values(column?.options));
66+
});
67+
68+
it('every shipped locale translates exactly those two options', () => {
69+
for (const [locale, bundle] of LOCALES) {
70+
const options = bundle?.sys_oauth_application?.fields?.type?.options;
71+
expect(Object.keys(options ?? {}), `${locale} type options`).toEqual(['web', 'native']);
72+
}
73+
});
74+
75+
it('keeps the `public` column', () => {
76+
expect(fields.public?.type).toBe('boolean');
77+
});
78+
});

0 commit comments

Comments
 (0)