@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,700 @@
1
+ # Account security reference
2
+
3
+ `AccountSecurityApp` is a `@terreno/api` plugin that adds per-user security routes for
4
+ platform staff and, through `OrgsApp`, for org-admins: a summary of a user's sign-in methods, and recovery actions. Staff can reset
5
+ two-factor authentication, remove a passkey, unlink a sign-in provider, sign the user out
6
+ everywhere, mail a password reset, disable or enable the account, and issue a set-password link to
7
+ a legacy user who has no way to sign in. Every action that changes something writes an
8
+ `AuditEvent` with `source: "security"` and emails the affected user
9
+ ([Notifications](#notifications)). The user's own security changes (linking a provider, turning
10
+ on 2FA, changing their password) are recorded and emailed the same way
11
+ ([Self-service events](#self-service-events)).
12
+
13
+ It reads Better Auth's data (`user`, `account`, `session`, `twoFactor`, `passkey`) through
14
+ Better Auth's internal adapter, so custom collection names keep working. The app `User` links to
15
+ its Better Auth user through `betterAuthId`.
16
+
17
+ ## Plugin
18
+
19
+ ```typescript
20
+ import {AccountSecurityApp, AuditApp, BetterAuthApp, TerrenoApp} from "@terreno/api";
21
+
22
+ new TerrenoApp({accessControl: access, userModel: User})
23
+ .register(new BetterAuthApp({config: betterAuthConfig}))
24
+ .register(new AuditApp())
25
+ .register(new AccountSecurityApp())
26
+ .start();
27
+ ```
28
+
29
+ Registration throws `AccountSecurityApp requires AuditApp` unless `AuditApp` is registered on
30
+ the same `TerrenoApp` or you pass `audit: false`. Recovery changes who can reach an account, so
31
+ the default is to refuse to run without an audit trail.
32
+
33
+ ### Outside TerrenoApp (setupServer)
34
+
35
+ The plugin reads `AuditApp`, Better Auth, and the user model from the `TerrenoApp` it is
36
+ registered on. With `setupServer` there is no `TerrenoApp`, so:
37
+
38
+ - Register `AuditApp` before `AccountSecurityApp`. The startup check then looks for an
39
+ installed audit recorder instead of a registration.
40
+ - Pass `userModel` and `getBetterAuth`. Without `getBetterAuth`, every Better Auth feature reads
41
+ as off: summaries show no sign-in methods and `reset-two-factor` answers 404. The plugin logs one
42
+ warning (`AccountSecurityApp found no Better Auth instance`) the first time this happens.
43
+
44
+ ### Options
45
+
46
+ | Option | Type | Default | Description |
47
+ | --- | --- | --- | --- |
48
+ | `actions` | `SecurityRouteAction[]` | none | Extra recovery actions, mounted and listed after the built-in ones. Construction throws `Duplicate security action id` when one reuses another action's `id` |
49
+ | `accessControl` | `AnyTerrenoAccess` | the TerrenoApp's `accessControl` | RBAC used for the platform routes. Without RBAC, the routes require `admin: true` |
50
+ | `audit` | `boolean` | `true` | `false` boots without `AuditApp`. Actions still run and still reach listeners, but nothing is written to the audit log |
51
+ | `basePath` | `string` | `"/users"` | Prefix for the platform routes |
52
+ | `getBetterAuth` | `() => BetterAuthInstance \| undefined` | the registered `BetterAuthApp`'s instance | Better Auth instance to read from. Required with `setupServer` |
53
+ | `notifications` | `{enabled?, includeOrgName?, throttleMinutes?, render?, sendMail?}` | `{enabled: true, includeOrgName: false, throttleMinutes: 10}` | Security notification emails; see [Notifications](#notifications) |
54
+ | `publicAppUrl` | `string` | the registered `BetterAuthApp`'s `publicAppUrl` | App page base for `issue-sign-in-link` URLs (`{publicAppUrl}/resetPassword?token=`). Required with `setupServer` to issue links |
55
+ | `userModel` | `UserModel` | the TerrenoApp's `userModel` | App User model. Registration throws without one |
56
+
57
+ ## Permissions
58
+
59
+ | Caller | Platform routes (`/users/:id/security/*`) |
60
+ | --- | --- |
61
+ | Holder of `user:manageSecurity` (`superadmin`, `operator`) | Allowed, within the target rules below |
62
+ | Any other signed-in user | `403 Access denied` |
63
+ | No session | `401` |
64
+ | MCP service token (`mcp_`), MCP OAuth token (`mcpat_`), or any credential other than the user's own session | `403 Account security requires your own session` |
65
+
66
+ `user:manageSecurity` is a Terreno RBAC statement. `superadmin` holds it through `*`. The
67
+ `operator` role (seeded with `createAccess({organizations: true})`) holds it. An `operator` row
68
+ seeded before the statement existed gains it once, on the next seed; the role records that in
69
+ `appliedGrants`, so an admin who later revokes it keeps it revoked. Without `accessControl`, the
70
+ routes accept `admin: true` users instead.
71
+
72
+ Operators reach a user's Security screen from the user's admin record, so they also need to read
73
+ users in the admin panel. The `operator` role holds `user:list` and `user:read`. An app that opts
74
+ User into per-model admin access by declaring an `adminUser` statement gates the admin User list and
75
+ record on `adminUser` instead, so the `operator` role also gets `adminUser:read` in such an app. It
76
+ does not get `write` or `writeOwned`, so an operator can't edit, create, or delete users through the
77
+ admin panel. The grant is recorded in `appliedGrants` the same way: an existing `operator` row gains
78
+ it once, on the first seed after the app declares `adminUser`, and a revoked grant stays revoked.
79
+
80
+ `issue-sign-in-link` also needs `user:issueSignInLink` (D11). Only `superadmin` holds it by
81
+ default, through `*`; `operator` does not, so an operator gets `403 Access denied` and does not see
82
+ the action in the summary. Grant it to another role to delegate it. Without `accessControl`, the
83
+ caller needs `admin: true`.
84
+
85
+ Only the user's own session is accepted: the request must be authenticated by a Terreno JWT or a
86
+ Better Auth session (cookie or bearer). Every other credential type is refused, including
87
+ delegated agent credentials that belong to an operator.
88
+
89
+ Each route runs the same steps before its handler:
90
+
91
+ 1. Authorize the caller: `403` without the right. This runs before the target is looked up, so
92
+ a caller without access gets `403` for every id and cannot probe which users exist.
93
+ 2. Load the target: `404` when it does not exist.
94
+ 3. Check the target is within reach, else `403`:
95
+ - Acting on yourself is refused (`Use your own security settings to change your account`).
96
+ Your own 2FA, passkeys, and providers belong on your own security screen.
97
+ - A target who holds any permission the caller lacks is refused
98
+ (`Target holds permissions you lack`): a superadmin unless the caller is a superadmin, an
99
+ `admin: true` user unless the caller is one, and under RBAC any role grant the caller does
100
+ not also hold. Recovery can never be used to take over a more privileged account.
101
+ - When a permission source is down and the target's grants from it are unknown, the check
102
+ answers `503 Permission source unavailable` rather than judging the target without them.
103
+
104
+ Routes mounted with `mountSecurityRoutes` (such as the org-admin prefix) run the same steps.
105
+ Each user's permissions are resolved in their own context. On the platform routes both are
106
+ resolved outside any organization. On the org-admin routes an org-admin is resolved under their
107
+ own membership in `:orgId` and the target under theirs, so org-scoped grants count for both and a
108
+ target is never judged by the acting org-admin's membership. Platform staff on an org route are
109
+ resolved outside the organization, the same as `/users/:id/security`. An org-only grant on the
110
+ member does not block an operator who can already recover that user on the platform path.
111
+
112
+ ## Org-admin routes
113
+
114
+ With organizations on, `OrgsApp` mounts the same handlers at
115
+ `/orgs/:orgId/members/:userId/security/*` when the app registers `AccountSecurityApp`
116
+ (`TerrenoApp` passes the registered one; pass `accountSecurity` yourself when you register
117
+ `OrgsApp` by hand). An app without `AccountSecurityApp` has no `/orgs/*/security` routes. The
118
+ instance must be registered too (on the same `TerrenoApp`, in any order, or with `register`
119
+ before `OrgsApp` outside one): `OrgsApp` throws `OrgsApp accountSecurity is not registered`
120
+ otherwise, because only registration applies the `AuditApp` check, access control, and the user
121
+ model.
122
+
123
+ | Method | Path | Description |
124
+ | --- | --- | --- |
125
+ | `GET` | `/orgs/:orgId/members/:userId/security` | Security summary |
126
+ | `POST` | `.../reset-two-factor`, `.../revoke-sessions`, `.../send-password-reset` | As on the platform routes |
127
+ | `DELETE` | `.../passkeys/:passkeyId`, `.../accounts/:providerId` | As on the platform routes |
128
+ | `POST` | `.../suspend-membership` | Org only. Suspend the member's membership in `:orgId` |
129
+
130
+ The platform-only actions (`disable`, `enable`, `issue-sign-in-link`) are not mounted there. The
131
+ org authorizer runs these checks, in order:
132
+
133
+ | Check | Response |
134
+ | --- | --- |
135
+ | No session | `401` |
136
+ | `:orgId` unknown or not an ObjectId | `404 Organization not found` |
137
+ | The organization is disabled | `403 Organization is disabled` |
138
+ | The caller is neither platform staff (`user:manageSecurity`) nor an active `org-admin` of `:orgId` | `403 Only org-admins of this organization can manage member security` |
139
+ | The target has no active membership in `:orgId` (including a suspended one) | `404 Member not found` |
140
+ | Org-admin callers only: the target holds `superadmin` or `operator`, or has `admin: true` | `403 Platform staff are managed by platform staff` |
141
+ | Org-admin callers only: the membership is younger than `orgRecovery.minMembershipDays` | `403 Member joined too recently` |
142
+
143
+ Then the shared steps above apply (self-target and out-of-reach refusals). The target may belong
144
+ to other organizations too (D2). Every event records the organization: `after.organizationId` and
145
+ the AuditEvent's `organizationId`.
146
+
147
+ `orgRecovery` is an `OrgsApp` option (`new TerrenoApp({organizations: {orgRecovery:
148
+ {minMembershipDays: 7}}})`). It defaults to `0` (off). Set it to stop an org-admin from attaching
149
+ an existing user by email and running recovery on them the same day.
150
+
151
+ ### `POST /orgs/:orgId/members/:userId/security/suspend-membership`
152
+
153
+ Sets the member's `Membership.status` to `suspended` in `:orgId` only (Q8). `User.disabled`, the
154
+ user's sessions, and their other memberships are untouched: they still sign in and use their
155
+ other organizations. The suspended member is then out of the recovery routes' reach (`404`);
156
+ restore them with `PATCH /orgs/:orgId/members/:memberId` and `{status: "active"}`.
157
+
158
+ | Result | Response |
159
+ | --- | --- |
160
+ | Suspended | `200 {data: {action: "suspend-membership", changed: true, membershipId, status: "suspended"}}`, one security `AuditEvent` with `after: {membershipId, roleName, securityAction, organizationId}` |
161
+ | The member is the org's last active `org-admin` | `400 Cannot remove the last org-admin` |
162
+ | Another request suspended the membership first | `200 {data: {..., changed: false}}`, no `AuditEvent` |
163
+
164
+ The last-org-admin rule holds under concurrency: two org-admins suspending each other at once
165
+ never leave the org without one. The action suspends with a conditional update, then counts the
166
+ active org-admins left and undoes its own suspension (answering `400`) when none remain. It needs
167
+ no transaction, so it works on a standalone MongoDB too.
168
+
169
+ ## Routes
170
+
171
+ All responses use the `{data: ...}` envelope.
172
+
173
+ | Method | Path | Description |
174
+ | --- | --- | --- |
175
+ | `GET` | `/users/:id/security` | Security summary |
176
+ | `POST` | `/users/:id/security/reset-two-factor` | Remove the TOTP secret and backup codes and turn 2FA off |
177
+ | `DELETE` | `/users/:id/security/passkeys/:passkeyId` | Remove one of the user's passkeys |
178
+ | `DELETE` | `/users/:id/security/accounts/:providerId` | Unlink a sign-in provider (every account the user has with it) |
179
+ | `POST` | `/users/:id/security/revoke-sessions` | Sign the user out on every device |
180
+ | `POST` | `/users/:id/security/send-password-reset` | Mail the user a password-reset link |
181
+ | `POST` | `/users/:id/security/disable` | Set `User.disabled` and sign the user out |
182
+ | `POST` | `/users/:id/security/enable` | Clear `User.disabled` |
183
+ | `POST` | `/users/:id/security/issue-sign-in-link` | Platform only. Create a Better Auth account for a legacy user and return a set-password link |
184
+
185
+ `:id` is the app `User` id. An unknown id, or one that is not an ObjectId, returns
186
+ `404 User not found`. An action whose Better Auth feature is off on this server (for example
187
+ `reset-two-factor` without `twoFactor` in `BetterAuthConfig`, or `remove-passkey` without
188
+ `passkey`) returns `404 Security action not available`. So do `disable` and `enable` when the
189
+ User model has no `disabled` field (add `isDisabledPlugin`).
190
+
191
+ ### `GET /users/:id/security`
192
+
193
+ ```json
194
+ {
195
+ "data": {
196
+ "userId": "665f1c...",
197
+ "betterAuthLinked": true,
198
+ "disabled": false,
199
+ "emailVerified": true,
200
+ "legacyPassword": false,
201
+ "password": true,
202
+ "providers": [{"providerId": "github", "linkedAt": "2026-10-01T12:00:00.000Z"}],
203
+ "passkeys": {
204
+ "count": 1,
205
+ "items": [{"id": "pk_1", "name": "Work laptop", "createdAt": "2026-10-02T09:30:00.000Z"}]
206
+ },
207
+ "twoFactor": {"enabled": true, "backupCodesRemaining": 8},
208
+ "sessions": {"active": 2},
209
+ "lastSignIn": {"method": "github", "at": "2026-10-22T18:04:11.000Z"},
210
+ "actions": [
211
+ {
212
+ "id": "reset-two-factor",
213
+ "label": "Reset two-factor",
214
+ "destructive": true,
215
+ "method": "POST",
216
+ "path": "/reset-two-factor"
217
+ },
218
+ {
219
+ "id": "remove-passkey",
220
+ "label": "Remove passkey",
221
+ "destructive": true,
222
+ "method": "DELETE",
223
+ "path": "/passkeys/:passkeyId"
224
+ }
225
+ ]
226
+ }
227
+ }
228
+ ```
229
+
230
+ | Field | Meaning |
231
+ | --- | --- |
232
+ | `betterAuthLinked` | The app User has a `betterAuthId` whose Better Auth user exists |
233
+ | `disabled` | `User.disabled` |
234
+ | `emailVerified` | Better Auth `emailVerified` (`false` without a Better Auth link) |
235
+ | `legacyPassword` | The app User has a legacy JWT (passport-local-mongoose) password hash |
236
+ | `password` | The user has a Better Auth email and password credential |
237
+ | `providers` | Linked OAuth providers, sorted by id. The password credential is reported as `password`, not here |
238
+ | `passkeys` | Passkey count and each passkey's id, name, and creation time. Empty when the `passkey` feature is off |
239
+ | `twoFactor.enabled` | Better Auth `twoFactorEnabled` (always `false` when the `twoFactor` feature is off) |
240
+ | `twoFactor.backupCodesRemaining` | Unused backup codes, or `null` when 2FA is off or Better Auth cannot read them |
241
+ | `sessions.active` | Unexpired Better Auth sessions |
242
+ | `lastSignIn.method` | Better Auth `lastLoginMethod`. Needs `lastLoginMethod: true` in `BetterAuthConfig` |
243
+ | `lastSignIn.at` | Start time of the user's most recent Better Auth session |
244
+ | `actions` | Recovery actions this server supports and that apply to this user, in display order. Each has `id`, `label`, `destructive`, `method` (`POST` or `DELETE`), and `path` relative to the user's security base, with `:param` placeholders (`/passkeys/:passkeyId`, `/accounts/:providerId`) filled from the summary's items |
245
+
246
+ The summary never contains secrets: no TOTP secret, no backup codes, no session tokens, no
247
+ OAuth tokens or provider account ids, and no passkey key material.
248
+
249
+ A legacy user with no Better Auth link (or a `betterAuthId` whose Better Auth user is gone)
250
+ gets `betterAuthLinked: false`, empty lists, zero counts, and `null` times. Its actions are
251
+ limited to `disable` or `enable`, and `issue-sign-in-link` when it has no legacy password.
252
+
253
+ ### `POST /users/:id/security/reset-two-factor`
254
+
255
+ Deletes the user's `twoFactor` row (TOTP secret and backup codes), sets Better Auth
256
+ `twoFactorEnabled` to `false`, and forgets every device the user marked as trusted (Better Auth
257
+ `verification` rows for that user whose identifier starts with `trust-device-`). The user's next
258
+ password sign-in gets no 2FA challenge, and they can enroll again from their own security
259
+ screen. A browser that was trusted before the reset is challenged again once they re-enroll.
260
+ Sessions are not revoked.
261
+
262
+ If Better Auth is configured to hash verification identifiers (`verification.storeIdentifier`),
263
+ the prefix cannot be matched, so the reset deletes every `verification` row for the user (which
264
+ also cancels a pending password reset link) and logs a warning. Rows kept only in Better Auth
265
+ `secondaryStorage` are not reached.
266
+
267
+ | Result | Response |
268
+ | --- | --- |
269
+ | 2FA was on, half-enrolled (secret stored but never confirmed), or a trusted device existed | `200 {data: {action: "reset-two-factor", changed: true}}`, one security `AuditEvent` |
270
+ | Nothing to remove, or the user has no Better Auth link | `200 {data: {action: "reset-two-factor", changed: false}}`, no `AuditEvent` |
271
+
272
+ A reset that changes nothing is not audited and does not notify listeners, so the user is not
273
+ told that an administrator reset something that was never set.
274
+
275
+ ### `DELETE /users/:id/security/passkeys/:passkeyId`
276
+
277
+ Deletes the passkey with that id (the `id` from the summary's `passkeys.items`), but only when it
278
+ belongs to the target. Another user's passkey id gets `404` and is never touched. Sessions are not
279
+ revoked. Removing the user's last sign-in method follows the
280
+ [last sign-in method rule](#last-sign-in-method).
281
+
282
+ | Result | Response |
283
+ | --- | --- |
284
+ | Removed | `200 {data: {action: "remove-passkey", changed: true, passkeyId, passwordResetSent}}`, one security `AuditEvent` with `after: {passkeyId, passkeyName, passwordResetSent, securityAction}` |
285
+ | Unknown id, another user's passkey, or a user with no Better Auth link | `404 Passkey not found` |
286
+ | `passkey` is off in `BetterAuthConfig` | `404 Security action not available` |
287
+ | Last sign-in method and the email is unverified | `409 Cannot remove the last sign-in method` |
288
+ | Last sign-in method, email verified, `sendMail` not configured | `409 Password reset email is not configured` |
289
+ | Last sign-in method, email verified, `publicAppUrl` not configured | `501 publicAppUrl is required to send recovery mail` (from the mail hook) |
290
+
291
+ ### `DELETE /users/:id/security/accounts/:providerId`
292
+
293
+ Removes the target's Better Auth `account` rows for `providerId` (such as `github` or
294
+ `mock-oidc`). The summary never exposes provider account ids, so a user who linked two accounts
295
+ of the same provider loses both; the response's `accountCount` says how many went. The password
296
+ (`credential`) account is never removed here: send a password reset instead. Removing the
297
+ user's last sign-in method follows the [last sign-in method rule](#last-sign-in-method).
298
+
299
+ Unlinking does not end sessions the user started with that provider: Better Auth sessions do not
300
+ record which method created them, so they cannot be told apart. When the provider account may be
301
+ compromised, follow the unlink with `revoke-sessions` to sign the user out everywhere.
302
+
303
+ | Result | Response |
304
+ | --- | --- |
305
+ | Unlinked | `200 {data: {action: "unlink-provider", changed: true, providerId, accountCount, passwordResetSent}}`, one security `AuditEvent` with `after: {providerId, accountCount, passwordResetSent, securityAction}` |
306
+ | `providerId` is `credential` | `400 The password cannot be unlinked` |
307
+ | The user has no account with that provider, or no Better Auth link | `404 Provider not linked` |
308
+ | Last sign-in method and the email is unverified | `409 Cannot remove the last sign-in method` |
309
+ | Last sign-in method, email verified, `sendMail` not configured | `409 Password reset email is not configured` |
310
+ | Last sign-in method, email verified, `publicAppUrl` not configured | `501 publicAppUrl is required to send recovery mail` (from the mail hook) |
311
+
312
+ ### `POST /users/:id/security/revoke-sessions`
313
+
314
+ Deletes every Better Auth session the user has, including each account a multi-session browser
315
+ holds. When the User model has `tokenEpoch`, it is also bumped, so legacy JWT refresh tokens stop
316
+ working. JWT access tokens stay valid until they expire (default 15 minutes).
317
+
318
+ | Result | Response |
319
+ | --- | --- |
320
+ | Sessions deleted, or `tokenEpoch` bumped | `200 {data: {action: "revoke-sessions", changed: true, revokedCount, jwtRefreshRevoked}}`, one security `AuditEvent` with `after: {revokedCount, jwtRefreshRevoked, securityAction}` |
321
+ | No sessions and no `tokenEpoch` | `200 {data: {..., changed: false, revokedCount: 0}}`, no `AuditEvent` |
322
+
323
+ `revokedCount` counts the unexpired sessions that were deleted.
324
+
325
+ #### Revocation delay for cookie sessions
326
+
327
+ A bearer client (native apps, bearer-mode web, socket handshakes) sends the session token
328
+ itself, and Better Auth looks it up on every request. Its next request after a revoke gets `401`.
329
+
330
+ A cookie client can keep working for up to 5 minutes. Terreno turns on Better Auth's session
331
+ cookie cache (`session.cookieCache`, `maxAge: 300`), which lets the browser's signed
332
+ `session_data` cookie stand in for the database lookup until it expires. Revoking cannot reach
333
+ that cookie. This trade-off is accepted (Q7): turning the cache off costs a database read on every
334
+ request. To close the window, set `options: {session: {cookieCache: {enabled: false}}}` in
335
+ `BetterAuthConfig`. `disable` has no such window: the disabled check reads the app User on every
336
+ request, whichever way the session resolved.
337
+
338
+ ### `POST /users/:id/security/send-password-reset`
339
+
340
+ Mails the user a Better Auth password-reset link through `BetterAuthConfig.sendMail`. It uses the
341
+ same template, `{publicAppUrl}/resetPassword?token=` link, and lifetime
342
+ (`resetPasswordTokenExpiresIn`, default 1 hour) as a self-service reset. Better Auth's
343
+ `/reset-password` endpoint consumes the token once. Sessions are not revoked until the user
344
+ resets the password; Better Auth then revokes them (`revokeSessionsOnPasswordReset`).
345
+
346
+ | Result | Response |
347
+ | --- | --- |
348
+ | Mailed | `200 {data: {action: "send-password-reset", changed: true, sent: true}}`, one security `AuditEvent` |
349
+ | `sendMail` not configured | `501 Password reset email is not configured` |
350
+ | `publicAppUrl` not configured | `501 publicAppUrl is required to send recovery mail` (from the mail hook) |
351
+ | The user has no Better Auth account | `409 User has no Better Auth account` (use `issue-sign-in-link`) |
352
+
353
+ ### `POST /users/:id/security/disable` and `.../enable`
354
+
355
+ `disable` sets `User.disabled` to `true` and then revokes every session as `revoke-sessions`
356
+ does. A disabled user is refused everywhere:
357
+
358
+ - REST: a Better Auth session for a disabled user does not set `req.user`, so protected routes
359
+ answer `401`; a JWT gets `401 User is disabled`. Better Auth's own routes (sign-in, sign-out)
360
+ still answer, so the user can sign in, but the new session authenticates nothing.
361
+ - Sockets: `RealtimeApp`'s Better Auth handshake validator refuses the session
362
+ (`Unauthorized: user is disabled`), and its revalidation sweep disconnects open sockets.
363
+
364
+ `enable` sets `User.disabled` to `false`. The user signs in again as usual.
365
+
366
+ | Result | Response |
367
+ | --- | --- |
368
+ | `disable` changed the flag | `200 {data: {action: "disable", changed: true, revokedCount, jwtRefreshRevoked}}`, one security `AuditEvent` with `after: {revokedCount, jwtRefreshRevoked, securityAction}` |
369
+ | `enable` changed the flag | `200 {data: {action: "enable", changed: true}}`, one security `AuditEvent` |
370
+ | Already disabled (or enabled) | `200 {data: {..., changed: false}}`, no `AuditEvent` |
371
+ | The User model has no `disabled` field | `404 Security action not available` |
372
+
373
+ Apply `isDisabledPlugin` to the User schema to enable both. Both are platform only:
374
+ `mountSecurityRoutes` leaves them out unless the mount passes `includePlatformOnly`. Under the
375
+ org-admin prefix, `suspend-membership` takes their place and suspends the org membership
376
+ instead; the global flag stays platform-only (Q8).
377
+
378
+ ### `POST /users/:id/security/issue-sign-in-link`
379
+
380
+ Needs `user:issueSignInLink` (see [Permissions](#permissions)). For a legacy User with no Better
381
+ Auth account and no password (for example a GitHub-JWT user, or one whose sign-up was refused for
382
+ an unverified email): creates their Better Auth user, links it through `betterAuthId`, and returns
383
+ a single-use set-password link for the administrator to hand over. The user opens it, sets a password through Better Auth's `/reset-password`, and signs in to
384
+ the same app User.
385
+
386
+ ```json
387
+ {
388
+ "data": {
389
+ "action": "issue-sign-in-link",
390
+ "changed": true,
391
+ "url": "https://app.example.com/resetPassword?token=...",
392
+ "expiresAt": "2026-10-23T19:00:00.000Z"
393
+ }
394
+ }
395
+ ```
396
+
397
+ - The link is a Better Auth password-reset token: it works once and expires after
398
+ `resetPasswordTokenExpiresIn` (default 1 hour).
399
+ - A link that was never used can be issued again, for example after it expired. Reissuing is
400
+ allowed only while the linked Better Auth user still looks like one this action created: no
401
+ account rows (no password, no provider), no passkey, and an unverified email. It mints a fresh
402
+ token for the same Better Auth user and deletes the user's earlier reset tokens, so only the
403
+ newest link works. (When Better Auth hashes verification identifiers, earlier links stay valid
404
+ until they expire, and a warning is logged.) Once the user has set a password, the account is
405
+ live and further requests get `409`. The URL is the
406
+ same page the reset mail opens, `{publicAppUrl}/resetPassword?token=`.
407
+ - The URL is returned only to the administrator. It is never mailed, logged, or written to the
408
+ audit event, which records `{betterAuthId, expiresAt}`.
409
+ - The Better Auth email stays unverified (`emailVerified: false`). The administrator hands the
410
+ link over, so nothing proves the user controls the inbox. If the app sets
411
+ `requireEmailVerification`, the user must verify the email before signing in.
412
+ - The Better Auth user is created without Better Auth's `user.create` hooks, because Terreno's
413
+ sign-up guard refuses exactly this case: an unverified user whose email matches an unclaimed
414
+ app User. An administrator vouching for the user is what makes it safe here.
415
+ - It is platform only. `mountSecurityRoutes` leaves it out unless the mount passes
416
+ `includePlatformOnly`, so org-admin prefixes never expose it.
417
+
418
+ | Result | Response |
419
+ | --- | --- |
420
+ | Issued | `200 {data: {action, changed: true, url, expiresAt}}`, one security `AuditEvent` |
421
+ | `publicAppUrl` not configured | `501 Sign-in links are not configured` |
422
+ | The caller lacks `user:issueSignInLink` | `403 Access denied` |
423
+ | The user has a Better Auth account that has a sign-in method or a verified email (or was linked concurrently) | `409 User already has a Better Auth account` (use `send-password-reset`) |
424
+ | The user has a legacy password | `409 User already has a password` (signing in with it links the account) |
425
+ | Another Better Auth user already has the email | `409 Email already belongs to another sign-in account` |
426
+ | The user has no email | `409 User has no email` |
427
+
428
+ A `betterAuthId` whose Better Auth user no longer exists counts as unlinked: the link replaces it.
429
+
430
+ ### Last sign-in method
431
+
432
+ Recovery must not lock a user out. Before `remove-passkey` or `unlink-provider` runs, the route
433
+ counts the sign-in methods the user would have left: their remaining accounts (including the
434
+ password) plus their passkeys. Passkeys count only when `passkey` is on, because otherwise they
435
+ cannot sign in. When none would remain:
436
+
437
+ - If the user's Better Auth email is not verified, the action is refused with `409`.
438
+ - If it is verified, the route first mails a password-reset link through
439
+ `BetterAuthConfig.sendMail`, then removes the method. The link uses the same template and
440
+ `publicAppUrl` link as a self-service reset, and is valid for Better Auth's
441
+ `resetPasswordTokenExpiresIn`. The user sets a password from the link and signs in with it.
442
+ The response and audit event say `passwordResetSent: true`.
443
+ - If the reset mail cannot be sent, nothing is removed.
444
+
445
+ Two recovery actions running at once can each see the other's method and both remove theirs.
446
+ So after removing, the route counts again. If nothing is left and no reset went out yet, it
447
+ mails one when the email is verified (and reports `passwordResetSent: true`). If the email is
448
+ unverified, or that late mail fails, the action still succeeds, reports
449
+ `passwordResetSent: false`, and logs an error. If the method was removed by the other request
450
+ before this one could, the route answers `404`. Any reset it already mailed is still audited, as
451
+ a `send-password-reset` event with `after: {providerId | passkeyId, reason: "last-sign-in-method"}`.
452
+
453
+ ## Actions
454
+
455
+ The summary's `actions` list and the routes come from one action table. Each action has an
456
+ `id`, a `label`, a `destructive` flag (the admin UI confirms destructive actions first), and
457
+ its route: `method` and `path` under `.../:userId/security`. A client runs any listed action,
458
+ including a custom one, from the summary alone: `{method} {securityBase}{path}`, filling
459
+ `:passkeyId` from `passkeys.items[].id` and `:providerId` from `providers[].providerId`.
460
+ `@terreno/admin-frontend`'s
461
+ [`MemberSecurityPanel`](./admin-frontend.md#membersecuritypanel) renders this list on the
462
+ org members screen and on the platform
463
+ [`UserSecurityScreen`](./admin-frontend.md#usersecurityscreen), so a new action gets a button
464
+ without a frontend change.
465
+
466
+ | `id` | Label | Destructive | Listed when |
467
+ | --- | --- | --- | --- |
468
+ | `reset-two-factor` | Reset two-factor | yes | `twoFactor` is on and the user has 2FA enabled |
469
+ | `remove-passkey` | Remove passkey | yes | `passkey` is on and the user has at least one passkey |
470
+ | `unlink-provider` | Unlink provider | yes | Better Auth is on and the user has at least one linked provider |
471
+ | `revoke-sessions` | Sign out everywhere | yes | Better Auth is on and the user has an active session |
472
+ | `send-password-reset` | Send password reset | no | Better Auth is on and the user has a Better Auth account |
473
+ | `disable` | Disable account | yes | Platform mount only; the User model has `disabled` and the user is enabled |
474
+ | `enable` | Enable account | no | Platform mount only; the User model has `disabled` and the user is disabled |
475
+ | `suspend-membership` | Suspend membership | yes | Org mount only |
476
+ | `issue-sign-in-link` | Issue sign-in link | no | Platform mount only; the caller holds `user:issueSignInLink`; Better Auth is on; the user has no legacy password and either no Better Auth account or one whose link was never used |
477
+
478
+ Every action name the security log can record, including self-service changes:
479
+
480
+ | `id` | Started by |
481
+ | --- | --- |
482
+ | `reset-two-factor`, `remove-passkey`, `unlink-provider`, `revoke-sessions`, `send-password-reset`, `disable`, `enable`, `suspend-membership`, `issue-sign-in-link` | An administrator |
483
+ | `provider-linked`, `provider-unlinked`, `passkey-added`, `passkey-removed`, `two-factor-enabled`, `two-factor-disabled`, `password-changed`, `password-set`, `password-reset`, `other-sessions-revoked` | The user (see [Self-service events](#self-service-events)) |
484
+
485
+ `SECURITY_ACTIONS` exports the full table with labels.
486
+
487
+ ## Audit events
488
+
489
+ `recordSecurityAction({action, actor, target, org?, details?})` writes one `AuditEvent`. An
490
+ action handler's `audit` result becomes `details` (for example, which passkey was removed);
491
+ `securityAction` and `organizationId` always come from the framework and cannot be overridden.
492
+ `details` must not hold secrets.
493
+
494
+ | Field | Value |
495
+ | --- | --- |
496
+ | `source` | `"security"` |
497
+ | `modelName` | `"User"` |
498
+ | `recordId` | Target app User id |
499
+ | `actorId` | Acting app User id |
500
+ | `verb` / `operation` | `"updated"` / `"update"` |
501
+ | `after` | `{...details, securityAction, organizationId?}` |
502
+ | `organizationId` | Set when an org-admin acted for an org |
503
+
504
+ The write follows the audit recorder's rules: the HTTP handler does not wait for it, and a failed
505
+ write is logged without failing the action. Filter the log with
506
+ `GET /audit-events?source=security&recordId=<userId>`.
507
+
508
+ `onSecurityAction(listener)` subscribes to every recorded action and returns an unsubscribe
509
+ function. Listeners receive the record plus `occurredAt` (ISO time) and `origin`
510
+ (`"admin"` or `"self"`). A listener that throws is logged and never fails the action.
511
+
512
+ ## Self-service events
513
+
514
+ `createBetterAuth` records the security changes a user makes to their own account with
515
+ `origin: "self"`, and the user as both actor and target. Each change writes a security
516
+ `AuditEvent` when `AuditApp` is registered and reaches every `onSecurityAction` listener. It needs
517
+ a `userModel`, because security principals are app User ids. Only successful requests are
518
+ recorded. Admin recovery actions change Better Auth data through its adapters, not its endpoints,
519
+ so they never show up here twice.
520
+
521
+ | Action | Recorded when | `after` details |
522
+ | --- | --- | --- |
523
+ | `provider-linked` | A non-password account is created for a user who already had another sign-in method: `/link-social`, the link callback, the OAuth handoff, or implicit linking on sign-in. A first account is a sign-up and is not recorded | `providerId` |
524
+ | `provider-unlinked` | `POST {basePath}/unlink-account` deletes an account | `providerId` |
525
+ | `passkey-added` | `POST {basePath}/passkey/verify-registration` succeeds | `passkeyId` |
526
+ | `passkey-removed` | `POST {basePath}/passkey/delete-passkey` succeeds | `passkeyId` |
527
+ | `two-factor-enabled` | A successful `/two-factor/verify-totp`, `verify-otp`, or `enable` request that turned the stored `twoFactorEnabled` on (enrolment). The 2FA step of a sign-in is not recorded | none |
528
+ | `two-factor-disabled` | A successful `POST {basePath}/two-factor/disable` that turned the stored `twoFactorEnabled` off | none |
529
+ | `password-changed` | `POST {basePath}/change-password` succeeds | none |
530
+ | `password-set` | `POST {basePath}/terreno/set-password` adds a first password | none |
531
+ | `password-reset` | `POST {basePath}/reset-password` completes (Better Auth's `onPasswordReset`) | none |
532
+ | `other-sessions-revoked` | `POST {basePath}/revoke-other-sessions` succeeds | none |
533
+
534
+ Two-factor changes are read from the database, not the session. A `user.update.before` database
535
+ hook compares the `twoFactorEnabled` being written with the stored user row, so a stale
536
+ cookie-cached session cannot hide or invent a change. Admin resets run outside any Better Auth
537
+ endpoint, so they are never mistaken for the user's own change.
538
+
539
+ The plugin is `createSecurityEventsPlugin(userModel)` (id `terreno-security-events`).
540
+ `createBetterAuth` adds it next to the app user sync plugin whenever it has a `userModel`.
541
+
542
+ ## Notifications
543
+
544
+ `AccountSecurityApp` emails the affected user about every recorded security action, both admin
545
+ recovery and self-service. It subscribes at registration with `onSecurityAction`. Security
546
+ actions are process-wide, so one registration owns the emails: the newest `AccountSecurityApp`
547
+ to register replaces any earlier subscription, and two registered apps never email twice.
548
+ `accountSecurityApp.unsubscribeNotifications()` stops them on shutdown (it does nothing unless
549
+ that instance is the owner).
550
+
551
+ ```typescript
552
+ new AccountSecurityApp({
553
+ notifications: {
554
+ enabled: true, // default; nothing is sent while no sendMail is configured
555
+ includeOrgName: false, // default; see below
556
+ throttleMinutes: 10, // default
557
+ },
558
+ });
559
+ ```
560
+
561
+ | Option | Default | Description |
562
+ | --- | --- | --- |
563
+ | `enabled` | `true` | `false` sends no notification. Recording and audit are unchanged |
564
+ | `includeOrgName` | `false` | Name the organization in emails about actions an org-admin ran |
565
+ | `throttleMinutes` | `10` | At most one email per user, template, origin (self or admin), and org in this window, so a burst of the same change is one email but an administrator's change is never hidden behind the user's own (D3). Expired entries are pruned as emails go out. `0` sends every one. Every action is still audited |
566
+ | `render` | the built-in renderer | Renders the emails instead, for example `@terreno/comms` `renderAuthMail`. See [Templates](#templates) |
567
+ | `sendMail` | the registered `BetterAuthApp`'s `sendMail` | Mailer for the notifications. Set it with `setupServer` |
568
+
569
+ Each email:
570
+
571
+ - Goes to the target app User's `email`. It is skipped, with no error, when `sendMail` is not
572
+ configured or the user has no email.
573
+ - Says who acted: "You" for the user's own changes, or "An administrator" for recovery actions.
574
+ It never gives the admin's name or email.
575
+ - Names the organization only when `includeOrgName` is `true` and an org-admin acted ("An
576
+ administrator at Acme Clinic ..."). On a shared inbox the org name can reveal care, so it is
577
+ off by default (Q9).
578
+ - Gives the time (UTC) and ends with "If you did not expect this change, contact support right
579
+ away." When `publicAppUrl` is set, it adds "You can review your account security at
580
+ {publicAppUrl}."
581
+ - Never includes tokens, links that sign in, passkey names, or other text the user chose. A
582
+ provider is named only when it is a known one (Google, GitHub, Apple, Microsoft, Facebook);
583
+ otherwise the email says "a sign-in provider".
584
+
585
+ A failed delivery is logged (`Security notification failed action=... userId=...`, never the
586
+ address) and never fails the action, and it frees the throttle slot so the next change can still
587
+ email. No-op actions (`changed: false`) are not recorded, so they send nothing.
588
+
589
+ | Action | Template id | Subject |
590
+ | --- | --- | --- |
591
+ | `reset-two-factor`, `two-factor-disabled` | `securityTwoFactorDisabled` | Two-step verification was turned off |
592
+ | `two-factor-enabled` | `securityTwoFactorEnabled` | Two-step verification was turned on |
593
+ | `remove-passkey`, `passkey-removed` | `securityPasskeyRemoved` | A passkey was removed from your account |
594
+ | `passkey-added` | `securityPasskeyAdded` | A passkey was added to your account |
595
+ | `unlink-provider`, `provider-unlinked` | `securityProviderUnlinked` | A sign-in method was disconnected from your account |
596
+ | `provider-linked` | `securityProviderLinked` | A sign-in method was connected to your account |
597
+ | `revoke-sessions` | `securitySessionsRevoked` | You were signed out everywhere |
598
+ | `other-sessions-revoked` | `securityOtherSessionsRevoked` | Your other sessions were signed out |
599
+ | `password-changed` | `securityPasswordChanged` | Your password was changed |
600
+ | `password-set` | `securityPasswordSet` | A password was added to your account |
601
+ | `password-reset` | `securityPasswordReset` | Your password was reset (the body does not say who: anyone holding the link can reset) |
602
+ | `disable` | `securityAccountDisabled` | Your account was disabled |
603
+ | `enable` | `securityAccountEnabled` | Your account was re-enabled |
604
+ | `suspend-membership` | `securityMembershipSuspended` | Your organization membership was suspended |
605
+ | `issue-sign-in-link` | `securitySignInLinkIssued` | A sign-in link was created for your account (the link itself went to the administrator and is never in the email; the action refuses users with no email) |
606
+ | `send-password-reset` | none | The reset email it sends is the notice; a second email is noise |
607
+
608
+ ### Templates
609
+
610
+ The built-in renderer renders the emails. `BetterAuthConfig.renderAuthMail` is never used for
611
+ them: a renderer written only for `resetPassword`/`verifyEmail` would answer every security id
612
+ with a reset or verify mail. To render them yourself, opt in with `notifications.render`; it gets
613
+ `{templateId, data, publicAppUrl, templates: authMailTemplates}`. `@terreno/comms`
614
+ `renderAuthMail` fits and gives the same output as the built-in renderer. When `render` throws or
615
+ returns no subject, the built-in renderer is used and a warning is logged.
616
+
617
+ Override any template's subject, text, or HTML through `BetterAuthConfig.authMailTemplates`:
618
+
619
+ ```typescript
620
+ const betterAuthConfig: BetterAuthConfig = {
621
+ // ...
622
+ authMailTemplates: {
623
+ securityPasswordChanged: {
624
+ subject: "Your Acme password changed",
625
+ text: "{{actor}} changed your password on {{occurredAt}}.\n\n{{secureAccountLine}}",
626
+ },
627
+ },
628
+ sendMail: async (message) => getCommsService().sendMail(message),
629
+ };
630
+
631
+ new AccountSecurityApp({notifications: {render: renderAuthMail}}); // optional, from @terreno/comms
632
+ ```
633
+
634
+ | Placeholder | Value |
635
+ | --- | --- |
636
+ | `actor` | `You` or `An administrator` |
637
+ | `orgClause` | `""`, or ` at {org name}` with `includeOrgName` |
638
+ | `orgReference` | `an organization`, or the org name with `includeOrgName` |
639
+ | `providerName` | `Google`, `GitHub`, `Apple`, `Microsoft`, `Facebook`, or `a sign-in provider` |
640
+ | `occurredAt` | Such as `October 23, 2026 at 14:05 UTC` |
641
+ | `appUrl` | `publicAppUrl` without a trailing slash, or `""` |
642
+ | `secureAccountLine` | The "contact support" sentence, with the app URL when set |
643
+
644
+ HTML bodies get HTML-escaped values, so an org name cannot inject markup. Subject and text get
645
+ the raw values.
646
+
647
+ ## Mounting the routes elsewhere
648
+
649
+ `accountSecurityApp.mountSecurityRoutes(router, authorize, options?)` mounts
650
+ `GET /:userId/security` and every action route on any Express router, guarded by `authorize`.
651
+ Actions marked `platformOnly` (`disable`, `enable`, `issue-sign-in-link`) are mounted and listed only when
652
+ `options.includePlatformOnly` is `true`; the platform routes pass it, other mounts should not.
653
+ Actions marked `orgOnly` (`suspend-membership`) are mounted and listed only when
654
+ `options.includeOrgOnly` is `true`; the org-admin prefix passes it.
655
+
656
+ ```typescript
657
+ type SecurityAuthorizer = (args: {
658
+ req: express.Request;
659
+ targetUserId: string;
660
+ }) => Promise<{actor: User; org?: {id: string; name?: string}; orgScope?: SecurityOrgScope}>;
661
+ ```
662
+
663
+ `orgScope` (`{organization, actorMembership?, targetMembership}`) makes the reach check resolve
664
+ each user under their own membership in that organization. Omit it for routes that are not
665
+ org-scoped, and for a platform operator on the org prefix.
666
+
667
+ A route action is `{id, method, path, isSupported, isApplicable, run, platformOnly?, orgOnly?,
668
+ requiredPermission?}`. `requiredPermission` (such as `{user: ["issueSignInLink"]}`) is checked on
669
+ top of the authorizer; without RBAC it requires `admin: true`. An action the caller may not run
670
+ answers `403` and is left out of the summary. `run` returns
671
+ `{changed, data?, audit?}`: `data` is merged into the response, `audit` into the audit event.
672
+
673
+ The authorizer throws an `APIError` to refuse: `403` when the caller lacks the right, `404`
674
+ when the target is out of their reach. A returned `org` is recorded on each audit event. The
675
+ platform routes use `accountSecurityApp.authorizePlatform`. Put a prefix's own params in
676
+ `options.pathPrefix` (the org routes pass `/:orgId/members`) so the leaf routes carry them: the
677
+ authorizer can read them and the OpenAPI spec documents them as path parameters. A router nested
678
+ under a param prefix instead needs `Router({mergeParams: true})`, and OpenAPI then shows the
679
+ prefix's params as literal text.
680
+
681
+ ## Exports
682
+
683
+ | Export | Description |
684
+ | --- | --- |
685
+ | `AccountSecurityApp` | The plugin |
686
+ | `MANAGE_SECURITY_PERMISSION` | `{user: ["manageSecurity"]}` |
687
+ | `ISSUE_SIGN_IN_LINK_PERMISSION` | `{user: ["issueSignInLink"]}` |
688
+ | `SECURITY_ACTIONS`, `SecurityActionId` | Action names, labels, and origins |
689
+ | `recordSecurityAction`, `onSecurityAction` | Record an action; subscribe to actions |
690
+ | `createSecurityEventsPlugin`, `recordSelfSecurityAction` | The Better Auth plugin that records self-service events; record one by Better Auth user id |
691
+ | `createSecurityNotificationListener`, `SECURITY_NOTIFICATION_TEMPLATES`, `DEFAULT_SECURITY_MAIL_TEMPLATES`, `SECURITY_MAIL_TEMPLATE_IDS`, `renderSecurityMail`, `buildSecurityMailData`, `escapeMailHtml` | Notification listener, the action-to-template map, the default templates, and the built-in renderer |
692
+ | `SecurityNotificationsOptions`, `SecurityMailTemplateId`, `SecurityMailRenderer` | Types |
693
+ | `createAccountSecurityService` | The summary, reset, credential, and access logic without routes |
694
+ | `createOrgSecurityAuthorizer`, `mountOrgSecurityRoutes`, `OrgRecoveryOptions` | The org-admin authorizer and mount that `OrgsApp` uses |
695
+ | `suspendMembershipAction` | The org-only `suspend-membership` action |
696
+ | `AccountSecuritySummary`, `MountSecurityRoutesOptions`, `SecurityAuthorization`, `SecurityAuthorizer`, `SecurityOrgScope`, `SecurityRouteAction` | Types |
697
+
698
+ Related: [Recover a locked-out user](../how-to/recover-locked-out-user.md),
699
+ [Enable the framework audit log](../how-to/audit-log.md),
700
+ [Configure Better Auth](../how-to/configure-better-auth.md).