@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.
- package/CHANGELOG.md +393 -0
- package/README.md +32 -269
- package/dist/bootstrap.d.ts +12 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +77 -122
- package/dist/bootstrap.js.map +1 -1
- package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
- package/dist/docs/resources/overview.md +4 -2
- package/dist/docs/resources/patterns.md +237 -176
- package/dist/docs/resources/ui.md +48 -17
- package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/dist/docs/upgrades/57.20.0.md +238 -0
- package/dist/docs/versioned/next/approval-rubric.md +30 -12
- package/dist/docs/versioned/next/explanation/README.md +5 -0
- package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
- package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/dist/docs/versioned/next/explanation/authentication.md +45 -1
- package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/dist/docs/versioned/next/explanation/organizations.md +37 -0
- package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/dist/docs/versioned/next/how-to/README.md +7 -2
- package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
- package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/dist/docs/versioned/next/how-to/circleci.md +15 -5
- package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
- package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/dist/docs/versioned/next/reference/README.md +1 -0
- package/dist/docs/versioned/next/reference/account-security.md +700 -0
- package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
- package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/dist/docs/versioned/next/reference/api.md +461 -35
- package/dist/docs/versioned/next/reference/cli.md +1 -1
- package/dist/docs/versioned/next/reference/comms.md +8 -0
- package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
- package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/dist/docs/versioned/next/reference/syncdb.md +8 -1
- package/dist/docs/versioned/next/reference/ui.md +1271 -27
- package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- package/dist/docs/versioned/next/tutorials/README.md +4 -0
- package/dist/httpApp.d.ts +14 -0
- package/dist/httpApp.d.ts.map +1 -0
- package/dist/httpApp.js +99 -0
- package/dist/httpApp.js.map +1 -0
- package/dist/index.js +8 -90
- package/dist/index.js.map +1 -1
- package/dist/local/localTools.d.ts.map +1 -1
- package/dist/local/localTools.js +44 -14
- package/dist/local/localTools.js.map +1 -1
- package/dist/local/metro/metroDevSession.d.ts +18 -0
- package/dist/local/metro/metroDevSession.d.ts.map +1 -1
- package/dist/local/metro/metroDevSession.js +42 -3
- package/dist/local/metro/metroDevSession.js.map +1 -1
- package/dist/local/tools/databaseQuery.d.ts.map +1 -1
- package/dist/local/tools/databaseQuery.js +3 -13
- package/dist/local/tools/databaseQuery.js.map +1 -1
- package/dist/local/tools/databaseSchema.d.ts.map +1 -1
- package/dist/local/tools/databaseSchema.js +3 -13
- package/dist/local/tools/databaseSchema.js.map +1 -1
- package/dist/local/tools/localMongo.d.ts +10 -0
- package/dist/local/tools/localMongo.d.ts.map +1 -0
- package/dist/local/tools/localMongo.js +30 -0
- package/dist/local/tools/localMongo.js.map +1 -0
- package/dist/local/tools/runtime.d.ts +18 -2
- package/dist/local/tools/runtime.d.ts.map +1 -1
- package/dist/local/tools/runtime.js +75 -20
- package/dist/local/tools/runtime.js.map +1 -1
- package/dist/local/tools/syncDb.d.ts +11 -0
- package/dist/local/tools/syncDb.d.ts.map +1 -1
- package/dist/local/tools/syncDb.js +15 -3
- package/dist/local/tools/syncDb.js.map +1 -1
- package/dist/rateLimit.d.ts +35 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +112 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +42 -60
- package/dist/tools.js.map +1 -1
- package/package.json +4 -3
- package/src/docs/guidelines/ui/guidelines/core.md +1 -1
- package/src/docs/resources/overview.md +4 -2
- package/src/docs/resources/patterns.md +237 -176
- package/src/docs/resources/ui.md +48 -17
- package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/src/docs/upgrades/57.20.0.md +238 -0
- package/src/docs/versioned/next/approval-rubric.md +30 -12
- package/src/docs/versioned/next/explanation/README.md +5 -0
- package/src/docs/versioned/next/explanation/account-linking.md +169 -0
- package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/src/docs/versioned/next/explanation/authentication.md +45 -1
- package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/src/docs/versioned/next/explanation/organizations.md +37 -0
- package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/src/docs/versioned/next/how-to/README.md +7 -2
- package/src/docs/versioned/next/how-to/audit-log.md +22 -3
- package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/src/docs/versioned/next/how-to/circleci.md +15 -5
- package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/src/docs/versioned/next/how-to/password-reset.md +1 -1
- package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/src/docs/versioned/next/reference/README.md +1 -0
- package/src/docs/versioned/next/reference/account-security.md +700 -0
- package/src/docs/versioned/next/reference/admin-backend.md +3 -1
- package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/src/docs/versioned/next/reference/api.md +461 -35
- package/src/docs/versioned/next/reference/cli.md +1 -1
- package/src/docs/versioned/next/reference/comms.md +8 -0
- package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/src/docs/versioned/next/reference/environment-variables.md +9 -3
- package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/src/docs/versioned/next/reference/syncdb.md +8 -1
- package/src/docs/versioned/next/reference/ui.md +1271 -27
- package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- 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).
|