@terreno/mcp 57.19.2 → 57.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/README.md +32 -269
  3. package/dist/bootstrap.d.ts +12 -0
  4. package/dist/bootstrap.d.ts.map +1 -1
  5. package/dist/bootstrap.js +77 -122
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
  8. package/dist/docs/resources/overview.md +4 -2
  9. package/dist/docs/resources/patterns.md +237 -176
  10. package/dist/docs/resources/ui.md +48 -17
  11. package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
  12. package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
  13. package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
  14. package/dist/docs/upgrades/57.20.0.md +238 -0
  15. package/dist/docs/versioned/next/approval-rubric.md +30 -12
  16. package/dist/docs/versioned/next/explanation/README.md +5 -0
  17. package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
  18. package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
  19. package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
  20. package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
  21. package/dist/docs/versioned/next/explanation/authentication.md +45 -1
  22. package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
  23. package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
  24. package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
  25. package/dist/docs/versioned/next/explanation/organizations.md +37 -0
  26. package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
  27. package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
  28. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
  29. package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
  30. package/dist/docs/versioned/next/how-to/README.md +7 -2
  31. package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
  32. package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
  33. package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
  34. package/dist/docs/versioned/next/how-to/circleci.md +15 -5
  35. package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
  36. package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
  37. package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
  38. package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
  39. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
  40. package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
  41. package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
  42. package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
  43. package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
  44. package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
  45. package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
  46. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
  47. package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
  48. package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
  49. package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
  50. package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
  51. package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
  52. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
  53. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
  54. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
  55. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
  56. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
  57. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
  58. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
  59. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
  60. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
  61. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
  62. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
  63. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
  64. package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
  65. package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
  66. package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
  67. package/dist/docs/versioned/next/reference/README.md +1 -0
  68. package/dist/docs/versioned/next/reference/account-security.md +700 -0
  69. package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
  70. package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
  71. package/dist/docs/versioned/next/reference/api.md +461 -35
  72. package/dist/docs/versioned/next/reference/cli.md +1 -1
  73. package/dist/docs/versioned/next/reference/comms.md +8 -0
  74. package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
  75. package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
  76. package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
  77. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
  78. package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
  79. package/dist/docs/versioned/next/reference/syncdb.md +8 -1
  80. package/dist/docs/versioned/next/reference/ui.md +1271 -27
  81. package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
  82. package/dist/docs/versioned/next/tutorials/README.md +4 -0
  83. package/dist/httpApp.d.ts +14 -0
  84. package/dist/httpApp.d.ts.map +1 -0
  85. package/dist/httpApp.js +99 -0
  86. package/dist/httpApp.js.map +1 -0
  87. package/dist/index.js +8 -90
  88. package/dist/index.js.map +1 -1
  89. package/dist/local/localTools.d.ts.map +1 -1
  90. package/dist/local/localTools.js +44 -14
  91. package/dist/local/localTools.js.map +1 -1
  92. package/dist/local/metro/metroDevSession.d.ts +18 -0
  93. package/dist/local/metro/metroDevSession.d.ts.map +1 -1
  94. package/dist/local/metro/metroDevSession.js +42 -3
  95. package/dist/local/metro/metroDevSession.js.map +1 -1
  96. package/dist/local/tools/databaseQuery.d.ts.map +1 -1
  97. package/dist/local/tools/databaseQuery.js +3 -13
  98. package/dist/local/tools/databaseQuery.js.map +1 -1
  99. package/dist/local/tools/databaseSchema.d.ts.map +1 -1
  100. package/dist/local/tools/databaseSchema.js +3 -13
  101. package/dist/local/tools/databaseSchema.js.map +1 -1
  102. package/dist/local/tools/localMongo.d.ts +10 -0
  103. package/dist/local/tools/localMongo.d.ts.map +1 -0
  104. package/dist/local/tools/localMongo.js +30 -0
  105. package/dist/local/tools/localMongo.js.map +1 -0
  106. package/dist/local/tools/runtime.d.ts +18 -2
  107. package/dist/local/tools/runtime.d.ts.map +1 -1
  108. package/dist/local/tools/runtime.js +75 -20
  109. package/dist/local/tools/runtime.js.map +1 -1
  110. package/dist/local/tools/syncDb.d.ts +11 -0
  111. package/dist/local/tools/syncDb.d.ts.map +1 -1
  112. package/dist/local/tools/syncDb.js +15 -3
  113. package/dist/local/tools/syncDb.js.map +1 -1
  114. package/dist/rateLimit.d.ts +35 -0
  115. package/dist/rateLimit.d.ts.map +1 -0
  116. package/dist/rateLimit.js +112 -0
  117. package/dist/rateLimit.js.map +1 -0
  118. package/dist/tools.d.ts.map +1 -1
  119. package/dist/tools.js +42 -60
  120. package/dist/tools.js.map +1 -1
  121. package/package.json +4 -3
  122. package/src/docs/guidelines/ui/guidelines/core.md +1 -1
  123. package/src/docs/resources/overview.md +4 -2
  124. package/src/docs/resources/patterns.md +237 -176
  125. package/src/docs/resources/ui.md +48 -17
  126. package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
  127. package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
  128. package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
  129. package/src/docs/upgrades/57.20.0.md +238 -0
  130. package/src/docs/versioned/next/approval-rubric.md +30 -12
  131. package/src/docs/versioned/next/explanation/README.md +5 -0
  132. package/src/docs/versioned/next/explanation/account-linking.md +169 -0
  133. package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
  134. package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
  135. package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
  136. package/src/docs/versioned/next/explanation/authentication.md +45 -1
  137. package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
  138. package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
  139. package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
  140. package/src/docs/versioned/next/explanation/organizations.md +37 -0
  141. package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
  142. package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
  143. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
  144. package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
  145. package/src/docs/versioned/next/how-to/README.md +7 -2
  146. package/src/docs/versioned/next/how-to/audit-log.md +22 -3
  147. package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
  148. package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
  149. package/src/docs/versioned/next/how-to/circleci.md +15 -5
  150. package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
  151. package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
  152. package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
  153. package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
  154. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
  155. package/src/docs/versioned/next/how-to/password-reset.md +1 -1
  156. package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
  157. package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
  158. package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
  159. package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
  160. package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
  161. package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
  162. package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
  163. package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
  164. package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
  165. package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
  166. package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
  167. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
  168. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
  169. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
  170. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
  171. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
  172. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
  173. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
  174. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
  175. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
  176. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
  177. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
  178. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
  179. package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
  180. package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
  181. package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
  182. package/src/docs/versioned/next/reference/README.md +1 -0
  183. package/src/docs/versioned/next/reference/account-security.md +700 -0
  184. package/src/docs/versioned/next/reference/admin-backend.md +3 -1
  185. package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
  186. package/src/docs/versioned/next/reference/api.md +461 -35
  187. package/src/docs/versioned/next/reference/cli.md +1 -1
  188. package/src/docs/versioned/next/reference/comms.md +8 -0
  189. package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
  190. package/src/docs/versioned/next/reference/environment-variables.md +9 -3
  191. package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
  192. package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
  193. package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
  194. package/src/docs/versioned/next/reference/syncdb.md +8 -1
  195. package/src/docs/versioned/next/reference/ui.md +1271 -27
  196. package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
  197. package/src/docs/versioned/next/tutorials/README.md +4 -0
