Skip to content

fix(plugin-auth): a verification-email resend from a visitor who is not signed in answers the same whatever the send does - #30144

Draft
objectstack-fleet[bot] wants to merge 4 commits into
mainfrom
claude/sec-hardening-w
Draft

objectstack-fleet[bot] wants to merge 4 commits into
mainfrom
claude/sec-hardening-w

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:未登录访客调用 POST /api/v1/auth/send-verification-email 时,回答不再随发信结果变化。邮件通道可用时一律答 { "status": true },信在响应之后才发,发送失败只在服务端记一条 error 日志;邮件通道不可用时(未接邮件服务,或 isConfigured() 不为 true,例如 log 传输),访客的每次重发都以同一个 400 EMAIL_SERVICE_REQUIRED 拒绝。已登录用户(cookie 或 bearer)重发自己的地址、以及服务端 auth.api 调用,行为不变。
  • 为什么改:访客重发的回答应当只取决于部署配置,而不取决于发信结果;同时保留「邮件未配置」这一配置错误信号。
  • 风险与回滚:用户可见变化在验证页。邮件通道不可用时(含开发环境默认的 log 传输),访客点「重发」会看到「未配置邮件投递」的提示;邮件通道可用但发送失败时,访客看到「已发送」,失败只见于服务端日志与发信记录。已登录用户的重发不受影响。回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-auth/src/visitor-verification-resend.ts(规则、访客判定与拒绝);auth-manager-auth-instance.ts 的 sendAfterResponseOnSignUpAndVisitorResend;auth-manager-before-hook.ts 新增的一处调用。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/signup-existing-address-success-shape.test.ts,预期 25 个用例全绿,其中 ⑧ 组 7 个。
  • 需要你做的:在本 PR 上书面确认对尚未发布的 .changeset/sign-up-mail-after-response.md 第 12 行的改写(把「重发仍等待并报告失败」限定为已登录用户的重发);Check Changeset 因此按设计保持红,它不是必需检查。

What changes

  • The rule. A verification-email resend (POST /api/v1/auth/send-verification-email) from a visitor who is not signed in answers the same whatever the send does:
    • while the email transport is live (isEmailDeliveryLive()), it answers { "status": true }. The mail is built and sent after the response through sendAfterResponseOnPublicDoor, as the sign-up's verification mail already is, and a send that fails is logged on the server at error (verification email was not delivered);
    • while the transport is not live, every such resend is refused before the endpoint runs, with one coded answer: 400 EMAIL_SERVICE_REQUIRED.
  • Unchanged. A signed-in caller's resend for their own address, by session cookie or by bearer token, still awaits its send, still reports one that failed, and is not refused while the transport is not live. A server-side auth.api.sendVerificationEmail call, which carries no Request, still awaits its send.
  • Telling a visitor apart. In the send callback: the endpoint context's declared path, plus the session the resend endpoint resolved before it called the send (ctx.context.session, null for a caller with none). In the before-hook, where that field cannot answer yet: AuthManager.resolveActor, the actor reader the other before-hook gates use, which reads the cookie and the bearer token alike. getSessionFromCtx alone reads a caller signed in by bearer token as no session there, because better-auth's bearer() plugin applies its cookie only after every before-hook has run (measured; ablation leg 3 below pins it).
  • Files.
    • visitor-verification-resend.ts (new, not exported from the package): the rule, the endpoint-context predicate, and the before-hook refusal.
    • auth-manager-auth-instance.ts: sendAfterResponseOnSignUp becomes sendAfterResponseOnSignUpAndVisitorResend, which also hands the visitor's resend to the after-response path; comments in the callback scoped to the signed-in resend.
    • auth-manager-before-hook.ts: one call, and isEmailDeliveryLive on the hook's host.
    • verification-email-failure-propagation.test.ts: header text scoped to the signed-in resend; assertions unchanged.
  • Changesets. New visitor-verification-resend.md (@objectstack/plugin-auth patch). sign-up-mail-after-response.md corrected, see Acceptance notes.

Tests

