Repository navigation
fix(plugin-auth)!: implicit account linking requires the standard local-ownership condition; unlink is honoured - #21872
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f1fe29a0182b07c5cf126d3792ce6581bb1dd2e8 && git checkout f1fe29a0182b07c5cf126d3792ce6581bb1dd2e8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e f7814a1b914e3ea2b8b7656d59d066cdff803b6a && git checkout -B drift-repro 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e && git merge --no-ff f7814a1b914e3ea2b8b7656d59d066cdff803b6a
node scripts/docs-audit/affected-docs.mjs --json 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e
|
… user, clear records on user delete Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…OIDC discovery, fail-closed unlink, user delete Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Contract reviewServed-tier: Inputs: card #21846 (body + 5 comments: triage, claim, two os-dev reports, the Clause-② correction), PR #21872 body and its 7-file list, the net diff ① Derived judgments
② Semver levelThe diff publishes a behaviour narrowing in Clause-②: no (narrowing) — correct. There is no widening (no new export, no new authorable key), and the narrowing arm comes with banner, ③ Boundary flags
Gates: 43 success, 9 skipped (Auto Label, Check PR Size, Packed-tarball smoke, Console Pin Gate: all opt-in or not applicable), and 1 Check Changeset run still in_progress. Three sibling Check Changeset runs on this head are green. Landing waits for every check to be green. Implemented-by: VERDICT: PASS |
…atform exception bound to the OAuth method Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…alues for hosts with secondaryStorage Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Contract reviewServed-tier: Inputs read: card #21846 body and all 7 comments (triage 5991270306, claim 5991690180, dev reports 5994181249 / 5996242400 / 5999082055, Clause-② correction 5996304603, cross-lane note 5996720856); PR #21872 body and file list (7 files, none on a governed surface); net diff ① Derived judgmentsAccept-set changes the diff implies, each judged:
Public surface (package
② Semver level
Clause-②: no (narrowing) ③ Boundary flags
Implemented-by: VERDICT: PASS |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37371558473 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37374282440 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…ad on every boot; unlink_account stays (objectstack-ai#21894) Fixes objectstack-ai#21849 Clause-②: no (narrowing) Retires the `sys_account` `link_social` action from `@objectstack/platform-objects` under ADR-0049 enforce-or-remove, as ruled on the card (ruling D, comment 5995717941; maintainer reply verbatim: 「同意」). `unlink_account` stays. Self-service linking returns as a native console surface reading `/auth/config` once a linking need is named; that is not this PR. ## Why `link_social` was a `type: 'url'` toolbar action. It navigated 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, with no visibility gate. It was dead on every boot: the earlier measurement on the card (5992085207) read 404 on a provider-less boot and on a configured one. The ruled direction (options from the configured providers, hidden when none) cannot be said in today's action contract, so the ruling retires the action instead of widening four lanes for one consumer. Linking stays reachable through the signed-in `POST /api/v1/auth/link-social`, which is `auth.accounts.linkSocial` in `@objectstack/client`. That door, its `plugin-auth` route-ledger rows and the SDK method are untouched. ## What changed - `packages/platform-objects/src/identity/sys-account.object.ts`: the action and the comment paragraph that introduced it are gone. A short comment now records why there is no link action and where linking lives. `unlink_account` keeps its name, type, target, mode, placement and row-id param. Its confirm question drops "from their account settings"; see **Deviation from the claim** below. - Translations, regenerated with the repo's tool (`node scripts/check-i18n-bundles.mjs --write --filter=platform-objects`). The diff removes only the `sys_account._actions.link_social` block from each of the four objects bundles (17 lines each) and its seven provenance rows from each of the three source-hash tables, and rewrites the `en` unlink question from source. The three translated unlink questions are hand-written values, edited by hand to match. - The three echo-decision ledgers drop their seven `link_social` provider-brand rows and the now-unused brand reason. The size pins move with them: zh-CN 45 to 38 rows (42 to 35 echoes), ja-JP 46 to 39 (43 to 36), es-ES 57 to 50 (54 to 47). A header note in each says why. - New pin `packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts`. - Comments that cited the action: `sys-member.object.ts` (the `add_member` icon note now cites the Account app's Linked Accounts entry, which uses the same icon) and `apps/account.app.ts` (the resultDialog list). - Outside the lane, declared on the claim: - `packages/spec/src/ui/action.zod.ts`: the `target` docblock sentence that cited `link_social` and its dead GET target is removed. Docblock only, no schema line; the interpolation and encoding sentences stay. - `content/docs/protocol/objectui/actions.mdx`: the URL Actions example no longer teaches `link_social`. It is replaced by a working `${param.X}` example (a Maps search URL with an `address` param); the interpolation prose stays. - `.changeset/21849-retire-sys-account-link-social.md`: `@objectstack/platform-objects: minor`, BREAKING, `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`. That category fits because the withdrawn action is platform-shipped metadata on a `lock: 'full'` object: no spec key, spelling, export name or config field is retired, nothing an author wrote needs rewriting, and no stored row can carry it. No `@objectstack/spec` entry: its change is one docblock sentence, and no gate asked for one. ### The checklist half left this PR The `domain:devx` seat objected on the card (5996996202): `identity-auth.linked-accounts-social` in `docs/qa/platform-checklist/areas/identity-auth.json` is held by the claim on objectstack-ai#21851, whose entry adds the item's link fixture through `POST /api/v1/auth/link-social`. objectstack-ai#21851 had not landed when this PR opened, so the checklist half is not here. The file is restored to `origin/main` byte for byte: `git diff origin/main -- docs/qa/platform-checklist/areas/identity-auth.json` is empty, and the blob at HEAD equals the one on `origin/main` and at the base (`f0734d3cb7`). The seat files that revision as its own card when this lands, to be worked once objectstack-ai#21851 has landed. Until then the item's link step still names the retired action. ### Deviation from the claim The claim said `unlink_account` stays byte-identical. Its confirm question told the user they could re-link "from their account settings", and after this retirement no console surface offers a link (zero hits for a link affordance in objectui at the pin, see H4). The dev contract says a shipped text that this change makes false is fixed in the same change, so the clause is dropped in all four locales. Name, type, target, mode, placement and params are unchanged and pinned. Reverting that one sentence is a single-file edit plus a regeneration, if the seat prefers the claim's reading. ## Census **H1, every reference** (base `e864db56df`, outside `CHANGELOG.md`). | Reference | Disposition | | --- | --- | | `sys-account.object.ts`: the action (about :58-84) and its intro comment (:51-56) | removed; a new comment names the retired action as a record | | `sys-member.object.ts` :137 | reworded | | `apps/account.app.ts` :22 | removed from the resultDialog list | | four `*.objects.generated.ts` | regenerated, leaves removed | | three `*.source-hashes.generated.ts` | regenerated, 7 rows each removed | | three `objects-*-echo-decisions.test.ts` | 7 rows each removed, counts moved; one header note each names the retirement | | `packages/spec/src/ui/action.zod.ts` :1035-1036 | sentence removed (docblock only) | | `content/docs/protocol/objectui/actions.mdx` :97-100 | example replaced | | `docs/qa/platform-checklist/areas/identity-auth.json` (7 lines) | not touched here (see above) | | `packages/spec/src/ui/inline-action.test.ts` :307 (the sign-in URL as a parse-acceptance input) | stays: an incidental fixture, outside the docblock-only scope | | `plugin-auth` route-ledger rows for `POST /api/v1/auth/link-social`, `packages/client` `auth.accounts.linkSocial` | stay: the door is not retired | **H2, translations.** The regenerated diff is 90 removed lines and 1 added: 4 times 17 lines of the `link_social` block, 3 times 7 provenance rows, and the one `en` unlink question rewritten from source. No other key moved. The echo ledgers drop exactly the seven provider rows each, and `pnpm check:i18n` and `pnpm check:i18n-stale-fill` are green (below). **H3, declaration reach**, measured on built `dist/**/*.d.ts`: - With the action present (the base shape, rebuilt in the reverse-verification leg), `link_social` appears in 2 declaration files, `dist/identity/index.d.ts` and `.d.mts`, once each. It sits inside the type argument of `SysAccount`'s declared type, which is `ObjectSchema.create`'s return type: `Omit` of `ServiceObject` without `fields`, intersected with a `Pick` of the literal that keeps only `fields`. So no reachable member carries it. A `tsc` probe on that build compiled `const probe: ActionName = 'zzz_not_an_action'`, where `ActionName` is the type of `SysAccount.actions[number].name`, which therefore resolves to `string`. The control line in the same file, a non-field assigned to `keyof (typeof SysAccount)['fields']`, failed with TS2322 as expected. - The translation bundles: 0 declaration hits even while the leaves were in the JS. The bundles are annotated `NonNullable` of `TranslationData['objects']`, so no key types reach `./apps` or `./metadata-translations`. - At HEAD: 0 declaration files and 0 JS files carry the action. The comment that names it survives into 4 JS files. So the exported types are structurally unchanged and the narrowing is runtime and wire only. The `Clause-②: no (narrowing)` arm stands as declared. **H4, consumers.** No workspace code, test, example app or dogfood test reads the action by name outside the H1 rows. objectui at the pinned `.objectui-sha` `0abd4f9f87`, from a depth-1 fetch of that commit: `git grep -e link_social -e 'Link Social' -e linkSocial -e link-social` gives 0 hits (exit 1). The control `git grep sys_account` on the same tree gives 4 hits (CHANGELOGs), so the grep reached the tree. objectui's own ActionRunner tests use the sign-in URL as a generic url-action fixture and import nothing from here. No objectui change and no pin bump are needed (Post-Task Checklist item 4). **H5, stored data.** None. The action is code-shipped metadata registered at boot, never a row. `sys_account` is `protection.lock: 'full'`, so `evaluateLockForWrite` refuses every overlay save with `ITEM_LOCKED`, and no `sys_metadata` overlay can carry the action. No migration, seed, example or dogfood fixture names it. ## Tests - New pin, 11 cases: `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2 src/identity/sys-account-link-social-retired.test.ts` reads `Tests 11 passed (11)`. - Package: `pnpm --filter @objectstack/platform-objects test` reads `Test Files 60 passed (60) · Tests 960 passed (960)`. `pnpm --filter @objectstack/platform-objects typecheck` exits 0; `check:test-typecheck` puts the new pin and the edited ledgers in its program (`--listFilesOnly`). - Consumer: `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/action-execution-destructive.test.ts` reads `Tests 66 passed (66)`; it reads the identity actions off their real declarations. - `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date against the rebuilt spec `dist`. **Reverse verification** (on committed HEAD `a299763014`, through `scripts/ablation-replace.mjs`, which restores on exit, INT and TERM): - **Leg A, the action restored in source.** The anchor hit once and the blob moved `55baacb20f` to `1faf2af6f8`. `pnpm --filter @objectstack/platform-objects build` ran, and `ablation-dist-preflight` found the marker in 6 built files. The pin went red: `Tests 2 failed, 9 passed (11)`, on "declares exactly one action" and "no action targets a social sign-in or link door". Restored: the blob equals HEAD and `git diff HEAD` is empty. Rebuilt, preflight `--absent` read the marker absent from all 66 built files with a clean tree, and the pin is green again. - **Leg B, a `link_social` leaf put back in the `en` bundle.** The anchor hit once and the blob moved `0d214ad760` to `3bddbb31f8`. The pin went red: `Tests 1 failed, 10 passed (11)`, on "en: sys_account._actions holds unlink_account and no link_social". Restored the same way: blob equality and an empty `git diff HEAD`. No build was needed for this leg, because the pin imports the bundle by relative source path, not through `exports`. **Whole-repo pin sweep.** Pins asserting `sys_account`'s action set, count or `link_social` keys were swept repo-wide. They are the three echo ledgers (re-pinned on their new row counts, which assert the substance: 38, 39 and 50 rows) plus `platform-objects.test.ts`, `action-confirm-one-dialog.test.ts`, `confirm-question-carryover.test.ts`, `action-predicate-sparse-face.test.ts` and `runtime`'s `action-execution-destructive.test.ts`, which read `unlink_account` or iterate the object's actions and need no change. The new pin asserts the action set itself (`['unlink_account']`), not only that a name is gone. ## Gates Taken at HEAD `c2bea2c789`, after the last merge of `origin/main`. - **Derived.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 109 commands, and all 109 were run. `--ran` reconciles them: `✓ dispatch-gates --ran: 109 derived famil(ies) accounted for — 109 run, 0 NOT-MEASURED`. - **A first-pass refusal.** Five `@objectstack/spec` gates refused with `PREREQUISITE NOT MET` (exit 3), because the merge had moved spec test files and the spec `dist` input digest no longer matched. After `pnpm --filter @objectstack/spec build`, the whole spec family (22 commands) was rerun, and all of it is green. - **The artifact-roster block** the derivation prints outside its total: 54 commands, 52 at exit 0. `check-closing-target-claim` and `check-single-claim-paths` answer NOT WIRED without a PR number (exit 2) and are rerun once this PR exists. `check-partof-closing-keyword` was run against this body through `PR_BODY` and passes. - **The four symbol-anchor sweeps**, `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors`, exit 0. - Among the green: `check:i18n`, `check:i18n-stale-fill`, `check:nul-bytes`, `check:adr-0087-registration`, `check:changeset-no-major`, `check:empty-changeset`, `check:yaml-examples`, `check:docs`, `check:api-surface`, `check:keyed-text-bounds` and `check:platform-object-tenancy-census`. - **An earlier run** of the same list at `cc34db92de`, before the checklist revert, was green apart from the same three PR-context gates. - `check:pm-dispatch-gates` is not derived for this diff. - **NOT MEASURED locally, declared to CI:** the path-scheduled CI jobs and the type-check lanes that the derivation names outside its list (Test Core, Dogfood, Build Core, Build Docs, Temporal Conformance and the workspace type-check). ## Acceptance notes - Release text in an open sibling PR: PR objectstack-ai#21872's changeset (`.changeset/21846-implicit-account-linking-ownership.md`) tells a refused user to "link the provider from account settings". After this PR no console surface links. It is noted for that PR's holder, not filed. - `docs/NORTH-STAR.md` (governed) names `linked-accounts-social` on its identity line. The item id does not change, so no governed edit follows from this PR. - `packages/spec/src/ui/inline-action.test.ts:307` keeps the sign-in URL as a parse-acceptance input. It asserts parsing only, not that the target works. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ctstack-ai#21926) Closes objectstack-ai#21885 Clause-②: no ## What changes `content/docs/permissions/sso.mdx`, the "OAuth flow" section only. Step 2 of the OIDC flow told readers to call `POST /api/v1/auth/sign-in/oauth2` with `{ providerId }`. Nothing registers that route, so a reader following the page got a 404. The OIDC steps now use the two routes the social flow already uses: - Step 2: `POST /api/v1/auth/sign-in/social` with `{ provider: "okta", callbackURL }`, where `provider` is the `oidcProviders` entry's `providerId`. - Step 4: the provider redirects to `/api/v1/auth/callback/okta`. better-auth exchanges the code, reads the profile from the ID token or `userInfoUrl`, creates a session and redirects to `callbackURL`. - A one-line lead-in says why: better-auth's generic-OAuth plugin registers each `oidcProviders` entry as a social provider and adds no endpoints of its own. No product change, no other page, no changeset (docs do not publish). ## Grounding for each route **`POST /api/v1/auth/sign-in/social`** (OIDC step 2, changed; social step 2, re-checked and unchanged) - Ledger row, `packages/plugins/plugin-auth/src/auth-route-ledger.ts` line 167: `{ route: 'POST /api/v1/auth/sign-in/social', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.signInWithProvider' },` - Published-route list, same file, line 433: `'POST /api/v1/auth/sign-in/social',` - Body shape, installed better-auth 1.7.3, `dist/api/routes/sign-in.mjs` line 40: `provider: SocialProviderListEnum`. In `@better-auth/core` `dist/social-providers/index.mjs` line 78 that is `z.enum(socialProviderList).or(z.string())`, so a generic provider id is accepted. The handler looks `c.body.provider` up in `c.context.socialProviders`. - The generic-OAuth plugin, `dist/plugins/generic-oauth/index.mjs` lines 61-66: "registers any OAuth/OIDC provider as a first-class social provider. Providers are used through the standard `signIn.social` and `callback/:id` core endpoints — no plugin-specific endpoints needed." Its `init` prepends the generic providers to `ctx.socialProviders` (line 272). - Tests merged with objectstack-ai#21872, `packages/plugins/plugin-auth/src/implicit-account-linking.test.ts`: - lines 10-11: "driving a real OAuth round trip (`/sign-in/social` → `/callback/:id`) through generic-OAuth providers" - line 246: `{ provider: providerId, callbackURL: AFTER, disableRedirect: true, ...extraBody },` posted to `sign-in/social` **`/api/v1/auth/callback/:id`** (OIDC step 4, now named; social step 4 `/callback/google`, re-checked and unchanged) - Published-route list, `auth-route-ledger.ts` line 321 `'GET /api/v1/auth/callback/:id',` and line 372 `'POST /api/v1/auth/callback/:id',` - Callback path, better-auth 1.7.3 `dist/oauth2/utils.mjs` line 29: `if (!provider.callbackPath) return` the path `/callback/` plus `provider.id`. The generic-OAuth plugin sets no `callbackPath`, and `auth-manager.ts` passes `providerId: p.providerId` straight through (line 3710). - Test, `implicit-account-linking.test.ts` line 254: the callback is requested at `${BASE}/api/v1/auth/callback/${providerId}?code=code-1&state=…`. **`POST /api/v1/auth/sign-in/oauth2`** (removed) - `grep -c "sign-in/oauth2" packages/plugins/plugin-auth/src/auth-route-ledger.ts` gives `0`, in both the ledger rows and the published-route list. The section names no other route. ## Validation All at head `57258e13b4`. `node scripts/pm/dispatch-gates.mjs` derived 44 gate commands for this one-file change. Every one of them exited 0, and `--ran` reconciled them: "44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED (a DERIVED zero — all 44 recorded an exit code and none of them is 3)". - Docs gates that read this page: `check:doc-authoring`, `check:doc-anchors` (429 fragment links resolve), `check-doc-route-spelling --advisory` ("every shape-matched literal spells its ledger row"), `check:docs-single-h1`, `check:doc-frontmatter`, `check:docs-redirects`, `check:docs-transcript-drift`, `check:nul-bytes`. All passed. - Four gates first exited 3 (PREREQUISITE NOT MET, nothing measured): `check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift` and `check:skill-examples`. I built `@objectstack/lint...`, `@objectstack/spec` and `@objectstack/client-react...`, then re-ran all four. Each exited 0. ## Acceptance notes - Outside this section and untouched: the SAML flow names `POST /api/v1/auth/sign-in/sso`. The ledger's pinned config (`LEDGERED_PLUGIN_CONFIG`) does not turn on `sso`, so the ledger neither confirms nor refutes that route. Noting it here only. Changes 1 file: `content/docs/permissions/sso.mdx`, +8 / -3. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ Co-authored-by: Claude <noreply@anthropic.com>
…licit system opt-in (objectstack-ai#21939) Fixes objectstack-ai#21912 Clause-②: no This PR is a slice of objectstack-ai#21908, the closure of the security middleware's principal-less hand-off (ADR-0096). It covers the identity and runtime producers. objectstack-ai#21908 stays open for the deny, which lands last. ## What moved Each producer below reached the data engine with no principal and no `isSystem`. That is the hand-off, and it is not an authorization. Each one now takes the explicit system opt-in that already exists. ⛔ No new elevation API, no change to what any door authorizes, no accept-set change. | Row | Position (function) | Engine calls | Route taken | |---|---|---|---| | 17 | `plugin-auth` `auth-plugin.ts`, the platform-admin OAuth client toggle route (`/admin/oauth2/toggle-disabled`) | `findOne` + `update` `sys_oauth_application` | `withSystemContext`, the wrapper better-auth's adapter already writes these rows through | | 18 | `plugin-auth` `scim-connection-service.ts` `verifyScimBearerToken` | `findOne` `sys_scim_connection_credential` | `isSystem: true` in the read's trailing options | | 19 | `plugin-auth` `auth-manager.ts` `organizationHooks.beforeUpdateOrganization` (the slug guard) | `findOne` `sys_organization`, `find` `sys_environment` | `withSystemContext` | | 21 | `runtime` `http-dispatcher.ts` `enforceProjectMembership` | `find` `sys_environment_member` | `isSystem: true` as the read's query context | **Row 20 moves nothing.** I read every site, and each one already runs with the opt-in: - `adopt-membership.ts` `adoptExistingMembership`: its only caller hands it the adapter's `withSystemContext` engine. - `membership-ended-session.ts` `endSessionClaimsForEndedMembership`: all four calls pass `{ context: SYSTEM_CTX }` (`isSystem: true`) as the trailing options, and the engine honours that argument on reads and writes. - The `auth-manager.ts` insert helper (`settleSelfRegistrationGrant`, with `findPermissionSetRows`): it reads and writes through `withSystemReadContext`, the deprecated alias of `withSystemContext`. The `auth-manager.ts` edit (row 19) was made after objectstack-ai#21872 landed, on a merge of `origin/main` that contains it. ## Measured: no gate fires on any moved call today An `isSystem` context short-circuits the gates the hand-off still runs before `next()`: package-managed, system-row, curated-capability, audience-anchor, engine-owned and delegated-administration. A move is neutral only if none of them fires on the producer's calls. - **Static.** Each gate is keyed to objects and verbs that none of these calls touch. The first four guard writes to `sys_permission_set`, `sys_position`, `sys_capability` and `sys_position_permission_set`. Engine-owned needs a `userId`. Delegated-administration guards writes to the RBAC link tables, `sys_permission_set` and `sys_member`. Rows 18, 19 and 21 are reads. Row 17 writes `sys_oauth_application`, which none of the gates names. - **Instrumented.** I added a local, uncommitted probe in `security-plugin.ts`. It recorded each principal-less, non-system context that reached the hand-off, with its stack, and each gate refusal of such a context. Over every run below it recorded **0 gate refusals**. Every call of the card's functions reached the hand-off, so no gate had stopped it. Per function, before → after (records at the hand-off): | Function | dogfood subset | dev boot | runtime harness | |---|---|---|---| | toggle route (row 17) | 5 → 0 (`findOne` 3, `update` 2) | 5 → 0 | — | | `verifyScimBearerToken` (row 18) | 0 → 0 | 1 → 0 | — | | `beforeUpdateOrganization` (row 19) | 0 → 0 | 1 → 0 (`sys_organization` `findOne`) | — | | `enforceProjectMembership` (row 21) | — | — | 2 → 0 | | row 20 functions | 0 → 0 | 0 → 0 | 0 → 0 | | all records | 1601 → 1596 | 300 → 293 | 39 → 37 | Before = the base tree with the probe. After = the change with the probe: the dev boot and the harness on the final tree (`9878b925`), and the dogfood subset on the pre-merge commit `4239dd47`, whose row 17 code is the same. The boot's background ticks (the outbox claims) make the totals differ by a few records between runs. The per-function counts are the reading. - **Dogfood subset.** Seven files that reach the card's functions: the two platform-admin route sweeps, the organization-update door, the two SCIM-enabled suites, org-admin reach and membership attribution. 57 tests passed both times. Only row 17 appears in the dogfood suite. This subset reproduces the full-suite census of the measure-first round (`6003676228`) for these rows exactly. - **Dev boot.** `pnpm dev -- --fresh` on showcase with SCIM enabled, driven as the seeded admin. It registers an OAuth client, toggles it twice, toggles a missing id, sends a SCIM request with an unknown bearer, and changes the default organization's slug. The answers were identical before and after: register 201, toggles 200 / 200 / 404, SCIM 401, slug update 200. - **Runtime harness.** A scratch file, deleted afterwards, booted a real engine with `SecurityPlugin` and called `enforceProjectMembership` for a member and a non-member. No open-source composition reaches row 21: no `KernelResolver` sets `environmentId`, and `sys_environment_member` is a cloud control-plane object. The answers were `null` and 403 both times. - **Restored.** The probe was reverted (`security-plugin.ts` blob `5b4ab280` equals HEAD), `plugin-security` was rebuilt, and `ablation-dist-preflight --absent` confirms the marker is gone from `dist/`. The positive control: 4 hits in `dist/` while the probe was live. **One difference that is not a gate (row 17).** Under the hand-off, the engine's static read-only strip ran on the toggle's `update` and dropped the `updated_at` the route supplies, with a WARN. Under `isSystem` the strip does not run. I compared the stored rows: on the SQL driver, both paths store `disabled` and an `updated_at` equal to the driver's own stamp. The only change is that the WARN line no longer appears on each toggle. ## Pins (one per package) and ablations - `plugin-auth/src/principal-less-producers-system-context.test.ts`: a real engine with a context-recording middleware. The toggle route's `findOne` and `update` and the SCIM probe's `findOne` are `isSystem`. The route still answers 200 and flips the stored flag, and still answers 404 `RESOURCE_NOT_FOUND`. The verifier still resolves a known bearer to its connection, and still answers `null` for an unknown one. - `plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts`: both slug-guard reads are `isSystem`, and the guard still refuses with `FORBIDDEN` / 403 while an active environment exists. **On an engine that refuses a principal-less, non-system context, the guard still refuses.** The pre-existing catches are pinned as they stand: a read that throws ends the hook without refusing. - `runtime/src/http-dispatcher.membership-system-context.test.ts`: the membership read is `isSystem`, a member passes, and a non-member gets 403 `PROJECT_MEMBERSHIP_REQUIRED`. **On an engine that refuses a principal-less, non-system context, the non-member is still refused.** The pre-existing fail-open catch is pinned as it stands: a read that throws lets the request through. - **Ablations.** Each went through `scripts/ablation-replace.mjs`: the anchor hit once, the mutation was verified on disk, and the restore was proven (blob equals HEAD, `git diff HEAD` empty). Each pin imports its subject from `src`, so no build sat between the mutation and the run. - A, row 17, `withSystemContext` dropped: 2 red. - B, row 18, trailing context dropped: 2 red. - C, row 21, query context dropped (re-run on `9878b925`): 3 red, including the refusing-engine non-member case. - D, row 19, `withSystemContext` dropped: 2 red, including the refusing-engine case. ## Tests and gates (at `9878b925`) - New pins, on `9878b925`: plugin-auth 2 files, 9/9 passed. runtime 1 file, 5/5 passed. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` on `9878b925`: 330 files, 4654 passed, 19 skipped. `pnpm --filter @objectstack/runtime run typecheck`: exit 0. - plugin-auth on `60c5f22c`: the suite (126 files, 2607 passed, 10 skipped) and `run typecheck` (exit 0). The only commit since, `9878b925`, touches `runtime` and the changeset, and plugin-auth imports neither. - **The runtime suite caught a spelling of mine.** The membership read first carried its context as a trailing third argument. Eight existing assertions read the read's two arguments: `toHaveBeenCalledWith` in `http-dispatcher.test.ts` and `http-dispatcher.membership-skip-boundary.test.ts`. They turned red. The context now rides inside the query instead. The opt-in is the same and so is the engine's reading (ObjectQL merges the two), and both suites pass unedited. - `eslint --no-inline-config` over the 7 changed `.ts` files: 7 files, 0 errors, 0 warnings. These 7 are the whole population whose lint verdict this diff can move. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so no untouched file's verdict can change. The repo-wide `pnpm lint` is CI's. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `9878b925` derived 98 commands. All 98 ran and exited 0. The `--ran` reconciliation over the exit-coded record reads: "98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3)". The same 98 also ran green on `60c5f22c`. - The branch sits 3 commits behind `origin/main` (`9dce6353`). Those commits touch `content/docs/permissions/sso.mdx` and a `rest` test, none of this diff's files. objectstack-ai#21902 is merged into `main` and contained in this branch. ## Acceptance notes - **The fail-open catches on rows 19 and 21 are unchanged.** They are pre-existing, and row 21's is documented as deferred. This PR removes the path by which a principal-less deny would trip them: both reads are now `isSystem`. A read that throws for any other reason still skips the slug guard (row 19) or opens the membership gate (row 21). Both behaviours are pinned as they stand, so the seat can sequence them before the deny. - **Row 19 in the open-source composition.** `sys_environment` is not registered there, so the environment read throws before it reaches the engine middleware. The catch then ends the hook, and the slug guard never refuses in an open-source deployment. It acts only where the object exists. Measured on the dev boot: the slug change answered 200 and recorded no environment read at the hand-off. - **NOT MEASURED: a cloud composition.** An `isSystem` read also bypasses any host read hook keyed on the caller, such as a control-plane org-scope hook. I measured the six named gates only, and only in-repo. - `mintScimConnectionCredential` inserts without the opt-in. It has no runtime caller (tests only) and is not exported from the package entry, so nothing produces through it today. Noted, not changed. - The census page (`content/docs/permissions/system-context.mdx`) is current. `--fix` moved its held declaration count from 25 to 26, for the new trailing-options type on the SCIM probe. It asked for no anchors. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…jectstack-ai#21943) Part of objectstack-ai#21932 Clause-②: no ## What changes The platform checklist gains items for the rules the 17.7 pre-release security follow-up landed, and two re-checks from the card are resolved. All edits are in `docs/qa/platform-checklist/areas/*.json`. `automation.json` is untouched (open PR objectstack-ai#21928 holds it). | Card row | Disposition | Item | |---|---|---| | objectstack-ai#21792 (PR objectstack-ai#21809) settings audit and secret-valued settings | new item | `platform-core.settings-audit-secret-fingerprint` | | objectstack-ai#21846 (PR objectstack-ai#21872) implicit account linking | new item | `identity-auth.implicit-account-linking-ownership` | | objectstack-ai#21839 (PR objectstack-ai#21890) share-link password | three clauses added, rev 4 to 5 | `access-security.share-link-capability-tokens` | | objectstack-ai#21836 (PR objectstack-ai#21879) global search skips unreadable objects, plus the two cases objectstack-ai#21880 lists | new item | `search.global-search-skips-unreadable` | | re-check 1: A2 / A7 and the plugin-driver boundary | rev 2 to 3 | `integration-system.datasource-credential-refusal-matrix` | | re-check 2: the objectstack-ai#21845 CLI and quorum N1 notes | already applied by objectstack-ai#21891, no edit | `cli.scaffold-first-run`, `cli.scaffold-console-first-paint`, `approvals.quorum-m-of-n` | Each item states rules, not reproductions. Withheld security detail stays out. ### Grounding, per row - **Settings audit fingerprint.** Both ledgers record the keyed digest for a secret-valued setting, or no fingerprint when none is available, and never the value or an unkeyed hash. Grounded in `settings-service.ts#secretAuditDigest`, `config-change-audit.ts#CONFIG_CHANGE_ACTION` and the contract text at `crypto-provider.ts#keyedDigest`. The pin is `settings-audit-secret-digest.test.ts` (7 cases). The offline check carries a positive control: the non-secret key's unkeyed digest IS found, so a no-hit on the secret rows means something. The no-keyed-digest arm cannot be reached on a stock boot, so that clause is scored from the pin. - **Implicit account linking.** Four rules: no implicit link to an unverified local user; an unlink is honoured; an explicit, signed-in link still works and lifts the refusal; the platform IdP exception holds only on its OAuth path. Grounded in `implicit-account-linking.ts` (`decideImplicitLink`, `IMPLICIT_LINK_REFUSED`, `PLATFORM_IDP_PROVIDER_ID`, `recordUnlinkTombstone`, `refuseImplicitAccountLink`) and the published `sso.mdx` section. The pin is `implicit-account-linking.test.ts`. The item reuses the local OIDC provider recipe from `identity-auth.linked-accounts-social`. The platform-IdP clause and the operator override are pin-scored, and knownGaps says why. - **Share-link password.** The stored hash leaves on no exit (mint, list, redemption). The password is accepted from the `X-Share-Password` header, the query form is still accepted, and the default CORS allow-list carries the header. Both public routes answer `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, and the authenticated routes do not. Grounded in `share-link-service.ts#withoutPasswordHash`, `share-link-routes.ts#SHARE_LINK_PUBLIC_RESPONSE_HEADERS`, the runtime `share-links.ts#PUBLIC_RESPONSE_HEADERS` and `adapter.ts#DEFAULT_CORS_ALLOW_HEADERS`. The pins are the `[objectstack-ai#21839]` blocks in `share-link-password.test.ts`, `share-links-public-cache-headers.test.ts` and the hono-plugin CORS case. Existing clause indices are unchanged. - **Global search.** An unreadable object is never queried, named or counted. An explicit `objects=` naming one answers exactly as a name that matches no object. The object stays refused at its own door. Row scope still narrows a searched object, and a term found only in a field hidden from the caller yields no hit. Grounded in `protocol.ts#searchAll` (the `canReadObject` pre-filter and the `getQueryableFields` narrowing). The pins are the dogfood `search-skip-unreadable.dogfood.test.ts` and the 12 unit cases in `protocol.search-skip-unreadable.test.ts`. The two objectstack-ai#21880 cases have no end-to-end pin yet, and knownGaps says so. The open pinyin-companion finding on objectstack-ai#21880 is recorded as a knownGap with a flag-off instruction, at class level only. The persona reuses the area recipe `qa-contributor-bound-member`. - **Datasource credential matrix.** A2 / A7 (`acceptance[1]` and `acceptance[6]`) are recorded as a known environment gap. They need a reachable credential-protected database of a shipped driver, which no run has had. No recipe is claimed, because none is proven. A successful publish alone may not score them, and the stored-credential half of A7 can be read as a partial reading. Separately, the unknown-driver clause, step 7, its negative and the title now state the ruled boundary from objectstack-ai#21921 and the docs note objectstack-ai#21927. For a plugin driver, only the fixed spellings are redacted (the canonical keys, the former aliases and URL credentials). A non-canonical key served as written is the boundary, not a FAIL. Grounded in `common.zod.ts#CANONICAL_CREDENTIAL_KEYS` and `datasource-credential-redaction.ts#redactableConfigKeys`. ### Re-check 2 evidence (no edit) At the claim ref `9dce635337`: - `cli.scaffold-first-run` (rev 3) step 0 and `cli.scaffold-console-first-paint` (rev 3) step 0 both drop the trailing `npm install` and warn against adding it. Their rev 3 history entries cite objectstack-ai#21845. No other `npm install` step remains in `cli.json`. - `approvals.quorum-m-of-n` (rev 4) `negative[0]` requires a NON-PRIVILEGED repeat actor and names the documented admin override (objectstack-ai#3424) as never a distinctness FAIL. ## Remaining on objectstack-ai#21932 (held, not in this PR) - The objectstack-ai#21864 row (public-form withdrawal layering). Its PR is still open. - The objectstack-ai#21928 row (run-state trigger record mask). That PR adds its own item in `automation.json`. objectstack-ai#21932 remains open for these two rows. ## Validation (at `a72b827e43`) - `pnpm check:platform-checklist`: exit 0. It reports 15 areas and 273 items (269 active, 2 planned). The baseline was 270. Symbol anchors resolve 674 of 684 (baseline 657 of 667): all 17 new anchors resolve, and the objectstack-ai#16898 residual is unchanged at 10. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 13 commands, and all 13 exit 0. `check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET: `@objectstack/formula` and `@objectstack/lint` were not built). After building them it exited 0. `--ran` reconciliation: 13 derived, 13 run, 0 unrun. - No package source changed, so there is no package build, test or typecheck. No changeset: `docs/qa/**` publishes nothing. ## Acceptance notes - Source citations name test cases and symbols, never line numbers, because `check:platform-checklist` refuses a `file:line` pin. - `content/docs/data-modeling/drivers.mdx` says a plugin driver's `config` is "stored and served to administrators as written". The read redactor still withholds the canonical spellings (`password`, `authToken`), the former aliases and URL credentials for such a driver (`redactableConfigKeys`). So the docs sentence is slightly broader than the code, and the code is the more protective of the two. The checklist follows the code. This is noted only, with no card. Carrier: none. - A run of `search.global-search-skips-unreadable` picks the walled object and the hidden-field value on the live boot, behind premise guards. The item names likely candidates and does not assume them. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ Co-authored-by: Claude <noreply@anthropic.com>
…-per-head re-queue verdict (objectstack-ai#21946) Part of objectstack-ai#21933 — this PR delivers the classification, the comment, and the once-per-head re-queue verdict. It does not deliver the automatic re-queue act: the workflow holds no credential that can enqueue a pull request (see "Blocker" below). The card should stay open for that half. ## What changed `.github/workflows/merge-queue-triage.yml` gains limb ③: 1. **Classify, from the job record only.** A job is `infra:no-runner` when it ended `cancelled` with an empty `runner_name` and no steps. A cancelled job the record does not decide (it did get a runner) is checked for the platform's "The job was not acquired by Runner" check-run annotation. No log text is read. The **build** is `infra:no-runner` only when that explains every red. Every other red job must be an aggregate gate whose failed steps are all `Verify … results`, and whose own shard family (`NAME (k/n)`) has a no-runner member. Anything else makes the build `failure`, read as before: a real failing step, a cancelled job that had a runner, an aggregate from a different family, or an annotation that could not be read. 2. **Re-queue budget: one per PR head.** On an `infra:no-runner` build, the script reads the PR's current head through `pulls.get`. The comment carries a durable per-head marker: a hidden HTML comment named `merge-queue-infra:no-runner` with the PR number, head sha and run id. The next `infra:no-runner` red on the same head finds that marker. Its verdict is then "hand to a person, do not re-queue", and it names the earlier queue build. A `failure` build never gets a verdict and never writes the marker. If the head or the PR's comments cannot be read, the verdict is `unknown`, never `once`. 3. **The comment names the class:** `分类:infra:no-runner` or `分类:failure`. For `infra:no-runner` it lists the no-runner jobs, the aggregates they explain, and the verdict. For a mixed build it still lists the no-runner jobs, and it names the red those jobs cannot explain. ⛔ The attestation rule is unchanged: a shard that never ran still does not count as passing (objectstack-ai#6082). The comment says so too. Nothing here changes whether a build is green. The idempotency read of the PR's comments now paginates, because the per-head markers live in those comments. Permission change, declared: the job gains **`checks: read`** (read only) for the annotation leg. No other grant changes. The harness now pins that the job holds no `contents:` grant. ## Blocker — the re-queue act The workflow's verdict cannot be carried out by the workflow. - **No enqueue credential.** The only enqueue path measured in this repo is `enablePullRequestAutoMerge` through the fleet App token. `fleet-write.yml` documents that this needs `contents: write`, which is "ONE consumer". This workflow's `GITHUB_TOKEN` holds `actions: read`, `pull-requests: write`, `issues: write` and now `checks: read`. - **The token's own grant would not be enough either.** Granting `contents: write` to `GITHUB_TOKEN` and calling `enqueuePullRequest` / `enablePullRequestAutoMerge` would widen this job's permissions. Even then, the merge group would be created by `GITHUB_TOKEN`, and GitHub does not start workflow runs for events that `GITHUB_TOKEN` causes. It is not established that CI would ever build such a merge group, and this PR does not measure it. So the comment says the workflow will not re-queue, and asks a person to re-queue once. The harness makes an enqueue call an unmodelled API that fails the battery (mutation M28). Wiring the act needs a decision: either a second consumer of the fleet App token with `contents: write`, or a measured `GITHUB_TOKEN` path. ## Done-when, clause by clause - **A synthetic no-runner cancellation is classified and re-queued once.** - Classified: proven. N1 replays the real job records of queue build 37374282440. N2 replays queue build 37371558473, with eight no-runner jobs and two aggregates. N7 uses the real annotation of check run 111979038621. - Re-queued once: only the verdict is proven. N1 grants one re-queue, and the N3 pair (run 2 reads run 1's own comment) sends a second red on the same head to a person. N4 proves the budget is per head. The act is blocked, as described above. - **A real shard failure is not re-queued.** N5 (real failure), N6 (no-runner beside a real failure) and N9 (an aggregate from another family) all classify as `failure`, with no verdict and no marker. - **The triage comment names the class.** N1 through N10 all assert the class line. - **Test / dry-run fixture for both cases.** - Ten scenarios cover limb ③. The new fixtures in `scripts/fixtures/merge-queue-triage/` are trimmed real records, with provenance in the README: `run-37374282440.jobs.json`, `run-37371558473.jobs.json` and `check-run-111979038621.annotations.json`. - Nine new self-test mutations (M20 to M28) each turn the battery red at the scenario they name, with a named control scenario that stays green. ## Validation (at 499d0ae) - `node scripts/check-merge-queue-triage-outcome.mjs` → `OK (133 assertions over 34 scenarios …)`. The base had 90 assertions over 24 scenarios. - `node scripts/check-merge-queue-triage-outcome.mjs --self-test` → `181 assertions, 32 mutations of the shipped script each driven to red`. - `node scripts/pm/dispatch-gates.mjs --commands` derived 51 commands. 50 ran with exit 0, including `pnpm check:workflow-status-functions`, `check:nul-bytes`, `check:required-contexts` and `check:shard-attestation`. Reconciliation `--ran`: 51 accounted, 0 UNRUN. - NOT MEASURED: `pnpm check:pm-dispatch-gates`. Its `--self-test` half alone exceeded the 10-minute foreground cap (exit 124). Its log showed 1785 pass marks and 1 fail mark. The fail mark is on `packages/qa/dogfood/test/per-file-cwd.setup.ts`, a path this diff does not touch. Declared to CI. - Narrowed lint: `eslint --no-inline-config --format json scripts/check-merge-queue-triage-outcome.mjs` → 1 file, 0 errors, 0 warnings. The repo config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The workflow YAML is not an eslint target. ## Acceptance notes - The triage workflow listens to `workflows: [CI]` only. Run 37371558492 is the **Governed Surface Guard** workflow, and its no-runner red is never triaged. The same holds for the sibling guard runs that ejected objectstack-ai#21872 that afternoon. Out of this card's file surface; noted here only. - The budget keys on the PR head read at triage time. A push that lands between the ejection and the triage run would be charged for the earlier head's red. That is the conservative direction: the next red reaches a person sooner. - A run whose conclusion is `cancelled` (not `failure`) still gets no comment. That is the existing eviction rule, unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21846
Clause-②: no (narrowing)
What changes
Implicit account linking on external sign-in (OAuth, OIDC, SSO) now requires the library's standard local-ownership condition. The platform identity provider keeps its documented exception, and a user's unlink is honoured. This follows the ruling recorded on the card (「算漏洞,收紧」).
objectstack-cloud) links implicitly only to a local user whose email is verified. Otherwise the callback answerserror=account_not_linked, the same code better-auth's own refusal produces. No link is written and the local row stays unverified, so the link no longer setsemailVerifiedon an unverified row.emailVerified=false). The exception applies only to its OAuth sign-in path (source.method === 'oauth'), so an SSO provider registered under the same id gets no exception./link-socialis still allowed and ends the refusal.account.accountLinking.requireLocalEmailVerified:true: also handed to better-auth, so the strict form applies to every provider, the platform one included;false: turns off only the local-verification check; the unlink rule stays.Mechanism (better-auth 1.7.3, measured in the installed
dist/)requireLocalEmailVerifiedis one global boolean. It has no per-provider form, andtrustedProvidersdoes not relax it, so it cannot carry the platform exception. The vendor flag therefore staysfalseby default, and the requirement is enforced at theuser.validateUserInfoseam.handleOAuthUserInfocallsuser.validateUserInfowithaction: 'link-account'and the provider id, right beforelinkAccountand theemailVerifiedflip. Every implicit-link entry goes through it: the OAuth callback, id-token sign-in, one-tap, oauth-proxy and SSO.linkin the parsed OAuth state (getOAuthState()), and only when that state'slink.userIdequals the user being linked.generateStatewriteslinkafter the client'sadditionalData, so a client cannot forge it.sys_verificationrow per user and provider (account-unlinked:<user id>:<provider id>), created inaccount.delete.before, scoped to the/unlink-accountpath. A row is only ever created or deleted, never rewritten, so no write passes through a state with less protection and concurrent unlinks each keep their own row. A failed create is logged aterrorand rethrown, so the unlink fails and the provider stays linked (fail-closed). A landed link deletes only that provider's row, before the identity source is stamped; deleting the user deletes all of that user's rows by prefix. Records go through the database adapter. When a host configures better-authsecondaryStorage, the auth manager now also setsverification.storeInDatabase: true, so the record stays a database row behind the cache and survives eviction; hosts withoutsecondaryStorageare unchanged.New module:
packages/plugins/plugin-auth/src/implicit-account-linking.ts. Wiring:auth-manager.ts(validateUserInfo,account.accountLinking,composeDatabaseHooks).Docs:
content/docs/permissions/sso.mdxgains a "Linking to an existing account" section (the verified-email rule, the platform-provider exception, unlink and explicit re-link, the operator override, and whattrustedProvidersdoes and does not relax);content/docs/permissions/authentication.mdxpoints to it from the OAuth callback step.auth-service.mdxandservices-checklist.mdx, also named by the docs drift check, say nothing about linking and are unchanged.Tests
All at head
93ed0240e1unless noted.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 121 files, 2538 passed, 10 skipped.src/implicit-account-linking.test.ts: 23 passed. Besides the decision table, the vendor config and the end-to-end OAuth round trips over the real better-auth pipeline (stubbed IdP, in-memory engine), it covers:/sign-in/social: refused for an unverified user, linked for a verified one;linkinadditionalDatais refused;secondaryStorage, the record is a database row and survives evicting every verification cache entry;pnpm --filter @objectstack/plugin-auth typecheck(src, examples,check:test-typecheck): exit 0.scripts/ablation-replace.mjs, each restored to the HEAD blob withgit diff HEADempty:account.delete.beforeremoved: the store-fault test fails;dispatch-gates --ranat83010a685e(round 2): 105 derived, 104 run with exit 0,check:dual-build-cjs-loadsNOT MEASURED (exit 3, it needs a whole-workspace build; declared to CI). Also exit 0:check:adr-0087-registration,check:error-code-casing,check:durability-log-level,check:startup-registry-verdict.scripts/engine-double-contract.pinned.jsonis regenerated (--write) for the new test file's pinned double, a coverage-only addition.eslint --no-inline-config --format jsonover the 3 changed TS files: 0 errors, 0 warnings. The config has no type-aware linting, so this diff cannot change a verdict on an untouched file.Acceptance notes
account_not_linked. The vendor's own refusal on those paths answers 401OAUTH_LINK_ERROR. On the browser callback the two are identical (error=account_not_linked). No in-repo or objectui consumer reads either code.objectstack-cloudmust re-link from account settings before platform SSO signs them in to that environment again. That follows the ruling's wording, and the platform exception is about the verification precondition only.requireLocalEmailVerifiedbecomes unconditional, the platform-provider exception needs a new carrier, for example the owner seed. Carrier: the PR that bumps better-auth to that minor, where the pinned end-to-end test turns red.link.userIdbinding cannot be reached through a real flow today (the explicit-link callback always passes the linking user), so no test turns it red without a synthetic state.Generated by Claude Code