@terreno/mcp 57.16.2 → 57.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
  2. package/dist/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
  3. package/dist/docs/upgrades/57.17.0.md +126 -0
  4. package/dist/docs/versioned/next/approval-rubric.md +171 -0
  5. package/dist/docs/versioned/next/explanation/admin-interface.md +5 -1
  6. package/dist/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
  7. package/dist/docs/versioned/next/explanation/authentication.md +60 -10
  8. package/dist/docs/versioned/next/explanation/example-coverage.md +2 -1
  9. package/dist/docs/versioned/next/explanation/model-router-actions.md +32 -1
  10. package/dist/docs/versioned/next/how-to/README.md +1 -0
  11. package/dist/docs/versioned/next/how-to/add-github-oauth.md +49 -16
  12. package/dist/docs/versioned/next/how-to/admin-add-model.md +4 -1
  13. package/dist/docs/versioned/next/how-to/ai-feature-development.md +5 -0
  14. package/dist/docs/versioned/next/how-to/background-jobs.md +18 -5
  15. package/dist/docs/versioned/next/how-to/circleci.md +39 -3
  16. package/dist/docs/versioned/next/how-to/configure-better-auth.md +283 -5
  17. package/dist/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
  18. package/dist/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
  19. package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
  20. package/dist/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
  21. package/dist/docs/versioned/next/how-to/install-agent-skills.md +8 -1
  22. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
  23. package/dist/docs/versioned/next/how-to/password-reset.md +8 -2
  24. package/dist/docs/versioned/next/how-to/product-announcements.md +5 -3
  25. package/dist/docs/versioned/next/how-to/rate-limiting.md +2 -2
  26. package/dist/docs/versioned/next/how-to/run-tests-locally.md +50 -0
  27. package/dist/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
  28. package/dist/docs/versioned/next/how-to/websocket-integration.md +42 -9
  29. package/dist/docs/versioned/next/how-to/whats-new-banner.md +55 -0
  30. package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
  31. package/dist/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
  32. package/dist/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
  33. package/dist/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
  34. package/dist/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
  35. package/dist/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
  36. package/dist/docs/versioned/next/reference/admin-backend.md +46 -0
  37. package/dist/docs/versioned/next/reference/admin-config.md +2 -2
  38. package/dist/docs/versioned/next/reference/ai.md +48 -4
  39. package/dist/docs/versioned/next/reference/api.md +410 -12
  40. package/dist/docs/versioned/next/reference/blocks.md +1 -1
  41. package/dist/docs/versioned/next/reference/comms.md +19 -1
  42. package/dist/docs/versioned/next/reference/environment-variables.md +6 -1
  43. package/dist/docs/versioned/next/reference/feature-flags.md +1 -1
  44. package/dist/docs/versioned/next/reference/jobs.md +8 -6
  45. package/dist/docs/versioned/next/reference/legacy/rtk.md +145 -18
  46. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
  47. package/dist/docs/versioned/next/reference/syncdb.md +64 -3
  48. package/dist/docs/versioned/next/reference/ui.md +23 -1
  49. package/package.json +3 -3
  50. package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
  51. package/src/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
  52. package/src/docs/upgrades/57.17.0.md +126 -0
  53. package/src/docs/versioned/next/approval-rubric.md +171 -0
  54. package/src/docs/versioned/next/explanation/admin-interface.md +5 -1
  55. package/src/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
  56. package/src/docs/versioned/next/explanation/authentication.md +60 -10
  57. package/src/docs/versioned/next/explanation/example-coverage.md +2 -1
  58. package/src/docs/versioned/next/explanation/model-router-actions.md +32 -1
  59. package/src/docs/versioned/next/how-to/README.md +1 -0
  60. package/src/docs/versioned/next/how-to/add-github-oauth.md +49 -16
  61. package/src/docs/versioned/next/how-to/admin-add-model.md +4 -1
  62. package/src/docs/versioned/next/how-to/ai-feature-development.md +5 -0
  63. package/src/docs/versioned/next/how-to/background-jobs.md +18 -5
  64. package/src/docs/versioned/next/how-to/circleci.md +39 -3
  65. package/src/docs/versioned/next/how-to/configure-better-auth.md +283 -5
  66. package/src/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
  67. package/src/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
  68. package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
  69. package/src/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
  70. package/src/docs/versioned/next/how-to/install-agent-skills.md +8 -1
  71. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
  72. package/src/docs/versioned/next/how-to/password-reset.md +8 -2
  73. package/src/docs/versioned/next/how-to/product-announcements.md +5 -3
  74. package/src/docs/versioned/next/how-to/rate-limiting.md +2 -2
  75. package/src/docs/versioned/next/how-to/run-tests-locally.md +50 -0
  76. package/src/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
  77. package/src/docs/versioned/next/how-to/websocket-integration.md +42 -9
  78. package/src/docs/versioned/next/how-to/whats-new-banner.md +55 -0
  79. package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
  80. package/src/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
  81. package/src/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
  82. package/src/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
  83. package/src/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
  84. package/src/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
  85. package/src/docs/versioned/next/reference/admin-backend.md +46 -0
  86. package/src/docs/versioned/next/reference/admin-config.md +2 -2
  87. package/src/docs/versioned/next/reference/ai.md +48 -4
  88. package/src/docs/versioned/next/reference/api.md +410 -12
  89. package/src/docs/versioned/next/reference/blocks.md +1 -1
  90. package/src/docs/versioned/next/reference/comms.md +19 -1
  91. package/src/docs/versioned/next/reference/environment-variables.md +6 -1
  92. package/src/docs/versioned/next/reference/feature-flags.md +1 -1
  93. package/src/docs/versioned/next/reference/jobs.md +8 -6
  94. package/src/docs/versioned/next/reference/legacy/rtk.md +145 -18
  95. package/src/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
  96. package/src/docs/versioned/next/reference/syncdb.md +64 -3
  97. package/src/docs/versioned/next/reference/ui.md +23 -1
