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