@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
|
@@ -56,7 +56,7 @@ echo -n 'mongodb+srv://user:pass@cluster.example/terreno?retryWrites=true&w=majo
|
|
|
56
56
|
|
|
57
57
|
Repeat for `TOKEN_SECRET`, `TOKEN_ISSUER`, `REFRESH_TOKEN_SECRET`, and `SESSION_SECRET`.
|
|
58
58
|
|
|
59
|
-
For Better Auth, also create `better-auth-secret` and set `AUTH_PROVIDER=better-auth` plus `BETTER_AUTH_URL` at deploy time. See [Configure Better Auth](configure-better-auth.md).
|
|
59
|
+
For Better Auth, also create `better-auth-secret` and set `AUTH_PROVIDER=better-auth` plus `BETTER_AUTH_URL` at deploy time. See [Configure Better Auth](configure-better-auth.md). The example backend uses Better Auth when `AUTH_PROVIDER` is unset, and with `NODE_ENV=production` it refuses to start unless `BETTER_AUTH_SECRET` is set to a value other than its published development secret.
|
|
60
60
|
|
|
61
61
|
## 4. Runtime service account
|
|
62
62
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Enable MCP OAuth for a JWT app
|
|
2
|
+
|
|
3
|
+
Let Claude.ai custom connectors, ChatGPT apps, and other remote MCP clients connect to your app's `POST /mcp` through OAuth 2.1, when your app signs users in with Terreno JWTs. Terreno runs the authorization server; your app hosts one consent screen.
|
|
4
|
+
|
|
5
|
+
Use [a service token](connect-mcp-service-token.md) instead for clients that take a static Bearer key (Perplexity, Cursor, Claude Code).
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- A `TerrenoApp` backend with at least one MCP tool ([Expose MCP tools](expose-mcp-tools.md))
|
|
10
|
+
- A public https origin for the API, for example `https://api.example.com`
|
|
11
|
+
- A signed-in page in your app that can call the API with the user's JWT
|
|
12
|
+
|
|
13
|
+
## 1. Turn on `mcpOAuth`
|
|
14
|
+
|
|
15
|
+
MCP OAuth is off until you set `enabled: true`. Without it the app has no `/oauth/*` routes, no `.well-known/oauth-*` documents, and no OAuth challenge on `/mcp`.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
new TerrenoApp({
|
|
19
|
+
userModel: User,
|
|
20
|
+
mcpOAuth: {
|
|
21
|
+
consentUrl: "https://app.example.com/oauth/consent",
|
|
22
|
+
enabled: true,
|
|
23
|
+
issuer: "https://api.example.com",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `issuer` is the API's public origin, with no path. Every discovery URL and endpoint is built from it, so it must be the origin clients reach.
|
|
29
|
+
- `consentUrl` is the page from step 2.
|
|
30
|
+
|
|
31
|
+
### Choose how clients get a `client_id`
|
|
32
|
+
|
|
33
|
+
Dynamic client registration (`POST /oauth/register`) is a separate opt-in and stays off by default. The endpoint is unauthenticated: anyone who can reach your API can create a client with any name and a redirect on an allowed host. The consent screen is then the only thing between that client and a user's grant, so a convincing name is enough to phish an approval.
|
|
34
|
+
|
|
35
|
+
- **Registration off (default).** Create each client yourself and give its `client_id` to the client (Claude.ai custom connectors accept one under advanced settings). `/oauth/register` returns 404 and the metadata has no `registration_endpoint`.
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import {McpOAuthClient} from "@terreno/api";
|
|
39
|
+
|
|
40
|
+
await McpOAuthClient.create({
|
|
41
|
+
clientId: "claude-connector",
|
|
42
|
+
clientName: "Claude",
|
|
43
|
+
grantTypes: ["authorization_code", "refresh_token"],
|
|
44
|
+
redirectUris: ["https://claude.ai/api/mcp/auth_callback"],
|
|
45
|
+
responseTypes: ["code"],
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- **Registration on.** Turn it on only when clients you cannot pre-register must connect. Set `dynamicClientRegistration: true` and list `allowedRedirectHosts`, which limits where a registered client may send codes. Claude.ai redirects to `claude.ai`, ChatGPT to `chatgpt.com`. Use `*.example.com` for subdomains, and add `localhost` only if you want local clients with loopback `http://` redirects. Keep rate limiting on.
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
mcpOAuth: {
|
|
53
|
+
allowedRedirectHosts: ["claude.ai", "chatgpt.com"],
|
|
54
|
+
consentUrl: "https://app.example.com/oauth/consent",
|
|
55
|
+
dynamicClientRegistration: true,
|
|
56
|
+
enabled: true,
|
|
57
|
+
issuer: "https://api.example.com",
|
|
58
|
+
},
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Booting with an issuer that has a path, a consent URL that is not a URL, or registration on with no redirect hosts throws. Full option list: [API reference → MCP OAuth](../reference/api.md#mcp-oauth).
|
|
62
|
+
|
|
63
|
+
## 2. Host the consent screen
|
|
64
|
+
|
|
65
|
+
The client sends the user's browser to `/oauth/authorize`. Terreno checks the client, redirect URI, and PKCE, stores a pending request, and redirects to `consentUrl?request=<id>`. Your page:
|
|
66
|
+
|
|
67
|
+
1. Makes sure the user is signed in (send them through login and back if not, keeping `request`).
|
|
68
|
+
2. Loads the request to show who is asking:
|
|
69
|
+
|
|
70
|
+
```http
|
|
71
|
+
GET /oauth/authorize/<request>
|
|
72
|
+
Authorization: Bearer <user JWT>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Response: `{data: {clientName, redirectHost, scope, expiresAt, …}}`. Show the client name and redirect host so the user can spot a client they did not start.
|
|
76
|
+
|
|
77
|
+
3. Posts the decision with the same JWT, then sends the browser to the returned URL:
|
|
78
|
+
|
|
79
|
+
```http
|
|
80
|
+
POST /oauth/authorize/<request>/approve # or /deny
|
|
81
|
+
Authorization: Bearer <user JWT>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
const {data} = await approve(requestId);
|
|
86
|
+
window.location.assign(data.redirectUrl);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The three endpoints are in OpenAPI (`getMcpOAuthRequest`, `approveMcpOAuthRequest`, `denyMcpOAuthRequest`), so `bun run sdk` generates hooks for them. A request expires after 10 minutes (400 `Authorization request expired`), and a decided request cannot be decided again (409). The endpoints refuse `mcp_` and OAuth bearers, so an agent cannot approve itself.
|
|
90
|
+
|
|
91
|
+
## 3. Connect a client
|
|
92
|
+
|
|
93
|
+
Give the client the MCP URL, `https://api.example.com/mcp`. It discovers the rest:
|
|
94
|
+
|
|
95
|
+
1. `POST /mcp` without credentials returns `401` with `WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"`.
|
|
96
|
+
2. The client reads that document, then `/.well-known/oauth-authorization-server`.
|
|
97
|
+
3. It uses the `client_id` you gave it, or registers itself at `/oauth/register` when `dynamicClientRegistration` is on, then runs the browser flow through your consent screen, and exchanges the code at `/oauth/token` for an access token (`mcpat_…`, one hour) and a refresh token (`mcprt_…`, 30 days).
|
|
98
|
+
|
|
99
|
+
For Claude.ai: **Settings → Connectors → Add custom connector**, paste the MCP URL, and connect. For ChatGPT: create a connector with the MCP URL and choose OAuth.
|
|
100
|
+
|
|
101
|
+
## 4. Check it
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
curl -i -X POST https://api.example.com/mcp \
|
|
105
|
+
-H "Accept: application/json, text/event-stream" \
|
|
106
|
+
-H "Content-Type: application/json" \
|
|
107
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
108
|
+
# HTTP/1.1 401 … WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp"
|
|
109
|
+
|
|
110
|
+
curl https://api.example.com/.well-known/oauth-authorization-server
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## What changes on `/mcp`
|
|
114
|
+
|
|
115
|
+
- Every request that cannot be authenticated, including `initialize` and `tools/list`, gets the 401 challenge. A rejected credential adds `error="invalid_token"` so clients refresh.
|
|
116
|
+
- JWTs, Better Auth sessions, and `mcp_` service tokens keep working.
|
|
117
|
+
- Tools see the approving user. `getCurrentRequestContext()?.credential` is `"oauth"`, so permission code can treat an agent's token differently from the user's own session (for example, no admin bypass). See [Request credential](../reference/api.md#request-credential).
|
|
118
|
+
- Scopes are not enforced for you. A token can call every tool its user can, whatever scope it was granted. If `scopesSupported` lists scopes with different meanings (for example `["read", "write"]`), add `requireOAuthScope("write")` to the permissions of the methods and actions that need it. The granted scopes are in `getCurrentRequestContext()?.oauthScopes`.
|
|
119
|
+
|
|
120
|
+
## Revoking access
|
|
121
|
+
|
|
122
|
+
- The client revokes with `POST /oauth/revoke`. Revoking a refresh token ends the whole chain.
|
|
123
|
+
- Disabling the user stops their tokens at the next call.
|
|
124
|
+
- A password reset, Better Auth password change, or session revocation (anything that bumps `tokenEpoch`) ends every grant approved before it: the next `/mcp` call, refresh, or code exchange fails and the chain is revoked. The user reconnects the client.
|
|
125
|
+
- Server-side, revoke everything a user granted with `McpOAuthToken.updateMany({userId, revokedAt: {$exists: false}}, {$set: {revokedAt: DateTime.now().toJSDate()}})`.
|
|
126
|
+
|
|
127
|
+
Refresh tokens rotate on every use. If an old refresh token comes back, Terreno assumes it leaked and revokes every token issued from that approval; the user reconnects the client. The revoke is final: Terreno records the revoked approval, so a refresh or code exchange that was already in flight cannot issue a new token for it.
|
|
128
|
+
|
|
129
|
+
## Security notes
|
|
130
|
+
|
|
131
|
+
- Only public clients with PKCE `S256` are supported. `plain` PKCE and client secrets are rejected.
|
|
132
|
+
- Tokens, codes, and refresh tokens are stored as SHA-256 hashes.
|
|
133
|
+
- Registration, authorize, token, and revoke count against the `auth` [rate-limit](rate-limiting.md) bucket. Turn rate limiting on in production, since registration is unauthenticated.
|
|
134
|
+
- If a browser-based client (such as the MCP Inspector) runs discovery from another origin, allow that origin in `corsOrigin`.
|
|
@@ -51,12 +51,15 @@ to work while clients migrate.
|
|
|
51
51
|
Point any MCP client at `POST /mcp`. Auth order on the Bearer header is:
|
|
52
52
|
|
|
53
53
|
1. `mcp_…` service token, when `mcpServiceTokens` is enabled
|
|
54
|
-
2.
|
|
55
|
-
3.
|
|
54
|
+
2. `mcpat_…` OAuth access token, when `mcpOAuth` is enabled
|
|
55
|
+
3. Better Auth session
|
|
56
|
+
4. JWT
|
|
57
|
+
|
|
58
|
+
The credential type that won is recorded as `getCurrentRequestContext()?.credential` (`"mcp-service-token"`, `"oauth"`, `"better-auth-session"`, or `"jwt"`), so a permission function can refuse delegated agent tokens where it would trust the user's own session. Remote clients that need OAuth (Claude.ai connectors, ChatGPT apps) are covered in [Enable MCP OAuth for a JWT app](enable-mcp-oauth.md).
|
|
56
59
|
|
|
57
60
|
The resolved user is what permission checks run against, so an LLM never sees more than that user could see over REST. Mint and paste a static key with [Connect an MCP client with a service token](connect-mcp-service-token.md).
|
|
58
61
|
|
|
59
|
-
`initialize` and `tools/list` are the unauthenticated catalog. Identity is enforced on `tools/call`. GET `/mcp` is Streamable HTTP **405** (`Method not allowed.`) — there is no GET SSE stream; Perplexity still uses that 405 as a successful probe.
|
|
62
|
+
`initialize` and `tools/list` are the unauthenticated catalog (unless `mcpOAuth` is enabled, which makes every unauthenticated `/mcp` request a 401 challenge). Identity is enforced on `tools/call`. GET `/mcp` is Streamable HTTP **405** (`Method not allowed.`) — there is no GET SSE stream; Perplexity still uses that 405 as a successful probe.
|
|
60
63
|
|
|
61
64
|
Authentication is required by default, matching REST. A tool call with no resolvable user is refused before any permission check unless the model router sets `allowAnonymous: true` — the same flag REST passes to `authenticateMiddleware`. That matters for read-only helpers like `IsAuthenticatedOrReadOnly`, which would otherwise pass for an anonymous `list`. Disabled accounts are refused too, as they are over HTTP. Service tokens are MCP-only: they are rejected on `/mcp/service-tokens` and are not accepted on REST, sync, or admin.
|
|
62
65
|
|
|
@@ -116,6 +119,8 @@ populatePaths: [{fields: ["name", "email"], path: "ownerId"}],
|
|
|
116
119
|
- A bare name (`"hash"`) is removed at **every depth**, including inside arrays and populated refs, so a redacted name cannot leak through a nested document.
|
|
117
120
|
- A dot path (`"metadata.secretKey"`) removes exactly that location, following arrays of subdocuments element-wise.
|
|
118
121
|
|
|
122
|
+
The list tool's `sort` argument accepts only the router's sortable fields (`queryFields`, fields in the router's `sort`, and `sortFields`) minus `excludeFields`. Sorting on any other field returns an error that lists the allowed fields, because result order would reveal the hidden values.
|
|
123
|
+
|
|
119
124
|
## Customizing responses
|
|
120
125
|
|
|
121
126
|
`mcpResponseHandler` shapes MCP responses independently of the REST `responseHandler`, which is useful for returning compact summaries to an LLM:
|
|
@@ -174,9 +179,43 @@ Unexpected exceptions and caught internal causes are also captured with
|
|
|
174
179
|
receives the stable MCP error text — internal exceptions and stack traces stay
|
|
175
180
|
server-side. Tool arguments are not logged.
|
|
176
181
|
|
|
182
|
+
## Actions as tools
|
|
183
|
+
|
|
184
|
+
A verb that is not CRUD belongs in a [modelRouter action](../explanation/model-router-actions.md). Add `mcp` to the action to expose the same implementation as a tool, instead of writing it twice:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
export const todoRouter = modelRouter("/todos", Todo, {
|
|
188
|
+
collectionActions: {
|
|
189
|
+
bulkComplete: {
|
|
190
|
+
body: z.object({ids: z.array(z.string()).min(1)}).strict(),
|
|
191
|
+
handler: async ({body, user}) => completeTodos(body.ids, user),
|
|
192
|
+
mcp: {description: "Mark several todos complete", name: "todos_bulk_complete"},
|
|
193
|
+
method: "POST",
|
|
194
|
+
permissions: [Permissions.IsAuthenticated],
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
instanceActions: {
|
|
198
|
+
markComplete: {
|
|
199
|
+
handler: async ({doc}) => markComplete(doc),
|
|
200
|
+
mcp: {name: "todos_mark_complete"},
|
|
201
|
+
method: "POST",
|
|
202
|
+
permissions: [Permissions.IsOwner],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
permissions: { /* CRUD */ },
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- `todos_mark_complete` takes `{id}`; an instance action's tool input is `{id, ...body}` (or `{id, ...query}` for GET). `todos_bulk_complete` takes `{ids}`.
|
|
210
|
+
- The tool runs the action's own `permissions` and RBAC `access`, before and after loading the document, exactly as REST does. It does not need `mcp` on the router, and the router's CRUD permissions do not apply.
|
|
211
|
+
- The handler sees `req.isMCPRequest === true`. Its return value is the tool's JSON text. Touching `res` fails the call, so return values instead of writing to `res`.
|
|
212
|
+
- `body` / `query` must be a `z.object()`, and instance actions cannot define `id` in it. Tool names must be unique; a duplicate fails at boot.
|
|
213
|
+
|
|
214
|
+
Audit log lines for action tools carry `mcpTool`, `mcpModel`, and `mcpAction` labels.
|
|
215
|
+
|
|
177
216
|
## Custom tools
|
|
178
217
|
|
|
179
|
-
CRUD tools cover one model at a time. For a query that spans models, register a custom tool with `registerMCPTool`. It shows up on `POST /mcp` and in `getMCPTools` next to the generated CRUD tools:
|
|
218
|
+
CRUD tools cover one model at a time, and actions cover one model's verbs. For a query that spans models, register a custom tool with `registerMCPTool`. It shows up on `POST /mcp` and in `getMCPTools` next to the generated CRUD tools:
|
|
180
219
|
|
|
181
220
|
```typescript
|
|
182
221
|
import {registerMCPTool} from "@terreno/api";
|
|
@@ -68,7 +68,7 @@ scan stages come from
|
|
|
68
68
|
| Lifecycle | `terreno-1-grow` … `terreno-5-taste`, `terreno-pick-roast-loop`, `terreno-planning-loop`, `terreno-taste-sweep` |
|
|
69
69
|
| Terreno apps | backend, UI, admin interfaces, data, schema, SDK, prompts, upgrades, deployment |
|
|
70
70
|
| Docs | `update-docs`, `update-agent-docs`, architecture skills |
|
|
71
|
-
| GitHub | issues, review, UI verification, release, dependency updates |
|
|
71
|
+
| GitHub | issues, review, UI verification, PR screenshots (`attach-pr-media`), release, dependency updates |
|
|
72
72
|
| Code scans | `terreno-scan-1-aim` … `terreno-scan-5-track`, `terreno-scan-campaign`, `terreno-scan-loop` |
|
|
73
73
|
| Expo and native | `track-upstream-expo`, `upgrading-expo`, `store-assets`, deployment / EAS skills |
|
|
74
74
|
| Plugin agents | `pre-commit`, `ui-verifier` |
|
|
@@ -101,6 +101,13 @@ bun run check:lifecycle-skills
|
|
|
101
101
|
|
|
102
102
|
Do not hand-edit `skills/`.
|
|
103
103
|
|
|
104
|
+
A `.rulesync/skills/` skill that links a lifecycle reference as
|
|
105
|
+
`references/<name>.md` (for example `references/push-ownership.md`) gets its own copy of
|
|
106
|
+
`plugins/terreno-planning/references/<name>.md`, so the link still resolves once the skill
|
|
107
|
+
is installed elsewhere. `skills:sync` rewrites those copies and `skills:check` fails when
|
|
108
|
+
one drifts. Edit the plugin reference, never the copy, then run `bun run skills:sync` and
|
|
109
|
+
`bun run rules`.
|
|
110
|
+
|
|
104
111
|
## Keep native hooks in sync
|
|
105
112
|
|
|
106
113
|
Repository-level hooks are canonical in `.rulesync/hooks.json`; shared commands live in
|
|
@@ -335,8 +335,9 @@ export const syncDb = createSyncDb({authProvider, baseUrl, collections: ["todos"
|
|
|
335
335
|
|
|
336
336
|
Better Auth session tokens live in the Better Auth client (`@terreno/rtk` `createBetterAuthClient`):
|
|
337
337
|
|
|
338
|
-
- **Native:** `expo-secure-store`
|
|
339
|
-
- **Web:**
|
|
338
|
+
- **Native:** the Expo plugin's cookie jar in `expo-secure-store`
|
|
339
|
+
- **Web (default):** Better Auth's HttpOnly session cookie, managed by the browser. No token is in app storage
|
|
340
|
+
- **Web with `webAuth: "bearer"`:** the `set-auth-token` bearer in `localStorage` (or the `bearerStorage` you pass), readable with `getBetterAuthBearerToken()`. See [Frontend and API on different sites](configure-better-auth.md#frontend-and-api-on-different-sites)
|
|
340
341
|
|
|
341
342
|
Syncdb does not store tokens itself — it calls `authProvider` on every transport/HTTP request.
|
|
342
343
|
|
|
@@ -346,12 +347,14 @@ On connect, the socket transport sends the bearer token from `getToken()`. When
|
|
|
346
347
|
|
|
347
348
|
### Backend
|
|
348
349
|
|
|
350
|
+
Register `BetterAuthApp` and `RealtimeApp` on the same `TerrenoApp`. `RealtimeApp` picks up the
|
|
351
|
+
Better Auth instance by itself, so no extra option is needed. Legacy JWT sockets keep working
|
|
352
|
+
alongside it. Only a `RealtimeApp` mounted without `TerrenoApp` needs the instance passed in:
|
|
353
|
+
|
|
349
354
|
```typescript
|
|
350
355
|
new RealtimeApp({betterAuth: {auth, userModel: User}})
|
|
351
356
|
```
|
|
352
357
|
|
|
353
|
-
Required for Better Auth socket sessions. Legacy JWT sockets continue to work without it.
|
|
354
|
-
|
|
355
358
|
## 8. Codegen
|
|
356
359
|
|
|
357
360
|
Synced collections use `bun run sync-sdk`, which runs `terreno-syncdb-codegen` from
|
|
@@ -29,6 +29,8 @@ authOptions: {
|
|
|
29
29
|
}
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
Pass the `message` through unchanged. Recovery mail carries `sensitive: true`, and `@terreno/comms` stores no payload for a sensitive message, so the reset or verify link never reaches the `CommsMessage` log or the admin Comms Dashboard. If you send through another transport, do not log or store the content of a message with `sensitive: true`.
|
|
33
|
+
|
|
32
34
|
| Action | Path | Body |
|
|
33
35
|
| --- | --- | --- |
|
|
34
36
|
| Forgot | `POST /auth/forgotPassword` | `{email}` → 202 |
|
|
@@ -71,6 +73,10 @@ On `BetterAuthConfig` set the same `publicAppUrl`, `sendMail`, and `renderAuthMa
|
|
|
71
73
|
4. Show a profile banner when `emailVerified` is false, with Resend calling `POST /auth/sendVerification`.
|
|
72
74
|
5. Add `/verifyEmail` that reads `token` from the query string. JWT apps call `POST /auth/verifyEmail`. Better Auth apps try `GET /verify-email?token=...` first, then the JWT route so either token type can complete.
|
|
73
75
|
|
|
74
|
-
A new `issueFor` for the same user and type invalidates earlier unused tokens of that type. Changing the mailbox through `PATCH /auth/me` invalidates unused `passwordReset` tokens even when the User schema has no `emailVerified` field. When `emailVerificationPlugin` is applied, mailbox change also sets `emailVerified` to false and invalidates unused `emailVerification` tokens. Letter casing alone does not count as a mailbox change. `emailVerified` and `tokenEpoch` are privileged user fields: signup and `PATCH /auth/me` drop them
|
|
76
|
+
A new `issueFor` for the same user and type invalidates earlier unused tokens of that type. Changing the mailbox through `PATCH /auth/me` invalidates unused `passwordReset` tokens even when the User schema has no `emailVerified` field. When `emailVerificationPlugin` is applied, mailbox change also sets `emailVerified` to false and invalidates unused `emailVerification` tokens. Letter casing alone does not count as a mailbox change. `emailVerified` and `tokenEpoch` are privileged user fields: signup and `PATCH /auth/me` drop them, along with the password fields `hash` and `salt`.
|
|
77
|
+
|
|
78
|
+
To set a password from your own server code (for example an admin action), call `changeUserPassword(user, password, {adminId, app: req.app})` from `@terreno/api` so the change revokes sessions the same way a reset does. The example backend's `POST /users/:id/password` uses it.
|
|
79
|
+
|
|
80
|
+
Either reset path also ends the user's MCP credentials: `mcp_` service tokens and MCP OAuth grants issued before the reset stop working, because they are bound to `tokenEpoch`. With `CommsApp` registered, push also stops going to devices the user registered before the reset until each device registers again.
|
|
75
81
|
|
|
76
|
-
Better Auth password reset revokes that user's Better Auth sessions (`revokeSessionsOnPasswordReset`) and, when an app User exists, updates the JWT password and `tokenEpoch` so `POST /auth/login` cannot keep the old passport hash. JWT `POST /auth/resetPassword` does the reverse for Better Auth when `BetterAuthApp` is registered: it updates the Better Auth password and deletes Better Auth sessions. Console mail in development prints length only; check `CommsMessage` / console adapter logs for delivery, not the raw token in logger output. Request logs redact
|
|
82
|
+
Better Auth password reset revokes that user's Better Auth sessions (`revokeSessionsOnPasswordReset`) and, when an app User exists, updates the JWT password and `tokenEpoch` so `POST /auth/login` cannot keep the old passport hash. JWT `POST /auth/resetPassword` does the reverse for Better Auth when `BetterAuthApp` is registered: it updates the Better Auth password and deletes Better Auth sessions. Console mail in development prints length only; check `CommsMessage` / console adapter logs for delivery, not the raw token in logger output. Request logs redact password, token, secret, and code fields (any key name, any depth) in bodies and in URL query strings.
|
|
@@ -131,7 +131,7 @@ Interrupt frequency is enforced **client-side** in `AnnouncementNavigator` befor
|
|
|
131
131
|
</AnnouncementNavigator>
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
`maxInterruptionsPerSession` defaults to `1` when omitted. The example app sets `skipFirstLaunch: false` so
|
|
134
|
+
`maxInterruptionsPerSession` defaults to `1` when omitted. The example app sets `skipFirstLaunch: false` so an announcement an admin publishes appears on the next launch.
|
|
135
135
|
|
|
136
136
|
| Prop | Default | Behavior |
|
|
137
137
|
|------|---------|----------|
|
|
@@ -150,9 +150,11 @@ Paste YouTube or Loom URLs in the announcement `body` using markdown links or im
|
|
|
150
150
|
|
|
151
151
|
## Example app
|
|
152
152
|
|
|
153
|
-
`example-backend` registers `AnnouncementsApp` with `isStaff: (user) => user.admin === true` and an optional `matchAudience` org filter. `bun run backend:seed` idempotently seeds
|
|
153
|
+
`example-backend` registers `AnnouncementsApp` with `isStaff: (user) => user.admin === true` and an optional `matchAudience` org filter. `bun run backend:seed` idempotently seeds two **drafts**:
|
|
154
154
|
|
|
155
155
|
- **Staff modal (required)** — `audienceType: "staff"`, `displayMode: "modal"`, `acknowledgementPolicy: "required"` (`Example staff operations bulletin`)
|
|
156
156
|
- **Patient banner (dismiss-only)** — `audienceType: "patient"`, `displayMode: "banner"`, `acknowledgementPolicy: "dismiss-only"` (`Example patient care tip`)
|
|
157
157
|
|
|
158
|
-
The staff
|
|
158
|
+
The seed keeps them as drafts so a fresh database (local dev or a PR preview) does not interrupt sign-in. Publish one from **Admin → Announcements** to see it. Re-running the seed updates both examples in place and sets them back to draft. The staff example is rich markdown with a YouTube embed, overview/editor screenshots, and a tracked docs CTA. An existing legacy welcome row (`Welcome to Terreno announcements`) is archived idempotently. `example-frontend` wraps authenticated users with `AnnouncementNavigator` (`skipFirstLaunch: false`, `userId` from the profile).
|
|
159
|
+
|
|
160
|
+
Release notes in the example app use [`WhatsNewBanner`](./whats-new-banner.md) instead of announcements.
|
|
@@ -77,13 +77,13 @@ const uploadLimit = createRouteRateLimitMiddleware({
|
|
|
77
77
|
| `POST /auth/forgotPassword`, `/auth/resetPassword`, `/resetPassword` | auth |
|
|
78
78
|
| `POST /auth/sendVerification`, `/auth/verifyEmail` | auth |
|
|
79
79
|
| `GET /auth/github`, `/auth/github/callback` | auth |
|
|
80
|
-
| `{betterAuthBasePath}/sign-in/*`, `sign-up/*`, `forget-password`, `request-password-reset`, `reset-password`, `send-verification-email`, `verify-email`, `callback/*` | auth |
|
|
80
|
+
| `{betterAuthBasePath}/sign-in/*`, `sign-up/*`, `forget-password`, `request-password-reset`, `reset-password`, `send-verification-email`, `verify-email`, `callback/*`, `oauth-handoff/*`, `one-time-token/*` | auth |
|
|
81
81
|
| Trailing slash on those paths (`/auth/login/`) | same bucket as the unsuffixed path |
|
|
82
82
|
| `GET`/`PATCH /auth/me` | api (600 / 15 min) |
|
|
83
83
|
| modelRouter, admin, AI HTTP, `POST /mcp`, `POST /sync/mutate` | api |
|
|
84
84
|
| `GET /health`, `/healthz`, `/openapi.json`, `/swagger` | skip |
|
|
85
85
|
|
|
86
|
-
`rateLimit.skip` adds extra skips. Health/openapi/swagger always skip. Paths use Express `req.path` (not the raw request-target), without a trailing slash.
|
|
86
|
+
`rateLimit.skip` adds extra skips. Health/openapi/swagger always skip. Paths use Express `req.path` (not the raw request-target), without a trailing slash. Classification also resolves `.` and `..` segments, including `%2e` forms, the way Better Auth's router does: a request is charged to the auth bucket when either the path as sent or its resolved form is a credential route (so `/api/auth/%2e/sign-in/email` counts as sign-in), and a path that contains dot segments never gets the health/openapi/swagger skip.
|
|
87
87
|
|
|
88
88
|
JWT login/signup/refresh and password-reset paths skip access-token verification so a stale `Authorization` header cannot block credential exchange. Other routes still 401 on an expired JWT. Path matching uses Express `req.path` and ignores trailing slashes and letter case.
|
|
89
89
|
|
|
@@ -89,6 +89,56 @@ Full local suite: `bun run frontend:e2e`. Nightly load (`e2e-load` / `syncdb-loa
|
|
|
89
89
|
not PR-blocking; run `bunx playwright test e2e/syncdb-loadlab.spec.ts` only when that job
|
|
90
90
|
failed.
|
|
91
91
|
|
|
92
|
+
`e2e/crossSiteBearer.spec.ts` and `e2e/crossSiteOAuth.spec.ts` have no CircleCI job: they need
|
|
93
|
+
a web server built with `EXPO_PUBLIC_API_URL=http://127.0.0.1:4000`,
|
|
94
|
+
`EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true` and `EXPO_PUBLIC_MOCK_OIDC_SIGN_IN=true`, a backend
|
|
95
|
+
with `BETTER_AUTH_URL=http://127.0.0.1:4000`, `BETTER_AUTH_OAUTH_HANDOFF=true` and
|
|
96
|
+
`MOCK_OIDC_ISSUER`, and the mock OIDC provider on `127.0.0.1:4555`. Naming either spec file (or
|
|
97
|
+
setting `E2E_CROSS_SITE_BEARER=true`) makes `playwright.config.ts` start all three and run only
|
|
98
|
+
the cross-site specs, so stop any other backend on 4000 and web server on 8082 first:
|
|
99
|
+
`cd example-frontend && bunx playwright test e2e/crossSiteBearer.spec.ts e2e/crossSiteOAuth.spec.ts`.
|
|
100
|
+
|
|
101
|
+
### Deployed bearer smoke (`deploy-frontend-preview`)
|
|
102
|
+
|
|
103
|
+
`netlify-deploy.sh frontend` builds the web app with `EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true`
|
|
104
|
+
for production and previews. After the preview deploy, the `deploy-frontend-preview` job's
|
|
105
|
+
"Smoke the deployed preview in web bearer mode" step runs `bun run smoke:deployed`
|
|
106
|
+
(`playwright.smoke.config.ts`, `e2e/deployedBearer.smoke.ts`) against
|
|
107
|
+
`https://pr-<n>--terreno-frontend.netlify.app`. It signs up a new `smoke-*@example.com` user on
|
|
108
|
+
the PR's own backend, agrees to the consent forms, reloads, opens the profile, and fails if the
|
|
109
|
+
API's origin holds a Better Auth session cookie. It also checks that the Google button's OAuth
|
|
110
|
+
handoff start link redirects (to the provider, or back to `/login?error=provider_not_found`)
|
|
111
|
+
instead of returning 404; `gcp-deploy.sh` sets `BETTER_AUTH_OAUTH_HANDOFF=true` on both backends.
|
|
112
|
+
Production deploys never run it. To re-run it:
|
|
113
|
+
|
|
114
|
+
1. Against the PR preview, which takes about 1 minute:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
cd example-frontend
|
|
118
|
+
SMOKE_BASE_URL=https://pr-<n>--terreno-frontend.netlify.app \
|
|
119
|
+
SMOKE_API_URL=https://pr-<n>---terreno-backend-example-7knxlrnpqq-uc.a.run.app \
|
|
120
|
+
bun run smoke:deployed
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
2. Against a local cross-site stack, which takes about 5 minutes because of the web export.
|
|
124
|
+
Start the backend with Better Auth on `127.0.0.1:4000`, then build and serve the web app
|
|
125
|
+
with the flag on `localhost:8082` (a different site):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Terminal 1, from example-backend/ (the replica-set mongod from step 2 above)
|
|
129
|
+
AUTH_PROVIDER=better-auth BETTER_AUTH_URL=http://127.0.0.1:4000 PORT=4000 \
|
|
130
|
+
BETTER_AUTH_OAUTH_HANDOFF=true \
|
|
131
|
+
MONGO_URI="mongodb://127.0.0.1:27017/terreno-smoke?replicaSet=rs0" \
|
|
132
|
+
TOKEN_SECRET=dev TOKEN_ISSUER=dev REFRESH_TOKEN_SECRET=dev SESSION_SECRET=dev \
|
|
133
|
+
bun run src/index.ts
|
|
134
|
+
# Terminal 2, from example-frontend/. --clear: Metro's cache can keep an earlier
|
|
135
|
+
# build's EXPO_PUBLIC_* values.
|
|
136
|
+
EXPO_PUBLIC_API_URL=http://127.0.0.1:4000 EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true \
|
|
137
|
+
bun run export --clear && bunx serve dist -s -l 8082
|
|
138
|
+
# Terminal 3, from example-frontend/
|
|
139
|
+
SMOKE_BASE_URL=http://localhost:8082 bun run smoke:deployed
|
|
140
|
+
```
|
|
141
|
+
|
|
92
142
|
CircleCI skips a shard no changed file can reach. Ask for the same verdict before
|
|
93
143
|
pushing with `bun run check:e2e-affected` (see
|
|
94
144
|
[the affected gate](circleci.md#e2e-affected-gate)).
|
|
@@ -96,7 +96,7 @@ In tests, clean up with `Model.collection.deleteMany(...)`, which bypasses Mongo
|
|
|
96
96
|
|
|
97
97
|
### Deltas don't apply `queryFilter`
|
|
98
98
|
|
|
99
|
-
Snapshots and `/sync/entities` apply the router's `queryFilter`. Live deltas check only `permissions.read` (
|
|
99
|
+
Snapshots and `/sync/entities` apply the router's `queryFilter`. Live deltas check only `permissions.read` per recipient (on an RBAC router: the role, `access.scope`, and `access.also.read`, the same checks REST read applies; subscriptions also apply `access.also.list`). A router that restricts visibility only through `queryFilter` leaks rows over the socket. Every synced router's `read` permission must enforce the same restriction itself.
|
|
100
100
|
|
|
101
101
|
### Membership grants arrive late
|
|
102
102
|
|
|
@@ -137,24 +137,56 @@ Add to your `app.json` or environment:
|
|
|
137
137
|
}
|
|
138
138
|
``````
|
|
139
139
|
|
|
140
|
-
### 2.
|
|
140
|
+
### 2. Register the token source
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
The hook reads its handshake token from the token source the app registers once at startup
|
|
143
|
+
with `setTokenSource`. A JWT app registers nothing: the default source reads the JWT in
|
|
144
|
+
storage. A Better Auth app registers the adapter it builds for syncdb:
|
|
145
|
+
|
|
146
|
+
``````typescript
|
|
147
|
+
// store/tokenSource.ts: import once from the app entry, before the store is created
|
|
148
|
+
import {setTokenSource} from "@terreno/rtk";
|
|
149
|
+
import {betterAuthAdapter, bridgeBetterAuthReactClient} from "@terreno/syncdb";
|
|
150
|
+
import {authClient} from "@/lib/betterAuth";
|
|
151
|
+
|
|
152
|
+
setTokenSource(betterAuthAdapter(bridgeBetterAuthReactClient(authClient)));
|
|
153
|
+
``````
|
|
154
|
+
|
|
155
|
+
With `webAuth: "bearer"`, also pass the adapter `getBearerToken`
|
|
156
|
+
([syncdb adapter](../reference/syncdb.md#createsyncdb-configuration)).
|
|
157
|
+
|
|
158
|
+
With a token source, the hook:
|
|
159
|
+
|
|
160
|
+
- reads `getToken()` on every connect and reconnect;
|
|
161
|
+
- calls `refresh()` once when the server rejects the token, then reconnects, and stops after a
|
|
162
|
+
second rejection (`onConnectError` fires). With no `refresh`, or a failed one, it stops at
|
|
163
|
+
once;
|
|
164
|
+
- reconnects with the new token when `onAuthChange` fires, and disconnects when there is no
|
|
165
|
+
token.
|
|
166
|
+
|
|
167
|
+
To use a different source for one socket, pass it as `tokenSource`. Passing the legacy
|
|
168
|
+
`getAuthToken` getter instead turns the token source off for that hook. See
|
|
169
|
+
[Token source](../reference/legacy/rtk.md#token-source).
|
|
170
|
+
|
|
171
|
+
### 3. Set Up Connection Hook
|
|
172
|
+
|
|
173
|
+
Create a custom hook to manage the connection. A Better Auth app gates `shouldConnect` on
|
|
174
|
+
`selectBetterAuthUserId` instead of the JWT user id:
|
|
143
175
|
|
|
144
176
|
``````typescript
|
|
145
177
|
// hooks/useWebSocket.ts
|
|
146
|
-
import {useSocketConnection,
|
|
147
|
-
import {
|
|
178
|
+
import {useSocketConnection, baseWebsocketsUrl} from "@terreno/rtk";
|
|
179
|
+
import {selectBetterAuthUserId} from "@terreno/rtk";
|
|
180
|
+
import {useSelector} from "react-redux";
|
|
148
181
|
import {useCallback} from "react";
|
|
149
182
|
import * as Sentry from "@sentry/react-native";
|
|
150
183
|
|
|
151
184
|
export const useWebSocket = () => {
|
|
152
|
-
const userId = useSelectCurrentUserId()
|
|
185
|
+
const userId = useSelector(selectBetterAuthUserId); // JWT apps: useSelectCurrentUserId()
|
|
153
186
|
|
|
154
187
|
const {socket, isSocketConnected} = useSocketConnection({
|
|
155
188
|
baseUrl: baseWebsocketsUrl,
|
|
156
189
|
shouldConnect: !!userId,
|
|
157
|
-
getAuthToken,
|
|
158
190
|
onConnect: () => {
|
|
159
191
|
console.info("[WebSocket] Connected");
|
|
160
192
|
},
|
|
@@ -176,7 +208,7 @@ export const useWebSocket = () => {
|
|
|
176
208
|
};
|
|
177
209
|
``````
|
|
178
210
|
|
|
179
|
-
###
|
|
211
|
+
### 4. Subscribe to Events in Components
|
|
180
212
|
|
|
181
213
|
``````typescript
|
|
182
214
|
// screens/TodoList.tsx
|
|
@@ -226,7 +258,7 @@ export const TodoListScreen = () => {
|
|
|
226
258
|
};
|
|
227
259
|
``````
|
|
228
260
|
|
|
229
|
-
###
|
|
261
|
+
### 5. Show Connection Status (Optional)
|
|
230
262
|
|
|
231
263
|
``````typescript
|
|
232
264
|
import {useWebSocket} from "@/hooks/useWebSocket";
|
|
@@ -361,7 +393,8 @@ This logs:
|
|
|
361
393
|
**Cause:** Token not provided or invalid.
|
|
362
394
|
|
|
363
395
|
**Solution:**
|
|
364
|
-
1. Ensure
|
|
396
|
+
1. Ensure the token source returns a valid token: `await getTokenSource()?.getToken()` (or
|
|
397
|
+
`getAuthToken()` on a JWT app with no source registered)
|
|
365
398
|
2. Check backend token verification logic
|
|
366
399
|
3. Enable `WEBSOCKETS_DEBUG` to see token status
|
|
367
400
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Show release notes with WhatsNewBanner
|
|
2
|
+
|
|
3
|
+
Use `WhatsNewBanner` from `@terreno/ui` to tell people about a new release without blocking
|
|
4
|
+
them. The banner reads "What's new in 57.16.2" until the viewer opens or dismisses it, then
|
|
5
|
+
stays hidden until a newer release is listed. Use it instead of a required announcement modal
|
|
6
|
+
for routine release notes.
|
|
7
|
+
|
|
8
|
+
## Add the banner
|
|
9
|
+
|
|
10
|
+
1. Build a list of releases, newest first. Each entry has a `version`, Markdown `body`, and
|
|
11
|
+
an optional `date`:
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import type {WhatsNewRelease} from "@terreno/ui";
|
|
15
|
+
|
|
16
|
+
export const RELEASES: WhatsNewRelease[] = [
|
|
17
|
+
{body: "### Fixed\n\n- Exports finish on slow networks.", date: "2026-10-13", version: "2.4.1"},
|
|
18
|
+
{body: "### Added\n\n- Bulk-complete todos.", date: "2026-10-01", version: "2.4.0"},
|
|
19
|
+
];
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
2. Render it near the top of a screen people see after signing in:
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import {WhatsNewBanner} from "@terreno/ui";
|
|
26
|
+
|
|
27
|
+
<WhatsNewBanner releases={RELEASES} testID="home-whats-new-banner" />
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
3. Open the app. The banner shows the latest version and how many earlier releases the viewer
|
|
31
|
+
has not seen. **See what's new** opens a modal with the notes for every unseen release.
|
|
32
|
+
|
|
33
|
+
The last seen version is stored on the device under `@TerrenoUI:whatsNew:lastSeenVersion`.
|
|
34
|
+
Pass `storageKey` to keep separate histories, for example one per product.
|
|
35
|
+
|
|
36
|
+
## Generate releases from CHANGELOG.md
|
|
37
|
+
|
|
38
|
+
The example app builds its list from the repository changelog, so every release appears in the
|
|
39
|
+
banner without hand edits:
|
|
40
|
+
|
|
41
|
+
- `bun run whats-new:generate` writes the 10 most recent `CHANGELOG.md` releases to
|
|
42
|
+
`example-frontend/constants/whatsNew.generated.json`. Repo-relative links and `(#123)` pull
|
|
43
|
+
request numbers become GitHub URLs.
|
|
44
|
+
- `bun run changelog:assemble <version>` regenerates that file when it cuts a release.
|
|
45
|
+
- `bun run check:whats-new` fails when the file does not match `CHANGELOG.md`. CircleCI runs it
|
|
46
|
+
in the lint job, next to `check:changelog`.
|
|
47
|
+
|
|
48
|
+
`example-frontend/constants/whatsNew.ts` types the JSON as `WhatsNewRelease[]`, and
|
|
49
|
+
`SyncTodosScreen` renders the banner above the todo list.
|
|
50
|
+
|
|
51
|
+
## When to use announcements instead
|
|
52
|
+
|
|
53
|
+
`WhatsNewBanner` is static and per device. Use [product announcements](./product-announcements.md)
|
|
54
|
+
when you need audience targeting, required acknowledgement, scheduling, or analytics. Props are
|
|
55
|
+
listed in the [UI reference](../reference/ui.md#whatsnewbanner).
|
|
@@ -115,6 +115,8 @@ How asks and blocks divide the work:
|
|
|
115
115
|
| D25 | Compact surface mode | `surface: "compact"` on `/gpt/prompt` or `turn` offers only `ask_confirm` and a narrowed `ask_choice` (`select: one`, 2–3 options, labels ≤ 20), and appends one prompt line: keep replies to two short sentences and ask only yes/no or up to three short options. | assumed |
|
|
116
116
|
| D26 | Button order and safety | Primary answer first, Cancel last. A `destructive` confirm marks its approve button `destructive`, so Apple Watch Double Tap, which triggers the first non-destructive action, can never approve it. | assumed |
|
|
117
117
|
|
|
118
|
+
D27 note: Markdown images in `text` blocks and plain replies bypass `imageHosts`; this is documented as a known gap in `docs/explanation/agent-ui-asks.md`.
|
|
119
|
+
|
|
118
120
|
## Architecture
|
|
119
121
|
|
|
120
122
|
```
|
|
@@ -65,7 +65,7 @@ When this is done, an operator can:
|
|
|
65
65
|
| Q2 | Primitive shape | **Factories:** `defineMCPTool` / `defineMCPPrompt` / `defineMCPResource` wrapping `registerMCP*`. |
|
|
66
66
|
| Q3 | Default endpoint | **Keep `/mcp`.** Additive capabilities. No rename. |
|
|
67
67
|
| Q4 | Multiple servers | **Yes, v1.** Named mounts with explicit include lists. Default `/mcp` remains the union of CRUD + globally registered primitives. Extra servers are opt-in and do not auto-include every model tool. |
|
|
68
|
-
| Q5 | Auth | **Bearer (JWT + Better Auth session) plus OAuth 2.1 for MCP** via Better Auth (`@better-auth/mcp` + `@better-auth/oauth-provider`). Same User as REST after token exchange. JWT-only apps
|
|
68
|
+
| Q5 | Auth | **Bearer (JWT + Better Auth session) plus OAuth 2.1 for MCP** via Better Auth (`@better-auth/mcp` + `@better-auth/oauth-provider`). Same User as REST after token exchange. JWT-only apps get the Terreno-native `mcpOAuth` authorization server instead (#1573); with `mcpOAuth` off they keep bearer-only and mount no OAuth metadata. |
|
|
69
69
|
| Q6 | Tool metadata | **MCP spec hints:** `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, plus optional `outputSchema` + `structuredContent`. |
|
|
70
70
|
| Q7 | Conditional register | **`shouldRegister(user)`** (and optional `shouldRegister: false`). Filter list methods and reject call/get/read if hidden. |
|
|
71
71
|
| Q8 | Response helper | **`mcpResult.text` / `.json` / `.error`.** Images/audio out. |
|
|
@@ -161,7 +161,7 @@ Remote MCP clients (Claude connectors, ChatGPT, Cursor HTTP) expect **OAuth 2.1*
|
|
|
161
161
|
5. Extend `extractUserFromHeaders` to accept an OAuth access token on `Authorization: Bearer`, resolve it through Better Auth, then load the app `User` by `betterAuthId` — same mapping as session cookies today.
|
|
162
162
|
6. Keep JWT verification as a fallback so Inspector, tests, and `useMCPTools` keep working with session/JWT bearer.
|
|
163
163
|
|
|
164
|
-
**When Better Auth is off (JWT-only):**
|
|
164
|
+
**When Better Auth is off (JWT-only):** set `mcpOAuth: {enabled: true, …}` to mount the Terreno-native OAuth 2.1 authorization server ([Enable MCP OAuth](../how-to/enable-mcp-oauth.md)). It serves the RFC 9728 and RFC 8414 documents, authorize/consent, token, and revoke, and `/mcp` answers unauthenticated requests with the 401 challenge. Dynamic client registration is a second opt-in (`dynamicClientRegistration: true`), off by default because the endpoint is unauthenticated and lets anyone create a client; without it the app creates `McpOAuthClient` documents itself. Revoking a token family writes a tombstone that `McpOAuthToken.issue` honors, so in-flight exchanges cannot revive it. The app hosts the consent screen. Bearer JWT keeps working. With `mcpOAuth` off, no OAuth metadata mounts and bearer behavior is unchanged.
|
|
165
165
|
|
|
166
166
|
**Not in this IP:** social login providers (already documented); infra-mcp’s RBAC/elicitation; replacing Passport JWT for REST.
|
|
167
167
|
|
|
@@ -250,7 +250,7 @@ Tracer: example-backend custom tool already exists (`users_todo_statuses`). Add
|
|
|
250
250
|
- Wire plugin in Better Auth setup used by `BetterAuthApp`.
|
|
251
251
|
- RFC 9728 + 401 `WWW-Authenticate` on `/mcp`.
|
|
252
252
|
- `extractUserFromHeaders` accepts OAuth access tokens.
|
|
253
|
-
- JWT-only apps unchanged.
|
|
253
|
+
- JWT-only apps unchanged by this phase: they use the Terreno-native `mcpOAuth` server (#1573, [Enable MCP OAuth](../how-to/enable-mcp-oauth.md)). Reuse its well-known builders (`buildProtectedResourceMetadata`, `buildAuthorizationServerMetadata`) and `/mcp` challenge (`sendMcpOAuthChallenge`) from `api/src/mcp/oauthMetadata.ts`, which does not depend on the JWT authorization-server routes, so both stacks share one code path.
|
|
254
254
|
- How-to `docs/how-to/secure-app-mcp.md`; reference well-known paths.
|
|
255
255
|
- Tests with Better Auth test instance: no token → 401 challenge; valid access token → `tools/list` as that user.
|
|
256
256
|
|
|
@@ -311,7 +311,10 @@ None. Additive APIs. `registerMCPTool` signature may gain optional fields only.
|
|
|
311
311
|
- [ ] `invokeMCPTool` can assert a successful custom tool in Bun tests
|
|
312
312
|
- [ ] `MCPService` can list tools over Streamable HTTP against `mountMCPServer`
|
|
313
313
|
- [ ] Better Auth on: unauthenticated `/mcp` returns 401 with resource metadata; a valid OAuth access token runs tools as that user
|
|
314
|
-
- [ ] JWT-only: existing bearer behavior unchanged; no OAuth well-known
|
|
314
|
+
- [ ] JWT-only with `mcpOAuth` off: existing bearer behavior unchanged; no OAuth well-known mounted
|
|
315
|
+
- [ ] JWT-only with `mcpOAuth` on: RFC 9728/8414 documents mount, unauthenticated `/mcp` returns the 401 challenge, and bearer JWT still works
|
|
316
|
+
- [ ] `mcpOAuth` on without `dynamicClientRegistration`: `/oauth/register` is 404, metadata has no `registration_endpoint`, and an app-created client completes the flow
|
|
317
|
+
- [ ] A revoked token family stays revoked when a concurrent refresh or code exchange issues after the revoke
|
|
315
318
|
- [ ] Hosted `terreno_generate_mcp_tool|prompt|resource` return TypeScript matching the factories
|
|
316
319
|
- [ ] Docs: two MCP layers; prompts/resources; Inspector; OAuth vs bearer; generator tools
|
|
317
320
|
- [ ] `registerMCPTool` without new fields still typechecks and runs
|