fix(plugin-auth): GET /organization/list-user-invitations honours the declared requireEmailVerificationOnInvitation - #16730
Conversation
…mailVerificationOnInvitation better-auth 1.7.2's listUserInvitations refuses every unverified session unconditionally, while accept / reject / get-invitation read the option AuthManager declares false. Rebuild the endpoint in place on the organization plugin's own endpoints record from the vendor's own options object, with the verification refusal asked against the declared option; the listing stays the vendor's getOrgAdapter(...).listUserInvitations (session email, pending only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ity, not options identity createAuthEndpoint shallow-copies the options record to append its base middleware, and the vendor's $ERROR_CODES entry carries a toString helper; pin method/query/metadata by identity and code/message by value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
- classify the restated vendor code as foreign-vocabulary (dispatcher error vocabulary row, same shape as the impersonate precedent's) - let the engine-double ledger record the new suite's doubles - apply the fake engine's limit by presence, not truthiness - log the vendor-drift fallback at warn: a visible 403 is a functional degradation under AGENTS.md's one-question rule, not a durability one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
check:doc-authoring — a runtime string reaches readers who cannot resolve an issue id; the anchor lives in git history and the source comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift Check11 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 31 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 9488217b0a479fd68cc0db2427299f583885ec12 && git checkout 9488217b0a479fd68cc0db2427299f583885ec12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7f96e1417e01d011884272b28278b6b400521415 f8685bbde9a9d5a8f5225d243d4bb8157e2c4e22 && git checkout -B drift-repro 7f96e1417e01d011884272b28278b6b400521415 && git merge --no-ff f8685bbde9a9d5a8f5225d243d4bb8157e2c4e22
node scripts/docs-audit/affected-docs.mjs --json 7f96e1417e01d011884272b28278b6b400521415 |
Contract review (
|
CI red diagnosed —
|
…n PLUGIN_ROUTE_MODULES
The module added for the declared-verification fix writes a Hono response, so
the route-envelope walk discovers it, and a discovered file absent from the
table is an ERROR rather than a default.
Measured with the gate's own `scanHonoRouteSource`: one body, all six asserted
counters zero. The single write is `return ctx.json(pendingInvitations)`, whose
argument is an identifier -- the deliberate relayed-body blindness -- and the
three refusals are `throw APIError.*`, which this surface does not count. So
`{}` is the honest declaration: nothing this file builds departs from the
envelope. No ruled state applies and nothing was hoisted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
CI repair — the route-envelope declaration for this card's new route moduleFollow-up commit
The module this card added writes a Hono response, so Counters — measured through the gate's own scanner, not inferredDriven through
All six asserted counters are zero, and every The declaration, and why it is that one
It also records the contrast with its twin: Nothing was hoisted. There is no object literal in this module to hoist, and the named forbidden move was not available even in principle. Gate verdict
(12 modules / 8 conformant before this commit.) Ablation — a green that can be made redFrom the committed state, deleting only the new declaration line, under a
Empty-hash-reads-as-failure and no-op-mutation guards were both armed and neither fired. Gate union, re-derived after the commitThe first derivation from this branch's tree printed a STALE TREE warning (53 commits behind 78 derived / 74 run and measured green / 4 NOT MEASURED / 0 UNRUN / 0 exit 1. The tool's own The four are Worth noting for the record:
eslint, narrowed and proven
ChangesetNone added, deliberately. This commit's only file is 验收备注 — addition
Generated by Claude Code |
Pointer for re-review — the owed
|
Contract review (
|
| file | change |
|---|---|
scripts/check-route-envelope.mjs |
+24 / −0: one PLUGIN_ROUTE_MODULES entry, 'packages/plugins/plugin-auth/src/list-user-invitations-verification.ts': {}, with a 22-line comment (measured counters, why the write is an identifier, the explicit contrast with admin-impersonate-endpoint.ts) |
Nothing else is touched (git diff --stat c62690f75..f8685bbde names that one file). The module, the predicate, the tests, auth-manager.ts, the vocabulary row, the engine-double ledger and the changeset are byte-identical to the reviewed head. No ratchet, no exempt, no vendorWire, no other table entry moved, no hoist.
The checker-definition verdict on {}
The question: does {} (all six counters zero) assert "every response this module writes is the ObjectStack envelope"? If so it is false — the only 2xx write, return ctx.json(pendingInvitations) (line 201), is better-auth's bare invitation array. By the checker's own text, it does not assert that.
What the surface-3 header says the counters govern (check-route-envelope.mjs, "## Relayed bodies are not counted, on purpose"):
Every counter reads an OBJECT LITERAL. A
c.json(upstreamBody, status)that relays a control plane's own answer is invisible to all six, which is the correct answer rather than a gap: this gate governs the bodies this repo BUILDS, not the bytes it passes through — the same reasoning that makes a dispatcher domain's passthrough "kind 2" rather than drift.
What vendorWire is for — and it is narrower than "this module writes the vendor's shape on purpose":
A body this repo BUILDS whose shape is a VENDOR's wire format — required by that vendor's own client library — is outside
BaseResponseSchemabecause the vendor owns the shape. Surface 2 already names this class kind 3 ("a foreign wire format a client library requires") and treats RELAYED instances as by-design invisible to the counters;vendorWireis kind 3's counterpart for the case where such a body becomes VISIBLE because the handler was reimplemented in-repo, turning a relay into a built literal.
The hoist prohibition draws the same line, and says which side a relay is on:
For a body this repo only relays, that invisibility is the design (see above). For a body this repo BUILDS it is the exact state this gate exists to prevent: the repo still owns the shape, and the hoist's only effect is to hide it from the auditor.
And the audit function itself names the declaration for this exact case (the vendorWire && pinned === 0 branch):
declares
vendorWirebut pins no non-conforming body. A vendor-wire declaration over nothing is a waiver over whatever this file emits next: the counters would stop meaning anything here the moment a bare body appeared. A module that departs from the envelope in no way these counters can see is conformant — declare {}.
So the class split is relayed vs. built literal, not vendor-shape vs. envelope. Both are kind 3 (vendor wire); only the built literal gets the vendorWire state, because only a built literal is visible and needs a ruled count to stay green. Applied to this file:
- The write is
ctx.json(pendingInvitations)wherependingInvitationsis(await getOrgAdapter(ctx.context, options).listUserInvitations(userEmail)).filter(inv => inv.status === 'pending')— the vendor adapter's own rows, narrowed by the vendor's own post-filter. No object literal exists anywhere in the handler; the.filterdoes not re-shape a row. There is nothing to hoist, so the named forbidden move (const BODY = { … }; return c.json(BODY)) is not available even in principle. - The three refusals (lines 181, 188, 194) are
throw APIError.*, not.jsonwrites; the scanner does not count them, by design — identical to the impersonate entry's own comment ("the four refusals arethrow APIError.from(…), which this surface does not count"). - The impersonate precedent (
admin-impersonate-endpoint.ts,unenveloped: 1,vendorWire,notewithvendor:/reader:/partner:) is the other half of kind 3: reimplementing that handler produced a built literalctx.json({ session, user })(its comment: "It is the ONLY body this file BUILDS that any counter here reads"). That is why it has a count of 1 to pin and why it needed a ruled state. This rebuild took the same door but never converted the relay into a literal. - Table precedents already read
{}this way:inbound-rate-limit.ts: {}("That the counters cannot see INSIDEbuildApiError(…)… is the deliberate relayed-body blindness, so read this{}for what it is: nothing this file BUILDS departs from the envelope");error-response.ts("both sites writeresolved.body, an IDENTIFIER, and the dialect counters only see object literals"); and — the closest — theauth-plugin.tsentry's own note: "The rest of this file (~46 bodies) is better-auth's own wire format, relayed rather than built, and stays invisible to these counters by design." Better-auth's wire, relayed, has been{}-class on this surface since [Decision] Pre-auth discovery/bootstrap payloads: inside BaseResponseSchema (coordinated objectui flip) or ruled exempt with reasons — today they are neither #9389; this file is one more instance.
Is it a scanner-blindness pass? The blindness is the declared design, and it was not manufactured: the control below shows a built literal at this site is caught. {} is also stricter than any vendorWire spelling would be — the counters stay live at zero, so the next literal at this boundary goes red, whereas a vendorWire at 0 is refused as "a waiver over whatever this file emits next".
Verdict on the question: (a) — {} is honest by the checker's own definitions. The one residual judgement call is that this body is computed (adapter call + filter) rather than a pure c.json(upstreamBody) passthrough; it resolves toward relay because the AST class the checker uses is "an identifier, a call, a member access", the shape ownership is the vendor's (adapter rows + the vendor's OpenAPI schema the endpoint passes through), and the filter mints no key.
Numbered verification
- Measured here, with the checker's own functions. I extracted
check-route-envelope.mjsand its three helpers from the PR head plus the module into a scratch tree (globaltypescriptsymlinked in; no checkout, no install) and drovescanHonoRouteSourceon the module:{"bodies":1,"reads":0,"unenveloped":0,"errorWithoutMessage":0,"errorCodeNotString":0,"strayKeys":0,"stringError":0,"siblingCode":0}, everysiteslist empty. Matches the commit message and the table comment exactly. - All spellings through
auditPluginRouteModule:{}→ GREEN.vendorWire+unenveloped: 1+ a conforming three-party note (the entry ruling 5579654057 / handoff 5582291725 asked for) → RED: "unenveloped: found 0, declared 1 — 1 fewer than pinned … or drop the entry entirely — nothing departs from the envelope here any more".vendorWire+unenveloped: 0+ note → RED: "declaresvendorWirebut pins no non-conforming body … declare {}". Undeclared → RED "NOT DECLARED". So the authorised entry cannot be written green in either count, independently confirming the seat's 5582971711. - Control — the blindness is not a loophole at this site: mutating line 201 to
ctx.json({ invitations: pendingInvitations })measuresunenveloped: 1at:201and makes{}RED; mutating toctx.json({ success: true, data: pendingInvitations })measures 0 and stays green. The gate sees exactly what its header says it sees. - Table comment vs. code: the entry's comment claims match the source — one
ctx.jsonat 201 with an identifier argument; refusals at 181/188/194 arethrow APIError.fromStatus/APIError.from; the citedinbound-rate-limit.ts: {}precedent exists on surface 4 (line 996 on this head); the impersonate contrast is accurate to that entry's own text. - Authority:
{}is the author-available path the NOT DECLARED diagnostic names first ("If every body it BUILDS is the declared envelope, declare {}"). No ruled state was added or widened, so the ⛔ MAINTAINER-ONLY marker (check:engine-double-contractfires at CI time, not authoring time — four independent PRs tripped it on brand-new test files in one shift, and a pre-warning in the brief did not prevent it #8435) is not engaged by this commit. ThevendorWireauthorisation in 5579654057 was not used — correctly, since it cannot be. - CI on
f8685bbde— 37 check runs: 30 success · 0 failure · 0 in progress · 7 skipped.Lint & Repo Gatesis green (job 102003068925, 09:08–09:33Z), socheck:route-envelopepasses on the head. Green:Test Core(aggregate + 6 shards),Build Core,TypeScript Type Check(+ workspace / source gates / consumer gates / debt ledger),Dogfood Regression Gate(aggregate + 3 shards),Dogfood Verify CLI,Temporal Conformance,Check Changeset×2,Check PR Size,Governed Surface Queue Guard, both single-writer guards,Part-of PR must not also close its card,Check Documentation Links,Flag docs affected by code changes,Auto Label,filter. Skipped (path / opt-in):Packed-tarball smoke×2,Console Pin Gate,Build Docs, oneAuto Label, oneCheck PR Size. - PR state: draft;
mergeable_state: clean(readunknownon my first fetch,cleanon the second); labelsdocumentation, size/l, tests, tooling, needs:contract-review(the carrier was re-hung as the handoff instructed); 5 commits, 7 files, +839/−2; 64 commits behindorigin/main(merge-base6ba0db4e0) — no conflict reported. - Suite not re-run in this seat (no
node_modulesin the review checkout, as before); the commit does not touch any package, so thec62690f75readings and CITest Core6/6 green onf8685bbdestand.
Findings
F1 — discharged, with a correction to my own prior reading. check:route-envelope is green on the head via {}. The previous review's F1 said the honest declaration was a vendorWire entry at unenveloped: 1; that inherited the impersonate precedent's count without measuring this file, and conflated the two halves of the checker's kind 3. The checker's own definitions (quoted above) put a relayed vendor body in the {} class and reserve vendorWire for a built literal. {} here is honest and is the stricter declaration. Nothing owed on the PR.
F2 — still open, unchanged in kind. Shape (a) and the vendorWire authorisation are rulings of the dispatch seat and the director seat (5579654057 cites a verbatim maintainer delegation, 「你应该自主处理完」 / 「自主继续呈报」). There is still no ## Ruling recorded comment written by the maintainer on #16569; the director seat's is titled that but is a seat ruling under delegation. Whether that delegation covers a security-boundary accept-set change is above this seat; recorded, not decided.
F3 — undeclared → refuse diverges from the siblings' default derivation. Unchanged; moot for this repo (declares false); expectation none.
F4 — suite not re-measured in this seat. Unchanged; CI is the instrument (§6, §8); expectation none.
F5 — three suites now emit the vendor-drift warn on every construction (mocked endpoints-less organization plugin). Unchanged; expectation none for this PR; follow-up candidate.
F6 — createAuthEndpoint appends the base middleware a second time (2 → 3). Unchanged, same as precedent; expectation none.
F7 — new: ruling 5579654057 item (2) and handoff 5582291725's owed item are unfulfillable as written, and need amending by the director seat, not the PR. The authorised entry (vendorWire, unenveloped: 1) is red in both counts (§2) — the gate's own closing sentence names {}. The ruling's substance (the body is better-auth's wire format, the repo does not own the shape, enveloping it would contradict the vendor OpenAPI metadata this endpoint passes through and break organizations.invitations.listMine) is intact and is exactly what the relayed-body blindness already encodes. Expectation for this PR: none. Expectation for the director seat: amend item (2) to record {} as the correct declaration for a relayed vendor body (the class boundary is relayed vs. built literal, per the checker header), so the card's record matches the tree before it lands. The seat's 5582971711 and the PM's 5583050675 already route this correctly.
Maintainer-only merge: no — by the gate table. This commit takes the author-available {} path; no ⛔ MAINTAINER-ONLY state (exempt / vendorWire) was added or widened, which was the whole of the previous review's F1 basis for "maintainer-only". The residual authority question is F2: every ruling on #16569 is a seat ruling (dispatch seat for shape (a); director seat, citing maintainer delegation, for ratification and the now-moot vendorWire item), not the maintainer's own. If that delegation stands, the director seat lands it per its own execution plan in 5579654057 after amending item (2) (F7); if it does not, this is a maintainer merge. That is the maintainer's / director's call, and this seat records it rather than making it.
Generated by Claude Code
Fixes #16569
What
AuthManagerconstructs better-auth's organization plugin withrequireEmailVerificationOnInvitation: falseon purpose (no mailer wired ⇒ nothing can ever verify an invitee ⇒ requiring verification dead-ends every invite flow). The pinned better-auth 1.7.2 reads that option onaccept-invitation,reject-invitationandget-invitation(all three viashouldRequireVerifiedEmailForInvitationIdAction, whose first line returns the declared value), but itslistUserInvitationshandler refuses every unverified session unconditionally — it never reads the option. So on exactly the deployment shape the declaration exists for, an invitee could accept an invitation they were handed and never list it, and the SDK'sorganizations.invitations.listMine()inbox was empty-by-403 for every user.This PR makes
GET /organization/list-user-invitationshonour the declared option, the ruled shape (a). The endpoint is rebuilt in place on the organization plugin's ownendpointsrecord, under the vendor's own key, from the vendor endpoint's own options object (same path, method,queryschema,use: [orgMiddleware], OpenAPI entry), with one predicate changed: the verification refusal is asked against the declared option instead of assumed. Everything else in the handler is the vendor's, in the vendor's order.Files:
packages/plugins/plugin-auth/src/list-user-invitations-verification.ts(the rebuild + the predicate, header carries the full reading), the wiring inauth-manager.ts(the options literal becomes a namedsatisfies OrganizationOptionsobject so the very object the vendor was constructed with is what the rebuilt endpoint hands togetOrgAdapter),list-user-invitations-verification.test.ts, aforeign-vocabularyrow inpackages/runtime/src/dispatcher-error-vocabulary.tsfor the restated vendor code, the engine-double ledger learning the new suite, and apatchchangeset.The security gate — measured, not quoted
Real
AuthManager→ real better-auth 1.7.2 organization plugin → real ObjectQL adapter over the package's memory-engine double; no mailer; fixture users enter through the audience gate's invitation carve-out and are asserted unverified (email_verifiedfalse) throughout. Owner invitesb@example.cominto two organizations andc@example.cominto one; B signs up.GET /organization/get-invitation?id=…(B's own)POST /organization/reject-invitation(B's own)POST /organization/accept-invitation(B's own)GET /organization/list-user-invitationsEMAIL_VERIFICATION_REQUIRED_FOR_INVITATIONb@example.comonlyGET /organization/list-user-invitations?email=c@example.com(client-side)GET /organization/list-user-invitations, no session, no emailThe pre-fix column is the suite's first run against the unmodified route (4 red on the 403, the premise test green); the post-fix column is the same suite at 16/16.
Both stop conditions of the dispatch were checked and neither holds: (1) accept / reject / get-invitation are already permitted to this session on this shape — the premise test is the first test in the file and it passed before any change; (2) the fix returns nothing beyond what the declared option already grants — the parity test pins the unverified answer equal to the verified answer, and the scope pins below bound it.
Own-email pin, stated positively. The listing is the vendor's own exported
getOrgAdapter(ctx.context, options).listUserInvitations(session.user.email)followed by the vendor's ownstatus === "pending"post-filter. This module writes no query, joins nothing and reads no other table. The suite pins: C's invitation is never in B's inbox; every row in B's inbox is addressed to B (the audience-gate seed row included — it is a real pending invitation to B); a client-side?email=is still refused with the vendor's 400 before the session is consulted; a request with neither session nor email keeps the vendor's 400. "List by organization" and "fetch by id" are not reachable from this route by construction.The triage hard stop ("if the only way is to compute the list yourself in a before-hook, stop") does not trigger: the vendor exports
getOrgAdapterfrombetter-auth/plugins/organization, so the one definition of "which invitations may this session see" stays the vendor's and is called, not copied.Why this shape, and why not a before-hook
admin-impersonate-endpoint.tsreplaces a vendor endpoint on the vendor plugin's ownendpointsrecord, rebuilt from the vendor's ownoptions, so exactly one plugin registers the path (checkEndpointConflictslogs nothing) andauth.api.listUserInvitationsis this endpoint.dist/api/dispatch.mjs: when a before-hook returns a non-context object, dispatch returnstoResponse(before, …)and never reachesrunAfterHooks— thebearer()plugin'sset-auth-tokenecho and every ObjectStack after-hook would silently stop firing on this path. Rebuilding the endpoint keeps the full pipeline.createAuthEndpoint(path, vendor.options, handler)— the suite pinsmethod,queryandmetadataby object identity against the vendor endpoint (better-call'screateEndpoint.createshallow-copies the record only to append its base middleware touse; every vendor middleware is still present).false→ open to an unverified session;true→ refused, byte-identical to today; undeclared or non-boolean → refused, the vendor's own list-route posture. The siblings derive an undeclared value fromhasBuiltInOpaqueInvitationIdGeneration(…), a vendor internal this module deliberately refuses to re-implement — honouring what is declared restoresdeclared = enforced; re-deriving a default would be a second definition of a security posture.false, and logged atwarn(AGENTS.md's one-question rule: the inbox visibly answers a 403, nothing claims a persistence it did not perform — a functional degradation, not a durability one; this deliberately differs from the impersonate precedent'serror). In the suite, a vendor pin reads the installedcrud-invites.mjsand asserts the three siblings still call the option reader (count 3) while the listing still carries the unconditional refusal verbatim — an upstream fix turns that pin red and this module is what gets deleted.Clause-②: yes —
needs:contract-reviewon both carriersRe-derived from the route contract and the export surface, not from the label of "bug fix":
GET /organization/list-user-invitations(ledgereddisposition: 'sdk', clientorganizations.invitations.listMine) answered 403 for every unverified session and now answers 200 for the ones the deployment declared exempt. That is the reference's conformance class — an input class re-chosen between two published codes — which is judgement, not mechanics, and the tier tool's own line says a card that changes contract accept/reject behaviour is contract-review territory. The provisional read stands: yes.src/index.tsor the./rate-limit-storageentry (grep exit 1 on both); no new key on any published payload — the response is the vendor's own OpenAPI array untouched; no new error code (the vendor'sEMAIL_VERIFICATION_REQUIRED_FOR_INVITATIONis classifiedforeign-vocabulary, same as the impersonate rows).node scripts/pm/check-widening-tells.mjs --declaration no --diff …→ "4 changed file(s) read, no widening tell on any declared surface" (exit 0). So the mechanical floor would acceptno; the accept-set change is why the declaration isyesanyway.Verification
pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/list-user-invitations-verification.test.ts→Tests 16 passed (16),VERDICT command-exit 0.pnpm --filter @objectstack/plugin-auth teston85c101db0→Test Files 104 passed (104),Tests 2195 passed (2195),VERDICT command-exit 0. The two later commits touch onlypackages/runtime/src/dispatcher-error-vocabulary.ts(a gate-ledger string;git diff --stat 85c101db0 HEADnames that one file), and plugin-auth does not depend on@objectstack/runtime, so the reading carries to the head.pnpm --filter @objectstack/plugin-auth typecheck(tsc --noEmit+ examples config +check:test-typecheck) on the head →VERDICT command-exit 0,check:test-typecheck: OK(no new test-layer debt).@objectstack/runtime's owntscis NOT MEASURED locally (its dependency closure is not built here;check:type-check-debtsays so with exit 3); the vocabulary edit is one string literal inside an existing array, the vocabulary gate parses the file and finds the row, andtsximports the module (5 exports, row hit 1). CI'sTypeScript Type Checkjob covers it.return options.requireEmailVerificationOnInvitation !== false;→return true; /* ABLATION-16569 */), on-disk prooforig-count 1→0, marker 0→1, mutated blob87f578aa…vs HEAD blob9c788674…; re-run → 5 red / 11 green: red are exactly the reported bug, the own-email scope pin, pending-only, parity and thedeclared falsepredicate case; green are the premise, the?email=and no-session guards, the endpoint-shape pins and the vendor pin. Restoregit checkout HEAD -- ABSOLUTE_PATHunder a trap; proven by on-disk blob9c788674…equal toHEAD:blob andgit diff HEADempty.scripts/pm/dispatch-gates.mjs, derived from the merge base,--repo objectstack-ai/objectstack), run after the final commit onc62690f75: 66 derived / 66 run / 64 exit 0 / 2 exit 3 =PREREQUISITE NOT MET(check:dual-build-cjs-loads,check:type-check-debt— both read the whole workspace's builtdist/, which is not built in this worktree; NOT MEASURED locally, CI'sLint & Repo Gatesruns them on a full build) / 0 exit 1;--ranreconcile: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED", exit 0. The families the last two commits added to the derivation (the engine-double ledger and the runtime vocabulary file pulled in 9 more, 57 → 66) were all run. The previous head's one red,check:doc-authoring(a tracker id inside a runtime string), is green here after the string was fixed.pnpm exec eslint --no-inline-config --format jsonover the 4 changed source files → 4 files, 0 errors, 0 warnings. Population read fromeslint.config.mjs(the**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block minusNEVER_LINTED, plus thepackages/**blocks); invariance: the config's own comment states noparserOptions.projectand no typed rules for any block, so this diff cannot move an untouched file's verdict. Repo-widepnpm lintis CI's run.check:nul-bytesOK (8256 files); control-byte grep over the touched files exit 1 (none);check:cross-package-test-inputsgreen (the vendor-source pin locates the package via the manifest-namefindUpspelling and its read lands innode_modules).Changeset
.changeset/list-user-invitations-declared-verification.md—@objectstack/plugin-auth: patch(a bug fix in a released package; notskip-changeset).@objectstack/runtimeis not bumped: the only change there is a classification row in the dispatcher error vocabulary, a gate ledger with no runtime behaviour; the precedent that added rows to the same file forplugin-schema-ui-required-keysdid not bump runtime either.Upstream (b) — proposed better-auth report, not part of this card's delivery
验收备注
admin-impersonate-endpoint.tslogs its vendor-drift fallback atconsole.error; under AGENTS.md's degradation-level rule that case (a visible refusal, nothing claiming persistence) reads aswarn. This PR useswarnfor the same door and leaves the precedent's level alone.auth-manager.test.ts,auth-email-locale.test.ts,verification-email-failure-propagation.test.ts) mockbetter-auth/plugins/organizationwith a plugin double that carries noendpoints, so the construction-time rebuild never runs there and each construction now prints the vendor-driftwarn(silenced by their existingconsole.warnspies). The rebuild path is exercised by the real-pipeline suite instead.optionsviacreateAuthEndpointappends better-auth's baseoptionsMiddlewarea second time touse(measured: 2 → 3 entries). Harmless (the middleware is a no-op return), and the impersonate precedent has the same shape.🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Generated by Claude Code