From 567750befa1c4409a840cb073823e3f25f63b3b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:56:59 +0000 Subject: [PATCH 1/6] fix(platform-objects)!: retire the sys_account link_social action The action navigated to a GET of the POST-only social sign-in route with a fixed provider list, so it was dead on every boot. It is removed under ADR-0049 enforce-or-remove; unlink_account stays. Comments, the spec docblock, the docs example and the checklist item stop citing it, the echo-decision ledgers drop its seven provider rows, and the unlink confirm question no longer points at a re-link affordance in account settings. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- content/docs/protocol/objectui/actions.mdx | 11 +++-- .../areas/identity-auth.json | 33 ++++++--------- .../platform-objects/src/apps/account.app.ts | 1 - .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../objects-es-es-echo-decisions.test.ts | 23 ++++------ .../objects-ja-jp-echo-decisions.test.ts | 23 ++++------ .../objects-zh-cn-echo-decisions.test.ts | 23 ++++------ .../translations/zh-CN.objects.generated.ts | 2 +- .../src/identity/sys-account.object.ts | 42 ++++--------------- .../src/identity/sys-member.object.ts | 4 +- packages/spec/src/ui/action.zod.ts | 2 - 12 files changed, 61 insertions(+), 107 deletions(-) diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 0680a64e258..a3184f31de4 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -94,10 +94,15 @@ label: View Dashboard type: url target: /_console/dashboards/sales_overview -name: link_social -label: Connect Account +name: open_in_maps +label: Open in Maps type: url -target: '/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account' +target: 'https://www.google.com/maps/search/?api=1&query=${param.address}' +params: + - name: address + label: Address + type: text + required: true ``` For a **static** url, `openIn: 'new-tab'` opens `target` in a new tab and `openIn: 'self'` navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an **async handler that redirects**, set `opensInNewTab: true` instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); `newTabUrl` provides a zero-roundtrip new-tab target template supporting the `{recordId}` placeholder. diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index f0734d3cb74..f89c7d41802 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -1100,36 +1100,29 @@ }, { "id": "identity-auth.linked-accounts-social", - "title": "Linked accounts: link a social/OIDC identity through the redirect round-trip → a sys_account row appears in mine-view; unlink removes it; provider-less boot degrades honestly", + "title": "Linked accounts: the caller's social/OIDC links appear in mine-view; unlink removes one; the surface offers no link affordance, so a provider-less boot degrades honestly", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", - "personas": ["a signed-in user linking a second identity", "the same user after unlinking"], + "personas": ["a signed-in user holding a second (social/OIDC) identity link", "the same user after unlinking"], "fixtures": { "app": "showcase", "requires": [ - "at least one social/OIDC provider configured (socialProviders or oidcProviders) so link_social has a provider to dance with — stock showcase ships none, so this item is blocked(fixture) until a provider is configured", + "at least one social/OIDC provider configured (socialProviders or oidcProviders) and the caller holding a link to it, made OUTSIDE the console — sys_account declares no link action (link_social retired under ADR-0049); a social sign-in, or the signed-in POST /api/v1/auth/link-social (SDK auth.accounts.linkSocial), makes one. Stock showcase ships no provider, so this item is blocked(fixture) until one is configured", "the Account 'Identity Links' surface (nav_accounts → sys_account) reachable" ] }, - "blocked": { "by": "fixture", "ref": "no stock showcase social/OIDC IdP — link_social needs a configured provider, same fixture class as identity-auth.sso-enforced-first-paint / oauth-app-consent-loop" }, + "blocked": { "by": "fixture", "ref": "no stock showcase social/OIDC IdP — the identity link this item unlinks needs a configured provider, same fixture class as identity-auth.sso-enforced-first-paint / oauth-app-consent-loop" }, "steps": [ "as a signed-in user, open Account → Identity Links (sys_account `mine` view, filter user_id={current_user_id}); screenshot the initial link set", - "invoke link_social for a configured provider: the action is type:'url' — GET /api/v1/auth/sign-in/social?provider=

&callbackURL=/_console/apps/account/sys_account (full-page navigation, NOT XHR, so the OAuth 302 dance and link cookie work); complete the provider round-trip and land back on the Identity Links view", - "confirm a sys_account row now exists for that provider in the caller's mine-view (provider_id = the provider, user_id = the caller) — (provider_id, account_id) is the whole account identity; there is no issuer to check, sys_account.issuer retired with better-auth 1.7.3 (packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts)", + "confirm the fixture's link is a sys_account row in the caller's mine-view (provider_id = the provider, user_id = the caller) — (provider_id, account_id) is the whole account identity; there is no issuer to check, sys_account.issuer retired with better-auth 1.7.3 (packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts)", "unlink it: the unlink_account action → POST /api/v1/auth/unlink-account with accountId = the sys_account ROW id (better-auth 1.7 keys on the row id); confirm the row is gone from mine-view", - "both-sides / degradation: on a boot with NO provider configured, confirm link_social degrades honestly — the affordance is absent or names the missing provider, rather than offering a link that dead-ends (open-edition honest-degradation posture)", + "both-sides / degradation: on a boot with NO provider configured, confirm the surface offers no link affordance — sys_account declares only unlink_account since link_social retired, so nothing offers a link that dead-ends (open-edition honest-degradation posture)", "confirm sys_account is read-only over the data API — a forged direct insert/delete is refused (apiMethods ['get','list'], writes 405)" ], "acceptance": [ - { - "clause": "linking a social identity creates a sys_account row in the caller's mine-view after the redirect round-trip completes", - "oracle": "api", - "verify": "GET /api/v1/data/sys_account (mine view) after the link shows a new row with provider_id = the provider and user_id = the caller; a row with no issuer is the healthy shape, never a FAIL — the column is retired", - "evidence": "the post-link mine-view read" - }, { "clause": "the link surface renders the caller's own links only (mine-view scoped) — screenshot-confirmed", "oracle": "screenshot", @@ -1143,9 +1136,9 @@ "evidence": "the unlink response + the follow-up read" }, { - "clause": "a provider-less boot degrades honestly: link_social is absent or names the missing provider — never a link affordance that dead-ends", + "clause": "a provider-less boot degrades honestly: the surface offers no link affordance — never one that dead-ends", "oracle": "screenshot", - "verify": "with no provider configured, the Identity Links surface shows no dead link action (or an explicit unavailable state)", + "verify": "with no provider configured, the Identity Links surface shows no link action at all (sys_account declares none; its one action, unlink_account, is a row action)", "evidence": "the provider-less screenshot" }, { @@ -1156,20 +1149,20 @@ } ], "negative": [ - "a link that appears in the UI but does not create a sys_account row (client-only) is a FAIL — the row is the durable identity link", "unlink that hides the row from the list but leaves the sys_account (so the provider still signs the user in) is a FAIL", "one user's identity links appearing in another's mine-view is an RLS FAIL", - "a provider-less boot offering a link_social action that navigates to a dead endpoint is a dishonest-degradation FAIL" + "a link affordance on the Identity Links surface that navigates to a dead endpoint is a dishonest-degradation FAIL — sys_account declares none" ], "traps": ["wrong-persona", "dispatcher-vs-hono-route", "hydration-race"], "source": [ - "packages/platform-objects/src/identity/sys-account.object.ts#user_id (link_social type:'url' → /api/v1/auth/sign-in/social?provider=&callbackURL=; unlink_account → /api/v1/auth/unlink-account accountId=row id; mine view user_id={current_user_id} vs all_links; provider options; apiMethods ['get','list'])", + "packages/platform-objects/src/identity/sys-account.object.ts#user_id (unlink_account → /api/v1/auth/unlink-account accountId=row id; no link action, link_social retired under ADR-0049; mine view user_id={current_user_id} vs all_links; apiMethods ['get','list'])", "packages/plugins/plugin-auth/src/auth-route-ledger.ts#AUTH_ROUTE_LEDGER (POST link-social=auth.accounts.linkSocial, GET list-accounts=auth.accounts.list, POST unlink-account=auth.accounts.unlink — re-pointed #18104: linkSocial is a CLIENT METHOD name, carried in this file only inside a dotted string value)", "packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_accounts (nav_accounts → 'Identity Links', objectName sys_account)" ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: social/OIDC account linking round-trip → sys_account mine-view row, unlink removal, provider-less honest degradation; blocked(fixture) pending a configured IdP (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-09-23", "change": "the 'issuer stamped' requirement is withdrawn from both the step and the first acceptance clause's verify: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, so nothing stamps it and the old verify failed a healthy system. The reason is kept — the step now says (provider_id, account_id) is the whole identity and names the retirement's migration entry in packages/spec — and the verify states that an issuer-less row is the expected shape", "ref": "#19217" } + { "revision": 2, "date": "2026-09-23", "change": "the 'issuer stamped' requirement is withdrawn from both the step and the first acceptance clause's verify: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, so nothing stamps it and the old verify failed a healthy system. The reason is kept — the step now says (provider_id, account_id) is the whole identity and names the retirement's migration entry in packages/spec — and the verify states that an issuer-less row is the expected shape", "ref": "#19217" }, + { "revision": 3, "date": "2026-10-05", "change": "the link half retires with the sys_account link_social action (ADR-0049 enforce-or-remove, the maintainer-ratified ruling on #21849): it navigated to a GET of the POST-only social sign-in route with a fixed provider list, dead on every boot. Removed the link step, the first acceptance clause (linking creates a row) and the client-only-link negative; the social/OIDC link the unlink half needs moves into the fixture, made outside the console (a social sign-in, or the signed-in POST /api/v1/auth/link-social = auth.accounts.linkSocial). The unlink, mine-view and read-only clauses stay; the provider-less degradation clause now reads against 'no link affordance', since sys_account declares none", "ref": "#21849" } ] }, { diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts index b705aa57b63..87334cef729 100644 --- a/packages/platform-objects/src/apps/account.app.ts +++ b/packages/platform-objects/src/apps/account.app.ts @@ -19,7 +19,6 @@ * make the experience equivalent to the old account SPA for supported * surfaces: * - `sys_oauth_application.create` — one-time client_secret reveal - * - `sys_account.link_social` — OAuth redirect URL * * The same objects also appear (admin-only) in `setup.app.ts`'s * Advanced group, gated by `manage_platform_settings`, for tenant-wide 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 d2a55d78fd1..95a2792c6d4 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 @@ -490,7 +490,7 @@ export const esESObjects: NonNullable = { }, unlink_account: { label: "Desvincular cuenta", - description: "¿Desvincular este vínculo de identidad? El usuario ya no podrá iniciar sesión con este proveedor hasta que lo vuelva a vincular desde la configuración de su cuenta.", + description: "¿Desvincular este vínculo de identidad? El usuario ya no podrá iniciar sesión con este proveedor hasta que lo vuelva a vincular.", successMessage: "Vínculo de identidad eliminado" } } 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 12466d2e907..80d2b143956 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 @@ -490,7 +490,7 @@ export const jaJPObjects: NonNullable = { }, unlink_account: { label: "連携解除", - description: "このID連携を解除しますか?ユーザーがアカウント設定から再度連携するまで、このプロバイダーでサインインできなくなります。", + description: "このID連携を解除しますか?ユーザーが再度連携するまで、このプロバイダーでサインインできなくなります。", successMessage: "アイデンティティ連携を解除しました" } } diff --git a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts index 7b1377fc36a..b696d5865e9 100644 --- a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts @@ -48,6 +48,11 @@ // table holds nothing BEYOND the echoes is not asserted, for the reason the // next section gives. // +// Since that walk, the seven `sys_account._actions.link_social` provider-brand +// rows (Google through Discord) left this ledger together with the action, +// retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer +// carries those leaves, so the ledger below holds 47 echoes. +// // ## What this file deliberately does NOT assert // // That no OTHER es-ES objects leaf reads its `en` source. A new field's label @@ -89,8 +94,6 @@ interface Decision { departure?: string; } -const BRAND = - 'A sign-in provider brand, shown as the name a user recognises on the link button. This bundle renders no brand name: the authored SSO empty state keeps Okta, Entra and Auth0 verbatim.'; const BARE_ID = 'The bare initialism ID. This bundle keeps ID verbatim inside every label that carries it (ID de usuario, ID de organización, ID de cliente); a bare en ID has no noun to render, and adding one would invent content.'; const JWKS = @@ -151,15 +154,7 @@ const DECISIONS: readonly Decision[] = [ reason: 'The team record page field zh-CN reproduced as MEMBER COUNT: an ordinary field label an admin reads, an unauthored fill.', }, - // ── Kept as written by design (54) ─────────────────────────────────────── - echo('sys_account._actions.link_social.params.provider.options.google', 'Google', BRAND), - echo('sys_account._actions.link_social.params.provider.options.github', 'GitHub', BRAND), - echo('sys_account._actions.link_social.params.provider.options.microsoft', 'Microsoft', BRAND), - echo('sys_account._actions.link_social.params.provider.options.apple', 'Apple', BRAND), - echo('sys_account._actions.link_social.params.provider.options.facebook', 'Facebook', BRAND), - echo('sys_account._actions.link_social.params.provider.options.gitlab', 'GitLab', BRAND), - echo('sys_account._actions.link_social.params.provider.options.discord', 'Discord', BRAND), - + // ── Kept as written by design (47) ─────────────────────────────────────── echo('sys_oauth_application.fields.jwks.label', 'JWKS', JWKS), echo('sys_oauth_application.fields.jwks_uri.label', 'JWKS URI', JWKS), echo('sys_oauth_application._actions.create_oauth_application.params.type.options.web', 'Web', WEB_CLIENT_TYPE), @@ -242,10 +237,10 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20493 es-ES — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 54 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(57); + it('is the size it claims: 3 pinned translations and 47 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(50); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(54); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(47); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts index 6aa17e0307b..303d7773d66 100644 --- a/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts @@ -45,6 +45,11 @@ // table holds nothing BEYOND the echoes is not asserted, for the reason the // next section gives. // +// Since that walk, the seven `sys_account._actions.link_social` provider-brand +// rows (Google through Discord) left this ledger together with the action, +// retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer +// carries those leaves, so the ledger below holds 36 echoes. +// // ## What this file deliberately does NOT assert // // That no OTHER ja-JP objects leaf reads its `en` source. A new field's label @@ -89,8 +94,6 @@ interface Decision { departure?: string; } -const BRAND = - 'A sign-in provider brand, shown as the name a user recognises on the link button. This bundle renders no brand name in kana: the authored SSO empty state keeps Okta, Entra and Auth0 verbatim.'; const BARE_ID = 'The bare initialism ID. This bundle keeps ID verbatim inside every label that carries it (ユーザー ID, 組織 ID, クライアント ID); a bare en ID has no noun to render, and adding one would invent content.'; const JWKS = @@ -143,15 +146,7 @@ const DECISIONS: readonly Decision[] = [ reason: 'The team record page field zh-CN reproduced as MEMBER COUNT: an ordinary field label an admin reads, an unauthored fill.', }, - // ── Kept in English by design (43) ─────────────────────────────────────── - echo('sys_account._actions.link_social.params.provider.options.google', 'Google', BRAND), - echo('sys_account._actions.link_social.params.provider.options.github', 'GitHub', BRAND), - echo('sys_account._actions.link_social.params.provider.options.microsoft', 'Microsoft', BRAND), - echo('sys_account._actions.link_social.params.provider.options.apple', 'Apple', BRAND), - echo('sys_account._actions.link_social.params.provider.options.facebook', 'Facebook', BRAND), - echo('sys_account._actions.link_social.params.provider.options.gitlab', 'GitLab', BRAND), - echo('sys_account._actions.link_social.params.provider.options.discord', 'Discord', BRAND), - + // ── Kept in English by design (36) ─────────────────────────────────────── echo('sys_oauth_application.fields.jwks.label', 'JWKS', JWKS), echo('sys_oauth_application.fields.jwks_uri.label', 'JWKS URI', JWKS), echo('sys_oauth_application._actions.create_oauth_application.params.type.options.web', 'Web', WEB_CLIENT_TYPE), @@ -221,10 +216,10 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20493 ja-JP — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 43 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(46); + it('is the size it claims: 3 pinned translations and 36 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(39); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(43); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(36); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts index 5f9d83ea888..205c82c2ec4 100644 --- a/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts @@ -36,6 +36,11 @@ // table holds nothing BEYOND the echoes is not asserted, for the reason the // next section gives. // +// Since that walk, the seven `sys_account._actions.link_social` provider-brand +// rows (Google through Discord) left this ledger together with the action, +// retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer +// carries those leaves, so the ledger below holds 35 echoes. +// // ## What this file deliberately does NOT assert // // That no OTHER zh-CN objects leaf reads its `en` source. A new field's label @@ -80,8 +85,6 @@ interface Decision { departure?: string; } -const BRAND = - 'A sign-in provider brand, shown as the name a user recognises on the link button. The bundle renders no brand name: the authored SSO empty state keeps Okta, Entra and Auth0 verbatim.'; const BARE_ID = 'The bare initialism ID. This bundle keeps ID verbatim inside every label that carries it (用户 ID, 团队 ID, 客户端 ID); a bare en ID has no noun to render, and adding one would invent content.'; const JWKS = @@ -132,15 +135,7 @@ const DECISIONS: readonly Decision[] = [ reason: 'Reproduced on the team record page as MEMBER COUNT: an ordinary field label an admin reads, an unauthored fill.', }, - // ── Kept in English by design (42) ─────────────────────────────────────── - echo('sys_account._actions.link_social.params.provider.options.google', 'Google', BRAND), - echo('sys_account._actions.link_social.params.provider.options.github', 'GitHub', BRAND), - echo('sys_account._actions.link_social.params.provider.options.microsoft', 'Microsoft', BRAND), - echo('sys_account._actions.link_social.params.provider.options.apple', 'Apple', BRAND), - echo('sys_account._actions.link_social.params.provider.options.facebook', 'Facebook', BRAND), - echo('sys_account._actions.link_social.params.provider.options.gitlab', 'GitLab', BRAND), - echo('sys_account._actions.link_social.params.provider.options.discord', 'Discord', BRAND), - + // ── Kept in English by design (35) ─────────────────────────────────────── echo('sys_oauth_application.fields.jwks.label', 'JWKS', JWKS), echo('sys_oauth_application.fields.jwks_uri.label', 'JWKS URI', JWKS), echo( @@ -217,10 +212,10 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20462 — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 42 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(45); + it('is the size it claims: 3 pinned translations and 35 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(38); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(42); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(35); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); 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 74f069cefb5..9109dd6ac1f 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 @@ -490,7 +490,7 @@ export const zhCNObjects: NonNullable = { }, unlink_account: { label: "解除关联", - description: "确定要解除此身份关联吗?在用户从账户设置中重新关联之前,将无法再使用此提供方登录。", + description: "确定要解除此身份关联吗?在用户重新关联之前,将无法再使用此提供方登录。", successMessage: "已解除身份关联" } } diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index e5196ab7b57..55baacb20f1 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -48,40 +48,14 @@ export const SysAccount = ObjectSchema.create({ // `accountId`). The form is locked to the row's values so it acts // as a one-click confirmation rather than a free-form edit. // - // `link_social` is the self-service counterpart — a toolbar action - // that redirects the browser to better-auth's social sign-in endpoint - // with a callbackURL pointing back to the linked-accounts view. The - // endpoint sets the link cookie and OAuth-dances through the provider, - // which is why it's `type: 'url'` (full page navigation) rather than - // `type: 'api'` (XHR — would block on CORS / 302). + // There is deliberately no LINK action. `link_social` was retired under + // ADR-0049 enforce-or-remove: it navigated to a GET of the POST-only + // social sign-in route and offered a fixed provider list, so it was dead + // on every boot. Linking is the signed-in `POST /api/v1/auth/link-social` + // (SDK `auth.accounts.linkSocial`), which answers the provider URL; a + // console surface for it reads the configured providers from + // `/auth/config`, never a static option list. actions: [ - { - name: 'link_social', - label: 'Link Social Account', - icon: 'link-2', - variant: 'primary', - mode: 'create', - locations: ['list_toolbar'], - type: 'url', - target: '/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account', - params: [ - { - name: 'provider', - label: 'Provider', - type: 'select', - required: true, - options: [ - { label: 'Google', value: 'google' }, - { label: 'GitHub', value: 'github' }, - { label: 'Microsoft', value: 'microsoft' }, - { label: 'Apple', value: 'apple' }, - { label: 'Facebook', value: 'facebook' }, - { label: 'GitLab', value: 'gitlab' }, - { label: 'Discord', value: 'discord' }, - ], - }, - ], - }, { name: 'unlink_account', label: 'Unlink Account', @@ -94,7 +68,7 @@ export const SysAccount = ObjectSchema.create({ // Confirm question on `description`, not `confirmText`: this action collects // params, and pairing the two keys opens two dialogs for one decision // (#7278 ruling 2026-08-10, swept by #7309). - description: 'Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.', + description: 'Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it.', successMessage: 'Identity link removed', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 4d97d3afa11..0c96e3905d0 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -134,8 +134,8 @@ export const SysMember = ObjectSchema.create({ // and `ghost` are indistinguishable HERE; the choice is `secondary` // because it is what the button means, not what this surface draws.) // - `icon: 'link-2'` — "attach an EXISTING record", the same icon - // sys_account's `link_social` uses for attaching an existing external - // identity. `user-plus` is reserved for the flows that bring a NEW + // the Account app's Linked Accounts entry uses for existing external + // identities. `user-plus` is reserved for the flows that bring a NEW // person in. // The LABEL is deliberately left alone: "Add Member" and "Invite User" // already differ, and the four translation bundles' hand-written values diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 38986cbb574..5952b1f69f3 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1032,8 +1032,6 @@ const actionObject = () => strictObject({ * - `${param.X}` — value collected from the action's params dialog. * - `${ctx.X}` — values from the action context: `ctx.origin` * (window.origin), `ctx.recordId`, `ctx.user.id`, `ctx.org.id`, etc. - * Used by redirect-style actions like `link_social`, where the target is - * e.g. `/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account`. * Renderers MUST `encodeURIComponent` interpolated values before * substituting them into URL query positions. */ From bf068c3dfa78521c7d859902eda716edc946dfbe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:03:29 +0000 Subject: [PATCH 2/6] chore(platform-objects): regenerate translation bundles after the link_social retirement node scripts/check-i18n-bundles.mjs --write --filter=platform-objects. The diff removes the sys_account._actions.link_social leaves from the four objects bundles and their seven provenance rows from each of the three source-hash tables, and rewrites the en unlink confirm question from source. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../apps/translations/en.objects.generated.ts | 19 +------------------ .../translations/es-ES.objects.generated.ts | 17 ----------------- .../es-ES.source-hashes.generated.ts | 7 ------- .../translations/ja-JP.objects.generated.ts | 17 ----------------- .../ja-JP.source-hashes.generated.ts | 7 ------- .../translations/zh-CN.objects.generated.ts | 17 ----------------- .../zh-CN.source-hashes.generated.ts | 7 ------- 7 files changed, 1 insertion(+), 90 deletions(-) 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 d56a0f174e9..0d214ad760d 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -471,26 +471,9 @@ export const enObjects: NonNullable = { } }, _actions: { - link_social: { - label: "Link Social Account", - params: { - provider: { - label: "Provider", - options: { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - facebook: "Facebook", - gitlab: "GitLab", - discord: "Discord" - } - } - } - }, unlink_account: { label: "Unlink Account", - description: "Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.", + description: "Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it.", successMessage: "Identity link removed" } } 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 95a2792c6d4..c5e6f49414d 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 @@ -471,23 +471,6 @@ export const esESObjects: NonNullable = { } }, _actions: { - link_social: { - label: "Vincular cuenta social", - params: { - provider: { - label: "Proveedor", - options: { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - facebook: "Facebook", - gitlab: "GitLab", - discord: "Discord" - } - } - } - }, unlink_account: { label: "Desvincular cuenta", description: "¿Desvincular este vínculo de identidad? El usuario ya no podrá iniciar sesión con este proveedor hasta que lo vuelva a vincular.", diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 2decb02bf6f..bf49df2e212 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -18,13 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", - "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", - "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", - "objects.sys_account._actions.link_social.params.provider.options.github": "2971d247eb4e6abc", - "objects.sys_account._actions.link_social.params.provider.options.gitlab": "bc3dbbf4b650e600", - "objects.sys_account._actions.link_social.params.provider.options.google": "6fadcd05bb8da367", - "objects.sys_account._actions.link_social.params.provider.options.microsoft": "17309efbdb1ec122", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.error.label": "786fed84bd8d5a32", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", 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 80d2b143956..0022b513d0a 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 @@ -471,23 +471,6 @@ export const jaJPObjects: NonNullable = { } }, _actions: { - link_social: { - label: "ソーシャルアカウント連携", - params: { - provider: { - label: "プロバイダー", - options: { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - facebook: "Facebook", - gitlab: "GitLab", - discord: "Discord" - } - } - } - }, unlink_account: { label: "連携解除", description: "このID連携を解除しますか?ユーザーが再度連携するまで、このプロバイダーでサインインできなくなります。", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index a96e8eb503d..1ecb3cd524f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -18,13 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", - "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", - "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", - "objects.sys_account._actions.link_social.params.provider.options.github": "2971d247eb4e6abc", - "objects.sys_account._actions.link_social.params.provider.options.gitlab": "bc3dbbf4b650e600", - "objects.sys_account._actions.link_social.params.provider.options.google": "6fadcd05bb8da367", - "objects.sys_account._actions.link_social.params.provider.options.microsoft": "17309efbdb1ec122", "objects.sys_email.fields.bcc_addresses.label": "8674899b7b6d5126", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", 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 9109dd6ac1f..6dbf1a1f3fe 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 @@ -471,23 +471,6 @@ export const zhCNObjects: NonNullable = { } }, _actions: { - link_social: { - label: "关联社交账号", - params: { - provider: { - label: "服务提供商", - options: { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - facebook: "Facebook", - gitlab: "GitLab", - discord: "Discord" - } - } - } - }, unlink_account: { label: "解除关联", description: "确定要解除此身份关联吗?在用户重新关联之前,将无法再使用此提供方登录。", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 31882e433b8..73ffa865234 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -18,13 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", - "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", - "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", - "objects.sys_account._actions.link_social.params.provider.options.github": "2971d247eb4e6abc", - "objects.sys_account._actions.link_social.params.provider.options.gitlab": "bc3dbbf4b650e600", - "objects.sys_account._actions.link_social.params.provider.options.google": "6fadcd05bb8da367", - "objects.sys_account._actions.link_social.params.provider.options.microsoft": "17309efbdb1ec122", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", From ca2532fdd5f4dacec1c7b04443868d0cd1124cf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:04:44 +0000 Subject: [PATCH 3/6] test(platform-objects): pin the link_social retirement and the unchanged unlink action sys_account declares exactly unlink_account and no action targeting a social sign-in or link door; unlink_account keeps its type, target, placement and row-id param; the four objects bundles and three provenance tables carry no sys_account._actions.link_social leaf. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../sys-account-link-social-retired.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts diff --git a/packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts b/packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts new file mode 100644 index 00000000000..c57f04d182a --- /dev/null +++ b/packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21849 — `sys_account` declares no link action; `unlink_account` stays. + * + * `link_social` was a `type: 'url'` toolbar action that navigated the browser to + * a GET of better-auth's social sign-in route, which better-auth serves as POST + * only, and it offered a fixed list of seven providers whatever the boot had + * configured. It was dead on every boot: a provider-less boot offered seven + * providers that 404, and a configured boot still could not link. It was + * retired under ADR-0049 enforce-or-remove rather than repaired, because the + * action contract has no way to say "options from the configured providers" + * or "hidden when none is configured" (the maintainer-ratified ruling on the + * card). Linking stays reachable through the signed-in + * `POST /api/v1/auth/link-social` (SDK `auth.accounts.linkSocial`). + * + * What is pinned is the absence of a LINK AFFORDANCE, not only of one name: an + * action that targets a social sign-in or link door under any other name is + * the same dead shape returning. The unlink half is pinned on the fields that + * make it work against better-auth, so the retirement cannot have taken it + * along. The four shipped translation bundles and the three provenance tables + * are pinned too: a leaf left behind for a retired action is a catalog entry + * nothing renders, and a provenance row for it keeps a stale echo decision + * alive. + */ + +import { describe, expect, it } from 'vitest'; + +import { SysAccount } from './sys-account.object.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'; +import { zhCNGeneratedSourceHashes } from '../apps/translations/zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from '../apps/translations/ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from '../apps/translations/es-ES.source-hashes.generated.js'; + +type ActionLike = { name?: string; type?: string; target?: string; [key: string]: unknown }; + +const actions = (): ActionLike[] => ((SysAccount as { actions?: ActionLike[] }).actions ?? []); + +/** A target that starts a social/OIDC sign-in or link round-trip. */ +const LINK_DOOR = /\/auth\/(sign-in\/(social|oauth2)|link-social)\b/; + +describe('#21849 — sys_account declares no link action', () => { + it('declares exactly one action, unlink_account — no link_social', () => { + const names = actions().map((a) => a.name); + expect(names).not.toContain('link_social'); + expect(names).toEqual(['unlink_account']); + }); + + it('no action targets a social sign-in or link door, under any name', () => { + const doors = actions() + .filter((a) => typeof a.target === 'string' && LINK_DOOR.test(a.target)) + .map((a) => `${a.name} -> ${a.target}`); + expect(doors).toEqual([]); + }); + + it('the link-door predicate can say "yes" — fed the retired target and the link door', () => { + // Without this, a predicate that matches nothing would green the test above. + expect(LINK_DOOR.test('/api/v1/auth/sign-in/social?provider=google')).toBe(true); + expect(LINK_DOOR.test('/api/v1/auth/link-social')).toBe(true); + expect(LINK_DOOR.test('/api/v1/auth/unlink-account')).toBe(false); + }); +}); + +describe('#21849 — unlink_account is unchanged by the retirement', () => { + it('keeps its name, type, target, placement and row-id param', () => { + const unlink = actions().find((a) => a.name === 'unlink_account'); + expect(unlink, 'unlink_account is missing from sys_account').toBeDefined(); + expect(unlink?.type).toBe('api'); + expect(unlink?.target).toBe('/api/v1/auth/unlink-account'); + expect(unlink?.mode).toBe('delete'); + expect(unlink?.locations).toEqual(['list_item', 'record_header']); + expect(unlink?.params).toEqual([ + { name: 'accountId', field: 'id', defaultFromRow: true, required: true }, + ]); + }); +}); + +describe('#21849 — the shipped translation bundles carry no link_social leaf', () => { + const BUNDLES = [ + ['en', enObjects], + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], + ] as const; + + for (const [locale, bundle] of BUNDLES) { + it(`${locale}: sys_account._actions holds unlink_account and no link_social`, () => { + const acts = (bundle as Record }>).sys_account?._actions; + // Positive control first: an absent `_actions` would make the absence + // check below vacuous. + expect(acts, `${locale} bundle has no sys_account._actions`).toBeDefined(); + expect(Object.keys(acts ?? {})).toEqual(['unlink_account']); + expect(acts).not.toHaveProperty('link_social'); + }); + } + + const TABLES = [ + ['zh-CN', zhCNGeneratedSourceHashes], + ['ja-JP', jaJPGeneratedSourceHashes], + ['es-ES', esESGeneratedSourceHashes], + ] as const; + + for (const [locale, table] of TABLES) { + it(`${locale}: the provenance table keeps no row for the retired action`, () => { + const keys = Object.keys(table); + expect(keys.length, `${locale} provenance table is empty`).toBeGreaterThan(0); + expect(keys.filter((k) => k.startsWith('objects.sys_account._actions.link_social'))).toEqual([]); + }); + } +}); From a29976301428c8435861aa74371d347975a8ab38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:06:10 +0000 Subject: [PATCH 4/6] chore(changeset): platform-objects minor for the link_social retirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-② no (narrowing); ADR-0087 disposition not-required (no-migration-prescription). Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../21849-retire-sys-account-link-social.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/21849-retire-sys-account-link-social.md diff --git a/.changeset/21849-retire-sys-account-link-social.md b/.changeset/21849-retire-sys-account-link-social.md new file mode 100644 index 00000000000..03c2b2e24d6 --- /dev/null +++ b/.changeset/21849-retire-sys-account-link-social.md @@ -0,0 +1,15 @@ +--- +'@objectstack/platform-objects': minor +--- + +fix(platform-objects)!: retire the `sys_account` `link_social` action, which was dead on every boot; `unlink_account` stays + +Clause-②: no (narrowing) + + + +**BREAKING**: `sys_account` no longer declares the `link_social` action, so the "Link Social Account" toolbar button is gone from the Account app's Linked Accounts list and from Setup's Identity Links. It never completed a link on any boot: it navigated to a `GET` of the social sign-in route, which is served as `POST` only, and it offered a fixed list of seven providers whatever the boot had configured. It is retired under ADR-0049 (enforce or remove) and ships as `minor` under the launch-window convention for narrowings. + +**What stays.** `unlink_account` is unchanged: the same type, target, placement and row-id parameter. Its confirm question no longer says the user can re-link "from their account settings", because no console surface offers that now. The `sys_account._actions.link_social` leaves are gone from the `en`, `zh-CN`, `ja-JP` and `es-ES` bundles. + +**What to do after upgrading.** Linking a social or OIDC identity stays available through the signed-in `POST /api/v1/auth/link-social`, which is `auth.accounts.linkSocial({ provider, callbackURL })` in `@objectstack/client`: call it and navigate to the `url` it answers. From c62ac63d2a5a786e69895b47153a1b959b609ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:08:39 +0000 Subject: [PATCH 5/6] chore(changeset): state the measured declaration reach of the link_social retirement Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/21849-retire-sys-account-link-social.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21849-retire-sys-account-link-social.md b/.changeset/21849-retire-sys-account-link-social.md index 03c2b2e24d6..bfceb82e8a9 100644 --- a/.changeset/21849-retire-sys-account-link-social.md +++ b/.changeset/21849-retire-sys-account-link-social.md @@ -6,7 +6,7 @@ fix(platform-objects)!: retire the `sys_account` `link_social` action, which was Clause-②: no (narrowing) - + **BREAKING**: `sys_account` no longer declares the `link_social` action, so the "Link Social Account" toolbar button is gone from the Account app's Linked Accounts list and from Setup's Identity Links. It never completed a link on any boot: it navigated to a `GET` of the social sign-in route, which is served as `POST` only, and it offered a fixed list of seven providers whatever the boot had configured. It is retired under ADR-0049 (enforce or remove) and ships as `minor` under the launch-window convention for narrowings. From c2bea2c7897fdd03e09eb56ee362cdabbb2ce502 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:32:32 +0000 Subject: [PATCH 6/6] revert(qa): leave identity-auth.linked-accounts-social to the claim that holds it The domain:devx seat objected on the card: the checklist item is held by the claim on a sibling checklist card, whose entry adds the item's link fixture through POST /api/v1/auth/link-social. The checklist half leaves this PR and is revised from main once that card lands. The file is restored to origin/main byte for byte. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../areas/identity-auth.json | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index f89c7d41802..f0734d3cb74 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -1100,29 +1100,36 @@ }, { "id": "identity-auth.linked-accounts-social", - "title": "Linked accounts: the caller's social/OIDC links appear in mine-view; unlink removes one; the surface offers no link affordance, so a provider-less boot degrades honestly", + "title": "Linked accounts: link a social/OIDC identity through the redirect round-trip → a sys_account row appears in mine-view; unlink removes it; provider-less boot degrades honestly", "since": "v16", "status": "active", - "revision": 3, + "revision": 2, "priority": "P2", "surface": "mixed", - "personas": ["a signed-in user holding a second (social/OIDC) identity link", "the same user after unlinking"], + "personas": ["a signed-in user linking a second identity", "the same user after unlinking"], "fixtures": { "app": "showcase", "requires": [ - "at least one social/OIDC provider configured (socialProviders or oidcProviders) and the caller holding a link to it, made OUTSIDE the console — sys_account declares no link action (link_social retired under ADR-0049); a social sign-in, or the signed-in POST /api/v1/auth/link-social (SDK auth.accounts.linkSocial), makes one. Stock showcase ships no provider, so this item is blocked(fixture) until one is configured", + "at least one social/OIDC provider configured (socialProviders or oidcProviders) so link_social has a provider to dance with — stock showcase ships none, so this item is blocked(fixture) until a provider is configured", "the Account 'Identity Links' surface (nav_accounts → sys_account) reachable" ] }, - "blocked": { "by": "fixture", "ref": "no stock showcase social/OIDC IdP — the identity link this item unlinks needs a configured provider, same fixture class as identity-auth.sso-enforced-first-paint / oauth-app-consent-loop" }, + "blocked": { "by": "fixture", "ref": "no stock showcase social/OIDC IdP — link_social needs a configured provider, same fixture class as identity-auth.sso-enforced-first-paint / oauth-app-consent-loop" }, "steps": [ "as a signed-in user, open Account → Identity Links (sys_account `mine` view, filter user_id={current_user_id}); screenshot the initial link set", - "confirm the fixture's link is a sys_account row in the caller's mine-view (provider_id = the provider, user_id = the caller) — (provider_id, account_id) is the whole account identity; there is no issuer to check, sys_account.issuer retired with better-auth 1.7.3 (packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts)", + "invoke link_social for a configured provider: the action is type:'url' — GET /api/v1/auth/sign-in/social?provider=

&callbackURL=/_console/apps/account/sys_account (full-page navigation, NOT XHR, so the OAuth 302 dance and link cookie work); complete the provider round-trip and land back on the Identity Links view", + "confirm a sys_account row now exists for that provider in the caller's mine-view (provider_id = the provider, user_id = the caller) — (provider_id, account_id) is the whole account identity; there is no issuer to check, sys_account.issuer retired with better-auth 1.7.3 (packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts)", "unlink it: the unlink_account action → POST /api/v1/auth/unlink-account with accountId = the sys_account ROW id (better-auth 1.7 keys on the row id); confirm the row is gone from mine-view", - "both-sides / degradation: on a boot with NO provider configured, confirm the surface offers no link affordance — sys_account declares only unlink_account since link_social retired, so nothing offers a link that dead-ends (open-edition honest-degradation posture)", + "both-sides / degradation: on a boot with NO provider configured, confirm link_social degrades honestly — the affordance is absent or names the missing provider, rather than offering a link that dead-ends (open-edition honest-degradation posture)", "confirm sys_account is read-only over the data API — a forged direct insert/delete is refused (apiMethods ['get','list'], writes 405)" ], "acceptance": [ + { + "clause": "linking a social identity creates a sys_account row in the caller's mine-view after the redirect round-trip completes", + "oracle": "api", + "verify": "GET /api/v1/data/sys_account (mine view) after the link shows a new row with provider_id = the provider and user_id = the caller; a row with no issuer is the healthy shape, never a FAIL — the column is retired", + "evidence": "the post-link mine-view read" + }, { "clause": "the link surface renders the caller's own links only (mine-view scoped) — screenshot-confirmed", "oracle": "screenshot", @@ -1136,9 +1143,9 @@ "evidence": "the unlink response + the follow-up read" }, { - "clause": "a provider-less boot degrades honestly: the surface offers no link affordance — never one that dead-ends", + "clause": "a provider-less boot degrades honestly: link_social is absent or names the missing provider — never a link affordance that dead-ends", "oracle": "screenshot", - "verify": "with no provider configured, the Identity Links surface shows no link action at all (sys_account declares none; its one action, unlink_account, is a row action)", + "verify": "with no provider configured, the Identity Links surface shows no dead link action (or an explicit unavailable state)", "evidence": "the provider-less screenshot" }, { @@ -1149,20 +1156,20 @@ } ], "negative": [ + "a link that appears in the UI but does not create a sys_account row (client-only) is a FAIL — the row is the durable identity link", "unlink that hides the row from the list but leaves the sys_account (so the provider still signs the user in) is a FAIL", "one user's identity links appearing in another's mine-view is an RLS FAIL", - "a link affordance on the Identity Links surface that navigates to a dead endpoint is a dishonest-degradation FAIL — sys_account declares none" + "a provider-less boot offering a link_social action that navigates to a dead endpoint is a dishonest-degradation FAIL" ], "traps": ["wrong-persona", "dispatcher-vs-hono-route", "hydration-race"], "source": [ - "packages/platform-objects/src/identity/sys-account.object.ts#user_id (unlink_account → /api/v1/auth/unlink-account accountId=row id; no link action, link_social retired under ADR-0049; mine view user_id={current_user_id} vs all_links; apiMethods ['get','list'])", + "packages/platform-objects/src/identity/sys-account.object.ts#user_id (link_social type:'url' → /api/v1/auth/sign-in/social?provider=&callbackURL=; unlink_account → /api/v1/auth/unlink-account accountId=row id; mine view user_id={current_user_id} vs all_links; provider options; apiMethods ['get','list'])", "packages/plugins/plugin-auth/src/auth-route-ledger.ts#AUTH_ROUTE_LEDGER (POST link-social=auth.accounts.linkSocial, GET list-accounts=auth.accounts.list, POST unlink-account=auth.accounts.unlink — re-pointed #18104: linkSocial is a CLIENT METHOD name, carried in this file only inside a dotted string value)", "packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_accounts (nav_accounts → 'Identity Links', objectName sys_account)" ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: social/OIDC account linking round-trip → sys_account mine-view row, unlink removal, provider-less honest degradation; blocked(fixture) pending a configured IdP (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-09-23", "change": "the 'issuer stamped' requirement is withdrawn from both the step and the first acceptance clause's verify: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, so nothing stamps it and the old verify failed a healthy system. The reason is kept — the step now says (provider_id, account_id) is the whole identity and names the retirement's migration entry in packages/spec — and the verify states that an issuer-less row is the expected shape", "ref": "#19217" }, - { "revision": 3, "date": "2026-10-05", "change": "the link half retires with the sys_account link_social action (ADR-0049 enforce-or-remove, the maintainer-ratified ruling on #21849): it navigated to a GET of the POST-only social sign-in route with a fixed provider list, dead on every boot. Removed the link step, the first acceptance clause (linking creates a row) and the client-only-link negative; the social/OIDC link the unlink half needs moves into the fixture, made outside the console (a social sign-in, or the signed-in POST /api/v1/auth/link-social = auth.accounts.linkSocial). The unlink, mine-view and read-only clauses stay; the provider-less degradation clause now reads against 'no link affordance', since sys_account declares none", "ref": "#21849" } + { "revision": 2, "date": "2026-09-23", "change": "the 'issuer stamped' requirement is withdrawn from both the step and the first acceptance clause's verify: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, so nothing stamps it and the old verify failed a healthy system. The reason is kept — the step now says (provider_id, account_id) is the whole identity and names the retirement's migration entry in packages/spec — and the verify states that an issuer-less row is the expected shape", "ref": "#19217" } ] }, {