@@ -68,7 +68,8 @@ setupServer({
68
68
  clientSecret: process.env.GITHUB_CLIENT_SECRET!,
69
69
  callbackURL: process.env.GITHUB_CALLBACK_URL || "http://localhost:4000/auth/github/callback",
70
70
  scope: ["user:email"], // Optional, defaults to ["user:email"]
71
- allowAccountLinking: true, // Optional, defaults to true
71
+ allowAccountLinking: true, // Optional
72
+ allowedReturnTo: ["https://app.yourapp.com", "yourapp://auth"], // Optional, needed for returnTo
72
73
  },
73
74
  });
74
75
  ``````
@@ -81,7 +82,8 @@ setupServer({
81
82
  | `clientSecret` | string | Yes | GitHub OAuth Client Secret |
82
83
  | `callbackURL` | string | Yes | OAuth callback URL (must match GitHub app settings) |
83
84
  | `scope` | string[] | No | OAuth scopes (default: `["user:email"]`) |
84
- | `allowAccountLinking` | boolean | No | Allow linking GitHub to existing accounts (default: `true`) |
85
+ | `allowAccountLinking` | boolean | No | `true` lets `GET /auth/github/link` attach GitHub to the signed-in user. When omitted, the link route is mounted but linking returns 400, and a GitHub login whose email matches an existing account links to it. `false` turns off both. |
86
+ | `allowedReturnTo` | string[] | No | Exact http(s) origins (`https://app.yourapp.com`) or custom-scheme authorities (`yourapp://auth`) that `returnTo` may point to. Unset means `returnTo` is ignored. |
85
87
  | `findOrCreateUser` | function | No | Custom user creation/lookup handler |
86
88
 
87
89
  ## Step 4: Set Environment Variables
@@ -106,8 +108,20 @@ The following routes are automatically added when `githubAuth` is configured:
106
108
  GET /auth/github
107
109
  ``````
108
110
 
109
- Redirects users to GitHub for authentication. Optional query parameter:
110
- - `returnTo` — URL to redirect after successful authentication (tokens will be appended as query params)
111
+ Redirects users to GitHub for authentication. The redirect carries an OAuth `state` value, and
112
+ the response sets a short-lived (10 minute), signed, HttpOnly, `SameSite=Lax` cookie named
113
+ `terreno_github_oauth_state` that holds the same value. The callback accepts the GitHub response
114
+ only from the browser that holds that cookie, and each cookie works once. The cookie is signed
115
+ with `SESSION_SECRET` (or `TOKEN_SECRET` when `SESSION_SECRET` is unset).
116
+
117
+ Optional query parameter:
118
+ - `returnTo` — URL to redirect to after successful authentication. Its origin (or custom-scheme
119
+ authority) must be listed in `allowedReturnTo`; any other value returns 400. When
120
+ `allowedReturnTo` is unset, `returnTo` is ignored and the callback returns JSON. Tokens are
121
+ delivered in the URL fragment (`#token=...&refreshToken=...&userId=...`), never the query
122
+ string. `returnTo` is kept in `req.session` between the two requests, so it needs a
123
+ persistent session middleware (for example `express-session`) mounted through
124
+ `beforeJsonSetup` or `addMiddleware`; TerrenoApp ships none.
111
125
 
112
126
  ### OAuth Callback
113
127
 
@@ -115,7 +129,7 @@ Redirects users to GitHub for authentication. Optional query parameter:
115
129
  GET /auth/github/callback
116
130
  ``````
117
131
 
118
- Handles the GitHub OAuth callback. Returns JSON with tokens:
132
+ Handles the GitHub OAuth callback. Without an allowlisted `returnTo`, returns JSON with tokens:
119
133
 
120
134
  ``````json
121
135
  {
@@ -134,7 +148,12 @@ GET /auth/github/link
134
148
  Authorization: Bearer <jwt-token>
135
149
  ``````
136
150
 
137
- Links GitHub to the authenticated user's existing account. Redirects to GitHub, then back to callback.
151
+ Links GitHub to the authenticated user's existing account. Redirects to GitHub, then back to
152
+ the callback. The user ID from the bearer token is recorded in the signed state cookie, and the
153
+ callback links GitHub only to that user. A session cookie or token that happens to be on the
154
+ callback request is never used as the link target. Open this route in the same browser that will
155
+ return to the callback, so the state cookie is present there. Requires
156
+ `allowAccountLinking: true`.
138
157
 
139
158
  ### Unlink GitHub Account (Requires Auth)
140
159
 
@@ -164,7 +183,7 @@ Removes GitHub authentication from the user's account. Returns:
164
183
  3. User authorizes on GitHub
165
184
  4. Backend receives callback, creates new user with GitHub profile data
166
185
  5. Backend generates JWT tokens
167
- 6. Backend redirects to `returnTo` URL with tokens in query params
186
+ 6. Backend checks `returnTo` against `allowedReturnTo` again and redirects there with tokens in the URL fragment
168
187
  7. Frontend stores tokens and logs user in
169
188
 
170
189
  ### Existing User Login
@@ -195,7 +214,8 @@ setupServer({
195
214
  // - profile: GitHub profile data
196
215
  // - accessToken: GitHub access token
197
216
  // - refreshToken: GitHub refresh token (if available)
198
- // - existingUser: If user is authenticated, their user document
217
+ // - existingUser: The user who started GET /auth/github/link (from the verified
218
+ // OAuth state), otherwise undefined
199
219
 
200
220
  if (existingUser) {
201
221
  // Link GitHub to existing user
@@ -243,6 +263,7 @@ const handleGitHubLogin = async () => {
243
263
  });
244
264
 
245
265
  if (result.type === "success") {
266
+ // Tokens arrive in the fragment; expo-auth-session merges fragment values into params
246
267
  const {token, refreshToken, userId} = result.params;
247
268
  // Store tokens and log in user
248
269
  }
@@ -258,12 +279,13 @@ const handleGitHubLogin = () => {
258
279
  window.location.href = `https://api.yourapp.com/auth/github?returnTo=${window.location.origin}/auth/callback`;
259
280
  };
260
281
 
261
- // On callback page (/auth/callback)
262
- const urlParams = new URLSearchParams(window.location.search);
263
- const token = urlParams.get("token");
264
- const refreshToken = urlParams.get("refreshToken");
265
- const userId = urlParams.get("userId");
266
- // Store tokens and redirect to app
282
+ // On callback page (/auth/callback). Tokens are in the fragment, not the query string.
283
+ const fragment = new URLSearchParams(window.location.hash.slice(1));
284
+ const token = fragment.get("token");
285
+ const refreshToken = fragment.get("refreshToken");
286
+ const userId = fragment.get("userId");
287
+ // Clear the fragment, store tokens, and redirect to app
288
+ window.history.replaceState(null, "", window.location.pathname);
267
289
  ``````
268
290
 
269
291
  ## Creating a GitHub OAuth App
@@ -284,8 +306,9 @@ const userId = urlParams.get("userId");
284
306
  ## Security Considerations
285
307
 
286
308
  - **Never expose client secrets** — They should only exist on the backend
309
+ - **State is enforced** — The callback rejects a response without a valid, unexpired state cookie from the same browser (redirect to `/auth/github/failure`), which blocks login CSRF and cross-site account linking
287
310
  - **Use HTTPS in production** — GitHub OAuth requires HTTPS for production callbacks
288
- - **Validate redirect URLs** — Only allow trusted domains in `returnTo` parameters
311
+ - **Redirect allowlist** — List only your own app origins in `allowedReturnTo`. Matching is exact on origin (or custom-scheme authority); subdomains and other ports are not included
289
312
  - **Scope minimization** — Only request GitHub scopes your app actually needs
290
313
  - **Token storage** — Store tokens securely on the frontend (SecureStore on mobile, httpOnly cookies on web)
291
314
 
@@ -301,13 +324,23 @@ const userId = urlParams.get("userId");
301
324
 
302
325
  A different user has already linked this GitHub account. Users must unlink it first or use a different GitHub account.
303
326
 
327
+ ### Callback redirects to `/auth/github/failure` right away
328
+
329
+ The OAuth state did not verify. The flow must start (`/auth/github` or `/auth/github/link`) and
330
+ finish (`/auth/github/callback`) in the same browser within 10 minutes, and the API host that
331
+ serves `callbackURL` must be the host that set the cookie. Check that `SESSION_SECRET` is the
332
+ same on every instance.
333
+
304
334
  ### "Account linking is disabled"
305
335
 
306
336
  Set `allowAccountLinking: true` in `githubAuth` configuration.
307
337
 
308
338
  ### Tokens not included in callback
309
339
 
310
- If using `returnTo`, tokens are appended as query parameters. Check that your frontend is reading them correctly.
340
+ Tokens are in the URL fragment (`window.location.hash`), not the query string. If the callback
341
+ returns JSON instead of redirecting, check that `allowedReturnTo` lists the `returnTo` origin and
342
+ that a session middleware keeps `req.session` between `/auth/github` and the callback. A 400
343
+ "returnTo is not an allowed redirect target" means the origin is not listed.
311
344
 
312
345
  ## Learn More
313
346
 
@@ -49,4 +49,7 @@ Common fields:
49
49
  sentinel is consumed before Mongo receives the query.
50
50
  - `fieldsets`: grouped form sections.
51
51
  - `readonlyFields` / `hiddenFields`: display-only or omitted form fields.
52
- - `adminPermissions`: optional admin-specific permission methods.
52
+ - `adminPermissions`: optional per-action (`list`, `read`, `create`, `update`, `delete`)
53
+ permission methods. They run in addition to the admin check (`admin:access` plus the model's
54
+ RBAC action, or `IsAdmin`), so they can only restrict access. An empty array disables that
55
+ action, including `list` and `read`.
@@ -108,6 +108,11 @@ await aiService.generateText({
108
108
 
109
109
  Missing `production` fails the request (400). Do not fall back to a hardcoded string.
110
110
 
111
+ 3. Bound cost before users reach the AI routes. `@terreno/ai` has no per-user token or spend
112
+ limit, and a chat reply keeps running after the client disconnects. Enable `TerrenoApp`
113
+ `rateLimit` and set a provider budget or quota alert; see
114
+ [Cost and abuse limits](../reference/ai.md#cost-and-abuse-limits).
115
+
111
116
  ## 7. Production tracing and feedback
112
117
 
113
118
  Do this in parallel with steps 1–6 once the feature is reachable.
@@ -46,7 +46,10 @@ entrypoint.
46
46
  When `JobsApp` is registered **before** `AdminApp`, `POST {adminBase}/scripts/:name/run`
47
47
  enqueues job `admin/script` (`maxAttempts: 1`) and still returns `{taskId}` for the existing
48
48
  Scripts poll UI (`BackgroundTask`). Progress, logs, dry/wet, and cancel stay on that row.
49
- Cancel also cancels the job so `ctx.signal` aborts.
49
+ Cancel also cancels the job so `ctx.signal` aborts. The job handler starts the script only
50
+ when it moves the task from `pending` to `running`; a delivery for a task that already
51
+ started fails with 409 and the script is not run again. Start a new run instead of retrying
52
+ a dead `admin/script` job.
50
53
 
51
54
  Apps without `JobsApp` keep the previous in-process `void` runner. The CLI
52
55
  (`runScriptCli` / `bun run script`) stays process-local.
@@ -114,8 +117,17 @@ On shutdown, call `jobsApp.stopWorker()` so in-flight handlers receive `ctx.sign
114
117
 
115
118
  ## Mongo runner: restart, retry, idempotency, schedules
116
119
 
117
- **Restart:** Jobs are Mongo documents. A crashed worker leaves `running` rows; after
118
- `lockTtlMs` (default 15 minutes) another worker reclaims them via `lockedAt` expiry.
120
+ **Lease:** A claimed job holds a lease on its row (`lockedAt` / `lockedBy`). While the
121
+ handler runs, the worker renews `lockedAt` every `lockTtlMs / 3`, so a long handler keeps its
122
+ claim and no other worker or execute dispatch runs it at the same time. If a renewal finds
123
+ that another consumer owns the row, the worker aborts `ctx.signal` and discards its own
124
+ result. A finalize failure on one job is logged and does not stop the poll loop.
125
+
126
+ **Restart:** Jobs are Mongo documents. A crashed worker leaves `running` rows; once their
127
+ lease is older than `lockTtlMs` (default 15 minutes) another worker reclaims them. A reclaim
128
+ counts as an attempt (`attemptCount++`, `attempts[]` entry with `errorClass: "LeaseExpired"`).
129
+ When that exhausts `maxAttempts`, the job goes to `status: "dead"` instead of running again,
130
+ so a `maxAttempts: 1` job never runs twice.
119
131
 
120
132
  **Retry:** Handler throws → `attemptCount++`, `attempts[]` entry, exponential backoff with
121
133
  equal jitter, `status: "pending"` and future `runAt`. At `maxAttempts` (default 5) →
@@ -130,8 +142,9 @@ tick when a child for that schedule is still `pending` / `running` / `scheduled`
130
142
 
131
143
  ## At-least-once handlers
132
144
 
133
- Delivery is **at-least-once**: lock expiry, Cloud Task retries, or Vercel queue redelivery
134
- can run a handler more than once.
145
+ Delivery is **at-least-once**: a reclaim after a worker crash (within `maxAttempts`),
146
+ Cloud Task retries, or Vercel queue redelivery can run a handler more than once. A live
147
+ handler is never run concurrently by a second consumer while its lease renews.
135
148
 
136
149
  - Make handlers idempotent (stable keys, upserts, “already processed” checks).
137
150
  - Use `idempotencyKey` on enqueue to prevent duplicate **rows**, not duplicate execution after admin retry/requeue.
@@ -239,7 +239,8 @@ restrict `terreno-release` and `terreno-npm` to tag/manual release pipelines.
239
239
  | `terreno-e2e` | `E2E_TOKEN_SECRET`, `E2E_REFRESH_TOKEN_SECRET`, `E2E_SESSION_SECRET` | `e2e`, `admin-spa-integration`, `maestro-e2e` |
240
240
  | `terreno-release` | `REPO_ADMIN_TOKEN`, `ZOOM_WEBHOOK_URL`, `ZOOM_WEBHOOK_TOKEN` | stable version bump + release notification |
241
241
  | `terreno-agentic` | `CURSOR_API_KEY`, optional `CURSOR_MODEL` | `architectural-pr-review` |
242
- | `terreno-github-api` | PAT (`pull-requests`, `contents`, …) | `dco`, `architectural-pr-review` |
242
+ | `terreno-github-api` | PAT (`pull-requests`, `contents`, …) | `dco`, `architectural-pr-review`, `approve` |
243
+ | `terreno-approve` | `TERRENO_APP_ID`, `TERRENO_APP_PRIVATE_KEY` (the terreno-approval-agent GitHub App), `ANTHROPIC_API_KEY` | `approve` |
243
244
  | `terreno-github-deployments` | `GITHUB_DEPLOYMENTS_TOKEN`: fine-grained PAT on `TerrenoLabs/terreno` with **Deployments: read/write** and **Pull requests: read** only | GitHub Deployment records + preview PR lookup in every Netlify/GCP deploy job |
244
245
 
245
246
  Until contexts exist, e2e jobs may use **project env vars for `E2E_*` secrets only**
@@ -247,8 +248,9 @@ Until contexts exist, e2e jobs may use **project env vars for `E2E_*` secrets on
247
248
  GitHub PAT) in project env vars — those are injected into every job, including
248
249
  `bun` scripts from the PR. `terreno-github-api` must hold the PAT as
249
250
  `GITHUB_TOKEN`; restrict it to this project and leave fork-PR secret passing off.
250
- The `always` workflow attaches it **only** to `dco` and `architectural-pr-review`
251
- (the latter also gets `terreno-agentic`). DCO skips if `GITHUB_TOKEN` is unset.
251
+ The `always` workflow attaches it **only** to `dco`, `architectural-pr-review`
252
+ (which also gets `terreno-agentic`), and `approve` (which also gets `terreno-approve`).
253
+ DCO skips if `GITHUB_TOKEN` is unset.
252
254
  `architectural-pr-review` also skips if `GITHUB_TOKEN` or `CURSOR_API_KEY` is
253
255
  unset, and it skips fork PRs. The job checks out `origin/master` before running
254
256
  the review script so a PR cannot rewrite the reviewer.
@@ -293,6 +295,7 @@ the same commands through the parameterized `packages-ci` job, gated by
293
295
  | Repository policies (barrels, source rules, explicit any, licenses, changelog, parity, lifecycle, static analysis) | `repo-policies` |
294
296
  | Verify rules are in sync | `rulesync-check` |
295
297
  | `dco` | `dco` |
298
+ | (none) | `approve` |
296
299
  | Run all tests (API CI) | `api-ci` |
297
300
  | Admin backend lint, compile, coverage | `packages-ci` (`admin-backend`) |
298
301
  | Admin frontend lint, compile, coverage | `packages-ci` (`admin-frontend`) |
@@ -339,6 +342,39 @@ CD replacement map:
339
342
  | Publish on tag (`publish-on-tag.yml`) | `publish-release` |
340
343
  | Appium Android / iOS | Not ported (Maestro web is `maestro-e2e`) |
341
344
 
345
+ ## Approval agent
346
+
347
+ `approve` runs `brewery approve` from
348
+ [joshgachnang/brewery](https://github.com/joshgachnang/brewery) on every same-repo PR
349
+ pipeline and posts one review as the **terreno-approval-agent** GitHub App plus a
350
+ `terreno/approve` check run. Policy lives in `.brewery.json` (`approve` block: levels
351
+ the App may approve, path rules that floor, cap, or forbid approval; when a file matches
352
+ both a floor and a lower ceiling, the floor wins) and
353
+ `docs/approval-rubric.md` (what the model scores). Both are read from `origin/master`,
354
+ so a PR cannot change the rules it is judged by, and a PR that edits either file is
355
+ never approved. The job resets its working tree to `origin/master` right after
356
+ checkout, so the PR-number script, `bunfig.toml`, and the agent's working directory all
357
+ come from master and nothing from the PR tree runs while the App key is in the
358
+ environment; brewery reads the head through `git show <sha>:`. Brewery itself is
359
+ pinned to a commit (`BREWERY_REF`) and cloned into `$HOME`; the model runs read-only
360
+ with an allowlisted environment that holds no GitHub or App credential. The job waits
361
+ (`--wait-checks`, up to 45 minutes) for every other check on the head before judging,
362
+ ignoring its own `ci/circleci: approve` check (`selfChecks` in `.brewery.json`), and
363
+ skips a red or still-pending head without spending a model, so it runs once per push,
364
+ last.
365
+
366
+ To teach the agent from what reviewers actually say, run
367
+ `brewery calibrate TerrenoLabs/terreno` (optionally `--reviewer <login>` or `--notes
368
+ <file>` of pasted comments): it opens one PR editing `docs/approval-rubric.md` and the
369
+ path rules from the review comments since the last pass.
370
+
371
+ Rollout: `approveLevels` is `[]`, so every PR gets a `COMMENT` review that says the
372
+ risk level and why, and nothing approves. After a dozen PRs, compare the levels with
373
+ how humans reviewed them, tune the path rules and rubric, then set
374
+ `"approveLevels": ["low"]` in a one-line PR. The prompts and answers behind each
375
+ verdict are stored as the `brewery-approve` job artifact. Full reference:
376
+ [brewery `docs/approve.md`](https://github.com/joshgachnang/brewery/blob/master/docs/approve.md).
377
+
342
378
  ## Manual pipelines
343
379
 
344
380
  Trigger a pipeline from the CircleCI UI/API on the ref containing the code to
@@ -97,7 +97,10 @@ email-verification mail match the JWT recovery templates. The hooks throw 501 an
97
97
  `publicAppUrl` is missing. JWT `POST /auth/resetPassword` updates the Better Auth password and
98
98
  deletes Better Auth sessions when this plugin is registered. Better Auth password reset updates
99
99
  the JWT password and `tokenEpoch` for the matching app User so dual-enrolled accounts cannot keep
100
- the old passport hash.
100
+ the old passport hash. Better Auth `change-password` does the same for an app User that already
101
+ has a JWT password, and bumps `tokenEpoch` for every app User. `revoke-sessions` and
102
+ `revoke-other-sessions` bump `tokenEpoch`, so JWT refresh tokens issued before them stop working.
103
+ An account with only a Better Auth password does not gain a JWT password.
101
104
 
102
105
  ```typescript
103
106
  import {getCommsService, renderAuthMail} from "@terreno/comms";
@@ -117,6 +120,51 @@ Throwaway in-process seed apps (CLI / container smoke tests that call Better Aut
117
120
  sign-up many times) should set `disableRateLimit: true` on that instance only.
118
121
  Better Auth enables its limiter in production, which 429s the fourth seeded user.
119
122
 
123
+ #### Pass other Better Auth options and plugins
124
+
125
+ `BetterAuthConfig` covers the settings Terreno manages. For anything else, use `options` and
126
+ `plugins` instead of replacing `BetterAuthApp`:
127
+
128
+ - `options` takes any Better Auth option except `basePath` and `plugins`, with nested fields
129
+ optional. It is deep-merged over the options Terreno builds. Arrays (such as `trustedOrigins`)
130
+ replace Terreno's instead of merging. An explicit value wins; an `undefined` value is ignored,
131
+ so it cannot clear a default.
132
+ - `plugins` are appended after Terreno's `bearer()` plugin. Their endpoints mount under
133
+ `basePath`, like the built-in ones.
134
+
135
+ ``````typescript
136
+ const config: BetterAuthConfig = {
137
+ enabled: true,
138
+ options: {
139
+ emailAndPassword: {
140
+ requireEmailVerification: true,
141
+ // Carry over legacy password hashes during a migration
142
+ password: {hash: hashPassword, verify: verifyPassword},
143
+ },
144
+ databaseHooks: {user: {create: {after: async (user) => onUserCreated(user)}}},
145
+ },
146
+ plugins: [myBetterAuthPlugin()],
147
+ };
148
+ ``````
149
+
150
+ | Terreno default | Set from | What overriding it drops |
151
+ | --- | --- | --- |
152
+ | `database` | MongoDB adapter on the app's connection, in the connection's database (the `dbName` passed to `mongoose.connect`, else the URI's database) | Users and sessions in the app's database |
153
+ | `emailAndPassword.enabled` | `true` | Email/password sign-in |
154
+ | `emailAndPassword.revokeSessionsOnPasswordReset` | `true` | Signing out other sessions after a password reset |
155
+ | `emailAndPassword.sendResetPassword` | `sendMail` | Terreno's reset email |
156
+ | `emailAndPassword.onPasswordReset` | `userModel` | Syncing the new password to JWT credentials |
157
+ | `terreno-credential-sync` plugin (always registered with `userModel`) | `userModel` | Not overridable; syncs change-password and session revocation to JWT credentials |
158
+ | `emailVerification.sendOnSignUp`, `emailVerification.sendVerificationEmail` | `sendMail` | Terreno's verification email on sign-up |
159
+ | `session.cookieCache` | `{enabled: true, maxAge: 300}` | The 5-minute session cookie cache |
160
+ | `advanced.defaultCookieAttributes` | `crossDomainCookies` | `SameSite=None; Secure` cookies for cross-site frontends |
161
+ | `rateLimit` | `disableRateLimit` | Turning Better Auth's limiter off |
162
+ | `socialProviders` | `googleOAuth`, `githubOAuth`, `appleOAuth` | The configured social providers (`options` adds to them per provider) |
163
+ | `trustedOrigins` | `trustedOrigins` | The origins listed in the config |
164
+
165
+ Endpoints added by `plugins` are not part of the `auth.api` type. Call them over HTTP, or cast
166
+ `auth.api` where you call them on the server.
167
+
120
168
  ### 3. Register BetterAuthApp Plugin
121
169
 
122
170
  Use the TerrenoApp plugin system:
@@ -143,10 +191,16 @@ if (betterAuthConfig) {
143
191
  const server = app.start();
144
192
  ``````
145
193
 
194
+ `RealtimeApp` (registered directly or through `TerrenoApp({realtime})`) uses this plugin's Better Auth instance for socket sessions. Do not build a second instance with `createBetterAuth` for sockets.
195
+
196
+ `BetterAuthApp` keeps Better Auth's `user`, `account`, `session`, and `verification` collections in the mongoose connection's database, so a `dbName` passed to `mongoose.connect` (the example backend's `MONGO_DB_NAME`) isolates them along with your models. Earlier releases wrote them to the connection string's default database instead. If your app connects with a `dbName` that differs from the URI's database, copy those four collections into the `dbName` database when you upgrade, or users must sign up and sign in again.
197
+
146
198
  ### 4. Update User Model
147
199
 
148
200
  `syncBetterAuthUser` creates the app `User` on the first authenticated request after Better Auth sign-up. Declare `betterAuthId` on that schema. Declare `oauthProvider` only if the app uses social OAuth.
149
201
 
202
+ To lock an account out, set `disabled: true` on its app `User` (for example with `isDisabledPlugin`). That user's Better Auth sessions stop authenticating: `req.user` is not set, so protected routes answer 401. Sign-out still works.
203
+
150
204
  Email/password sync does **not** write `oauthProvider`. A `strict: "throw"` schema that omits the field stays valid. Social login still passes a provider string and requires the field.
151
205
 
152
206
  ``````typescript
@@ -169,27 +223,46 @@ const userSchema = new mongoose.Schema(
169
223
  ``````typescript
170
224
  import {createBetterAuthClient} from "@terreno/rtk";
171
225
 
226
+ // `scheme` is the app's deep-link scheme (required).
172
227
  export const authClient = createBetterAuthClient({
173
228
  baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:4000",
229
+ scheme: "yourapp",
174
230
  });
175
231
  ``````
176
232
 
233
+ On web the client authenticates with Better Auth's session cookie. When the web frontend and
234
+ the API are on different sites, use [bearer mode](#frontend-and-api-on-different-sites).
235
+
236
+ A cookie-authenticated `POST`, `PUT`, `PATCH`, or `DELETE` to any Terreno route is
237
+ authenticated only when its `Origin` (or, without one, its `Referer`) is in `trustedOrigins` or
238
+ is the API's own origin. Otherwise `req.user` stays unset and protected routes answer 401, so
239
+ another site cannot submit a form that writes as the signed-in user. Add every web origin that
240
+ uses cookie mode to `trustedOrigins`. Requests with an `Authorization: Bearer` session token,
241
+ safe methods, and clients that send no `Origin`, `Referer`, or cross-site `Sec-Fetch-Site`
242
+ (native apps, server scripts) are not affected. Better Auth's `advanced.disableCSRFCheck` turns
243
+ the check off. A `trustedOrigins` function is not consulted; list the origins.
244
+
177
245
  ### 2. Add Session Redux Slice
178
246
 
179
247
  ``````typescript
180
248
  import {generateBetterAuthSlice} from "@terreno/rtk";
181
249
  import {configureStore} from "@reduxjs/toolkit";
182
250
  import {authClient} from "./authClient";
251
+ import {api} from "./sdk"; // the app's generated RTK Query API
183
252
 
184
- const {sessionReducer, sessionMiddleware} = generateBetterAuthSlice(authClient);
253
+ // Purge the SDK's cached responses on logout or user switch.
254
+ const betterAuth = generateBetterAuthSlice({apisToReset: [api], authClient});
185
255
 
186
256
  export const store = configureStore({
187
257
  reducer: {
188
- session: sessionReducer,
258
+ betterAuth: betterAuth.reducer, // selectors read `state.betterAuth`
189
259
  // ... other reducers
190
260
  },
191
- middleware: (getDefault) => getDefault().concat(sessionMiddleware),
261
+ middleware: (getDefault) => getDefault().concat(...betterAuth.middleware),
192
262
  });
263
+
264
+ // Call after sign-in, sign-out, and at app startup to copy the session into Redux
265
+ void betterAuth.syncSession(store.dispatch);
193
266
  ``````
194
267
 
195
268
  ### 3. Add Social Login UI
@@ -237,6 +310,201 @@ const LoginScreen = () => {
237
310
  };
238
311
  ``````
239
312
 
313
+ ## Frontend and API on different sites
314
+
315
+ By default the web client relies on Better Auth's HttpOnly session cookie. When the web app
316
+ and the API are on different sites (for example `app.example.com` and `api.example.net`, or
317
+ two hosting providers' domains), that cookie is a third-party cookie, and browsers such as
318
+ Safari block it. The user signs in and is signed out on the next request.
319
+
320
+ Use bearer mode in that case. Keep cookie mode when the frontend and API share a site.
321
+
322
+ ``````typescript
323
+ import {createBetterAuthClient} from "@terreno/rtk";
324
+
325
+ export const authClient = createBetterAuthClient({
326
+ baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:4000",
327
+ scheme: "yourapp",
328
+ webAuth: "bearer",
329
+ });
330
+ ``````
331
+
332
+ With `webAuth: "bearer"`, on web only:
333
+
334
+ - The client stores the `set-auth-token` response header from sign-in under
335
+ `${storagePrefix}_bearer_token` (default `terreno_bearer_token`) in `localStorage`.
336
+ - Every Better Auth client call, `getSession` included, sends `Authorization: Bearer <token>`
337
+ and no cookies (`credentials: "omit"`).
338
+ - `signOut()` forgets the token before its request is sent, so a failed sign-out never leaves
339
+ a usable token on the device.
340
+ - A successful `getSession` that returns no session clears the token, unless a newer token was
341
+ stored while that request was in flight. A failed or 5xx `getSession` keeps it.
342
+
343
+ Native apps ignore `webAuth`: the Expo plugin already keeps the session without browser cookies.
344
+
345
+ Bearer mode covers email and password sign-in directly. Social and generic OAuth sign-in end
346
+ with a redirect, so the page never sees `set-auth-token`. They need the
347
+ [one-time-token handoff](#social-sign-in-in-bearer-mode) below.
348
+
349
+ Apps that handle PHI should pass `bearerStorage: window.sessionStorage` and serve a strict
350
+ Content-Security-Policy. `localStorage` is readable by any XSS on the frontend's origin and
351
+ outlives the tab, so a stolen or forgotten token stays usable after the person closes it.
352
+
353
+ Read the stored token outside the client, without a network call, with
354
+ `getBetterAuthBearerToken({storagePrefix?, bearerStorage?})`. Pass the same options the client
355
+ was created with.
356
+
357
+ example-frontend shows the whole setup. `EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true` turns on
358
+ bearer mode in `lib/betterAuth.ts`. `store/syncdb.ts` passes the stored token to
359
+ `betterAuthAdapter` as `getBearerToken`, and `store/index.ts` registers that adapter with
360
+ `setTokenSource` before it creates the store, so RTK Query, the socket and MCP tools send the
361
+ same token. `e2e/crossSiteBearer.spec.ts` signs in from `localhost:8082` to an API on
362
+ `127.0.0.1:4000` with every `set-cookie` header stripped, then reloads.
363
+
364
+ ### Server requirements
365
+
366
+ - The web app's origin must be in `trustedOrigins`. A cookie-less sign-in sends an `Origin`
367
+ header, and Better Auth rejects an unlisted origin with 403 `INVALID_ORIGIN`:
368
+
369
+ ``````typescript
370
+ const config: BetterAuthConfig = {
371
+ enabled: true,
372
+ trustedOrigins: ["https://app.example.com", "yourapp://", "exp://"],
373
+ };
374
+ ``````
375
+
376
+ Better Auth skips this check when `NODE_ENV=test`, so test it against a non-test build.
377
+ - No CORS change is needed. Bearer mode sends no cookies, so the default `corsOrigin` (`"*"`)
378
+ works, and so does an explicit list that includes the web origin.
379
+ - No header change is needed. Better Auth's bearer plugin, which `BetterAuthApp` enables, lists
380
+ `set-auth-token` in `Access-Control-Expose-Headers` itself, so the page can read it.
381
+
382
+ ### Social sign-in in bearer mode
383
+
384
+ The server adds a single-use, three-minute token to the OAuth callback's redirect, in the URL
385
+ fragment, and the web app exchanges it for the bearer. rtk binds each exchange to a random
386
+ nonce the tab stored when it started the sign-in, so a token planted in a link is never sent.
387
+
388
+ 1. On the server, list the web app's origin and the one page that reads the token. The origin
389
+ must also be in `trustedOrigins`:
390
+
391
+ ``````typescript
392
+ const config: BetterAuthConfig = {
393
+ enabled: true,
394
+ oauthHandoff: {
395
+ callbackPaths: ["/"], // the default; list only the page that runs the steps below
396
+ webOrigins: ["https://app.example.com"],
397
+ },
398
+ trustedOrigins: ["https://app.example.com", "yourapp://", "exp://"],
399
+ };
400
+ ``````
401
+
402
+ `webOrigins` takes exact `http(s)` origins. `callbackPaths` takes exact paths in normal form
403
+ (no trailing-slash or case variants). The query is free. Any other return page is refused.
404
+
405
+ 2. Start the sign-in from the buttons with `signInSocialWithHandoff`. It stores the nonce in
406
+ `sessionStorage` and navigates the page to `GET {basePath}/oauth-handoff/start`, which keeps
407
+ Better Auth's OAuth state cookie first-party on the API's origin. The start endpoint answers
408
+ 400 when a request's `Sec-Fetch-Dest` is set and is not `document`, so open it as a page
409
+ navigation, never with `fetch`, an `<img>`, or an `<iframe>`:
410
+
411
+ ``````typescript
412
+ import {signInSocialWithHandoff} from "@terreno/rtk";
413
+
414
+ <SocialLoginButton
415
+ provider="google"
416
+ onPress={() =>
417
+ signInSocialWithHandoff({
418
+ callbackURL: "/", // must be one of callbackPaths; defaults to "/" on this origin
419
+ client: authClient,
420
+ errorCallbackURL: "/login", // optional; defaults to callbackURL
421
+ provider: "google",
422
+ })
423
+ }
424
+ />
425
+ ``````
426
+
427
+ It throws on native, in cookie mode, and when `sessionStorage` is blocked. Keep
428
+ `authClient.signIn.social` on native and in cookie mode.
429
+
430
+ 3. On the return page, await `completeBetterAuthOAuthHandoff` at startup, before anything
431
+ reads the session, before any code acts on other query parameters (such as `next=`), and
432
+ before Sentry, analytics, or other third-party scripts start:
433
+
434
+ ``````typescript
435
+ import {completeBetterAuthOAuthHandoff} from "@terreno/rtk";
436
+
437
+ await completeBetterAuthOAuthHandoff({client: authClient});
438
+ await betterAuth.syncSession(store.dispatch);
439
+ initSentry();
440
+ ``````
441
+
442
+ When the URL has `#ott=<token>` and a `handoff=` equal to the stored nonce, it removes
443
+ `ott`, `handoff` and the stored nonce with `history.replaceState`, then posts the token to
444
+ `{basePath}/one-time-token/verify`. Bearer mode stores the `set-auth-token` that comes back.
445
+ It resolves `true` only when a token was stored, and it never rejects. Other query and
446
+ fragment parts stay. A token without a matching nonce, or an `ott` in the query instead of the
447
+ fragment, is removed from the URL and never sent.
448
+ Calls made while one is running share its result, so React strict mode verifies once.
449
+
450
+ 4. Show sign-in errors from the query. A failed start or callback comes back with
451
+ `?error=provider_not_found`, `?error=sign_in_failed`, or one of Better Auth's own `error=`
452
+ codes. The function resolves `false` and leaves `error` in the URL.
453
+
454
+ The return page must not redirect, on the server or in the browser, before the function has
455
+ run. A browser carries the fragment through a redirect that has none of its own, so a redirect
456
+ would hand the token to the next page. For the same reason, keep `callbackPaths` to that page.
457
+
458
+ example-frontend uses `/login` as the return page. With `BETTER_AUTH_OAUTH_HANDOFF=true`,
459
+ example-backend sets `oauthHandoff` to its web origins and `callbackPaths: ["/login"]`. With
460
+ `NODE_ENV=production` it drops loopback origins (`localhost`, `127.0.0.1`, `[::1]`, any port) from
461
+ the handoff only, so a token never goes to a page on the visitor's own machine, and it refuses to
462
+ start when no other origin is left in `CORS_ORIGINS`. CORS and `trustedOrigins` keep them. In bearer mode `lib/betterAuth.ts` starts the social buttons with
463
+ `signInSocialWithHandoff`. `app/_layout.tsx` syncs the session only after the exchange resolves.
464
+
465
+ In an Expo Router app, run the exchange from the entry file, not from a layout. Expo Router
466
+ copies the URL, fragment included, as soon as it is imported, and writes that copy back into
467
+ the address bar when it routes, which would put `ott` and `handoff` back. Importing
468
+ `@terreno/rtk` imports Expo Router too (through `@terreno/ui`). example-frontend's `index.js`
469
+ first imports `lib/oauthHandoffUrl.ts`, which has no imports and hides `handoff` and `ott` from
470
+ the URL, then `lib/oauthHandoff.ts`, which puts the URL back and calls
471
+ `completeBetterAuthOAuthHandoff` in the same synchronous step, then `expo-router/entry`.
472
+
473
+ The login screen shows `?error=` in `login-screen-error`. `e2e/crossSiteOAuth.spec.ts` signs in
474
+ cross-site through a local mock OIDC provider (`e2e/helpers/mockOidcProvider.ts`), which
475
+ example-backend registers as the `mock-oidc` generic OAuth provider only when `MOCK_OIDC_ISSUER`
476
+ is set. example-backend refuses to start when `MOCK_OIDC_ISSUER` is set with
477
+ `NODE_ENV=production` or names a host other than `127.0.0.1`, `localhost` or `[::1]`.
478
+
479
+ ### Trade-offs
480
+
481
+ A bearer token in web storage is readable by any script on the frontend's origin, so an XSS
482
+ can steal it. An HttpOnly cookie cannot be read that way, which is why cookie mode stays the
483
+ default. `localStorage` also outlives browser restarts, so on a shared device, such as a family
484
+ computer, the next person to open the app is still signed in.
485
+
486
+ On shared devices, keep the token in `sessionStorage` so it ends when the browser session does.
487
+ Reading `window.sessionStorage` throws when the browser blocks site storage, so guard it:
488
+
489
+ ``````typescript
490
+ const sessionStorageOrDefault = (): Storage | undefined => {
491
+ try {
492
+ return typeof window === "undefined" ? undefined : window.sessionStorage;
493
+ } catch {
494
+ return undefined; // falls back to localStorage, or to no storage when that is blocked too
495
+ }
496
+ };
497
+
498
+ export const authClient = createBetterAuthClient({
499
+ baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:4000",
500
+ bearerStorage: sessionStorageOrDefault(),
501
+ scheme: "yourapp",
502
+ webAuth: "bearer",
503
+ });
504
+ ``````
505
+
506
+ `bearerStorage` takes any synchronous `{getItem, setItem, removeItem}` store.
507
+
240
508
  ## OAuth Provider Setup
241
509
 
242
510
  ### Google OAuth
@@ -318,9 +586,19 @@ Better Auth runs **in parallel** with JWT auth. You don't need to migrate existi
318
586
  4. Existing users continue using JWT tokens
319
587
  5. Optionally add migration logic to link accounts
320
588
 
589
+ An existing JWT user who signs up through Better Auth with the same email is linked to their app
590
+ `User` only once Better Auth has verified that email. Until then their session does not
591
+ authenticate. Configure `sendMail` so verification mail goes out, and consider
592
+ `options.emailAndPassword.requireEmailVerification: true`. Social sign-in with a provider that
593
+ verifies email links on first contact. If the app `User` never verified its email
594
+ (`emailVerified` is not `true`), linking removes its legacy password and bumps `tokenEpoch`, so
595
+ the user signs in through Better Auth or resets the password from then on. A row that already has
596
+ a different `betterAuthId` is never relinked. Set `SIGNUP_DISABLED=true` once Better Auth is the
597
+ only sign-up path, so new accounts cannot be created through the legacy `/auth/signup`.
598
+
321
599
  ## Learn More
322
600
 
323
601
  - [Authentication Architecture](../explanation/authentication.md)
324
602
  - [@terreno/api Reference](../reference/api.md#better-auth)
325
- - [@terreno/rtk Reference (legacy)](../reference/legacy/rtk.md#better-auth)
603
+ - [@terreno/rtk Reference (legacy)](../reference/legacy/rtk.md#better-auth-integration)
326
604
  - [Better Auth Documentation](https://better-auth.com/)
@@ -2,7 +2,7 @@
2
2
 
3
3
  Mint a personal `mcp_…` Bearer key, then paste the MCP URL and key into Perplexity, Cursor, VS Code, or Claude Code.
4
4
 
5
- This is the **static-key** path for the consumer app's `POST /mcp`. It is not the hosted `@terreno/mcp` codegen server. For exposing tools, see [Expose MCP tools](expose-mcp-tools.md). Interactive OAuth for remote clients is a separate plan ([app MCP server](../implementationPlans/app-mcp-server.md)); do not send these keys in a query string.
5
+ This is the **static-key** path for the consumer app's `POST /mcp`. It is not the hosted `@terreno/mcp` codegen server. For exposing tools, see [Expose MCP tools](expose-mcp-tools.md). Interactive OAuth for remote clients such as Claude.ai and ChatGPT is [Enable MCP OAuth for a JWT app](enable-mcp-oauth.md); do not send these keys in a query string.
6
6
 
7
7
  ## 1. Enable the flag
8
8
 
@@ -108,3 +108,5 @@ The token acts as the owning user on **MCP only**. It cannot mint or list tokens
108
108
  - **Admin:** Admin → MCP service tokens (`/admin/mcp-service-tokens`). Create and update are off. Delete sets `revokedAt`; it does not hard-delete the row.
109
109
 
110
110
  Revoked and expired keys fail `tools/call` immediately. Disabled user accounts are refused the same way as over HTTP.
111
+
112
+ A password reset, Better Auth password change, or "sign out everywhere" (`revoke-sessions` / `revoke-other-sessions`) bumps the user's `tokenEpoch` and ends every key minted before it. The keys show as revoked in the list; mint a new one for each client you still use.