@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
@@ -0,0 +1,952 @@
1
+ # Auth experience: social login, account linking, passkeys, 2FA, and admin recovery
2
+
3
+ Terreno already runs Better Auth with email/password, and `BetterAuthConfig` has slots
4
+ for Google, GitHub, and Apple. On every deployment, though, social login is off, the
5
+ native OAuth return is broken, and the only plugin enabled is `bearer()`. No app has
6
+ passkeys, 2FA, linked-account management, or any way for an admin to help a locked-out
7
+ user. This plan turns auth into a set of features an app switches on in config.
8
+ Consumers get a login screen that knows how you signed in last and who is signed in on
9
+ this device. They also get a security screen for providers, passkeys, 2FA, sessions,
10
+ and password, plus recovery tools for org-admins and platform staff. The example app
11
+ turns on all of it, and it works on per-PR previews and on the stable deployment.
12
+
13
+ ## The idea
14
+
15
+ - **One config contract.** `BetterAuthConfig` gains first-class `accountLinking`,
16
+ `twoFactor`, `passkey`, `lastLoginMethod`, `multiSession`, `oauthProxy`, and
17
+ `nativeApps` fields. Terreno maps them to Better Auth plugins, so consumers never
18
+ assemble plugins by hand.
19
+ - **Discovery.** A public endpoint, `GET {basePath}/terreno/methods`, reports what is
20
+ enabled. The login screen renders from it and never shows a provider that isn't
21
+ configured.
22
+ - **Three layers on the frontend:**
23
+ - Presentational screens and cards in `@terreno/ui`.
24
+ - Connected screens and hooks in `@terreno/rtk`, which already depends on ui.
25
+ - Recovery panels in `@terreno/admin-frontend`.
26
+ - **Recovery is Terreno-owned.** An `AccountSecurityApp` plugin adds per-user routes.
27
+ Platform staff reach them at `/users/:id/security/*`. An org-admin reaches the same
28
+ handlers at `/orgs/:orgId/members/:userId/security/*` for any active member of
29
+ their org (D2). Every action writes an `AuditEvent` through the existing `AuditApp`, with
30
+ `source: "security"`, and emails the affected user.
31
+ - **Builds on #1604.** Master already has web bearer mode (`createBetterAuthClient({webAuth:
32
+ "bearer"})`, `bearerStorage`), the OAuth one-time-token handoff (`oauthHandoff`,
33
+ `signInSocialWithHandoff`), the mock OIDC provider (`MOCK_OIDC_ISSUER`), and the
34
+ cross-site bearer Playwright project. This plan reuses them instead of building its own
35
+ (D7).
36
+ - **Previews.** Previews sign in through Better Auth's `oauth-proxy`. The stable
37
+ deployment, called "staging" here (D1), owns the registered callback URLs and
38
+ forwards each OAuth return to the preview that started it.
39
+ - **Rejected alternative:** Better Auth's `admin` and `organization` plugins. Terreno
40
+ already owns orgs, RBAC, and `User.disabled`, and a second authority would drift.
41
+
42
+ ## The plan
43
+
44
+ | # | Task | Depends on | Lands in | Proves it |
45
+ | --- | --- | --- | --- | --- |
46
+ | T1 | Methods discovery drives the example login buttons (tracer) | none | api, rtk, example-frontend | api test + Playwright |
47
+ | T2 | Auth feature config contract and plugin wiring | T1 | api | api tests |
48
+ | T3 | Account linking modes and verified-email sync | T2 | api | api tests (stubbed provider) |
49
+ | T4 | Example backend enables the full suite; the mock OIDC provider joins e2e | T2 | example-backend, e2e | api test + Playwright |
50
+ | T5 | Linking a provider works for bearer-mode web clients | T4 | api, rtk | api tests |
51
+ | T27 | The 2FA, trust-device and passkey challenges work without cookies | T5, T18 | api, rtk, example-frontend | api test + Playwright cross-site bearer |
52
+ | T6 | Security summary, 2FA reset, security audit events, platform routes | T2 | api | api tests |
53
+ | T7 | Recovery: remove passkey, unlink provider | T6 | api | api tests |
54
+ | T8 | Recovery: revoke sessions, send reset, disable/enable | T7 | api, example-backend | api tests |
55
+ | T9 | Org-admin scoped recovery routes | T6 | api | api tests (auth matrix) |
56
+ | T10 | Security notification emails | T8, T27 | api, comms | api + comms tests |
57
+ | T11 | Previews sign in through oauth-proxy on the stable deploy | T5 | example-backend, CI, infra, docs | config test, terraform validate |
58
+ | T28 | Live social sign-in on a preview and on the stable deploy (needs H1) | T11 | e2e | live curl + opt-in Playwright |
59
+ | T12 | LoginScreen v2 (presentational) | none | ui, demo | ui tests + snapshots |
60
+ | T13 | OtpField and TwoFactorChallengeScreen | none | ui, demo | ui tests |
61
+ | T14 | LinkedAccountsCard and PasswordCard | none | ui, demo | ui tests |
62
+ | T15 | PasskeysCard and SessionsCard | none | ui, demo | ui tests |
63
+ | T16 | TwoFactorSettingsCard (enroll, backup codes) | T2, T13 | ui, demo | ui tests |
64
+ | T17 | AccountSecurityScreen, exports, demo registration | T12, T14, T15, T16 | ui, demo, docs | ui tests + demo coverage |
65
+ | T18 | rtk client feature plugins | T2 | rtk | rtk tests |
66
+ | T19 | Remembered accounts on device | none | rtk | rtk tests |
67
+ | T20 | Connected TerrenoLoginScreen and login controller | T3, T11, T17, T19, T27 | rtk | rtk tests |
68
+ | T21 | Connected TerrenoAccountSecurityScreen | T20 | rtk | rtk tests |
69
+ | T22 | Example login, 2FA challenge, passkey sign-in | T4, T20 | example-frontend | Playwright |
70
+ | T29 | Android example signs in with the mock provider | T22 | example-frontend (Maestro) | Maestro on emulator |
71
+ | T23 | Example security screen | T21, T22 | example-frontend | Playwright |
72
+ | T24 | Org-admin member security panel | T4, T9 | admin-frontend, example-frontend | admin tests + Playwright |
73
+ | T25 | Platform user security screen | T8, T24 | admin-frontend, example-frontend | admin tests + Playwright |
74
+ | T26 | create-terreno-app generates the auth suite | T4, T21 | create-terreno-app | generator tests |
75
+
76
+ Tracer: config, then `/terreno/methods`, then `useAuthMethods`, then the example login
77
+ buttons.
78
+
79
+ Out of scope: dedicated staging environment, iOS native proof, native Apple button,
80
+ native passkeys, org security policies, magic link / email OTP / One Tap, enterprise
81
+ SSO, and self-service email change or account deletion. The last two are deliberate
82
+ cuts from "all of it" (X6). See Expansions.
83
+
84
+ Open risks:
85
+ - **Apple through oauth-proxy.** Apple returns with `form_post`; whether the proxy
86
+ handles that is unproven.
87
+ - **Safari.** Safari blocks third-party cookies. After H3 the stable sites are
88
+ same-site (D6), but previews stay on `netlify.app` and `run.app`. #1604's bearer mode
89
+ removes the dependence on cookies for sessions; T27 does the same for the 2FA and
90
+ passkey challenges.
91
+ - **Revocation delay.** The 5-minute session cookie cache delays revocation for
92
+ cookie (not bearer) clients.
93
+ - **Credentials.** Real provider credentials need a human (H1).
94
+
95
+ ## Human prerequisites
96
+
97
+ - **H1.** Create the OAuth apps and add their secrets with `gcloud secrets versions add`
98
+ to the containers T11 creates:
99
+ - a GitHub OAuth app;
100
+ - a Google OAuth client;
101
+ - an Apple Services ID with a Sign in with Apple key (team ID, key ID, `.p8`).
102
+
103
+ Register exactly one callback per provider, on the stable backend's final host:
104
+ `https://api.<domain>/api/auth/callback/{provider}` once H3 is done (D6). Before H3,
105
+ register `https://terreno-backend-example-7knxlrnpqq-uc.a.run.app/api/auth/callback/{provider}`
106
+ too, and drop it after the move. Apple's Services ID also lists `api.<domain>` and
107
+ `app.<domain>` under Domains.
108
+ Also create a GitHub test account without 2FA for the opt-in live Playwright run.
109
+ Only T28 waits on this; every other task proves itself with the mock
110
+ provider from T4.
111
+ - **H3.** Move the stable sites onto one domain (D6): the Netlify custom domain
112
+ `app.<domain>` for `terreno-frontend`, and a Cloud Run domain mapping `api.<domain>`
113
+ for `terreno-backend-example`. Then set `STABLE_FRONTEND_URL` and `STABLE_BACKEND_URL`
114
+ in CI (T11). Nothing waits on this: until then every URL defaults to today's hosts
115
+ and T27 covers the cross-site gap.
116
+ - **H2.** After T28 passes, sign in once with Google and once with Apple on a preview
117
+ and on the stable deploy. These providers block automated sign-in.
118
+
119
+ ## Tasks
120
+
121
+ - [x] **T1** — Methods discovery drives the example login buttons
122
+ - Depends on: none
123
+ - Files: api/src/betterAuthMethods.ts, api/src/betterAuthMethods.test.ts, api/src/betterAuthSetup.ts, rtk/src/useAuthMethods.ts, rtk/src/useAuthMethods.test.ts, rtk/src/index.ts, example-frontend/app/login.tsx, example-frontend/e2e/login.spec.ts, docs/reference/api.md, changelog/unreleased/auth-methods-discovery.md
124
+ - Build:
125
+ - Add a Better Auth plugin that serves a public `GET {basePath}/terreno/methods`
126
+ returning `{emailPassword, signUp, providers: string[], passkey, twoFactor, accountLinking, multiSession}`.
127
+ `providers` lists social providers and `genericOAuth` provider ids (so the
128
+ example's `mock-oidc` appears when `MOCK_OIDC_ISSUER` is set).
129
+ Flags that T2 has not built yet report `false`.
130
+ - Add `useAuthMethods(authClient)` in rtk.
131
+ - The example login replaces its hard-coded `["google","github","apple"]`
132
+ (`example-frontend/app/login.tsx:62`) with the result.
133
+ - Proof:
134
+ - `api/src/betterAuthMethods.test.ts` "lists only configured providers": with only
135
+ `githubOAuth` set, `providers` equals `["github"]` and the endpoint answers
136
+ without a session.
137
+ - `e2e/login.spec.ts` "user sees only configured providers": with no provider env,
138
+ `login-screen-oauth` has no buttons.
139
+ - Docs: `docs/reference/api.md` Better Auth section (endpoint + response).
140
+ - Skills: terreno-backend-api, terreno-data-fetching, verify-ui-changes
141
+
142
+ - [x] **T2** — Auth feature config contract and plugin wiring
143
+ - Depends on: T1
144
+ - Files: api/src/betterAuth.ts, api/src/betterAuthSetup.ts, api/src/betterAuthFeatures.ts, api/src/betterAuthFeatures.test.ts, api/src/betterAuthMethods.ts, api/src/rateLimit/policies.ts, api/src/rateLimit/policies.test.ts, api/package.json, rtk/package.json, ui/package.json, example-backend/package.json, example-frontend/package.json, package.json, bun.lock, docs/how-to/configure-better-auth.md, changelog/unreleased/auth-feature-config.md
145
+ - Build:
146
+ - Add these optional fields to `BetterAuthConfig`:
147
+ - `accountLinking: {mode: "verified-email" | "explicit" | "never", trustedProviders?}`
148
+ - `verified-email`: a sign-in whose verified email matches joins that account.
149
+ - `explicit`: the user links a method from the Security screen.
150
+ - `never`: no linking. Each sign-in method stays its own account, and a same-email
151
+ sign-in with a new method is refused.
152
+ - Better Auth's `requireLocalEmailVerified` stays `true` in every mode.
153
+ - `twoFactor: {issuer, backupCodes?, trustDeviceDays?}`
154
+ - `passkey: {rpID, rpName, origin: string[]}`
155
+ - `signUp: {mode: "open" | "closed"}` (default `open`). `closed` maps to Better
156
+ Auth `emailAndPassword.disableSignUp` plus `disableImplicitSignUp` on every
157
+ social provider, so only existing users can sign in (D4).
158
+ - `lastLoginMethod: boolean` (`storeInDatabase: true`)
159
+ - `multiSession: {maximumSessions}`
160
+ - `oauthProxy: {productionURL}`
161
+ - `nativeApps: boolean` (registers the `@better-auth/expo` server plugin)
162
+ - `appleOAuth` additionally accepts `{teamId, keyId, privateKey, appBundleIdentifier}`
163
+ and signs Apple's client-secret JWT at startup.
164
+ - `buildFeaturePlugins(config)` in `betterAuthFeatures.ts` maps each field to its
165
+ plugin:
166
+ - `twoFactor`, `passkey` (add `@better-auth/passkey` to the catalog)
167
+ - `lastLoginMethod`, `multiSession`, `oAuthProxy`, `expo`
168
+ - `account.accountLinking`
169
+ - The plugins are appended before `config.plugins`, which keeps the escape hatch
170
+ (#1592).
171
+ - The methods endpoint reports the real flags.
172
+ - Add every new dependency for the plan here, so one task owns the lockfile:
173
+ - `@better-auth/passkey` (catalog) in api and rtk;
174
+ - `react-native-qrcode-svg` in ui;
175
+ - `otpauth` in example-frontend dev dependencies (the mock OIDC provider from
176
+ #1604 replaces `oauth2-mock-server`).
177
+ - Add `/two-factor`, `/passkey`, `/link-social`, `/unlink-account`, `/multi-session`
178
+ to the `auth` rate-limit bucket (`/one-time-token` and `/oauth-handoff` are
179
+ already there from #1604; keep them).
180
+ - Proof:
181
+ - `betterAuthFeatures.test.ts` "each feature mounts only when configured": for each
182
+ feature, its endpoint is non-404 when enabled and 404 when absent. The endpoints
183
+ are:
184
+ - `/two-factor/enable`
185
+ - `/passkey/generate-register-options`
186
+ - `/multi-session/list-device-sessions`
187
+ - `/expo-authorization-proxy`
188
+ - `policies.test.ts`: `/api/auth/two-factor/verify-totp` resolves to the `auth`
189
+ bucket.
190
+ - `signUp: closed`:
191
+ - email sign-up is refused;
192
+ - a social sign-in for an unknown email returns `signup_disabled` and creates no
193
+ user;
194
+ - an existing user still signs in;
195
+ - `/terreno/methods` reports `signUp: false`.
196
+ - Existing `betterAuthSetup.test.ts` passes unchanged, which shows the change is
197
+ backward compatible.
198
+ - Docs: `configure-better-auth.md` gets a new "Auth features" table, with each field,
199
+ the plugin it maps to, and a default.
200
+ - Skills: terreno-backend-api, mongoose-schema-safety (Better Auth adds the
201
+ `twoFactor`/`passkey` collections and user fields), backend-test-env, security-audit
202
+
203
+ - [x] **T3** — Account linking modes and verified-email sync
204
+ - Depends on: T2
205
+ - Files: api/src/betterAuthSetup.ts, api/src/betterAuthLinking.test.ts, docs/explanation/account-linking.md, docs/how-to/configure-better-auth.md, changelog/unreleased/account-linking.md
206
+ - Changelog: marked breaking. Unverified sign-ups no longer claim an existing app
207
+ User by email. The fragment includes the migration note.
208
+ - Build:
209
+ - `syncBetterAuthUser` (`api/src/betterAuthSetup.ts:488-498`) stops linking to an
210
+ existing app `User` by email unless the Better Auth user's `emailVerified` is
211
+ true.
212
+ - The provider id is passed through so `oauthProvider` gets filled in.
213
+ - Build (legacy migration): an unverified Better Auth user links to an existing
214
+ legacy app `User` with the same email only when the Better Auth password verifies
215
+ against that User's passport hash. Otherwise the sync returns 409 "Verify your
216
+ email to use this account". It never inserts a duplicate `User` (no 11000 error).
217
+ This closes today's takeover path, where anyone signing up unverified with a
218
+ victim's email is linked to the victim's User.
219
+ - Proof (`betterAuthLinking.test.ts`, Google stubbed through
220
+ `verifyIdToken`/`getUserInfo` and an idToken sign-in):
221
+ - `verified-email`: an email/password user whose email is verified signs in with a
222
+ verified Google identity on the same email. The result is one Better Auth user,
223
+ two `account` rows and one app `User`.
224
+ - `verified-email`, with the local email unverified: the sign-in returns
225
+ `account_not_linked`, and nothing is created.
226
+ - `never`: `linkSocial` returns 403, and a same-email social sign-in is refused.
227
+ - Legacy migration:
228
+ - a legacy User plus a Better Auth sign-up with the same password gives one
229
+ User;
230
+ - a different password gives 409 and still one User;
231
+ - a legacy User with no hash (a GitHub-JWT user) and no `sendMail` gives 409.
232
+ Nothing is left behind (no Better Auth user, account row, or verification
233
+ mail). The T8 `issue-sign-in-link` admin action then lets them in (D8).
234
+ - `explicit`: the same sign-in returns error code `account_not_linked` and creates
235
+ no user. `linkSocial` from the signed-in session then links it.
236
+ - An unverified provider email never links in either mode.
237
+ - Unlinking the only remaining sign-in method is refused.
238
+ - Docs: new `docs/explanation/account-linking.md`. Covers the three modes, why both
239
+ emails must be verified, Apple private-relay emails, `multiSession`, and a
240
+ migration note for JWT apps. `configure-better-auth.md`'s "Migration from JWT
241
+ Auth" section links to it.
242
+ - Skills: terreno-backend-api, security-audit
243
+
244
+ - [x] **T4** — Example backend enables the full suite; the mock OIDC provider joins e2e
245
+ - Depends on: T2
246
+ - Files: example-backend/src/utils/betterAuthConfig.ts, example-backend/src/utils/betterAuthConfig.test.ts, example-backend/.env.example, example-frontend/playwright.config.ts, example-frontend/e2e/helpers/mockOidcProvider.ts, example-frontend/e2e/authMethods.spec.ts, changelog/unreleased/example-auth-suite.md
247
+ - Build:
248
+ - The example turns on every T2 feature:
249
+ - `accountLinking.mode` comes from `AUTH_ACCOUNT_LINKING` (default
250
+ `verified-email`).
251
+ - `signUp.mode` comes from `AUTH_SIGN_UP` (default `open`).
252
+ - Email verification stays optional at sign-up, as today. Linking needs a
253
+ verified email, and the T20 notice covers that.
254
+ - The seeded e2e users are created verified.
255
+ - `twoFactor.issuer` is "Terreno Example".
256
+ - `passkey.origin` comes from `FRONTEND_URL`. `passkey.rpID` comes from
257
+ `PASSKEY_RP_ID` when set, otherwise the `FRONTEND_URL` host. On stable after H3
258
+ it is `<domain>`, so a passkey works on every subdomain (D6).
259
+ - `lastLoginMethod`, `multiSession` and `nativeApps` are on.
260
+ - The `mock-oidc` provider from #1604 (`MOCK_OIDC_ISSUER`) stays the e2e provider.
261
+ The auth-experience specs (T22, T23) join the cross-site bearer project's spec
262
+ list, which already starts the mock provider and runs the backend with the
263
+ handoff on (D7).
264
+ - The mock provider can name any email and whether it is verified per sign-in
265
+ (a login-hint or query option on its authorize page), so T3 and T22 can drive the
266
+ verified and unverified linking cases.
267
+ - Proof:
268
+ - `betterAuthConfig.test.ts`: the default env enables all features,
269
+ `AUTH_ACCOUNT_LINKING=explicit` switches the mode, and `PASSKEY_RP_ID` overrides
270
+ the rpID.
271
+ - `e2e/authMethods.spec.ts` (cross-site bearer project): `GET
272
+ /api/auth/terreno/methods` lists `mock-oidc` and reports every feature on.
273
+ - Skills: terreno-backend-api, backend-test-env
274
+
275
+ - [x] **T5** — Linking a provider works for bearer-mode web clients
276
+ - Depends on: T4
277
+ - Files: api/src/betterAuthOAuthHandoff.ts, api/src/betterAuthOAuthHandoff.test.ts, api/src/betterAuth.ts, rtk/src/betterAuthClient.ts, rtk/src/betterAuthClient.test.ts, rtk/src/index.ts, docs/reference/api.md, docs/how-to/configure-better-auth.md, changelog/unreleased/oauth-handoff-link.md
278
+ - Why: #1604's handoff covers sign-in. Linking a provider from the Security screen
279
+ needs the OAuth state cookie on the API's origin too, and a bearer client's
280
+ `POST /link-social` fetch can't set it cross-site.
281
+ - Build:
282
+ - `POST {basePath}/oauth-handoff/link-intent` (bearer-authenticated,
283
+ `{provider, callbackURL}`) mints a single-use, three-minute link token for the
284
+ caller's session. `callbackURL` follows the handoff's `errorCallbackURL` rule
285
+ (any page on a listed web origin), since no token travels on a link return.
286
+ - `POST {basePath}/oauth-handoff/link-start` takes the token from a form body in a
287
+ top-level navigation (so it never lands in a URL or access log), consumes it,
288
+ runs `linkSocial` for that session, forwards the state cookie, and redirects to
289
+ the provider.
290
+ - The provider callback links the account and redirects to `callbackURL` with no
291
+ one-time token (no session is created).
292
+ - rtk exports `linkSocialWithHandoff({client, provider, callbackURL})`, which on web
293
+ bearer mode fetches the intent and submits the form. Cookie mode and native call
294
+ `linkSocial` as before.
295
+ - Proof (`betterAuthOAuthHandoff.test.ts`, mock genericOAuth provider):
296
+ - a signed-in bearer user links the provider and ends with two `account` rows and no
297
+ new session;
298
+ - a reused or expired token answers 400 with no redirect;
299
+ - a `callbackURL` outside the web origins answers 400;
300
+ - with `accountLinking.mode: "never"` the intent answers 403.
301
+ - rtk test: bearer mode posts the intent and submits the form; cookie mode calls
302
+ `linkSocial`.
303
+ - Docs: `api.md` Better Auth section and the bearer-mode section of
304
+ `configure-better-auth.md`.
305
+ - Skills: terreno-backend-api, terreno-data-fetching, security-audit
306
+
307
+ - [x] **T27** — The 2FA, trust-device and passkey challenges work without cookies
308
+ - Depends on: T5, T18
309
+ - Files: api/src/betterAuthChallengeHeaders.ts, api/src/betterAuthChallengeHeaders.test.ts, api/src/betterAuthFeatures.ts, rtk/src/betterAuthClient.ts, rtk/src/betterAuthClient.test.ts, example-frontend/playwright.config.ts, example-frontend/e2e/crossSiteChallenges.spec.ts, docs/explanation/authentication.md, changelog/unreleased/cookieless-challenges.md
310
+ - Why: bearer mode (#1604) sends no cookies (`credentials: "omit"`). Better Auth keeps
311
+ the 2FA challenge, the trust-device token, and the passkey challenge in cookies, so
312
+ a bearer client on previews (and on stable before H3) can't finish 2FA or passkeys.
313
+ - Build:
314
+ - A Terreno Better Auth plugin copies each challenge cookie the server sets
315
+ (`two_factor`, `trust_device`, the passkey challenge) into a response header,
316
+ `X-Terreno-Auth-Challenge`, carrying the signed cookie values.
317
+ - On requests it accepts that header and converts it back to the cookies in a
318
+ `before` hook, the same way `bearer()` converts the Authorization header. Values
319
+ stay signed, so a tampered header fails the signature check.
320
+ - rtk bearer mode stores the header value next to the bearer (same
321
+ `bearerStorage`) and sends it on `/two-factor/*` and `/passkey/*` calls. It clears
322
+ it on sign-out and after a successful verify (except trust-device, which lives
323
+ for `trustDeviceDays`).
324
+ - Proof:
325
+ - `betterAuthChallengeHeaders.test.ts`: verify-totp with only the header (no cookie)
326
+ succeeds; a tampered header gives 401; passkey register and sign-in finish with
327
+ only the header; a trusted device skips the challenge on the next sign-in.
328
+ - Playwright, cross-site bearer project (cookies are never sent, so the browser
329
+ engine doesn't matter): `e2e/crossSiteChallenges.spec.ts` covers "user with 2FA
330
+ completes the challenge" and "user signs in with a passkey" (CDP virtual
331
+ authenticator).
332
+ - Docs: `authentication.md` gets "Challenges without cookies" next to #1604's bearer
333
+ section, including XSS exposure of `bearerStorage` and the HIPAA note to use
334
+ `sessionStorage`.
335
+ - Skills: terreno-backend-api, terreno-data-fetching, security-audit
336
+
337
+ - [x] **T6** — Security summary, 2FA reset, security audit events, platform routes
338
+ - Depends on: T2
339
+ - Files: api/src/accountSecurity/accountSecurityService.ts, api/src/accountSecurity/accountSecurityApp.ts, api/src/accountSecurity/securityEvents.ts, api/src/accountSecurity/accountSecurity.test.ts, api/src/audit/record.ts, api/src/audit/auditEventModel.ts, api/src/rbac/statements.ts, api/src/rbac/roleModel.ts, api/src/index.ts, example-backend/src/server.ts, docs/reference/account-security.md, docs/how-to/audit-log.md, changelog/unreleased/account-security-app.md
340
+ - Build:
341
+ - The `AccountSecurityApp` TerrenoPlugin exports
342
+ `mountSecurityRoutes(router, authorize)`.
343
+ - `GET /users/:id/security` returns:
344
+ - linked providers;
345
+ - passkey count and names;
346
+ - 2FA on or off and backup codes remaining;
347
+ - active session count;
348
+ - last sign-in method and time;
349
+ - `disabled`;
350
+ - `actions: [{id, label, destructive}]`.
351
+ - `POST /users/:id/security/reset-two-factor` removes the user's TOTP secret and
352
+ backup codes and turns 2FA off.
353
+ - Access is granted to holders of the new RBAC statement `user:manageSecurity`.
354
+ `superadmin` and `operator` hold it; the operator grant goes in
355
+ `api/src/rbac/roleModel.ts:131-140`.
356
+ - The example backend registers `AccountSecurityApp`
357
+ (`example-backend/src/server.ts`). Only the user's own session credential is
358
+ accepted; delegated credentials are refused.
359
+ - `recordSecurityAction({actor, target, action, org})` in `securityEvents.ts`
360
+ writes an `AuditEvent` with `source: "security"`, `modelName: "User"`,
361
+ `recordId` set to the target, `verb: "updated"`, `operation: "update"`, and
362
+ `after: {securityAction, organizationId?}`. That source is added to
363
+ `AuditEventWrite` (`api/src/audit/record.ts:22-33`) and to the model enum.
364
+ `recordSecurityAction` also emits to in-process listeners, which T10 uses.
365
+ - `securityEvents.ts` defines every action name used in T7–T10.
366
+ - `AccountSecurityApp` throws at startup when `AuditApp` is not registered, unless
367
+ the consumer passes `audit: false` (Q6). The example already registers `AuditApp`
368
+ (`example-backend/src/server.ts:428`).
369
+ - Proof (`accountSecurity.test.ts`):
370
+ - An operator gets the summary and can reset 2FA. Afterwards the target signs in
371
+ with a password and gets no 2FA challenge.
372
+ - A member is refused with 403.
373
+ - An MCP service-token request is refused with 403.
374
+ - Each reset writes one `AuditEvent` with `source: "security"`.
375
+ - Startup without `AuditApp` throws; `audit: false` boots.
376
+ - Docs: new `docs/reference/account-security.md` (routes, actions, permissions).
377
+ `audit-log.md` gains a `security` source row.
378
+ - Skills: terreno-backend-api, mongoose-schema-safety, security-audit
379
+
380
+ - [x] **T7** — Recovery: remove passkey, unlink provider
381
+ - Depends on: T6
382
+ - Files: api/src/accountSecurity/credentialActions.ts, api/src/accountSecurity/accountSecurityApp.ts, api/src/accountSecurity/credentialActions.test.ts, docs/reference/account-security.md
383
+ - Build:
384
+ - `DELETE /users/:id/security/passkeys/:passkeyId`
385
+ - `DELETE /users/:id/security/accounts/:providerId`
386
+ - An unlink that would leave zero sign-in methods returns 409, unless the user has
387
+ a verified email, in which case it also sends a password reset.
388
+ - Proof:
389
+ - After removal, the passkey is no longer listed, and the provider row is gone.
390
+ - The 409 guard holds.
391
+ - Each action writes a security `AuditEvent`.
392
+ - The summary `actions` include both.
393
+ - Skills: terreno-backend-api, security-audit
394
+
395
+ - [x] **T8** — Recovery: revoke sessions, send reset, disable/enable
396
+ - Depends on: T7
397
+ - Files: api/src/accountSecurity/accessActions.ts, api/src/accountSecurity/accountSecurityApp.ts, api/src/accountSecurity/accessActions.test.ts, example-backend/src/models/user.ts, example-backend/src/types/models/userTypes.ts, docs/reference/account-security.md
398
+ - Build:
399
+ - Fix the existing Better Auth reset-password JWT sync: over HTTP,
400
+ `applyBetterAuthPasswordResetToJwt` re-reads a request body Better Auth already
401
+ consumed, so `POST {basePath}/reset-password` answers 500 whenever a `userModel`
402
+ is set (found in T7). Capture `newPassword` from the parsed endpoint body instead
403
+ (a `hooks.before` matcher on `/reset-password`). Proof: an HTTP reset with a
404
+ `userModel` succeeds, signs in with the new password, and bumps `tokenEpoch`.
405
+ - `POST .../revoke-sessions`
406
+ - `POST .../send-password-reset`, which uses `sendMail` and returns 501 when
407
+ `publicAppUrl` is unset, matching the existing hooks.
408
+ - `POST .../disable` and `.../enable`, which set `User.disabled`.
409
+ - `POST .../issue-sign-in-link` (platform-only, for legacy users with no password
410
+ hash and no mail path, T3) creates the Better Auth user for that app User,
411
+ linked by `betterAuthId`, and returns a single-use set-password link the admin
412
+ hands over. It never marks an attacker-chosen credential verified: T3 refuses
413
+ unverified same-email sign-ups before any Better Auth user exists (D8).
414
+ - The example User gains `isDisabledPlugin`, applied in `user.ts` only.
415
+ - Proof:
416
+ - After revoke, the next REST request with the old bearer token gets 401.
417
+ - After disable, REST gets 401 and the socket handshake is refused. After enable,
418
+ sign-in works again.
419
+ - The reset email is captured by the test mailer.
420
+ - After `issue-sign-in-link`, the link sets a password and the user signs in to
421
+ the same app User; the link is refused on reuse.
422
+ - Skills: terreno-backend-api, mongoose-schema-safety, security-audit
423
+
424
+ - [x] **T9** — Org-admin scoped recovery routes
425
+ - Depends on: T6
426
+ - Files: api/src/orgs/orgsApp.ts, api/src/terrenoApp.ts, api/src/accountSecurity/orgSecurityRoutes.ts, api/src/accountSecurity/orgSecurityRoutes.test.ts, api/src/rbac/membershipOrgAdminSource.ts, docs/explanation/organizations.md, docs/how-to/recover-locked-out-user.md
427
+ - Build:
428
+ - `TerrenoApp` passes the registered `AccountSecurityApp`, if any, to the
429
+ `OrgsApp` it builds (`api/src/terrenoApp.ts:599`). `OrgsApp` mounts
430
+ `mountSecurityRoutes` at `/orgs/:orgId/members/:userId/security` only when one is
431
+ present.
432
+ - The authorizer allows an org-admin of `:orgId` when the target has an active
433
+ membership there, even if the target also belongs to other orgs (D2). It always
434
+ refuses targets with a platform role (`superadmin`, `operator`) or `admin: true`
435
+ (Q1).
436
+ - `orgRecovery.minMembershipDays` is opt-in hardening and defaults to 0. When set,
437
+ it refuses members who joined more recently than that. This defends against an
438
+ org-admin attaching an existing user by email
439
+ (`api/src/orgs/orgsApp.ts:406-450`) and acting on them right away. T10's email
440
+ tells the user whenever an org-admin acts.
441
+ - Under the org prefix, a separate `suspend-membership` action
442
+ (`POST /orgs/:orgId/members/:userId/security/suspend-membership`) suspends that
443
+ membership; `disable`, `enable` and `issue-sign-in-link` are not mounted there.
444
+ Global `User.disabled` stays platform-only (Q8). Restoring a member uses the
445
+ existing `PATCH /orgs/:id/members/:memberId {status: "active"}`.
446
+ - Platform actors always pass.
447
+ - Events record `org`.
448
+ - Proof (`orgSecurityRoutes.test.ts`, authorization matrix on summary and
449
+ reset-two-factor):
450
+ - The org-admin of A acting on an active member of A is allowed. This includes a
451
+ member who also belongs to B.
452
+ - The org-admin of A acting on a member of B only gets 404.
453
+ - The org-admin of A acting on a suspended member gets 404.
454
+ - The org-admin of A acting on a member who holds `superadmin` gets 403.
455
+ - The org-admin of A acting on another org-admin of A is allowed.
456
+ - With `minMembershipDays: 7`, attaching an existing user by email and resetting
457
+ their 2FA the same day gets 403. With the default 0, it is allowed.
458
+ - Org-prefix `suspend-membership` suspends only the A membership, and the user can
459
+ still sign in.
460
+ - An app without `AccountSecurityApp` exposes no `/orgs/*/security` routes.
461
+ - A plain member gets 403.
462
+ - A disabled org gets 403.
463
+ - A generic check confirms that every route `mountSecurityRoutes` registers is
464
+ reachable under the org prefix.
465
+ - Docs: `organizations.md` gets a "Member recovery" section. New
466
+ `recover-locked-out-user.md` covers platform and org paths.
467
+ - Skills: terreno-backend-api, security-audit, building-admin-interfaces
468
+
469
+ - [x] **T10** — Security notification emails
470
+ - Depends on: T8, T27
471
+ - Files: api/src/accountSecurity/securityNotifications.ts, api/src/accountSecurity/securityEventHooks.ts, api/src/accountSecurity/securityEvents.ts, api/src/betterAuthFeatures.ts, api/src/accountSecurity/securityNotifications.test.ts, comms/src/templates.ts, comms/src/templates.test.ts, comms/src/types.ts, docs/reference/account-security.md
472
+ - Build:
473
+ - A listener on `recordSecurityAction` emails the affected user. This covers both admin
474
+ actions and self-service ones (provider linked or unlinked, passkey added or
475
+ removed, 2FA enabled or disabled, password changed).
476
+ - Self-service events are recorded by a Better Auth `hooks.after` plugin.
477
+ - Each email names who acted: "you", or "an administrator". The org name appears
478
+ only when `notifications.includeOrgName` is true (default false; Q9).
479
+ - Proof:
480
+ - Each event type sends exactly one email to the target, through the test mailer.
481
+ - `renderAuthMail` renders each new template id.
482
+ - No email is sent when `sendMail` is unset.
483
+ - Skills: terreno-backend-api, security-audit
484
+
485
+ - [x] **T11** — Previews sign in through oauth-proxy on the stable deploy
486
+ - Depends on: T5
487
+ - Files: example-backend/src/utils/betterAuthConfig.ts, example-backend/src/utils/betterAuthConfig.test.ts, api/src/betterAuthOAuthHandoff.ts, api/src/betterAuthOAuthProxy.test.ts, scripts/ci/gcp-deploy.sh, infra/flourish/main.tf, docs/how-to/set-up-social-login.md, changelog/unreleased/preview-oauth.md
488
+ - Build:
489
+ - When `OAUTH_PROXY_PRODUCTION_URL` is set, the example backend enables
490
+ `oauthProxy`.
491
+ - `gcp-deploy.sh` reads `STABLE_BACKEND_URL` and `STABLE_FRONTEND_URL`, defaulting
492
+ to today's `run.app` and `netlify.app` hosts, so the H3 move is a two-variable
493
+ change (D6). It sets `OAUTH_PROXY_PRODUCTION_URL` to `STABLE_BACKEND_URL` for
494
+ `pr-N` revisions, sets `BETTER_AUTH_URL` to it on stable, and sets `FRONTEND_URL`
495
+ for both. `PASSKEY_RP_ID` is set on stable only when `STABLE_FRONTEND_URL` is a
496
+ subdomain (its parent domain); previews keep their own host.
497
+ - The stable `trustedOrigins` include both the old and new frontend hosts during the
498
+ move.
499
+ - `gcp-deploy.sh` mounts the `GOOGLE_*`, `GITHUB_*` and `APPLE_*` secrets.
500
+ - Terraform adds those secret containers.
501
+ - Previews already share the stable `BETTER_AUTH_SECRET`
502
+ (`scripts/ci/gcp-deploy.sh:129-131`), which oauth-proxy needs.
503
+ - The #1604 handoff hook also runs on oauth-proxy's `/oauth-proxy-callback`, so a
504
+ bearer-mode preview gets its one-time token after the proxied return. This file is
505
+ `api/src/betterAuthOAuthHandoff.ts` (ordered after T5).
506
+ - Proof:
507
+ - Config test: the preview env includes the oauthProxy plugin, with `productionURL`
508
+ set to stable. Stable runs it too (Better Auth 1.7.4 forwards a preview's return
509
+ from stable's callback hook), naming itself, and its own sign-ins skip the proxy
510
+ so they keep the state-cookie check.
511
+ - api test (`betterAuthOAuthProxy.test.ts`): two Better Auth instances, "stable" and
512
+ "preview", with separate databases and one secret. A mock-provider sign-in started
513
+ on the preview returns through stable's callback to the preview, and the preview's
514
+ redirect carries the one-time token. This proves the state survives the hop.
515
+ - Config test: with `STABLE_*` on subdomains of one domain, stable gets
516
+ `passkey.rpID` = the parent domain and no `crossSubDomainCookies`; with the
517
+ defaults it gets the `netlify.app` host.
518
+ - `terraform validate` passes.
519
+ - `bash -n scripts/ci/gcp-deploy.sh` passes.
520
+ - Docs: new `set-up-social-login.md` (linked from `configure-better-auth.md` by T20),
521
+ covering:
522
+ - registering each provider;
523
+ - Apple key and JWT rotation;
524
+ - one callback per provider;
525
+ - how previews proxy;
526
+ - passkey `rpID` per preview host, given that `netlify.app` is a public suffix,
527
+ and the parent domain on stable once it shares a domain;
528
+ - moving stable onto one domain (H3): the custom domain, the domain mapping, the
529
+ callback re-registration, and why passkeys made on the old host stop working.
530
+ - Skills: deploy-gcp, terreno-backend-api, verify-ui-changes
531
+
532
+ - [ ] **T28** — Live social sign-in on a preview and on the stable deploy
533
+ - Depends on: T11
534
+ - Blocked by: H1
535
+ - Status: deferred past PR #1613 and tracked as X10. It needs the H1 OAuth apps; every other
536
+ proof uses the mock provider.
537
+ - Files: example-frontend/e2e/live-social-login.spec.ts
538
+ - Proof:
539
+ - On this PR's preview and on the stable deploy, after H1:
540
+ - On both, `GET /api/auth/terreno/methods` lists github, google and apple.
541
+ - For each provider on both, `POST /api/auth/sign-in/social` returns an
542
+ authorization URL whose `redirect_uri` host is the stable backend.
543
+ - Apple `form_post` gate: a `POST` to the stable
544
+ `/api/auth/callback/apple`, carrying a preview-originated state, answers 302 to
545
+ the preview host (not 404/405). If it fails, Apple on previews moves to Open
546
+ risks with the cause, and stable Apple still ships.
547
+ - `e2e/live-social-login.spec.ts` (opt-in, `LIVE_OAUTH=1`) signs in with the H1
548
+ GitHub test account on both URLs.
549
+ - Google and Apple are checked by reaching the provider consent page with the
550
+ stable `redirect_uri`.
551
+
552
+ Screenshots go to `/opt/cursor/artifacts/`. A full Google/Apple sign-in is H2.
553
+ - Skills: verify-ui-changes, deploy-gcp
554
+
555
+ - [x] **T12** — LoginScreen v2 (presentational)
556
+ - Depends on: none
557
+ - Files: ui/src/login/LoginScreen.tsx, ui/src/login/loginTypes.ts, ui/src/login/LoginScreen.test.tsx, ui/src/login/__snapshots__/LoginScreen.test.tsx.snap, ui/src/signUp/OAuthButtons.tsx, ui/src/signUp/signUpTypes.ts, ui/src/SocialLoginButton.tsx, ui/src/Common.ts, demo/stories/LoginScreen.stories.tsx, demo/story-config/LoginScreen.config.tsx, docs/reference/ui.md
558
+ - Build: new optional props.
559
+ - `lastUsedMethod`: shows a "Last used" badge on that method and moves it to the
560
+ top.
561
+ - `currentSessions` / `rememberedAccounts`: shows "Continue as" cards with avatar,
562
+ name, provider, `onContinue` and `onForget`.
563
+ - `onPasskeyPress` and `passkeyAutofill`: the email field gets
564
+ `autoComplete="username webauthn"`.
565
+ - `notice`: for `account_not_linked` guidance.
566
+ - Provider buttons move above the form when `lastUsedMethod` is a provider.
567
+ - Fix `SocialLoginButton` ignoring later `loading` prop changes
568
+ (`ui/src/SocialLoginButton.tsx:76`).
569
+ - Every new element gets a testID, named `login-screen-continue-<i>` and so on.
570
+ - Proof: `LoginScreen.test.tsx` covers:
571
+ - badge and ordering per `lastUsedMethod`;
572
+ - continue and forget callbacks;
573
+ - the passkey button only when `onPasskeyPress` is set;
574
+ - the loading prop update.
575
+
576
+ Existing snapshots stay unchanged with no new props.
577
+ - Docs: `ui.md` LoginScreen section.
578
+ - Skills: terreno-ui, accessibility-testing, verify-ui-changes
579
+
580
+ - [x] **T13** — OtpField and TwoFactorChallengeScreen
581
+ - Depends on: none
582
+ - Files: ui/src/OtpField.tsx, ui/src/OtpField.test.tsx, ui/src/login/TwoFactorChallengeScreen.tsx, ui/src/login/TwoFactorChallengeScreen.test.tsx, demo/stories/OtpField.stories.tsx, demo/story-config/OtpField.config.tsx, demo/stories/TwoFactorChallengeScreen.stories.tsx, demo/story-config/TwoFactorChallengeScreen.config.tsx
583
+ - Build:
584
+ - `OtpField` takes N digits, supports paste, and uses `autoComplete="one-time-code"`.
585
+ - The challenge screen has TOTP mode, a backup-code toggle, a "Trust this device"
586
+ checkbox, an error state, and `onSubmit({code, kind, trustDevice})`.
587
+ - Proof:
588
+ - Pasting "123456" fills every cell and fires `onComplete`.
589
+ - The backup toggle switches `kind`.
590
+ - Errors render under `two-factor-challenge-error`.
591
+ - Skills: terreno-ui, accessibility-testing
592
+
593
+ - [x] **T14** — LinkedAccountsCard and PasswordCard
594
+ - Depends on: none
595
+ - Files: ui/src/security/LinkedAccountsCard.tsx, ui/src/security/LinkedAccountsCard.test.tsx, ui/src/security/PasswordCard.tsx, ui/src/security/PasswordCard.test.tsx, ui/src/security/securityTypes.ts, demo/stories/LinkedAccountsCard.stories.tsx, demo/story-config/LinkedAccountsCard.config.tsx, demo/stories/PasswordCard.stories.tsx, demo/story-config/PasswordCard.config.tsx
596
+ - Build:
597
+ - `LinkedAccountsCard` lists each provider as linked (with email and linked date) or
598
+ not linked. It offers Link and Unlink, with a confirm. Unlink is disabled with an
599
+ explanation when it is the last method.
600
+ - `PasswordCard` shows "Change password" when a credential exists, and "Set a
601
+ password" for OAuth-only users.
602
+ - Proof: tests for the last-method disabled state, the confirm modal, and the
603
+ set/change variants.
604
+ - Skills: terreno-ui, accessibility-testing
605
+
606
+ - [x] **T15** — PasskeysCard and SessionsCard
607
+ - Depends on: none
608
+ - Files: ui/src/security/PasskeysCard.tsx, ui/src/security/PasskeysCard.test.tsx, ui/src/security/SessionsCard.tsx, ui/src/security/SessionsCard.test.tsx, demo/stories/PasskeysCard.stories.tsx, demo/story-config/PasskeysCard.config.tsx, demo/stories/SessionsCard.stories.tsx, demo/story-config/SessionsCard.config.tsx
609
+ - Build:
610
+ - `PasskeysCard`: add, rename and remove passkeys, each showing its created and
611
+ last-used dates.
612
+ - `SessionsCard`: device and browser, location if given, last active (Luxon
613
+ relative time), "This device" marker, revoke one, and "Sign out everywhere else".
614
+ - Proof: tests for remove confirm, the current session not being revocable
615
+ individually, and relative time rendering.
616
+ - Skills: terreno-ui, accessibility-testing
617
+
618
+ - [x] **T16** — TwoFactorSettingsCard (enroll, backup codes)
619
+ - Depends on: T2, T13
620
+ - Files: ui/src/security/TwoFactorSettingsCard.tsx, ui/src/security/TwoFactorSettingsCard.test.tsx, demo/stories/TwoFactorSettingsCard.stories.tsx, demo/story-config/TwoFactorSettingsCard.config.tsx
621
+ - Build: the card walks through these steps:
622
+ 1. Password confirm.
623
+ 2. QR code from `totpURI` (`react-native-qrcode-svg` over the existing
624
+ `react-native-svg`), plus a copyable secret.
625
+ 3. Verify the code with `OtpField`.
626
+ 4. Show backup codes with copy and download. The user must check "I saved these"
627
+ before finishing.
628
+
629
+ Once enrolled, the card offers regenerate backup codes and disable.
630
+ - Proof: tests for step progression, the finish button gated on "I saved these", and
631
+ regenerate showing the new codes.
632
+ - Skills: terreno-ui, accessibility-testing
633
+
634
+ - [x] **T17** — AccountSecurityScreen, exports, demo registration
635
+ - Depends on: T12, T14, T15, T16
636
+ - Files: ui/src/security/AccountSecurityScreen.tsx, ui/src/security/AccountSecurityScreen.test.tsx, ui/src/index.tsx, demo/demoConfig.tsx, demo/stories/AccountSecurityScreen.stories.tsx, demo/story-config/AccountSecurityScreen.config.tsx, docs/reference/ui.md, changelog/unreleased/ui-auth-components.md
637
+ - Build:
638
+ - The composite screen renders the cards that the props enable, so a disabled
639
+ feature hides its card.
640
+ - Export everything from T13–T17 at the ui package root.
641
+ - Register every new story config.
642
+ - Proof:
643
+ - `bun run check:demo-coverage` passes.
644
+ - The screen test hides the passkey card when `passkeys` is undefined.
645
+ - `bun run ui:test` passes.
646
+ - Docs: `ui.md` gets an "Account security" section.
647
+ - Skills: terreno-ui, update-docs, verify-ui-changes
648
+
649
+ - [x] **T18** — rtk client feature plugins
650
+ - Depends on: T2
651
+ - Files: rtk/src/betterAuthClient.ts, rtk/src/betterAuthTypes.ts, rtk/src/betterAuthClient.test.ts
652
+ - Build:
653
+ - `createBetterAuthClient({features})` adds `twoFactorClient` (with
654
+ `onTwoFactorRedirect`), `passkeyClient`, `multiSessionClient`,
655
+ `lastLoginMethodClient` and `oneTimeTokenClient`.
656
+ - Provider types widen to `string` so generic providers work.
657
+ - Proof:
658
+ - A test checks that each feature flag exposes its namespace on the client
659
+ (`client.twoFactor.enable`, and so on).
660
+ - Calls without `features` still produce today's client shape, so existing apps are
661
+ unaffected.
662
+ - Skills: terreno-data-fetching
663
+
664
+ - [x] **T19** — Remembered accounts on device
665
+ - Depends on: none
666
+ - Files: rtk/src/auth/rememberedAccounts.ts, rtk/src/auth/rememberedAccounts.test.ts
667
+ - Build:
668
+ - `createRememberedAccountsStore({rememberIdentity})` stores data in SecureStore
669
+ on native and localStorage on web.
670
+ - It records `{userId, method, lastUsedAt}`. Name, email (masked) and avatar are
671
+ stored only when `rememberIdentity` is true.
672
+ - It keeps the newest 5, supports `forget(userId)`, and exposes
673
+ `useRememberedAccounts()`.
674
+ - Proof:
675
+ - With `rememberIdentity: false`, the persisted record has no name, email or
676
+ image.
677
+ - `forget` removes the record.
678
+ - Ordering is by `lastUsedAt`.
679
+ - Skills: terreno-data-fetching, security-audit
680
+
681
+ - [x] **T20** — Connected TerrenoLoginScreen and login controller
682
+ - Depends on: T3, T11, T17, T19, T27
683
+ - Files: rtk/src/auth/useLoginController.ts, rtk/src/auth/useLoginController.test.ts, rtk/src/auth/TerrenoLoginScreen.tsx, rtk/src/index.ts, docs/how-to/configure-better-auth.md, changelog/unreleased/terreno-login-screen.md
684
+ - Build: the controller combines:
685
+ - `useAuthMethods`;
686
+ - a live session (the #1604 bearer from `getBetterAuthBearerToken`, or the cookie
687
+ session): a valid one skips the login screen entirely;
688
+ - device sessions from `multiSession`, which become one-tap "Continue as" cards
689
+ where the backend cookie is first-party (native cookie jar, same-site web);
690
+ - remembered accounts from T19, which become "Continue as" cards that
691
+ re-authenticate with the remembered method. On cross-site web this is the only
692
+ source;
693
+ - the last used method, read from T19's on-device store. Better Auth's
694
+ `lastLoginMethodClient` reads the backend's cookie, which a cross-site page
695
+ cannot see;
696
+ - passkey conditional UI on web;
697
+ - social sign-in through #1604's `signInSocialWithHandoff` in bearer mode;
698
+ - the 2FA redirect;
699
+ - mapping `account_not_linked` to guidance for the linking mode: "verify your
700
+ email to link" when the local email is unverified, "sign in with <method> and
701
+ link from Security" in `explicit` mode, or "this email already has an account"
702
+ in `never` mode;
703
+ - hiding sign-up links and mapping `signup_disabled` to "ask your administrator
704
+ for access" when `signUp` is closed.
705
+
706
+ `TerrenoLoginScreen` renders ui's `LoginScreen` from it.
707
+ - Proof: controller tests with a mocked client cover:
708
+ - device sessions taking precedence over remembered accounts;
709
+ - the 2FA redirect firing `onTwoFactorRequired`;
710
+ - `account_not_linked` producing the notice;
711
+ - a successful sign-in recording a remembered account.
712
+ - Docs: `configure-better-auth.md` gets a "Drop-in login screen" section and a link
713
+ to `set-up-social-login.md`.
714
+ - Skills: terreno-data-fetching, terreno-ui
715
+
716
+ - [x] **T21** — Connected TerrenoAccountSecurityScreen
717
+ - Depends on: T20
718
+ - Files: rtk/src/auth/useAccountSecurity.ts, rtk/src/auth/useAccountSecurity.test.ts, rtk/src/auth/TerrenoAccountSecurityScreen.tsx, rtk/src/index.ts, docs/how-to/configure-better-auth.md, api/src/betterAuthSetPassword.ts, api/src/betterAuthSetPassword.test.ts, api/src/rateLimit/policies.ts
719
+ - Build: the hook wraps:
720
+ - `listAccounts`, `linkSocialWithHandoff` (T5), `unlinkAccount`;
721
+ - the passkey list, add and delete;
722
+ - the `twoFactor` enable, verify, disable and backup-code calls;
723
+ - `listSessions`, `revokeSession`, `revokeOtherSessions`;
724
+ - `changePassword`, and "set a password" for OAuth-only users. Better Auth's
725
+ `setPassword` is server-only, so T21 adds a Terreno endpoint
726
+ `POST {basePath}/terreno/set-password` (session required; refused when a
727
+ credential account already exists; rate-limited in the `auth` bucket) in
728
+ `api/src/betterAuthSetPassword.ts` with its own api test.
729
+
730
+ After each mutation it refetches the affected section. The connected screen renders
731
+ ui's `AccountSecurityScreen`.
732
+ - Proof: hook tests with a mocked client cover:
733
+ - unlinking the last method never calls the client;
734
+ - enabling 2FA returns backup codes to the card;
735
+ - "sign out everywhere else" calls `revokeOtherSessions`.
736
+ - Skills: terreno-data-fetching, terreno-ui
737
+
738
+ - [x] **T22** — Example login, 2FA challenge, passkey sign-in
739
+ - Depends on: T4, T20
740
+ - Files: example-frontend/app/login.tsx, example-frontend/app/_layout.tsx, example-frontend/lib/betterAuth.ts, example-frontend/e2e/login.spec.ts, example-frontend/e2e/two-factor.spec.ts, example-frontend/e2e/account-linking.spec.ts, example-frontend/e2e/helpers/login.ts
741
+ - Build:
742
+ - The example login uses `TerrenoLoginScreen` with `rememberIdentity: true`.
743
+ - The two-factor challenge runs inline on the login route (TerrenoLoginScreen's default), so there is no separate `app/twoFactor.tsx`.
744
+ - `loginAs` handles a 2FA challenge when a fixture user has a TOTP secret.
745
+ - Proof (Playwright; social, 2FA and passkey cases run in the cross-site bearer
746
+ project, which has the mock OIDC provider; the rest in the default project):
747
+ - "user sees last used method after signing out": sign in with mock OIDC, sign
748
+ out, and `login-screen-oauth-mock-oidc` shows the badge.
749
+ - "user continues as a remembered account".
750
+ - "user with 2FA completes the TOTP challenge": the code is computed in the test
751
+ from the fixture secret.
752
+ - "user signs in with a passkey": uses a CDP `WebAuthn.addVirtualAuthenticator`
753
+ authenticator with a passkey registered through the API.
754
+ - "explicit linking mode shows the link guidance": the backend runs with
755
+ `AUTH_ACCOUNT_LINKING=explicit`.
756
+ - "user's email and mock-OAuth sign-ins land in one account" (the requested
757
+ josh@ example):
758
+ 1. Sign up by email and verify it.
759
+ 2. Sign out, then sign in with mock OAuth on the same email.
760
+ 3. The same todos show.
761
+ The unverified variant shows "verify your email to link", and after verifying,
762
+ the same sign-in joins the account.
763
+ - Remove `test.fixme` from `e2e/crossSiteChallenges.spec.ts` (added by T27) once the
764
+ example login wires 2FA and passkey, and make it pass.
765
+ - In the cross-site bearer project, "last used" and "Continue as" both render after
766
+ sign-out (they come from T19's on-device store, not a backend cookie).
767
+
768
+ Screenshots go to `/opt/cursor/artifacts/`.
769
+ - Skills: verify-ui-changes, accessibility-testing, building-terreno-apps
770
+
771
+ - [ ] **T29** — Android example signs in with the mock provider (device-proven native)
772
+ - Depends on: T22
773
+ - Status: deferred past PR #1613 and tracked as X11. The emulator companion never stayed up.
774
+ The flow and the native proxy fix shipped, so only the device run is left.
775
+ - Blocked by: an Android emulator companion (`hive-companion start`). If none is
776
+ available, return BLOCKED, environment.
777
+ - Files: example-frontend/maestro/native-social-login.yaml, docs/how-to/set-up-social-login.md
778
+ - Build: no app code is expected. It is a native proof of the T2 `nativeApps`
779
+ wiring and the T20 controller.
780
+ - `adb reverse` exposes the backend and the mock OAuth server to the emulator.
781
+ - The Maestro flow taps `login-screen-oauth-mock-oidc`, completes the browser sign-in,
782
+ and lands on `todos-screen`.
783
+ - Proof: `maestro --device 127.0.0.1:5555 test example-frontend/maestro/native-social-login.yaml`
784
+ passes. A screenshot from `adb ... screencap` is saved to `/opt/cursor/artifacts/`.
785
+ - Docs: `set-up-social-login.md` gets a "Native" section (scheme, `trustedOrigins`).
786
+ - Skills: verify-ui-changes, building-terreno-apps
787
+
788
+ - [x] **T23** — Example security screen
789
+ - Depends on: T21, T22
790
+ - Files: example-frontend/app/settings/security.tsx, example-frontend/app/settings/_layout.tsx, example-frontend/app/(tabs)/profile.tsx, example-frontend/e2e/account-security.spec.ts
791
+ - Build:
792
+ - Add `settings/security`, using `TerrenoAccountSecurityScreen`.
793
+ - Profile links to it (`profile-security-link`).
794
+ - Password change on profile moves to Better Auth `changePassword`, replacing the
795
+ users PATCH at `app/(tabs)/profile.tsx:151`.
796
+ - Proof (Playwright): the user can:
797
+ - link the mock provider and see it listed;
798
+ - unlink it;
799
+ - enroll 2FA, saving backup codes, then sign in with a backup code;
800
+ - add a passkey with the virtual authenticator and then remove it;
801
+ - revoke another session (a second browser context gets 401 afterwards);
802
+ - change the password and sign in with the new one.
803
+
804
+ Screenshots go to artifacts.
805
+ - Skills: verify-ui-changes, accessibility-testing, building-terreno-apps
806
+
807
+ - [x] **T24** — Org-admin member security panel
808
+ - Depends on: T4, T9
809
+ - Files: admin-frontend/src/orgs/MemberSecurityPanel.tsx, admin-frontend/src/orgs/MemberSecurityPanel.test.tsx, admin-frontend/src/orgs/useMemberSecurityApi.ts, admin-frontend/src/orgs/OrgMembersScreen.tsx, admin-frontend/src/index.tsx, example-frontend/e2e/org-admin-security.spec.ts, docs/reference/admin-frontend.md, changelog/unreleased/admin-member-security.md
810
+ - Build:
811
+ - Each member row on `OrgMembersScreen` gets a "Security" action, which opens a
812
+ panel with the T6 summary.
813
+ - The panel renders the summary's `actions` generically, with a confirm modal on
814
+ destructive ones, so T7 and T8 actions appear without frontend changes.
815
+ - It calls the routes through the host-bound `adminRequest`
816
+ (`admin-frontend/src/adminRequest.ts`). The package rules forbid new admin
817
+ `injectEndpoints`.
818
+ - Proof:
819
+ - Panel test: actions render from the summary, and confirm calls the action route.
820
+ - Playwright "org-admin resets a member's 2FA": the member then signs in without a
821
+ challenge.
822
+ - Docs: `admin-frontend.md` MemberSecurityPanel section.
823
+ - Skills: building-admin-interfaces, verify-ui-changes, accessibility-testing
824
+
825
+ - [x] **T25** — Platform user security screen
826
+ - Depends on: T8, T24
827
+ - Files: admin-frontend/src/UserSecurityScreen.tsx, admin-frontend/src/UserSecurityScreen.test.tsx, admin-frontend/src/index.tsx, example-frontend/app/admin/users/[id]/security.tsx, example-frontend/e2e/admin-user-security.spec.ts, docs/reference/admin-frontend.md
828
+ - Build: operators get a user security screen that reuses `MemberSecurityPanel`
829
+ against `/users/:id/security`, linked from the admin user record.
830
+ - Proof: Playwright "operator disables and re-enables a user". The user's session gets
831
+ 401 while disabled, and sign-in works after enable.
832
+ - Skills: building-admin-interfaces, verify-ui-changes
833
+
834
+ - [x] **T26** — create-terreno-app generates the auth suite
835
+ - Depends on: T4, T21
836
+ - Files: create-terreno-app/src/generate.ts, create-terreno-app/src/__tests__/generate.test.ts, docs/how-to/configure-better-auth.md, changelog/unreleased/cta-auth-suite.md
837
+ - Build: the generated backend config enables:
838
+ - `accountLinking: verified-email`
839
+ - `twoFactor`
840
+ - `passkey` (`rpID` from `FRONTEND_URL`)
841
+ - `lastLoginMethod`
842
+ - `multiSession`
843
+ - `nativeApps`
844
+
845
+ The generated frontend uses `TerrenoLoginScreen` and `settings/security`. The
846
+ login route is the OAuth return page (#1604's handoff and T20 make a separate
847
+ `auth/callback` route unnecessary).
848
+ - Proof: generator test asserting the generated `server.ts` and `app/login.tsx`
849
+ contain the features and the connected screens. The existing generator tests pass.
850
+ - Skills: build-terreno-app, update-docs
851
+
852
+ ## Assumptions
853
+
854
+ - **Better Auth only.** Legacy JWT/Passport auth gets no new features.
855
+ - **Plugins come from the catalog.** Better Auth plugins are pinned through the
856
+ `better-auth` catalog version (lock 1.7.4). `@better-auth/passkey` is added to the
857
+ catalog at the matching version (1.7.x on npm).
858
+ - **"Multiple accounts".** Better Auth enforces a unique email per user, so "multiple
859
+ accounts" is the three-way `accountLinking.mode` plus `multiSession` (Q2). Same-email
860
+ duplicate users are not offered.
861
+ - **Recovery routes use Express routers inside plugins.** They follow the `OrgsApp`
862
+ precedent (a framework plugin, an allowed exception to modelRouter actions). The
863
+ example SDK is not regenerated because admin-frontend calls the routes through
864
+ `adminRequest`.
865
+ - **Security actions reuse the existing audit log.** They are recorded as `AuditEvent`
866
+ rows with `source: "security"` through `AuditApp`, not in a new collection.
867
+ - **Grouped recovery tasks.** T7 and T8 each group recovery actions that share one route
868
+ table, so T8 is ordered after T7 for that file.
869
+ - **Dependency chains.**
870
+ - T1→T2→T6→T9→T24→T25 is ordered because each link reads what the previous one
871
+ creates:
872
+ - methods file
873
+ - feature plugin tables
874
+ - mount function
875
+ - endpoints
876
+ - panel
877
+ - T2→T4→T5→T27→T20→T21→T23 is ordered for the same reason:
878
+ - feature wiring
879
+ - mock provider and e2e config
880
+ - link handoff in `betterAuthOAuthHandoff.ts` and `betterAuthClient.ts`
881
+ - challenge headers in `betterAuthClient.ts`
882
+ - controller and `rtk/src/index.ts`
883
+ - security hook
884
+ - screen
885
+ - Docs pages shared across tasks are ordered too:
886
+ - `configure-better-auth.md` goes T2→T3→T20→T21→T26;
887
+ - `account-security.md` goes T6→T7→T8→T10;
888
+ - `set-up-social-login.md` goes T11→T29.
889
+ - All new dependencies land in T2, so one task owns `bun.lock`.
890
+ - **Passkey `rpID` on previews.** It is the full preview host (`netlify.app` is a public
891
+ suffix), so a passkey made on a preview works only on that preview. On stable after
892
+ H3 it is the shared parent domain.
893
+ - **Host-only session cookies.** Same-site subdomains (D6) don't need Better Auth's
894
+ `crossSubDomainCookies`: a host-only cookie on `api.<domain>` is sent on same-site
895
+ fetches. Leaving it off keeps the session cookie away from every other subdomain.
896
+ - **Passkeys made before H3 are lost.** A passkey registered on the `netlify.app` host
897
+ can't be used under the new `rpID`. Users sign in another way and add a new one;
898
+ the example has no real users, and `set-up-social-login.md` says so for consumers.
899
+ - **This repo has no PHI.** The example app holds no PHI. Privacy-sensitive defaults are
900
+ still chosen for consumers like Flourish (Q3).
901
+ - **Native: Android-proven, iOS parked.** Native social sign-in uses the browser
902
+ through the `@better-auth/expo` server plugin (T2, `nativeApps`). T29 proves it on
903
+ the Android emulator. iOS, the native Apple button and native passkeys are X2.
904
+
905
+ ## Open questions (recommendation assumed)
906
+
907
+ | ID | Question | Recommendation | Why | If answered differently | Tasks affected |
908
+ | --- | --- | --- | --- | --- | --- |
909
+ | Q10 | Where should the reusable auth screens live? | ui for presentation, rtk for connected screens and hooks, admin-frontend for recovery panels | ui cannot import rtk (rtk depends on ui); admin-frontend already takes an `api` prop | A new `@terreno/auth` package: same tasks, new package scaffolding, files move | T17–T21, T26 |
910
+ | Q4 | Should orgs get security policies (require 2FA, restrict sign-in methods)? | Not in this plan (X3) | The request asks for org-admin recovery; policies are new enforcement surface | +3 tasks: org settings, sign-in enforcement, member UI | T9, T24 |
911
+ | Q5 | Should an admin unlink be allowed to remove a user's last sign-in method? | Only when the user has a verified email, and then a reset email goes out automatically | Otherwise recovery creates new lockouts | Always allowed: drop the 409 guard; never allowed: always 409 | T7 |
912
+ | Q6 | Should recovery routes refuse to start without `AuditApp`? | Yes: throw at startup unless `audit: false` is passed explicitly | HIPAA reviews expect who-did-what for access changes; D2 makes cross-org reach auditable; `AuditApp` is opt-in today | Warn and run unaudited: drop the startup check in T6 | T6 |
913
+ | Q8 | Under the org prefix, should "disable" suspend the membership rather than the global account? | Yes; global `User.disabled` stays platform-only | One org must not lock a user out of every other org | Org-admins disable globally: T9 drops the remap | T9, T24 |
914
+ | Q9 | Should security emails name the org whose admin acted? | No by default (`notifications.includeOrgName`) | On a shared inbox, the org name can reveal care (same concern as Q3) | Name it by default: flip one default | T10 |
915
+ | Q7 | Accept a 5-minute revocation delay for cookie (web) sessions? | Yes, documented; bearer sessions revoke immediately | Better Auth `cookieCache` (`api/src/betterAuthSetup.ts:229-232`); disabling it costs a DB read per request | Disable the cache when recovery is enabled: one config line in T2, tested in T8 | T2, T8 |
916
+
917
+ ## Expansions (follow up later)
918
+
919
+ | ID | Idea | Why it came up | Rough size | Depends on |
920
+ | --- | --- | --- | --- | --- |
921
+ | X1 | A dedicated staging environment (Cloud Run service, Netlify site, terraform, CI) | No staging exists today; D1 uses the stable deploy | L | T11 |
922
+ | X2 | iOS proof, the native Sign in with Apple button (`expo-apple-authentication`), and native passkeys | The example is universal; iOS proof needs an EAS build | M | T18, T29 |
923
+ | X3 | Org security policies: require 2FA, allowed methods, session length | "Works for organization management" | M | T9, Q4 |
924
+ | X4 | Magic link, email OTP, Google One Tap | Other sign-in methods for the login screen | M | T2, T20 |
925
+ | X5 | Enterprise SSO (SAML/OIDC per org) | Org-managed identity | L | X3 |
926
+ | X6 | Self-service email change and account deletion | Profile completeness | M | T21 |
927
+ | X7 | Account lockout after repeated failed attempts | "Locked out" recovery | S | T8 |
928
+ | X9 | Previews under the shared domain (Netlify deploy subdomains plus a load balancer for Cloud Run tag URLs), making previews same-site too | D6 makes stable same-site; previews stay cross-site | M | T11, H3 |
929
+ | X8 | Implement the empty `PasswordField` stub; fix the stale rtk rule doc (`betterAuthReducer`) | Found during research | S | none |
930
+ | X10 | Finish T28: the live GitHub sign-in on a preview and on stable, plus the Google and Apple consent-page checks, then H2 | T28 waits on H1 (OAuth apps and secrets); deferred past PR #1613 | S | H1, T11 |
931
+ | X11 | Finish T29: run `example-frontend/maestro/native-social-login.yaml` on an Android emulator and save a screenshot | The emulator companion never stayed up; the flow and the native proxy fix shipped in PR #1613 | S | T22 |
932
+
933
+ ## Decisions
934
+
935
+ | ID | Question asked | Answer | What it changes |
936
+ | --- | --- | --- | --- |
937
+ | D1 | There is no staging environment. What is "the final deployed staging one"? | Use the existing stable example deploy | It hosts the registered callbacks and proxies to previews (T11); a real staging env is X1 |
938
+ | D2 (durable) | Who may an org-admin run recovery actions on? | Any active member of their org, even one in other orgs | T9 authorizer; every action is audited (T6) and emails the user (T10) |
939
+ | D3 | An org-admin can attach any existing user by email and then act on them. Which guards? | Light guards: always refuse platform staff and `admin` targets, email the user on every org-admin action, and make a minimum membership age opt-in (default 0) | T9 as written; `orgRecovery.minMembershipDays` defaults to 0 |
940
+ | D4 (durable) | "Leave it to the consumer whether people can make multiple accounts": which knobs? | All three: linking policy (`verified-email` / `explicit` / `never`), account switching (`multiSession`), and open vs closed sign-up | T2 adds `signUp.mode`; T4 reads `AUTH_SIGN_UP`; T20 hides sign-up and maps `signup_disabled` |
941
+ | D6 | Stable frontend and backend will move to subdomains of one domain. Does the plan still work? | Yes. Stable becomes same-site, so cookies work in Safari there; previews stay cross-site, so T27 stays | H1 callbacks go on `api.<domain>`; new H3; T4 adds `PASSKEY_RP_ID`; T11 reads `STABLE_*` URLs; X9 parks same-site previews |
942
+ | D7 | Master merged #1604 after approval: web bearer mode, the OAuth one-time-token handoff, the mock OIDC provider, and a cross-site bearer Playwright project | Reuse them (engineering reconciliation, no product change) | T4 reuses `mock-oidc`; T5 becomes the link handoff; T27 narrows to the 2FA/passkey challenges; T11 extends the handoff to oauth-proxy returns; cross-site proofs use the bearer project, where cookies are never sent, instead of WebKit |
943
+ | D8 | Review found that a refused unverified sign-up left a Better Auth user behind, which a later email verification could turn into a takeover of the legacy User | Refuse before the Better Auth user is created; T8's admin action issues a set-password link instead of marking an email verified | T3 adds a `user.create.before` guard; T8 `verify-email` becomes `issue-sign-in-link` |
944
+ | D9 | Review: should recovery routes let an operator act on a superadmin, or on themselves? | No: refuse targets holding permissions the actor lacks (a `*` target needs a superadmin actor) and refuse self-targeting; self-service lives on the user's own Security screen | T6 adds the guard to the shared route pipeline, so T9 org routes inherit it (alongside D3's platform-staff refusal) |
945
+ | D10 | Review of T7: removing a user's last passkey can lock them out just like unlinking their last provider | Apply Q5's rule to passkey removal too: 409 unless the email is verified, then send a reset first | T7 guards both routes; unlink also points operators to revoke-sessions, since sessions don't record their provider |
946
+ | D11 | Review of T8: a sign-in link sets the password of a user with no inbox, which is a full takeover by whoever issues it | It needs its own RBAC statement, `user:issueSignInLink`, granted to superadmin only by default; expired links can be reissued | T8 |
947
+ | D5 (durable) | Remember name, email and avatar on the login screen by default? | No: method only by default; identity is opt-in (`rememberIdentity`); the example opts in | T19, T20 and T26 as written |
948
+
949
+ ## Sign-off
950
+
951
+ Status: approved 2026-10-22
952
+ Cut: 2 rounds, 24 findings (8 blocking): 22 fixed in the plan, 2 left for the human as fundamental questions (Q1, Q2), 0 rebutted. Round 1: 12 fixed; Q1, Q2, Q8 and Q9 added to Open questions. Round 2: 10 fixed; C2 and C3 carried into Q1 and Q2, which go first at sign-off.