Skip to content

Commit 9ea5afe

Browse files
fix(platform-objects): Rotate Client Secret result dialog states the immediate cut-off, not a grace period (#24870)
Fixes #24774 Clause-②: no (text only: no accept set, served shape or behaviour moves) Dispatched by the `domain:engine` seat 1 PM (`session_01JtSQQ3EWsbNPzyYMug1tXy`, claim 6289452299). Developer session: `session_01MrvwG9BMeMc4oCSG9FsV95`. ## What rotation does to the previous secret (read first) - `POST /api/v1/auth/oauth2/client/rotate-secret` is better-auth's own route. `plugin-auth` only lists it in `auth-route-ledger.ts`, and nothing in this repository wraps it. - In the pinned `@better-auth/oauth-provider` 1.7.3, `rotateClientSecretEndpoint` (`dist/authorize-9whjxVLJ.mjs`) generates a new secret and runs one `adapter.update` that **overwrites** the client's single `clientSecret` column. No previous secret is stored anywhere. - Client authentication, `validateClientCredentials` (`dist/utils-oOcdCCVw.mjs`), checks a presented secret against that one stored `client.clientSecret` through `verifyStoredClientSecret`. There is no second value to match. - **Result: an immediate cut-off.** The old secret is refused from the moment the rotation's update commits. No grace period exists. The code leaves no doubt, so no booted measurement was made. - No document promises a window. `content/docs/protocol/objectui/actions.mdx` already says "The current secret stops working immediately". So the confirmation was true and the result dialog was false. The behaviour itself is not in question here. ## Change - `sys-oauth-application.object.ts`, `rotate_client_secret`: - `description` (the confirmation) is unchanged. It was already true. - `resultDialog.description` now reads "Save the new secret now — it is shown only once. The previous secret has already stopped working: update every integration with the new secret." The shown-once sentence, the title, the acknowledgement and the `client_secret` field are kept. - A comment names the handler and why there is no grace period. - Translation bundles: - `en.objects.generated.ts` was regenerated with `pnpm i18n:extract`, and only that one line moved. - `zh-CN`, `ja-JP` and `es-ES` all carried a translated grace-period sentence. These are hand-written translated values, not byte copies, so they have no source-hash entry. The extractor keeps them in merge mode, and the staleness rule ("a path with no entry is LEGACY-TRUSTED and never reported stale") would never flag them. So I re-translated the value by hand in each locale. No keys were added or dropped. `check:i18n` is in sync. ## Pins - New `packages/platform-objects/src/identity/rotate-client-secret-copy.test.ts`: - in the source, the confirmation says the previous secret "will stop working immediately", the result dialog says it "has already stopped working", and neither text mentions a grace period, a window or an expiry; - the result dialog keeps its shown-once sentence, its acknowledgement and its secret field; - the `en` bundle mirrors the source; - in each of the four locales, neither text promises a grace period (`宽限`, `猶予`, `gracia` and similar words), and each translated result dialog is a real translation, not the English text. - Normally this repo does not pin copy. It does here because the card's defect is the wording itself, and the card asked for the pins. - **Ablation**, with the fix committed, through `scripts/ablation-replace.mjs`. Each mutation was confirmed on disk, and each restore was proven by blob hash against HEAD. There is no dist leg: the test imports `src` relatively. - Source leg: the old grace-period sentence put back into `resultDialog.description` (blob `d5ffc56c6b52` to `4f08d6f86141`). **2 failed, 2 passed (4).** The source-agreement test and the `en`-mirror test went red. Restored to `d5ffc56c6b52`. - Locale leg: the old zh-CN grace-period sentence put back (blob `65c34bca849d` to `0b3f3e584462`). **1 failed, 3 passed (4):** `zh-CN resultDialog.description promises a grace period`. Restored to `65c34bca849d`. - **Pin sweep:** I searched every package, `qa/`, `docs/qa/platform-checklist/` and `content/docs/` for the old and new strings and for `rotate_client_secret`. - `action-confirm-one-dialog.test.ts` pins the unchanged confirmation ("stop working immediately", "shown only once"). It stays green and does not move. - No other pin reads the result-dialog text. No `docs/qa/platform-checklist/**` item names this action. ## Verification record Base `abd5acdaf7`, merged with `origin/main` `9a06f0be0` before opening (the merge touched nothing in `platform-objects`). All results are on head `c41eb1949`. | Command | Result | |:---|:---| | `pnpm --filter @objectstack/platform-objects typecheck` | exit 0. The new test is in `tsconfig.test.json`'s program (`--listFiles`: 1 hit). | | `pnpm --filter @objectstack/platform-objects test` (full suite) | exit 0, 80 files, 1203 tests passed | | `pnpm --filter @objectstack/core test:repo` | exit 0, 6 files, 82 tests passed | | `pnpm exec turbo run build --concurrency=2` (whole workspace, docs excluded, after the merge) | exit 0, 76 of 76 tasks | | `vitest run` of `runtime`'s `src/action-execution-destructive.test.ts` (this action's consumer test), against the rebuilt `platform-objects` dist | exit 0, 63 passed | | `vitest run` of `cli`'s `test/platform-page-i18n-parity.test.ts` and `test/i18n-extract-action-description.test.ts` | exit 0, 34 passed | | `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 66 derived commands, each run with its exit code captured before any pipe | 66 of 66 exit 0. `--ran` (each line recorded as `COMMAND :: exit CODE`): "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3)" | On the first run, `check:i18n` and `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: no `dist/`). I built the whole workspace, and both then exited 0. ## Siblings This PR regenerates one line of the `platform-objects` `en` bundle and changes one value in each of the three translated bundles. Whichever of #24758, #24765 and this one lands second re-derives the bundles with `scripts/pm/os-regen-merge.sh`. ## Acceptance notes - The YAML example in `content/docs/protocol/objectui/actions.mdx` ("Create via API with secret reveal") gives `target: /api/v1/auth/sys-oauth-application/rotate-secret`. That is not the route the shipped action calls (`/api/v1/auth/oauth2/client/rotate-secret`). It is an illustrative docs example, and its own copy already says the cut-off is immediate. Noted, not filed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MrvwG9BMeMc4oCSG9FsV95 --- _Generated by [Claude Code](https://claude.ai/code/session_01MrvwG9BMeMc4oCSG9FsV95)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad8cf92 commit 9ea5afe

7 files changed

Lines changed: 101 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): Rotate Client Secret's result dialog states the immediate cut-off its confirmation states
6+
7+
Clause-②: no (text only: no accept set, served shape or behaviour moves)
8+
9+
- **Before.** On `sys_oauth_application`, the Rotate Client Secret confirmation said the previous secret "will stop working immediately". The result dialog then told the admin to "update every integration before the previous secret's grace period ends". Only the first is true.
10+
- **What rotation does.** `POST /api/v1/auth/oauth2/client/rotate-secret` is better-auth's own route. It overwrites the client's one stored secret, and client authentication checks a presented secret against that value only. No previous secret is kept, so there is no grace period: the old secret is refused from the moment the rotation completes.
11+
- **Now.** The result dialog reads "Save the new secret now — it is shown only once. The previous secret has already stopped working: update every integration with the new secret." The zh-CN, ja-JP and es-ES translations of that sentence are updated to match. The confirmation, the dialog's title, acknowledgement and secret field are unchanged.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1557,7 +1557,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
15571557
description: "Rotate this OAuth client's secret? The previous secret will stop working immediately and any integrations using it will break until they are updated with the new secret. The new secret is shown only once.",
15581558
resultDialog: {
15591559
title: "Client secret rotated",
1560-
description: "Save the new secret now — it is shown only once. Update every integration before the previous secret's grace period ends.",
1560+
description: "Save the new secret now — it is shown only once. The previous secret has already stopped working: update every integration with the new secret.",
15611561
acknowledge: "I have updated my integrations",
15621562
fields: {
15631563
client_secret: "New Client Secret"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1557,7 +1557,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
15571557
description: "¿Rotar el secreto de este cliente OAuth? El secreto anterior dejará de funcionar de inmediato y cualquier integración que lo utilice fallará hasta que se actualice con el nuevo secreto. El nuevo secreto se muestra una sola vez.",
15581558
resultDialog: {
15591559
title: "Secreto de cliente rotado",
1560-
description: "Guarde el nuevo secreto ahora: se muestra una sola vez. Actualice todas las integraciones antes de que termine el periodo de gracia del secreto anterior.",
1560+
description: "Guarde el nuevo secreto ahora: se muestra una sola vez. El secreto anterior ya ha dejado de funcionar; actualice todas las integraciones con el nuevo secreto.",
15611561
acknowledge: "He actualizado mis integraciones",
15621562
fields: {
15631563
client_secret: "Nuevo secreto de cliente"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1557,7 +1557,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
15571557
description: "このOAuthクライアントのシークレットをローテーションしますか?以前のシークレットは直ちに使用できなくなり、それを使用している連携は新しいシークレットに更新されるまで動作しなくなります。新しいシークレットは一度しか表示されません。",
15581558
resultDialog: {
15591559
title: "クライアントシークレットをローテーションしました",
1560-
description: "新しいシークレットを今すぐ保存してください。表示は一度きりです。旧シークレットの猶予期間が終わる前にすべての連携を更新してください。",
1560+
description: "新しいシークレットを今すぐ保存してください。表示は一度きりです。以前のシークレットはすでに使用できなくなっています。すべての連携を新しいシークレットに更新してください。",
15611561
acknowledge: "連携を更新しました",
15621562
fields: {
15631563
client_secret: "新しいクライアントシークレット"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1557,7 +1557,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
15571557
description: "确定要轮换此 OAuth 客户端的密钥吗?旧密钥将立即失效,任何使用它的集成都将中断,直到更新为新密钥为止。新密钥仅显示一次。",
15581558
resultDialog: {
15591559
title: "Client Secret 已轮换",
1560-
description: "请立即保存新密钥——它只显示一次。请在旧密钥宽限期结束前更新所有集成。",
1560+
description: "请立即保存新密钥——它只显示一次。旧密钥已经失效:请用新密钥更新所有集成。",
15611561
acknowledge: "我已更新所有集成",
15621562
fields: {
15631563
client_secret: "新的 Client Secret"
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+
* `sys_oauth_application.rotate_client_secret` — the confirmation and the
5+
* result dialog say the same thing about the previous secret.
6+
*
7+
* The two texts once disagreed: the confirmation said the previous secret
8+
* "will stop working immediately", the result dialog told the admin to update
9+
* integrations "before the previous secret's grace period ends". Only the
10+
* first is true. The route is better-auth's own
11+
* (`@better-auth/oauth-provider` 1.7.3, `rotateClientSecretEndpoint`): it
12+
* overwrites the client's single stored `clientSecret`, and
13+
* `validateClientCredentials` checks a presented secret against that one value.
14+
* No previous secret is kept, so no grace period exists.
15+
*
16+
* The wording IS the contract here — it is what an admin acts on before
17+
* breaking every integration — so it is pinned in the source and in all four
18+
* shipped locales. The translated leaves are hand-written: `pnpm i18n:extract`
19+
* keeps them in merge mode and the source-hash table holds no entry for them
20+
* (they are real translations, not byte copies), so a stale translation of an
21+
* edited source is not flagged anywhere else.
22+
*/
23+
24+
import { describe, expect, it } from 'vitest';
25+
import { SysOauthApplication } from './index.js';
26+
import { enObjects } from '../apps/translations/en.objects.generated.js';
27+
import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js';
28+
import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js';
29+
import { esESObjects } from '../apps/translations/es-ES.objects.generated.js';
30+
31+
const rotate = (SysOauthApplication.actions ?? []).find((a) => a.name === 'rotate_client_secret');
32+
const node = (bundle: any) => bundle?.sys_oauth_application?._actions?.rotate_client_secret;
33+
34+
/** Any promise of a window during which the previous secret still works. */
35+
const GRACE_TERMS: readonly (readonly [string, any, RegExp])[] = [
36+
['en', enObjects, /grace|window|expire/i],
37+
['zh-CN', zhCNObjects, /宽限|过渡期|有效期/],
38+
['ja-JP', jaJPObjects, /猶予|移行期間|有効期限/],
39+
['es-ES', esESObjects, /gracia|transición|caduque|expire/i],
40+
];
41+
42+
describe('rotate_client_secret — both texts state the immediate cut-off', () => {
43+
it('the source confirmation and result dialog both say the previous secret stops at once', () => {
44+
expect(rotate, 'rotate_client_secret is declared').toBeDefined();
45+
const confirm = rotate?.description ?? '';
46+
const result = rotate?.resultDialog?.description ?? '';
47+
expect(confirm).toMatch(/previous secret will stop working immediately/);
48+
expect(result).toMatch(/previous secret has already stopped working/);
49+
for (const [label, text] of [['description', confirm], ['resultDialog.description', result]] as const) {
50+
expect(text, `${label} promises a grace period the route does not keep`).not.toMatch(/grace|window|expire/i);
51+
}
52+
});
53+
54+
it('the result dialog keeps its shown-once warning, acknowledgement and secret field', () => {
55+
expect(rotate?.resultDialog?.description).toMatch(/^Save the new secret now — it is shown only once\./);
56+
expect(rotate?.resultDialog?.acknowledge).toBe('I have updated my integrations');
57+
expect(rotate?.resultDialog?.fields?.map((f) => [f.path, f.format])).toEqual([['client_secret', 'secret']]);
58+
});
59+
60+
it('the English bundle mirrors the source', () => {
61+
expect(node(enObjects)?.description).toBe(rotate?.description);
62+
expect(node(enObjects)?.resultDialog?.description).toBe(rotate?.resultDialog?.description);
63+
});
64+
65+
it('no locale promises a grace period in either text, and each translates the result dialog', () => {
66+
for (const [locale, bundle, grace] of GRACE_TERMS) {
67+
const n = node(bundle);
68+
expect(n?.description, `${locale} description`).toBeTruthy();
69+
expect(n?.resultDialog?.description, `${locale} resultDialog.description`).toBeTruthy();
70+
expect(n.description, `${locale} description promises a grace period`).not.toMatch(grace);
71+
expect(n.resultDialog.description, `${locale} resultDialog.description promises a grace period`).not.toMatch(grace);
72+
if (locale !== 'en') {
73+
expect(n.resultDialog.description, `${locale} resultDialog.description is the English source`)
74+
.not.toBe(node(enObjects)?.resultDialog?.description);
75+
}
76+
}
77+
});
78+
});

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,21 @@ export const SysOauthApplication = ObjectSchema.create({
183183
// then the `resultDialog` that reveals the new secret AFTER the rotation.
184184
// The result dialog is not part of the #7278 defect — it is a post-run
185185
// reveal for output shown only once, not a second pre-run decision.
186+
//
187+
// Both texts state the same cut-off, because that is what the route does:
188+
// better-auth's `rotateClientSecretEndpoint` (@better-auth/oauth-provider
189+
// 1.7.3) overwrites the client's single stored `clientSecret`, and
190+
// `validateClientCredentials` checks a presented secret against that one
191+
// value only. No previous secret is kept, so there is no grace period —
192+
// the old secret is refused from the moment the rotation commits.
186193
description: 'Rotate this OAuth client\'s secret? The previous secret will stop working immediately and any integrations using it will break until they are updated with the new secret. The new secret is shown only once.',
187194
refreshAfter: true,
188195
params: [
189196
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
190197
],
191198
resultDialog: {
192199
title: 'Client secret rotated',
193-
description: 'Save the new secret now — it is shown only once. Update every integration before the previous secret\'s grace period ends.',
200+
description: 'Save the new secret now — it is shown only once. The previous secret has already stopped working: update every integration with the new secret.',
194201
acknowledge: 'I have updated my integrations',
195202
fields: [
196203
{ path: 'client_secret', label: 'New Client Secret', format: 'secret' },

0 commit comments

Comments
 (0)