@terreno/mcp 57.16.2 → 57.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
  2. package/dist/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
  3. package/dist/docs/upgrades/57.17.0.md +126 -0
  4. package/dist/docs/versioned/next/approval-rubric.md +171 -0
  5. package/dist/docs/versioned/next/explanation/admin-interface.md +5 -1
  6. package/dist/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
  7. package/dist/docs/versioned/next/explanation/authentication.md +60 -10
  8. package/dist/docs/versioned/next/explanation/example-coverage.md +2 -1
  9. package/dist/docs/versioned/next/explanation/model-router-actions.md +32 -1
  10. package/dist/docs/versioned/next/how-to/README.md +1 -0
  11. package/dist/docs/versioned/next/how-to/add-github-oauth.md +49 -16
  12. package/dist/docs/versioned/next/how-to/admin-add-model.md +4 -1
  13. package/dist/docs/versioned/next/how-to/ai-feature-development.md +5 -0
  14. package/dist/docs/versioned/next/how-to/background-jobs.md +18 -5
  15. package/dist/docs/versioned/next/how-to/circleci.md +39 -3
  16. package/dist/docs/versioned/next/how-to/configure-better-auth.md +283 -5
  17. package/dist/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
  18. package/dist/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
  19. package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
  20. package/dist/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
  21. package/dist/docs/versioned/next/how-to/install-agent-skills.md +8 -1
  22. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
  23. package/dist/docs/versioned/next/how-to/password-reset.md +8 -2
  24. package/dist/docs/versioned/next/how-to/product-announcements.md +5 -3
  25. package/dist/docs/versioned/next/how-to/rate-limiting.md +2 -2
  26. package/dist/docs/versioned/next/how-to/run-tests-locally.md +50 -0
  27. package/dist/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
  28. package/dist/docs/versioned/next/how-to/websocket-integration.md +42 -9
  29. package/dist/docs/versioned/next/how-to/whats-new-banner.md +55 -0
  30. package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
  31. package/dist/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
  32. package/dist/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
  33. package/dist/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
  34. package/dist/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
  35. package/dist/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
  36. package/dist/docs/versioned/next/reference/admin-backend.md +46 -0
  37. package/dist/docs/versioned/next/reference/admin-config.md +2 -2
  38. package/dist/docs/versioned/next/reference/ai.md +48 -4
  39. package/dist/docs/versioned/next/reference/api.md +410 -12
  40. package/dist/docs/versioned/next/reference/blocks.md +1 -1
  41. package/dist/docs/versioned/next/reference/comms.md +19 -1
  42. package/dist/docs/versioned/next/reference/environment-variables.md +6 -1
  43. package/dist/docs/versioned/next/reference/feature-flags.md +1 -1
  44. package/dist/docs/versioned/next/reference/jobs.md +8 -6
  45. package/dist/docs/versioned/next/reference/legacy/rtk.md +145 -18
  46. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
  47. package/dist/docs/versioned/next/reference/syncdb.md +64 -3
  48. package/dist/docs/versioned/next/reference/ui.md +23 -1
  49. package/package.json +3 -3
  50. package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
  51. package/src/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
  52. package/src/docs/upgrades/57.17.0.md +126 -0
  53. package/src/docs/versioned/next/approval-rubric.md +171 -0
  54. package/src/docs/versioned/next/explanation/admin-interface.md +5 -1
  55. package/src/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
  56. package/src/docs/versioned/next/explanation/authentication.md +60 -10
  57. package/src/docs/versioned/next/explanation/example-coverage.md +2 -1
  58. package/src/docs/versioned/next/explanation/model-router-actions.md +32 -1
  59. package/src/docs/versioned/next/how-to/README.md +1 -0
  60. package/src/docs/versioned/next/how-to/add-github-oauth.md +49 -16
  61. package/src/docs/versioned/next/how-to/admin-add-model.md +4 -1
  62. package/src/docs/versioned/next/how-to/ai-feature-development.md +5 -0
  63. package/src/docs/versioned/next/how-to/background-jobs.md +18 -5
  64. package/src/docs/versioned/next/how-to/circleci.md +39 -3
  65. package/src/docs/versioned/next/how-to/configure-better-auth.md +283 -5
  66. package/src/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
  67. package/src/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
  68. package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
  69. package/src/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
  70. package/src/docs/versioned/next/how-to/install-agent-skills.md +8 -1
  71. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
  72. package/src/docs/versioned/next/how-to/password-reset.md +8 -2
  73. package/src/docs/versioned/next/how-to/product-announcements.md +5 -3
  74. package/src/docs/versioned/next/how-to/rate-limiting.md +2 -2
  75. package/src/docs/versioned/next/how-to/run-tests-locally.md +50 -0
  76. package/src/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
  77. package/src/docs/versioned/next/how-to/websocket-integration.md +42 -9
  78. package/src/docs/versioned/next/how-to/whats-new-banner.md +55 -0
  79. package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
  80. package/src/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
  81. package/src/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
  82. package/src/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
  83. package/src/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
  84. package/src/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
  85. package/src/docs/versioned/next/reference/admin-backend.md +46 -0
  86. package/src/docs/versioned/next/reference/admin-config.md +2 -2
  87. package/src/docs/versioned/next/reference/ai.md +48 -4
  88. package/src/docs/versioned/next/reference/api.md +410 -12
  89. package/src/docs/versioned/next/reference/blocks.md +1 -1
  90. package/src/docs/versioned/next/reference/comms.md +19 -1
  91. package/src/docs/versioned/next/reference/environment-variables.md +6 -1
  92. package/src/docs/versioned/next/reference/feature-flags.md +1 -1
  93. package/src/docs/versioned/next/reference/jobs.md +8 -6
  94. package/src/docs/versioned/next/reference/legacy/rtk.md +145 -18
  95. package/src/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
  96. package/src/docs/versioned/next/reference/syncdb.md +64 -3
  97. package/src/docs/versioned/next/reference/ui.md +23 -1
