@@ -1681,15 +1681,15 @@ describe('AuthManager', () => {
16811681 describe ( 'phone-number OTP over SMS (#2780)' , ( ) => {
16821682 const PHONE = '+8613800000000' ;
16831683
1684- const fakeSms = ( opts : { failed ?: boolean } = { } ) => {
1684+ const fakeSms = ( opts : { failed ?: boolean ; error ?: string } = { } ) => {
16851685 const sent : any [ ] = [ ] ;
16861686 return {
16871687 sent,
16881688 service : {
16891689 async send ( input : any ) {
16901690 sent . push ( input ) ;
16911691 return opts . failed
1692- ? { id : 'sms_1' , status : 'failed' , error : 'provider down' }
1692+ ? { id : 'sms_1' , status : 'failed' , error : opts . error ?? 'provider down' }
16931693 : { id : 'sms_1' , status : 'sent' , messageId : 'prov_1' } ;
16941694 } ,
16951695 isConfigured : ( ) => true ,
@@ -1785,6 +1785,158 @@ describe('AuthManager', () => {
17851785 . rejects . toSatisfy ( ( e : Error ) => / p r o v i d e r d o w n / . test ( e . message ) && ! e . message . includes ( '555555' ) ) ;
17861786 } ) ;
17871787
1788+ // ── #6039 / #2814 — the deployment-wide daily SMS quota wall ───────────
1789+ //
1790+ // `SmsService.send()` refuses a send past the deployment's daily cost
1791+ // ceiling by RETURNING a failed result whose `error` carries the
1792+ // `TOO_MANY_REQUESTS:` code prefix (#2814) — it cannot throw an HTTP-shaped
1793+ // error, because it is a kernel service with no idea who is calling.
1794+ // Rethrowing that envelope as a plain `Error` made better-call answer
1795+ // **500 with a null body**: its router maps only `APIError`
1796+ // (`isAPIError = err instanceof APIError || err?.name === 'APIError'`,
1797+ // better-call@1.3.7 `dist/utils.mjs:57`, consumed at `dist/router.mjs:93`),
1798+ // and everything else takes the `console.error` + 500 branch. Meanwhile the
1799+ // per-number wall on the SAME endpoint (`assertPhoneOtpSendAllowed`, in the
1800+ // admission hook) throws a real `APIError('TOO_MANY_REQUESTS')` and answers
1801+ // 429 — so one endpoint spoke with two voices, which is the reverse of what
1802+ // #2814 asked for.
1803+ describe ( 'daily SMS quota refusal reaches the caller as 429 (#6039)' , ( ) => {
1804+ /**
1805+ * The refusal envelope an `SmsService` hands back on a quota refusal.
1806+ * Written out here rather than imported: `@objectstack/service-sms`
1807+ * already depends on THIS package (its day counter imports
1808+ * `InProcessCounterStore` / `incrementFixedWindow` from plugin-auth), so
1809+ * importing its `SMS_QUOTA_EXCEEDED_ERROR` back would close a dependency
1810+ * cycle. Source of truth: `SMS_QUOTA_EXCEEDED_CODE` /
1811+ * `SMS_QUOTA_EXCEEDED_ERROR` in
1812+ * `packages/services/service-sms/src/sms-daily-quota.ts`.
1813+ */
1814+ const QUOTA_REFUSAL = 'TOO_MANY_REQUESTS: daily SMS quota exhausted' ;
1815+
1816+ /**
1817+ * The outward shape a client — and better-call's router — actually
1818+ * branches on. Message text is deliberately NOT part of it: the two walls
1819+ * must be indistinguishable in code and status, while each may still say
1820+ * something true (only the per-number wall can name a retry window).
1821+ */
1822+ const outwardShape = ( e : any ) => ( {
1823+ name : e ?. name ,
1824+ status : e ?. status ,
1825+ statusCode : e ?. statusCode ,
1826+ bodyCode : e ?. body ?. code ,
1827+ } ) ;
1828+
1829+ const rejection = async ( run : ( ) => Promise < unknown > ) : Promise < any > => {
1830+ try {
1831+ await run ( ) ;
1832+ } catch ( e ) {
1833+ return e ;
1834+ }
1835+ throw new Error ( 'expected the call to reject, but it resolved' ) ;
1836+ } ;
1837+
1838+ it ( 'OTP send: rejects with an APIError carrying 429 / TOO_MANY_REQUESTS' , async ( ) => {
1839+ const { manager, opts } = await bootOtp ( ) ;
1840+ manager . setSmsService ( fakeSms ( { failed : true , error : QUOTA_REFUSAL } ) . service ) ;
1841+
1842+ const err = await rejection ( ( ) => opts . sendOTP ( { phoneNumber : PHONE , code : '424242' } ) ) ;
1843+ // Exactly what better-call reads to choose 429 over 500.
1844+ const { isAPIError } = await import ( 'better-auth/api' ) ;
1845+ expect ( isAPIError ( err ) ) . toBe ( true ) ;
1846+ expect ( err . name ) . toBe ( 'APIError' ) ;
1847+ expect ( err . status ) . toBe ( 'TOO_MANY_REQUESTS' ) ;
1848+ expect ( err . statusCode ) . toBe ( 429 ) ;
1849+ // #2780 standing requirement: the code never travels in an error.
1850+ expect ( String ( err . message ) ) . not . toContain ( '424242' ) ;
1851+ } ) ;
1852+
1853+ it ( 'invitation SMS: same APIError / 429 at the AuthManager boundary' , async ( ) => {
1854+ const { manager } = await bootOtp ( ) ;
1855+ manager . setSmsService ( fakeSms ( { failed : true , error : QUOTA_REFUSAL } ) . service ) ;
1856+
1857+ const err = await rejection ( ( ) => manager . sendPhoneInviteSms ( PHONE ) ) ;
1858+ const { isAPIError } = await import ( 'better-auth/api' ) ;
1859+ expect ( isAPIError ( err ) ) . toBe ( true ) ;
1860+ expect ( err . status ) . toBe ( 'TOO_MANY_REQUESTS' ) ;
1861+ expect ( err . statusCode ) . toBe ( 429 ) ;
1862+ } ) ;
1863+
1864+ it ( 'both walls on the endpoint present the SAME outward shape' , async ( ) => {
1865+ const { manager, opts } = await bootOtp ( ) ;
1866+ manager . setSmsService ( fakeSms ( { failed : true , error : QUOTA_REFUSAL } ) . service ) ;
1867+
1868+ // Wall A — the per-number cooldown, refused in the admission hook (#2780).
1869+ await manager . assertPhoneOtpSendAllowed ( PHONE ) ;
1870+ const perNumber = await rejection ( ( ) => manager . assertPhoneOtpSendAllowed ( PHONE ) ) ;
1871+ // Wall B — the deployment's daily quota, refused inside the send (#2814).
1872+ const quota = await rejection ( ( ) => opts . sendOTP ( { phoneNumber : PHONE , code : '111111' } ) ) ;
1873+
1874+ expect ( outwardShape ( quota ) ) . toEqual ( outwardShape ( perNumber ) ) ;
1875+ expect ( outwardShape ( quota ) ) . toEqual ( {
1876+ name : 'APIError' ,
1877+ status : 'TOO_MANY_REQUESTS' ,
1878+ statusCode : 429 ,
1879+ bodyCode : undefined ,
1880+ } ) ;
1881+ } ) ;
1882+
1883+ it ( 'matches the CODE prefix, so the service may reword the message half' , async ( ) => {
1884+ const { manager, opts } = await bootOtp ( ) ;
1885+ // Only `TOO_MANY_REQUESTS` — an ADR-0112 error code — is restated across
1886+ // the package boundary; the prose after the colon is service-owned and
1887+ // free to change without breaking this mapping.
1888+ manager . setSmsService (
1889+ fakeSms ( { failed : true , error : 'TOO_MANY_REQUESTS: budget spent for today' } ) . service ,
1890+ ) ;
1891+ const err = await rejection ( ( ) => opts . sendOTP ( { phoneNumber : PHONE , code : '333333' } ) ) ;
1892+ expect ( err . statusCode ) . toBe ( 429 ) ;
1893+ } ) ;
1894+
1895+ it ( 'does NOT over-tighten: a transport failure keeps its 500 semantics' , async ( ) => {
1896+ const { manager, opts } = await bootOtp ( ) ;
1897+ manager . setSmsService ( fakeSms ( { failed : true } ) . service ) ; // 'provider down'
1898+ const { isAPIError } = await import ( 'better-auth/api' ) ;
1899+
1900+ const otpErr = await rejection ( ( ) => opts . sendOTP ( { phoneNumber : PHONE , code : '555555' } ) ) ;
1901+ expect ( isAPIError ( otpErr ) ) . toBe ( false ) ;
1902+ expect ( otpErr . name ) . toBe ( 'Error' ) ;
1903+ expect ( String ( otpErr . message ) ) . toContain ( 'provider down' ) ;
1904+
1905+ const inviteErr = await rejection ( ( ) => manager . sendPhoneInviteSms ( PHONE ) ) ;
1906+ expect ( isAPIError ( inviteErr ) ) . toBe ( false ) ;
1907+ expect ( inviteErr . name ) . toBe ( 'Error' ) ;
1908+ expect ( String ( inviteErr . message ) ) . toContain ( 'provider down' ) ;
1909+ } ) ;
1910+
1911+ it ( 'the code must PREFIX the envelope — a provider merely mentioning it stays 500' , async ( ) => {
1912+ const { manager, opts } = await bootOtp ( ) ;
1913+ manager . setSmsService (
1914+ fakeSms ( { failed : true , error : 'upstream rejected: TOO_MANY_REQUESTS at carrier' } ) . service ,
1915+ ) ;
1916+ const { isAPIError } = await import ( 'better-auth/api' ) ;
1917+ const err = await rejection ( ( ) => opts . sendOTP ( { phoneNumber : PHONE , code : '777777' } ) ) ;
1918+ expect ( isAPIError ( err ) ) . toBe ( false ) ;
1919+ expect ( err . name ) . toBe ( 'Error' ) ;
1920+ } ) ;
1921+
1922+ it ( 'the 429 message carries no quota ceiling, remaining count or reset clock' , async ( ) => {
1923+ const { manager, opts } = await bootOtp ( ) ;
1924+ manager . setSmsService ( fakeSms ( { failed : true , error : QUOTA_REFUSAL } ) . service ) ;
1925+
1926+ for ( const run of [
1927+ ( ) => opts . sendOTP ( { phoneNumber : PHONE , code : '999999' } ) ,
1928+ ( ) => manager . sendPhoneInviteSms ( PHONE ) ,
1929+ ] ) {
1930+ const message = String ( ( await rejection ( run ) ) . message ) ;
1931+ // No digits at all ⇒ no ceiling, no remaining count, no reset clock.
1932+ expect ( message ) . not . toMatch ( / \d / ) ;
1933+ // …and not the raw service envelope, which names the budget that was hit.
1934+ expect ( message ) . not . toContain ( QUOTA_REFUSAL ) ;
1935+ expect ( message . toLowerCase ( ) ) . not . toContain ( 'quota' ) ;
1936+ }
1937+ } ) ;
1938+ } ) ;
1939+
17881940 it ( 'honours phoneOtp knobs (cooldown off ⇒ back-to-back admissions allowed)' , async ( ) => {
17891941 const { manager } = await bootOtp ( { phoneOtp : { cooldownSeconds : 0 , maxPerHour : 0 } } ) ;
17901942 manager . setSmsService ( fakeSms ( ) . service ) ;
0 commit comments