@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
@@ -14,6 +14,7 @@ REST API framework built on Express and Mongoose. Provides modelRouter (CRUD end
14
14
  - [Middleware](#middleware)
15
15
  - [Logging & Tracing](#logging--tracing)
16
16
  - [Extensibility](#extensibility)
17
+ - [Consent forms (ConsentApp)](#consent-forms-consentapp)
17
18
  - [In-app notifications](#in-app-notifications)
18
19
  - [Webhooks & Notifications](#webhooks--notifications)
19
20
  - [HTTP Client](#http-client)
@@ -165,6 +166,7 @@ new TerrenoApp({userModel: User})
165
166
  | `modelRouter` | `audit: true` or `{redact?: string[]}` after successful HTTP create/update/delete and array push/update/remove |
166
167
  | `AdminApp` | Auto when `AuditApp` is registered (`source: "admin"`). `onAdminAudit` is extra |
167
168
  | RBAC | `createAccess({auditSink: persistRbacAuditToAuditEvent})` (`source: "rbac"`) |
169
+ | `AccountSecurityApp` | Each recovery action that changes something (`source: "security"`). See [Account security](account-security.md) |
168
170
 
169
171
  HTTP is list+read only: `GET /audit-events` with `Permissions.IsAdmin`. Empty create/update/delete permission arrays mean POST/PATCH/DELETE return **405**, including `/admin/audit-events` (`admin.adminPermissions`). Non-admin list is also **405** (`permissionMiddleware`). There is no `isDeletedPlugin`; rows are not soft-deleted.
170
172
 
@@ -328,8 +330,10 @@ updating, and removing array items:
328
330
 
329
331
  Each operation runs like a `PATCH` that replaces the whole field. The transform, `preUpdate`,
330
332
  and RBAC write checks see `{[field]: newArray}`, and only the body they return is saved. A
331
- `PATCH` of a subdocument merges into a copy of the item, so a hook (or admin `readonlyFields`)
332
- that drops the field leaves the stored item unchanged.
333
+ `PATCH` of a subdocument merges into a plain-data copy of the item, so a hook (or admin
334
+ `readonlyFields`) that drops the field leaves the stored item unchanged. It deliberately does not
335
+ use `Subdocument.$clone()`: that clone has no parent, and assigning a nested document array to it
336
+ causes Mongoose setters to fail.
333
337
 
334
338
  `:id` must be an ObjectId. Any other id leaves this router so a later route on the same
335
339
  prefix can handle it. `POST /ai/observability/evaluators` is not an array push on a model
@@ -415,23 +419,23 @@ new TerrenoApp({
415
419
  | `authorizationRequestTtlSeconds` | 600 | How long a request waits for a consent decision |
416
420
  | `authorizationCodeTtlSeconds` | 60 | How long a code can be exchanged |
417
421
 
418
- Misconfiguration (an issuer with a path, a non-URL `consentUrl`, registration on with no redirect hosts) throws at boot. With registration off, create clients yourself as `McpOAuthClient` documents (`clientId`, `clientName`, `redirectUris`, `grantTypes: ["authorization_code", "refresh_token"]`, `responseTypes: ["code"]`); `/oauth/register` is then 404 and the metadata has no `registration_endpoint`.
422
+ Misconfiguration (an issuer with a path, a non-URL `consentUrl`, registration on with no redirect hosts) throws at boot. With registration off, create public clients yourself as `McpOAuthClient` documents (`clientId`, `clientName`, `redirectUris`, `grantTypes: ["authorization_code", "refresh_token"]`, `responseTypes: ["code"]`). Create a headless client with `McpOAuthClient.issueConfidential({clientName, tokenEndpointAuthMethod, userId})`, which returns `{client, clientSecret}` once. `/oauth/register` is 404 while registration is off, and the metadata has no `registration_endpoint`.
419
423
 
420
424
  | Method | Path | Auth | Purpose |
421
425
  | --- | --- | --- | --- |
422
426
  | GET | `/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource/mcp` | none | RFC 9728 metadata: `resource`, `authorization_servers`, `bearer_methods_supported`, `scopes_supported` |
423
- | GET | `/.well-known/oauth-authorization-server` | none | RFC 8414 metadata: `issuer`, endpoints, `response_types_supported: ["code"]`, `grant_types_supported`, `code_challenge_methods_supported: ["S256"]`, `token_endpoint_auth_methods_supported: ["none"]` |
427
+ | GET | `/.well-known/oauth-authorization-server` | none | RFC 8414 metadata: `issuer`, endpoints, `response_types_supported: ["code"]`, `grant_types_supported` (`authorization_code`, `refresh_token`, `client_credentials`), `code_challenge_methods_supported: ["S256"]`, `token_endpoint_auth_methods_supported` (`none`, `client_secret_basic`, `client_secret_post`) |
424
428
  | POST | `/oauth/register` | none | Only with `dynamicClientRegistration`. RFC 7591 dynamic client registration. Public clients only. 201 with `client_id`; `invalid_redirect_uri` for a redirect outside `allowedRedirectHosts`, non-https (except loopback), or with a fragment |
425
- | GET | `/oauth/authorize` | none | Validates `client_id`, `redirect_uri`, `response_type=code`, PKCE (`S256` only), `scope`, and `resource`, stores a pending request, and redirects to `consentUrl?request=<id>`. An unknown client or unregistered redirect is a 400 in place; other errors redirect with `error` and `state` |
426
- | GET | `/oauth/authorize/:request` | JWT or session | What the consent screen shows: `{data: {clientId, clientName, redirectUri, redirectHost, scope, resource, expiresAt}}` |
427
- | POST | `/oauth/authorize/:request/approve` | JWT or session | `{data: {redirectUrl}}` carrying `code`, `state`, and `iss` |
428
- | POST | `/oauth/authorize/:request/deny` | JWT or session | `{data: {redirectUrl}}` carrying `error=access_denied` and `state` |
429
- | POST | `/oauth/token` | `client_id` | `authorization_code` (with `code`, `redirect_uri`, `code_verifier`) or `refresh_token`. Returns `{access_token, token_type: "Bearer", expires_in, refresh_token, scope}` with `Cache-Control: no-store` |
429
+ | GET | `/oauth/authorize` | none | Validates `client_id`, `redirect_uri`, `response_type=code`, PKCE (`S256` only), `scope`, and `resource`, stores a pending request, and redirects to `consentUrl?request=<id>`. An unknown or revoked client, or an unregistered redirect, is a 400 in place; other errors redirect with `error` and `state`. A loopback redirect (`localhost`, `127.0.0.1`, `[::1]`) matches a registered URI with the same scheme, host, path, and query on any port (RFC 8252 §7.3). PKCE `S256` is what stops another process on that host from exchanging a code delivered to a different port. The pending request stores the redirect the client sent, so the code returns to that port. Those three hosts do not match each other. Every other redirect must match the registered string exactly |
430
+ | GET | `/oauth/authorize/:request` | JWT, session, or `req.user` already set | What the consent screen shows: `{data: {clientId, clientName, redirectUri, redirectHost, scope, resource, expiresAt}}` |
431
+ | POST | `/oauth/authorize/:request/approve` | JWT, session, or `req.user` already set | `{data: {redirectUrl}}` carrying `code`, `state`, and `iss`. The grant is issued to `req.user._id` |
432
+ | POST | `/oauth/authorize/:request/deny` | JWT, session, or `req.user` already set | `{data: {redirectUrl}}` carrying `error=access_denied` and `state` |
433
+ | POST | `/oauth/token` | public: `client_id` in the body. confidential: `client_secret_basic` or `client_secret_post`, matching the client | `authorization_code` (with `code`, `redirect_uri`, `code_verifier`), `refresh_token`, or `client_credentials`. Authorization-code and refresh responses are `{access_token, token_type: "Bearer", expires_in, refresh_token, scope}`. `client_credentials` returns the same without `refresh_token`, and the access token acts as the client's bound user. `Cache-Control: no-store` |
430
434
  | POST | `/oauth/revoke` | `client_id` | RFC 7009. Always 200. Revoking a refresh token revokes its whole chain |
431
435
 
432
- The consent endpoints are in OpenAPI (`getMcpOAuthRequest`, `approveMcpOAuthRequest`, `denyMcpOAuthRequest`) and refuse `mcp_` and `mcpat_` bearers, so an agent cannot approve its own grants. A pending request that expired is 400 `Authorization request expired`; one already decided is 409. Protocol endpoints answer `{error, error_description}` per RFC 6749 and never carry the `requestId` member TerrenoApp adds to other JSON bodies. `/oauth/register`, `/oauth/authorize`, `/oauth/token`, and `/oauth/revoke` count against the `auth` rate-limit bucket.
436
+ The consent endpoints are in OpenAPI (`getMcpOAuthRequest`, `approveMcpOAuthRequest`, `denyMcpOAuthRequest`) and refuse `mcp_` and `mcpat_` bearers, so an agent cannot approve its own grants. `authenticateMiddleware()` calls `next()` when `req.user` is already set, so an app with no Terreno JWT sets that user in `addMiddleware` (before these routes), from a session the app has already verified, and calls the consent routes with no bearer. The user object needs `_id`. See [Enable MCP OAuth](../how-to/enable-mcp-oauth.md#apps-without-a-terreno-jwt). A pending request that expired is 400 `Authorization request expired`; one already decided is 409. Protocol endpoints answer `{error, error_description}` per RFC 6749 and never carry the `requestId` member TerrenoApp adds to other JSON bodies. `/oauth/register`, `/oauth/authorize`, `/oauth/token`, and `/oauth/revoke` count against the `auth` rate-limit bucket.
433
437
 
434
- Tokens are opaque (`mcpat_` access, `mcprt_` refresh) and stored only as SHA-256 hashes in `McpOAuthToken`. Each refresh rotates the pair; reusing a rotated refresh token revokes every pair issued from that authorization (its family). Codes are single-use: a failed exchange burns the code, and replaying a used code revokes the tokens it issued. The code exchange fixes the family id when it consumes the code, so a replay can revoke the family before its tokens exist.
438
+ Tokens are opaque (`mcpat_` access, `mcprt_` refresh) and stored only as SHA-256 hashes in `McpOAuthToken`. Each refresh rotates the pair; reusing a rotated refresh token revokes every pair issued from that authorization (its family). Codes are single-use: a failed exchange burns the code, and replaying a used code revokes the tokens it issued. The code exchange fixes the family id when it consumes the code, so a replay can revoke the family before its tokens exist. A `client_credentials` token has no refresh token. Its document still stores a unique hash of a discarded secret so the `refreshTokenHash` unique index is unchanged, and `hasRefreshToken: false` rejects that hash if it is ever presented. `McpOAuthClient.revoke(clientId)` revokes every family issued to that client. An authorization-code exchange, a refresh, or a client_credentials grant that loses a race with that revoke is discarded instead of returned. `deleteOne`, `findOneAndDelete`, and `deleteMany` revoke those families too. A query `deleteOne` or `findOneAndDelete` whose filter matches several clients deletes one of them and revokes that client's tokens. `bulkWrite` bypasses the hooks.
435
439
 
436
440
  `McpOAuthToken.revokeFamily(familyId)` first writes an `McpOAuthRevokedFamily` tombstone, then revokes every pair in the family. `McpOAuthToken.issue` refuses a tombstoned family with `McpOAuthFamilyRevokedError` (the token endpoint answers `invalid_grant`), and checks again after creating the pair, revoking it if a revoke landed meanwhile. A request that won a rotation or code exchange before a concurrent revoke therefore cannot leave a live pair behind. Tombstones expire after the family's last refresh expiry, and at least one hour after the revoke.
437
441
 
@@ -465,8 +469,12 @@ setupServer({
465
469
  - `POST /auth/signup` — Create user account
466
470
  - `POST /auth/login` — Authenticate with email/password
467
471
  - `POST /auth/refresh_token` — Refresh access token
468
- - `POST /auth/forgotPassword` — Always 202; mails a reset link only when the email exists
469
- - `POST /auth/resetPassword` — `{token, password}`; also aliased at `POST /resetPassword` for the RTK client
472
+ - `POST /auth/forgotPassword` — Always 202; mails a reset link only when the email exists (with
473
+ `BetterAuthApp` registered, only when that user already has a JWT password)
474
+ - `POST /auth/resetPassword` — `{token, password}`; also aliased at `POST /resetPassword` for the
475
+ RTK client. With `BetterAuthApp` registered, a token for a user without a JWT password gets 400
476
+ `legacy-password-reset-unavailable` and sets no password (`POST /auth/login` has no second
477
+ factor); that user resets through Better Auth
470
478
  - `POST /auth/sendVerification` — Authenticated; 202 only after delivery succeeds; mails a verification link when `emailVerified` is not true
471
479
  - `POST /auth/verifyEmail` — `{token}` sets `emailVerified` true
472
480
  - `GET /auth/me` — Get current user profile
@@ -539,7 +547,9 @@ Request logs redact credential values at any depth in request bodies and in URL
539
547
  any key whose name contains `password`, `passwd`, `passphrase`, `passcode`, `token`, `secret`,
540
548
  `verifier`, `signature`, `apikey`, `authorization`, `credential`, or `privatekey` (case and `_`/`-`
541
549
  ignored, so `currentPassword`, `refresh_token`, `client_secret`, and `idToken.token` all match), or
542
- that has `code`, `otp`, or `pin` as a word (`code`, `zipCode`, `otp_code`). Changing the mailbox through `PATCH /auth/me`
550
+ that has `code`, `otp`, `pin`, `note`, or `reason` as a word (`code`, `zipCode`, `otp_code`,
551
+ `provisionalNote`). `note` and `reason` are free text that can hold PHI, such as a provisional
552
+ consent signer's note. Changing the mailbox through `PATCH /auth/me`
543
553
  invalidates unused `passwordReset` AuthTokens for that user even when the schema has no
544
554
  `emailVerified` field. When the schema uses `emailVerificationPlugin`, mailbox change also
545
555
  sets `emailVerified` to false and invalidates unused `emailVerification` tokens; changing letter
@@ -562,8 +572,8 @@ case instead of 202. Email lookup is case-insensitive. Successful reset calls `s
562
572
  `tokenEpoch` so outstanding **refresh** tokens and MCP credentials (`mcp_` service tokens and
563
573
  `mcpat_`/`mcprt_` OAuth grants) fail, and returns new JWT tokens. When
564
574
  `BetterAuthApp` is registered, JWT reset also updates that user's Better Auth password
565
- and deletes Better Auth sessions. Better Auth password reset updates the JWT password
566
- and `tokenEpoch` for the matching app User. Access
575
+ and deletes Better Auth sessions. Better Auth password reset updates the JWT password of the
576
+ matching app User when it already has one, and bumps its `tokenEpoch`. Access
567
577
  tokens stay valid until expiry (default 15m). `POST /resetPassword`
568
578
  matches the `@terreno/rtk` `resetPassword` mutation path (`password` or `newPassword`).
569
579
 
@@ -663,9 +673,11 @@ Set `publicAppUrl`, `sendMail`, and `renderAuthMail` (from `@terreno/comms`) so
663
673
  Those hooks throw 501 and do not send when `publicAppUrl` is missing, so links are never
664
674
  relative. Password reset also sets `revokeSessionsOnPasswordReset`, so existing Better Auth sessions
665
675
  are deleted when that reset path succeeds. JWT `POST /auth/resetPassword` updates the
666
- Better Auth password and deletes those sessions when `BetterAuthApp` is registered.
676
+ Better Auth password and deletes those sessions when `BetterAuthApp` is registered; it only
677
+ serves app `User`s that already have a JWT password then (others get 400
678
+ `legacy-password-reset-unavailable`, and `POST /auth/forgotPassword` mails them nothing).
667
679
  With a user model, Better Auth `reset-password` sets the same password as the app `User`'s JWT
668
- password and bumps `tokenEpoch`. `change-password` sets the new JWT password only on an app
680
+ password when that `User` already has one, and bumps `tokenEpoch` either way. `change-password` sets the new JWT password only on an app
669
681
  `User` that already has one. `change-password`, `revoke-sessions`, and `revoke-other-sessions`
670
682
  bump `tokenEpoch` for the linked app `User`, so JWT refresh tokens and MCP credentials issued
671
683
  before the change stop working.
@@ -690,9 +702,10 @@ trusted origin (`trustedOrigins` plus the API's own origin), or that has neither
690
702
  browser headers are unchanged; `advanced.disableCSRFCheck` disables the check.
691
703
 
692
704
  On first contact, `syncBetterAuthUser` finds the app `User` by `betterAuthId` and creates it
693
- when missing. It links an existing app `User` that has the same email only when Better Auth has
694
- verified that email and the row has no other `betterAuthId`. Otherwise the session does not set
695
- `req.user` and the socket handshake is refused, until the address is verified. When a verified
705
+ when missing. It links an existing app `User` that has the same email only when the row has no
706
+ other `betterAuthId` and Better Auth has verified that email or the request's password matches the
707
+ row's passport hash (see the plugin rules below). Otherwise the session does not set `req.user`
708
+ and the socket handshake is refused. When a verified
696
709
  email links a row whose own `emailVerified` is not `true`, its legacy passport password is removed
697
710
  and `tokenEpoch` is bumped, because whoever set that password never proved the mailbox. Seeds
698
711
  that create the Better Auth account themselves pass `{trustEmail: true}` as the fourth argument
@@ -705,6 +718,43 @@ registered `BetterAuthApp` instance for socket sessions. With a user model (its
705
718
  The legacy JWT socket handshake loads the same user model and refuses a token whose user is
706
719
  disabled or missing (`createSocketAuthMiddleware({tokenSecret, userModel})`).
707
720
 
721
+ With a user model, the `terreno-app-user-sync` plugin guards Better Auth user creation and syncs
722
+ the app `User` as each Better Auth session is created
723
+ (`syncBetterAuthUser(userModel, betterAuthUser, oauthProvider?, {password?, trustEmail?})`):
724
+
725
+ - An app `User` with the same email that no Better Auth user has claimed is claimed only when the
726
+ email is verified, or when the request's plaintext password (`/sign-up/email`,
727
+ `/sign-in/email`) verifies against that `User`'s passport-local-mongoose hash. The hash check
728
+ runs on a detached copy, so passport's `limitAttempts` counters never change.
729
+ - Before a Better Auth user is created, the same rule runs on the provider-reported
730
+ `emailVerified` and the sign-up password. A refusal answers 409 with
731
+ `code: "VERIFY_EMAIL_TO_USE_ACCOUNT"` (an OAuth callback redirects with that `error`) and
732
+ creates nothing: no Better Auth user, account, or verification email. A `User` already bound to
733
+ another Better Auth account is refused the same way with `code: "ACCOUNT_ALREADY_EXISTS"`.
734
+ - At session creation the rule runs again; `syncBetterAuthUser` throws `APIError` 409
735
+ `VERIFY_EMAIL_TO_USE_ACCOUNT` ("Verify your email to use this account") and the sign-in answers
736
+ the same 409. No second `User` is inserted.
737
+ - The provider id of a social or generic OAuth sign-in fills `oauthProvider` on create and link.
738
+ - Emails match ignoring case (the address is matched literally, not as a pattern).
739
+ - `BetterAuthApp` without its own `userModel` adopts `TerrenoApp`'s before it creates Better Auth
740
+ (`adoptUserModel`), so the plugin is installed whenever a user model is in play. Adopting after
741
+ the instance exists throws at boot. Callers of `createBetterAuth` must pass `userModel`
742
+ themselves.
743
+ - A password-proven link that fails to save fails the sign-in with 500 `APP_USER_LINK_FAILED`.
744
+ Other sync failures are logged and the session proceeds; the session middleware retries.
745
+
746
+ With a user model, Terreno's `onPasswordReset` sets the same password on the app `User`'s
747
+ passport hash when it already has one, and bumps `tokenEpoch` either way. A `User` without a JWT
748
+ password (a social-only account) does not gain one, since `POST /auth/login` has no second
749
+ factor. It reads the new password (`newPassword`, or `password` for
750
+ the email-OTP and phone-number resets) from the parsed body of the Better Auth endpoint being
751
+ served, through Better Auth's endpoint context. Better Auth reads an HTTP body before the hook
752
+ runs, so the request it passes cannot be read again. HTTP requests and direct `auth.api` calls
753
+ both work.
754
+
755
+ `accountLinking: {mode: "never"}` refuses `POST {basePath}/link-social` with 403
756
+ `LINKING_DISABLED`. See [Account linking](../explanation/account-linking.md).
757
+
708
758
  **Social sign-in for a web app on another site (`oauthHandoff`):**
709
759
 
710
760
  `oauthHandoff: {webOrigins, callbackPaths?}` lets a bearer-mode web client on another site
@@ -729,14 +779,119 @@ Endpoints it adds, under `basePath` (default `/api/auth`):
729
779
  `callbackPaths` on one of `webOrigins`, `errorCallbackURL` (optional) is on one of
730
780
  `webOrigins`, and `trustedOrigins` allows both. It also answers 400 when `Sec-Fetch-Dest` is
731
781
  set and is not `document`. The OAuth callback then redirects to `callbackURL` with
732
- `#ott=<token>`, a single-use token that expires in three minutes.
782
+ `#ott=<token>`, a single-use token that expires in three minutes. With `oauthProxy` on a
783
+ preview, the session is created on the preview's `GET {basePath}/callback/:id/oauth-proxy`
784
+ (or the deprecated `GET {basePath}/oauth-proxy-callback`) after the production server
785
+ receives the provider's return, and that redirect carries the `#ott` instead.
733
786
  - `POST {basePath}/one-time-token/verify` — Body `{token}`. Exchanges the token once for the
734
787
  session and the `set-auth-token` header, and sets no cookie. `GET
735
788
  {basePath}/one-time-token/generate` is refused.
789
+ - `POST {basePath}/oauth-handoff/link-intent` — Bearer- or cookie-authenticated. Body
790
+ `{provider, callbackURL, errorCallbackURL?}`. Both URLs must be absolute and on one of
791
+ `webOrigins` that `trustedOrigins` allows; `callbackPaths` do not apply, since no token travels
792
+ on a link's return (400; Better Auth's origin check answers 403 first for a URL outside
793
+ `trustedOrigins`). Answers
794
+ `{token, startURL}`: a single-use link token that expires in three minutes, stored hashed in
795
+ Better Auth's verification table (`terreno-link-intent:<hash>`) and bound to the caller's
796
+ session. Answers 401 without a session and 403 `LINKING_DISABLED` when account linking is off.
797
+ - `POST {basePath}/oauth-handoff/link-start` — Form body `token=<token>`, submitted as a page
798
+ navigation (`application/x-www-form-urlencoded` only). Answers 400 when `Sec-Fetch-Dest` is set
799
+ and is not `document`, and 403 when `Origin` is missing, `null`, or not one of `webOrigins`,
800
+ without using the token. Web origins must not serve `Referrer-Policy: no-referrer` or
801
+ `same-origin`, which make the form's `Origin` `null`. Otherwise it uses the token up (400, no redirect, when unknown, used, or
802
+ expired), runs Better Auth's link flow as the token's session, sets only the OAuth state
803
+ cookie, and redirects to the provider. A link that cannot start redirects to
804
+ `errorCallbackURL` (or `callbackURL`) with `error=session_expired`, `error=provider_not_found`,
805
+ or `error=link_failed`. The provider callback links the account and redirects to `callbackURL`
806
+ with no `#ott`, since no session is created. rtk's `linkSocialWithHandoff` drives both steps;
807
+ see [Linking a provider in bearer mode](../how-to/configure-better-auth.md#linking-a-provider-in-bearer-mode).
808
+
809
+ With the opt-in `rateLimit`, every path above is in the auth bucket. Better Auth's own limiter
810
+ allows three starts, three link intents, and three link starts per ten seconds per IP.
811
+
812
+ **Sign-in methods discovery (`GET {basePath}/terreno/methods`):**
813
+
814
+ Every Better Auth server answers `GET {basePath}/terreno/methods` (default
815
+ `/api/auth/terreno/methods`) with the sign-in methods it has enabled, so a login screen renders
816
+ only those. It needs no session. Read it from a frontend with `useAuthMethods(authClient)` from
817
+ `@terreno/rtk`.
818
+
819
+ ``````json
820
+ {
821
+ "accountLinking": {"mode": "verified-email"},
822
+ "emailPassword": true,
823
+ "multiSession": false,
824
+ "passkey": false,
825
+ "providers": ["github", "google", "mock-oidc"],
826
+ "signUp": true,
827
+ "twoFactor": false
828
+ }
829
+ ``````
736
830
 
737
- With the opt-in `rateLimit`, both paths are in the auth bucket.
831
+ - `providers` — Sorted ids of every OAuth provider that can start a sign-in: the configured
832
+ `googleOAuth`, `githubOAuth`, and `appleOAuth` providers, plus every `genericOAuth` provider id
833
+ from `plugins`. The endpoint is public, so every configured provider id is published, including
834
+ generic providers meant only for integrations or internal staff. A generic provider whose
835
+ discovery failed at startup is left out.
836
+ - `emailPassword` — Better Auth's `emailAndPassword.enabled` (on unless `options` turns it off).
837
+ - `signUp` — `true` when email and password is on and `emailAndPassword.disableSignUp` is not set
838
+ (`signUp: {mode: "closed"}` sets it).
839
+ - `accountLinking.mode` — From Better Auth's `account.accountLinking`: `"never"` when
840
+ `enabled: false`, `"explicit"` when `disableImplicitLinking: true`, otherwise
841
+ `"verified-email"` (Better Auth's default: a same-email sign-in links when the provider reports
842
+ a verified email).
843
+ - `passkey`, `twoFactor`, `multiSession` — `true` when the `passkey`, `two-factor`, or
844
+ `multi-session` plugin is in the final plugin list: set by the `passkey`, `twoFactor`, and
845
+ `multiSession` config fields, or added through `plugins`. See
846
+ [Auth features](../how-to/configure-better-auth.md#auth-features).
847
+
848
+ The values come from Better Auth's final context on each request, so `options` overrides and
849
+ plugin-registered providers are reflected.
850
+
851
+ **Set a first password (`POST {basePath}/terreno/set-password`):**
852
+
853
+ Every Better Auth server serves `POST {basePath}/terreno/set-password` (default
854
+ `/api/auth/terreno/set-password`) so a signed-in user who has no password, such as one who signed
855
+ up with a provider, can add one. Better Auth's own `setPassword` is server-only. rtk's
856
+ `useAccountSecurity` calls it from the password card's "Set a password" form.
857
+
858
+ | Request | Response |
859
+ | --- | --- |
860
+ | Body `{newPassword}` with a bearer or session cookie, and no password yet | `200 {status: true}` with `cache-control: no-store`; the user can now sign in with email and password |
861
+ | The user already has a password | `409` `PASSWORD_ALREADY_SET` (change it with `/change-password`). Of two concurrent sets, one succeeds and the other gets this 409; one credential row remains |
862
+ | No session, or a revoked one (the session is read from the database, not the cookie cache) | `401` |
863
+ | The session signed in longer ago than Better Auth's `session.freshAge` (a day by default) | `403` `SESSION_NOT_FRESH`: sign in again first, so a stolen old session cannot add a lasting password |
864
+ | Shorter than `emailAndPassword.minPasswordLength` (8) or longer than `maxPasswordLength` (128) | `400` `PASSWORD_TOO_SHORT` / `PASSWORD_TOO_LONG` |
865
+ | Email and password sign-in is off | `400` `EMAIL_PASSWORD_DISABLED` |
866
+
867
+ With a `userModel`, the new password is also set on the app User's passport-local-mongoose hash
868
+ (when the model has one), so `POST /auth/login` accepts it on dual-enrolled apps, and the request
869
+ records a `password-set` security event. The app User is the one whose `betterAuthId` matches, or
870
+ else the one with the same email, but only when the Better Auth email is verified and that User is
871
+ not linked to another Better Auth user. A successful `POST {basePath}/change-password` syncs the
872
+ same way and also bumps the app User's `tokenEpoch`, so the old password stops working at
873
+ `POST /auth/login` (which has no second factor) and outstanding JWT refresh tokens stop
874
+ refreshing (see
875
+ [Self-service events](account-security.md#self-service-events)). With the opt-in `rateLimit`, the
876
+ path is in the auth bucket.
877
+
878
+ **Challenge header (`X-Terreno-Auth-Challenge`):**
879
+
880
+ With `twoFactor` or `passkey` configured, a request that sends `X-Terreno-Auth-Challenge` (`-`
881
+ when empty) gets back the challenge cookies its response sets (`two_factor`, `trust_device`, the
882
+ passkey challenge, and `dont_remember` when it comes with a two-factor challenge) in the same
883
+ header, as URL-search-params `name=value` pairs with the signed cookie values; an empty value
884
+ means the cookie expired. Only values the response sets are carried. Such a sign-in that
885
+ answers with a two-factor challenge has no `set-auth-token`. The header is listed in
886
+ `Access-Control-Expose-Headers`. On `{basePath}/two-factor/*`, `/passkey/*`, and `/sign-in/*`,
887
+ the header's challenge cookies are merged into the request's cookies unless a real cookie of
888
+ the same name is present; other names, including session cookies, are ignored. rtk's bearer mode
889
+ uses it; see
890
+ [Challenges without cookies](../explanation/authentication.md#challenges-without-cookies).
738
891
 
739
892
  **Endpoints (when enabled):**
893
+ - `GET /api/auth/terreno/methods` — Enabled sign-in methods (public)
894
+ - `POST /api/auth/terreno/set-password` — Add a first password for the signed-in user
740
895
  - `POST /api/auth/signup/email` — Email/password signup
741
896
  - `POST /api/auth/signin/email` — Email/password signin
742
897
  - `GET /api/auth/signin/{provider}` — Initiate OAuth (google, github, apple)
@@ -1251,6 +1406,14 @@ source's deny posture for the user. Under every policy:
1251
1406
  role mappings, membership lookups). Their cold failure then drops only their grants and local
1252
1407
  roles apply. The built-in organization membership source (`createAccess({organizations: true})`)
1253
1408
  sets it.
1409
+ - Dropped grants make a user look less privileged, which is safe for the user being authorized
1410
+ but not for the target of "may this actor act on that user?". Pass
1411
+ `access.getPermissions({user, requireComplete: true})` for such a target: when a failed source's
1412
+ grants were dropped (not served from a `"use-stale"` cache), it throws 503
1413
+ `Permission source unavailable` instead of returning the partial set. The account-security
1414
+ reach check, the RBAC user-write guard, and the RoleManager target-privilege rule
1415
+ (`access.roles.assign`, `unassign`, `previewAssignment`, and `assertCanModifyUser`, so also
1416
+ `PUT /rbac/users/:id/roles` and admin user writes) resolve their target this way.
1254
1417
 
1255
1418
  ## Request Validation
1256
1419
 
@@ -1474,6 +1637,27 @@ See [Schema metadata explanation](../explanation/schema-metadata.md).
1474
1637
 
1475
1638
  ## Middleware
1476
1639
 
1640
+ ### Error middleware
1641
+
1642
+ `TerrenoApp` ends the app with `apiUnauthorizedMiddleware`, `apiErrorMiddleware`,
1643
+ and `apiFallthroughErrorMiddleware`, in that order. `apiErrorMiddleware` answers with a JSONAPI
1644
+ error body for these errors:
1645
+
1646
+ | Error | Status | Title |
1647
+ |-------|--------|-------|
1648
+ | `APIError` (or a subclass) | its `status` | its `title` |
1649
+ | Mongoose `ValidationError` or `CastError` | 400 | `Validation failed`, with `meta.fields` |
1650
+ | Malformed JSON or urlencoded body (`entity.parse.failed`) | 400 | `Malformed request body` |
1651
+ | Body over the parser `limit` (`entity.too.large`, `parameters.too.many`) | 413 | `Request body too large` |
1652
+ | Unsupported body charset or encoding | 415 | `Unsupported request body encoding` |
1653
+ | Aborted or truncated body (`request.aborted`, `request.size.invalid`) | 400 | `Request body incomplete` |
1654
+
1655
+ Body-parser errors keep the parser's message in `detail`, are logged at `warn`, and skip Sentry.
1656
+ `bodyParserErrorToAPIError(err)` and `mongooseErrorToAPIError(err)` are exported for custom error
1657
+ handlers; each returns `null` for errors it does not handle. Anything else reaches
1658
+ `apiFallthroughErrorMiddleware`, which logs `Fallthrough error: <error>` with the stack at `error`
1659
+ level and answers `500 {"status":500,"title":"Internal server error"}`.
1660
+
1477
1661
  ### openApiEtagMiddleware
1478
1662
 
1479
1663
  Adds ETag support to the `/openapi.json` endpoint for efficient caching.
@@ -1520,7 +1704,8 @@ background job can be followed across many log lines, both in plain-text console
1520
1704
  transports (Google Cloud Logging, Sentry).
1521
1705
 
1522
1706
  > Never use `console.log` for permanent server logs — use `logger` or a scoped logger. Request
1523
- > logging already redacts password, token, secret, and code fields in bodies and query strings;
1707
+ > logging already redacts password, token, secret, code, note, and reason fields in bodies and
1708
+ > query strings;
1524
1709
  > never log secrets, full auth tokens, or raw passwords.
1525
1710
 
1526
1711
  ### Quick reference
@@ -1756,19 +1941,98 @@ To correlate with Cloud Trace, ensure clients or load balancers send `x-cloud-tr
1756
1941
 
1757
1942
  ### Development browser-log ingestion
1758
1943
 
1759
- `TerrenoApp` mounts `POST /__terreno/browser-logs` only when `NODE_ENV=development`, or when
1760
- `TERRENO_BROWSER_LOGS=true` in another non-production environment. Production always returns 404;
1761
- set `TERRENO_BROWSER_LOGS=false` to disable the route in development.
1944
+ `POST /__terreno/browser-logs` lets a running client write its console errors and warnings to a
1945
+ file on the backend, where the local MCP `read_logs` and `last_error` tools can read them. The
1946
+ Terreno dev console logger (`installTerrenoDevConsoleLogger`) is the client that sends them. The
1947
+ route lives in `api/src/browserLogsRoute.ts`.
1948
+
1949
+ **When the route exists.** `TerrenoApp` calls `addTerrenoDevBrowserLogsRoute(app)` while it builds
1950
+ the app. `setupServer` does not register the route. An app built on `setupServer` can call the
1951
+ exported `addTerrenoDevBrowserLogsRoute(app)` itself. The check runs once, when the route is added:
1952
+
1953
+ | `NODE_ENV` | `TERRENO_BROWSER_LOGS` | Route |
1954
+ |------------|------------------------|-------|
1955
+ | `production` | any value, including `true` | Not registered |
1956
+ | anything else, or unset | `false` or `0` | Not registered |
1957
+ | anything else, or unset | `true` or `1` | Registered |
1958
+ | `development` | unset or any other value | Registered |
1959
+ | not `development`, or unset | unset or any other value | Not registered |
1960
+
1961
+ An unregistered route falls through to the app's normal not-found handling, so the request gets a
1962
+ 404. Production always returns 404. `TERRENO_BROWSER_LOGS=true` cannot turn the route on there.
1963
+ `NODE_ENV` unset counts as "not `development`", so the route is off unless
1964
+ `TERRENO_BROWSER_LOGS=true` is set.
1965
+
1966
+ **Who may post.** The route accepts a request when `req.user` is set or when the socket's remote
1967
+ address is loopback (`127.0.0.1`, `::1`, or `::ffff:127.0.0.1`). Any other request gets 403. The
1968
+ check reads the socket address, not `X-Forwarded-For`. `TerrenoApp` mounts the route after JWT
1969
+ decoding, the Better Auth session middleware, and the optional rate limiter. Remote clients can
1970
+ authenticate the usual way, and configured API limits apply to ingestion too.
1971
+
1972
+ **Request body.**
1973
+
1974
+ ``````json
1975
+ {"entries": [{"level": "error", "message": "Cannot read properties of undefined", "stack": "TypeError: ...", "timestamp": "2026-10-12T00:29:20.067+00:00"}]}
1976
+ ``````
1977
+
1978
+ | Field | Type | Handling |
1979
+ |-------|------|----------|
1980
+ | `entries` | array | Required and non-empty. At most 100 items. |
1981
+ | `entries[].level` | string | Defaults to `info`. Truncated to 32 characters. |
1982
+ | `entries[].message` | any | A string is kept as is. Any other value is JSON-stringified. Truncated to 8,000 characters. |
1983
+ | `entries[].stack` | string | Optional. Truncated to 16,000 characters. Left out of the line when it is not a string. |
1984
+ | `entries[].timestamp` | string | Kept as sent. When missing or empty, the server uses Luxon `DateTime.now().toISO()`. |
1985
+
1986
+ Items that are not objects are skipped, and the request still succeeds.
1987
+
1988
+ **Responses.**
1989
+
1990
+ | Status | When |
1991
+ |--------|------|
1992
+ | `204` | The batch was accepted and written. |
1993
+ | `400` | The body has no `entries` array, or the array is empty: `{"error": "Expected { entries: [...] }"}`. |
1994
+ | `403` | The client is neither loopback nor authenticated. |
1995
+ | `413` | The parsed body is over 256 kB, or `entries` has more than 100 items. |
1996
+ | `404` | The route is not registered (see the table above). |
1997
+
1998
+ The route has its own `express.json({limit: "256kb"})` parser. Under `TerrenoApp`, the app-wide
1999
+ JSON parser runs first with a larger limit, so the route measures the parsed body itself and
2000
+ answers 413.
1762
2001
 
1763
- The route accepts loopback requests for local browser development. Non-loopback clients must
1764
- authenticate through the app's normal JWT or Better Auth middleware. `TerrenoApp` mounts the route
1765
- after its optional rate limiter, so configured API limits also apply to ingestion.
2002
+ **Output file.** Each accepted item becomes one JSON line in
2003
+ `<process.cwd()>/.terreno/logs/browser.log`. The directory is created if needed, and the file is
2004
+ created with mode `0600`. The path follows the backend's working directory. In this monorepo,
2005
+ `bun run backend:dev` runs in `example-backend/`, so the file is
2006
+ `example-backend/.terreno/logs/browser.log`. A line looks like this:
1766
2007
 
1767
- The request body is `{entries: Array<{level?, message?, stack?, timestamp?}>}`. Batches are capped
1768
- at 100 entries and 256 kB. Accepted rows are truncated to bounded field lengths and appended as
1769
- mode-`0600` JSONL under `<backend cwd>/.terreno/logs/browser.log`. The file restarts with the latest
1770
- batch before it would exceed 5 MB. The local MCP `read_logs` / `last_error` tools and
1771
- `terreno logs --sources browser` consume that file.
2008
+ ``````json
2009
+ {"level":"warn","message":"TASK-1.2 direct POST probe","source":"browser","timestamp":"2026-10-12T00:29:20.067+00:00"}
2010
+ ``````
2011
+
2012
+ Each line has `level`, `message`, `source` (always `"browser"`), `stack` (only when present), and
2013
+ `timestamp`, in that order. There is no rotation and no backup file. If a batch would push the file
2014
+ past 5 MB, the file is overwritten with that batch and all earlier lines are lost.
2015
+
2016
+ **Who reads it.** The local MCP `read_logs` tool (source `browser`), the `last_error` tool, and
2017
+ `terreno logs --sources browser` / `terreno logs last-error` read `browser.log` from each of these
2018
+ directories that exists under the project root: `backend/.terreno/logs`,
2019
+ `example-backend/.terreno/logs`, and `.terreno/logs`. Backend logs go to `app.log` in the same
2020
+ directory. That file comes from the Winston JSONL transport, which `TERRENO_LOG_FILE` switches on
2021
+ or off (see [Environment variables](environment-variables.md#observability)).
2022
+
2023
+ **Client behavior.** The Terreno dev console logger runs only in development builds (`__DEV__`). It
2024
+ captures `console.error`, `console.warn`, uncaught errors, and unhandled rejections. It sends them
2025
+ in batches of up to 100, about 400 ms after the first one is queued. If a post fails, it retries
2026
+ once and then drops the batch without reporting anything. A missing route (404) or a 403 therefore
2027
+ shows up only as an empty `browser.log`. Uncaught errors and unhandled rejections whose value is
2028
+ an `Error` carry a `stack`. `console.error` and `console.warn` entries do not. An error passed to
2029
+ React Native's `ErrorUtils` handler is logged only after the previous handler returns. On web the
2030
+ default handler rethrows, so the logger skips that error: its caller may still catch it, as Expo's
2031
+ web `import()` does while it loads a split bundle. If nothing catches it, the window `error` or
2032
+ `unhandledrejection` listener logs it.
2033
+
2034
+ On the client, `installTerrenoDevConsoleLogger({baseUrl})` from `@terreno/syncdb` posts to this
2035
+ route in `__DEV__` builds. The `@terreno/rtk` export of the same name is a deprecated re-export.
1772
2036
 
1773
2037
  ### Message style
1774
2038
 
@@ -1871,6 +2135,168 @@ setupServer({
1871
2135
  - Optional/configurable functionality
1872
2136
  - Clean separation of concerns
1873
2137
 
2138
+ ## Consent forms (ConsentApp)
2139
+
2140
+ Versioned consent forms, user responses, and the consent gate. Concepts:
2141
+ [Consent forms](../explanation/consent-forms.md).
2142
+
2143
+ ```typescript
2144
+ import {ConsentApp, TerrenoApp} from "@terreno/api";
2145
+
2146
+ new TerrenoApp({userModel: User})
2147
+ .register(new ConsentApp({accessControl: access, auditTrail: true}))
2148
+ .start();
2149
+ ```
2150
+
2151
+ | Option | Default | Description |
2152
+ | --- | --- | --- |
2153
+ | `accessControl` | — | RBAC access control. When set, roles granting `consentResponse:signOnBehalf` may sign provisionally for other users. When unset, only `user.admin` may, and register logs a warning |
2154
+ | `auditTrail` | `false` | Store IP address, user agent, and content snapshots on responses, and mount `GET /consents/audit/:userId` |
2155
+ | `resolveConsentForms` | — | `(user, forms) => forms` narrows the active forms that apply to a user |
2156
+ | `supportedLocales` | — | Accepted for app configuration; `ConsentApp` does not read it |
2157
+ | `aiConfig` | — | `{generateContent, translateContent}` enables `POST /consent-forms/generate` and `/translate` |
2158
+
2159
+ ### Endpoints
2160
+
2161
+ | Method + path | Who | Description |
2162
+ | --- | --- | --- |
2163
+ | `GET/POST/PATCH/DELETE /consent-forms` | admin | Form CRUD |
2164
+ | `POST /consent-forms/:id/publish` | admin | Copy the form to `version + 1`, activate it, deactivate other versions |
2165
+ | `GET /consent-responses`, `GET /consent-responses/:id` | admin | Read responses, populated with form and user |
2166
+ | `POST /consent-responses/provisional` | sign-on-behalf | Record a provisional signature for another user |
2167
+ | `GET /consent-responses/status?userId=` | sign-on-behalf | Consent status of each form that applies to a user |
2168
+ | `POST /consent-responses/:id/void` | sign-on-behalf | Void a provisional signature made in error |
2169
+ | `GET /consents/pending` | user | Active forms the user has not answered themselves at the current version |
2170
+ | `POST /consents/respond` | user | `{consentFormId, agreed, locale, checkboxValues?, signature?}` |
2171
+ | `GET /consents/my` | user | The user's own responses with form data |
2172
+ | `GET /consents/audit/:userId` | admin | Full audit history (only with `auditTrail`) |
2173
+
2174
+ "sign-on-behalf" means an authenticated user with `consentResponse:signOnBehalf` when
2175
+ `accessControl` is set, otherwise `user.admin`. Others get 405.
2176
+
2177
+ ### `POST /consent-responses/provisional`
2178
+
2179
+ Body: `{userId, consentFormId, note, locale?}`. `note` is required, trimmed, and at most 2000
2180
+ characters. `locale` defaults to the form's `defaultLocale`.
2181
+
2182
+ Creates a `ConsentResponse` for `userId` with `provisional: true`, `agreed: true`,
2183
+ `agreedAt` (server time), `provisionalSignedBy` (the caller), `provisionalNote`, and
2184
+ `formVersionSnapshot`. With `auditTrail`, it also stores `contentSnapshot`,
2185
+ `provisionalSignerIp`, and `provisionalSignerUserAgent`. `ipAddress`, `userAgent`,
2186
+ `signature`, and `signedAt` stay unset: they describe the user's own device and signature.
2187
+
2188
+ Returns `{data: ConsentResponse}` with `provisionalSignedBy` populated as `{_id, name, email}`.
2189
+ The form stays in the target user's `/consents/pending` until they respond themselves.
2190
+
2191
+ | Status | When |
2192
+ | --- | --- |
2193
+ | 400 | Missing or invalid ids, blank or too-long note, `userId` is the caller, inactive form, or the form is filtered out for the target user by `resolveConsentForms` |
2194
+ | 404 | Unknown user or form |
2195
+ | 405 | The caller may not sign on behalf of others |
2196
+
2197
+ With `AuditApp` registered, each provisional signature writes an `AuditEvent`
2198
+ (`modelName: "ConsentResponse"`, `operation: "create"`, `after: {action: "signOnBehalf",
2199
+ userId, consentFormId}`, `actorId`: the signer).
2200
+
2201
+ `/consent-responses` and its actions are in OpenAPI, so `bun run sdk` generates
2202
+ `useConsentresponsesProvisionalMutation`, `useConsentresponsesStatusQuery`, and
2203
+ `useConsentresponsesVoidMutation`. The other
2204
+ ConsentApp routes are not in OpenAPI.
2205
+
2206
+ ### `POST /consent-responses/:id/void`
2207
+
2208
+ Body: `{reason}` (required, trimmed, at most 2000 characters). Sets `voidedAt`, `voidedBy`
2209
+ (the caller), and `voidReason` on a provisional response and returns
2210
+ `{data: ConsentResponse}` with `provisionalSignedBy` and `voidedBy` populated. The record is
2211
+ never deleted. A voided signature no longer counts: `getConsentStatuses` ignores it, and
2212
+ `/consents/pending` drops its `provisionalSignature`.
2213
+
2214
+ | Status | When |
2215
+ | --- | --- |
2216
+ | 400 | Blank or too-long reason, the response is not provisional, or it is already voided |
2217
+ | 404 | Unknown response |
2218
+ | 405 | The caller may not sign on behalf of others |
2219
+
2220
+ With `AuditApp` registered, each void writes an `AuditEvent` (`operation: "update"`,
2221
+ `verb: "updated"`, `after: {action: "voidProvisional", userId, consentFormId}`).
2222
+
2223
+ ### `GET /consent-responses/status` and `getConsentStatuses`
2224
+
2225
+ `GET /consent-responses/status?userId=<id>` returns `{data: ConsentStatusRow[]}`, one row per
2226
+ active form that applies to the user (after `resolveConsentForms`), in form order. 400 when
2227
+ `userId` is missing or malformed, 404 for an unknown user, 405 for callers who may not sign
2228
+ on behalf of others. With `AuditApp` registered, each read writes an `AuditEvent`
2229
+ (`operation: "read"`, `verb: "viewed"`, `after: {action: "viewStatus", userId}`).
2230
+
2231
+ The same rows come from the exported helper, for server code that needs the "considered
2232
+ signed" answer:
2233
+
2234
+ ```typescript
2235
+ import {getConsentStatuses} from "@terreno/api";
2236
+
2237
+ const rows = await getConsentStatuses({userId, resolveConsentForms});
2238
+ const allConsented = rows.every((row) => row.isConsented);
2239
+ ```
2240
+
2241
+ ```typescript
2242
+ interface ConsentStatusRow {
2243
+ form: ConsentStatusForm; // the form as clients receive it: string ids, content as an object
2244
+ status: "pending" | "provisional" | "signed" | "declined";
2245
+ isConsented: boolean; // true for "signed" and "provisional"
2246
+ respondedAt?: Date; // agreedAt of the response that decided the status
2247
+ provisional?: {
2248
+ responseId: string;
2249
+ signedAt: Date;
2250
+ signedBy: {id: string; name?: string; email?: string} | null;
2251
+ note?: string;
2252
+ };
2253
+ }
2254
+ ```
2255
+
2256
+ Only non-voided responses to each form's current version count:
2257
+
2258
+ 1. The user's own latest response decides: `agreed` → `signed`, declined → `declined`.
2259
+ 2. Otherwise a provisional response → `provisional`.
2260
+ 3. Otherwise → `pending`.
2261
+
2262
+ `provisional` is set whenever a provisional response exists for the current version, even
2263
+ when the user's own answer decides the status. The helper throws `APIError` 400 for a
2264
+ malformed `userId` and 404 for an unknown user.
2265
+
2266
+ ### Admin columns
2267
+
2268
+ The "Consent Responses" admin model lists `provisional`, offers a "Provisional" boolean
2269
+ filter, and populates `provisionalSignedBy` (`name`, `email`).
2270
+
2271
+ ### Provisional data on user-facing payloads
2272
+
2273
+ The user sees who signed on their behalf and when. They never see the signer's note or the
2274
+ signer's request metadata.
2275
+
2276
+ | Endpoint | Adds |
2277
+ | --- | --- |
2278
+ | `GET /consents/pending` | `provisionalSignature?: {signedAt, signedByName}` on each form, from the latest non-voided provisional response for the form's current version. Absent when there is none. Provisional responses never remove a form from this list |
2279
+ | `GET /consents/my` | `provisional: boolean`, `provisionalSignedBy: {name} \| null`, and `voidedAt` on each entry. No note, IP, or user agent of the signer, and no void reason |
2280
+ | `GET /consents/audit/:userId` | `provisional`, `provisionalSignedBy: {_id, name, email} \| null`, `provisionalNote`, `provisionalSignerIp`, `provisionalSignerUserAgent`, `voidedAt`, `voidedBy: {_id, name, email} \| null`, `voidReason` |
2281
+
2282
+ `signedByName` falls back to the signer's email, then `"Staff"`.
2283
+
2284
+ ### RBAC
2285
+
2286
+ `terrenoStatements.consentResponse` is `["list", "read", "signOnBehalf"]`. The built-in
2287
+ `admin` role grants `consentResponse: ["signOnBehalf"]`. Seeding adds it to existing unsealed
2288
+ `admin` roles without removing other grants. `superadmin` has every action.
2289
+
2290
+ ### Response fields
2291
+
2292
+ | Field | Notes |
2293
+ | --- | --- |
2294
+ | `provisional` | `false` by default; `true` only on responses from `/consent-responses/provisional` |
2295
+ | `provisionalSignedBy` | User ref; required when `provisional` |
2296
+ | `provisionalNote` | Required when `provisional`; max 2000 characters |
2297
+ | `provisionalSignerIp`, `provisionalSignerUserAgent` | Signer's request metadata, with `auditTrail` |
2298
+ | `voidedAt`, `voidedBy`, `voidReason` | Set only by the void action |
2299
+
1874
2300
  ## In-app notifications
1875
2301
 
1876
2302
  Owner-scoped inbox and channel preferences via `NotificationsApp` (`ConsentApp` pattern).