@terreno/mcp 57.16.1 → 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.
- package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
- package/dist/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
- package/dist/docs/upgrades/57.17.0.md +126 -0
- package/dist/docs/versioned/next/approval-rubric.md +171 -0
- package/dist/docs/versioned/next/explanation/admin-interface.md +5 -1
- package/dist/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
- package/dist/docs/versioned/next/explanation/authentication.md +60 -10
- package/dist/docs/versioned/next/explanation/example-coverage.md +2 -1
- package/dist/docs/versioned/next/explanation/model-router-actions.md +32 -1
- package/dist/docs/versioned/next/how-to/README.md +1 -0
- package/dist/docs/versioned/next/how-to/add-github-oauth.md +49 -16
- package/dist/docs/versioned/next/how-to/admin-add-model.md +4 -1
- package/dist/docs/versioned/next/how-to/ai-feature-development.md +5 -0
- package/dist/docs/versioned/next/how-to/background-jobs.md +18 -5
- package/dist/docs/versioned/next/how-to/circleci.md +39 -3
- package/dist/docs/versioned/next/how-to/configure-better-auth.md +283 -5
- package/dist/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
- package/dist/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
- package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
- package/dist/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
- package/dist/docs/versioned/next/how-to/install-agent-skills.md +8 -1
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
- package/dist/docs/versioned/next/how-to/password-reset.md +8 -2
- package/dist/docs/versioned/next/how-to/product-announcements.md +5 -3
- package/dist/docs/versioned/next/how-to/rate-limiting.md +2 -2
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +50 -0
- package/dist/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
- package/dist/docs/versioned/next/how-to/websocket-integration.md +42 -9
- package/dist/docs/versioned/next/how-to/whats-new-banner.md +55 -0
- package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
- package/dist/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
- package/dist/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
- package/dist/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
- package/dist/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
- package/dist/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
- package/dist/docs/versioned/next/reference/admin-backend.md +46 -0
- package/dist/docs/versioned/next/reference/admin-config.md +2 -2
- package/dist/docs/versioned/next/reference/ai.md +48 -4
- package/dist/docs/versioned/next/reference/api.md +410 -12
- package/dist/docs/versioned/next/reference/blocks.md +1 -1
- package/dist/docs/versioned/next/reference/comms.md +19 -1
- package/dist/docs/versioned/next/reference/environment-variables.md +6 -1
- package/dist/docs/versioned/next/reference/feature-flags.md +1 -1
- package/dist/docs/versioned/next/reference/jobs.md +8 -6
- package/dist/docs/versioned/next/reference/legacy/rtk.md +145 -18
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
- package/dist/docs/versioned/next/reference/syncdb.md +64 -3
- package/dist/docs/versioned/next/reference/ui.md +23 -1
- package/package.json +3 -3
- package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
- package/src/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
- package/src/docs/upgrades/57.17.0.md +126 -0
- package/src/docs/versioned/next/approval-rubric.md +171 -0
- package/src/docs/versioned/next/explanation/admin-interface.md +5 -1
- package/src/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
- package/src/docs/versioned/next/explanation/authentication.md +60 -10
- package/src/docs/versioned/next/explanation/example-coverage.md +2 -1
- package/src/docs/versioned/next/explanation/model-router-actions.md +32 -1
- package/src/docs/versioned/next/how-to/README.md +1 -0
- package/src/docs/versioned/next/how-to/add-github-oauth.md +49 -16
- package/src/docs/versioned/next/how-to/admin-add-model.md +4 -1
- package/src/docs/versioned/next/how-to/ai-feature-development.md +5 -0
- package/src/docs/versioned/next/how-to/background-jobs.md +18 -5
- package/src/docs/versioned/next/how-to/circleci.md +39 -3
- package/src/docs/versioned/next/how-to/configure-better-auth.md +283 -5
- package/src/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
- package/src/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
- package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
- package/src/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
- package/src/docs/versioned/next/how-to/install-agent-skills.md +8 -1
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
- package/src/docs/versioned/next/how-to/password-reset.md +8 -2
- package/src/docs/versioned/next/how-to/product-announcements.md +5 -3
- package/src/docs/versioned/next/how-to/rate-limiting.md +2 -2
- package/src/docs/versioned/next/how-to/run-tests-locally.md +50 -0
- package/src/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
- package/src/docs/versioned/next/how-to/websocket-integration.md +42 -9
- package/src/docs/versioned/next/how-to/whats-new-banner.md +55 -0
- package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
- package/src/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
- package/src/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
- package/src/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
- package/src/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
- package/src/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
- package/src/docs/versioned/next/reference/admin-backend.md +46 -0
- package/src/docs/versioned/next/reference/admin-config.md +2 -2
- package/src/docs/versioned/next/reference/ai.md +48 -4
- package/src/docs/versioned/next/reference/api.md +410 -12
- package/src/docs/versioned/next/reference/blocks.md +1 -1
- package/src/docs/versioned/next/reference/comms.md +19 -1
- package/src/docs/versioned/next/reference/environment-variables.md +6 -1
- package/src/docs/versioned/next/reference/feature-flags.md +1 -1
- package/src/docs/versioned/next/reference/jobs.md +8 -6
- package/src/docs/versioned/next/reference/legacy/rtk.md +145 -18
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
- package/src/docs/versioned/next/reference/syncdb.md +64 -3
- 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
|
|
358
|
-
`
|
|
359
|
-
`
|
|
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=...`.
|
|
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
|
|
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
|
|
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).
|
|
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.
|