@terreno/mcp 57.16.2 → 57.17.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 (97) hide show
  1. package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
  2. package/dist/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
  3. package/dist/docs/upgrades/57.17.0.md +126 -0
  4. package/dist/docs/versioned/next/approval-rubric.md +171 -0
  5. package/dist/docs/versioned/next/explanation/admin-interface.md +5 -1
  6. package/dist/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
  7. package/dist/docs/versioned/next/explanation/authentication.md +60 -10
  8. package/dist/docs/versioned/next/explanation/example-coverage.md +2 -1
  9. package/dist/docs/versioned/next/explanation/model-router-actions.md +32 -1
  10. package/dist/docs/versioned/next/how-to/README.md +1 -0
  11. package/dist/docs/versioned/next/how-to/add-github-oauth.md +49 -16
  12. package/dist/docs/versioned/next/how-to/admin-add-model.md +4 -1
  13. package/dist/docs/versioned/next/how-to/ai-feature-development.md +5 -0
  14. package/dist/docs/versioned/next/how-to/background-jobs.md +18 -5
  15. package/dist/docs/versioned/next/how-to/circleci.md +39 -3
  16. package/dist/docs/versioned/next/how-to/configure-better-auth.md +283 -5
  17. package/dist/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
  18. package/dist/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
  19. package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
  20. package/dist/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
  21. package/dist/docs/versioned/next/how-to/install-agent-skills.md +8 -1
  22. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
  23. package/dist/docs/versioned/next/how-to/password-reset.md +8 -2
  24. package/dist/docs/versioned/next/how-to/product-announcements.md +5 -3
  25. package/dist/docs/versioned/next/how-to/rate-limiting.md +2 -2
  26. package/dist/docs/versioned/next/how-to/run-tests-locally.md +50 -0
  27. package/dist/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
  28. package/dist/docs/versioned/next/how-to/websocket-integration.md +42 -9
  29. package/dist/docs/versioned/next/how-to/whats-new-banner.md +55 -0
  30. package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
  31. package/dist/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
  32. package/dist/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
  33. package/dist/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
  34. package/dist/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
  35. package/dist/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
  36. package/dist/docs/versioned/next/reference/admin-backend.md +46 -0
  37. package/dist/docs/versioned/next/reference/admin-config.md +2 -2
  38. package/dist/docs/versioned/next/reference/ai.md +48 -4
  39. package/dist/docs/versioned/next/reference/api.md +410 -12
  40. package/dist/docs/versioned/next/reference/blocks.md +1 -1
  41. package/dist/docs/versioned/next/reference/comms.md +19 -1
  42. package/dist/docs/versioned/next/reference/environment-variables.md +6 -1
  43. package/dist/docs/versioned/next/reference/feature-flags.md +1 -1
  44. package/dist/docs/versioned/next/reference/jobs.md +8 -6
  45. package/dist/docs/versioned/next/reference/legacy/rtk.md +145 -18
  46. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
  47. package/dist/docs/versioned/next/reference/syncdb.md +64 -3
  48. package/dist/docs/versioned/next/reference/ui.md +23 -1
  49. package/package.json +3 -3
  50. package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
  51. package/src/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
  52. package/src/docs/upgrades/57.17.0.md +126 -0
  53. package/src/docs/versioned/next/approval-rubric.md +171 -0
  54. package/src/docs/versioned/next/explanation/admin-interface.md +5 -1
  55. package/src/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
  56. package/src/docs/versioned/next/explanation/authentication.md +60 -10
  57. package/src/docs/versioned/next/explanation/example-coverage.md +2 -1
  58. package/src/docs/versioned/next/explanation/model-router-actions.md +32 -1
  59. package/src/docs/versioned/next/how-to/README.md +1 -0
  60. package/src/docs/versioned/next/how-to/add-github-oauth.md +49 -16
  61. package/src/docs/versioned/next/how-to/admin-add-model.md +4 -1
  62. package/src/docs/versioned/next/how-to/ai-feature-development.md +5 -0
  63. package/src/docs/versioned/next/how-to/background-jobs.md +18 -5
  64. package/src/docs/versioned/next/how-to/circleci.md +39 -3
  65. package/src/docs/versioned/next/how-to/configure-better-auth.md +283 -5
  66. package/src/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
  67. package/src/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
  68. package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
  69. package/src/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
  70. package/src/docs/versioned/next/how-to/install-agent-skills.md +8 -1
  71. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
  72. package/src/docs/versioned/next/how-to/password-reset.md +8 -2
  73. package/src/docs/versioned/next/how-to/product-announcements.md +5 -3
  74. package/src/docs/versioned/next/how-to/rate-limiting.md +2 -2
  75. package/src/docs/versioned/next/how-to/run-tests-locally.md +50 -0
  76. package/src/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
  77. package/src/docs/versioned/next/how-to/websocket-integration.md +42 -9
  78. package/src/docs/versioned/next/how-to/whats-new-banner.md +55 -0
  79. package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
  80. package/src/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
  81. package/src/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
  82. package/src/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
  83. package/src/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
  84. package/src/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
  85. package/src/docs/versioned/next/reference/admin-backend.md +46 -0
  86. package/src/docs/versioned/next/reference/admin-config.md +2 -2
  87. package/src/docs/versioned/next/reference/ai.md +48 -4
  88. package/src/docs/versioned/next/reference/api.md +410 -12
  89. package/src/docs/versioned/next/reference/blocks.md +1 -1
  90. package/src/docs/versioned/next/reference/comms.md +19 -1
  91. package/src/docs/versioned/next/reference/environment-variables.md +6 -1
  92. package/src/docs/versioned/next/reference/feature-flags.md +1 -1
  93. package/src/docs/versioned/next/reference/jobs.md +8 -6
  94. package/src/docs/versioned/next/reference/legacy/rtk.md +145 -18
  95. package/src/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
  96. package/src/docs/versioned/next/reference/syncdb.md +64 -3
  97. package/src/docs/versioned/next/reference/ui.md +23 -1
