@terreno/mcp 57.16.1 → 57.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
- package/dist/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
- package/dist/docs/upgrades/57.17.0.md +126 -0
- package/dist/docs/versioned/next/approval-rubric.md +171 -0
- package/dist/docs/versioned/next/explanation/admin-interface.md +5 -1
- package/dist/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
- package/dist/docs/versioned/next/explanation/authentication.md +60 -10
- package/dist/docs/versioned/next/explanation/example-coverage.md +2 -1
- package/dist/docs/versioned/next/explanation/model-router-actions.md +32 -1
- package/dist/docs/versioned/next/how-to/README.md +1 -0
- package/dist/docs/versioned/next/how-to/add-github-oauth.md +49 -16
- package/dist/docs/versioned/next/how-to/admin-add-model.md +4 -1
- package/dist/docs/versioned/next/how-to/ai-feature-development.md +5 -0
- package/dist/docs/versioned/next/how-to/background-jobs.md +18 -5
- package/dist/docs/versioned/next/how-to/circleci.md +39 -3
- package/dist/docs/versioned/next/how-to/configure-better-auth.md +283 -5
- package/dist/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
- package/dist/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
- package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
- package/dist/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
- package/dist/docs/versioned/next/how-to/install-agent-skills.md +8 -1
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
- package/dist/docs/versioned/next/how-to/password-reset.md +8 -2
- package/dist/docs/versioned/next/how-to/product-announcements.md +5 -3
- package/dist/docs/versioned/next/how-to/rate-limiting.md +2 -2
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +50 -0
- package/dist/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
- package/dist/docs/versioned/next/how-to/websocket-integration.md +42 -9
- package/dist/docs/versioned/next/how-to/whats-new-banner.md +55 -0
- package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
- package/dist/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
- package/dist/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
- package/dist/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
- package/dist/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
- package/dist/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
- package/dist/docs/versioned/next/reference/admin-backend.md +46 -0
- package/dist/docs/versioned/next/reference/admin-config.md +2 -2
- package/dist/docs/versioned/next/reference/ai.md +48 -4
- package/dist/docs/versioned/next/reference/api.md +410 -12
- package/dist/docs/versioned/next/reference/blocks.md +1 -1
- package/dist/docs/versioned/next/reference/comms.md +19 -1
- package/dist/docs/versioned/next/reference/environment-variables.md +6 -1
- package/dist/docs/versioned/next/reference/feature-flags.md +1 -1
- package/dist/docs/versioned/next/reference/jobs.md +8 -6
- package/dist/docs/versioned/next/reference/legacy/rtk.md +145 -18
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
- package/dist/docs/versioned/next/reference/syncdb.md +64 -3
- package/dist/docs/versioned/next/reference/ui.md +23 -1
- package/package.json +3 -3
- package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
- package/src/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
- package/src/docs/upgrades/57.17.0.md +126 -0
- package/src/docs/versioned/next/approval-rubric.md +171 -0
- package/src/docs/versioned/next/explanation/admin-interface.md +5 -1
- package/src/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
- package/src/docs/versioned/next/explanation/authentication.md +60 -10
- package/src/docs/versioned/next/explanation/example-coverage.md +2 -1
- package/src/docs/versioned/next/explanation/model-router-actions.md +32 -1
- package/src/docs/versioned/next/how-to/README.md +1 -0
- package/src/docs/versioned/next/how-to/add-github-oauth.md +49 -16
- package/src/docs/versioned/next/how-to/admin-add-model.md +4 -1
- package/src/docs/versioned/next/how-to/ai-feature-development.md +5 -0
- package/src/docs/versioned/next/how-to/background-jobs.md +18 -5
- package/src/docs/versioned/next/how-to/circleci.md +39 -3
- package/src/docs/versioned/next/how-to/configure-better-auth.md +283 -5
- package/src/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
- package/src/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
- package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
- package/src/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
- package/src/docs/versioned/next/how-to/install-agent-skills.md +8 -1
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
- package/src/docs/versioned/next/how-to/password-reset.md +8 -2
- package/src/docs/versioned/next/how-to/product-announcements.md +5 -3
- package/src/docs/versioned/next/how-to/rate-limiting.md +2 -2
- package/src/docs/versioned/next/how-to/run-tests-locally.md +50 -0
- package/src/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
- package/src/docs/versioned/next/how-to/websocket-integration.md +42 -9
- package/src/docs/versioned/next/how-to/whats-new-banner.md +55 -0
- package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
- package/src/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
- package/src/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
- package/src/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
- package/src/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
- package/src/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
- package/src/docs/versioned/next/reference/admin-backend.md +46 -0
- package/src/docs/versioned/next/reference/admin-config.md +2 -2
- package/src/docs/versioned/next/reference/ai.md +48 -4
- package/src/docs/versioned/next/reference/api.md +410 -12
- package/src/docs/versioned/next/reference/blocks.md +1 -1
- package/src/docs/versioned/next/reference/comms.md +19 -1
- package/src/docs/versioned/next/reference/environment-variables.md +6 -1
- package/src/docs/versioned/next/reference/feature-flags.md +1 -1
- package/src/docs/versioned/next/reference/jobs.md +8 -6
- package/src/docs/versioned/next/reference/legacy/rtk.md +145 -18
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
- package/src/docs/versioned/next/reference/syncdb.md +64 -3
- package/src/docs/versioned/next/reference/ui.md +23 -1
|
@@ -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
|
|
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 |
|
|
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.
|
|
110
|
-
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
263
|
-
const token =
|
|
264
|
-
const refreshToken =
|
|
265
|
-
const userId =
|
|
266
|
-
//
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
|
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
|
-
**
|
|
118
|
-
`
|
|
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**:
|
|
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
|
|
251
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
258
|
+
betterAuth: betterAuth.reducer, // selectors read `state.betterAuth`
|
|
189
259
|
// ... other reducers
|
|
190
260
|
},
|
|
191
|
-
middleware: (getDefault) => getDefault().concat(
|
|
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
|
|
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.
|