@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
@@ -6,6 +6,15 @@ This guide is **AI-context-first**: it is written for an agent (or human) perfor
6
6
 
7
7
  `modelRouter` `realtime`, `realtimeList`, `realtimeDocument`, and `setRealtimeSocket` are **deprecated** and **will be removed in Terreno 58**. `RealtimeApp` stays as the socket host for syncdb. Removal checklist: [`docs/tasks/remove-legacy-realtime.md`](https://github.com/TerrenoLabs/terreno/blob/master/docs/tasks/remove-legacy-realtime.md).
8
8
 
9
+ `installTerrenoDevConsoleLogger` moved to `@terreno/syncdb`. The `@terreno/rtk` export is a **deprecated** re-export kept for the deprecation window. Change the import and pass the base URL:
10
+
11
+ ```typescript
12
+ import {baseUrl} from "@terreno/rtk";
13
+ import {installTerrenoDevConsoleLogger} from "@terreno/syncdb";
14
+
15
+ installTerrenoDevConsoleLogger({baseUrl});
16
+ ```
17
+
9
18
  ## 1. Before you start
10
19
 
11
20
  | Decision | Requirement | Why |
@@ -79,4 +79,4 @@ To set a password from your own server code (for example an admin action), call
79
79
 
80
80
  Either reset path also ends the user's MCP credentials: `mcp_` service tokens and MCP OAuth grants issued before the reset stop working, because they are bound to `tokenEpoch`. With `CommsApp` registered, push also stops going to devices the user registered before the reset until each device registers again.
81
81
 
82
- Better Auth password reset revokes that user's Better Auth sessions (`revokeSessionsOnPasswordReset`) and, when an app User exists, updates the JWT password and `tokenEpoch` so `POST /auth/login` cannot keep the old passport hash. JWT `POST /auth/resetPassword` does the reverse for Better Auth when `BetterAuthApp` is registered: it updates the Better Auth password and deletes Better Auth sessions. Console mail in development prints length only; check `CommsMessage` / console adapter logs for delivery, not the raw token in logger output. Request logs redact password, token, secret, and code fields (any key name, any depth) in bodies and in URL query strings.
82
+ Better Auth password reset revokes that user's Better Auth sessions (`revokeSessionsOnPasswordReset`) and, when an app User exists, updates the JWT password and `tokenEpoch` so `POST /auth/login` cannot keep the old passport hash. JWT `POST /auth/resetPassword` does the reverse for Better Auth when `BetterAuthApp` is registered: it updates the Better Auth password and deletes Better Auth sessions. With `BetterAuthApp` registered, the JWT routes only replace an existing JWT password, because `POST /auth/login` has no second factor: `POST /auth/forgotPassword` mails no link to an app User without one (the response is still 202), and `POST /auth/resetPassword` refuses that User's token with 400 `legacy-password-reset-unavailable`, setting no password. That account (for example a social-only one) resets through Better Auth. Without Better Auth, the JWT routes still let any user set a password. Console mail in development prints length only; check `CommsMessage` / console adapter logs for delivery, not the raw token in logger output. Request logs redact password, token, secret, and code fields (any key name, any depth) in bodies and in URL query strings.
@@ -0,0 +1,112 @@
1
+ # Recover a locked-out user
2
+
3
+ Help a user who lost their authenticator, passkey, or social account get back in. Platform
4
+ staff use `/users/:id/security`; org-admins use `/orgs/:orgId/members/:userId/security`. Both
5
+ run the same handlers, and every change writes a security `AuditEvent`.
6
+
7
+ ## Before you start
8
+
9
+ 1. Register `AuditApp` and `AccountSecurityApp` on the `TerrenoApp`:
10
+
11
+ ```typescript
12
+ import {AccountSecurityApp, AuditApp, BetterAuthApp, TerrenoApp} from "@terreno/api";
13
+
14
+ new TerrenoApp({accessControl: access, organizations: true, userModel: User})
15
+ .register(new BetterAuthApp({config: betterAuthConfig}))
16
+ .register(new AuditApp())
17
+ .register(new AccountSecurityApp())
18
+ .start();
19
+ ```
20
+
21
+ 2. For `disable` and `enable`, apply `isDisabledPlugin` to the User schema.
22
+ 3. For `send-password-reset`, set `sendMail` and `publicAppUrl` in `BetterAuthConfig`.
23
+ 4. For the org path, turn organizations on (`organizations: true`). `TerrenoApp` hands the
24
+ registered `AccountSecurityApp` to `OrgsApp`, which mounts the org routes.
25
+
26
+ ## Who can do what
27
+
28
+ | Action | Route suffix | Platform staff (`user:manageSecurity`) | Org-admin of the user's org |
29
+ | --- | --- | --- | --- |
30
+ | Read the summary | `GET /security` | Yes | Yes |
31
+ | Reset two-factor | `POST /security/reset-two-factor` | Yes | Yes |
32
+ | Remove a passkey | `DELETE /security/passkeys/:passkeyId` | Yes | Yes |
33
+ | Unlink a provider | `DELETE /security/accounts/:providerId` | Yes | Yes |
34
+ | Sign out everywhere | `POST /security/revoke-sessions` | Yes | Yes |
35
+ | Send a password reset | `POST /security/send-password-reset` | Yes | Yes |
36
+ | Disable or enable the account | `POST /security/disable`, `/enable` | Yes | No |
37
+ | Suspend the membership | `POST /security/suspend-membership` | Through the org path | Yes |
38
+ | Issue a sign-in link | `POST /security/issue-sign-in-link` | `superadmin` only (`user:issueSignInLink`) | No |
39
+
40
+ `superadmin` and `operator` hold `user:manageSecurity`. An org-admin is a user whose active
41
+ `Membership` in that org has `roleName: "org-admin"`.
42
+
43
+ Nobody can act on themselves, or on someone who holds a permission they lack. An org-admin can't
44
+ reach platform staff or `admin: true` users, members of other orgs, or suspended members. Platform
45
+ staff on the org path are judged the same way as on the platform path, so an org-only permission
46
+ does not block them.
47
+
48
+ ## Platform path
49
+
50
+ Prefix every route with `/users/<userId>`.
51
+
52
+ 1. Read the summary to see the user's sign-in methods and the actions that apply:
53
+
54
+ ```bash
55
+ curl -H "Authorization: Bearer $TOKEN" https://api.example.com/users/<userId>/security
56
+ ```
57
+
58
+ `data.actions` lists only the actions that apply to this user and that you may run.
59
+ 2. Run the action that fixes the problem:
60
+ - Lost authenticator: `POST .../reset-two-factor`. The user signs in with their password and
61
+ can enroll again.
62
+ - Lost device with a passkey: `DELETE .../passkeys/<passkeyId>` (ids are in the summary).
63
+ - Lost social account: `DELETE .../accounts/<providerId>`.
64
+ - Stolen device: `POST .../revoke-sessions`.
65
+ - Forgotten password: `POST .../send-password-reset`.
66
+ - Compromised account: `POST .../disable`. Later, `POST .../enable`.
67
+ - Legacy user with no password and no inbox: `POST .../issue-sign-in-link`, then give the
68
+ user the returned link yourself. Only a superadmin can do this.
69
+ 3. Removing the user's last sign-in method answers `409` unless their email is verified. With a
70
+ verified email, the route also mails a password reset.
71
+
72
+ ## Org path
73
+
74
+ Prefix every route with `/orgs/<orgId>/members/<userId>`. The user must be an active member of
75
+ `<orgId>`. They may belong to other orgs too.
76
+
77
+ 1. Read the summary: `GET /orgs/<orgId>/members/<userId>/security`.
78
+ 2. Run the same recovery actions as the platform path (2FA, passkey, provider, sessions, reset).
79
+ 3. To cut the user off from your org, run `POST .../suspend-membership`. It suspends only their
80
+ membership in `<orgId>`. They still sign in and keep their other orgs.
81
+ 4. To bring a suspended member back, use the membership route:
82
+ `PATCH /orgs/<orgId>/members/<memberId>` with `{"status": "active"}`. A suspended member is out
83
+ of reach of the security routes until then.
84
+
85
+ To stop an org-admin from attaching an existing user by email and acting on them right away, set
86
+ a minimum membership age:
87
+
88
+ ```typescript
89
+ new TerrenoApp({
90
+ accessControl: access,
91
+ organizations: {orgRecovery: {minMembershipDays: 7}},
92
+ userModel: User,
93
+ });
94
+ ```
95
+
96
+ ## Check the audit trail
97
+
98
+ Every change is one `AuditEvent` with `source: "security"`. Org actions carry the org's id in
99
+ `organizationId`. When `sendMail` is configured, each change also emails the user (except
100
+ `send-password-reset`, whose reset email is the notice). For `issue-sign-in-link` the email says a
101
+ link was created, never the link itself. The email says "an administrator" and never names you;
102
+ see
103
+ [Notifications](../reference/account-security.md#notifications).
104
+
105
+ ```bash
106
+ curl -H "Authorization: Bearer $TOKEN" \
107
+ "https://api.example.com/audit-events?source=security&recordId=<userId>"
108
+ ```
109
+
110
+ Related: [Account security reference](../reference/account-security.md),
111
+ [How organization tenancy works](../explanation/organizations.md#member-recovery),
112
+ [Enable the framework audit log](audit-log.md).
@@ -79,9 +79,10 @@ MONGO_URI="mongodb://127.0.0.1:27017/terreno-e2e?replicaSet=rs0" \
79
79
 
80
80
  | CircleCI shard | Specs |
81
81
  | --- | --- |
82
- | `e2e` `auth` | `login` `signup` `consents` `forgot-password` `reset-password` `verify-email` |
82
+ | `e2e` `auth` | `login` `signup` `consents` `forgot-password` `reset-password` `verify-email` `two-factor` |
83
83
  | `e2e` `app` | `todos` `profile` `realtime` `ai-chat` `pdf` |
84
- | `e2e` `admin-core` | `admin` `admin-home` `admin-form` `admin-todo-crud` `admin-title-update-depth` |
84
+ | `e2e` `admin-core` | `admin` `admin-home` `admin-form` `admin-todo-crud` `admin-title-update-depth` `org-admin-security` `admin-user-security` |
85
+ | `e2e-cross-site` (not required) | the cross-site specs below, then `account-linking` with `E2E_ACCOUNT_LINKING=explicit` |
85
86
  | `e2e` `admin-table` | `admin-table-search-filter` `admin-table-bulk-actions` `admin-custom-screens` `admin-comms-back` `harness-approvals` `harness-intake` |
86
87
  | `e2e` `syncdb` | `syncdb-load-delta` `syncdb-offline` `syncdb-conflicts` `syncdb-storage` `syncdb-chaos` |
87
88
 
@@ -89,14 +90,42 @@ Full local suite: `bun run frontend:e2e`. Nightly load (`e2e-load` / `syncdb-loa
89
90
  not PR-blocking; run `bunx playwright test e2e/syncdb-loadlab.spec.ts` only when that job
90
91
  failed.
91
92
 
92
- `e2e/crossSiteBearer.spec.ts` and `e2e/crossSiteOAuth.spec.ts` have no CircleCI job: they need
93
- a web server built with `EXPO_PUBLIC_API_URL=http://127.0.0.1:4000`,
94
- `EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true` and `EXPO_PUBLIC_MOCK_OIDC_SIGN_IN=true`, a backend
95
- with `BETTER_AUTH_URL=http://127.0.0.1:4000`, `BETTER_AUTH_OAUTH_HANDOFF=true` and
96
- `MOCK_OIDC_ISSUER`, and the mock OIDC provider on `127.0.0.1:4555`. Naming either spec file (or
93
+ The cross-site specs (`CROSS_SITE_SPEC_FILES` in `playwright.config.ts`:
94
+ `e2e/crossSiteBearer.spec.ts`, `e2e/crossSiteOAuth.spec.ts`, `e2e/authMethods.spec.ts`,
95
+ `e2e/crossSiteChallenges.spec.ts`, `e2e/crossSiteLogin.spec.ts`,
96
+ `e2e/account-linking.spec.ts`, `e2e/account-security.spec.ts`) run in CircleCI's `e2e-cross-site` job, which is not a required
97
+ check, rather than in an `e2e` shard: they need
98
+ a web server built with `EXPO_PUBLIC_API_URL=http://127.0.0.1:4000` and
99
+ `EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true`, a backend with
100
+ `BETTER_AUTH_URL=http://127.0.0.1:4000`, `BETTER_AUTH_OAUTH_HANDOFF=true` and
101
+ `MOCK_OIDC_ISSUER` (the login page shows the mock provider's button because
102
+ `/api/auth/terreno/methods` then lists `mock-oidc`), and the mock OIDC provider on
103
+ `127.0.0.1:4555`. Naming any of those spec files (or
97
104
  setting `E2E_CROSS_SITE_BEARER=true`) makes `playwright.config.ts` start all three and run only
98
105
  the cross-site specs, so stop any other backend on 4000 and web server on 8082 first:
99
- `cd example-frontend && bunx playwright test e2e/crossSiteBearer.spec.ts e2e/crossSiteOAuth.spec.ts`.
106
+ `cd example-frontend && E2E_CROSS_SITE_BEARER=true bunx playwright test`. CI serves a static
107
+ export instead of a Metro dev server, which gets OOM-killed bundling beside the backend and
108
+ Chromium. To run the specs exactly as `e2e-cross-site` does:
109
+
110
+ ```bash
111
+ cd example-frontend
112
+ EXPO_PUBLIC_API_URL=http://127.0.0.1:4000 EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true \
113
+ bun expo export --platform web --output-dir dist-cross-site --clear
114
+ bunx serve dist-cross-site -s -l 8082 &
115
+ CI=true E2E_CROSS_SITE_BEARER=true MONGO_URI="mongodb://127.0.0.1:27017/terreno-e2e?replicaSet=rs0" \
116
+ bunx playwright test --config playwright.circleci.config.ts --reporter=list
117
+ ```
118
+
119
+ `--clear` matters: Metro's cache keeps the `EXPO_PUBLIC_*` values of an earlier export. A new
120
+ spec that needs
121
+ the mock provider or cookie-less sessions joins `CROSS_SITE_SPEC_FILES` and the
122
+ `cross-site-bearer` shard in `scripts/ci/e2eAffected/shards.ts`.
123
+
124
+ `e2e/account-linking.spec.ts` has cases for the backend's `explicit` linking mode, which skip
125
+ unless the backend reports that mode. `E2E_ACCOUNT_LINKING=explicit` starts the backend with
126
+ `AUTH_ACCOUNT_LINKING=explicit`; run that spec alone, because the other specs expect the default
127
+ `verified-email` (`e2e-cross-site` does this as its second step):
128
+ `cd example-frontend && E2E_ACCOUNT_LINKING=explicit bunx playwright test e2e/account-linking.spec.ts`.
100
129
 
101
130
  ### Deployed bearer smoke (`deploy-frontend-preview`)
102
131
 
@@ -0,0 +1,424 @@
1
+ # Set up social login
2
+
3
+ Turn on Google, GitHub, and Sign in with Apple for a Terreno app, on local dev, on per-PR
4
+ previews, and on the stable deploy. Register each provider once, on the stable backend, and let
5
+ every preview sign in through it. The example app's deploy (`scripts/ci/gcp-deploy.sh`) is the
6
+ worked case; a consumer app follows the same steps with its own hosts and secret names.
7
+
8
+ Config fields used here (`googleOAuth`, `githubOAuth`, `appleOAuth`, `oauthProxy`, `passkey`) are
9
+ listed in [Configure Better Auth](configure-better-auth.md#auth-features). Agents can follow the
10
+ `set-up-social-login` skill, which walks the same steps.
11
+
12
+ ## Before you start
13
+
14
+ 1. **Point gcloud at the project** that holds the backend's secrets, once per machine:
15
+
16
+ ```bash
17
+ gcloud config set project <gcp-project-id>
18
+ gcloud config get-value project
19
+ ```
20
+
21
+ Every `gcloud` command below then uses it. Pass `--project=<id>` to override it for one
22
+ command.
23
+ 2. **Share one `BETTER_AUTH_SECRET`** between stable and previews. oauth-proxy encrypts the
24
+ forwarded profile with it, and a preview refuses a profile encrypted under another secret.
25
+ 3. **Choose the hosts** ([below](#choose-the-hosts)). The provider callbacks go on the stable
26
+ backend's final host, so decide it before registering.
27
+
28
+ ## Choose the hosts
29
+
30
+ Put the stable web app and backend on subdomains of one domain you own, such as
31
+ `app.<domain>` and `api.<domain>`.
32
+
33
+ - **They are same-site.** A browser treats hosts as one site when they share a registrable
34
+ domain (the part just below a public suffix such as `.com` or `.app`). Safari blocks only
35
+ cross-site cookies, so the host-only session cookie on `api.<domain>` is sent on the web app's
36
+ requests, and the web app can use cookie mode.
37
+ - **Keep the cookie host-only.** Leave Better Auth's `crossSubDomainCookies` off; it would send
38
+ the session cookie to every subdomain.
39
+ - **Passkeys.** Set `PASSKEY_RP_ID=<domain>` so a passkey works on `app.<domain>` and any other
40
+ web origin you list on `<domain>`.
41
+ - **Safari's 7-day cap.** Safari caps a cookie at 7 days when the server that sets it answers
42
+ from an IP address unlike the web app's, as when a CDN serves `app.` and Cloud Run serves
43
+ `api.`. A user who stays away longer than 7 days signs in again. Serving both hosts from one
44
+ load balancer removes the cap.
45
+
46
+ **Previews:** leave them on their hosting domains (`pr-<n>--<site>.netlify.app` and the Cloud
47
+ Run tag URL `pr-<n>---<service>.run.app`). They are cross-site to their backend, so their web
48
+ build runs in [bearer mode](configure-better-auth.md#frontend-and-api-on-different-sites), which
49
+ needs no third-party cookies and works in Safari. Do not put previews under the stable domain:
50
+ a preview runs unreviewed PR code, and on the same site it could send requests that carry a
51
+ visitor's stable session cookie and request passkey assertions for the stable rpID. Same-site
52
+ previews need a separate domain of their own.
53
+
54
+ ## Turn the providers on in the server
55
+
56
+ A provider turns on when its config field is set. The login and security screens read
57
+ `GET {basePath}/terreno/methods` and show a provider's button only once the server lists it.
58
+
59
+ ```typescript
60
+ const config: BetterAuthConfig = {
61
+ enabled: true,
62
+ baseURL: process.env.BETTER_AUTH_URL, // this backend's public origin
63
+ secret: process.env.BETTER_AUTH_SECRET,
64
+ trustedOrigins: [...webOrigins, "https://appleid.apple.com", "myapp://"],
65
+ googleOAuth: {clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!},
66
+ githubOAuth: {clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!},
67
+ appleOAuth: {
68
+ clientId: process.env.APPLE_CLIENT_ID!, // the Services ID
69
+ keyId: process.env.APPLE_KEY_ID!,
70
+ privateKey: process.env.APPLE_PRIVATE_KEY!, // the .p8 contents
71
+ teamId: process.env.APPLE_TEAM_ID!,
72
+ },
73
+ accountLinking: {mode: "verified-email"},
74
+ oauthProxy: process.env.OAUTH_PROXY_PRODUCTION_URL
75
+ ? {productionURL: process.env.OAUTH_PROXY_PRODUCTION_URL}
76
+ : undefined,
77
+ nativeApps: true,
78
+ };
79
+ ```
80
+
81
+ - Add a provider's field only when its env vars are set; example-backend's
82
+ `src/utils/betterAuthConfig.ts` shows the pattern, including the preview placeholder secret.
83
+ - `https://appleid.apple.com` must be in `trustedOrigins` for Apple
84
+ ([why](#sign-in-with-apple)).
85
+ - `accountLinking.mode` decides whether a Google sign-in with the same verified email joins an
86
+ existing account. See [Account linking](../explanation/account-linking.md).
87
+ - `signUp: {mode: "closed"}` stops social sign-in from creating users; stable decides this for
88
+ previews too.
89
+
90
+ ## Callback URLs
91
+
92
+ Each provider gets one redirect URI per backend that exchanges codes:
93
+
94
+ ```text
95
+ https://<backend that owns the callbacks>/api/auth/callback/<provider>
96
+ ```
97
+
98
+ | Stage | Backend that owns the callbacks | Example |
99
+ | --- | --- | --- |
100
+ | Local dev (Google and GitHub only) | `http://localhost:4000` | `http://localhost:4000/api/auth/callback/github` |
101
+ | Preview first ([below](#register-on-a-preview-first)) | The preview's Cloud Run tag URL | `https://pr-1613---terreno-backend-example-7knxlrnpqq-uc.a.run.app/api/auth/callback/google` |
102
+ | Stable before the domain move | The Cloud Run URL | `https://terreno-backend-example-7knxlrnpqq-uc.a.run.app/api/auth/callback/google` |
103
+ | Stable after the move | `api.<domain>` | `https://api.<domain>/api/auth/callback/google` |
104
+
105
+ - During the move, register both stable URLs, and drop the Cloud Run one afterwards
106
+ ([below](#move-stable-onto-one-domain)).
107
+ - Previews never get their own callback. They borrow stable's through oauth-proxy, unless one
108
+ preview owns them for a while.
109
+ - Apple accepts only https return URLs on a real host, so it has no local dev callback. Test
110
+ Apple on a preview or on stable.
111
+ - Keep local dev on its own OAuth apps (a "dev" GitHub app and Google client), so a dev secret
112
+ never sits next to the stable ones.
113
+
114
+ ## Register on a preview first
115
+
116
+ Stable exchanges codes only once it runs a release with this setup. To try the providers before
117
+ then, let one preview own the callbacks:
118
+
119
+ 1. **Name the preview.** In CircleCI, Organization Settings → Contexts → `terreno-gcp`, add
120
+ `OAUTH_CALLBACK_TAG` = `pr-<n>`.
121
+ 2. **Register every provider** ([below](#register-each-provider)) with the preview's backend,
122
+ `https://pr-<n>---terreno-backend-example-7knxlrnpqq-uc.a.run.app`, as the callback host.
123
+ For Apple, list both preview hosts under Domains:
124
+ `pr-<n>---terreno-backend-example-7knxlrnpqq-uc.a.run.app` and
125
+ `pr-<n>--terreno-frontend.netlify.app`.
126
+ 3. **Add the secrets** ([below](#store-the-secrets)).
127
+ 4. **Redeploy the preview.** Approve the PR's `approve` hold in CircleCI (or rerun its
128
+ `gcp-cd-preview` job), so the new revision mounts the secrets.
129
+ 5. **Sign in** at `https://pr-<n>--terreno-frontend.netlify.app`.
130
+
131
+ While the variable is set:
132
+
133
+ - That preview mounts every OAuth secret, including client secrets and the Apple key, and
134
+ exchanges codes itself. Only name a preview whose code you have reviewed.
135
+ - Other previews proxy through it.
136
+ - Stable gets only the client IDs, so stable has no social sign-in.
137
+
138
+ When stable runs the release, register the stable callbacks, delete `OAUTH_CALLBACK_TAG`,
139
+ redeploy stable, and remove the preview callbacks from each provider.
140
+
141
+ ## Register each provider
142
+
143
+ ### GitHub
144
+
145
+ 1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App. For an organization app,
146
+ use the organization's Settings → Developer settings instead.
147
+ 2. Homepage URL: the web app. Authorization callback URL:
148
+ `https://<callback host>/api/auth/callback/github`.
149
+ 3. Generate a client secret. GitHub shows it once.
150
+
151
+ A GitHub OAuth app has one callback URL. To move it (preview to stable, or to `api.<domain>`),
152
+ edit it, or create a second app for the overlap.
153
+
154
+ ### Google
155
+
156
+ 1. Google Cloud console → APIs & Services → OAuth consent screen. Set the app name, support
157
+ email, and authorized domains. While the screen is in **Testing**, only the test users you
158
+ list can sign in; add yours.
159
+ 2. Credentials → Create credentials → OAuth client ID → Web application.
160
+ 3. Authorized redirect URI: `https://<callback host>/api/auth/callback/google`. A client can list
161
+ several, so the preview, Cloud Run, and `api.<domain>` URIs can sit side by side.
162
+ 4. Copy the client ID and secret.
163
+
164
+ ### Sign in with Apple
165
+
166
+ 1. Apple Developer → Certificates, Identifiers & Profiles → Identifiers → add a **Services ID**
167
+ (this is the `clientId`, such as `com.example.web`). Turn on Sign in with Apple and configure
168
+ it:
169
+ - Domains: the backend and web hosts (`api.<domain>` and `app.<domain>` after the move).
170
+ - Return URL: `https://<callback host>/api/auth/callback/apple`.
171
+ 2. Keys → add a key with Sign in with Apple, download the `.p8` file (once only), and note the
172
+ key ID. Your team ID is in the top right of the developer portal.
173
+
174
+ Apple needs `https://appleid.apple.com` in `trustedOrigins`. It returns with a cross-site
175
+ `POST` (`form_post`) from that origin, and a browser that holds a `SameSite=None` cookie for the
176
+ API sends it, which makes Better Auth check the POST's `Origin`.
177
+
178
+ If Apple refuses a preview's `run.app` host, try Google and GitHub on the preview and register
179
+ Apple on stable.
180
+
181
+ ## Store the secrets
182
+
183
+ The example keeps each value in Secret Manager, in containers Terraform creates
184
+ (`infra/flourish/main.tf`, `backend_secret_oauth`). The source values live in OpenBao under
185
+ `terreno`; `secret terreno <KEY>` (from the ops dotfiles) prints one. With the default project
186
+ set, copy each into its Secret Manager container:
187
+
188
+ **Before the first Terraform apply** (until the change that adds them merges), the containers do
189
+ not exist and `put` fails with `NOT_FOUND`. Create them, with read access for the backend's
190
+ runtime account, once:
191
+
192
+ ```bash
193
+ S=terreno-backend-example
194
+ for n in google-client-id google-client-secret github-client-id github-client-secret apple-client-id apple-team-id apple-key-id apple-private-key; do gcloud secrets create "$S-$n" --replication-policy=automatic --quiet && gcloud secrets add-iam-policy-binding "$S-$n" --member=serviceAccount:terreno-backend-runtime@<gcp-project-id>.iam.gserviceaccount.com --role=roles/secretmanager.secretAccessor --quiet >/dev/null && echo "created $S-$n"; done
195
+ ```
196
+
197
+ `infra/flourish/imports.tf` adopts these containers into Terraform on its next apply, so it does
198
+ not fail on "already exists". Then copy the values:
199
+
200
+ ```bash
201
+ S=terreno-backend-example
202
+ put() { local v; v="$(secret terreno "$1" </dev/null)" && [ -n "$v" ] && printf '%s' "$v" | gcloud secrets versions add "$S-$2" --data-file=- --quiet >/dev/null && echo "added $S-$2" || echo "skipped $1"; }
203
+ put GITHUB_OAUTH_CLIENT_ID github-client-id
204
+ put GITHUB_OAUTH_CLIENT_SECRET github-client-secret
205
+ put GOOGLE_OAUTH_CLIENT_ID google-client-id
206
+ put GOOGLE_OAUTH_CLIENT_SECRET google-client-secret
207
+ put APPLE_OAUTH_CLIENT_ID apple-client-id
208
+ put APPLE_OAUTH_TEAM_ID apple-team-id
209
+ put APPLE_OAUTH_KEY_ID apple-key-id
210
+ put APPLE_OAUTH_SECERT apple-private-key
211
+ ```
212
+
213
+ Every line stands alone, so the block pastes into bash or zsh without a continuation prompt.
214
+ `</dev/null` and `--quiet` stop `secret` and `gcloud` from reading the rest of the paste as
215
+ input; a command that needs input fails and prints `skipped` instead. If every line skips, run
216
+ `secret terreno GITHUB_OAUTH_CLIENT_ID >/dev/null` alone first to sign in to OpenBao.
217
+
218
+ - `APPLE_OAUTH_SECERT` (spelled as stored) holds the `.p8` key's contents, `-----BEGIN PRIVATE
219
+ KEY-----` included. `APPLE_OAUTH_CLIENT_ID` is the Services ID, such as `com.example.web`.
220
+ - `put` never prints a value, and skips a key that is missing or empty instead of storing an
221
+ empty version, which would mount a blank secret.
222
+ - `printf '%s'` keeps a trailing newline out of the value. Check one with
223
+ `gcloud secrets versions access latest --secret=$S-github-client-id`.
224
+ - Without OpenBao, replace `secret terreno "$key"` with your own store's read command, or pipe
225
+ each value in by hand (`--data-file=AuthKey_<key id>.p8` for the Apple key).
226
+
227
+ The deploy mounts only the secrets that have a version, so a provider turns on when both of its
228
+ values exist (all four for Apple). **Redeploy after adding a version.** The example reads them as
229
+ `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`,
230
+ `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, and `APPLE_PRIVATE_KEY`.
231
+
232
+ Previews get only the `*_CLIENT_ID` secrets: starting a sign-in needs the client ID, and the
233
+ callback owner exchanges every code. No client secret or `.p8` reaches a `pr-*` revision other
234
+ than the one `OAUTH_CALLBACK_TAG` names. Better Auth refuses to configure Google or GitHub
235
+ without some secret, so on a preview (`OAUTH_PROXY_PRODUCTION_URL` on another origin than
236
+ `BETTER_AUTH_URL`) the example fills in the placeholder `OAUTH_PROXY_PREVIEW_CLIENT_SECRET`
237
+ (`"unused-on-oauth-proxy-preview"`). It is never sent to a provider.
238
+
239
+ For local dev, put the dev app's values in `example-backend/.env` under the same names.
240
+
241
+ ## Check it
242
+
243
+ 1. **The server lists the providers:**
244
+
245
+ ```bash
246
+ curl -s https://<backend>/api/auth/terreno/methods | jq .
247
+ ```
248
+
249
+ Each configured provider appears. A missing one means its secrets have no version, or the
250
+ revision predates them: redeploy.
251
+ 2. **Sign in** on the web app with each provider. The button sits on the login screen; the
252
+ Security screen's Linked accounts card links more providers to the same account.
253
+ 3. **On a redirect mismatch,** compare the `redirect_uri` in the provider's error with the URI
254
+ you registered, character for character (scheme, host, no trailing slash).
255
+
256
+ ## Rotate Apple's key and client secret
257
+
258
+ Apple's client secret is a JWT signed with your key, valid for at most six months.
259
+
260
+ - **Key form (the example's).** Terreno signs a fresh 180-day secret every time the server
261
+ starts, so the JWT rotates on every deploy and instance start. Nothing expires as long as some
262
+ deploy or restart happens within 180 days.
263
+ - **Rotating the key itself.** Create a second key in the developer portal, add its `.p8` and
264
+ key ID as new versions of `apple-private-key` and `apple-key-id`, and redeploy. Revoke the old
265
+ key once the new revision serves traffic. Users stay signed in: sessions do not depend on the
266
+ key.
267
+ - **Client-secret form** (`APPLE_CLIENT_SECRET`, a JWT you signed yourself). Re-sign it before
268
+ it expires, add it as a new version, and redeploy. The example uses this form only when the
269
+ key form is incomplete.
270
+
271
+ ## How previews sign in
272
+
273
+ Every preview reaches the providers through the stable deploy's registered callback, using
274
+ Better Auth's oauth-proxy plugin:
275
+
276
+ 1. The preview starts the sign-in. oauth-proxy points the provider's `redirect_uri` at stable,
277
+ and packs the preview's OAuth state into the `state` parameter, encrypted with
278
+ `BETTER_AUTH_SECRET`.
279
+ 2. The provider returns to stable's `/api/auth/callback/<provider>`. Stable decrypts the state,
280
+ exchanges the code, and redirects to the preview's `/api/auth/callback/<provider>/oauth-proxy`
281
+ with the user's profile, encrypted, in the URL. Stable writes nothing to its database.
282
+ 3. The preview checks the state against the row it stored in its own database, creates the user
283
+ and session there, and redirects to the web app. A bearer-mode web app gets `#ott=<token>`
284
+ on that redirect, as on any other social sign-in
285
+ ([OAuth handoff](configure-better-auth.md#social-sign-in-in-bearer-mode)).
286
+
287
+ What this needs:
288
+
289
+ - **One shared secret.** Previews and stable use the same `BETTER_AUTH_SECRET`; previews keep
290
+ their own databases. A profile encrypted under a different secret is refused.
291
+ - **The plugin on both.** Previews set `oauthProxy.productionURL` to stable. Stable sets it to
292
+ its own URL, because its callback hook is what forwards a preview's return. Terreno then sets
293
+ oauth-proxy's `currentURL` on stable, so stable's own sign-ins skip the proxy and keep Better
294
+ Auth's state-cookie check, whichever host name (such as a Cloud Run tag URL) the client used.
295
+ In the example, `scripts/ci/backend-auth-env.sh` sets `OAUTH_PROXY_PRODUCTION_URL` on both.
296
+ - **Production's sign-up mode governs.** Stable decides whether the provider's user may be
297
+ created, so give previews the same `signUp.mode`.
298
+ - **No browser binding on the preview hop.** The preview's completion endpoint skips the state
299
+ cookie check (the cookie lives on the preview's host, and the browser arrives from stable). The
300
+ encrypted profile in the forwarded URL is the only proof: whoever holds that URL can finish the
301
+ sign-in, as the user, within 60 seconds, once (the preview consumes its state row). Treat it
302
+ like a password-reset link: previews are for testing, so sign in there with test accounts.
303
+
304
+ **Apple's `form_post`.** Apple returns with a cross-site `POST` to the callback rather than a
305
+ redirect. Stable's oauth-proxy reads `code` and `state` from the form body and forwards the
306
+ browser to the preview with a normal redirect, so no `SameSite=Lax` cookie is needed on stable.
307
+ The api test `betterAuthOAuthProxy.test.ts` proves this with the mock provider; the live check
308
+ against Apple is T28 in the auth-experience plan.
309
+
310
+ ## Native
311
+
312
+ The Expo app signs in with the same providers through the system browser, using Better Auth's
313
+ Expo plugin. The provider still returns to the backend's own
314
+ `/api/auth/callback/<provider>`, so a native app needs no extra redirect URI at the provider.
315
+
316
+ 1. **Turn on `nativeApps: true`** in the server config. Terreno adds `expo()` from
317
+ `@better-auth/expo`, which serves `GET {basePath}/expo-authorization-proxy`. The app opens
318
+ that URL in the browser; it sets the OAuth state cookie there and redirects to the provider.
319
+ 2. **Give the app a scheme.** Set `expo.scheme` in `app.json` (`frontend` in the example) and
320
+ pass the same value to `createBetterAuthClient({scheme})` from `@terreno/rtk`.
321
+ 3. **List the scheme in `trustedOrigins`**, as `frontend://`. After the callback, the backend
322
+ redirects the browser to the app's `callbackURL` (`frontend://.../login`), with the session in
323
+ a `cookie` query parameter. Better Auth refuses a callback URL that matches no trusted origin.
324
+ An entry with only the scheme matches every URL on that scheme, so `frontend://` also covers a
325
+ development build's `frontend://localhost:8081/login`. The example lists `frontend://`,
326
+ `terreno://` and `exp://`.
327
+ 4. **Pass a path as `callbackURL`** (`/login`). The Expo client turns it into the deep link, opens
328
+ the browser, waits for that link, and stores the session from it in `expo-secure-store`.
329
+
330
+ `expo-web-browser` must be in the native build. The example gets it through `@terreno/ui`;
331
+ check with `bunx expo-modules-autolinking resolve -p android`.
332
+
333
+ Two limits:
334
+
335
+ - **No oauth-proxy.** The Expo client sends `x-skip-oauth-proxy`, so a native sign-in uses the
336
+ backend it talks to as the provider's callback. Point native builds at the stable deploy, whose
337
+ callback is the registered one, not at a preview.
338
+ - **https providers only.** The authorization proxy refuses an `http:` authorization URL. Real
339
+ providers are all https. The example's mock OIDC provider is plain http, so example-backend
340
+ serves the proxy for that one origin itself (`mock-oidc-native-proxy` in
341
+ `src/utils/betterAuthConfig.ts`). It exists only while `MOCK_OIDC_ISSUER` is set, which
342
+ production refuses.
343
+
344
+ ### Prove it on an Android emulator
345
+
346
+ `example-frontend/maestro/native-social-login.yaml` signs in with the mock provider and lands on
347
+ the todos screen. `localhost` has to mean the same machine to the backend and to the device's
348
+ browser, so every server is reached through `adb reverse`:
349
+
350
+ ```bash
351
+ # Mongo as a replica set, then the mock provider and the backend
352
+ MOCK_OIDC_SERVE=true MOCK_OIDC_PORT=4655 MOCK_OIDC_ISSUER=http://localhost:4655 \
353
+ MOCK_OIDC_REDIRECT_URI=http://localhost:4000/api/auth/callback/mock-oidc \
354
+ bun example-frontend/e2e/helpers/mockOidcProvider.ts
355
+ AUTH_PROVIDER=better-auth BETTER_AUTH_URL=http://localhost:4000 \
356
+ MOCK_OIDC_ISSUER=http://localhost:4655 MONGO_URI=... PORT=4000 bun run backend:dev
357
+
358
+ # The development build and Metro
359
+ cd example-frontend && bunx expo run:android # or install an EAS development build
360
+ EXPO_PUBLIC_API_URL=http://localhost:4000 bunx expo start --dev-client --port 8081
361
+
362
+ adb -s 127.0.0.1:5555 reverse tcp:8081 tcp:8081
363
+ adb -s 127.0.0.1:5555 reverse tcp:4000 tcp:4000
364
+ adb -s 127.0.0.1:5555 reverse tcp:4655 tcp:4655
365
+ maestro --device 127.0.0.1:5555 test example-frontend/maestro/native-social-login.yaml
366
+ ```
367
+
368
+ To run beside a Playwright run that holds ports 4000 and 4555, start the backend on another host
369
+ port and reverse to it (`adb reverse tcp:4000 tcp:4100`); the app and `BETTER_AUTH_URL` keep
370
+ `localhost:4000`. `MOCK_OIDC_PORT`, `MOCK_OIDC_ISSUER` and `MOCK_OIDC_REDIRECT_URI` move the mock
371
+ provider; unset, it keeps the Playwright defaults (`127.0.0.1:4555`, callback on
372
+ `127.0.0.1:4000`).
373
+
374
+ ## Passkeys on previews and stable
375
+
376
+ A passkey is bound to an rpID, a domain the browser checks against the page's host.
377
+
378
+ - **Previews** use their own host (`pr-<n>--terreno-frontend.netlify.app`). `netlify.app` is a
379
+ public suffix, so it can't be the rpID, and a passkey made on one preview works only on that
380
+ preview.
381
+ - **Stable before the move** uses `terreno-frontend.netlify.app`, for the same reason.
382
+ - **Stable after the move** uses the parent domain: `backend-auth-env.sh` sets
383
+ `PASSKEY_RP_ID=<domain>` when `STABLE_FRONTEND_URL` is `app.<domain>` and `STABLE_BACKEND_URL`
384
+ is on `<domain>` too. It never derives a public suffix (`netlify.app`, `run.app`) or a country
385
+ second level (`co.uk`, `com.au`); set `STABLE_PASSKEY_RP_ID` to choose the rpID yourself. A
386
+ passkey made on `app.<domain>` then works on the pages that list it as an origin: the
387
+ `FRONTEND_URL` origin and each `CORS_ORIGINS` origin on `<domain>`, not on every subdomain.
388
+
389
+ Session cookies stay host-only on `api.<domain>`. Same-site subdomains send them on fetches
390
+ without Better Auth's `crossSubDomainCookies`, which would expose the session to every other
391
+ subdomain.
392
+
393
+ ## Move stable onto one domain
394
+
395
+ Moving the stable web app to `app.<domain>` and the backend to `api.<domain>` makes them
396
+ same-site, so Safari's third-party cookie blocking no longer applies on stable. Previews stay on
397
+ `netlify.app` and `run.app`, and keep using bearer mode.
398
+
399
+ 1. **Netlify custom domain.** In the `terreno-frontend` site, add `app.<domain>` under Domain
400
+ management and point a `CNAME` at the Netlify site. Wait for the certificate.
401
+ 2. **Cloud Run domain mapping.** Map `api.<domain>` to `terreno-backend-example`:
402
+
403
+ ```bash
404
+ gcloud beta run domain-mappings create --service=terreno-backend-example \
405
+ --domain=api.<domain> --region=us-central1
406
+ ```
407
+
408
+ Add the DNS records it prints, and wait until `gcloud beta run domain-mappings describe`
409
+ reports the certificate ready.
410
+ 3. **Re-register the callbacks.** Add `https://api.<domain>/api/auth/callback/<provider>` to
411
+ GitHub, Google, and Apple (and both new hosts under Apple's Domains). Keep the Cloud Run
412
+ callback until the next step has deployed.
413
+ 4. **Set two CI variables** in the CircleCI project: `STABLE_BACKEND_URL=https://api.<domain>`
414
+ and `STABLE_FRONTEND_URL=https://app.<domain>`, then redeploy stable and the web app. The
415
+ deploy sets `BETTER_AUTH_URL`, `FRONTEND_URL`, `OAUTH_PROXY_PRODUCTION_URL`, and
416
+ `PASSKEY_RP_ID` from them, previews proxy to `api.<domain>`, and the web build calls
417
+ `api.<domain>` (by default it calls the untagged Cloud Run URL, the same one the deploy names). The old Netlify host stays in `CORS_ORIGINS` (and so in `trustedOrigins`)
418
+ during the move.
419
+ 5. **Drop the old callbacks** from each provider once sign-in works on the new hosts.
420
+
421
+ **Passkeys made before the move stop working.** They are bound to
422
+ `terreno-frontend.netlify.app`, and the new rpID is `<domain>`, which the browser refuses to
423
+ match. Users sign in another way (password or a provider) and add a new passkey from the security
424
+ screen. The example has no real users; a consumer app should tell its users before moving.