@@ -93,14 +93,24 @@ const buildBetterAuthConfig = (): BetterAuthConfig | undefined => {
93
93
  ``````
94
94
 
95
95
  Set `publicAppUrl`, `sendMail`, and `renderAuthMail` from `@terreno/comms` so password-reset and
96
- email-verification mail match the JWT recovery templates. The hooks throw 501 and do not send when
96
+ email-verification mail match the JWT recovery templates. With `AccountSecurityApp` registered,
97
+ `sendMail`, `publicAppUrl`, and `authMailTemplates` also send security notification emails, which
98
+ use their own renderer rather than `renderAuthMail` (see
99
+ [Account security: Notifications](../reference/account-security.md#notifications)). The hooks throw 501 and do not send when
97
100
  `publicAppUrl` is missing. JWT `POST /auth/resetPassword` updates the Better Auth password and
98
- deletes Better Auth sessions when this plugin is registered. Better Auth password reset updates
99
- the JWT password and `tokenEpoch` for the matching app User so dual-enrolled accounts cannot keep
100
- the old passport hash. Better Auth `change-password` does the same for an app User that already
101
- has a JWT password, and bumps `tokenEpoch` for every app User. `revoke-sessions` and
102
- `revoke-other-sessions` bump `tokenEpoch`, so JWT refresh tokens issued before them stop working.
103
- An account with only a Better Auth password does not gain a JWT password.
101
+ deletes Better Auth sessions when this plugin is registered. With this plugin registered, the JWT
102
+ recovery routes only replace an existing JWT password: `POST /auth/forgotPassword` mails no link
103
+ to an app User without one (it still answers 202), and `POST /auth/resetPassword` refuses that
104
+ User's token with 400 `legacy-password-reset-unavailable`. Such an account resets through Better
105
+ Auth (`request-password-reset` / `reset-password`). Better Auth password reset updates
106
+ the JWT password of a matching app User that already has one, so dual-enrolled accounts cannot
107
+ keep the old passport hash, and bumps `tokenEpoch` for every matching app User. Terreno reads the new password from Better Auth's parsed request body, so
108
+ this works over HTTP (`POST {basePath}/reset-password`). Better Auth `change-password` does the
109
+ same for an app User that already has a JWT password, and bumps `tokenEpoch` for every app User.
110
+ `revoke-sessions` and `revoke-other-sessions` bump `tokenEpoch`, so JWT refresh tokens issued
111
+ before them stop working. An account with only a Better Auth password does not gain a JWT
112
+ password, and neither does a social-only account that resets its password or adds its first one
113
+ with `terreno/set-password`: `POST /auth/login` has no second factor.
104
114
 
105
115
  ```typescript
106
116
  import {getCommsService, renderAuthMail} from "@terreno/comms";
@@ -129,8 +139,9 @@ Better Auth enables its limiter in production, which 429s the fourth seeded user
129
139
  optional. It is deep-merged over the options Terreno builds. Arrays (such as `trustedOrigins`)
130
140
  replace Terreno's instead of merging. An explicit value wins; an `undefined` value is ignored,
131
141
  so it cannot clear a default.
132
- - `plugins` are appended after Terreno's `bearer()` plugin. Their endpoints mount under
133
- `basePath`, like the built-in ones.
142
+ - `plugins` are appended after Terreno's `bearer()`, `terreno-methods`, and feature plugins
143
+ (see [Auth features](#auth-features)). Their endpoints mount under `basePath`, like the
144
+ built-in ones.
134
145
 
135
146
  ``````typescript
136
147
  const config: BetterAuthConfig = {
@@ -159,12 +170,158 @@ const config: BetterAuthConfig = {
159
170
  | `session.cookieCache` | `{enabled: true, maxAge: 300}` | The 5-minute session cookie cache |
160
171
  | `advanced.defaultCookieAttributes` | `crossDomainCookies` | `SameSite=None; Secure` cookies for cross-site frontends |
161
172
  | `rateLimit` | `disableRateLimit` | Turning Better Auth's limiter off |
162
- | `socialProviders` | `googleOAuth`, `githubOAuth`, `appleOAuth` | The configured social providers (`options` adds to them per provider) |
173
+ | `socialProviders` | `googleOAuth`, `githubOAuth`, `appleOAuth`, `signUp` | The configured social providers (`options` adds to them per provider) |
174
+ | `account.accountLinking` | `accountLinking` | The linking mode Terreno maps (`options` wins field by field) |
175
+ | `emailAndPassword.disableSignUp` | `signUp` | Closed email sign-up |
163
176
  | `trustedOrigins` | `trustedOrigins` | The origins listed in the config |
164
177
 
165
178
  Endpoints added by `plugins` are not part of the `auth.api` type. Call them over HTTP, or cast
166
179
  `auth.api` where you call them on the server.
167
180
 
181
+ #### Auth features
182
+
183
+ Switch auth features on with these `BetterAuthConfig` fields instead of adding Better Auth
184
+ plugins yourself. Every feature is off until you set its field. Terreno adds the plugins after
185
+ `bearer()` and `terreno-methods` and before `config.plugins`, so your own plugins still run
186
+ after them. `GET {basePath}/terreno/methods` reports `twoFactor`, `passkey`, `multiSession`,
187
+ `signUp`, and `accountLinking.mode` from the final configuration.
188
+
189
+ | Field | Maps to | Default |
190
+ | --- | --- | --- |
191
+ | `accountLinking: {mode, trustedProviders?}` | `account.accountLinking`: `verified-email` = linking on; `explicit` = `disableImplicitLinking: true`; `never` = `enabled: false`. `requireLocalEmailVerified` is `true` in every mode. `trustedProviders` skip the verified-email check, including on `/link-social`: list only providers that prove email ownership | Better Auth's default (`verified-email`) |
192
+ | `twoFactor: {issuer, backupCodes?: {amount?, length?}, trustDeviceDays?}` | `twoFactor()` from `better-auth/plugins` (TOTP + backup codes; adds the `twoFactor` collection and `user.twoFactorEnabled`) | Off. When on: 10 backup codes of 10 characters, 30 trusted days |
193
+ | `passkey: {rpID, rpName, origin: string[]}` | `passkey()` from `@better-auth/passkey` (adds the `passkey` collection; registering needs a session). Each origin must be a bare origin on `rpID` or a subdomain | Off |
194
+ | `signUp: {mode: "open" \| "closed"}` | `closed`: `emailAndPassword.disableSignUp`, plus `disableSignUp` and `disableImplicitSignUp` on every social and `genericOAuth` provider (not on other sign-in plugins) | `open` |
195
+ | `lastLoginMethod: true` | `lastLoginMethod({storeInDatabase: true})` (a cookie plus `user.lastLoginMethod`) | Off |
196
+ | `multiSession: {maximumSessions?}` | `multiSession()` | Off. When on: 5 sessions |
197
+ | `oauthProxy: {productionURL}` | `oAuthProxy()`. Turn it on for previews and for the production server (naming itself), with one shared `secret`. When `productionURL` is on the server's own `baseURL` origin, Terreno also sets `currentURL`, so the production server's own sign-ins skip the proxy and keep the state-cookie check. See [Set up social login](set-up-social-login.md#how-previews-sign-in) | Off |
198
+ | `nativeApps: true` | `expo()` from `@better-auth/expo` (`GET {basePath}/expo-authorization-proxy` for native social sign-in) | Off |
199
+ | `appleOAuth: {clientId, teamId, keyId, privateKey, appBundleIdentifier?}` | `socialProviders.apple`, with the ES256 client secret signed from the key at startup | The `{clientId, clientSecret}` form still works |
200
+
201
+ ```typescript
202
+ const config: BetterAuthConfig = {
203
+ enabled: true,
204
+ accountLinking: {mode: "explicit"},
205
+ lastLoginMethod: true,
206
+ multiSession: {maximumSessions: 3},
207
+ passkey: {origin: ["https://app.example.com"], rpID: "example.com", rpName: "Example"},
208
+ signUp: {mode: "closed"},
209
+ twoFactor: {issuer: "Example", trustDeviceDays: 14},
210
+ };
211
+ ```
212
+
213
+ Notes:
214
+
215
+ - **Closed sign-up.** `POST {basePath}/sign-up/email` answers 400, and a social or generic
216
+ OAuth sign-in for an unknown email redirects to the error URL with `error=signup_disabled`
217
+ (an ID-token sign-in answers 401 `signup disabled`). A client's `requestSignUp: true` does not
218
+ reopen it. Terreno reaches providers registered through `config.plugins` (such as
219
+ `genericOAuth`) with a `terreno-sign-up-closed` plugin that runs after them. Closed mode also
220
+ refuses `auth.api.signUpEmail` on the same instance, so seed users with a separate instance
221
+ that leaves `signUp` unset.
222
+ - It covers email and password and OAuth providers only. Other sign-in plugins you add
223
+ through `plugins` (`magicLink`, `emailOTP`, `phoneNumber`, `anonymous`, ...) create users on
224
+ their own terms: set their own `disableSignUp` (or equivalent) option too.
225
+ - With `oauthProxy`, the production server finishes a preview's OAuth sign-in and decides
226
+ whether it may create a user, so the preview follows production's sign-up mode. Give both
227
+ the same `signUp.mode`.
228
+ - **Trusted providers.** Better Auth skips the verified-email check for `trustedProviders` on
229
+ sign-in, on an explicit `/link-social`, and on its callback. List only providers that prove
230
+ the user owns the email they report. A provider that lets users set any email lets an
231
+ attacker link into someone else's account.
232
+ - **Passkey origins.** `createBetterAuth` throws `passkey config is invalid` unless `origin`
233
+ lists at least one bare `http(s)` origin (no path or trailing slash) whose host is `rpID` or
234
+ a subdomain of it.
235
+ - **Apple key.** `privateKey` is the `.p8` file's PEM contents; escaped `\n` sequences from an env
236
+ var are turned back into newlines. The signed secret is valid for 180 days, so redeploy at
237
+ least that often. Web sign-in needs `https://appleid.apple.com` in `trustedOrigins`, since
238
+ Apple posts the callback from there.
239
+ - **Schema.** Better Auth stores `twoFactorEnabled` and `lastLoginMethod` on its own `user`
240
+ collection, not on your app `User` model, so a `strict: "throw"` User schema needs no new
241
+ fields.
242
+ - **Rate limits.** With `TerrenoApp` `rateLimit` on, `{basePath}/two-factor/*`,
243
+ `/passkey/*`, `/link-social`, `/unlink-account`, `/multi-session/*`,
244
+ `/oauth-proxy-callback`, and `/expo-authorization-proxy` count against the `auth` bucket
245
+ (oauth-proxy's `/callback/:id/oauth-proxy` is under `/callback/*`, which already does).
246
+ - **Linking.** `never` (or `options.account.accountLinking.enabled: false`) adds a
247
+ `terreno-linking-disabled` plugin that refuses `POST {basePath}/link-social` with 403
248
+ `LINKING_DISABLED`. In every mode an unverified sign-up never claims an existing app `User`
249
+ with the same email (409 `VERIFY_EMAIL_TO_USE_ACCOUNT`). See
250
+ [Account linking](../explanation/account-linking.md).
251
+ - `options` still wins: for example `options.account.accountLinking.enabled` overrides the
252
+ mode `accountLinking` sets.
253
+
254
+ #### Auth features in example-backend
255
+
256
+ example-backend (`src/utils/betterAuthConfig.ts`) turns every feature on; `oauthProxy` follows
257
+ `OAUTH_PROXY_PRODUCTION_URL`, which only deploys set. These env vars tune it:
258
+
259
+ | Env var | Sets | Default |
260
+ | --- | --- | --- |
261
+ | `AUTH_ACCOUNT_LINKING` | `accountLinking.mode`: `verified-email`, `explicit`, or `never` | `verified-email` |
262
+ | `AUTH_SIGN_UP` | `signUp.mode`: `open` or `closed` | `open` |
263
+ | `FRONTEND_URL` | The first `passkey.origin` (its origin) and the default `passkey.rpID` (its host); also the auth mail links | `http://localhost:8082` |
264
+ | `PASSKEY_RP_ID` | `passkey.rpID`. Must be the `FRONTEND_URL` host or a parent domain of it, such as `example.com` for `https://app.example.com` | The `FRONTEND_URL` host |
265
+ | `OAUTH_PROXY_PRODUCTION_URL` | `oauthProxy.productionURL` (its origin): the stable backend that owns the registered OAuth callbacks | Unset (off) |
266
+ | `APPLE_CLIENT_ID` with `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` | `appleOAuth` in the key form, and `https://appleid.apple.com` in `trustedOrigins` | Unset (Apple off) |
267
+ | `APPLE_CLIENT_ID` with `APPLE_CLIENT_SECRET` | `appleOAuth` in the client-secret form, used when the key form is incomplete | Unset |
268
+
269
+ The rest is fixed: `twoFactor.issuer` and `passkey.rpName` are `Terreno Example`, and
270
+ `lastLoginMethod`, `multiSession` (5 sessions) and `nativeApps` are on. Email verification
271
+ stays optional at sign-up; linking by email needs it.
272
+
273
+ `passkey.origin` is the `FRONTEND_URL` origin plus each `CORS_ORIGINS` (and dev) origin whose
274
+ host is the rpID or one of its subdomains, because a browser refuses a passkey on any other
275
+ host. `localhost` and `127.0.0.1` cannot share an rpID, so with the default `FRONTEND_URL`
276
+ passkeys work on `http://localhost:8082` and not on `http://127.0.0.1:8082`. Set
277
+ `FRONTEND_URL=http://127.0.0.1:8082` to use the loopback address instead. On a deploy where the
278
+ web app is `app.<domain>`, set `PASSKEY_RP_ID=<domain>` so a passkey works on every subdomain.
279
+ A passkey made under one rpID cannot be used under another.
280
+
281
+ With `NODE_ENV=production` and a loopback `FRONTEND_URL` (the default when a deploy does not set
282
+ it), example-backend leaves `passkey` out, logs one warning, and `/terreno/methods` reports
283
+ `passkey: false`: a passkey bound to `localhost` can never work for a deployed web app.
284
+
285
+ The seed (`seedBetterAuthUserInProcess`) and the e2e setup (`e2e/auth.setup.ts`) mark their
286
+ users' Better Auth email verified, so `verified-email` linking treats them as the email's
287
+ owner. The seed still works with `AUTH_SIGN_UP=closed`: it builds its own instance with sign-up
288
+ open.
289
+
290
+ #### Auth features in generated apps
291
+
292
+ [create-terreno-app](../reference/create-terreno-app.md) generates the same suite in
293
+ `backend/src/utils/betterAuthConfig.ts`, with the same env vars (`AUTH_ACCOUNT_LINKING`,
294
+ `AUTH_SIGN_UP`, `FRONTEND_URL`, `PASSKEY_RP_ID`) and the same production rule for passkeys on a
295
+ loopback `FRONTEND_URL`, and `getWebOrigins` adds the `FRONTEND_URL` origin to CORS and
296
+ `trustedOrigins`. It differs from example-backend in five ways:
297
+
298
+ - `twoFactor.issuer` and `passkey.rpName` are the app's display name (`APP_NAME`), and
299
+ `twoFactor.trustDeviceDays` is 30, matching the login screen's `trustDeviceDays`.
300
+ - It configures no social providers and no `oauthHandoff`: the web client stays in cookie mode.
301
+ Add providers as in [OAuth Provider Setup](#oauth-provider-setup); the login and security
302
+ screens show them once `/terreno/methods` lists them.
303
+ - `server.ts` registers `AuditApp` and then `AccountSecurityApp({accessControl: access})`, so
304
+ operators get the recovery routes at `/users/:id/security/*`.
305
+ - The seed builds its own instance with `signUp` unset, so `AUTH_SIGN_UP=closed` still seeds.
306
+ - It sets no `sendMail`. Until the app passes `@terreno/comms` `sendMail` and `renderAuthMail`
307
+ (as example-backend does), nothing that sends mail works: forgot-password, the operator
308
+ recovery actions `send-password-reset` and `issue-sign-in-link` (they answer 501), any flow
309
+ that depends on a reset or set-password link, and
310
+ [security notification emails](../reference/account-security.md#notifications).
311
+
312
+ On the frontend of a generated app (not the example app, whose screen is described under
313
+ [Linking in cookie mode on web](#6-drop-in-security-screen)), `lib/betterAuth.ts` passes
314
+ `features: {lastLoginMethod, multiSession, passkey, twoFactor}` and exports
315
+ `rememberedAccounts` with `rememberIdentity: false`, so the device keeps only how each account
316
+ signed in. `app/login.tsx` renders the [drop-in login screen](#5-drop-in-login-screen) with
317
+ `callbackURL="/login"`, and `app/settings/security.tsx` renders the
318
+ [drop-in security screen](#6-drop-in-security-screen) with the bare path
319
+ `callbackURL="/settings/security"` (the example app passes a full URL on web instead). The
320
+ generated Profile tab's **Security** button (`profile-security-button`; the example's is
321
+ `profile-security-link`) opens it, and passwords are changed there: the Profile tab edits only
322
+ name and email. Its email field changes the app `User`'s email through `PATCH /auth/me`, not the
323
+ Better Auth sign-in email, so the user keeps signing in with the old one.
324
+
168
325
  ### 3. Register BetterAuthApp Plugin
169
326
 
170
327
  Use the TerrenoApp plugin system:
@@ -242,6 +399,54 @@ safe methods, and clients that send no `Origin`, `Referer`, or cross-site `Sec-F
242
399
  (native apps, server scripts) are not affected. Better Auth's `advanced.disableCSRFCheck` turns
243
400
  the check off. A `trustedOrigins` function is not consulted; list the origins.
244
401
 
402
+ #### Client features
403
+
404
+ When the server turns on [auth features](#auth-features), pass `features` so the client gets
405
+ the matching Better Auth client plugins and their typed methods:
406
+
407
+ ``````typescript
408
+ import {router} from "expo-router";
409
+ import {createBetterAuthClient} from "@terreno/rtk";
410
+
411
+ export const authClient = createBetterAuthClient({
412
+ baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:4000",
413
+ features: {
414
+ lastLoginMethod: true,
415
+ multiSession: true,
416
+ passkey: true,
417
+ twoFactor: {onTwoFactorRedirect: () => router.push("/twoFactor")},
418
+ },
419
+ scheme: "yourapp",
420
+ });
421
+
422
+ await authClient.twoFactor.enable({password});
423
+ await authClient.passkey.addPasskey({name: "Laptop"});
424
+ const sessions = await authClient.multiSession.listDeviceSessions();
425
+ ``````
426
+
427
+ | Feature | Client plugin | Adds |
428
+ | --- | --- | --- |
429
+ | `twoFactor: true \| {onTwoFactorRedirect}` | `twoFactorClient` | `client.twoFactor.*` (`enable`, `disable`, `getTotpUri`, `verifyTotp`, `generateBackupCodes`, `verifyBackupCode`, ...). `onTwoFactorRedirect({twoFactorMethods})` runs when a sign-in answers `twoFactorRedirect` instead of a session. In [bearer mode](#frontend-and-api-on-different-sites) the 2FA challenge and trusted-device value travel in the `X-Terreno-Auth-Challenge` header (see [Challenges without cookies](../explanation/authentication.md#challenges-without-cookies)) |
430
+ | `passkey: true` | `passkeyClient` from `@better-auth/passkey/client` | `client.passkey.*` (`addPasskey`, `listUserPasskeys`, `deletePasskey`, `updatePasskey`) and `client.signIn.passkey()`. Web only: React Native has no WebAuthn. In bearer mode the passkey challenge travels in the `X-Terreno-Auth-Challenge` header |
431
+ | `multiSession: true` | `multiSessionClient` | `client.multiSession.*` (`listDeviceSessions`, `setActive`, `revoke`). Not in bearer mode: the server tracks device sessions in `session_token_multi-*` cookies, so `listDeviceSessions` answers `[]`; use [remembered accounts](#4-remember-accounts-on-this-device) for switching there |
432
+ | `lastLoginMethod: true` | `lastLoginMethodClient` | `client.getLastUsedLoginMethod()`, `isLastUsedLoginMethod(method)`, `clearLastUsedLoginMethod()`. They read the server's cookie, so they return `null` on native and in [bearer mode](#frontend-and-api-on-different-sites); use [remembered accounts](#4-remember-accounts-on-this-device) there |
433
+ | `oneTimeToken: true` | `oneTimeTokenClient` | `client.oneTimeToken.generate()` and `verify({token})`. The OAuth handoff does not need it |
434
+
435
+ Notes:
436
+
437
+ - **Types.** Each enabled feature's namespace is typed from the plugin, with no casts, when
438
+ `features` is written out at the call. Without `features`, or when the config's type does not
439
+ say which features are on (a config typed `BetterAuthClientConfig`, or a wrapper that forwards
440
+ `features?: BetterAuthClientFeatures`), the client is `BetterAuthClient`, exactly what it was
441
+ before, and `ReturnType<typeof createBetterAuthClient>` is still `BetterAuthClient`. `false`
442
+ or a missing key adds nothing; a `boolean` read at runtime (`{twoFactor: isOn}`) is typed as
443
+ on. Name the typed client with `BetterAuthClientWithFeatures<{twoFactor: true}>`.
444
+ - **Native.** The feature plugins go after the Expo plugin. Better-fetch runs plugin hooks in
445
+ order, so on native the Expo plugin stores the 2FA challenge cookie before
446
+ `onTwoFactorRedirect` runs, and every feature call sends the stored cookies.
447
+ - **Server first.** A client feature only adds client methods. Turn the feature on in
448
+ `BetterAuthConfig` too, or its calls answer 404.
449
+
245
450
  ### 2. Add Session Redux Slice
246
451
 
247
452
  ``````typescript
@@ -310,6 +515,350 @@ const LoginScreen = () => {
310
515
  };
311
516
  ``````
312
517
 
518
+ ### 4. Remember accounts on this device
519
+
520
+ The login screen can show how each person signed in last and offer a "Continue as" card per
521
+ account. `createRememberedAccountsStore` keeps that list on the device. It is not a session and
522
+ holds no credential: a remembered account still signs in with its method. Use it alongside
523
+ Better Auth's `lastLoginMethod`, which reads a backend cookie that a cross-site web page cannot
524
+ see.
525
+
526
+ ``````typescript
527
+ import {createRememberedAccountsStore, toLoginAccountOptions, useRememberedAccounts} from "@terreno/rtk";
528
+ import {LoginScreen} from "@terreno/ui";
529
+
530
+ // One store per app, at module scope.
531
+ export const rememberedAccounts = createRememberedAccountsStore({rememberIdentity: false});
532
+
533
+ // After a successful sign-in, record the user and the method they used:
534
+ // a provider id ("google", "mock-oidc"), "email", or "passkey".
535
+ await rememberedAccounts.record({
536
+ email: user.email, // ignored unless rememberIdentity is true
537
+ image: user.image,
538
+ method: "google",
539
+ name: user.name,
540
+ userId: user.id,
541
+ });
542
+
543
+ const Login = (): React.ReactElement => {
544
+ const {accounts, lastUsedMethod, forget} = useRememberedAccounts(rememberedAccounts);
545
+ return (
546
+ <LoginScreen
547
+ lastUsedMethod={lastUsedMethod}
548
+ onForget={(account) => forget(account.id)}
549
+ rememberedAccounts={toLoginAccountOptions(accounts)}
550
+ // ...fields, providers, onContinue
551
+ />
552
+ );
553
+ };
554
+ ``````
555
+
556
+ What is stored:
557
+
558
+ | Field | Stored when | Notes |
559
+ | --- | --- | --- |
560
+ | `userId`, `method`, `lastUsedAt` | Always | `lastUsedAt` is an ISO UTC timestamp |
561
+ | `name` | `rememberIdentity: true` | Stored in clear, not masked. Trimmed, at most 64 characters |
562
+ | `email` | `rememberIdentity: true` | Always masked first: `josh@nang.io` becomes `j***@n***.io` |
563
+ | `image` | `rememberIdentity: true` | `https://` URLs up to 256 characters only |
564
+
565
+ The store keeps the newest 5 accounts (`max`), one entry per user, and stays under SecureStore's
566
+ 2048-byte value limit by dropping the oldest. Storage by platform:
567
+
568
+ - **iOS:** SecureStore (the keychain) with `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, so the entry is not
569
+ restored to a new device from a backup.
570
+ - **Android:** SecureStore encrypts the value with an Android Keystore key and keeps it in the
571
+ app's `SecureStore` shared preferences. The `keychainAccessible` setting does not apply. The
572
+ `expo-secure-store` config plugin (on by default, `configureAndroidBackup: true`) excludes those
573
+ preferences from Android Auto Backup. Without the plugin, or when the app already has its own
574
+ backup rules, the encrypted value can be backed up; the Keystore key does not leave the device,
575
+ so a restored copy cannot be decrypted. Add the plugin to `app.json` `plugins`.
576
+ - **Web:** localStorage, shared by every tab on the origin. Each change re-reads storage first and
577
+ each store follows `storage` events, so a stale tab never brings back an account another tab
578
+ forgot. Call `rememberedAccounts.dispose()` if you create a store that does not live for the
579
+ whole page.
580
+
581
+ Native storage loads asynchronously: `useRememberedAccounts` returns `isHydrated: false` until it
582
+ has, so wait for it before deciding there is no last used method. Stored data the store cannot
583
+ read resets to an empty list instead of throwing.
584
+
585
+ **Privacy.** Apps that handle health or other sensitive data often run on shared devices: a
586
+ family tablet, a clinic kiosk. Showing who has an account there can itself disclose something,
587
+ so identity is opt-in:
588
+
589
+ - `rememberIdentity` is required. Pass `false` to remember only the method unless the app's
590
+ users own their devices. Cards then show only the method.
591
+ - With `rememberIdentity: true`, the name is stored and shown in clear. Masking hides most of the
592
+ email, but the first letters and top-level domain can still hint at an employer or clinic
593
+ (`j***@f***.health`), so treat the masked email as identifying too.
594
+ - Turning `rememberIdentity` off later scrubs names, emails, and avatars already stored, the next
595
+ time the store loads.
596
+ - Offer a way to forget. Pass `onForget` to `LoginScreen`, and on a "Sign out and forget me"
597
+ action call `rememberedAccounts.forget(userId)` after signing out. Call
598
+ `rememberedAccounts.clear()` to remove every account, for example from a "Not your device?"
599
+ link.
600
+
601
+ ### 5. Drop-in login screen
602
+
603
+ `TerrenoLoginScreen` is a complete login screen. It renders `@terreno/ui`'s `LoginScreen` from
604
+ the server's [`/terreno/methods`](../reference/api.md#better-auth-authentication), so it shows
605
+ only the sign-in methods the server enables, and it records each sign-in in the
606
+ [remembered accounts](#4-remember-accounts-on-this-device) store.
607
+
608
+ ``````typescript
609
+ import {createRememberedAccountsStore, TerrenoLoginScreen} from "@terreno/rtk";
610
+ import {useLocalSearchParams, useRouter} from "expo-router";
611
+
612
+ export const rememberedAccounts = createRememberedAccountsStore({rememberIdentity: false});
613
+
614
+ const Login = (): React.ReactElement => {
615
+ const router = useRouter();
616
+ const {error} = useLocalSearchParams<{error?: string}>();
617
+ return (
618
+ <TerrenoLoginScreen
619
+ callbackURL="/login" // where a social sign-in returns
620
+ client={authClient} // createBetterAuthClient({features: {multiSession, passkey, twoFactor}})
621
+ error={error} // the ?error= an OAuth return brings back
622
+ onForgotPasswordPress={() => router.push("/forgotPassword")}
623
+ onSignUpPress={() => router.push("/signup")}
624
+ onSignedIn={async () => {
625
+ await betterAuth.syncSession(store.dispatch);
626
+ router.replace("/");
627
+ }}
628
+ providerLabels={{"mock-oidc": "Mock OIDC"}}
629
+ rememberedAccounts={rememberedAccounts}
630
+ trustDeviceDays={30}
631
+ />
632
+ );
633
+ };
634
+ ``````
635
+
636
+ `example-frontend/app/login.tsx` is a working copy. The example opts into `rememberIdentity: true`
637
+ (its store is in `lib/betterAuth.ts`), shows the two-factor challenge inline, and labels its e2e
638
+ mock provider with `providerLabels`.
639
+
640
+ What it does:
641
+
642
+ - **Live session.** On mount it checks for a session. A valid one calls `onSignedIn` and the
643
+ form never shows. In bearer mode it first finishes any OAuth handoff the URL carries
644
+ (`completeBetterAuthOAuthHandoff`, safe to call twice), then reads the token; with no stored
645
+ token it asks the server nothing. Pass `skipWhenSignedIn={false}` on an "add another account"
646
+ route. If `onSignedIn` throws, the screen comes back with "Sign in failed. Please try again."
647
+ - **"Continue as" cards.** Sessions signed in on this device (`multiSession`) come first and
648
+ switch with one tap (`multiSession.setActive`). Remembered accounts follow, minus any user
649
+ who already has a session card, and sign in again with their method: a provider, a passkey,
650
+ or email. The store masks emails, so an email card asks for the email and password instead
651
+ of filling them in.
652
+ - **Last used method.** Read from the remembered-accounts store, not from Better Auth's
653
+ `lastLoginMethod` cookie, which a page on another site cannot read.
654
+ - **Passkeys.** With the server's `passkey` on and the client's `passkey` feature, the screen
655
+ shows a passkey button on web only: React Native has no WebAuthn, so native shows none, and a
656
+ remembered passkey card there says the method is not available. On web it also offers saved
657
+ passkeys in the email field's autofill (`passkeyAutofill`, default `true`) when the browser
658
+ supports it. Autofill starts again whenever the screen returns to the login step.
659
+ - **Social sign-in.** Bearer mode uses [the OAuth handoff](#social-sign-in-in-bearer-mode)
660
+ (`signInSocialWithHandoff`); cookie mode and native use `signIn.social`. Pass `callbackURL`
661
+ set to the login route: the page returns there, the controller finds the new session, and it
662
+ records the provider. In bearer mode that path must be one of the server's
663
+ `oauthHandoff.callbackPaths`. In cookie mode on web, when the app and the API are on different
664
+ origins, pass the full URL (`${window.location.origin}/login`): Better Auth resolves a bare path
665
+ against the API's host, and list the web page's origin in the server's `trustedOrigins`, or
666
+ Better Auth refuses the URL. Native takes the path, which the Expo plugin turns into a deep link.
667
+ Providers Google, GitHub and Apple get branded buttons; any other provider id (a generic OIDC
668
+ provider) gets a plain button labelled from `providerLabels`.
669
+ If your root layout sends signed-in users away from the login route, let that route through:
670
+ the screen has to see the returned session to record it, and it navigates on by itself. Pass
671
+ `onStepChange` to catch the screen landing on its `login` step while your app already holds a
672
+ session (the live-session check failed), and navigate away there; example-frontend's
673
+ `app/login.tsx` does both, and skips re-syncing Redux in `onSignedIn` when it already holds the
674
+ user.
675
+ - **Signing out.** Dispatch `generateBetterAuthSlice`'s `logout()`. It keeps the session until
676
+ `authClient.signOut()` answers, then clears it, so the login screen never opens while the old
677
+ session is still live. Meanwhile `selectBetterAuthIsSigningOut` is true: show it on the
678
+ sign-out control. `logout()` leaves `isLoading` alone, so the screen stays put; if your
679
+ navigator shows a spinner on `selectBetterAuthIsLoading`, show it only while no user is
680
+ loaded (`isLoading && !userId`), or a session refresh would remount the navigator on its
681
+ first tab. A sign-out that cannot reach the server leaves a cookie or native session in place
682
+ and sets the slice's `error` (`SIGN_OUT_FAILED_MESSAGE`); show it. Stop anything that calls the
683
+ API with the session first: example-frontend's `logout` in `store/index.ts` stops syncdb,
684
+ then dispatches `logout()`, and its profile screen shows both.
685
+ - **Sign-up.** The sign-up link hides when the server's sign-up is closed (`signUp: false`).
686
+ - **No email and password.** When the server has `emailPassword` off, the form, the Sign in
687
+ button, and the forgot-password link are left out (`LoginScreen` with `fields={[]}`), and a
688
+ remembered email row says the method is not available.
689
+ - **Keyboard and password managers.** On web the fields sit in a real `<form>`
690
+ ([`FormContainer`](../reference/ui.md#formcontainer)), so browsers stop warning "Password field
691
+ is not contained in a form" and password managers fill and save the email and password. The
692
+ email field is `autocomplete="username"` (`"username webauthn"` with passkey autofill) and the
693
+ password `"current-password"`; on iOS and Android the same hints become `textContentType` and
694
+ the Android autofill hint. Enter in the email field moves to the password, and Enter in the
695
+ password signs in through the form's submit, once. From the email field, Tab reaches forgot
696
+ password, the password field, show/hide password, Sign in, then sign up. Method pills sit above
697
+ the form. Pass your own `fields` with an
698
+ `autoComplete` to change a hint.
699
+
700
+ `multiSession` does not work in bearer mode: the server tracks device sessions in cookies that a
701
+ bearer client never sends. Remembered accounts are the only cards there.
702
+
703
+ On native, a refused social sign-in shows nothing: Better Auth's Expo plugin takes the
704
+ `?error=` from the deep link it returns on, so the controller sees only "no session". If your
705
+ app reads that error itself, pass it as `error`.
706
+
707
+ #### Two-factor step
708
+
709
+ When a password sign-in needs a second factor, the screen shows `TwoFactorChallengeScreen`
710
+ inline and finishes the sign-in itself. "Trust this device" starts checked for an authenticator
711
+ code. Pass `initialTrustDevice={false}` to start it unchecked. The same option is on
712
+ `useLoginController` and comes back as `twoFactor.initialTrustDevice`. To use your own route
713
+ instead, pass `onTwoFactorRequired`:
714
+
715
+ ``````typescript
716
+ <TerrenoLoginScreen
717
+ onTwoFactorRequired={({twoFactorMethods}) => router.push("/twoFactor")}
718
+ // ...
719
+ />
720
+ ``````
721
+
722
+ Your screen then calls `authClient.twoFactor.verifyTotp` or `verifyBackupCode`, and after it
723
+ succeeds calls `recordRememberedSignIn({store: rememberedAccounts, user, method: "email"})` so the
724
+ device still remembers the account. Don't also set the client's
725
+ `features.twoFactor.onTwoFactorRedirect`, or both run.
726
+
727
+ #### Messages
728
+
729
+ Server refusals become guidance above the form. Text from the URL is never shown: an unknown
730
+ `?error=` gets "Sign in failed. Please try again."
731
+
732
+ | Server says | The screen shows |
733
+ | --- | --- |
734
+ | `account_not_linked` (redirect) or "account not linked" (ID token), linking mode `verified-email` | "An account with this email already exists. Verify your email address, then sign in again to link this sign-in method." |
735
+ | The same, mode `explicit` | "... Sign in with {last used method}, then link this sign-in method from Security." |
736
+ | The same, mode `never` | "This email already has an account. Sign in with {last used method} instead." |
737
+ | `VERIFY_EMAIL_TO_USE_ACCOUNT` (409 or redirect) | "Verify your email to use this account. ..." |
738
+ | `signup_disabled` (redirect), "signup disabled" (ID token), or `EMAIL_PASSWORD_SIGN_UP_DISABLED` | "Sign-up is closed for this app. Ask your administrator for access." The sign-up link hides |
739
+ | `provider_not_found` | "That sign-in provider is not available." |
740
+ | Any other refused request | The server's message, such as "Invalid email or password" |
741
+
742
+ See [Account linking](../explanation/account-linking.md) for why each refusal happens.
743
+
744
+ #### Your own screen
745
+
746
+ `useLoginController` takes the same options and returns `screenProps` for `LoginScreen`, the
747
+ inline challenge state (`twoFactor`), the current `step`, and the handlers (`signInEmail`,
748
+ `signInSocial`, `signInPasskey`, `continueAs`, `forget`):
749
+
750
+ ``````typescript
751
+ const login = useLoginController({client: authClient, onSignedIn, rememberedAccounts});
752
+ if (login.step === "login") {
753
+ return <LoginScreen fields={fields} logo={<Logo />} {...login.screenProps} />;
754
+ }
755
+ ``````
756
+
757
+ Social providers need their OAuth apps and callback URLs set up first; see
758
+ [Set up social login](set-up-social-login.md).
759
+
760
+ ### 6. Drop-in security screen
761
+
762
+ `TerrenoAccountSecurityScreen` is a complete account security screen for the signed-in user. It
763
+ renders `@terreno/ui`'s [`AccountSecurityScreen`](../reference/ui.md#account-security) and wires
764
+ every card to the Better Auth client. Each card shows only when the server's
765
+ [`/terreno/methods`](../reference/api.md#better-auth-authentication) turns its feature on.
766
+
767
+ ``````typescript
768
+ import {TerrenoAccountSecurityScreen} from "@terreno/rtk";
769
+ import {useLocalSearchParams} from "expo-router";
770
+
771
+ const Security = (): React.ReactElement => {
772
+ const {error} = useLocalSearchParams<{error?: string}>();
773
+ return (
774
+ <TerrenoAccountSecurityScreen
775
+ callbackURL="/security" // where linking a provider returns
776
+ client={authClient} // createBetterAuthClient({features: {passkey, twoFactor}})
777
+ linkError={error} // the ?error= a failed link brings back
778
+ providerLabels={{"mock-oidc": "Mock OIDC"}}
779
+ />
780
+ );
781
+ };
782
+ ``````
783
+
784
+ | Card | Shown when | Calls |
785
+ | --- | --- | --- |
786
+ | Linked accounts | The server has at least one provider | `listAccounts`; Link: `linkSocialWithHandoff` (the [bearer handoff](#linking-a-provider-in-bearer-mode) in bearer mode, `linkSocial` otherwise); Unlink: `unlinkAccount({accountId})`. No Link button under `accountLinking: {mode: "never"}` |
787
+ | Password | `emailPassword` is on | Change: `changePassword`. Set (no password yet, as after a social sign-up): `POST {basePath}/terreno/set-password` |
788
+ | Passkeys | `passkey` is on, the client has the `passkey` feature, and the app runs on web | `passkey.listUserPasskeys`, `addPasskey`, `updatePasskey`, `deletePasskey` |
789
+ | Two-factor | `twoFactor` is on and the client has the `twoFactor` feature | `twoFactor.enable` (its backup codes go to the card), `verifyTotp`, `generateBackupCodes`, `disable`. On or off comes from the session user's `twoFactorEnabled` |
790
+ | Sessions | Always | `listSessions`, `revokeSession({token})`, `revokeOtherSessions` ("Sign out everywhere else") |
791
+
792
+ How it behaves:
793
+
794
+ - **Errors.** Every Better Auth call resolves `{data, error}`; the hook turns an `error` into a
795
+ thrown `Error` with the server's message, so the card that called shows it. A section that
796
+ fails to load shows its error on its own card; only a failed methods or session load replaces
797
+ the cards with a page error and **Try again**.
798
+ - **Refetching.** After each change only the affected section reloads: unlinking or setting a
799
+ password reloads the accounts, a passkey change the passkeys, revoking the sessions. Turning
800
+ two-factor on or off, or changing the password with "Sign out of other devices", replaces the
801
+ current session on the server, so those reload the session and the sessions list. The session
802
+ is read with `disableCookieCache`, so a two-factor change shows at once.
803
+ - **Last sign-in method.** The screen never asks the server to unlink the user's only account,
804
+ or to remove a passkey that is their only sign-in method. It answers "You can't remove your
805
+ only sign-in method. Add another one first." Better Auth refuses unlinking the last account
806
+ too; the screen does not offer it.
807
+ - **Recent sign-in.** Better Auth asks for a session signed in within `session.freshAge` (a day
808
+ by default) before listing sessions, unlinking, or setting a password. The screen shows its
809
+ `SESSION_NOT_FRESH` refusal as "Sign in again to manage this."
810
+ - **A credential row without a password.** Better Auth's account list does not say whether the
811
+ `credential` row holds a password. If a change is refused with `CREDENTIAL_ACCOUNT_NOT_FOUND`,
812
+ the password card switches to "Set a password" and says "You don't have a password yet. Set
813
+ one below."
814
+ - **Linking in bearer mode.** The page leaves for the provider and comes back to `callbackURL`,
815
+ which may be any page on one of the server's `oauthHandoff.webOrigins`. A failed link comes
816
+ back with `?error=`; pass it as `linkError`. Known codes read as guidance (such as "That
817
+ account is already linked to another user."); any other value reads "Couldn't link that
818
+ account. Please try again." Text from the URL is never shown.
819
+ - **Linking in cookie mode on web.** When the app and the API are on different origins, pass
820
+ the full URL (`${window.location.origin}/settings/security`), as for the login screen: Better
821
+ Auth resolves a bare path against the API's host. The example app's
822
+ `app/settings/security.tsx` passes the full URL on web and the path on native
823
+ (`getSecurityCallbackURL` in `lib/betterAuth.ts`), and the Profile tab opens it from its
824
+ **Security** button (`profile-security-link`).
825
+ - **Sessions in bearer mode.** `listSessions` and `revokeSession` authenticate with the bearer,
826
+ so the sessions card works on another site. (`multiSession` does not.)
827
+ - **Passkeys on native.** React Native has no WebAuthn, so the passkeys card is web-only.
828
+
829
+ Pass `methods` when the app already loaded them with `useAuthMethods`, so the screen does not
830
+ request them again. `minPasswordLength` sets the password card's minimum to match the server's.
831
+ The screen also takes `AccountSecurityScreen`'s `title`, `description`, `header`, `children`,
832
+ `withPage`, `maxWidth`, and `testID`.
833
+
834
+ #### Set a password for a social-only user
835
+
836
+ A user who signed up with a provider has no password. Better Auth's own `setPassword` is
837
+ server-only, so `@terreno/api` serves `POST {basePath}/terreno/set-password` (`{newPassword}`)
838
+ for the signed-in user. It needs a fresh session (signed in within Better Auth's
839
+ `session.freshAge`, a day by default; otherwise 403 `SESSION_NOT_FRESH`), refuses with 409
840
+ `PASSWORD_ALREADY_SET` when the user already has a password (changing one goes through
841
+ `/change-password`, which checks the current password), follows Better Auth's password length
842
+ rules, and counts against the `auth` rate-limit bucket. With a `userModel`, an app User linked by
843
+ `betterAuthId` that already has a JWT password gets the new one too (and a `tokenEpoch` bump); a
844
+ social-only account does not gain a JWT password, since `POST /auth/login` has no second factor.
845
+ It records a `password-set` security event, which emails the user when
846
+ [security notifications](../reference/account-security.md#notifications) are on. See the
847
+ [API reference](../reference/api.md#better-auth-authentication).
848
+
849
+ With a `userModel`, a successful `/change-password` also replaces the app User's JWT password (when
850
+ it has one) and bumps its `tokenEpoch`, so the old password stops working at `POST /auth/login`.
851
+
852
+ #### Your own security screen
853
+
854
+ `useAccountSecurity` takes the same options and returns `screenProps` for
855
+ `AccountSecurityScreen`, plus `methods`, `user`, and `refetch()`:
856
+
857
+ ``````typescript
858
+ const security = useAccountSecurity({callbackURL: "/security", client: authClient});
859
+ return <AccountSecurityScreen header={<ProfileHeader />} {...security.screenProps} />;
860
+ ``````
861
+
313
862
  ## Frontend and API on different sites
314
863
 
315
864
  By default the web client relies on Better Auth's HttpOnly session cookie. When the web app
@@ -339,6 +888,10 @@ With `webAuth: "bearer"`, on web only:
339
888
  a usable token on the device.
340
889
  - A successful `getSession` that returns no session clears the token, unless a newer token was
341
890
  stored while that request was in flight. A failed or 5xx `getSession` keeps it.
891
+ - Two-factor, trust-device, and passkey challenges travel in the `X-Terreno-Auth-Challenge`
892
+ header, stored under `${storagePrefix}_auth_challenge` beside the token and sent only on
893
+ `/two-factor/*`, `/passkey/*`, and `/sign-in/*` calls, so 2FA and passkey sign-in work with no
894
+ cookies. See [Challenges without cookies](../explanation/authentication.md#challenges-without-cookies).
342
895
 
343
896
  Native apps ignore `webAuth`: the Expo plugin already keeps the session without browser cookies.
344
897
 
@@ -375,7 +928,10 @@ same token. `e2e/crossSiteBearer.spec.ts` signs in from `localhost:8082` to an A
375
928
 
376
929
  Better Auth skips this check when `NODE_ENV=test`, so test it against a non-test build.
377
930
  - No CORS change is needed. Bearer mode sends no cookies, so the default `corsOrigin` (`"*"`)
378
- works, and so does an explicit list that includes the web origin.
931
+ works, and so does an explicit list that includes the web origin. `cors()` allows any request header the
932
+ browser asks for, which covers `X-Terreno-Auth-Challenge`. A custom CORS layer with an explicit
933
+ `allowedHeaders` list must include it, and must not drop the `Access-Control-Expose-Headers`
934
+ Better Auth sets.
379
935
  - No header change is needed. Better Auth's bearer plugin, which `BetterAuthApp` enables, lists
380
936
  `set-auth-token` in `Access-Control-Expose-Headers` itself, so the page can read it.
381
937
 
@@ -459,8 +1015,12 @@ example-frontend uses `/login` as the return page. With `BETTER_AUTH_OAUTH_HANDO
459
1015
  example-backend sets `oauthHandoff` to its web origins and `callbackPaths: ["/login"]`. With
460
1016
  `NODE_ENV=production` it drops loopback origins (`localhost`, `127.0.0.1`, `[::1]`, any port) from
461
1017
  the handoff only, so a token never goes to a page on the visitor's own machine, and it refuses to
462
- start when no other origin is left in `CORS_ORIGINS`. CORS and `trustedOrigins` keep them. In bearer mode `lib/betterAuth.ts` starts the social buttons with
463
- `signInSocialWithHandoff`. `app/_layout.tsx` syncs the session only after the exchange resolves.
1018
+ start when no other origin is left in `CORS_ORIGINS`. CORS and `trustedOrigins` keep them.
1019
+ `app/login.tsx` renders `TerrenoLoginScreen` with `callbackURL="/login"` in bearer mode, so its
1020
+ social buttons start with `signInSocialWithHandoff` and come back to the same screen, which finds
1021
+ the session and records the method. `app/_layout.tsx` syncs the session only after the exchange
1022
+ resolves, and leaves a signed-in user on `/login` so the screen can record the sign-in before it
1023
+ navigates on.
464
1024
 
465
1025
  In an Expo Router app, run the exchange from the entry file, not from a layout. Expo Router
466
1026
  copies the URL, fragment included, as soon as it is imported, and writes that copy back into
@@ -470,12 +1030,81 @@ first imports `lib/oauthHandoffUrl.ts`, which has no imports and hides `handoff`
470
1030
  the URL, then `lib/oauthHandoff.ts`, which puts the URL back and calls
471
1031
  `completeBetterAuthOAuthHandoff` in the same synchronous step, then `expo-router/entry`.
472
1032
 
473
- The login screen shows `?error=` in `login-screen-error`. `e2e/crossSiteOAuth.spec.ts` signs in
1033
+ The login screen (`TerrenoLoginScreen`) turns `?error=` into guidance in `login-screen-notice` or
1034
+ a short message in `login-screen-error`, never the raw text. `e2e/crossSiteOAuth.spec.ts` signs in
474
1035
  cross-site through a local mock OIDC provider (`e2e/helpers/mockOidcProvider.ts`), which
475
1036
  example-backend registers as the `mock-oidc` generic OAuth provider only when `MOCK_OIDC_ISSUER`
476
1037
  is set. example-backend refuses to start when `MOCK_OIDC_ISSUER` is set with
477
1038
  `NODE_ENV=production` or names a host other than `127.0.0.1`, `localhost` or `[::1]`.
478
1039
 
1040
+ By default the mock provider signs in one fixed verified user (`MOCK_OIDC_USER`). A test can
1041
+ name a different identity per sign-in with `setMockOidcIdentity(context, {email, emailVerified,
1042
+ name?, sub?})`, which sets a `mock_oidc_identity` cookie on the provider's host for that browser
1043
+ context only, so parallel tests do not collide. An authorize request with a `login_hint` and no
1044
+ cookie signs in that email, verified. Use an unverified identity to drive the linking cases that
1045
+ must refuse.
1046
+
1047
+ ### Linking a provider in bearer mode
1048
+
1049
+ A signed-in user links Google, GitHub, or a generic OAuth provider from the Security screen. In
1050
+ cookie mode `authClient.linkSocial` does this. In bearer mode it can't: its
1051
+ `POST /link-social` fetch comes from another site, so the OAuth state cookie it needs on the
1052
+ API's origin is never set. Use `linkSocialWithHandoff` from `@terreno/rtk` instead. It needs
1053
+ the same `oauthHandoff` server config as social sign-in, and account linking must be on
1054
+ (`accountLinking.mode` other than `never`).
1055
+
1056
+ ``````typescript
1057
+ import {linkSocialWithHandoff} from "@terreno/rtk";
1058
+
1059
+ const {error} = await linkSocialWithHandoff({
1060
+ callbackURL: "/settings/security", // any page on a web origin; sent as an absolute URL
1061
+ client: authClient,
1062
+ errorCallbackURL: "/settings/security?link=failed", // optional; defaults to callbackURL
1063
+ provider: "google",
1064
+ });
1065
+ if (error?.code === "LINKING_DISABLED") {
1066
+ // Linking is off on this server; hide the "Link" buttons.
1067
+ }
1068
+ ``````
1069
+
1070
+ On web in bearer mode it takes two steps:
1071
+
1072
+ 1. It posts `{provider, callbackURL, errorCallbackURL}` with the bearer to
1073
+ `POST {basePath}/oauth-handoff/link-intent`, with both URLs resolved to absolute ones. The
1074
+ server accepts any page on one of `oauthHandoff.webOrigins` that `trustedOrigins` allows.
1075
+ `callbackPaths` do not apply, because no token travels on a link's return. It answers
1076
+ `{token, startURL}`. The token is single-use, expires in
1077
+ three minutes, and stands for the caller's session.
1078
+ 2. It submits the token in a hidden HTML form (method POST) to `startURL`. That page navigation
1079
+ keeps the token out of the URL and lets the server set the state cookie first-party. The
1080
+ server uses the token up, starts Better Auth's link flow as that session, and redirects to
1081
+ the provider.
1082
+
1083
+ The provider sends the page back to `callbackURL`. The account is linked, and the URL carries
1084
+ no `#ott`, because linking creates no session. `completeBetterAuthOAuthHandoff` finds nothing
1085
+ to do there. A failure comes back to `errorCallbackURL` (default `callbackURL`) with `?error=`:
1086
+
1087
+ - `session_expired`: the session was revoked or expired between the two steps.
1088
+ - `provider_not_found`: the server has no such provider.
1089
+ - `link_failed`: any other failure.
1090
+ - Better Auth's own callback codes, such as `email_does_not_match`, `unable_to_link_account`
1091
+ (the provider did not verify the email), or `account_already_linked_to_different_user`.
1092
+
1093
+ The intent answers 400 for a return URL that is not on one of `oauthHandoff.webOrigins` (403
1094
+ when `trustedOrigins` does not allow it), and 403 `LINKING_DISABLED` when linking is off. The function resolves with that
1095
+ `{error}` and submits nothing. The start answers 400, with no redirect, for an unknown, used, or
1096
+ expired token. It also answers 400 when `Sec-Fetch-Dest` is set and is not `document`, and 403
1097
+ when `Origin` is missing, `null`, or not one of `webOrigins`. Those two refusals leave the token
1098
+ unused.
1099
+
1100
+ Don't serve the web app with `Referrer-Policy: no-referrer` or `Referrer-Policy: same-origin`.
1101
+ With either, the browser sends `Origin: null` on the cross-site form post, and every link start
1102
+ answers 403. The default, `strict-origin-when-cross-origin`, works.
1103
+
1104
+ Better Auth's same-email rule still applies: the provider's verified email must match the
1105
+ user's, unless `options.account.accountLinking.allowDifferentEmails` is set. In cookie mode and
1106
+ on native, `linkSocialWithHandoff` calls `client.linkSocial` as before.
1107
+
479
1108
  ### Trade-offs
480
1109
 
481
1110
  A bearer token in web storage is readable by any script on the frontend's origin, so an XSS
@@ -507,35 +1136,18 @@ export const authClient = createBetterAuthClient({
507
1136
 
508
1137
  ## OAuth Provider Setup
509
1138
 
510
- ### Google OAuth
511
-
512
- 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
513
- 2. Create a new project or select existing
514
- 3. Enable Google+ API
515
- 4. Create OAuth 2.0 credentials
516
- 5. Add authorized redirect URIs:
517
- - `http://localhost:4000/api/auth/callback/google` (development)
518
- - `https://yourapp.com/api/auth/callback/google` (production)
519
- 6. Copy Client ID and Client Secret to `.env`
520
-
521
- ### GitHub OAuth
522
-
523
- 1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
524
- 2. Create a new OAuth App
525
- 3. Set Authorization callback URL:
526
- - `http://localhost:4000/api/auth/callback/github` (development)
527
- - `https://yourapp.com/api/auth/callback/github` (production)
528
- 4. Copy Client ID and generate Client Secret
529
- 5. Add to `.env`
530
-
531
- ### Apple Sign In
1139
+ [Set up social login](set-up-social-login.md) registers Google, GitHub, and Sign in with Apple
1140
+ step by step: the callback URL for local dev, previews, and the stable deploy, the secrets to
1141
+ store, how to try the providers on one preview first, and how to check the result. In short:
532
1142
 
533
- 1. Go to [Apple Developer Console](https://developer.apple.com/)
534
- 2. Create a new Services ID
535
- 3. Configure Sign in with Apple
536
- 4. Add return URLs (similar to above)
537
- 5. Generate private key and download
538
- 6. Copy Service ID and Key to `.env`
1143
+ - **Callback:** `https://<backend>/api/auth/callback/<provider>` (`http://localhost:4000/...`
1144
+ in development; Apple accepts https only).
1145
+ - **Google:** an OAuth client ID of type Web application, with that redirect URI.
1146
+ - **GitHub:** an OAuth App with that authorization callback URL.
1147
+ - **Apple:** a Services ID with the backend and web hosts under Domains and that return URL,
1148
+ plus a Sign in with Apple key. Pass the Services ID, team ID, key ID, and key contents as
1149
+ `appleOAuth: {clientId, teamId, keyId, privateKey}`; Terreno signs the client secret at
1150
+ startup (see [Auth features](#auth-features)).
539
1151
 
540
1152
  ## Generated Endpoints
541
1153
 
@@ -584,17 +1196,25 @@ Better Auth runs **in parallel** with JWT auth. You don't need to migrate existi
584
1196
  2. Keep existing JWT endpoints available (they still work)
585
1197
  3. New users can sign up via Better Auth
586
1198
  4. Existing users continue using JWT tokens
587
- 5. Optionally add migration logic to link accounts
588
-
589
- An existing JWT user who signs up through Better Auth with the same email is linked to their app
590
- `User` only once Better Auth has verified that email. Until then their session does not
591
- authenticate. Configure `sendMail` so verification mail goes out, and consider
592
- `options.emailAndPassword.requireEmailVerification: true`. Social sign-in with a provider that
593
- verifies email links on first contact. If the app `User` never verified its email
594
- (`emailVerified` is not `true`), linking removes its legacy password and bumps `tokenEpoch`, so
595
- the user signs in through Better Auth or resets the password from then on. A row that already has
596
- a different `betterAuthId` is never relinked. Set `SIGNUP_DISABLED=true` once Better Auth is the
597
- only sign-up path, so new accounts cannot be created through the legacy `/auth/signup`.
1199
+ 5. Existing users who sign up through Better Auth with the same email are linked to their app
1200
+ `User` when the password they sign up with matches their existing passport password, or when
1201
+ they sign in with a provider that verified the email. Otherwise sign-up answers 409 "Verify
1202
+ your email to use this account" and nothing is created. Users without a password (GitHub JWT
1203
+ sign-in) use a verified provider sign-in or JWT forgot-password first, so keep the JWT routes
1204
+ mounted during the move.
1205
+
1206
+ See [Account linking](../explanation/account-linking.md) for the linking modes, why both emails
1207
+ must be verified, and the full migration path for JWT apps.
1208
+
1209
+ Without a matching password, an existing JWT user who signs up through Better Auth with the same
1210
+ email is linked to their app `User` only once Better Auth has verified that email. Configure
1211
+ `sendMail` so verification mail goes out, and consider
1212
+ `options.emailAndPassword.requireEmailVerification: true`. If the app `User` never verified its
1213
+ email (`emailVerified` is not `true`), linking through a verified email removes its legacy
1214
+ password and bumps `tokenEpoch`, so the user signs in through Better Auth or resets the password
1215
+ from then on. A row that already has a different `betterAuthId` is never relinked (409
1216
+ `ACCOUNT_ALREADY_EXISTS`). Set `SIGNUP_DISABLED=true` once Better Auth is the only sign-up path,
1217
+ so new accounts cannot be created through the legacy `/auth/signup`.
598
1218
 
599
1219
  ## Learn More
600
1220