@@ -47,7 +47,7 @@ Comprehensive guide to environment variables used across Terreno packages and ex
47
47
 
48
48
  | Variable | Read by | Required | Default | Secret | Scope |
49
49
  |----------|---------|----------|---------|--------|-------|
50
- | `BETTER_AUTH_SECRET` | `@terreno/api` | ✅ if Better Auth | — | Yes | server |
50
+ | `BETTER_AUTH_SECRET` | `@terreno/api`, example-backend | ✅ if Better Auth | example-backend outside production: a published dev-only value. With `NODE_ENV=production`, example-backend refuses to start when it is unset or equals that value | Yes | server |
51
51
  | `BETTER_AUTH_URL` | `@terreno/api` | ✅ if Better Auth | — | No | server |
52
52
  | `GOOGLE_CLIENT_ID` | `@terreno/api` | ❌ | — | No | server |
53
53
  | `GOOGLE_CLIENT_SECRET` | `@terreno/api` | ❌ | — | Yes | server |
@@ -282,6 +282,7 @@ example-backend **test** setup only. They are not read by `@terreno/jobs`. Use
282
282
  | Variable | Read by | Required | Default | Secret | Scope |
283
283
  |----------|---------|----------|---------|--------|-------|
284
284
  | `OTEL_SERVICE_NAME` | OpenTelemetry (example-backend) | ❌ | `example-backend` | No | server |
285
+ | `SEED_DEFAULTS` | example-backend boot | ❌ | — | No | server |
285
286
  | `PR_NUMBER` | PR preview deploy | ❌ | — | No | server |
286
287
  | `PR_SERVICE_URL` | PR preview deploy | ❌ | — | No | server |
287
288
  | `JOBS_RUNNER` | example-backend | ❌ | `mongo` | No | server |
@@ -297,6 +298,10 @@ example-backend **test** setup only. They are not read by `@terreno/jobs`. Use
297
298
  | `WIDGET_CLIENT_SECRET` | widgets | ❌ | — | Yes | server |
298
299
  | `API_KEY` | integrations | ❌ | — | Yes | server |
299
300
 
301
+ `SEED_DEFAULTS=true` runs the example seed (default users, all with the published password
302
+ `testpassword123`) when the server starts. A process with `NODE_ENV=production` logs a warning and
303
+ skips it, so no deployed revision, PR previews included, holds those accounts.
304
+
300
305
  ## Testing & CI (tooling only)
301
306
 
302
307
  | Variable | Read by | Scope |
@@ -301,4 +301,4 @@ Segment names can be referenced in flag rules via the admin UI.
301
301
 
302
302
  | Variable | Description | Default |
303
303
  |----------|-------------|---------|