@@ -110,7 +110,7 @@ new TerrenoApp({
110
110
  | Option | Default | Meaning |
111
111
  |--------|---------|---------|
112
112
  | `store` | `"memory"` | `"memory"` \| `"redis"` \| `"mongo"` |
113
- | `limits.authMax` | 20 / 15 min | login, signup, refresh, OTP, GitHub OAuth, Better Auth sign-in / sign-up / password reset / OAuth callback |
113
+ | `limits.authMax` | 20 / 15 min | login, signup, refresh, OTP, GitHub OAuth, Better Auth sign-in / sign-up / password reset / OAuth callback / OAuth handoff |
114
114
  | `betterAuthBasePath` | `BetterAuthApp` `config.basePath` or `/api/auth` | Prefix used to classify Better Auth credential routes |
115
115
  | `limits.apiMax` | 600 / 15 min | modelRouter and other HTTP |
116
116
  | `trustProxy` | `false` | Express `trust proxy`. Use `1` on Cloud Run. Unauthenticated key is `req.ip` |
@@ -228,6 +228,40 @@ export const todoRouter = modelRouter("/todos", Todo, {
228
228
  Do not add `endpoints: (router) => { router.get(...) }` when an action fits.
229
229
  Action config supports `allowAnonymous: true` as a per-action override; it does not change authentication for CRUD or sibling actions.
230
230
 
231
+ | Option | Meaning |
232
+ | --- | --- |
233
+ | `method` | `"GET"` or `"POST"` |
234
+ | `permissions` | Required unless `access` is set. `[]` disables the action (405) |
235
+ | `access` | RBAC `{resource, action}` checked with the router's `accessControl` |
236
+ | `allowAnonymous` | Accept unauthenticated calls for this action only |
237
+ | `body` / `query` | Zod schemas; failures are 400 `Validation failed` with `meta.fields` |
238
+ | `response` | Zod schema for OpenAPI |
239
+ | `status` | Success status (default 200) |
240
+ | `summary` / `description` / `tag` | OpenAPI metadata |
241
+ | `mcp` | `{name, description?}` — also expose the action as an MCP tool named `name` |
242
+ | `unwrapResponse` | REST only. Send the return value as the body instead of `{data: …}`; `undefined` sends an empty body |
243
+
244
+ An action with `mcp` runs the same permission chain (including RBAC `access`) over `/mcp`. Its input is `{id, ...body}` for an instance POST, `{id, ...query}` for an instance GET, and the body or query alone for a collection action, so `body` / `query` must be a `z.object()`, and an instance action's schema cannot define `id`. A collection action's `id` field passes through to its body or query. The handler receives `req.isMCPRequest === true` and a `res` that throws if touched. See [modelRouter actions → MCP tools](../explanation/model-router-actions.md#mcp-tools).
245
+
246
+ ## modelRouter write scope
247
+
248
+ Create and update check the document as it will be written, not only as it was loaded. REST,
249
+ MCP tools, and sync mutations all share this check. A non-admin caller gets `403` with title
250
+ `Write scope violation on {Model}` when:
251
+
252
+ - The router uses `Permissions.IsOwner` or `Permissions.IsOwnerOrReadOnly` on any method, and
253
+ a create (after `preCreate`) sets `ownerId` to someone other than the caller. A create that
254
+ leaves `ownerId` unset is still allowed.
255
+ - The router's `update` permissions use `IsOwner` or `IsOwnerOrReadOnly`, and the update would
256
+ set `ownerId` to someone else or clear it.
257
+ - The router uses RBAC `access`, and the written document fails the role check or the router
258
+ `access.scope` for that method. For example, with `scope: OwnerScope()`, a create or update
259
+ must leave `ownerId` set to the caller. Entries in `access.also` other than `IsOwner` /
260
+ `IsOwnerOrReadOnly` run only against the loaded document.
261
+
262
+ Admins pass `IsOwner`, so they can still reassign records. Admin-window sync writes skip this
263
+ check, as they skip the other modelRouter permissions.
264
+
231
265
  ## modelRouter list queries
232
266
 
233
267
  `GET /{path}` accepts filters on fields listed in `queryFields` (qs syntax, so operators work:
@@ -252,6 +286,35 @@ it can never be stripped and silently ignored:
252
286
  `@terreno/syncdb` raises this as `QueryFieldNotAllowedError` (see
253
287
  [Query windows](syncdb.md#query-windows)).
254
288
 
289
+ `sort` is allowlisted the same way. A client sort (`?sort=-created name`, or the object form
290
+ `?sort[created]=-1`) may name only fields in `queryFields`, fields named in the router's own
291
+ `sort`, and fields in `sortFields`. Any other field, or a repeated `sort` parameter, returns 400
292
+ before the query runs:
293
+
294
+ ```json
295
+ {
296
+ "status": 400,
297
+ "code": "sort-field-not-allowed",
298
+ "title": "Sort field not allowed",
299
+ "source": {"parameter": "sort"},
300
+ "meta": {"sortField": "priority", "model": "Todo", "allowedSortFields": ["completed", "created", "ownerId"]}
301
+ }
302
+ ```
303
+
304
+ Use `sortFields` for columns clients may order by but not filter on:
305
+
306
+ ```typescript
307
+ modelRouter("/todos", Todo, {
308
+ queryFields: ["completed", "ownerId"],
309
+ sort: "-created",
310
+ sortFields: ["title", "priority"],
311
+ });
312
+ ```
313
+
314
+ Sort order reveals a field's values (one compound sort exposes a hidden boolean), so keep
315
+ fields hidden from some readers (RBAC field views, `responseHandler` stripping) out of
316
+ `queryFields` and `sortFields`. `AdminApp` passes each model's `sortableFields` as `sortFields`.
317
+
255
318
  ## modelRouter array operations
256
319
 
257
320
  When a model has at least one array field, `modelRouter` provides subroutes for adding,
@@ -263,6 +326,11 @@ updating, and removing array items:
263
326
  | `PATCH` | `/:id/:field/:itemId` | Replace or merge an item |
264
327
  | `DELETE` | `/:id/:field/:itemId` | Remove an item |
265
328
 
329
+ Each operation runs like a `PATCH` that replaces the whole field. The transform, `preUpdate`,
330
+ 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
+
266
334
  `:id` must be an ObjectId. Any other id leaves this router so a later route on the same
267
335
  prefix can handle it. `POST /ai/observability/evaluators` is not an array push on a model
268
336
  mounted at `/ai`.
@@ -286,7 +354,7 @@ const todoRouter = modelRouter("/todos", Todo, {
286
354
  });
287
355
  ```
288
356
 
289
- Cross-model tools use `registerMCPTool` (see the example backend's `users_todo_statuses`). How-to: [Expose MCP tools](../how-to/expose-mcp-tools.md). In-process Vercel AI SDK wrappers: `getMCPTools` from `@terreno/ai`.
357
+ Named operations become tools with `mcp: {name}` on a `collectionActions` / `instanceActions` entry (see [modelRouter actions](#modelrouter-actions)). Cross-model tools use `registerMCPTool` (see the example backend's `users_todo_statuses`). How-to: [Expose MCP tools](../how-to/expose-mcp-tools.md). In-process Vercel AI SDK wrappers: `getMCPTools` from `@terreno/ai`.
290
358
 
291
359
  Create, update, and delete tools share REST permission, hook, and persistence semantics: they call the same `executeCreate` / `executeUpdate` / `executeDelete` pipeline. MCP error results use `APIError.title` (for example `Create not allowed`, `preCreate hook error`). User-role stripping for RBAC User writes happens in the executor after hooks; MCP supplies the registry `modelName` for that check. List and read stay MCP handlers. `excludeFields` and `mcpResponseHandler` still apply after the executor returns. Invalid ids on instance writes 404 only when the document `_id` cannot be cast, not when a populate ref fails.
292
360
 
@@ -304,7 +372,7 @@ const {mcpServiceToken, token} = await McpServiceToken.issueFor(
304
372
  // Store or show `token` once. Only its SHA-256 hash is persisted.
305
373
  ```
306
374
 
307
- The token begins with `mcp_` followed by 32 random bytes encoded as hex. `verify(token)` returns `null` for unknown, expired, or revoked tokens. `revokeForUser(user, tokenId)` records `revokedAt`, and `countActiveForUser(userId)` excludes expired or revoked records. Document `deleteOne` (admin DELETE) also sets `revokedAt` and leaves the row for audit. Never return or log `tokenHash` or the plaintext token outside the initial issue result.
375
+ The token begins with `mcp_` followed by 32 random bytes encoded as hex. `issueFor` records the user's `tokenEpoch` (pass it on the user argument; default 0). `/mcp` refuses a token whose recorded epoch differs from the user's current `tokenEpoch` and sets its `revokedAt`, so a password reset, Better Auth password change, or session revocation ends every key minted before it. A row stored without `tokenEpoch` counts as epoch 0. `revokeOutdatedForUser(user)` revokes the user's keys from earlier epochs; the list and create routes call it so the list shows them as revoked. `verify(token)` returns `null` for unknown, expired, or revoked tokens. `revokeForUser(user, tokenId)` records `revokedAt`, and `countActiveForUser(userId)` excludes expired or revoked records. Document `deleteOne` (admin DELETE) also sets `revokedAt` and leaves the row for audit. Never return or log `tokenHash` or the plaintext token outside the initial issue result.
308
376
 
309
377
  Self-serve HTTP routes live at `/mcp/service-tokens`. `TerrenoApp` mounts them when `mcpServiceTokens` is enabled, passing its OpenAPI bundle. You can also call `addMcpServiceTokenRoutes(app, {publicMcpUrl, openApi})` yourself. They require session or JWT auth and **reject** `Authorization: Bearer mcp_…` so a service token cannot mint or list tokens.
310
378
 
@@ -316,6 +384,61 @@ Self-serve HTTP routes live at `/mcp/service-tokens`. `TerrenoApp` mounts them w
316
384
 
317
385
  `expiresAt` is an optional ISO-8601 datetime (Luxon `DateTime.fromISO`). Omit it for a token that does not expire. Create returns `mcpUrl` from `publicMcpUrl`, else `BETTER_AUTH_URL`, else the request host, with `/mcp` appended when missing. An 11th **active** token for the same user is `400`. Revoke and cross-owner access are `404`. List includes revoked rows for the owner so the UI can show history. Default page size is 100 (maximum 100).
318
386
 
387
+ ### MCP OAuth
388
+
389
+ Opt-in OAuth 2.1 authorization server for `/mcp`, so remote MCP clients (Claude.ai custom connectors, ChatGPT apps) can connect to an app that signs users in with Terreno JWTs. Everything is off by default. Omitted or `enabled: false` mounts no `/oauth/*` route and no `.well-known/oauth-*` document, and `/mcp` sends no OAuth challenge. Dynamic client registration is a second opt-in on top of `enabled`.
390
+
391
+ ```typescript
392
+ new TerrenoApp({
393
+ userModel: User,
394
+ mcpOAuth: {
395
+ allowedRedirectHosts: ["claude.ai", "chatgpt.com", "localhost"],
396
+ consentUrl: "https://app.example.com/oauth/consent",
397
+ dynamicClientRegistration: true, // omit to accept only clients you create
398
+ enabled: true,
399
+ issuer: "https://api.example.com",
400
+ },
401
+ });
402
+ ```
403
+
404
+ | Option | Default | Meaning |
405
+ | --- | --- | --- |
406
+ | `issuer` | — | Public origin of the API (no path). RFC 8414 `issuer` and base of every endpoint URL. Must be https outside localhost |
407
+ | `consentUrl` | — | App page that hosts the consent screen; receives `?request=<id>` |
408
+ | `enabled` | — | Turns the authorization server on. Every other option is ignored while false |
409
+ | `dynamicClientRegistration` | `false` | Mount `POST /oauth/register` and advertise `registration_endpoint`. The endpoint is unauthenticated, so anyone who can reach the API can create a client; leave it off unless clients you cannot pre-register must connect |
410
+ | `allowedRedirectHosts` | `[]` | Hostnames a registering client may put redirect URIs on. Required (non-empty) with `dynamicClientRegistration`, unused without it. `*.example.com` matches subdomains. Add `localhost` for loopback `http://` redirects |
411
+ | `resource` | `${issuer}/mcp` | RFC 8707 resource identifier |
412
+ | `scopesSupported` | `["mcp"]` | Advertised and accepted scopes. Terreno does not map scopes to tools: a token may call every tool its user can. Enforce scopes in permissions with `requireOAuthScope` (see [Request credential](#request-credential)) |
413
+ | `accessTokenTtlSeconds` | 3600 | Access token lifetime |
414
+ | `refreshTokenTtlSeconds` | 30 days | Refresh token lifetime |
415
+ | `authorizationRequestTtlSeconds` | 600 | How long a request waits for a consent decision |
416
+ | `authorizationCodeTtlSeconds` | 60 | How long a code can be exchanged |
417
+
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`.
419
+
420
+ | Method | Path | Auth | Purpose |
421
+ | --- | --- | --- | --- |
422
+ | 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"]` |
424
+ | 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` |
430
+ | POST | `/oauth/revoke` | `client_id` | RFC 7009. Always 200. Revoking a refresh token revokes its whole chain |
431
+
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.
433
+
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.
435
+
436
+ `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
+
438
+ Models: `McpOAuthClient` (`mcpoauthclients`), `McpOAuthAuthorization` (`mcpoauthauthorizations`, TTL on `expiresAt`), `McpOAuthToken` (`mcpoauthtokens`, TTL on `refreshExpiresAt`), `McpOAuthRevokedFamily` (`mcpoauthrevokedfamilies`, unique `familyId`, TTL on `expiresAt`).
439
+
440
+ With `mcpOAuth` enabled, `/mcp` resolves `mcpat_` access tokens alongside JWTs, Better Auth sessions, and `mcp_` service tokens, and answers any request it cannot authenticate (including `initialize` and `tools/list`) with `401` and `WWW-Authenticate: Bearer resource_metadata="<issuer>/.well-known/oauth-protected-resource/mcp"`, adding `error="invalid_token"` when a credential was presented. Revoked or expired tokens and disabled users are refused. Each approval, and every pair issued from it, records the user's `tokenEpoch`; after it changes (password reset, Better Auth password change, or session revocation), the code exchange and refresh answer `invalid_grant` and `/mcp` refuses the access token, and the whole family is revoked. Operator steps: [Enable MCP OAuth for a JWT app](../how-to/enable-mcp-oauth.md).
441
+
319
442
  ## Authentication
320
443
 
321
444
  @terreno/api includes built-in authentication with multiple strategies:
@@ -347,16 +470,76 @@ setupServer({
347
470
  - `POST /auth/sendVerification` — Authenticated; 202 only after delivery succeeds; mails a verification link when `emailVerified` is not true
348
471
  - `POST /auth/verifyEmail` — `{token}` sets `emailVerified` true
349
472
  - `GET /auth/me` — Get current user profile
350
- - `PATCH /auth/me` — Update current user profile
473
+ - `PATCH /auth/me` — Update current user profile. It does not change passwords: a body with
474
+ `password` or `newPassword` gets 400 and nothing is saved. Change a password through
475
+ `POST /auth/forgotPassword` and `POST /auth/resetPassword`, or Better Auth change-password.
476
+ Changing the email needs the current password; see [Changing the email on PATCH /auth/me](#changing-the-email-on-patch-authme)
477
+
478
+ #### Changing the email on PATCH /auth/me
479
+
480
+ A body whose `email` differs from the stored email (compared after trimming and lowercasing)
481
+ must also send `currentPassword`. An unchanged email echoed back, or one that differs only in
482
+ letter case, needs no password. `currentPassword` is never saved.
483
+
484
+ | Case | Response | Saved |
485
+ |------|----------|-------|
486
+ | `currentPassword` missing or empty | 403 `Enter your current password to change your email.` | Nothing |
487
+ | Wrong `currentPassword` | 403 `The current password is incorrect.` | Nothing |
488
+ | passport-local-mongoose lockout reached (`limitAttempts`) | 403 with the lockout message | Nothing |
489
+ | User has no password (for example GitHub-only or social-only) | 400 `Set a password on your account before changing your email. …` | Nothing |
490
+ | Right `currentPassword` | 200 with the updated user | The whole body |
491
+
492
+ The status is 403, not 401, so `@terreno/rtk` does not treat a wrong password as an expired
493
+ session and log the user out.
494
+
495
+ The password is checked against the passport-local-mongoose hash first. A wrong password
496
+ counts toward the model's passport lockout when `limitAttempts` is on, the same counter
497
+ `POST /auth/login` uses. When the user has no passport hash and `BetterAuthApp` is registered,
498
+ the password is checked against the user's Better Auth credential account (found through
499
+ `betterAuthId`). TerrenoApp wires that check as `authOptions.verifyBetterAuthPassword`; hosts
500
+ that call `addMeRoutes` themselves can set it with `verifyBetterAuthCredentialPassword(auth,
501
+ user, password)`, which returns `"verified"`, `"wrong"`, or `"no-password"`.
502
+
503
+ After the change is saved, the old address gets a notice through `authOptions.sendMail` with
504
+ `sensitive: true`, subject `Your account email was changed`. It names the new address partially
505
+ masked (`r***@e***.com`), tells the reader to contact support if they did not make the change,
506
+ and carries no link or token. With no `sendMail`, the server logs a warning and still completes
507
+ the change; a delivery failure is logged and does not undo it. The change does not bump
508
+ `tokenEpoch`, and it does not change the Better Auth user's email.
509
+
510
+ An access JWT is read only from the `jwt` cookie or an `Authorization: Bearer <token>`
511
+ header; any other scheme is not a JWT credential. A user with `disabled: true` gets 401 on
512
+ every JWT-authenticated route, and `POST /auth/login`, `POST /auth/refresh_token`,
513
+ `POST /auth/resetPassword`, and `GET /auth/github/callback` refuse to issue tokens to them.
351
514
 
352
515
  JWT recovery routes default on. In `authOptions`, set `passwordReset: false` to omit
353
516
  forgot-password and both token-reset paths, `emailVerification: false` to omit send/verify
354
517
  email, or `legacyResetPasswordRoute: false` to omit only the deprecated
355
518
  `POST /resetPassword` alias while retaining `POST /auth/resetPassword`.
356
519
 
357
- Signup and `PATCH /auth/me` drop privileged fields: `admin`, `roles`, `organizationIds`,
358
- `emailVerified`, and `tokenEpoch`. Request logs redact `password`, `newPassword`,
359
- `oldPassword`, `token`, and `refreshToken` in request bodies and in URL query strings. Changing the mailbox through `PATCH /auth/me`
520
+ Signup and `PATCH /auth/me` drop the fields in `SELF_SERVICE_BLOCKED_USER_FIELDS`: the
521
+ privileged fields `admin`, `roles`, `organizationIds`, `emailVerified`, and `tokenEpoch`
522
+ (`PRIVILEGED_USER_FIELDS`), the identity-binding fields `betterAuthId`, `githubId`,
523
+ `githubUsername`, `githubProfileUrl`, `githubAvatarUrl`, and `oauthProvider`, the
524
+ passport-local-mongoose fields `hash`, `salt`, `attempts`, and `last`, and `disabled`. Only the
525
+ Better Auth sync, GitHub link, and password flows write those. Dotted paths into a dropped
526
+ field (`roles.0`, `organizationIds.1`) are dropped too, because Mongoose applies a dotted key
527
+ as a path into the field. With `accessControl` set, modelRouter, sync, and MCP writes to the user
528
+ model (compiled as `"User"`, or the `userModel` passed to `createAccess` under any name) drop
529
+ `PRIVILEGED_USER_FIELDS`, the passport fields `hash`, `salt`, `attempts`, and `last` (`USER_CREDENTIAL_FIELDS`), and dotted paths into them the same way; assign
530
+ roles with `access.roles.assign`. Those writes, plus array operations and POST instance actions on
531
+ the user model, also apply the RoleManager target-privilege rule: an actor whose permissions do not
532
+ cover the target user's gets 403 `Cannot modify a user with permissions you do not hold` on update
533
+ or delete. This runs on REST, sync (including admin-window mutations), and MCP. Writes to the
534
+ actor's own record and writes by legacy `admin: true` users are not checked. Changing another
535
+ user's identity fields also needs `user:updateIdentity`; see
536
+ [RBAC user identity writes](#rbac-user-identity-writes).
537
+
538
+ Request logs redact credential values at any depth in request bodies and in URL query strings:
539
+ any key whose name contains `password`, `passwd`, `passphrase`, `passcode`, `token`, `secret`,
540
+ `verifier`, `signature`, `apikey`, `authorization`, `credential`, or `privatekey` (case and `_`/`-`
541
+ 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`
360
543
  invalidates unused `passwordReset` AuthTokens for that user even when the schema has no
361
544
  `emailVerified` field. When the schema uses `emailVerificationPlugin`, mailbox change also
362
545
  sets `emailVerified` to false and invalidates unused `emailVerification` tokens; changing letter
@@ -371,16 +554,26 @@ marks one unused, unexpired row. TTL is 1 hour for `passwordReset` and 24 hours
371
554
 
372
555
  Wire `authOptions.publicAppUrl` and `authOptions.sendMail` (typically
373
556
  `getCommsService().sendMail`) so forgot-password can deliver the link
374
- `${publicAppUrl}/resetPassword?token=...`. Forgot-password skips issuing a token when
557
+ `${publicAppUrl}/resetPassword?token=...`. Reset and verification mail (JWT and Better Auth)
558
+ has `sensitive: true` so the transport keeps no copy of the one-time link; `@terreno/comms`
559
+ stores no payload for it. Forgot-password skips issuing a token when
375
560
  `publicAppUrl` is missing. Authenticated `POST /auth/sendVerification` returns 501 in that
376
561
  case instead of 202. Email lookup is case-insensitive. Successful reset calls `setPassword`, increments
377
- `tokenEpoch` so outstanding **refresh** tokens fail, and returns new JWT tokens. When
562
+ `tokenEpoch` so outstanding **refresh** tokens and MCP credentials (`mcp_` service tokens and
563
+ `mcpat_`/`mcprt_` OAuth grants) fail, and returns new JWT tokens. When
378
564
  `BetterAuthApp` is registered, JWT reset also updates that user's Better Auth password
379
565
  and deletes Better Auth sessions. Better Auth password reset updates the JWT password
380
566
  and `tokenEpoch` for the matching app User. Access
381
567
  tokens stay valid until expiry (default 15m). `POST /resetPassword`
382
568
  matches the `@terreno/rtk` `resetPassword` mutation path (`password` or `newPassword`).
383
569
 
570
+ For any other server-side password change, such as an admin "set password" action, call
571
+ `changeUserPassword(user, password, {adminId, app: req.app})`. It does what reset does: sets the
572
+ passport password, increments `tokenEpoch`, saves, and, when `BetterAuthApp` is registered (or
573
+ `authOptions.syncPasswordResetToBetterAuth` is set), sets the Better Auth password and deletes
574
+ that user's Better Auth sessions. `adminId` adds the audit log line. Pass `authOptions` instead
575
+ of `app` outside a request. `setPasswordForUser` alone only sets the hash and revokes nothing.
576
+
384
577
  Add `tokenEpoch` (number, default 0) on the User schema so epoch bumps persist under
385
578
  `strict: "throw"`. See `example-backend` User. Opt in to `emailVerified` with
386
579
  `emailVerificationPlugin`. Set `authOptions.requireEmailVerification` to reject login
@@ -414,16 +607,28 @@ setupServer({
414
607
  callbackURL: process.env.GITHUB_CALLBACK_URL!,
415
608
  scope: ["user:email"],
416
609
  allowAccountLinking: true,
610
+ allowedReturnTo: ["https://app.example.com", "myapp://auth"],
417
611
  },
418
612
  });
419
613
  ``````
420
614
 
615
+ `allowedReturnTo` lists the exact http(s) origins or custom-scheme authorities that
616
+ `GET /auth/github?returnTo=` may name. A value outside the list returns 400, the callback checks
617
+ it again before redirecting, and tokens go in the URL fragment. Without `allowedReturnTo`,
618
+ `returnTo` is ignored and the callback returns JSON.
619
+
421
620
  **Endpoints (when configured):**
422
621
  - `GET /auth/github` — Initiate OAuth flow
423
622
  - `GET /auth/github/callback` — OAuth callback handler
424
- - `GET /auth/github/link` — Link GitHub to authenticated user (requires JWT)
623
+ - `GET /auth/github/link` — Link GitHub to authenticated user (requires JWT; the user ID is bound into the OAuth state)
425
624
  - `DELETE /auth/github/unlink` — Unlink GitHub from account (requires JWT)
426
625
 
626
+ The flow sets a signed, HttpOnly, `SameSite=Lax` `terreno_github_oauth_state` cookie (10 minute
627
+ lifetime, signed with `SESSION_SECRET`, falling back to `TOKEN_SECRET`). The callback accepts the
628
+ GitHub response only with a matching, unexpired, single-use state, and links an account only to
629
+ the user recorded by `/auth/github/link`. `findOrCreateUser`'s `existingUser` argument is that
630
+ user, not the callback request's `req.user`.
631
+
427
632
  **Learn more:** [Add GitHub OAuth authentication](../how-to/add-github-oauth.md)
428
633
 
429
634
  ### Better Auth Authentication
@@ -459,6 +664,11 @@ Those hooks throw 501 and do not send when `publicAppUrl` is missing, so links a
459
664
  relative. Password reset also sets `revokeSessionsOnPasswordReset`, so existing Better Auth sessions
460
665
  are deleted when that reset path succeeds. JWT `POST /auth/resetPassword` updates the
461
666
  Better Auth password and deletes those sessions when `BetterAuthApp` is registered.
667
+ 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
669
+ `User` that already has one. `change-password`, `revoke-sessions`, and `revoke-other-sessions`
670
+ bump `tokenEpoch` for the linked app `User`, so JWT refresh tokens and MCP credentials issued
671
+ before the change stop working.
462
672
  Optional `authMailTemplates` overrides
463
673
  subject/text/html per template id.
464
674
 
@@ -468,6 +678,64 @@ turns its built-in limiter on when `NODE_ENV=production`, so seeding six demo
468
678
  users during a preview smoke test otherwise 429s on the fourth signup. Do not
469
679
  set this on the public `BetterAuthApp`.
470
680
 
681
+ `options` passes any other Better Auth option, deep-merged over Terreno's defaults (arrays
682
+ replaced, explicit values win), and `plugins` appends Better Auth plugins after `bearer()`.
683
+ See [Pass other Better Auth options and plugins](../how-to/configure-better-auth.md#pass-other-better-auth-options-and-plugins)
684
+ for the defaults each override replaces.
685
+
686
+ `createBetterAuthSessionMiddleware` does not set `req.user` from a session cookie on a `POST`,
687
+ `PUT`, `PATCH`, or `DELETE` whose `Origin` (or `Referer` origin) is `null` or is not a Better Auth
688
+ trusted origin (`trustedOrigins` plus the API's own origin), or that has neither header but
689
+ `Sec-Fetch-Site: cross-site`. Bearer session requests, safe methods, and requests without those
690
+ browser headers are unchanged; `advanced.disableCSRFCheck` disables the check.
691
+
692
+ 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
696
+ email links a row whose own `emailVerified` is not `true`, its legacy passport password is removed
697
+ and `tokenEpoch` is bumped, because whoever set that password never proved the mailbox. Seeds
698
+ that create the Better Auth account themselves pass `{trustEmail: true}` as the fourth argument
699
+ to skip these checks; `seedBetterAuthUser` does.
700
+
701
+ A session whose app `User` has `disabled: true` does not set `req.user`, so protected routes
702
+ answer 401 while Better Auth's own routes, such as sign-out, still work. `RealtimeApp` uses the
703
+ registered `BetterAuthApp` instance for socket sessions. With a user model (its own or the
704
+ `TerrenoApp`'s), the handshake creates the app `User` on first contact and refuses disabled users.
705
+ The legacy JWT socket handshake loads the same user model and refuses a token whose user is
706
+ disabled or missing (`createSocketAuthMiddleware({tokenSecret, userModel})`).
707
+
708
+ **Social sign-in for a web app on another site (`oauthHandoff`):**
709
+
710
+ `oauthHandoff: {webOrigins, callbackPaths?}` lets a bearer-mode web client on another site
711
+ finish a social or generic OAuth sign-in without cookies. Unset by default, which adds no
712
+ routes. See [Social sign-in in bearer mode](../how-to/configure-better-auth.md#social-sign-in-in-bearer-mode).
713
+
714
+ - `webOrigins` — Exact `http(s)` origins of the web apps that may receive the token. Patterns,
715
+ paths, other schemes, and an empty list are rejected when Better Auth is created. Each origin
716
+ must also be in `trustedOrigins`.
717
+ - `callbackPaths` — Exact paths on those origins that may receive the token, in normal form.
718
+ Default `["/"]`. The query is free. List only the page that reads the token; it must never
719
+ redirect before it strips the fragment.
720
+ - Production: the framework applies the same rules in every environment, so list only the
721
+ deployed origins. Leave loopback origins (`localhost`, `127.0.0.1`, `[::1]`) out of a
722
+ production `webOrigins`: a token sent there reaches whatever runs on the visitor's machine.
723
+ example-backend drops them when `NODE_ENV=production` and refuses to start when none is left.
724
+
725
+ Endpoints it adds, under `basePath` (default `/api/auth`):
726
+ - `GET {basePath}/oauth-handoff/start?provider=<id>&callbackURL=<url>&errorCallbackURL=<url>` —
727
+ Starts the sign-in as a top-level page navigation, so the OAuth state cookie is first-party
728
+ on the API's origin. Answers 400 with no redirect unless `callbackURL` is one of
729
+ `callbackPaths` on one of `webOrigins`, `errorCallbackURL` (optional) is on one of
730
+ `webOrigins`, and `trustedOrigins` allows both. It also answers 400 when `Sec-Fetch-Dest` is
731
+ 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.
733
+ - `POST {basePath}/one-time-token/verify` — Body `{token}`. Exchanges the token once for the
734
+ session and the `set-auth-token` header, and sets no cookie. `GET
735
+ {basePath}/one-time-token/generate` is refused.
736
+
737
+ With the opt-in `rateLimit`, both paths are in the auth bucket.
738
+
471
739
  **Endpoints (when enabled):**
472
740
  - `POST /api/auth/signup/email` — Email/password signup
473
741
  - `POST /api/auth/signin/email` — Email/password signin
@@ -659,6 +927,11 @@ todo.deleted = true;
659
927
  await todo.save();
660
928
  ``````
661
929
 
930
+ modelRouter create and update (REST, MCP tools, and sync mutations) drop `deleted` from the
931
+ client body. Only `DELETE` sets it, so soft delete always needs `delete` permission and runs
932
+ `preDelete` / `postDelete`. A `preCreate` or `preUpdate` hook can still set `deleted` on
933
+ purpose.
934
+
662
935
  ### isDisabledPlugin
663
936
 
664
937
  Disable user accounts (returns 401 for disabled users).
@@ -908,6 +1181,77 @@ the mapped resource action. Callers with `user.admin === true` still receive ful
908
1181
  mutations. Route mapping (for example prompt label moves → `aiPrompt:promote`, review
909
1182
  submit → `aiReview:score`) is documented in [AI reference — Observability](ai.md#observability).
910
1183
 
1184
+ ### RBAC role edits
1185
+
1186
+ `PATCH /rbac/roles/:name` (`access.roles.update`) requires `rbac:manageRoles` and never lets the
1187
+ actor hand out permissions it does not hold. A `permissions` key must be an object of action lists
1188
+ valid for the statements; any other value (`false`, `0`, `""`, `null`, an array) is a 400
1189
+ `Invalid permissions`. The actor must hold the role's current grants plus the new ones, so emptying
1190
+ a role (`{permissions: {}}`) still needs every permission the role had. Users hold roles by name,
1191
+ so a rename (`{name}`) is checked like a permission edit:
1192
+
1193
+ - Locked and sealed roles cannot be renamed (400).
1194
+ - The actor must hold every permission of the role being renamed (403 `Cannot grant permissions
1195
+ you do not hold`).
1196
+ - The new name must not belong to another role or appear in any user's `roles` (409).
1197
+ - Users holding the old name, and other roles' `excludesRoles` entries, move to the new name, so
1198
+ holders keep the same grants.
1199
+
1200
+ A `PermissionSource` that returns role names keeps returning the old name after a rename; update
1201
+ the source's mapping in the same change.
1202
+
1203
+ ### RBAC user identity writes
1204
+
1205
+ `terrenoStatements.user` includes `updateIdentity`. With `accessControl` set, a write that changes
1206
+ another user's identity field needs `user:updateIdentity` in addition to `user:update`. Without it
1207
+ the write fails with 403 `Missing user:updateIdentity permission`. The field is not silently
1208
+ dropped. The identity fields are `USER_IDENTITY_FIELDS`: `email`, `disabled`, `betterAuthId`,
1209
+ `githubId`, and `emailVerified`, plus dotted paths into them.
1210
+
1211
+ - The check runs on modelRouter `PATCH`, array operations, non-GET instance actions (on the
1212
+ request body), sync and MCP updates (including admin-window mutations that skip modelRouter
1213
+ permissions), and AdminApp `PATCH` and bulk-patch.
1214
+ - A key whose value equals the target's current value is not a change. A client that sends the
1215
+ whole record back, unchanged email included, still succeeds. Unset, `null`, `""`, and `false`
1216
+ count as the same empty value.
1217
+ - Not checked: writes to the actor's own record (`PATCH /auth/me` has its own guards), writes
1218
+ without an actor (server-internal calls), legacy `admin: true` actors, and creates. A
1219
+ `user:create` holder can still create a user with an email.
1220
+
1221
+ | Role | `user:updateIdentity` |
1222
+ | --- | --- |
1223
+ | `superadmin` | Yes, through `*` |
1224
+ | `admin` (seeded) | Yes, for new seeds |
1225
+ | `operator` (organizations) | No |
1226
+ | `auditor`, `member` | No |
1227
+
1228
+ `seedDefaults` does not add new `user` actions to an `admin` role that is already stored. It only
1229
+ merges observability actions. In an existing deployment, add `updateIdentity` to the stored `admin`
1230
+ role's `user` actions (and to any custom role that should manage identities) with
1231
+ `PATCH /rbac/roles/admin` or a migration. Until then, RBAC `admin` holders get 403 when they change
1232
+ another user's email or disabled flag. An app that overrides the `user` statement in its own
1233
+ statements must list `updateIdentity` for any role to hold it.
1234
+
1235
+ ### RBAC permission sources
1236
+
1237
+ `createAccess({sources})` adds `PermissionSource`s (IdP or ABAC lookups) whose `getGrants` returns
1238
+ `roles`, `permissions`, and `deny`. When `getGrants` throws, the source's `staleOnFailure` applies
1239
+ (default `"deny"`: drop its additive grants, keep local `user.roles`) once this process knows the
1240
+ source's deny posture for the user. Under every policy:
1241
+
1242
+ - The last `deny` set a source returned for a user stays in force while the source is down. It is
1243
+ kept apart from the grant caches, so `invalidateCache` (every role edit) and permission-cache
1244
+ eviction do not lift it. A later successful fetch without `deny` clears it.
1245
+ - A permission set resolved while any source was failing is not cached, so recovery takes effect
1246
+ on the next check.
1247
+ - A process with no last-known deny for the user (a new instance, or the posture was evicted)
1248
+ fails closed by default: the check throws 503 `Permission source unavailable`, because the
1249
+ source's deny is unknown. `failClosedWhenDenyUnknown` defaults to `true`.
1250
+ - Set `failClosedWhenDenyUnknown: false` on additive-only sources that never return `deny` (IdP
1251
+ role mappings, membership lookups). Their cold failure then drops only their grants and local
1252
+ roles apply. The built-in organization membership source (`createAccess({organizations: true})`)
1253
+ sets it.
1254
+
911
1255
  ## Request Validation
912
1256
 
913
1257
  @terreno/api provides runtime validation of incoming requests against OpenAPI schemas using [AJV](https://ajv.js.org/). Validation is opt-in and can be enabled globally or per-route.
@@ -1176,8 +1520,8 @@ background job can be followed across many log lines, both in plain-text console
1176
1520
  transports (Google Cloud Logging, Sentry).
1177
1521
 
1178
1522
  > Never use `console.log` for permanent server logs — use `logger` or a scoped logger. Request
1179
- > logging already redacts `password` on bodies; never log secrets, full auth tokens, or raw
1180
- > passwords.
1523
+ > logging already redacts password, token, secret, and code fields in bodies and query strings;
1524
+ > never log secrets, full auth tokens, or raw passwords.
1181
1525
 
1182
1526
  ### Quick reference
1183
1527
 
@@ -1305,6 +1649,8 @@ What you get:
1305
1649
  and `sessionId` are added.
1306
1650
  - **Correlation fields**: `requestId`, `jobId`, `sessionId`, `userId`, `traceId`, `spanId` (when
1307
1651
  available) are merged onto every line as top-level Winston metadata.
1652
+ - **Credential type**: `getCurrentRequestContext()?.credential` records how the caller
1653
+ authenticated (see [Request credential](#request-credential)). It is also a Sentry tag.
1308
1654
  - **Structured object**: every line also carries **`terrenoRequestLog`**: `{requestId, userId}`
1309
1655
  (with `userId: null` for anonymous requests) so Log Explorer and BigQuery can always filter on a
1310
1656
  single nested object. This object is structured metadata only — it is not repeated in the
@@ -1333,8 +1679,60 @@ import {getCurrentRequestContextAttributes} from "@terreno/api";
1333
1679
  await fetch(downstreamUrl, {headers: getCurrentRequestContextAttributes()});
1334
1680
  ``````
1335
1681
 
1682
+ ### Request credential
1683
+
1684
+ `getCurrentRequestContext()?.credential` says which credential authenticated `userId`. Permission
1685
+ functions receive only `(method, user, obj)`, so read it from the request context when an
1686
+ operation must tell the user's own session apart from a delegated agent:
1687
+
1688
+ | Value | Set when |
1689
+ | --- | --- |
1690
+ | `"jwt"` | A Terreno JWT bearer authenticated the request (REST or `/mcp`) |
1691
+ | `"better-auth-session"` | A Better Auth session authenticated the request (REST or `/mcp`) |
1692
+ | `"mcp-service-token"` | An `mcp_` service token authenticated a `/mcp` call |
1693
+ | `"oauth"` | An MCP OAuth access token (`mcpat_`) authenticated a `/mcp` call |
1694
+
1695
+ It is `undefined` for anonymous requests and for jobs. The JWT and Better Auth middlewares set it
1696
+ for REST; the MCP server sets it on `/mcp` before any tool runs. `extractAuthFromHeaders` returns
1697
+ the same `{user, credential}` pair for custom MCP mounts, plus `scopes` for an OAuth token.
1698
+
1699
+ For an `"oauth"` credential, `getCurrentRequestContext()?.oauthScopes` lists the scopes the
1700
+ token was granted. Terreno does not limit tools by scope on its own. With more than one entry in
1701
+ `mcpOAuth.scopesSupported`, add `requireOAuthScope(scope)` to the permissions of each action or
1702
+ method that needs it: it refuses an OAuth token without `scope` and lets every other credential
1703
+ through.
1704
+
1705
+ ``````typescript
1706
+ import {Permissions, requireOAuthScope} from "@terreno/api";
1707
+
1708
+ permissions: {
1709
+ create: [Permissions.IsAuthenticated, requireOAuthScope("write")],
1710
+ list: [Permissions.IsAuthenticated],
1711
+ // ...
1712
+ },
1713
+ ``````
1714
+
1715
+ ``````typescript
1716
+ import {getCurrentRequestContext, type PermissionMethod} from "@terreno/api";
1717
+
1718
+ const DELEGATED = new Set(["mcp-service-token", "oauth"]);
1719
+
1720
+ // Admins may act on any workspace from their own session, but not through an agent's token.
1721
+ const IsWorkspaceMemberOrAdmin: PermissionMethod<Workspace> = (method, user, obj) => {
1722
+ const credential = getCurrentRequestContext()?.credential;
1723
+ if (user?.admin && !DELEGATED.has(credential ?? "")) {
1724
+ return true;
1725
+ }
1726
+ return Boolean(obj?.memberIds.some((id) => String(id) === String(user?._id)));
1727
+ };
1728
+ ``````
1729
+
1336
1730
  ### Configuring transports
1337
1731
 
1732
+ Request logging (one debug line per request with method, URL, user, and redacted body) is on by
1733
+ default. Turn it off with `logRequests: false` on `TerrenoApp` or `loggingOptions: {logRequests: false}`;
1734
+ either one disables it.
1735
+
1338
1736
  `setupLogging` is called for you by `TerrenoApp` / `setupServer` (pass `loggingOptions`). Console
1339
1737
  and file transports are enabled by default; add custom transports (such as Google Cloud Logging) to
1340
1738
  forward the structured metadata above:
@@ -54,7 +54,7 @@ A dataset name matches `^[a-z][a-z0-9_]{0,63}$`. A document has at most 8 datase
54
54
  | `image` | `alt`, `src` | `id` |
55
55
  | `details` | `title`, `text` | `id` |
56
56
 
57
- `src` (on an `image` block, on each `gallery` tile, and on a `list` item's `image`) is a `data:image` URL, a `file:` ref (`file:` plus an id), or an `https` URL. `https` is allowed only when the hostname is in `validateBlocks` `imageHosts` (the server option is `uiBlocks.imageHosts`). Anything else is `IMAGE_HOST_NOT_ALLOWED`. An empty list rejects every `https` image. A ref dataset `id` uses 1–80 letters, digits, underscores, or hyphens. A block or action-element `id` stays lowercase and starts with a letter. `limit` of `0` is `INVALID_TYPE` (an integer of at least 1), not `TOO_SHORT`.
57
+ `src` (on an `image` block, on each `gallery` tile, and on a `list` item's `image`) is a `data:image` URL, a `file:` ref (`file:` plus an id), or an `https` URL. `https` is allowed only when the hostname is in `validateBlocks` `imageHosts` (the server option is `uiBlocks.imageHosts`). Anything else is `IMAGE_HOST_NOT_ALLOWED`. An empty list rejects every `https` image. `imageHosts` covers only these three image sources: a Markdown image inside a `text` block (or a plain assistant reply) renders through `MarkdownView` and loads from any URL, so the allowlist does not stop it; see [Agent UI asks: the image allowlist does not cover Markdown](../explanation/agent-ui-asks.md). A ref dataset `id` uses 1–80 letters, digits, underscores, or hyphens. A block or action-element `id` stays lowercase and starts with a letter. `limit` of `0` is `INVALID_TYPE` (an integer of at least 1), not `TOO_SHORT`.
58
58
 
59
59
  Warnings do not block rendering:
60
60
 
@@ -65,6 +65,15 @@ await getCommsService().sendSms({
65
65
  `context.attempt === 2`); push retries only the failed tokens. `checkVerification()` does
66
66
  not retry.
67
67
 
68
+ Push follows the account lifecycle. `POST /comms/pushTokens` records the caller's `tokenEpoch`
69
+ on the token. When `CommsService` has a user model (`CommsApp` takes `TerrenoApp`'s unless
70
+ `userModel` is set; a standalone `CommsService` or `setupServer` app passes `userModel` or calls
71
+ `service.setUserModel(User)`), `sendPushToUser()` sends nothing to a missing, disabled, or
72
+ `deleted` user and deactivates that user's tokens. It also deactivates tokens registered under
73
+ an earlier `tokenEpoch`, so a password reset, password change, or session revocation stops pushes
74
+ to devices bound before it until the device registers again. A token stored without
75
+ `tokenEpoch` counts as epoch 0.
76
+
68
77
  `beforeSend` may mutate the message or cancel (`status: "cancelled"`, no provider call).
69
78
  A throwing `beforeSend` is logged and treated as no-op (send continues). Adapters later
70
79
  call `recordDeliveryEvent()` / `recordOptOut()` to update the log and fire
@@ -319,7 +328,9 @@ default `createRequire("expo-server-sdk")` is not bundled into that binary.
319
328
 
320
329
  example-frontend requests notification permission, then `getExpoPushTokenAsync`, then
321
330
  `POST /comms/pushTokens` after login. Denied permission and web skip registration (empty
322
- token). Physical-device gating via `expo-device` is deferred until the native baseline
331
+ token). Sign-out on the profile screen sends `DELETE /comms/pushTokens/:id` for the token this
332
+ session registered before it signs out, so the signed-out account stops receiving pushes on the
333
+ device and the next account can claim it (an active token held by another user answers `409`). Physical-device gating via `expo-device` is deferred until the native baseline
323
334
  lands. The profile **Send test push** card is `__DEV__` only, so production web exports
324
335
  (CircleCI Playwright) do not render it.
325
336
 
@@ -343,6 +354,7 @@ interface CommsAppOptions {
343
354
  redactRecipients?: boolean; // default: true
344
355
  retainPayloadDays?: number; // default: 30; 0 stores no payload
345
356
  sms?: SmsProvider;
357
+ userModel?: {findById: (id) => PromiseLike<unknown>}; // default: TerrenoApp's user model
346
358
  verification?: VerificationProvider;
347
359
  }
348
360
  ```
@@ -352,6 +364,12 @@ When a channel is unconfigured:
352
364
  - non-production environments use the matching console provider and emit a warning;
353
365
  - production throws a `501` `APIError` titled `Comms channel not configured`.
354
366
 
367
+ A mail with `sensitive: true` (or sent with `sendMail(message, {sensitive: true})`) is logged
368
+ without a payload on every attempt, so its content, such as a password-reset or verification
369
+ link, is never stored and the dashboard cannot return it. Such a row cannot be retried from the
370
+ dashboard (`comms-retry-payload-expired`). Auth recovery mail from `@terreno/api` sets the flag;
371
+ pass it on any other mail that carries a secret. The flag is not sent to the provider.
372
+
355
373
  Delivery attempts are stored in `CommsMessage`. Recipient values are stored as `[redacted]` unless
356
374
  `redactRecipients` is explicitly `false`. Rendered payloads are retained for `retainPayloadDays`
357
375
  (default 30) after `redactPayload`; expired payloads are unset without deleting the log row.