fix(plugin-auth): 短信日配额拒发时 OTP / 邀请短信回 429,不再是 500 + 空响应体 (#6039) - #6092
Merged
Conversation
`SmsService.send()` reports the deployment's daily SMS quota being hit as a failed result carrying the service's `CODE: message` envelope (`TOO_MANY_REQUESTS: daily SMS quota exhausted`, #2814) — it is a kernel service and cannot throw an HTTP-shaped error. `AuthManager` never turned that back into one: `deliverPhoneOtp()` / `sendPhoneInviteSms()` rethrew every `status === 'failed'` as a plain `Error`, and better-call maps only `APIError` to a real status (`isAPIError`, better-call@1.3.7 dist/utils.mjs:57 → dist/router.mjs:93), so the caller got 500 with a null body while the per-number wall on the same endpoint answered 429. Both failure branches now recognise the `TOO_MANY_REQUESTS:` prefix and throw `APIError('TOO_MANY_REQUESTS')`. Only the ADR-0112 code crosses the package boundary — restated locally, because `@objectstack/service-sms` already depends on plugin-auth (mirror of `normalizeSmsRecipient` there). Transport failures keep their plain `Error` / 500 semantics, and the 429 message carries no ceiling, remaining count or reset clock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
baozhoutao
marked this pull request as ready for review
August 7, 2026 01:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6039
缺陷
#2814(PR #6042)把短信总量成本闸落在
SmsService.send()。它是内核服务,不知道调用方是谁,所以超限时返回一条失败结果,把码写在服务层既有的
CODE: message信封上 ——TOO_MANY_REQUESTS: daily SMS quota exhausted(packages/services/service-sms/src/sms-daily-quota.ts)。把 HTTP 语义还原回去是 auth 端点的职责,而
AuthManager此前没有做:deliverPhoneOtp()/sendPhoneInviteSms()对任何status === 'failed'一律抛普通Error。better-call(better-auth 的路由层)只把
APIError映射成真实状态码 ——isAPIError = err instanceof APIError || err?.name === "APIError"(better-call@1.3.7
dist/utils.mjs:57,消费点dist/router.mjs:93),其余一律走console.error+ 500、响应体null的分支。于是配额拒发对外是 500,TOO_MANY_REQUESTS只留在服务端日志里;而同一个端点上按号码冷却闸(
assertPhoneOtpSendAllowed,在admission hook 里)抛的是
APIError('TOO_MANY_REQUESTS'),正常回 429 —— 一个端点两种口径,正是 #2814「两道墙从外面看应当一样」的反面。
改法
两处失败分支先识别信封上的
TOO_MANY_REQUESTS:前缀,改抛APIError('TOO_MANY_REQUESTS');其余失败原样保持。
只有码跨包,且是本地重述。 识别用的
TOO_MANY_REQUESTS在auth-manager.ts本地写死(
SMS_QUOTA_EXCEEDED_CODE),注释指向出处packages/services/service-sms/src/sms-daily-quota.ts。不 import 的理由是实测的反向依赖:
service-sms已经依赖本包 ——packages/services/service-sms/src/sms-daily-quota.ts:69-73从@objectstack/plugin-authimport
InProcessCounterStore/incrementFixedWindow;反向 import 会成环。这与service-sms里normalizeSmsRecipient就地重述 plugin-auth 形状规则(
sms-service.ts:18「kept local: the two packages must not depend on each other」)是同一个取舍的另一半。跨包重述的只是一个 ADR-0112 闭集错误码,冒号后的措辞归服务层所有。
匹配是前缀,不是子串。 传输故障把 provider 原文放进
error(
sms-service.ts末尾按 500 字截断后原样回传),一条只在句中提到该码的 provider 报错仍然是传输故障 —— 500,不是 429。
文案不泄露预算。 沿按号码闸的措辞形状,不含上限、剩余量与重置时刻。按号码闸报自己的
重试窗口是因为它算得出;配额闸不承诺它给不出的时间。
反向验证(先定方向,再跑)
预判:配额路径 before-red / after-green;「不过度收紧」两钉 两轮皆绿(它们钉的是
不变的行为,唯一有意义的方向就是两轮都绿 —— 若第一轮就红,说明我改错了范围)。实测吻合。
pnpm --filter '@objectstack/plugin-auth' test(全包 38 文件 / 902 用例,两轮同一命令):新增 7 个用例,改动前 5 红 2 绿:
APIError/ 429 /TOO_MANY_REQUESTS;邀请短信路径同上;两道墙对外形状一致钉;码前缀匹配(服务层可自由改写冒号后的措辞);文案无配额数字。
provider down)仍抛普通Error、isAPIError为false;仅在句中提到该码的 provider 报错同样保持普通
Error。改动前的红有代表性的一条,直接读出缺陷:
一致性钉的断言方式:同一个 manager 上把两道墙各触发一次,把
{ name, status, statusCode, body?.code }取出来toEqual互比,再比一次字面量{ name: 'APIError', status: 'TOO_MANY_REQUESTS', statusCode: 429, bodyCode: undefined }。message 文本故意不进这个形状 —— 两道墙必须在码与状态上不可区分(#2814:攻击者不该
知道自己撞的是哪个预算),但各自仍可说各自为真的话。
一处如实记录:邀请短信路径的观测面比模板预设的窄
模板预设两条路径都是「500 → 429」。OTP 路径确实如此。邀请短信路径不是:仓内唯一调用方
admin-import-users.ts:464-471按行捕获这个抛出,记成INVITE_SMS_FAILED并继续,HTTP 响应本来就是 200 而非 500 —— 所以那条路径上变的不是状态码,而是行内报错不再携带
服务层原始信封。改动仍然正确且必要:
sendPhoneInviteSms是AuthManager的公开方法,任何直接把它的 rejection 交给路由层的调用方(含仓外 host、未来端点)都要拿到 429。
测试因此把断言下沉到 AuthManager 边界(抛出物本身),而不是假装存在一个 500→429 的
HTTP 观测。
其它
auth-manager.ts+ 其测试 + changeset。未触otp-send-guard.ts/invitation-role-cap.ts/last-admin-guard.ts/ service-sms(只读参照)。APIError沿用本文件既有的await import('better-auth/api')形状,未新增依赖。git diff --name-only origin/main...分支,无一触及plugin-auth/src/auth-manager*;fix(plugin-auth): break-glass 补上第三条路径 —— 撤销管理员「身份」的写(sys_member 降级/删行、admin_full_access 授权删/改)同样被拒 (#5978) #6086 的文件面是auth-plugin.ts+last-admin-guard.{ts,test.ts}+changeset,fix(plugin-sharing): hierarchy resolver 按权威字段拿到调用方活动组织 (#5859) #6067 全在
plugin-sharing。typecheck绿(tsc --noEmit无输出);node scripts/check-nul-bytes.mjs绿(5834 个文件);
node scripts/check-error-code-casing.mjs绿(3066 个文件)。Generated by Claude Code