304
- | `FEATURE_FLAGS_DEBUG` | Set to `"false"` to disable evaluation debug logs | Enabled |
304
+ | `FEATURE_FLAGS_DEBUG` | Set to `"true"` to write evaluation diagnostics at `debug` level: flag key, rule field, operator, and result. User attribute values are never logged | Disabled |
@@ -33,7 +33,7 @@ Peers: `mongoose ^8.0.0 || ^9.0.0`; optional `@google-cloud/tasks`, `@vercel/que
33
33
  | `basePath` | `"/jobs"` | Admin routes and execute route prefix |
34
34
  | `runner` | `MongoJobRunner` | Dispatch + optional poll loop |
35
35
  | `timezone` | `"UTC"` | Default IANA zone for schedules without `schedule.timezone` |
36
- | `lockTtlMs` | `900_000` (15 min) | Stale `lockedAt` reclaim threshold |
36
+ | `lockTtlMs` | `900_000` (15 min) | Claim lease length; renewed every `lockTtlMs / 3` while the handler runs, reclaimable once stale |
37
37
  | `pollIntervalMs` | `1_000` | Mongo runner idle sleep between polls |
38
38
  | `accessControl` | — | Terreno RBAC; jobs admin needs `admin:access` **and** `admin:jobs` |
39
39
  | `redactPayload` | — | Admin payload projection hook (see below) |
@@ -90,8 +90,10 @@ singleton (tests).
90
90
 
91
91
  `@terreno/admin-backend` `defineAdminScriptJob` registers handler name `admin/script` with
92
92
  `retry.maxAttempts: 1`. Payload: `{scriptName, taskId, wetRun, args, createdByName?}`.
93
- The Scripts UI still polls `BackgroundTask` by `taskId`. Without `JobsApp`, script HTTP
94
- runs stay in-process.
93
+ The Scripts UI still polls `BackgroundTask` by `taskId`. The handler runs the script only
94
+ when it wins the task's `pending` -> `running` transition: a task that already started
95
+ throws a 409 `APIError` (the job goes `dead`), and a task cancelled before it started is
96
+ skipped. Without `JobsApp`, script HTTP runs stay in-process.
95
97
 
96
98
  ## JobRunner
97
99
 
@@ -116,13 +118,13 @@ Built-in ids: `mongo`, `gcp-cloud-tasks`, `vercel-queues`.
116
118
  | `payload` | — | Handler input |
117
119
  | `payloadRedacted` | `false` | Admin omission flag |
118
120
  | `runAt` | — | Earliest claim (indexed) |
119
- | `attemptCount` | `0` | Executions so far |
121
+ | `attemptCount` | `0` | Failed or lost (lease-expired) attempts so far |
120
122
  | `attempts` | `[]` | `{at, error?, errorClass?}` |
121
123
  | `maxAttempts` | `5` | From definition at enqueue |
122
124
  | `backoffMs` | `1_000` | Retry base |
123
125
  | `maxBackoffMs` | `900_000` | Retry cap |
124
126
  | `lastError` | — | Latest failure message; `$unset` when the job completes |
125
- | `lockedAt` / `lockedBy` | — | Claim lease |
127
+ | `lockedAt` / `lockedBy` | — | Claim lease; `lockedAt` is renewed while the handler runs |
126
128
  | `idempotencyKey` | — | Partial unique with `name` |
127
129
  | `scheduleId` | — | Parent schedule ref |
128
130
  | `retriedFromId` / `retriedById` | — | Admin retry linkage |
@@ -138,7 +140,7 @@ Indexes: `(name, idempotencyKey)` partial unique; `(status, runAt)`; `(scheduleI
138
140
  | `running` | Claimed; handler executing |
139
141
  | `scheduled` | Reserved in schema / in-flight filter |
140
142
  | `completed` | Success |
141
- | `dead` | Exhausted `maxAttempts` |
143
+ | `dead` | Exhausted `maxAttempts` (handler failures and lease-expired reclaims both count) |
142
144
  | `failed` | No handler registered at run time |
143
145
  | `cancelled` | Admin cancel |
144
146
 
@@ -46,7 +46,7 @@ export const store = configureStore({
46
46
  **Returns:**
47
47
  - `authReducer` — Redux reducer for auth state
48
48
  - `authSlice` — Full Redux slice
49
- - `logout` — Action to clear tokens and reset state
49
+ - `logout` — Action to clear tokens and reset state. A logout that ends a signed-in session also purges the API's RTK Query cache (`resetApiState`), so the next user never gets the previous user's cached responses
50
50
  - `setUserId` — Action to set current user ID
51
51
  - `tokenRefreshedSuccess` — Action signaling token refresh
52
52
  - `middleware` — Login/logout listener middleware array
@@ -88,46 +88,158 @@ const userId = selectCurrentUserId(state);
88
88
  For apps using Better Auth instead of JWT:
89
89
 
90
90
  ``````typescript
91
- import {createBetterAuthClient, generateBetterAuthSlice} from "@terreno/rtk";
92
91
  import {configureStore} from "@reduxjs/toolkit";
92
+ import {createBetterAuthClient, generateBetterAuthSlice} from "@terreno/rtk";
93
+ import {api} from "./sdk"; // the app's generated RTK Query API
93
94
 
94
- // Create Better Auth client
95
+ // Create Better Auth client. `scheme` is the app's deep-link scheme (required).
95
96
  export const authClient = createBetterAuthClient({
96
97
  baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:4000",
98
+ scheme: "myapp",
97
99
  });
98
100
 
99
- // Generate session Redux slice
100
- const {sessionReducer, sessionMiddleware} = generateBetterAuthSlice(authClient);
101
+ // Generate the session Redux slice; list the APIs whose cache belongs to the signed-in user
102
+ const betterAuth = generateBetterAuthSlice({apisToReset: [api], authClient});
101
103
 
102
104
  export const store = configureStore({
103
105
  reducer: {
104
- session: sessionReducer,
106
+ betterAuth: betterAuth.reducer, // selectors read `state.betterAuth`
105
107
  // ... other reducers
106
108
  },
107
- middleware: (getDefault) => getDefault().concat(sessionMiddleware),
109
+ middleware: (getDefault) => getDefault().concat(...betterAuth.middleware),
108
110
  });
111
+
112
+ // Call after sign-in, sign-out, and at app startup to copy the session into Redux
113
+ void betterAuth.syncSession(store.dispatch);
109
114
  ``````
110
115
 
111
116
  **Key exports:**
112
- - `createBetterAuthClient` — Factory for Better Auth client with Expo support
113
- - `generateBetterAuthSlice` — Redux slice for session state management
114
- - `BetterAuthSessionData` — TypeScript types for session/user data
115
-
116
- **Session State:**
117
+ - `createBetterAuthClient({baseURL, scheme, storagePrefix?, webAuth?, bearerStorage?})` — Factory for Better Auth client with Expo support. `webAuth: "bearer"` authenticates the web client with a stored bearer token and no cookies, for a frontend and API on different sites ([how-to](../../how-to/configure-better-auth.md#frontend-and-api-on-different-sites))
118
+ - `getBetterAuthBearerToken({storagePrefix?, bearerStorage?})` — Reads the token bearer mode stored, or `null`
119
+ - `signInSocialWithHandoff({client, provider, callbackURL?, errorCallbackURL?})` — Starts a social or generic OAuth sign-in for a web bearer client: stores a random nonce in `sessionStorage` and navigates the page to the server's `{basePath}/oauth-handoff/start` with `handoff=<nonce>` on the callback URLs. Throws on native and in cookie mode ([how-to](../../how-to/configure-better-auth.md#social-sign-in-in-bearer-mode))
120
+ - `completeBetterAuthOAuthHandoff({client}): Promise<boolean>` — On the return page, removes `#ott` and `handoff` from the URL first, then exchanges the token at `{basePath}/one-time-token/verify` only when `handoff` matches the stored nonce. Resolves `true` only when a bearer token was stored; never rejects. Await it at startup before session reads and Sentry
121
+ - `generateBetterAuthSlice({authClient, apisToReset?, syncInterval?})` — Returns `{reducer, middleware, actions, slice, syncSession, authClient}`. Pass the app's RTK Query APIs (for example `apisToReset: [terrenoApi]`) so their caches are purged (`util.resetApiState()`) when a signed-in session logs out (`betterAuth/logout` or `auth/logout`), when `syncSession` finds the session gone, and when the session changes to another user. Without it, the next account on the same client can be served the previous account's cached responses
122
+ - `selectBetterAuthIsAuthenticated`, `selectBetterAuthUserId`, `selectBetterAuthUser` — Selectors over `state.betterAuth`
123
+ - `BetterAuthUser`, `BetterAuthState` — TypeScript types for session/user data
124
+
125
+ **Session State (`state.betterAuth`):**
117
126
  ``````typescript
118
127
  {
119
- session: BetterAuthSession | null;
128
+ isAuthenticated: boolean;
129
+ userId: string | null;
120
130
  user: BetterAuthUser | null;
121
131
  isLoading: boolean;
132
+ error: string | null;
133
+ lastSyncTimestamp: number | null;
122
134
  }
123
135
  ``````
124
136
 
125
137
  **Learn more:** [Configure Better Auth](../../how-to/configure-better-auth.md)
126
138
 
139
+ ### Token source
140
+
141
+ rtk reads the request token from one registered **token source**, typed as syncdb's
142
+ `AuthProvider` (`getToken`, `getUserId`, `onAuthChange`, optional `refresh`). A Better Auth
143
+ app registers the same adapter it builds for syncdb, once at startup:
144
+
145
+ ``````typescript
146
+ import {setTokenSource} from "@terreno/rtk";
147
+ import {betterAuthAdapter, bridgeBetterAuthReactClient} from "@terreno/syncdb";
148
+ import {authClient} from "./betterAuth";
149
+
150
+ export const authProvider = betterAuthAdapter(bridgeBetterAuthReactClient(authClient));
151
+ setTokenSource(authProvider); // before the first RTK Query request
152
+ ``````
153
+
154
+ With `webAuth: "bearer"`, also pass the adapter `getBearerToken` so it reads the stored token
155
+ without a `get-session` call ([syncdb adapter](../syncdb.md#createsyncdb-configuration)).
156
+
157
+ **Exports:**
158
+ - `setTokenSource(source | undefined)` — Registers the source. `undefined` restores the default.
159
+ - `getTokenSource()` — The registered source, or `undefined` when none is registered.
160
+ - `jwtTokenSource` — The legacy JWT source: `getToken` is `getAuthToken`, `refresh` runs
161
+ `refreshAuthToken` and resolves `true` on success, `getUserId` reads the stored JWT's `id`
162
+ (or `sub`) claim, and `onAuthChange` fires on JWT login, logout, and token refresh.
163
+ - `isJwtTokenSource(source)` — `true` for `undefined` or `jwtTokenSource`.
164
+
165
+ **Base query behavior (`emptySplitApi` / `staggeredBaseQuery`):**
166
+ - **No source registered, or `jwtTokenSource`:** the JWT path below, unchanged. JWT apps need
167
+ no code change.
168
+ - **Any other source:** sends `authorization: Bearer <token>` from `getToken()` on every
169
+ request, or no `authorization` header when the token is null. It never logs out because a
170
+ token is missing. On a 401 it calls `refresh()` once (shared by concurrent requests), re-reads
171
+ the token, and retries once; only a second 401 dispatches `auth/logout`. The public endpoints
172
+ `emailLogin`, `emailSignUp`, `getVersionCheck`, `getZoomSignature`, and `googleLogin` skip
173
+ the refresh and retry.
174
+
175
+ The registry is module-global, like `baseUrl`: one app, one source. Apps that forked the base
176
+ query to read the Better Auth session can point their SDK back at `emptySplitApi` once they
177
+ register the source.
178
+
179
+ **Other consumers.** The socket, chat, MCP, and offline APIs read the same source. Each takes a
180
+ `tokenSource` option that overrides it for that one consumer. Without the option they use the
181
+ registered source, else `jwtTokenSource`. Pass `useSocketConnection` a stable object, not a new
182
+ one per render, or it re-subscribes to `onAuthChange`.
183
+
184
+ | API | Option | How it uses the source |
185
+ | --- | --- | --- |
186
+ | `useSocketConnection` | `tokenSource`, used when `getAuthToken` is omitted | Reads `getToken()` on every connect and reconnect, refreshes once per auth failure, and reconnects when `onAuthChange` brings a new token. See [useSocketConnection](#usesocketconnection). |
187
+ | `useTerrenoChat` | `tokenSource` | Reads `getToken()` per chat request. Sends no `Authorization` header when it returns null. |
188
+ | `useMCPTools` | `tokenSource` | Reads `getToken()` before every MCP request, so a refreshed token needs no refetch. A fetch that starts with no token connects without auth. |
189
+ | `createOfflineMiddleware` | `tokenSource` | With a non-JWT source, tags queued mutations with `getUserId()` and replays them with `getToken()`. See [Offline queue](#offline-queue). |
190
+
191
+ #### Offline queue
192
+
193
+ `createOfflineMiddleware({endpoints, api, tokenSource?})` queues the listed mutations while
194
+ offline and replays them on reconnect.
195
+
196
+ - **JWT source:** the queue reads `state.auth.userId` and the stored JWT, as before.
197
+ - **Any other source** (for example syncdb's `betterAuthAdapter`): each queued mutation carries
198
+ `userId` from `getUserId()`. Replay runs only while that same user is signed in. It sends
199
+ `authorization: Bearer <token>` from `getToken()`, or no header when the token is null.
200
+ - **Last user id:** `state.offline.lastUserId` holds the last id the source confirmed. It
201
+ refreshes on the first action, on rehydrate, on `onAuthChange`, and on reconnect, so a failed
202
+ session read while offline does not untag new mutations.
203
+ - **Persist the `offline` slice** with redux-persist (do not blacklist it). Otherwise mutations
204
+ made after an app launches offline carry no user and are dropped on reconnect.
205
+ - **Unconfirmed ids:** a rehydrated id is unconfirmed until a read in this process returns a
206
+ user. Mutations tagged with it carry `userIdConfirmed: false`. A later read keeps them only
207
+ when it names the same user and no `onAuthChange` or source swap happened since. Any auth
208
+ change drops them, even for the same user.
209
+ - **Cost:** syncdb's `betterAuthAdapter` fires `onAuthChange` on the session's first successful
210
+ load. After an offline launch that happens when the network returns, so it drops
211
+ offline-launch writes the reconnect read has not confirmed yet.
212
+ - **Residual risk:** the signed-in user changes while no running instance of the middleware sees
213
+ it (for example in another browser tab that shares the persisted slice), between an
214
+ unconfirmed write and the next launch's first read. Then that write can replay as a user who
215
+ did not make it.
216
+ - **Sign-out:** `auth/logout`, `betterAuth/logout` (on a non-JWT source), or the source
217
+ reporting no user after `onAuthChange` clears the queue, conflicts, and `lastUserId`, and
218
+ discards in-flight enqueues and replays.
219
+ - **No user on reconnect:** when the source has no user, or the user changes during a replay,
220
+ the rest of the queue stays for a later reconnect.
221
+ - **Different user on reconnect:** a replay drops each mutation queued by a user other than the
222
+ one signed in.
223
+
224
+ Offline-slice exports the middleware uses for the user tag (exported from the package root):
225
+
226
+ - `setLastUserId(userId | undefined)` — Action that sets `state.offline.lastUserId`.
227
+ - `resolveUnconfirmedMutations(userId)` — Action that keeps unconfirmed mutations tagged with
228
+ `userId`, marks them confirmed, and drops the other unconfirmed ones.
229
+ - `dropUnconfirmedMutations()` — Action that drops every mutation whose user id is unconfirmed.
230
+ - `selectLastUserId(state)` — The last user id the source confirmed, or `undefined` when unknown
231
+ or signed out.
232
+
127
233
  ## WebSocket Integration
128
234
 
129
235
  > **Deprecated (removed in Terreno 58):** `realtimeList`, `realtimeDocument`, `setRealtimeSocket`, and `getRealtimeSocket` patch RTK Query caches from `sync` events. Use [`@terreno/syncdb`](../syncdb.md). `useSocketConnection` remains for feature flags and other sockets.
130
236
 
237
+ A `realtimeList` query subscription (`subscribe:query`) may filter only on fields in the router's
238
+ `queryFields`, the same as the REST list; the server ignores a subscription that names any other
239
+ field. On RBAC routers with field views, each subscriber's create, update, or delete event is
240
+ decided against the document as their field view masks it, so a hidden field never changes which
241
+ event they receive.
242
+
131
243
  ### useSocketConnection
132
244
 
133
245
  React hook for managing Socket.io connections with automatic reconnection, token refresh, and user feedback.
@@ -135,10 +247,10 @@ React hook for managing Socket.io connections with automatic reconnection, token
135
247
  ``````typescript
136
248
  import {useSocketConnection} from "@terreno/rtk";
137
249
 
250
+ // Reads the token source registered with setTokenSource (the JWT in storage by default).
138
251
  const {socket, isSocketConnected} = useSocketConnection({
139
252
  baseUrl: "wss://api.example.com",
140
253
  shouldConnect: !!userId,
141
- getAuthToken: () => getAuthToken(),
142
254
  onConnect: () => console.info("WebSocket connected"),
143
255
  onDisconnect: () => console.warn("WebSocket disconnected"),
144
256
  onConnectError: (error) => console.error("Connection error:", error),
@@ -162,7 +274,11 @@ useEffect(() => {
162
274
  **Options:**
163
275
  - `baseUrl` (string, required) — WebSocket server URL
164
276
  - `shouldConnect` (boolean, required) — Whether to connect (typically `!!userId`)
165
- - `getAuthToken` (function, required) — Async function returning JWT token
277
+ - `tokenSource` (`AuthProvider`) — Source for the handshake token. Defaults to the
278
+ [registered source](#token-source), else `jwtTokenSource`. Used only when `getAuthToken` is
279
+ omitted.
280
+ - `getAuthToken` (function) — Legacy getter. When passed, the hook reads the token once per
281
+ connect, sends it as a static `auth` payload, and ignores `tokenSource`.
166
282
  - `onConnect` (function) — Callback on successful connection
167
283
  - `onDisconnect` (function) — Callback on disconnection
168
284
  - `onConnectError` (function) — Callback on connection error
@@ -175,7 +291,7 @@ useEffect(() => {
175
291
 
176
292
  **Features:**
177
293
  - **Automatic reconnection:** 5 attempts with exponential backoff (1-5 seconds)
178
- - **Bearer token authentication:** Automatically includes JWT in `socket.auth`
294
+ - **Bearer token authentication:** Sends `Bearer <token>` in `socket.auth`
179
295
  - **Token refresh integration:** Reconnects automatically when tokens are refreshed
180
296
  - **User feedback:** Toast notifications for disconnections (after 9+ seconds) and token errors
181
297
  - **Connection monitoring:** Periodic checks with automatic reconnection attempts
@@ -186,7 +302,18 @@ useEffect(() => {
186
302
  - **Reconnection:** Shows "You have been reconnected" (suppressed if reconnect within 10 seconds)
187
303
  - **Token error:** Shows "Error refreshing token. Please log out and log back in..." with persistent error state
188
304
 
189
- **Token Management:**
305
+ **Token source behavior (no `getAuthToken`):**
306
+ - The handshake reads `getToken()` on every connect and reconnect, including socket.io's own
307
+ reconnects.
308
+ - On an auth `connect_error` (the server's `UnauthorizedError`), the hook calls `refresh()`
309
+ once and reconnects. A second auth failure before a successful connect stops reconnecting and
310
+ calls `onConnectError`. If the source has no `refresh`, or it fails, the hook stops at once.
311
+ A successful connect resets this.
312
+ - While `shouldConnect` is true, an `onAuthChange` with a different token drops a live
313
+ connection and reconnects with the new token. With no token it disconnects. The same token
314
+ keeps the connection up.
315
+
316
+ **JWT token management** (`getAuthToken` passed, or the JWT source):
190
317
  - Checks token expiration on disconnect and connection errors
191
318
  - Automatically refreshes tokens if expiring within 60 seconds
192
319
  - Attempts reconnection after successful token refresh
@@ -335,7 +462,7 @@ Base RTK Query API with authentication, retry logic, and automatic token refresh
335
462
  - **Query serialization:** Uses `qs.stringify()` for complex queries (`$in`, `$lt`, `$gte`)
336
463
 
337
464
  **Automatic Headers:**
338
- - `authorization: Bearer <token>`
465
+ - `authorization: Bearer <token>` (from the registered [token source](#token-source); the JWT in storage by default)
339
466
  - `App-Version` (from Expo config)
340
467
  - `App-Platform` ("web" or "mobile")
341
468
 
@@ -68,6 +68,11 @@ fresh subagent with no parent conversation. If the repository root defines a `pr
68
68
  package script, Taste runs it as the authoritative local gate; otherwise it falls back
69
69
  to lint, typecheck, and locally affected tests in each affected package. Taste then
70
70
  pushes and watches product CI.
71
+ Every stage that pushes follows `references/push-ownership.md`: it owns the PR until
72
+ CI is green on every host and every review-bot comment is fixed, declined with a
73
+ reply, or raised as a decision. After three fix rounds that leave the PR red, Taste
74
+ stops pushing and escalates as `BLOCKED`; the final message starts with
75
+ `BLOCKED — PR #<n> still red after <k> fix rounds`. No stage ends quietly on a red PR.
71
76
  Taste observes product CI on every discovered host (GitHub Actions, CircleCI,
72
77
  Buildkite, and similar), not only GitHub checks. A documented not-applicable host
73
78
  counts as skipped; an unexplained untriggered host prevents Brew `PASS`. Brew still
@@ -91,6 +91,10 @@ todoSchema.plugin(syncPlugin); // stamps per-stream _syncSeq on every write
91
91
 
92
92
  Apply plugins **before** the model compiles. Registration validates their presence.
93
93
 
94
+ `_syncSeq` and `_syncPrevStream` are server-owned. modelRouter create and update (REST, MCP
95
+ tools, and sync mutations) drop them from the client body, and a save that changes only these
96
+ fields gets a freshly claimed seq instead of keeping the value it was given.
97
+
94
98
  ### modelRouter sync config
95
99
 
96
100
  Use the three-argument `modelRouter` form:
@@ -132,8 +136,16 @@ new TerrenoApp({userModel: User})
132
136
  - **`ensureSyncIndexes`** — `TerrenoApp.start()` binds the HTTP port first (or attaches to `httpServer` if the process already listened), then awaits index builds for snapshot queries and sync bookkeeping (`SyncMutation.mutationId` unique, `SyncCounter.stream` unique, etc.). Registration queues this work without contacting MongoDB, so models can load before the database connects. Hosts that build Express without `TerrenoApp.start()` should await `ensureSyncIndexes()` after connecting.
133
137
 
134
138
  Socket auth requires at least one configured authentication method. It enables legacy JWT
135
- validation when `tokenSecret` or `TOKEN_SECRET` is available. Better Auth can be used without
136
- a JWT secret:
139
+ validation when `tokenSecret` or `TOKEN_SECRET` is available. When a `BetterAuthApp` is
140
+ registered on the same `TerrenoApp`, `RealtimeApp` validates Better Auth sessions with its
141
+ instance automatically, with or without a JWT secret. The handshake creates the app `User` on
142
+ first contact and refuses disabled users. A `RealtimeApp` without its own `userModel` uses the
143
+ `TerrenoApp`'s, for JWT and Better Auth sockets alike: socket authorization reads the user from
144
+ the database instead of the token's claims, and the revalidation sweep disconnects users who
145
+ were disabled or deleted. With a user model, the JWT handshake also refuses a token whose user
146
+ is disabled or no longer exists, so a disconnected client cannot reconnect with the same token.
147
+ Pass `betterAuth` only for a `RealtimeApp` mounted without
148
+ `TerrenoApp`:
137
149
 
138
150
  ```typescript
139
151
  new RealtimeApp({betterAuth: {auth, userModel: User}})
@@ -162,6 +174,49 @@ bridge exposes the client's `$store.atoms.session` atom and degrades to the poll
162
174
  that atom is absent. Clients that already expose `getSession` plus a subscribable `useSession` can
163
175
  be passed to `betterAuthAdapter` directly.
164
176
 
177
+ `betterAuthAdapter(client, options)` options:
178
+
179
+ | Option | Type | Default | Description |
180
+ |--------|------|---------|-------------|
181
+ | `getBearerToken` | `() => string \| null` | — | Reads a locally stored session token. When it returns a token, `getToken` uses it and skips `getSession()`. A null or empty result, or a throw, falls back to `getSession()`. `getUserId`, `onAuthChange` and `refresh` always read the session. |
182
+ | `pollIntervalMs` | `number` | `5000` | Polling cadence when the client has no subscribable session |
183
+ | `setIntervalFn` / `clearIntervalFn` | test hooks | global timers | Inject timers for deterministic tests |
184
+
185
+ For a web app in Better Auth bearer mode (`createBetterAuthClient({webAuth: "bearer"})`), pass
186
+ rtk's `getBetterAuthBearerToken` so every sync request reads the stored token without a
187
+ `get-session` round trip. syncdb does not import rtk; the app wires the two together:
188
+
189
+ ```typescript
190
+ import {getBetterAuthBearerToken} from "@terreno/rtk";
191
+ import {betterAuthAdapter, bridgeBetterAuthReactClient} from "@terreno/syncdb";
192
+
193
+ const authProvider = betterAuthAdapter(bridgeBetterAuthReactClient(authClient), {
194
+ getBearerToken: () => getBetterAuthBearerToken({storagePrefix: "myapp"}),
195
+ });
196
+ ```
197
+
198
+ Pass the same `storagePrefix` and `bearerStorage` you gave `createBetterAuthClient`. The option
199
+ is explicit: the adapter never detects bearer mode from the client.
200
+
201
+ The adapter never throws on a failed session read (a rejected `getSession()`, or a `{error}`
202
+ envelope other than a 401). `getToken` and `refresh` answer null and false. `getUserId` answers
203
+ with the user id the server last confirmed, through a successful read or a session-atom
204
+ emission, and null before the first confirmation. A read that fails while the device is offline
205
+ is therefore never reported as a sign-out, which would clear the client's current user and make
206
+ `mutate()` throw. A successful read with no session, or a 401 (from `getSession()` or the session
207
+ atom), is a sign-out; a 401 emission also fires `onAuthChange`, deduped like any other
208
+ signed-out emission.
209
+
210
+ The last confirmed user id follows the newest answer, not the last one to arrive. A read counts
211
+ from when it started; an emission that changes or clears the identity counts from when it
212
+ arrived. A read that started before a sign-out or user switch and resolves after it still
213
+ answers its own caller, but does not update the confirmed user id. Emissions that repeat the
214
+ atom's cached session are not new answers: a refetch-start emission (`isRefetching: true`) is
215
+ ignored, and the value a new subscriber receives on subscribing only fills the confirmed user id
216
+ before anything else has confirmed one.
217
+
218
+ `createSyncDb(options)` options:
219
+
165
220
  | Option | Type | Default | Description |
166
221
  |--------|------|---------|-------------|
167
222
  | `name` | `string` | — (required) | Persisted database name |
@@ -616,7 +671,7 @@ is 2 requests (`GET /sync/streams` plus one batch) instead of 101.
616
671
 
617
672
  | Endpoint | Purpose |
618
673
  |----------|---------|
619
- | `GET /sync/snapshot?collection=&stream=&cursor=&limit=` | Bootstrap + catch-up for one stream (`limit` max 100) |
674
+ | `GET /sync/snapshot?collection=&stream=&cursor=&limit=` | Bootstrap + catch-up for one stream (`limit` max 100). Soft-deleted documents, including pre-sync rows paged by `legacyCursor` (seq 0), arrive as tombstones with `data: null` |
620
675
  | `POST /sync/snapshot/batch` | Bootstrap + catch-up for many streams in one round trip. Body `{streams: [{stream, cursor?, legacyCursor?}], limit?}`, at most 500 streams, no repeats. `limit` (default 5,000, max 10,000) is the entity budget for the whole response, split evenly across the streams. Returns `{pages}`, one `GET /sync/snapshot`-shaped page per stream in request order. Any stream the caller may not read fails the whole request |
621
676
  | `GET /sync/streams` | Current stream membership for the user |
622
677
  | `GET /sync/entities` | Point lookup for entity repair and admin window hydrate. Admin-window callers (`admin:access` with RBAC, else `user.admin`) on `adminBroadcast` collections receive requested ids across product streams. When AdminApp registered a scope, list/read/`queryFilter` still apply; unknown or out-of-scope ids are omitted |
@@ -642,6 +697,12 @@ Conflict responses on mutate: **409** with `{nack}` body (`code: "conflict"`).
642
697
 
643
698
  Limits: 50 collection subscriptions per socket; 100 mutations/second per socket (each batch mutation counts); batches capped at 100.
644
699
 
700
+ A redelivered `mutationId` returns the outcome recorded for it only to the same user and only
701
+ for the collection it was first sent to; any other replay gets an `unauthorized` nack. A
702
+ replayed `conflict` nack includes `serverDoc` only when the caller can still read the document
703
+ now (read permission and stream membership, or the admin broadcast scope for an admin-window
704
+ mutation); otherwise it carries `serverSeq` alone.
705
+
645
706
  ## Encryption at rest
646
707
 
647
708
  **Web:** AES-256-GCM encrypted IndexedDB by default via pluggable `KeyProvider`:
@@ -97,7 +97,7 @@ const AppearanceSetting = () => {
97
97
  };
98
98
  ``````
99
99
 
100
- Dark `surface.warningLight` uses `warning200`. The lighter warning orange, `warning100`, is 2.52:1 against warning text and misses WCAG AA. Highlighted `DataTable` cells switch to `text.inverted` when `text.primary` is below 4.5:1 on that fill. Inverted `MarkdownView` code uses `surface.neutralDark` when `surface.neutralLight` is below 4.5:1 against that text, and blockquotes do the same with `surface.baseAlternate`. Primary `Button` labels use `text.secondaryDark` when that pair meets 4.5:1 on `surface.primary`, and `text.inverted` otherwise, so `setTheme(darkThemeConfig)` stays readable without changing `colorScheme`. The demo palette generator emits status `050` steps so custom palettes update those dark error, success, and warning surfaces.
100
+ Dark `surface.warningLight` uses `warning200`. The lighter warning orange, `warning100`, is 2.52:1 against warning text and misses WCAG AA. Striped `DataTable` rows use `neutral900` in dark mode instead of `surface.neutralLight`, which is too light for ghost and link text. Highlighted `DataTable` cells switch to `text.inverted` when `text.primary` is below 4.5:1 on that fill. Inverted `MarkdownView` code uses `surface.neutralDark` when `surface.neutralLight` is below 4.5:1 against that text, and blockquotes do the same with `surface.baseAlternate`. Primary `Button` labels use `text.secondaryDark` when that pair meets 4.5:1 on `surface.primary`, and `text.inverted` otherwise, so `setTheme(darkThemeConfig)` stays readable without changing `colorScheme`. The demo palette generator emits status `050` steps so custom palettes update those dark error, success, and warning surfaces.
101
101
 
102
102
  `themeColorSchemeOptions` labels those values Light, Dark, and Follow system.
103
103
  `useTheme()` also returns the resolved `colorScheme` (`"light"` or `"dark"`)
@@ -280,6 +280,26 @@ and do not expose dismiss or read-state actions.
280
280
  | `preferences` | `{inapp, mail, push, sms}` | Current toggles |
281
281
  | `onChange` | `(channel, value) => void` | Per-channel updates |
282
282
 
283
+ ## WhatsNewBanner
284
+
285
+ Non-blocking release notes banner. It shows `What's new in <version>` (plus a count of earlier
286
+ unseen releases) until the viewer opens or dismisses it, and stays hidden until a newer release
287
+ is listed. **See what's new** opens a `Modal` with each unseen release's Markdown notes.
288
+ Steps: [Show release notes with WhatsNewBanner](../how-to/whats-new-banner.md).
289
+
290
+ | Prop | Type | Description |
291
+ |---|---|---|
292
+ | `releases` | `WhatsNewRelease[]` | `{version, body, date?}` entries, newest first. `body` is Markdown |
293
+ | `storageKey` | `string` | Device storage key for the last seen version. Default `@TerrenoUI:whatsNew:lastSeenVersion` |
294
+ | `title` | `string` | Modal title. Default `What's new` |
295
+ | `onOpen` | `() => void` | Called when the viewer opens the notes |
296
+ | `testID` | `string` | Default `whats-new-banner`. Children: `-text`, `-open-button`, `-dismiss`, `-modal`, `-release-<version>` |
297
+
298
+ `getUnseenWhatsNewReleases({releases, lastSeenVersion})` returns the releases newer than
299
+ `lastSeenVersion`; an unknown or missing version counts as nothing seen.
300
+
301
+ `BannerButton` also accepts an optional `testID`.
302
+
283
303
  ## Component Behaviors
284
304
 
285
305
  ### Button Layout Behavior
@@ -428,6 +448,8 @@ Pass `mascot` when the app owns a character. Terreno ships none. The node render
428
448
 
429
449
  The composer row (attachment picker, tools, input, Send) is vertically centered, so controls stay aligned with the input as it grows. The composer input grows with its text up to 200px, then scrolls. The attachment cell is omitted when `onAttachFiles` is not provided.
430
450
 
451
+ The history sidebar (model selector, toolbar, New chat, and the history list) is a 250px-minimum column. Pass `showHistory={false}` to leave it out so the chat panel takes the full width, as on a phone. The host then owns history navigation and the sidebar's controls. `showHistory` defaults to `true`.
452
+
431
453
  Each history row keeps rename and delete in a three-dot overflow menu (`gpt-history-menu-{id}`), anchored with `DropdownPanel`. Rename uses an outlined pencil in the dark secondary text color. Titles truncate before the menu trigger, so the trigger stays aligned at any sidebar width.
432
454
 
433
455
  "Scroll to bottom" appears only when content sits more than 100px below the viewport. It never shows in an empty chat.