Pins in a new top-level describe ⑧ of signup-existing-address-success-shape.test.ts, the real-engine harness the after-response sends already share. deferred() and gatedEmail() moved from ⑦ to module scope (gatedEmail gains a throws outcome) so ⑦ and ⑧ share them.

  • While the transport is live, with a send that reports failure, and with one that throws: three addresses, one of each kind, get the same 200 {"status":true} while the send is still held open by a deferred, never by a wall-clock threshold. Released, the send settles and one error line is logged.
  • While the transport is not live (the log transport's isConfigured() answering false, a service without isConfigured(), no email service at all): the three addresses get the same 400 answer with code EMAIL_SERVICE_REQUIRED, and nothing is sent.
  • While the transport is not live, a caller signed in by cookie and one signed in by bearer token are not refused, and their resend still sends.
  • A server-side call (no Request) still awaits its send and rejects with its failure.
  • ⑦'s signed-in resend case keeps its body and now names its rule: "a signed-in caller's /send-verification-email resend still awaits its send and reports one that failed".
Results
signup-existing-address-success-shape.test.ts @ 95edc4ffde
  Test Files  1 passed (1)   Tests  25 passed (25)   VERDICT command-exit 0

plugin-auth full suite @ 4200bbebc7 (closure rebuilt after the merge of main; plugin-auth built)
  Test Files  220 passed (220)
       Tests  3822 passed | 10 skipped (3832)
  os-verify-lock: VERDICT command-exit 0

pnpm --filter @objectstack/plugin-auth typecheck @ 4200bbebc7: exit 0
  check:test-typecheck: OK, 10 file(s) / 88 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json
One-shot ablation, three legs

Each leg went through scripts/ablation-replace.mjs: anchor hit once, blob changed, the file run, then restored with blob == HEAD and an empty git diff HEAD. The tests import src/ relatively, so no dist/ leg exists to prove.

leg 1  visitor resend handed back to the awaited send
       x live transport, send reports failure ...  10019ms  Test timed out in 10000ms
       x live transport, send throws ...           10012ms  Test timed out in 10000ms
       Tests  2 failed | 23 passed (25)
leg 2  the not-live refusal removed
       x not live (log transport) ...     expected 200 to be 400
       x not live (no isConfigured) ...
       x not live (no email service) ...
       Tests  3 failed | 22 passed (25)
leg 3  before-hook reads the caller with getSessionFromCtx instead of resolveActor
       x not live, signed-in caller by cookie or bearer token still sends:
         EMAIL_SERVICE_REQUIRED body: expected 400 to be 200   (the bearer request)
       Tests  1 failed | 24 passed (25)

Acceptance notes

  • A pending changeset corrected (the DELIBERATE CORRECTION class check-empty-changeset.mjs names). .changeset/sign-up-mail-after-response.md (from fix(plugin-auth): a sign-up answers without waiting for any mail send, whether or not the address already has an account #29975, still unreleased on main) listed "the /send-verification-email resend, which still awaits its send and still reports one that failed" as unchanged. This PR keeps that true only for a signed-in caller, so the line now reads "a signed-in caller's /send-verification-email resend, which still awaits its send and still reports one that failed". Restoring it would publish a sentence this PR makes false. Check Changeset stays red by design until the correction is confirmed here in writing; it is not a required context.
  • No new error code. EMAIL_SERVICE_REQUIRED is already in the ADR-0112 ledger for @objectstack/plugin-auth, and /change-email and the identity import answer it for the same deployment condition. The constant is typed against the ledger, so a ledger change reds its compile. The body is better-auth's flat { code, message }, the shape /change-email's coded refusals answer in; the message names the fix (Settings → Mail).
  • Clause-②: no, measured. No spec schema, export or ledger entry changes. The new module is not re-exported: the built dist/*.d.ts and dist/*.d.mts carry none of its five names (control: AuthManager, 30 hits), and require('./dist/index.js') lists no new key.
  • Verify screens (not touched). The console's VerifyEmailPromptPage and RegisterPage show the server's message when a resend fails, so a visitor sees the refusal's message while no transport is live; while one is live, a visitor's resend now always reads as sent. The docs' resend example (content/docs/permissions/authentication.mdx) is the signed-in, bearer-token call, which is unchanged.
  • Dogfood sweep of packages/qa/dogfood/test. No test sends or reads a /send-verification-email mail. The only caller is upgrade-harness/cli-prerelease-fixture.ts, a fixture generator that is not a test and drives a pinned prerelease CLI, not HEAD code. Nothing to adapt.
  • Test weight. ⑧ adds seven cases to the file, each asking its three addresses concurrently. The file's local run at 95edc4f read tests 7.44s (shared box).

Verification

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on head 4200bbebc7 derived 60 commands; all 60 were run with each exit captured before any pipe, then reconciled with --ran (60 derived, 59 run, 1 NOT-MEASURED, 0 UNRUN).

  • 58 exit 0.
  • node scripts/check-empty-changeset.mjs --base origin/main exit 1: the deliberate-correction refusal above, red by design.
  • pnpm check:dual-build-cjs-loads exit 3, PREREQUISITE NOT MET (packages outside plugin-auth's closure have no dist/): NOT MEASURED locally; CI's build measures it.
  • pnpm check:type-check-debt exit 0, re-run under the verify lock at 4200bbebc7 (re-measure OK: 0 raw tsc errors above any recorded number).
  • node scripts/check-platform-checklist.mjs --changed-from origin/main: exit 0, no anchored file in the change set.

…ot signed in answers the same whatever the send does

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…resolveActor in the before-hook

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…sign-up changeset's resend line to a signed-in caller

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

15 anchor(s) derived from 1 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 55337f4ce35dbe62434b23517547a0b5052b936c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b09fc767ad4a6cd8af1022e8a1044cd703f876ce — the merge of head 4200bbebc7fa86bc43e9e93422cbfaeb134c34d9 into base 55337f4ce35dbe62434b23517547a0b5052b936c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b09fc767ad4a6cd8af1022e8a1044cd703f876ce && git checkout b09fc767ad4a6cd8af1022e8a1044cd703f876ce
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55337f4ce35dbe62434b23517547a0b5052b936c 4200bbebc7fa86bc43e9e93422cbfaeb134c34d9 && git checkout -B drift-repro 55337f4ce35dbe62434b23517547a0b5052b936c && git merge --no-ff 4200bbebc7fa86bc43e9e93422cbfaeb134c34d9

node scripts/docs-audit/affected-docs.mjs --json 55337f4ce35dbe62434b23517547a0b5052b936c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 4200bbe · 2026-11-04T16:32Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4200bbebc7fa86bc43e9e93422cbfaeb134c34d9
Local-runs: none
Scope: packages/plugins/plugin-auth/src/visitor-verification-resend.ts, packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts, packages/plugins/plugin-auth/src/auth-manager-before-hook.ts, packages/plugins/plugin-auth/src/signup-existing-address-success-shape.test.ts, packages/plugins/plugin-auth/src/verification-email-failure-propagation.test.ts + packages/plugins/plugin-auth/src/run-after-response.ts, packages/plugins/plugin-auth/src/auth-manager-actor-guards.ts (resolveActor), packages/plugins/plugin-auth/src/auth-manager.ts (isEmailDeliveryLive), packages/plugins/plugin-auth/src/send-verification-email.ts, packages/plugins/plugin-auth/src/auth-plugin.ts (the shadowing resend route), better-auth 1.7.12 dist/api/dispatch.mjs, dist/api/routes/session.mjs, dist/api/routes/email-verification.mjs, dist/plugins/bearer/index.mjs, ui/apps/console/src/pages/auth/VerifyEmailPromptPage.tsx, scripts/check-empty-changeset.mjs, .github/workflows/pr-automation.yml

Inputs: the PR body, its file list, the net diff against the merge base a52761fdd7, the check-runs on the head, and the ruling (maintainer, 2026-11-04, option G). The committed tree touches no published schema and no governed path; the PR body and both changesets declare Clause-②: no with no arm, so the tree itself decides no review face. This record is the at-tier review the seat ordered for a security-channel fix, and the scope above is every path of the diff outside the prose faces plus the code those hunks cite.

Check-runs on the head: every check is completed; none is still running. All are green except Check Changeset (two runs, both failure). Its only annotation is the foreign-changeset refusal on .changeset/sign-up-mail-after-response.md, the DELIBERATE CORRECTION class the gate's own header names. Red by design: the gate's header says the check stays red either way; the workflow that runs it (pr-automation.yml) triggers on pull_request only, never merge_group; the check is not a required context. The third red-by-design condition, a PR comment naming the gate and the cause, is still owed: the body names it, a comment does not yet.

① Derived judgments

  1. The rule under a live transport holds. sendAfterResponseOnSignUpAndVisitorResend hands the visitor's resend to sendAfterResponseOnPublicDoor, whose deferral is keyed on the Request the call carries, so the visitor's send runs after the response and its failure reaches logAfterResponseSendFailure only. Read in better-auth's resend route: for a caller it finds no session for, an address with no account or an already-confirmed one never reaches the send callback (a token is minted locally), and the remaining address reaches it; with the send deferred, every one of the three answers 200 { status: true } behind the route's own floor. Correct, and ablation leg 1 (two timeouts) pins exactly this.
  2. The rule under a non-live transport holds. refuseVisitorResendWithoutLiveTransport runs in globalBeforeHook ahead of the endpoint, is keyed on the endpoint's declared path and the presence of a Request, reads isEmailDeliveryLive once, and refuses every caller resolveActor finds no user for with one coded body, 400 EMAIL_SERVICE_REQUIRED, before any address is read. Correct. The code is the one plugin-auth already answers on /change-email and in the identity import for the same deployment condition; it is typed against the ADR-0112 ledger (RegisteredErrorCode), so no ledger change.
  3. The signed-in resend stays honest. A caller resolveActor resolves passes the before-hook; inside the endpoint the route resolves the session and records it on the endpoint context, so isVisitorResendInEndpointContext reads a session and the callback is awaited as before. ⑦'s renamed case and ⑧'s cookie-and-bearer case pin it. Correct.
  4. The discriminator: can the two readers disagree in a way that splits answers by address? No. Measured in better-auth 1.7.12: the bearer plugin's before-hook returns its cookie as a context patch, and dispatchAuthEndpoint merges every before-hook's patch into the request headers only after runBeforeHooks has returned, so a bearer caller reads as no session to getSessionFromCtx inside our before-hook; resolveActor reads the bearer header itself (unsigned token against the session store) and is the right reader there. Ablation leg 3 pins it. The two readers can still disagree in one direction, a credential the store knows but whose signature the endpoint does not accept: the before-hook then passes and the endpoint reads a visitor, and the callback decides how the send runs, so every address still gets the same 200. In the other direction (before-hook reads no caller, endpoint reads a session) the refusal is address-independent by construction. Neither direction produces an answer that depends on the address. The cached context.session the before-hook's read leaves behind is null for a visitor, which the endpoint's reader treats as unread and re-reads, so no stale reading crosses from the hook into the endpoint.
  5. A server-side auth.api call with no Request is neither deferred nor refused — right. The ruling is about the visitor on the public door; a call with no Request is the deployment's own code, and the previous changeset already states it awaits its send. The one in-repo relay, the shadowing POST …/send-verification-email route in auth-plugin.ts (runResendVerificationEmail), re-dispatches through handleRequest with a real Request carrying the caller's cookie and authorization headers, so a visitor through that door meets both halves of the rule; a visitor with an empty body is refused there before any re-dispatch (email is required). No door bypasses the rule.
  6. Clause-②: no (no arm) is true, and the plain no is the right declaration. AGENTS.md Post-Task Checklist step 3 is the rule text: "Clause-②: yes|no plus at most one arm from the closed pair (widening)/(narrowing) ((narrowing) is BREAKING, no (widening) malformed)". The reader's header (scripts/pm/clause2-line.mjs, the authority on detail) states the question the value answers, 「本卡放宽接受集或扩大公开面吗」, and the arm's trigger: "A diff that NARROWS a published accept set answers it no truthfully — and a narrowing is a breaking change." The accept set of POST /send-verification-email is unchanged: the same body, the same callers, no export, schema or ledger entry moves (the new module is not re-exported; index.ts does not name it). What moves is the answer under a deployment condition the platform already names a refusal, through the one reader of that fact (isEmailDeliveryLive, which already decides the sign-up answer and the invite outcome, and which plugin-email answers false for its log transport in every environment). The template's own test for the narrowing face is whether the change "takes a declared capability away at the consumer, or makes the spec's own refusal real": a visitor's 200 on a log transport was a send to a log the visitor cannot read, and on an address with no account it was no send at all; the platform's refusal for that condition becomes real on one more door. Not a narrowing of a published accept set, so no !, no (narrowing) arm, no BREAKING banner, no ADR-0087 marker, and no full dogfood battery owed (os-dev: the battery runs only on a ! title or a (narrowing) arm). The judgment is close enough to record its one user-visible edge in ③.
  7. The pending changeset correction is right, and the edit stands. .changeset/sign-up-mail-after-response.md line 12 listed the resend as unchanged; this PR keeps that true only for a signed-in caller, so the sentence now says so. Restoring the line from the merge base would republish a sentence this PR makes false, which is the one thing the gate's header says not to do. The new changeset visitor-verification-resend.md is patch on @objectstack/plugin-auth with both declaration lines at line start; its prose matches the diff.
  8. §6 holds. Title, body, both changesets and every test name state the rule (the same answer whatever the send does, whatever the address; one coded refusal while no transport is live). No advisory id, no attack wording, no mechanism narration beyond the rule; the one "probe" in the scan is pre-existing test-file prose on an unchanged line.

② Semver level

patch on @objectstack/plugin-auth (released 18.3.0): Metadata-protocol: none is true (nothing an author writes changes), Clause-②: no with no arm is true per ① item 6, and a bug fix in a released package takes patch. The PR body and changeset lines parse at line start (the PR-body guards are green). Consistent.

③ Boundary flags

  • Dev open question 1, the verify screens branching on the code: answered, not owed here. VerifyEmailPromptPage already shows the server's message, and the refusal's message names the capability and the fix (Settings → Mail); the resend's 200 while a transport is live reads as sent, as the ruling orders. A code-keyed string in the console is a ui/ follow-up at the seat's discretion, not a must-fix on this PR.
  • Dev open question 2, who confirms the changeset correction: the maintainer, in writing on this PR — escalated. The gate's header calls it "the existing human path" and says staying red "puts the decision in front of a person"; the workflow step says the approver merges over the red. No seat confirms it. Until that written confirmation, the PR waits; the red is by design and blocks no required context.
  • Red-by-design condition still owed by the seat: a PR comment naming Check Changeset and the DELIBERATE CORRECTION cause (SKILL.md: 源码自述、不跑 merge_group、PR 评论记明门与因). The first two hold on the tree and the workflow; the third needs the comment.
  • User-visible edge of the ruling, for the maintainer's awareness, not a defect: the dev-default log transport is not live, so on a dev deployment a visitor's resend now reads the Settings → Mail refusal instead of writing the link to the log. That is what option G orders; it changes the dev loop for a visitor who has lost the sign-up log line.
  • Minor, not a must-fix: while no transport is live, a signed-in caller whose session read throws in the before-hook is refused with the mail-configuration code rather than a read-fault status; the fallback is deliberate (a read that fails counts as no caller) and the condition is doubly degraded.
  • The author is objectstack-fleet[bot] (relay); no out-of-relay login to name.

④ Design judgment

  • visitor-verification-resend.ts against the ruling and the code it cites: the rule is stated once, the two readers are placed where each can answer (the before-hook asks the actor reader every other before-hook gate uses; the callback asks the session the endpoint recorded), and the module's own fallback statement (the endpoint's reading decides how the send runs) is what makes the answer address-independent under any disagreement. Holds.
  • auth-manager-auth-instance.ts: the rename widens the one wrapper from one path to two, keeping the awaited default for every other call; the comments scope the "throw so the resend is honest" rationale to the signed-in caller. Holds.
  • auth-manager-before-hook.ts: one call, placed before the lockout gate, with isEmailDeliveryLive added to the host Pick alongside the existing members. Holds.
  • Tests: ⑧ asks its three addresses concurrently against a deferred send and reads every answer before releasing it, which is the only honest way to pin "answered before the send settles"; the non-live battery covers the three ways a transport is not live; the signed-in case covers cookie and bearer, which is the exact seam the discriminator has. The scenario the PR is accepted against is covered.

Implemented-by: claude/sec-hardening-w
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Red by design · head 4200bbebc7fa86bc43e9e93422cbfaeb134c34d9 · 2026-11-04T17:12Z

Gate: Check Changeset (scripts/check-empty-changeset.mjs).

Cause: this PR edits a pending changeset that it did not create: line 12 of .changeset/sign-up-mail-after-response.md, from PR #29975. The edit scopes the resend sentence to the signed-in caller, because this PR makes the unscoped sentence false. The gate classes that as a DELIBERATE CORRECTION and stays red until the correction is confirmed in writing on the PR. Its header calls that "the existing human path". The contract review (comment 6636939836) found the edit right: restoring the line would publish a false sentence.

Why the red does not block CI: pr-automation.yml runs on pull_request only, never on merge_group, and this check is not a required context.

Owed: the maintainer's written confirmation on this PR. The seat will not land the PR before it is posted.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant