@terreno/announcements 57.18.0 → 57.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/CHANGELOG.md +2074 -0
  2. package/package.json +3 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,2074 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ All `@terreno/*` packages (`api`, `test`, `ui`, `rtk`, `admin-backend`,
6
+ `admin-frontend`, `admin-spa`, `ai`, `announcements`, `api-health`, `comms`,
7
+ `feature-flags`, `jobs`, `mcp`, `syncdb`) and the unscoped `create-terreno-app` CLI are versioned in lockstep
8
+ and published at the same version.
9
+
10
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
11
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
+
13
+ Unreleased changes live in [`changelog/unreleased/`](changelog/unreleased/) as one
14
+ file per feature. `bun run changelog:assemble <version>` folds those files into a
15
+ dated section below when cutting a release.
16
+
17
+ Upgrade notes for consumer action live in [`mcp-server/src/docs/upgrades/`](mcp-server/src/docs/upgrades/). Fetch a range with the MCP tool `terreno_get_upgrade_guide`. Format: [`mcp-server/src/docs/upgrades/README.md`](mcp-server/src/docs/upgrades/README.md). The [`58.0.0` stub](mcp-server/src/docs/upgrades/58.0.0.md) is for the next major (not yet a changelog section).
18
+
19
+ ## [Unreleased]
20
+
21
+ Unreleased changes live in [`changelog/unreleased/`](changelog/unreleased/). Add one Markdown file per feature (see that directory's README) instead of editing this section.
22
+
23
+ ## [57.20.0] - 2026-10-30
24
+
25
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.20.0.md`](mcp-server/src/docs/upgrades/57.20.0.md).
26
+
27
+ ### Breaking
28
+
29
+ - `@terreno/api`: the app `User` claim guard moves before the Better Auth user is created, and
30
+ legacy users can prove ownership with their password. 57.17.0 refused to link an unverified
31
+ Better Auth user to an existing `User` by email, but only after the Better Auth user existed, so
32
+ a later email verification could still hand that user the `User`. Now a Better Auth user claims
33
+ an unlinked `User` only when the email is verified (by the provider), or when the sign-up (or
34
+ email sign-in) password verifies against that `User`'s passport-local-mongoose hash. A refused
35
+ sign-up answers 409 with `code: "VERIFY_EMAIL_TO_USE_ACCOUNT"` ("Verify your email to use this
36
+ account"; OAuth callbacks redirect with that `error`) and creates no Better Auth user, account,
37
+ or verification email. A `User` bound to another Better Auth account is refused before
38
+ creation too, with `code: "ACCOUNT_ALREADY_EXISTS"`. `syncBetterAuthUser` throws `APIError`
39
+ 409 with the matching title, and never inserts a duplicate `User`. Emails match ignoring case.
40
+ A refusal reveals that an app `User` has that email (an accepted account-existence signal).
41
+ 57.17.0's rules still hold: a verified-email link removes a legacy password the `User` never
42
+ proved and bumps `tokenEpoch`, and `trustEmail` seeds skip the checks.
43
+ - `BetterAuthApp` registered without a `userModel` now adopts `TerrenoApp`'s before it creates
44
+ Better Auth (new `adoptUserModel`, called by `TerrenoApp.build()`), so the guard runs whenever
45
+ TerrenoApp's session middleware links app `User`s. The legacy hash check does not touch
46
+ passport's `limitAttempts` counters.
47
+ - With a user model, the `terreno-app-user-sync` plugin syncs the app `User` as each Better Auth
48
+ session is created, not only on the first authenticated request, and the OAuth provider id
49
+ fills `oauthProvider`. A password-proven link that fails to save fails the sign-in with 500
50
+ `APP_USER_LINK_FAILED`. `syncBetterAuthUser`'s options gain `password`;
51
+ `seedBetterAuthUser` passes the seed password beside `trustEmail`.
52
+ - `accountLinking: {mode: "never"}` (or `options.account.accountLinking.enabled: false`) now
53
+ refuses `POST {basePath}/link-social` with 403 `LINKING_DISABLED` instead of failing at the
54
+ provider callback.
55
+ - Migration for JWT apps: users who sign up with their existing password, or sign in with a
56
+ provider that verified their email, link at once. Users with no password hash (GitHub JWT
57
+ sign-in) reset their password through JWT `POST /auth/forgotPassword` first, or sign in with a
58
+ verified provider, so keep the JWT routes mounted during the move. See
59
+ `docs/explanation/account-linking.md`.
60
+
61
+ ### Added
62
+
63
+ - `AccountSecurityApp` adds access recovery routes under `/users/:id/security`: `revoke-sessions` deletes every Better Auth session (bearer clients get 401 on their next request; cookie sessions can survive up to Better Auth's 5-minute cookie cache) and bumps `tokenEpoch` when the User model has it; `send-password-reset` mails a reset link (501 without `sendMail` or `publicAppUrl`, 409 for a user with no Better Auth account); `disable` sets `User.disabled` and revokes sessions, and `enable` clears it (both need `isDisabledPlugin`). Each change is audited.
64
+ - `POST /users/:id/security/issue-sign-in-link` creates a Better Auth account for a legacy User with no Better Auth link and no password, links it by `betterAuthId`, and returns a single-use set-password link `{url, expiresAt}` for the administrator to hand over. The email stays unverified. A link that was never used can be issued again (the earlier one stops working). It needs the new RBAC statement `user:issueSignInLink`, which only `superadmin` holds by default (`admin: true` without RBAC).
65
+ - Security route actions can be marked `platformOnly` (`disable`, `enable`, and `issue-sign-in-link` are) and can name a `requiredPermission`. `mountSecurityRoutes(router, authorize, {includePlatformOnly})` leaves platform-only actions out unless asked, so org-admin prefixes never expose them.
66
+ - The security summary reports `emailVerified` and `legacyPassword`, and `AccountSecurityApp` takes a `publicAppUrl` option (defaulting to the registered `BetterAuthApp`'s).
67
+ - The example backend's User applies `isDisabledPlugin`, so its admin and security screens can disable accounts.
68
+ - `AccountSecurityApp` (`@terreno/api`) adds platform account-recovery routes: `GET /users/:id/security` summarizes a user's linked providers, passkeys, 2FA state and remaining backup codes, active sessions, last sign-in, and available actions without exposing any secret, and `POST /users/:id/security/reset-two-factor` turns a locked-out user's 2FA off and forgets their trusted devices. Access needs the new RBAC statement `user:manageSecurity` (held by `superadmin` and `operator`; an existing `operator` role gains it once on the next seed, and a later revocation sticks through the new `RbacRole.appliedGrants`) and the user's own session (JWT or Better Auth session; MCP tokens and any other credential get 403). Acting on yourself, or on someone holding a permission you lack (such as a superadmin), is refused with 403. The plugin refuses to start without `AuditApp` unless you pass `audit: false`. See `docs/reference/account-security.md`.
69
+ - `AuditEvent.source` accepts `security`. `recordSecurityAction` writes one security event per recovery action that changes something, and `onSecurityAction` lets in-process listeners react to it.
70
+ - `mountSecurityRoutes(router, authorize)` mounts the same handlers under another prefix with its own authorizer, and `TerrenoApp.getAccessControl()` returns the app's RBAC access control.
71
+ - `DELETE /users/:id/security/passkeys/:passkeyId` removes one of a user's passkeys (another user's passkey id is a 404), and `DELETE /users/:id/security/accounts/:providerId` unlinks a sign-in provider. The password can't be unlinked. Either action that would leave no sign-in method is refused with 409 unless the user's email is verified, in which case a password-reset link is mailed first (and also afterwards, if a concurrent removal took the last other method). Unlinking doesn't end sessions started with that provider. Both actions are audited and appear in the summary's `actions` when they apply. A custom action that reuses an action id now throws at construction.
72
+ - The example backend registers `AccountSecurityApp`.
73
+ - `MemberSecurityPanel` in `@terreno/admin-frontend` shows a user's sign-in security (providers, password, passkeys, two-factor, sessions, last sign-in, disabled and email-verified state) and runs the recovery actions the server lists. Actions render from the summary's `actions`, so new server actions get a button without a frontend change; destructive ones confirm first, results and server error titles show inline, and a returned sign-in link is shown once with a copy button. It calls `/orgs/:orgId/members/:userId/security` or, without `orgId`, `/users/:id/security` through the host-bound `adminRequest`.
74
+ - `OrgMembersScreen` rows get a **Security** button that opens the panel for active members, and a **Reactivate** button for suspended memberships.
75
+ - `useMemberSecurityApi`, `memberSecurityPath`, and `resolveSecurityActionRequest` are exported for custom security screens.
76
+ - Each entry of the account security summary's `actions` now carries its route: `method` (`POST` or `DELETE`) and `path` relative to the user's security base, with `:param` placeholders such as `/passkeys/:passkeyId`. Clients can run any listed action, including custom ones, from the summary alone.
77
+ - `UserSecurityScreen` in `@terreno/admin-frontend` is the platform staff page for one user's sign-in security: a header with the user's name and email from their admin record, then `MemberSecurityPanel` against `/users/:id/security`, so operators and superadmins can sign a user out everywhere, send a password reset, disable or enable the account, and run every other recovery action the server lists.
78
+ - `UserSecurityLink` and `userSecurityRoute` add a **Security** button to an admin user record (for example in `AdminModelForm` `footerContent`) that opens `{routeBase}/users/:id/security`.
79
+ - The example app mounts the screen at `/admin/users/[id]/security` and links it from the admin `User` record, both only for staff holding `user:manageSecurity`; the link is hidden on the viewer's own record.
80
+ - `@terreno/api`: `BetterAuthConfig` turns auth features on without hand-built plugins:
81
+ `accountLinking: {mode: "verified-email" | "explicit" | "never", trustedProviders?}`,
82
+ `twoFactor: {issuer, backupCodes?, trustDeviceDays?}`, `passkey: {rpID, rpName, origin}`,
83
+ `signUp: {mode: "open" | "closed"}`, `lastLoginMethod`, `multiSession: {maximumSessions?}`,
84
+ `oauthProxy: {productionURL}`, and `nativeApps` (the `@better-auth/expo` server plugin).
85
+ Every feature is off until set; with none set the plugin list stays
86
+ `bearer`, `terreno-methods`, then `config.plugins`. Feature plugins go before
87
+ `config.plugins`, and `options` still overrides what they set.
88
+ - `signUp: {mode: "closed"}` stops new accounts through email and password and OAuth: email
89
+ sign-up answers 400, and a social or generic OAuth sign-in for an unknown email fails with
90
+ `signup_disabled`, even with `requestSignUp: true`. Other sign-in plugins added through
91
+ `plugins` (magic link, email OTP, phone number, anonymous) need their own `disableSignUp`.
92
+ - `passkey` config is checked at startup: each `origin` must be a bare http(s) origin on
93
+ `rpID` or a subdomain of it.
94
+ - `appleOAuth` also accepts `{clientId, teamId, keyId, privateKey, appBundleIdentifier?}` and
95
+ signs Apple's ES256 client secret at startup (valid 180 days).
96
+ - `GET {basePath}/terreno/methods` now reports real `twoFactor`, `passkey`, and
97
+ `multiSession` flags.
98
+ - With `rateLimit` on, `{basePath}/two-factor/*`, `/passkey/*`, `/link-social`,
99
+ `/unlink-account`, `/multi-session/*`, `/oauth-proxy-callback`, and
100
+ `/expo-authorization-proxy` use the `auth` bucket.
101
+ - New dependencies: `@better-auth/passkey` (catalog, api and rtk), `@better-auth/expo` (api),
102
+ `react-native-qrcode-svg` (ui), and `otpauth` (example-frontend dev).
103
+ - `@terreno/api`: every Better Auth server now answers `GET {basePath}/terreno/methods`
104
+ (public, no session) with `{emailPassword, signUp, providers, passkey, twoFactor,
105
+ accountLinking, multiSession}`. `providers` lists the configured Google, GitHub, and Apple
106
+ providers and any `genericOAuth` provider ids. `createBetterAuth` adds the `terreno-methods`
107
+ plugin after `bearer()`, before `config.plugins`.
108
+ - `@terreno/rtk`: `useAuthMethods(authClient)` returns `{methods, isLoading, error}` from that
109
+ endpoint through the client's `$fetch`, so bearer and cookie modes both work.
110
+ - example-frontend: the login screen shows only the social providers the server reports, and
111
+ shows the mock OIDC button when the server lists `mock-oidc`.
112
+ `EXPO_PUBLIC_MOCK_OIDC_SIGN_IN` is no longer read.
113
+ - `@terreno/api` serves `POST {basePath}/terreno/set-password` (`{newPassword}`) so a signed-in user without a password, such as one who signed up with a provider, can add one. It needs a fresh session (403 `SESSION_NOT_FRESH` otherwise) read from the database, refuses with 409 `PASSWORD_ALREADY_SET` when a password exists, follows Better Auth's length rules, and sits in the `auth` rate-limit bucket. With a `userModel`, an app User linked by `betterAuthId` that already has a JWT password gets the new one too, and its `tokenEpoch` is bumped; a social-only account does not gain a JWT password. Two concurrent sets leave one password.
114
+ - A successful set records the new `password-set` security event, which emails the user "A password was added to your account" (template `securityPasswordSet`) when security notifications are on.
115
+ - Two-factor, trusted-device, and passkey sign-in now work for bearer-mode web clients that send no cookies (`createBetterAuthClient({webAuth: "bearer"})`). With `twoFactor` or `passkey` configured, `@terreno/api` copies Better Auth's signed challenge cookies into the `X-Terreno-Auth-Challenge` header for clients that send it and turns the header back into cookies on `/two-factor/*`, `/passkey/*`, and `/sign-in/*`. rtk's bearer mode stores the header beside the bearer token (`${storagePrefix}_auth_challenge`), sends it on those calls, never stores the dead token of a sign-in waiting on a second factor, drops the 2FA challenge after a successful verify and pending challenges on sign-out, and keeps the trusted-device value until it expires. Cookie-mode and native clients are unchanged.
116
+ - create-terreno-app: generated apps get the auth suite. `backend/src/utils/betterAuthConfig.ts` turns on `accountLinking` (`AUTH_ACCOUNT_LINKING`, default `verified-email`), `twoFactor` (issuer = the app's display name, 30 trusted days), `passkey` (origin and rpID from `FRONTEND_URL`, or `PASSKEY_RP_ID`; off in production while `FRONTEND_URL` is a loopback URL), `lastLoginMethod`, `multiSession`, `nativeApps`, and `signUp` (`AUTH_SIGN_UP`, default `open`); `getWebOrigins` now includes the `FRONTEND_URL` origin. `server.ts` registers `AuditApp` and `AccountSecurityApp`; the generated User schema now applies `rbacUserPlugin` (`roles`, default `member`), so the seeded admin's `operator` role is saved and reaches the `/users/:id/security` recovery routes; and the seed keeps sign-up open on its own instance. On the frontend, `lib/betterAuth.ts` passes the matching client `features` and exports a `rememberedAccounts` store that keeps only the sign-in method (`rememberIdentity: false`). `app/login.tsx` renders `TerrenoLoginScreen`, and a new `app/settings/security.tsx` renders `TerrenoAccountSecurityScreen`, opened from the Profile tab's **Security** button. The Profile tab's "New Password" field is gone: it sent `password` to `PATCH /auth/me`, which refuses password fields since 57.17.0. Change passwords on the Security screen. The "Current password" field that an email change needs stays. Generated apps set no `sendMail`, so forgot-password, the `send-password-reset` and `issue-sign-in-link` recovery actions (501), and security notification emails do nothing until the app passes `@terreno/comms` `sendMail` and `renderAuthMail` to the Better Auth config. To move an existing generated app over, copy those files (and `models/user.ts`, `types/models/userTypes.ts`) from a freshly generated app and add `FRONTEND_URL` to the backend env.
117
+ - example-backend turns on every Better Auth feature: account linking (`AUTH_ACCOUNT_LINKING`, default `verified-email`), sign-up mode (`AUTH_SIGN_UP`, default `open`), two-factor (issuer "Terreno Example"), passkeys (origin from `FRONTEND_URL`, rpID from `PASSKEY_RP_ID` or the `FRONTEND_URL` host; off in production while `FRONTEND_URL` is a loopback URL), last-used method, multi-session, and native sign-in. `GET /api/auth/terreno/methods` reports them. Seeded users (`seedBetterAuthUserInProcess` and the e2e setup) get a verified Better Auth email, and seeding still works with `AUTH_SIGN_UP=closed`. See [Auth features in example-backend](docs/how-to/configure-better-auth.md#auth-features-in-example-backend).
118
+ - The e2e mock OIDC provider can sign in any email, verified or not, per browser context with `setMockOidcIdentity(context, {email, emailVerified})` (or a `login_hint`). `playwright.config.ts` lists the cross-site bearer specs in `CROSS_SITE_SPEC_FILES`, and the new `e2e/authMethods.spec.ts` checks the discovery endpoint there.
119
+ - `ListDetailLayout`, `ListDetailIndex`, and `useListDetail` in `@terreno/ui`: a route-driven list/detail layout for an Expo Router group's `_layout.tsx`, like a mail inbox. Phones push the detail route; when the layout is at least 768px wide (tablets, wide browsers, inside a sidebar), the list stays in a left pane and the detail opens beside it. The open item lives in the URL, so deep links and back work, and it stays open across rotation and resize. See `docs/how-to/build-list-detail-screens.md`. `SplitPage` is unchanged; its docs now point new screens to `ListDetailLayout`.
120
+ - `Box` `accessibilityState.selected`. A clickable row sends it as `aria-current="true"` on web; a selectable `accessibilityRole` (`tab`, `option`, `row`, ...) sends `aria-selected`.
121
+ - The demo inbox exports each fixture message (`generateStaticParams`), and the Netlify demo deploy rewrites any other path to `index.html`, so `/demo/list-detail/2` and `/demo/sidebar-navigation/mail/2` load on refresh.
122
+ - The example todo browser shows a spinner while a deep-linked todo is still syncing, and “This todo does not exist” only after sync is idle and the row is absent.
123
+ - A clickable `Box` with `selected` sends only `aria-current` on web. `selected` is left off the host `accessibilityState`, so react-native-web does not also set `aria-selected` on the button.
124
+ - The hosted Terreno MCP server rate limits JSON-RPC calls per client IP (default 120 requests per 60 seconds). Excess calls get HTTP 429 with a JSON-RPC error naming the limit and retry window. Configure with `TERRENO_MCP_RATE_LIMIT_MAX` (`0` disables), `TERRENO_MCP_RATE_LIMIT_WINDOW_MS`, and `TERRENO_MCP_TRUST_PROXY_HOPS`.
125
+ - `@terreno/api` MCP OAuth accepts `grant_type=client_credentials` for a confidential client created with `McpOAuthClient.issueConfidential`. The helper binds the client to a user, stores the secret as a SHA-256 hash, and returns the secret once. The token endpoint accepts `client_secret_basic` or `client_secret_post`, whichever method the client was created with, and returns an `mcpat_` access token with no refresh token. The token acts as that user (`credential: "oauth"`). Revoking the client ends every token it holds, and a revoked client is rejected at `/oauth/authorize` and `/oauth/token`. An authorization-code exchange, a refresh, or a client_credentials grant that loses a race with that revoke is discarded. `deleteOne`, `findOneAndDelete`, and `deleteMany` do the same. A query `deleteOne` or `findOneAndDelete` whose filter matches more than one client pins the delete to one document and revokes that client's tokens. Dynamic client registration stays public-only. Discovery advertises `client_credentials`, `client_secret_basic`, and `client_secret_post`.
126
+ - `@terreno/api`: with `oauthHandoff`, a bearer-mode web client on another site can link a
127
+ social or generic OAuth provider to the signed-in user. `POST {basePath}/oauth-handoff/link-intent`
128
+ (bearer- or cookie-authenticated, `{provider, callbackURL, errorCallbackURL?}`) returns
129
+ `{token, startURL}`: a single-use, three-minute link token, stored hashed and bound to the
130
+ caller's session. Both URLs must be absolute pages on one of `oauthHandoff.webOrigins` that
131
+ `trustedOrigins` allows (400 otherwise); `callbackPaths` do not apply, since no token travels
132
+ on a link's return. The intent answers 403 `LINKING_DISABLED` when account linking is off. The web app posts the token
133
+ from a hidden form to `POST {basePath}/oauth-handoff/link-start`, which refuses a missing, `null`, or
134
+ foreign `Origin` with 403 (so web origins must not serve `Referrer-Policy: no-referrer` or
135
+ `same-origin`), then uses the token up (400 with no
136
+ redirect when it is unknown, used, or expired), sets only the OAuth state cookie, first-party on the
137
+ API's origin, and redirects to the provider. The provider callback links the account and
138
+ redirects to `callbackURL` with no one-time token, since no session is created. A session
139
+ revoked after the intent comes back with `error=session_expired`. Both routes are in the auth
140
+ rate-limit bucket, and Better Auth's limiter allows three of each per ten seconds per IP.
141
+ - `@terreno/rtk`: new `linkSocialWithHandoff({client, provider, callbackURL, errorCallbackURL?})`.
142
+ On web in bearer mode it posts the intent and submits the hidden form; in cookie mode and on
143
+ native it calls `client.linkSocial`. It resolves `{error}` (`null` once the browser is on its
144
+ way to the provider). New type `BetterAuthLinkClient`.
145
+ - Org-admins can run account recovery on members of their organization. When the app registers `AccountSecurityApp`, `OrgsApp` mounts the security routes at `/orgs/:orgId/members/:userId/security/*` (`TerrenoApp` passes the registered plugin; pass `accountSecurity` to a hand-registered `OrgsApp`). An org-admin may act on any active member of the org, even one in other orgs. Suspended members and non-members answer `404`; platform staff (`superadmin`, `operator`) and `admin: true` targets answer `403`; disabled orgs answer `403`. Platform staff holding `user:manageSecurity` pass the org checks. Every event records the org.
146
+ - New org-only action `suspend-membership` replaces `disable` under the org prefix: it suspends the membership in that org only, so the user still signs in and keeps their other orgs. `disable`, `enable`, and `issue-sign-in-link` stay platform-only. Security route actions can be marked `orgOnly`, mounted only with `mountSecurityRoutes(..., {includeOrgOnly: true})`. `mountSecurityRoutes` also takes `pathPrefix` (such as `/:orgId/members`) so a prefix's params appear in OpenAPI.
147
+ - `OrgsApp` takes `orgRecovery: {minMembershipDays}` (default `0`) to refuse org-admin recovery on members who joined too recently.
148
+ - The recovery routes' "target holds permissions you lack" check resolves each user in their own context: outside any organization on the platform routes, and under each user's own membership in `:orgId` on the org routes (authorizers return it as `orgScope`). A target is never judged by the acting org-admin's membership grants.
149
+ - `OrgsApp` throws `OrgsApp accountSecurity is not registered` when handed an `AccountSecurityApp` that is not registered (`AccountSecurityApp.isRegistered()`), so the org routes never run without the audit check, access control, or user model.
150
+ - `suspend-membership` keeps the last-org-admin rule under concurrency (suspend, recount, undo), so two org-admins suspending each other never leave the org without one.
151
+ - `oauthProxy` on the production server itself (`productionURL` on the `baseURL` origin) now sets oauth-proxy's `currentURL`, so that server's own sign-ins, including server-side starts such as `GET {basePath}/oauth-handoff/start` and requests on another host name, skip the proxy and keep Better Auth's state-cookie check. Its callback still forwards previews' returns.
152
+ - The OAuth one-time-token handoff also runs when oauth-proxy finishes a preview's sign-in (`GET {basePath}/callback/:id/oauth-proxy`, and the deprecated `GET {basePath}/oauth-proxy-callback`), so a bearer-mode web app on a preview gets `#ott=<token>` after the stable deploy forwards the provider's return.
153
+ - example-backend turns on `oauthProxy` from `OAUTH_PROXY_PRODUCTION_URL`, reads the Sign in with Apple key form (`APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY`; `APPLE_CLIENT_SECRET` still works), and trusts `https://appleid.apple.com` when Apple is configured.
154
+ - The example deploy sends every preview's social sign-in through the stable backend's registered callbacks. `STABLE_BACKEND_URL` and `STABLE_FRONTEND_URL` (defaulting to today's Cloud Run and Netlify hosts) set the stable `BETTER_AUTH_URL`, `FRONTEND_URL`, `OAUTH_PROXY_PRODUCTION_URL`, and, when both are subdomains of one domain, `PASSKEY_RP_ID`. Stable mounts the `GOOGLE_*`, `GITHUB_*` and `APPLE_*` secrets that have a version; previews mount only the `*_CLIENT_ID` ones and configure providers with the placeholder secret `OAUTH_PROXY_PREVIEW_CLIENT_SECRET`, since stable exchanges every code. `STABLE_PASSKEY_RP_ID` overrides the derived rpID, terraform creates the secret containers, and the stable web build defaults to the same untagged Cloud Run URL as the deploy. New guide: [Set up social login](docs/how-to/set-up-social-login.md).
155
+ - `OAUTH_CALLBACK_TAG=pr-<n>` makes that example preview own the registered OAuth callbacks: it mounts every provider secret and exchanges codes itself, other previews proxy through it, and stable gets only client IDs. It lets the providers be tried before stable runs a release with this setup. See [Register on a preview first](docs/how-to/set-up-social-login.md#register-on-a-preview-first).
156
+ - New `set-up-social-login` skill (planning plugin): the order of work for Google, GitHub, and Sign in with Apple in a Terreno app, from server config and callback URLs to secrets, preview-first registration, and the `/terreno/methods` check. [Set up social login](docs/how-to/set-up-social-login.md) now covers the whole setup end to end: setting the gcloud project, choosing same-site hosts, the server config, a callback URL table for local dev, previews, and stable, and how to check the result.
157
+ - **Provisional consent signing.** Staff can record consent on a user's behalf with
158
+ `POST /consent-responses/provisional` and a required note. Pass `accessControl` to
159
+ `ConsentApp` and grant the new `consentResponse:signOnBehalf` RBAC action (the built-in
160
+ `admin` role gets it on the next role seed); without RBAC only `user.admin` may sign. A
161
+ provisional signature counts as consent but leaves the form in the user's consent gate,
162
+ which now shows "Signed on your behalf by <name> on <date>". The user's own answer always
163
+ wins.
164
+ - `getConsentStatuses({userId})` (`@terreno/api`) and `GET /consent-responses/status` return
165
+ each form's `pending`, `provisional`, `signed`, or `declined` status. `POST
166
+ /consent-responses/:id/void` voids a provisional signature and keeps the record. With
167
+ `AuditApp`, signing, voiding, and status reads write audit events; audit events gain a
168
+ `read` operation and a `viewed` verb.
169
+ - `ProvisionalConsentSigner` and `useProvisionalConsent` (`@terreno/ui`) list a user's forms,
170
+ sign with a note, print, and void. `printConsentHtml` prints consent HTML on native and web,
171
+ and `buildConsentPdfHtml` renders a "Provisional signature" section.
172
+ - `ConsentResponseViewer` and `ConsentHistory` (`@terreno/admin-frontend`) show provisional
173
+ signatures and gain Print buttons, and their PDFs include the provisional signature.
174
+ - `@terreno/rtk`: `createRememberedAccountsStore({rememberIdentity})` remembers who signed in on
175
+ this device and how, for the login screen's "last used" badge and "Continue as" cards. It stores
176
+ `{userId, method, lastUsedAt}` in SecureStore on native and localStorage on web, keeps the newest
177
+ 5, re-reads storage before each change (and follows `storage` events on web) so a stale tab
178
+ cannot bring back a forgotten account, and resets on unreadable data. `rememberIdentity` is
179
+ required: pass `false` to keep only the method. With `true`, the name, a masked email
180
+ (`j***@n***.io`) and an `https://` avatar are stored too, and switching back to `false` scrubs
181
+ them on the next load. `useRememberedAccounts(store)` returns `{accounts, lastUsedMethod,
182
+ isHydrated, forget, clear}`, and `toLoginAccountOptions()` maps accounts to `@terreno/ui`
183
+ `LoginScreen`'s `rememberedAccounts`.
184
+ - `@terreno/rtk`: `createBetterAuthClient({features})` adds the Better Auth client plugins for the
185
+ server's auth features: `twoFactor` (`twoFactorClient`, with `onTwoFactorRedirect`), `passkey`
186
+ (`passkeyClient` from `@better-auth/passkey/client`), `multiSession`, `lastLoginMethod` and
187
+ `oneTimeToken`. Each enabled feature's namespace is typed without casts (`client.twoFactor.enable`,
188
+ `client.passkey.addPasskey`, `client.multiSession.listDeviceSessions`, ...), and the plugins run
189
+ after the Expo plugin on native. Without `features` (or with a config whose type does not name
190
+ them) the client and its type are unchanged. In web bearer mode `multiSession` lists no
191
+ sessions, and 2FA and passkey challenges still need cookies. New types:
192
+ `BetterAuthClientFeatures`, `BetterAuthClientWithFeatures`, `BetterAuthClientFor`,
193
+ `BetterAuthHandoffClient`.
194
+ `BetterAuthOAuthProvider` now accepts any generic OAuth provider id, the OAuth handoff helpers
195
+ accept clients with features, and `BetterAuthClientInterface` accepts the stricter session types
196
+ those clients return, so they work with `generateBetterAuthSlice`, which now stores a missing
197
+ user `image` as `null`.
198
+ - `AccountSecurityApp` emails the affected user about every security action, admin and
199
+ self-service.
200
+ - Each email says who acted ("you" or "an administrator", never the admin's name), when, and how
201
+ to get help.
202
+ - It names the org only with `notifications.includeOrgName: true` (default `false`), and never
203
+ includes tokens, links, passkey names, or other secrets.
204
+ - Mail goes out only when `sendMail` is configured. `notifications: {enabled: false}` turns it
205
+ off, and `notifications.sendMail` supplies a mailer outside `TerrenoApp`.
206
+ - `notifications.throttleMinutes` (default 10) sends at most one email per user, template,
207
+ origin (self or admin), and org per window, so an admin action is never hidden behind the
208
+ user's own change.
209
+ - A failed delivery is logged and never fails the action.
210
+ - `send-password-reset` sends no extra email, because its reset mail is the notice.
211
+ `issue-sign-in-link` tells the user a link was created, without the link.
212
+ - The newest registered `AccountSecurityApp` owns the emails, so two apps in one process never
213
+ email twice. `unsubscribeNotifications()` stops them.
214
+ - `createBetterAuth` records the user's own security changes as security actions with
215
+ `origin: "self"`, written as `AuditEvent`s when `AuditApp` is registered.
216
+ - Recorded: a provider linked or unlinked, a passkey added or removed, two-factor turned on or
217
+ off, a password changed or reset with a link, and other sessions signed out.
218
+ - Two-factor changes are read from the database, not the cookie-cached session.
219
+ - The new action ids are `password-reset` and `other-sessions-revoked`.
220
+ - The emails render with a built-in renderer. `BetterAuthConfig.renderAuthMail` is not used for
221
+ them; opt in to a custom renderer with `notifications.render`. `BetterAuthConfig.authMailTemplates`
222
+ accepts the new `security*` ids as overrides.
223
+ - `@terreno/comms` `renderAuthMail` renders the `security*` template ids (subject, text, and HTML),
224
+ so it can be passed as `notifications.render`. It takes optional `data`, `token` is now optional,
225
+ and HTML bodies get HTML-escaped values.
226
+ - `@terreno/rtk` adds `TerrenoAccountSecurityScreen`, a drop-in account security screen, and `useAccountSecurity`, the hook behind it. It renders `@terreno/ui`'s `AccountSecurityScreen` with the cards the server's `/terreno/methods` turns on: linked providers (linking through the OAuth handoff in bearer mode), the password (change, or set a first one), passkeys (web), two-factor (enrolment returns the backup codes to the card), and signed-in sessions ("Sign out everywhere else" calls `revokeOtherSessions`). Every Better Auth `{error}` becomes the calling card's error, each change reloads only the affected section, the screen never asks the server to remove a user's only sign-in method, `SESSION_NOT_FRESH` reads "Sign in again to manage this.", and a credential row without a password switches the password card to set mode.
227
+ - `useAuthMethods` returns `refetch()` for a Try again button and takes `{skip: true}` for callers that already have the methods.
228
+ - A link's `?error=`, an error code, or a provider id that matches an `Object.prototype` key (`__proto__`, `constructor`, `toString`) reads as the generic link error or goes unlabelled, instead of crashing the screen with a non-string value.
229
+ - `@terreno/rtk` adds `TerrenoLoginScreen`, a drop-in login screen, and `useLoginController`, the hook behind it. They read the server's `/terreno/methods` and skip the screen when a session is live. They offer "Continue as" cards for this device's signed-in sessions (`multiSession`, not in bearer mode) and its remembered accounts, a session card hiding the same user's remembered card. They badge the last used method from the remembered-accounts store, offer passkeys (button and web autofill), and sign in socially through the OAuth handoff in bearer mode or `signIn.social` otherwise. The two-factor step runs inline unless `onTwoFactorRequired` routes to the app's own screen. Each finished sign-in is recorded in the remembered-accounts store.
230
+ - `getLoginFeedback` turns server refusals into login guidance: `account_not_linked` worded for the linking mode, `VERIFY_EMAIL_TO_USE_ACCOUNT` as "verify your email", and `signup_disabled` (and the ID-token "signup disabled") as "ask your administrator for access", with the sign-up link hidden. Text from a URL is never shown.
231
+ - `getBetterAuthClientWebAuth(client)` reports whether a client is in web bearer mode and the token it holds. `recordRememberedSignIn` records a sign-in finished on an app's own screen.
232
+ - `@terreno/ui` `LoginScreen` takes `initialValues`, which prefill form fields; a new object later replaces those fields and keeps what the user typed in the others. With `fields={[]}` it leaves out the form block (fields, Log In, forgot-password, and the email dividers), and `TerrenoLoginScreen` does that when the server has email and password off. `OAuthButtons` takes `dividerPosition: "none"`.
233
+ - `TerrenoLoginScreen` takes `onStepChange`, called with each step the screen moves to, so an app that already holds a session can leave a login route the screen could not skip.
234
+ - A remembered method or a provider id that matches an `Object.prototype` key (`__proto__`, `constructor`) is named from the id ("Proto", "Constructor") instead of an inherited value.
235
+ - `@terreno/ui` adds `LinkedAccountsCard` and `PasswordCard`, the first account security cards.
236
+ `LinkedAccountsCard` shows one row per linked account (so two Google accounts are each
237
+ unlinkable) with its email and linked date, and a Link row for each offered provider without
238
+ one. Unlink asks for confirmation and calls `onUnlink(providerId, account)`; pass `account.id`
239
+ (Better Auth's account row id) to `unlinkAccount({accountId})`. The acting row shows a spinner
240
+ while `onLink` or `onUnlink` runs, and Unlink is disabled with an explanation when it would
241
+ remove the user's last sign-in method (the `credential` password account and
242
+ `otherMethodCount` count; `canUnlinkLast` opts out). `PasswordCard` shows "Change password"
243
+ (current, new, confirm, optional "Sign out of other devices" sent as `revokeOtherSessions`) or
244
+ "Set a password" for provider-only users, with inline length and confirmation errors, focus on
245
+ the first invalid field plus an alert summary, Enter to submit, and a promise-based `onSubmit`.
246
+ Also exported: the `LinkedAccount` (`linkedAt` accepts a `Date` or ISO string),
247
+ `LinkableProvider` and `PasswordCardSubmission` types, `CREDENTIAL_PROVIDER_ID`, and
248
+ `hasPasswordCredential(accounts)`.
249
+ A provider id that matches an `Object.prototype` key (`__proto__`, `constructor`) is labelled
250
+ with its id instead of an inherited value.
251
+ - `@terreno/ui` adds `AccountSecurityScreen`, a security settings screen composed from the account
252
+ security cards. Pass each card's props as one object (`linkedAccounts`, `password`, `passkeys`,
253
+ `twoFactor`, `sessions`); a card renders only when its object is given, so a feature the server
254
+ has off hides its card. Cards appear in a fixed order (sign-in methods, then passkeys, two-factor
255
+ and sessions) in one centered column up to `maxWidth` (720px by default), under a "Security"
256
+ heading with optional `description` and `header`. `isLoading` shows a spinner, `error` shows a
257
+ page-level alert with Try again (`onRetry`), and `children` add app-specific content after the
258
+ cards. The screen wraps itself in a scrolling `Page`; pass `withPage={false}` to place it inside
259
+ your own `Page` or `ScrollView`. Also exported: the `AccountSecurityScreenProps` type.
260
+
261
+ `Heading` gains an optional `level` (1–6): with it, the heading renders `role="heading"` and
262
+ `aria-level` (an `<h1>`–`<h6>` on web, the header accessibility role on native); without it,
263
+ output is unchanged. The account security cards' titles are now level 2 headings and the screen
264
+ title level 1. Below the desktop floor (`isSupportedDesktopWidth`), `Card` and
265
+ `AccountSecurityScreen` draw edge to edge.
266
+ - `@terreno/ui` adds `FormContainer`: a real `<form>` on web (`display: contents`, `noValidate`, page reload always prevented) and a `Box` on native. `ref.requestSubmit()` submits through the form element on web so password managers see a real submit and offer "Save password"; `username` adds a hidden `autocomplete="username"` input for change-password forms. Chrome no longer logs "Password field is not contained in a form" on `LoginScreen`, `SignUpScreen`, `PasswordCard`, the `TwoFactorSettingsCard` password step, `GPTChat`'s API key modal, the admin configuration screen, or the example app's reset-password, profile, and admin password forms.
267
+ - `TextField` sends `autoComplete` to native too: React Native's `autoComplete` (Android's autofill hint) and the matching iOS `textContentType` (`username`, `emailAddress`, `password`, `newPassword`, `oneTimeCode`, `none`), so iOS and Android offer saved passwords, strong new passwords, and one-time codes. It also accepts `"one-time-code"`.
268
+ - `LoginScreen` and `SignUpScreen` default the email field to `autoComplete="username"`, name each input by its field label, and handle Enter: it moves to the next field and submits from the last one once, only when the submit button is enabled. `PasswordCard` now moves from current to new to confirm on Enter and submits from confirm. `OtpField` gains `onEnter`, and `TwoFactorChallengeScreen` submits a complete code on Enter.
269
+ - `PasswordCard` and `TwoFactorSettingsCard` take a `username`, which `useAccountSecurity` fills with the session user's email.
270
+ - `LoginScreen` (`@terreno/ui`) gains optional props for returning users: `lastUsedMethod` badges the
271
+ method this device used last and moves a provider or passkey above the form; `currentSessions` and
272
+ `rememberedAccounts` render one-tap "Continue" cards (`onContinue`, `onForget`); `onPasskeyPress`
273
+ adds a "Sign in with a passkey" button and `passkeyAutofill` asks web browsers to offer saved
274
+ passkeys in the email field; `notice` shows guidance such as an `account_not_linked` hint; and
275
+ `genericProviders` renders providers without a branded button (for example a generic OIDC
276
+ provider). `OAuthButtons` accepts `genericProviders`, `lastUsed`, and `dividerPosition`. Without the
277
+ new props the screen renders exactly as before. A method or provider id that matches an
278
+ `Object.prototype` key (`__proto__`, `constructor`) is named by its id, and the new
279
+ `getSocialProviderName(providerId)` returns a branded provider's name or `undefined`.
280
+ - `@terreno/ui` adds `PasskeysCard` and `SessionsCard` to the account security cards.
281
+ `PasskeysCard` lists passkeys with their added and last-used dates and a "Synced" badge for
282
+ backed-up ones (an unnamed passkey shows `authenticatorName`, for example from
283
+ `getAuthenticatorName(aaguid)`, else "Unnamed passkey"), and adds (with an optional name, `onAdd(name?)`), renames (`onRename(id, name)`)
284
+ and removes (confirmed, `onRemove(passkey)`) them; `supported={false}` hides Add and explains that
285
+ the device can't create passkeys. `SessionsCard` lists sessions with the browser and OS parsed
286
+ from the user agent, location and IP when known, "Signed in" (`createdAt`) and "Last refreshed"
287
+ (`updatedAt`, which Better Auth moves only once per `session.updateAge`) as Luxon relative times
288
+ that recompute every minute (inject `now` for deterministic output). The current session, matched by `currentSessionId` or
289
+ `currentSessionToken`, comes first, is marked "This device", and can't be revoked on its own;
290
+ other sessions have Sign out (`onRevoke(session)`), and "Sign out everywhere else"
291
+ (`onRevokeOthers()`) asks for confirmation and is disabled when no other session exists. Better
292
+ Auth's `listUserPasskeys()` and `listSessions()` items fit the new `AccountPasskey` and
293
+ `AccountSession` types as is. Also exported: `parseUserAgent` and `describeUserAgent`, a small
294
+ dependency-free user agent parser that also recognizes React Native app clients. The account
295
+ security docs and examples now unwrap Better Auth's `{data, error}` results and throw, since the
296
+ client does not reject and the cards only show errors from rejected callbacks.
297
+ - `@terreno/ui` adds `OtpField` and `TwoFactorChallengeScreen`. `OtpField` is a one-time code input
298
+ with a cell per digit: paste or one-time-code autofill fills every cell, backspace steps back a
299
+ cell, each cell is labelled "Digit k of N", and `onComplete(code)` fires when the code is full.
300
+ `TwoFactorChallengeScreen` is the second sign-in step: an authenticator code that submits itself
301
+ once complete, a toggle to a backup code, an optional "Don't ask again on this device for N days"
302
+ checkbox (`trustDeviceDays`), an alert error that clears the code, and
303
+ `onSubmit({code, kind: "totp" | "backup", trustDevice})`. `TextField` accepts
304
+ `accessibilityLabel` to set its input's accessible name.
305
+ - `@terreno/ui` adds `TwoFactorSettingsCard` to the account security cards. Turning on two-factor
306
+ authentication walks through four steps: confirm the password (`onStartEnroll(password)` resolves
307
+ `{totpURI, backupCodes}`, narrowed from the TOTP branch of Better Auth's `twoFactor.enable()`
308
+ data, which is `{method: "otp"}` or `{method: "totp", totpURI, backupCodes}`), scan a QR code drawn with
309
+ `react-native-qrcode-svg` or copy the setup key under it, enter a code in `OtpField`
310
+ (`onVerify(code)`), then save the backup codes with Copy and Download (a `.txt` file on web) or
311
+ Share (native), with Finish gated on "I saved these backup codes". Once on, the card offers new
312
+ backup codes (`onRegenerateBackupCodes(password)`, shown with the same gate) and Turn off
313
+ (`onDisable(password)`, after a destructive confirm), and warns when `backupCodesRemaining` drops
314
+ below 3. Better Auth requires the password unless its two-factor plugin sets `allowPasswordless`,
315
+ so `hasPassword={false}` disables the card's actions and asks an OAuth-only user to set a
316
+ password first, and
317
+ `requirePassword={false}` skips the password steps on a passwordless server. Also exported:
318
+ `parseTotpSecret(totpURI)`, `parseTotpDigits(totpURI)` (the code length defaults to it) and the
319
+ `TwoFactorEnrollment` type. Step changes move focus to the step on web and are announced on native.
320
+
321
+ ### Changed
322
+
323
+ - `@terreno/admin-frontend` renders a nested (non-array) object field that has `items` in its admin config as a titled group with one control per subfield, instead of a JSON text box. Groups recurse to any depth and also render inside array-of-subdocument items. `Mixed`, `Map`, and objects without `items` keep the JSON editor, and a field `widget` still takes precedence. Subfield test IDs use the dotted path (`admin-field-tasks.enabled`, group `admin-field-group-tasks`); inside array items, nested groups are prefixed with the array key and item index (`admin-field-servers.0.media.port`). New array items and create-mode nested objects start from subfield defaults and leave out system fields and enums, refs, dates, and `Mixed` subfields that have no default. Save still sends the whole object for the field.
324
+ - The example app's login route is now `TerrenoLoginScreen` with `rememberIdentity: true`. It shows a badge on the last used method and "Continue as" cards after sign-out, offers a passkey button on web, and runs the two-factor challenge inline. Its Better Auth client turns on the `multiSession`, `passkey` and `twoFactor` features. The mock OIDC provider's button is now the login screen's generic provider button, `login-screen-oauth-mock-oidc`, which replaces `login-mock-oidc-button`. The root layout leaves a signed-in user on `/login`, so the screen can record a returning social sign-in before it moves on. The example's `logout` stops syncdb before it dispatches rtk's `logout()`, and the profile screen shows the sign-out in progress on its button, and the error when a sign-out could not reach the server. The root layout shows its loading spinner only while no user is loaded, so a session refresh or a sign-out keeps the current screen.
325
+ - e2e: `loginAs` answers the two-factor challenge for a user with a `totpSecret`. `crossSiteChallenges.spec.ts` now runs (its `test.fixme` is removed). New cross-site specs `crossSiteLogin.spec.ts`, `two-factor.spec.ts` and `account-linking.spec.ts` cover the last used method, "Continue as", passkey sign-in, TOTP and backup codes, and email and mock-OAuth sign-ins that end up in one account. `E2E_ACCOUNT_LINKING=explicit` starts the backend in explicit linking mode for that spec's explicit-mode case.
326
+ - The example app has a Security screen at `/settings/security` (`TerrenoAccountSecurityScreen`), opened from the profile's **Security** button (`profile-security-link`). It links and unlinks providers, changes the password through Better Auth's `changePassword`, adds and removes passkeys, turns two-factor on with backup codes, and signs out other sessions. The profile's "New Password" field, which sent `password` to `PATCH /auth/me` (refused since 57.17.0), is gone; the "Current password" field an email change needs stays.
327
+ - e2e: `account-security.spec.ts` joins the cross-site bearer run. It links the mock OIDC provider and unlinks it (the password still signs in), turns on two-factor and signs in with a saved backup code, adds and removes a passkey through the virtual authenticator, signs out a second browser's session (which then gets 401), and changes the password (the old one is refused, the new one signs in). `profile.spec.ts` opens Security from the profile in the cookie-mode run.
328
+ - The hosted Terreno MCP server moves to `https://mcp.terreno.app/mcp`. The README, docs, and `create-terreno-app` now use it. `https://mcp.terreno.flourish.health/mcp` keeps serving for at least one more release; update `.cursor/mcp.json`, `.mcp.json`, and `claude mcp add` registrations as described in `docs/how-to/set-up-terreno-mcp.md`.
329
+ - Card, MarkdownEditor, the admin shell, and the admin filter drawer switch to the narrow layout below the desktop floor (1024pt) instead of 768pt. Admin home and the example sync debugger stack their side-by-side panes below that floor. Demo headers and stories read the live window width instead of a one-time viewport check. `isSupportedDesktopWidth` reports that floor. `ADMIN_FILTER_MOBILE_BREAKPOINT` is 1024.
330
+ - `useLoginController` maps a cancelled sign-in (`AUTH_CANCELLED`, or a message of "auth cancelled"
331
+ / "authentication cancelled") to an info notice. A wrong email or password
332
+ (`INVALID_EMAIL_OR_PASSWORD`, `INVALID_PASSWORD`, `INVALID_EMAIL`, or those messages on a response)
333
+ is `passwordError` on the login screen instead of the error banner. Continuing a remembered email
334
+ account that has a usable address sets `continuingAccount` and opens the password step.
335
+ Canceling that step, or canceling two-factor, clears `continuingAccount` and sets the form
336
+ password back to empty so the next account does not keep the previous password.
337
+ `account_not_linked` notices name the original method and, when that method is known, offer
338
+ "Sign in with {method}". The two-factor failure message tells the user to enter the code their
339
+ app shows now. `TerrenoLoginScreen` passes the email that started the challenge as
340
+ `accountLabel`. Pass `initialTrustDevice={false}` to start "Trust this device" unchecked;
341
+ otherwise the inline challenge keeps the authenticator default (checked).
342
+ - `@terreno/rtk` `generateBetterAuthSlice`: `logout()` no longer clears the session before the server answers. The session stays in the state until `authClient.signOut()` finishes (`signOutTimeoutMs`, default 10 seconds), and then it is cleared. Meanwhile the new `isSigningOut` state (`selectBetterAuthIsSigningOut`) is true; `isLoading` is left alone, so a navigator gated on it keeps its screen. Before, a login screen that opened at once could find the still-live session and sign the user straight back in. A sign-out that fails or times out checks the session first. A session that is still live, or a cookie or native session the server cannot be asked about, stays signed in, and `error` is set to `SIGN_OUT_FAILED_MESSAGE` ("Couldn't sign out. Check your connection and try again."). A web bearer client, which drops its token before the request leaves, ends signed out. The global `auth/logout` action (a refused session) now ends signed out even when the sign-out request fails. With `apisToReset` (57.17.0), `logout()` purges the cache when the session clears, not when it is dispatched: a purge at dispatch refetched every mounted screen mid-sign-out, and a refetch that could not reach the server answered 401 and signed the user out even though the sign-out failed. A failed sign-out keeps both the session and the cache. The global `auth/logout` action still purges at once (only the first of several in flight) and again when the session clears. A sign-in for another user that lands while a sign-out is in flight is kept; a sign-out that started signed out asks `getSession` first, so a restore of the session it revoked is cleared. Show `selectBetterAuthIsSigningOut` on the sign-out control and `selectBetterAuthError` after a failed sign-out.
343
+ - `@terreno/ui`: `SyncStatusBanner` no longer flashes "Offline" while the sync socket first connects or during brief drops. The badge appears only after `isOnline` stays false for `offlineGraceMs` (default 3000 ms) and hides as soon as the client reconnects. Pass `offlineGraceMs={0}` to show it immediately, as before.
344
+ - `LoginScreen` matches the login redesign. Method pills sit above the email form. Returning users
345
+ see a continue-as list, with other methods behind "Sign in another way". Edit removes a remembered
346
+ account from this device; a signed-in session cannot be removed there. A remembered email opens a
347
+ password step (`continuingAccount`). A wrong password is `passwordError` on the password field.
348
+ `error` is a banner, and one `notice` (info, warning, or error) replaces it. The submit button
349
+ reads "Sign in". `TwoFactorChallengeScreen` titles the authenticator step "Enter your code" and
350
+ the backup step "Use a backup code", checks "Trust this device" by default for an authenticator
351
+ code (`initialTrustDevice={false}` starts it unchecked), and shows the `error` string it was
352
+ given. `OAuthButtons` `appearance="pill"` and
353
+ `SocialLoginButton` `variant="outline"` render the same neutral pill.
354
+
355
+ ### Deprecated
356
+
357
+ - `installTerrenoDevConsoleLogger` moves to `@terreno/syncdb` (also `@terreno/syncdb/devConsoleLogger`) and takes `{baseUrl}`. The `@terreno/rtk` export is a deprecated wrapper that keeps working with no arguments.
358
+ - `terreno-mcp-local` adds `get_client_state`, which inspects either the RTK Query store or a SyncDB client and returns a `layer` field (`rtk`, `syncdb`, `syncdb+rtk`, or `none`). `get_rtk_state` is now a deprecated alias for one release and returns the same wrapped response. `terreno state` uses the new tool.
359
+
360
+ ### Fixed
361
+
362
+ - `@terreno/admin-frontend`: an admin record no longer stays on its loading spinner when the organization switcher settles on a default organization after the record's request started. `useAdminApi`'s `useListQuery` and `useReadQuery` now carry the organization in the query argument, so the switch fetches the new organization's entry instead of waiting on one that was never requested. A read-only user record's `roles` field also no longer requests `GET /rbac/roles`, which staff without `rbac:read` (such as an operator viewing a user) were refused with 403; it lists the assigned role names instead.
363
+ - The admin user security header clears the previous user's name and email while the next user's record loads, so a recovery action is not confirmed under the wrong identity.
364
+ - `@terreno/api` answers a malformed JSON request body with 400 `Malformed request body` instead of 500 `Internal server error`. `apiErrorMiddleware` now converts body-parser client errors with the new `bodyParserErrorToAPIError` helper and keeps the parser's status: an oversized body returns 413 `Request body too large`, and an unsupported charset returns 415 `Unsupported request body encoding`. The parser's message is in `detail`. These errors are logged at `warn` and are not sent to Sentry.
365
+ - `@terreno/api`: a Better Auth password reset no longer gives a social-only account a JWT password. `onPasswordReset` now copies the new password to the app User's passport hash only when that User already has one (the same rule as `terreno/set-password` and `change-password`), so an account that signed in only with a provider cannot then sign in at `POST /auth/login`, which has no second factor. A dual-enrolled account still gets its JWT password replaced, and every matching app User's `tokenEpoch` is still bumped, so outstanding JWT refresh tokens and MCP credentials stop working after any reset.
366
+ - Terreno's Better Auth password reset sync to the app User's JWT password now also reads the new password from the parsed body of the Better Auth endpoint being served, so direct `auth.api.resetPassword` calls and the email-OTP and phone-number resets (which send `password`) update the passport hash and bump `tokenEpoch` the same way `POST {basePath}/reset-password` does.
367
+ - OpenAPI specs and model metadata from `@terreno/api` no longer mark a field as required when
368
+ its Mongoose `required` is a function. Such a field is only required on some documents, so
369
+ generated SDKs now type it as optional (for example `ConsentResponse.provisionalNote` and
370
+ `GptHistory.text`). Saving still enforces the condition.
371
+ - Apps generated by `create-terreno-app` now send web console errors and warnings to the backend, so `read_logs` and `last_error` with the `browser` source see them. The generated `frontend/app/_layout.tsx` calls `installTerrenoDevConsoleLogger({baseUrl})` from `@terreno/syncdb` at startup, and the generated `backend/.env` sets `NODE_ENV=development` so `TerrenoApp` registers `POST /__terreno/browser-logs`. In an existing app, add both lines; see `docs/how-to/set-up-terreno-mcp.md`.
372
+ - `create-terreno-app` MCP configs now start `terreno-mcp-local` with `bunx -p @terreno/mcp terreno-mcp-local`. The old `bunx terreno-mcp-local` entry failed in generated apps because no npm package has that name. Generated apps also get a root `.mcp.json` for Claude Code with the same servers as `.cursor/mcp.json`. In an existing app, add `"-p", "@terreno/mcp"` before `"terreno-mcp-local"` in the `terreno-local` args; see `docs/how-to/set-up-terreno-mcp.md`.
373
+ - `installTerrenoDevConsoleLogger` no longer logs `Requiring unknown module "<id>"` on the first lazy component load on web (for example `ConsentNavigator` after login). Metro reports the missing split-bundle module to `ErrorUtils` while Expo's web `import()` probes it, and Expo then catches the rethrown error and loads the bundle. The logger now records an `ErrorUtils` error only after the previous handler consumes it. An error that is rethrown and never caught is still logged by the window listeners, and unhandled rejections now include the `Error` stack.
374
+ - `@terreno/api`: with `BetterAuthApp` registered, the JWT recovery routes no longer give an account without a JWT password one. `POST /auth/forgotPassword` mails no legacy reset link to such an account (it still answers 202, so the response does not reveal the account), and `POST /auth/resetPassword` (and the `POST /resetPassword` alias) refuses its token with 400 `legacy-password-reset-unavailable` and issues no tokens. Before, a social-only account could reset through these routes and then sign in at `POST /auth/login`, which has no second factor. That account resets through Better Auth instead. Accounts that already have a JWT password reset here as before, and apps without Better Auth are unchanged.
375
+ - `terreno_bootstrap_ai_rules` now returns files that `rulesync generate` accepts with rulesync 9. `rulesync.jsonc` and the root rule target `cursor`, `claudecode`, `copilot`, and `agentsmd` (rulesync 9 has no `windsurf` target; Windsurf reads `AGENTS.md`). The single `.rulesync/rules/00-root.md` root rule now covers Claude Code, so the tool no longer returns the `01-claudecode-root.md` `localRoot` rule that rulesync rejected; delete it if an earlier bootstrap left it behind. The instructions run `bunx rulesync@9 generate` instead of the removed `rulesync sync`. The root rule now includes everything `create-terreno-app` writes to `CLAUDE.md` and `.cursorrules` (including the "Adding Features" steps), and the instructions say to delete the superseded `.cursorrules` and to move hand edits to `CLAUDE.md`, `AGENTS.md`, `.claude/rules/`, `.cursor/rules/`, and `.github/instructions/` into `.rulesync/rules/10-project.md` before generating, because `rulesync generate` overwrites or deletes them.
376
+ - The MCP server's `terreno_bootstrap` frontend template drops the profile screen's "New Password" field. It sent `password` to `PATCH /auth/me`, which refuses password fields since 57.17.0; change passwords through Better Auth (`changePassword`, or `TerrenoAccountSecurityScreen` from `@terreno/rtk`). The "Current password" field that an email change needs stays.
377
+ - `terreno_generate_form_fields` now emits code that compiles against `@terreno/ui`: fields use `title`, `onChange`, and `errorText` (not `label`, `onChangeText`/`onChangeValue`, `error`), dates use `type` (not `mode`), `NumberField` gets `type="number"` and a string value, passwords use `TextField type="password"`, and required fields declare the `errors` state they read. A test type-checks the output against the `@terreno/ui` declarations so prop drift fails CI. The `terreno://docs/ui` form-field example uses the same props.
378
+ - `terreno_validate_model_schema` no longer reports "Missing TypeScript interfaces" when the model imports its `Document` type from `src/types/models/` or `src/modelInterfaces.ts`; it still reports a model with no `Document` type.
379
+ - The hosted Terreno MCP image now builds the full `@terreno/ui` props data and the versioned docs search corpus, so `terreno_get_component_docs` knows every component and `terreno_search_docs` searches versioned docs instead of only the bundled resources. The `terreno://docs/patterns` resource now shows SyncDB collection CRUD instead of the deprecated RTK pattern.
380
+ - `terreno-mcp-local` CDP tools (`read_logs` source `app`, `last_error`, `get_client_state`, `evaluate`, `navigate`) connect to Metro again on Expo 57. The debugger socket now sends the `Origin` header React Native dev-middleware requires, and when another debugger such as React Native DevTools takes the session the status names Metro's close reason.
381
+ - `terreno-mcp-local` `database_schema` and `database_query` work under Bun again. They previously failed with `undefined is not an object (evaluating 'mongoose.connection.readyState')` whenever a Mongo URI was found; each call now uses its own short-lived connection.
382
+ - `GET /oauth/authorize` accepts a loopback `redirect_uri` (`localhost`, `127.0.0.1`, or `[::1]`) on any port when a registered URI has the same scheme, host, path, and query (RFC 8252 §7.3). The authorization code is returned to the port the client sent. Those three hosts do not match each other, and every other redirect URI must still match exactly.
383
+ - Prevented modelRouter array-item PATCH requests with nested subdocument arrays from failing after the first update.
384
+ - The example app's native (Android) sign-in now works with the local mock OIDC provider: Better
385
+ Auth's Expo authorization proxy refuses plain-http provider URLs, so example-backend serves that
386
+ proxy for the mock issuer's origin itself while `MOCK_OIDC_ISSUER` is set. The mock provider takes
387
+ `MOCK_OIDC_PORT`, `MOCK_OIDC_ISSUER` and `MOCK_OIDC_REDIRECT_URI`, and
388
+ `example-frontend/maestro/native-social-login.yaml` drives the sign-in on an emulator. See the new
389
+ "Native" section of `docs/how-to/set-up-social-login.md`.
390
+ - The `operator` role can find users in the admin panel and open a user's record, and from there their Security screen, in apps that declare an `adminUser` statement (as the example app does). Seeding gives the operator `adminUser:read` (never `write`), and an operator row seeded earlier gains it once through `appliedGrants`. Before this fix, `/admin/config` left User out for operators and `/admin/User/:id` showed a spinner that never finished.
391
+ - `@terreno/api`: `OrgsApp` now clears a user's cached permissions when their membership is added, changed, or removed through `POST` / `PATCH` / `DELETE /orgs/:id/members`. Before, a newly attached org-admin could be refused the admin page (no `admin:access`) for up to the 30-second permission cache TTL, and a demoted, suspended, or removed org-admin kept it as long. Disabling (`PATCH /orgs/:id` with `disabled: true`) or deleting (`DELETE /orgs/:id`) an organization, which suspends all of its memberships, now clears the cached permissions of every member too, so the organization's org-admins lose `admin:access` on their next request.
392
+ - A server bundled with `bun build --compile` no longer crashes at startup with "tsyringe requires a reflect polyfill". `@terreno/api` now depends on `reflect-metadata` and loads it before the passkey plugin, whose `@peculiar/x509` dependency needs it.
393
+ - `@terreno/api`: the account-security reach check (an org-admin or platform operator acting on another user) and the RBAC user-write guard no longer let an actor through because a permission source was down. A failed source drops its grants from the target's resolved permissions, which made the target look less privileged than it is. Both now resolve the target with the new `access.getPermissions({user, requireComplete: true})`, which answers 503 `Permission source unavailable` when a failed source's grants are unknown (grants served from a `"use-stale"` cache still count).
394
+ - `@terreno/api`: the RoleManager target-privilege rule (`access.roles.assign`, `unassign`, `previewAssignment`, and `assertCanModifyUser`, used by `PUT /rbac/users/:id/roles`, `POST /rbac/users/:id/roles/preview`, and `@terreno/admin-backend` user writes) now resolves the target user with `requireComplete`. Before, when an additive permission source was down, the target's grants from it were dropped, so the target looked less privileged and an actor without those permissions could change its roles. The check now answers 503 `Permission source unavailable` in that case. `createRoleManager` takes an optional `getTargetPermissions` for this.
395
+ - `SocialLoginButton` now follows later changes to its `loading` prop instead of reading it only on
396
+ the first render, so a parent can start or clear the spinner after mount.
397
+ - `@terreno/api`: a recovery action that changes something (such as `suspend-membership` under `/orgs/:orgId/members/:userId/security`) now clears the target's cached permissions, so a suspended org-admin loses `admin:access` at once instead of keeping it for up to the 30-second permission cache TTL.
398
+ - @terreno/syncdb no longer sends a stream discovery (`GET /sync/streams`) that a `stop()` overtook while it was reading the token. An app that stops syncdb and then signs out (as the example's `logout` does) used to see that request go out with the revoked session and fail with a 401. `createHttpChannel`'s `fetchStreams` takes an optional `{isSuperseded}` checked before the request is sent; a superseded request rejects with the new exported `RequestSupersededError` and reaches nothing.
399
+ - `@terreno/ui`: `TextField` now sets its `autoComplete` prop as the input's `autocomplete`
400
+ attribute on web (it was ignored before). Native rendering is unchanged. The prop also accepts
401
+ `"email"`, `"new-password"`, and `"username webauthn"` (passkey autofill).
402
+ - Because the hint now reaches the browser, `autoComplete="off"` takes effect on web and turns
403
+ autofill off for that field. The example app, `create-terreno-app`, and the MCP bootstrap
404
+ templates switch their email fields from `"off"` to `"username"` (login) and `"email"`
405
+ (sign-up).
406
+ - `Field type="password"` still defaults to `"current-password"`; pass `autoComplete="new-password"`
407
+ (also through `TapToEdit`) on fields that set a new password. The example admin set-password
408
+ fields now do.
409
+ `SignUpScreen` password fields default to `"new-password"` and `LoginScreen` password fields to
410
+ `"current-password"` unless the field config sets its own.
411
+ - `LoginScreen` names each email and password input by its field label for screen readers,
412
+ instead of the generic "Text input field".
413
+ - `Avatar` takes keyboard focus only when it is editable (`status="imagePicker"`), so a display
414
+ avatar, such as the one on a remembered-account card, is no longer an empty tab stop.
415
+
416
+ ## [57.19.2] - 2026-10-26
417
+
418
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.19.2.md`](mcp-server/src/docs/upgrades/57.19.2.md).
419
+
420
+ 57.19.0 and 57.19.1 were tagged but never published to npm (the publish gate failed before the release notes had merged). This release contains everything from both.
421
+
422
+ ### Added
423
+
424
+ - `@terreno/test` gains the test helpers Flourish built and proved in its own suites:
425
+
426
+ - `waitUntil` / `waitForCalls` to poll a condition instead of sleeping, and `freezeTime` to
427
+ freeze `Date.now()`, `new Date()`, and Luxon together.
428
+ - `authWithToken` for a supertest agent with a pre-minted token (no login round-trip),
429
+ `expectAPIError` for `APIError` bodies, and `assertStrictResponseProperties` to fail on any
430
+ response field outside an allowlist.
431
+ - Opt-in `mock.module` registrars for Cloud Tasks (with `drainTasks`), SendGrid, Twilio, Expo
432
+ push, GCS, Google auth, and the `@terreno/api` notifiers, plus `createMockLanguageModel` for
433
+ the AI SDK.
434
+ - `@terreno/test/expo/*`: `registerExpoTestEnvironment`, `renderWithTerreno`, in-memory auth
435
+ token storage, and a module-alias mocker for Bun + Expo tests. `example-frontend` now uses it.
436
+ - `terreno-test-shard` (weighted test sharding) and `terreno-test-hygiene` (AST lint for leaky
437
+ or flaky Bun test patterns, with a baseline) CLIs.
438
+ - Testing skills for Terreno apps, installable with the other agent skills: `terreno-test-setup`
439
+ bootstraps backend (Bun + in-memory Mongo), frontend (Bun + React Native Testing Library,
440
+ Playwright), and mobile (Maestro) test setups; `terreno-backend-tests`,
441
+ `terreno-frontend-tests`, and `terreno-mobile-tests` cover writing tests for each. New how-to:
442
+ [Set up tests in a Terreno app](docs/how-to/set-up-app-tests.md).
443
+ - `@terreno/ui` loading skeletons: `Box` and the container components (`Card`, `EditableCard`, `ChartCard`, `Scorecard`, `Page`, `SplitPage`, and the charts) take `loading`, `loadingComponent`, `loadingAnimation`, and `loadingLabel` props. New `Skeleton`, `SkeletonText`, `SkeletonCircle`, `SkeletonCard`, `SkeletonList`, and `SkeletonGroup` components support `pulse`, `shimmer`, or no animation and respect reduce-motion. `withLoading(Component, options)` adds the props to custom components. See [Show loading skeletons](docs/how-to/show-loading-skeletons.md). (#1571)
444
+ - Published packages now include the repository `CHANGELOG.md` in their tarballs. (#1632)
445
+
446
+ ### Changed
447
+
448
+ - `@terreno/cli` and `@terreno/mcp` require Bun `>=1.4.4`. 1.4.4 patches the Brotli decoder (CVE-2026-111497). CI, `example-backend`, and `mcp-server` images pin that release. `oven/bun:1` and `oven/bun:1-slim` can still resolve to an affected release, and `bun build --compile` embeds the builder's Bun binary. New apps from `create-terreno-app` get `oven/bun:1.4.4-slim`. Existing apps should set that tag in their Dockerfile and rebuild. See [Upgrade Terreno](docs/how-to/upgrade-terreno.md).
449
+
450
+ EAS build profiles stay on Bun 1.3.10. That pin is part of the Expo fingerprint, so changing it forces a native rebuild. Those profiles remain on an affected Bun release until that rebuild is scheduled.
451
+
452
+ ### Fixed
453
+
454
+ - `create-terreno-app` did nothing when run through its package bin (`bunx create-terreno-app`,
455
+ `npx create-terreno-app`): the entry check compared the module URL with the unresolved
456
+ `node_modules/.bin` symlink. It now resolves the path first.
457
+ - `@terreno/test` now installs and imports outside the monorepo. `supertest`,
458
+ `@types/supertest`, and the `mongodb-memory-server` packages moved from
459
+ `devDependencies` to `dependencies` (57.17.0 failed with `Cannot find package
460
+ 'supertest'` on import), and the published tarball no longer ships test files.
461
+ - `registerSimpleMongoPreload` now passes `TERRENO_TEST_MONGO_LAUNCH_TIMEOUT_MS` (default 60s) to
462
+ `mongodb-memory-server`, so a cold `mongod` start no longer fails at the library's 10s default.
463
+
464
+ ## [57.18.0] - 2026-10-25
465
+
466
+ ### Added
467
+
468
+ - Vercel hosting: `example-backend` runs on Vercel Functions (exported `http.Server` with Socket.IO, full Bun bundle), and `example-frontend` and `demo` deploy as static Expo exports. CircleCI deploys all three to the `terrenolabs` Vercel team alongside Cloud Run and Netlify. See `docs/how-to/deploy-to-vercel.md` and `docs/how-to/vercel-function-websockets.md`.
469
+ - `@terreno/rtk`: `useSocketConnection({transports})` chooses the Socket.IO transports (default unchanged: polling, then websocket). Use `["websocket"]` on hosts without session affinity, such as Vercel Functions.
470
+ - `@terreno/syncdb`: `createSyncDb({socketTransports})` does the same for the default socket transport.
471
+ - New `deploy-vercel` agent skill.
472
+
473
+ ### Fixed
474
+
475
+ - `@terreno/syncdb`'s tests now cover the debug-recorder paths (replay failures, retries, rate-limit and auth nacks, batch sends, stream join/leave/skip, cursor migration, tombstone compaction, snapshot-batch fallback, `forceResync`, and subscribe catch-up), so the 95% coverage gate passes again on Bun 1.4.4, which counts the lines of debug event objects that never run without a recorder.
476
+
477
+ ## [57.17.0] - 2026-10-23
478
+
479
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.17.0.md`](mcp-server/src/docs/upgrades/57.17.0.md).
480
+
481
+ ### Breaking
482
+
483
+ - `PATCH /auth/me` now requires `currentPassword` when the body changes the email to a different mailbox (casing-only changes and an unchanged email echoed back still need none). A missing or wrong password gets 403 and nothing is saved; a user with no password (for example GitHub-only) gets 400 and must set one through the reset flow first. The password is checked against the passport hash, which counts toward its `limitAttempts` lockout, or against the Better Auth credential when `BetterAuthApp` is registered (new `authOptions.verifyBetterAuthPassword`, wired by TerrenoApp, and `verifyBetterAuthCredentialPassword`). After a change the old address gets a `sensitive: true` notice through `authOptions.sendMail` with the new address partially masked and no links. Clients that let users change their email must collect and send `currentPassword`; the example frontend, `create-terreno-app`, and the MCP bootstrap profile screens now show a "Current password" field when the email is edited. See [Changing the email on PATCH /auth/me](docs/reference/api.md#changing-the-email-on-patch-authme).
484
+ - `PermissionSource.failClosedWhenDenyUnknown` now defaults to `true`. When a source's `getGrants` fails for a user this process has no last-known deny posture for (a new instance, or the posture was evicted), permission checks throw 503 `Permission source unavailable` instead of resolving local roles without the source's `deny`. Behavior after a successful fetch is unchanged. Add `failClosedWhenDenyUnknown: false` to app sources that never return `deny` (additive-only role or permission mappings) to keep their old outage behavior. The built-in organization membership source already sets it. See [RBAC permission sources](docs/reference/api.md#rbac-permission-sources).
485
+ - With RBAC (`accessControl`), changing another user's `email`, `disabled`, `betterAuthId`, `githubId`, or `emailVerified` now requires the new `user:updateIdentity` permission in addition to `user:update`. This applies to modelRouter, sync, MCP, instance actions, and AdminApp `PATCH` and bulk-patch. Writes without it get 403 `Missing user:updateIdentity permission`. Newly seeded `admin` roles include it and `superadmin` gets it through `*`. `seedDefaults` does not update an `admin` role that is already stored, so existing deployments must add `updateIdentity` to that role's `user` actions (for example with `PATCH /rbac/roles/admin`) to keep RBAC admins able to edit user emails and disable users. Legacy `admin: true` users and self-writes are not affected. See [RBAC user identity writes](docs/reference/api.md#rbac-user-identity-writes).
486
+
487
+ ### Added
488
+
489
+ - `attach-pr-media` agent skill. It uploads local screenshots and recordings to one shared secret gist with only the `gh` token and posts Markdown that embeds them in the pull request, for sandboxes where `/opt/cursor/artifacts/` is unavailable. `verify-ui-changes` now points to it. Install with `npx skills add TerrenoLabs/terreno --skill attach-pr-media` or from the `terreno` Claude plugin.
490
+ - `BetterAuthConfig` takes `options` and `plugins`. `options` passes any other Better Auth option (for example `emailAndPassword.requireEmailVerification`, legacy `password.{hash, verify}`, or `databaseHooks`), deep-merged over Terreno's defaults with arrays replaced and explicit values winning. `plugins` are appended after `bearer()` and mount under `basePath`. Apps no longer need to replace `BetterAuthApp` to reach them. (#1578)
491
+ - `BetterAuthConfig.oauthHandoff: {webOrigins, callbackPaths?}` lets a web app on another site finish a social or generic OAuth sign-in in bearer mode without cookies. When the OAuth callback creates a session and redirects to one of `callbackPaths` (exact paths written in normal form, default `["/"]`; the query is free) on one of `webOrigins` (exact `http(s)` origins), the redirect carries `#ott=<token>` in its URL fragment: a single-use token that expires in three minutes and is stored hashed. `POST {basePath}/one-time-token/verify` exchanges it once for the session and the `set-auth-token` header. Neither the handed-off callback redirect nor verify sets a session cookie on the API's origin. App schemes, relative paths, the API's own origin, and origins that only `trustedOrigins` allows get no token. Clients cannot mint one (`GET /one-time-token/generate` is refused), and no response sends a `set-ott` header. Start the sign-in with a top-level navigation to the new `GET {basePath}/oauth-handoff/start?provider=<id>&callbackURL=<url>` (optional `errorCallbackURL`): it sets the OAuth state cookie first-party on the API's origin and redirects to the provider, so the callback works when the browser blocks third-party cookies. It answers 400 when `Sec-Fetch-Dest` is set and is not `document`, so only a page navigation can start a sign-in. It works for social and generic OAuth providers, and answers 400 with no redirect unless `callbackURL` is one of `callbackPaths` on one of `webOrigins`, `errorCallbackURL` (optional) is on one of `webOrigins`, and `trustedOrigins` allows both. With the default `["/"]`, any other return page is refused. An unknown provider or one that cannot start sends the page back with `error=provider_not_found` or `error=sign_in_failed`. Terreno's opt-in `rateLimit` puts `{basePath}/oauth-handoff/*` and `{basePath}/one-time-token/*` in the auth bucket. Unset by default, which adds no `/one-time-token` or `/oauth-handoff` routes.
492
+ - `signInSocialWithHandoff({client, provider, callbackURL?, errorCallbackURL?})` starts a social or generic OAuth sign-in for a `createBetterAuthClient({webAuth: "bearer"})` web client. It stores a random nonce in `sessionStorage` and navigates the page to the server's `GET {basePath}/oauth-handoff/start` with `handoff=<nonce>` on the callback URL. It throws on native and in cookie mode.
493
+ - `completeBetterAuthOAuthHandoff({client})` finishes that sign-in on the return page. When the URL has `#ott=<token>` and a `handoff` equal to the stored nonce, it removes both from the URL with `history.replaceState`, then exchanges the token at `{basePath}/one-time-token/verify`, and bearer mode stores the `set-auth-token` that comes back. A token without a matching nonce is removed and never sent. Other query and fragment parts stay, and `?error=` is left for the app. It resolves `true` only when a token was stored. Await it at startup before session reads, other query handling, and Sentry. The server needs `oauthHandoff: {webOrigins, callbackPaths?}`. See [Social sign-in in bearer mode](docs/how-to/configure-better-auth.md#social-sign-in-in-bearer-mode).
494
+ - `createBetterAuthClient` takes `webAuth: "bearer"` for web apps whose frontend and API are on different sites. On web it stores the `set-auth-token` header from sign-in, sends `Authorization: Bearer` on every Better Auth call including `getSession`, and sends no cookies. `signOut()` clears the token before its request, and a successful `getSession` with no session clears it unless a newer token was stored meanwhile. The token lives in `localStorage` under `${storagePrefix}_bearer_token`, or in the `bearerStorage` you pass (for example `window.sessionStorage` on shared devices). The default stays `"cookie"`, and native is unchanged. The web origin must be in the server's Better Auth `trustedOrigins`. See [Frontend and API on different sites](docs/how-to/configure-better-auth.md#frontend-and-api-on-different-sites).
495
+ - `getBetterAuthBearerToken({storagePrefix?, bearerStorage?})` reads that stored token without a network call.
496
+ - `GPTChat` takes `showHistory` (default `true`). Pass `false` to leave out the history sidebar so the chat panel uses the full width on narrow and phone layouts.
497
+ - modelRouter actions can opt into MCP with `mcp: {name, description?}`. The action becomes a tool on `/mcp` that takes `{id, ...body}` (instance) or the body alone (collection), runs the action's own permission chain including RBAC `access`, and gets `req.isMCPRequest === true`. Actions also accept `unwrapResponse: true` to send the handler's return value as the REST body instead of `{data: …}`. See [modelRouter actions → MCP tools](docs/explanation/model-router-actions.md#mcp-tools).
498
+ - `TerrenoApp` `mcpOAuth: {enabled: true}` turns on an OAuth 2.1 authorization server for `/mcp`, so Claude.ai connectors and ChatGPT apps can connect to apps that sign in with Terreno JWTs. It is off by default. It serves RFC 9728 and RFC 8414 discovery, the authorization-code flow with PKCE S256 and an app-hosted consent screen, rotating refresh tokens with reuse detection, and revocation. Revoking a token family writes an `McpOAuthRevokedFamily` tombstone that `McpOAuthToken.issue` honors, so concurrent refreshes and code replays cannot leave a live token. Dynamic client registration (public clients, redirect hosts allowlisted) is a separate opt-in, `dynamicClientRegistration: true`; without it, the app creates `McpOAuthClient` documents itself. With `mcpOAuth` on, `/mcp` answers unauthenticated requests with a `401` `WWW-Authenticate` challenge. See [Enable MCP OAuth for a JWT app](docs/how-to/enable-mcp-oauth.md).
499
+ - `getCurrentRequestContext()?.credential` records how the caller authenticated: `"jwt"`, `"better-auth-session"`, `"mcp-service-token"`, or `"oauth"`. Permission functions and hooks can use it to keep broad access (such as an admin bypass) to the user's own session and away from delegated agent tokens. `extractAuthFromHeaders` returns the same `{user, credential}` pair for MCP mounts. See [Request credential](docs/reference/api.md#request-credential).
500
+ - `useTerrenoChat` takes a `tokenSource` option. Each chat request reads its `Authorization: Bearer` token from that source, else the one registered with `setTokenSource`, else the built-in JWT source, and sends no `Authorization` header when there is no token. JWT apps behave as before.
501
+ - `useMCPTools` takes a `tokenSource` option. The MCP transport reads its `Authorization: Bearer` token from that source, else the one registered with `setTokenSource`, else the built-in JWT source, before every MCP request instead of capturing one token per fetch. When the source has no token as a fetch starts, the hook connects without authentication, as JWT apps do today.
502
+ - `createOfflineMiddleware` takes a `tokenSource` option. With a non-JWT source (that option, else the one registered with `setTokenSource`), queued mutations are tagged with the source's `getUserId()` instead of `state.auth.userId`, so the middleware works without an `auth` slice. They replay only while that same user is signed in, with `Authorization: Bearer` from the source's `getToken()` (omitted when there is no token). The offline slice gains an optional `lastUserId` (with `setLastUserId` and `selectLastUserId`): the last user id the source confirmed, refreshed on the first action, on rehydrate, on `onAuthChange` and on reconnect. A session read that fails while offline therefore still tags new mutations, and when the `offline` slice is persisted with redux-persist, mutations made after an offline launch are tagged with the persisted user. Until a read in the running app confirms that persisted id, those mutations carry `userIdConfirmed: false` (new optional `QueuedMutation` field, with the new `resolveUnconfirmedMutations` and `dropUnconfirmedMutations` actions): a read that returns a user keeps them only if it is the same user and no auth change or source swap happened since, and any auth change drops them (even for the same user), so they survive only when the reconnect read confirms them first. syncdb's `betterAuthAdapter` fires `onAuthChange` on the session's first successful load, which after an offline launch happens once the network returns, so that event drops offline-launch writes the reconnect read has not confirmed yet. The one remaining risk needs the signed-in user to change while no running instance of the middleware observes it (for example in another browser tab sharing the persisted `offline` slice) between an unconfirmed write and the next launch's first read. On the token-source path, a sign-out reported by the source, `auth/logout`, or the Better Auth slice's `betterAuth/logout` clears the queue, conflicts and `lastUserId`, and discards writes still waiting on a user id read and any replay in progress. A reconnect with no readable user, or a user switch during a replay, keeps the remaining queue for a later reconnect, and a mutation still waiting on a user id read gets no user (so it never replays) if the user changes first. JWT apps behave as before.
503
+ - `useSocketConnection` no longer requires `getAuthToken`. Without it, the hook reads the token source: the new `tokenSource` option, else the one registered with `setTokenSource`, else the built-in JWT source. The handshake re-reads the token on every connect and reconnect, and when the source's `onAuthChange` reports a different token the hook reconnects with it (with no token it disconnects). When the server rejects the handshake token, the hook calls `refresh()` once and reconnects; a second rejection before a successful connect stops reconnecting and calls `onConnectError`. Passing `getAuthToken` keeps the previous behavior.
504
+ - `@terreno/rtk` reads request tokens from one registered token source. Call `setTokenSource(authProvider)` once at startup with the same syncdb `AuthProvider` the app builds for `createSyncDb` (for example `betterAuthAdapter(betterAuthClient)`). `getTokenSource()`, `jwtTokenSource`, and `isJwtTokenSource()` are also exported. Without a registered source, rtk keeps using the JWT tokens in storage, so JWT apps change nothing.
505
+ - `emptySplitApi` sends the registered source's token.
506
+ - `betterAuthAdapter` takes a `getBearerToken` option. When it returns a token, `getToken` uses it without calling `getSession()`; a null, empty or throwing getter falls back to `getSession()` as before. Pair it with `getBetterAuthBearerToken` from `@terreno/rtk` for web apps in Better Auth bearer mode. `getUserId`, `onAuthChange` and `refresh` are unchanged. See [createSyncDb configuration](docs/reference/syncdb.md#createsyncdb-configuration).
507
+ - `WhatsNewBanner` in `@terreno/ui` shows non-blocking release notes, such as "What's new
508
+ in 57.16.2", until the viewer opens or dismisses it, then hidden until a newer release. See
509
+ [Show release notes with WhatsNewBanner](docs/how-to/whats-new-banner.md).
510
+ - The example app's what's new banner lists the 10 most recent `CHANGELOG.md` releases.
511
+ `bun run whats-new:generate` writes them, `changelog:assemble` refreshes them on each
512
+ release, and CI fails through `bun run check:whats-new` when they are stale.
513
+ - `BannerButton` accepts an optional `testID`.
514
+
515
+ ### Changed
516
+
517
+ - `RealtimeApp` uses the Better Auth instance of a `BetterAuthApp` registered on the same `TerrenoApp`, so Better Auth socket sessions work without building a second instance and passing `betterAuth`. An explicit `betterAuth` option still wins. (#1576)
518
+ - An explicit `betterAuth` option without a `userModel` now gets the `RealtimeApp`'s (else the `TerrenoApp`'s), so its sockets resolve to the app user instead of the Better Auth id. (#1576)
519
+ - The Better Auth socket handshake creates the app `User` on first contact, as the REST session middleware does, and refuses disabled users. (#1576)
520
+ - A `RealtimeApp` registered without its own `userModel` uses the `TerrenoApp`'s, for JWT and Better Auth sockets alike. Socket authorization then reads the user from the database instead of the token's claims, and the revalidation sweep disconnects disabled or deleted users, as `TerrenoApp({realtime})` already did. (#1576)
521
+ - create-terreno-app: generated backends no longer build a second Better Auth instance for `RealtimeApp`; it uses the registered `BetterAuthApp`, so `BetterAuthConfig` `options` and `plugins` apply to sockets too. (#1594)
522
+ - create-terreno-app: generated frontends register the syncdb Better Auth adapter from `store/syncdb.ts` with `setTokenSource` before the store is built, and the generated SDK uses `@terreno/rtk`'s `emptySplitApi` (`openapi-config.ts` `apiFile: "@terreno/rtk"`). `store/betterAuthApi.ts` and its forked base query are no longer generated. Generated web clients still use Better Auth's session cookie. RTK requests now get rtk's retries, and a 401 that survives one session refresh signs the user out (`auth/logout`); the fork never did. To move an existing generated app over: export `authProvider` from `store/syncdb.ts`, call `setTokenSource(authProvider)` in `store/index.ts`, set `apiFile: "@terreno/rtk"` in `openapi-config.ts`, run `bun run sdk`, delete `store/betterAuthApi.ts`, and import `useGetVersionCheckQuery` from `@terreno/rtk` if the app used it.
523
+ - The example backend seeds its staff modal and patient banner announcements as drafts, so a
524
+ fresh database (local dev or a PR preview) no longer interrupts sign-in. Publish them from
525
+ **Admin → Announcements** to try them.
526
+ - `@terreno/feature-flags` evaluation diagnostics are off unless `FEATURE_FLAGS_DEBUG=true` (they were on unless set to `"false"`), are written at `debug` level instead of `info`, and no longer include the requesting user's value for a field rule.
527
+ - Lifecycle skills now own a PR after every push: Brew, Taste, the planning loop, taste-sweep, and the repository skills that push (fix-conflicts, respond-to-review, update-dependencies, release, track-upstream-expo, implement-ready-for-dev) keep fixing red CI and answer every review-bot comment, then escalate to the human as `BLOCKED` after three failed fix rounds instead of leaving the PR red. See `references/push-ownership.md`.
528
+
529
+ ### Fixed
530
+
531
+ - `BetterAuthApp` stores Better Auth users, accounts, sessions, and verifications in the mongoose connection's database (the `dbName` passed to `mongoose.connect`) instead of the connection string's default database. Apps that connect with a `dbName` different from the URI's database should copy the `user`, `account`, `session`, and `verification` collections into the `dbName` database when upgrading.
532
+ - A Better Auth session whose app `User` has `disabled: true` no longer authenticates on REST: `req.user` stays unset, so protected routes answer 401, matching JWT and MCP. Better Auth's own routes, such as sign-out, still work. (#1577)
533
+ - The `TerrenoApp` rate limiter resolves `.`/`..` path segments (including `%2e` forms) before choosing a bucket, so dot-segment variants of Better Auth sign-in, sign-up, and password-reset paths are charged to the auth bucket, and dot-segment paths no longer get the health/openapi/swagger skip.
534
+ - Request logging redacts credential fields under any key name and at any depth in request bodies and query strings (for example `currentPassword`, `idToken.token`, `client_secret`, `code_verifier`, and OAuth `code`), not only five top-level keys. `TerrenoApp` now honors `loggingOptions.logRequests: false`, which was previously ignored.
535
+ - Better Auth apps are no longer logged out on their first RTK Query request (#1217). With a registered non-JWT token source, `emptySplitApi` sends no `authorization` header when the token is null, and dispatches `auth/logout` only when a 401 survives one `refresh()` and retry.
536
+ - `betterAuthAdapter` no longer reports a failed session read as a sign-out. When `getSession()` rejects or returns a non-401 `{error}` envelope, `getUserId()` answers with the user id the server last confirmed instead of null. Before, going offline between a session-atom emission and the syncdb client's follow-up read cleared the client's current user, paused sync, and made `mutate()` throw "requires start() to have resolved an authenticated user" until the next sign-in.
537
+ - `betterAuthAdapter` keeps the newest confirmed user id. A session read that started before a sign-out or user switch and resolves after it no longer restores the previous user, and neither does a session-atom emission that repeats the cached session (a refetch start, or the value a new `onAuthChange` subscriber receives on subscribing). A later failed read therefore cannot revive a signed-out identity or report the wrong user.
538
+ - `betterAuthAdapter` treats a 401 session-atom emission as a sign-out, the same as a 401 from `getSession()`: it clears the confirmed user id and fires `onAuthChange`. Before, the subscribe path ignored it like a network failure and kept the previous user.
539
+
540
+ ## [57.16.2] - 2026-10-13
541
+
542
+ ### Fixed
543
+
544
+ - Light-mode primary button labels, icons, and loading indicators are white again,
545
+ including on disabled primary buttons. (#1567)
546
+
547
+ ## [57.16.1] - 2026-10-12
548
+
549
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.16.1.md`](mcp-server/src/docs/upgrades/57.16.1.md).
550
+
551
+ ### Added
552
+
553
+ - `terraform/modules/terreno-backend` provisions one Terreno backend on Cloud Run: Artifact Registry, a least-privilege runtime service account, empty Secret Manager secrets, and a websocket-safe service (session affinity, 3600s timeout, one minimum instance). Set `github_deploy` for a keyless GitHub Actions deployer. See [Deploy backend to Cloud Run](docs/how-to/deploy-backend-to-cloud-run.md#manage-with-terraform).
554
+
555
+ ### Changed
556
+
557
+ - `terraform/` now holds only reusable modules. Terreno's own GCP deployment (example backend, jobs worker, hosted MCP server) moved to `infra/flourish/`, which uses the shared modules through a symlink; CI stages a dereferenced copy for Infra Manager. No infrastructure changes: resource addresses are unchanged.
558
+
559
+ ### Fixed
560
+
561
+ - `TextField` and `TextArea` on web no longer drop a typo fix made within half a second. The
562
+ oscillation guard used to suppress any change that undid the previous one within 500ms, so
563
+ typing a character and backspacing it right away put the character back. Changes the user
564
+ types, deletes, pastes, drops or undoes (judged by the DOM input event's `inputType`) now
565
+ bypass the guard. Spellcheck replacements, IME composition and changes with no input event
566
+ stay guarded.
567
+
568
+ ## [57.15.0] - 2026-10-11
569
+
570
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.15.0.md`](mcp-server/src/docs/upgrades/57.15.0.md).
571
+
572
+ ### Changed
573
+
574
+ - SyncDB bootstraps and reconciles every stream through one `POST /sync/snapshot/batch` request
575
+ per round instead of one `GET /sync/snapshot` per stream. A reconnect with nothing new now costs
576
+ 2 requests however many collections the app syncs. The client sends the next round while it
577
+ writes the current one and writes rows in chunks of 500 so the UI keeps responding. Clients fall
578
+ back to per-stream paging against servers without the batch route. The web IndexedDB persister
579
+ no longer serializes the whole store once per transaction: it serializes once per write.
580
+
581
+ ## [57.14.0] - 2026-10-11
582
+
583
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.14.0.md`](mcp-server/src/docs/upgrades/57.14.0.md).
584
+
585
+ ### Changed
586
+
587
+ - `@terreno/syncdb` now pages snapshot streams side by side instead of one at a time, so cold
588
+ start no longer costs one sequential round trip per stream. Reconcile, subscribe catch-up,
589
+ `forceResync` and wipe re-bootstrap run up to `snapshotConcurrency` streams at once (new
590
+ `createSyncDb` option, default 6; `1` restores the old behavior). Each stream now runs one pass
591
+ at a time: a subscribe catch-up queues behind a pass that started before the room join, and a
592
+ force resync waits for the stream's in-flight pass before purging it, so a concurrent pass can
593
+ no longer mark a purged stream bootstrapped over rows it never paged. A new how-to,
594
+ [Use syncdb correctly](docs/how-to/use-syncdb-correctly.md), lists the library behavior
595
+ apps have to handle.
596
+
597
+ ## [57.13.0] - 2026-10-10
598
+
599
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.13.0.md`](mcp-server/src/docs/upgrades/57.13.0.md).
600
+
601
+ ### Added
602
+
603
+ - Agent UI rich replies: a block document can now hold a `stepper` (− value + that calls a host action and gets the replacement stepper back), a `checklist` (ticks with an "n of m" counter, local or sent to a host action), a `gallery` (2–6 photos), a `list` (titled rows with portrait thumbnails), a card `eyebrow`, and a `copy` button action that writes a block's text to the clipboard on the device. See [Add a stepper callback](docs/how-to/agent-ui-blocks.md#add-a-stepper-callback), [UI blocks](docs/reference/blocks.md), and [Agent UI blocks](docs/explanation/agent-ui-blocks.md).
604
+ - `@terreno/blocks` exports the stepper, checklist, gallery, list, and copy action types, `blockPlainText`, `stepperElementIds`, `checklistElementId`, `COPY_TARGET_TYPES`, and the error codes `OUT_OF_RANGE` and `COPY_TARGET_INVALID`. `blocksPromptSection` takes `richBlocks`, `stepperActions`, and `checklistActions`; `validateBlocks` takes the two action lists. The Sunday roast golden document is `blocks/src/fixtures/golden/sunday-roast.yaml`.
605
+ - `@terreno/ai` adds the opt-in host actions `scaleStepperHostAction` and `toggleChecklistHostAction`, `findAgentBlock` for reading the block the agent wrote from the stored history, and `runBufferedChatTurn` for one chat turn without HTTP. A host action can set `handles: "stepper" | "checklist"` and `logResponse: false` (the returned block stays out of the `ui_action` log). `uiBlocks.richBlocks` controls which blocks the prompt offers.
606
+ - `@terreno/ui` `BlocksView` renders the new blocks with Terreno components. `DataTable` has right-aligned `number` and Luxon `date` cells, and the `table` block uses them. `MarkdownView` tables get theme borders, a bold header, and sideways scroll. `Box` takes `accessibilityState` and `accessibilityLiveRegion`. `Image` uses `alt` as its accessible label.
607
+ - The example app generates a stored photo library once (`bun run photos:generate`, Vertex Imagen), gives the agent a `findPhotos` tool, serves `GET /photoLibrary/:id/url`, and registers `scaleStepper` and `toggleChecklist`. `bun run blocks:smoke` checks that a real model writes the roast reply, and skips without a model key.
608
+ - `@terreno/api` exports `createRouteRateLimitMiddleware({name, max, windowMs, store?, keyBy?})`, a fixed-window limit for one route that works without `TerrenoApp` `rateLimit`. See [Rate limiting](docs/how-to/rate-limiting.md). (#1499)
609
+ - `@terreno/admin-backend` `DocumentStorageApp` takes `access: "authenticated"` to open document storage to every signed-in user. Non-admins only see their own `users/<userId>/` folder under `folderPrefix`; admins keep the whole prefix. `uploadRateLimit` limits uploads per client IP (or `keyBy`). A `folderPrefix` without a trailing slash gets one, and a missing bucket lists as empty. (#1499)
610
+ - `@terreno/admin-frontend` `DocumentStorageBrowser` takes `backButton` and shows upload errors inline, including a 429 "Upload limit reached" message. (#1499)
611
+ - `@terreno/ui` `SegmentedControl` takes `size="sm"` (28px, sized to its labels). (#1499)
612
+
613
+ ### Changed
614
+
615
+ - The admin Roles screen shows one row per resource, with that resource's actions as badges, instead of one badge per `resource:action`. Custom permissions list before model permissions. Badges are green for read actions, amber for writes, and red for destructive actions (`delete`, `destroy`, `remove`). Saved role data is unchanged. (#1540)
616
+ - Behaviour change: hosts with `uiBlocks` on get the new rich blocks (`checklist`, `gallery`, `list`, card `eyebrow`, and `copy`, plus `stepper` when a host action has `handles: "stepper"`) in the chat system prompt by default. A native build released before them shows an error banner for a reply that uses them. Set `uiBlocks.richBlocks: false` to keep the old catalog until those builds update; the prompt is then unchanged from before, even with a stepper action registered. Validation accepts the new blocks either way. See [Keep the old catalog for shipped native builds](docs/how-to/agent-ui-blocks.md#keep-the-old-catalog-for-shipped-native-builds).
617
+ - `DataTable` columns with `columnType: "number"` now right-align their cells and header, and `columnType: "date"` cells format an ISO string or `Date` as Luxon `DATE_MED` (date only, local zone). Values that are already formatted strings show as before. Use `customColumnComponentMap` to keep the old look.
618
+ - `Image` with `alt` now sets it as the accessible label and makes the image its own screen-reader element.
619
+ - `table` blocks show column names with underscores as spaces, size to their rows, and show at most 10 rows before the body scrolls.
620
+ - The `neutral500` theme primitive is now `#949494` (was `#9A9A9A`), so extra-light text meets contrast guidelines. Disabled surfaces, dark borders, the away status, and new `create-terreno-app` projects pick up the new gray. Apps that set their own `neutral500` with `setPrimitives` are unaffected. (#1544)
621
+
622
+ ### Fixed
623
+
624
+ - On web, an open toast no longer blocks clicks on the rest of the page at the same height.
625
+ The toast's `SafeAreaView` and the `Toast` layout wrapper span the full width (up to 900px)
626
+ around the visible toast and swallowed clicks there. Both now use `pointerEvents: "box-none"`,
627
+ so only the visible toast is interactive.
628
+ - `Tooltip` no longer shows up in the top-left corner and blocks the screen when its trigger measures as zero-sized or off-screen. When the trigger measurement is invalid or the ref cannot be measured, the tooltip hides and logs `console.error`. Measurements left over from an earlier show are ignored. (#1490)
629
+ - `Tooltip` on web no longer mounts a full-screen layer over the app while it is open.
630
+ The bubble now renders into `document.body` with `position: fixed` instead of through
631
+ the `PortalHost` layer, so nothing covers the page and the content below stays
632
+ clickable. Web tooltips also use `zIndex: 9999`, matching the body-level overlays
633
+ (`Modal`, `DropdownPanel`), so tooltips inside a modal show on top of it. Native keeps
634
+ the `PortalHost` path.
635
+
636
+ ## [57.12.0] - 2026-10-07
637
+
638
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.12.0.md`](mcp-server/src/docs/upgrades/57.12.0.md).
639
+
640
+ ### Added
641
+
642
+ - `@terreno/ai` harness: `harness.createTask(def, input, {trace: {scope, tags}, prompts})` writes a scope (tenant, workspace), tags, and prompt versions onto the task's `ObsTrace`; child tasks and subagents share it. `ObsTrace` gains `scope` (indexed with `created`) and `tags`, and the admin trace list filters by `scope`. `rt.runAgent` takes `prompts` and accepts a registry `PromptVersionRef` as `instructions` (its `body` runs, its version is recorded). New subpath exports replace `dist/` deep imports: `@terreno/ai/admin`, `@terreno/ai/harness/{agentLoop,commit,events,internalRuntime}`, and `@terreno/ai/observability/{observabilityApp,localPlugin,promptStore}`. `getProjectModel()` returns the chat `Project` model, and `new AiApp({projects: false})` skips the `/gpt/projects` routes so an app can own a model named `Project`. See [AI harness reference](docs/reference/ai-harness.md#trace-scope-and-prompt-versions). Closes #1533.
643
+
644
+ ### Changed
645
+
646
+ - Harness task HTTP responses and the task OpenAPI schema omit `lease`. The fencing token stays on the server. Approve, reject, submit, and abort invalidate the same cache tags as the task, approval, and conversation lists (`harnesstasks`, `harnessapprovals`, `harnessconversations`).
647
+ - Importing `@terreno/ai` no longer registers the mongoose `Project` model. The `Project` export is a lazy proxy and is deprecated: the first use of `Project` or `getProjectModel()` registers the model. Statics, `new Project(...)`, and `instanceof Project` still work. The proxy is not the model (`Project !== mongoose.models.Project`, `Object.keys(Project)` is empty, and reading `Project.prototype` throws). Use `getProjectModel()`.
648
+
649
+ ### Fixed
650
+
651
+ - Web `SplitPage` desktop child columns scroll vertically again. The corner radius and clip sit on the pane, so `overflow: hidden` no longer blocks the column `ScrollView`. A child with `height: "100%"` stays within the visible pane and can scroll internally. Narrow web pager pages use the measured pane height, so that same full-height child can scroll internally while the page still clips its rounded corners. Labeled pager previous and next controls sit in fixed slots in a row under the column, and the column uses the height left after that row and its spacing. `desktopChildrenMinWidth` horizontal scrolling, the list column, and native `SplitPage` are unchanged.
652
+
653
+ ## [57.11.0] - 2026-10-05
654
+
655
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.11.0.md`](mcp-server/src/docs/upgrades/57.11.0.md).
656
+
657
+ ### Added
658
+
659
+ - `@terreno/ai/harness/jobsRunner`: `JobsRunner` runs durable harness phases as `@terreno/jobs` jobs (`terreno.harness.phase`) on every instance, for multi-instance deploys such as Cloud Run. The task lease decides who commits, so duplicate or late jobs are harmless. `@terreno/jobs` is an optional peer; importing `@terreno/ai` never loads it. Adds `HarnessTask.claims` (default 0, no migration). See [Run the harness on multiple instances](docs/how-to/run-harness-on-multiple-instances.md).
660
+ - `@terreno/api` can manage one Slack workspace with `SLACK_BOT_TOKEN`: `createSlackPrivateChannel` creates a private channel, invites member ids, and posts an optional first message; `inviteSlackUsersToChannel` adds staff to a channel the bot already belongs to; `postSlackMessage` posts as the bot; `findSlackChannelByName` resolves a visible channel name to its id.
661
+ - `@terreno/syncdb` query windows: `queryCollections` config, `client.queryWindows`, and the `useWindowQuery` hook (plus generated `use{Collection}Window` hooks) run server-filtered, paged list queries (`where`, `sort`, `pageSize`, `fetchNextPage`, `refetch`, `hasMore`, `total`) over a shared, deduplicated entity table. Overlapping windows store each row once and update it once; each window lists only its own members. `fullSync: true` adds windows to a collection that still snapshot-syncs.
662
+ - `useQuery` / `useEntityIds` accept the same Mongo-style `where`, string sort specs, and `limit` for local queries.
663
+ - Window refetches learn the route's `maxLimit` and pipeline page requests with a `console.warn` instead of silently truncating.
664
+ - `modelRouter` list requests that filter on a field missing from `queryFields` now return a 400 naming the field, model, and allowed fields (`meta.allowedQueryFields`), checked before query validation so the filter can never be silently stripped. syncdb raises it as `QueryFieldNotAllowedError`.
665
+ - `terreno-prd` interviews the app owner and writes a product PRD with measurable success metrics before Grow. Invoke `/terreno-prd` in Cursor, `/terreno:prd` in Claude Code, or `$terreno-prd` in Codex.
666
+
667
+ ### Changed
668
+
669
+ - `@terreno/ai/harness`: `InProcessRunner` now runs up to 8 claimed tasks at once (new `concurrency` option, default 8), each under its own task lease, and `stop()` waits for all of them. Expect parallel model calls (watch provider rate limits and cost) and more stream-counter transaction retries. Turns of one conversation stay serial. Pass `new InProcessRunner({concurrency: 1})` to keep the previous one-at-a-time behavior. See [AI harness reference → InProcessRunner](docs/reference/ai-harness.md#inprocessrunner).
670
+
671
+ ### Fixed
672
+
673
+ - Outline `Button`s match solid buttons in width and height. The border width is subtracted from horizontal padding as well as vertical, at the default and `sm` sizes.
674
+ - `@terreno/ui` date and table components import `FontAwesome6` from `@expo/vector-icons/FontAwesome6`, so the named export resolves on iOS. The public component API is unchanged.
675
+ - Web `SplitPage` navigation IconButtons use the default variant, and detail child columns get a default `md` corner radius with `overflow: hidden`. Pass `childColumnRounding` to override that radius. The list column is unchanged. Native `SplitPage` is unchanged.
676
+
677
+ ## [57.10.1] - 2026-10-04
678
+
679
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.10.1.md`](mcp-server/src/docs/upgrades/57.10.1.md).
680
+
681
+ ### Breaking
682
+
683
+ - Security: `GET /files/*gcsKey` (`addFileRoutes`, `AiApp` with `gcsBucket`) now requires authentication and returns a signed URL only for the caller's own upload. Before, anyone could get a signed URL for any stored key, and keys are guessable (`uploads/<userId>/<ms>-<name>`). Another user's file, including for an admin, returns 404 like a missing file, so keys cannot be probed. Send the user's session token with the request; an unauthenticated request now returns 401.
684
+
685
+ ### Added
686
+
687
+ - Agent UI Asks: the chat agent can pause a turn and ask the user a typed question, then continue with the exact answer. Turn it on with `asks: true` (or `{kinds, approvals, maxFileSizeBytes}`) on `AiApp` or `addGptRoutes`. Asks are off by default: with `asks` unset, tools, the system prompt, SSE events, and endpoints are unchanged. See `docs/how-to/agent-ui-asks.md`, `docs/reference/agent-ui-asks.md`, and `docs/explanation/agent-ui-asks.md`.
688
+ - New package `@terreno/blocks`: the shared Zod contracts for asks (input and answer schemas, `validateAskInput`, `validateAskResponse`, stable error codes, `ASK_LIMITS`), simple cards (`toSimpleCard`, `resolveButtonAnswer`), the compact surface, the headless endpoint bodies, and JSON Schema documents plus fixtures (`@terreno/blocks/schemas/*`, `@terreno/blocks/fixtures/*`) for native clients such as a Swift watch app.
689
+ - Five ask kinds, each a client-side `ask_<kind>` tool: `choice` (select one, or select many with an optional Other text answer), `confirm`, `markdown` (edit a draft), `form` (a few typed fields, one submit), and `files` (upload images or documents the model reads in the same turn, as data URLs or `fileId`s from `POST /files/upload`).
690
+ - Server-enforced approval: with asks on, a host tool marked `needsApproval: true` runs only after the user approves a server-made `confirm` ask. `asks.approvals` sets that ask's text per tool name. With asks off, such a tool never runs.
691
+ - `/gpt/prompt` accepts `askResponse` and `surface: "full" | "compact"`, and streams `{ask}` and `{askResolved}` events plus `pendingAsk` on `{done}`. `GptHistory` stores the pending ask. With `chat` options that turn asks on, `addGptHistoryRoutes` (and `AiApp`) adds `GET /gpt/histories/pendingAsks` and `POST /gpt/histories/:id/turn`, which run a turn to completion and return JSON for clients that do not read server-sent events.
692
+ - `@terreno/ui`: `GPTChat` shows asks as an `AskCard` in the transcript (`GPTChatMessage.ask`, `onAskSubmit`, `askErrors`, `resolveAskFiles`). New `AskCard` and `SimpleAskCard` components. `Button` has a new `wrapText` prop that wraps a long label onto centered lines instead of overflowing its container.
693
+ - The example app's AI screen answers asks end to end, and the example backend runs a scripted keyless demo agent (`terreno-demo-agent`) when no model is configured, so asks can be tried without an API key.
694
+ - Agent UI blocks: a chat reply can be one YAML document of headings, metrics, charts, tables, and actions. Turn it on with `uiBlocks` on `addGptRoutes`. The model is told its whole reply is that document. After the text, the route checks it and sends `{blocks}` before `{done}`. `repair: true` runs one repair call. See `docs/how-to/agent-ui-blocks.md`, `docs/reference/blocks.md`, and `docs/explanation/agent-ui-blocks.md`.
695
+ - `@terreno/blocks` adds `parseBlocks`, `validateBlocks`, `blocksJsonSchema`, and `terreno-blocks validate`. Charts and tables read inline rows or a `ref` dataset. `AIService.generateBlocks` returns one validated document at temperature 0.
696
+ - `@terreno/ai` stores chart rows on `AIDataset` via `registerAiDataset`. `GET /gpt/datasets/:id` is owner-only and can bucket, downsample, or paginate them. `POST /gpt/actions` runs a named host callback after an owner check and a payload check.
697
+ - `@terreno/ui` `BlocksView` paints a document. `GPTChat` `uiBlocks` renders assistant messages through that view, including a spinner while the reply is still streaming.
698
+ - The example backend registers `exportDataset` and a `todoStats` tool that stores open and completed todo counts. The example AI tab loads `ref` charts and posts callbacks.
699
+ - `html` blocks are opt-in (`uiBlocks.html` on the server, `allowHtml` on `GPTChat` or `BlocksView`). The server strips scripts, event handlers, forms, frames, and links, then stores the cleaned document. The client draws it in a sandboxed frame only after the reply finishes.
700
+ - `callout`, `image`, and `details` render as a banner, an image, and an accordion. An `https` image loads only when its host is in `uiBlocks.imageHosts`.
701
+ - A reply that omits an actions block `id` gets one before it is stored. A block document written in the same step as a tool call is kept when the turn would otherwise have no text. The chat receives the document after that fill, repair, and HTML sanitizing, so an invalid draft is not shown. `{replace: "text"}` is sent only when text was already streamed and then changed. A validation banner names the field path once the reply has finished.
702
+ - Example-backend registers `accessControl: access` on `ObservabilityApp` and seeds optional `aiObservabilityViewer` and `aiObservabilityOperator` RBAC roles that demonstrate read-only and full observability access without auto-assigning them to users.
703
+ - `@terreno/ai` ships local prompt versions, nested traces, evaluators, datasets, experiments, and a review queue through `ObservabilityApp`, with the admin screens in `@terreno/admin-frontend`. Prompts, datasets, and experiments have one writer. Traces and scores fan out to every registered sink (local Mongo, Langfuse, or OTLP). Six RBAC resources cover read-only, reviewer, and operator access. See `docs/how-to/observe-llm-calls.md`.
704
+ - `@terreno/ai/harness` runs phased tasks on Mongo that survive a process crash. Each checkpoint and its trace span commit in one transaction. Tasks can wait for a person, retry, abort from the bottom up, and stay pinned to the definition version they started with. `defineTask`, `defineAgent`, and `defineTool` describe the work; approvals have their own permissions and an admin inbox. See `docs/how-to/build-a-durable-workflow.md`.
705
+
706
+ ### Fixed
707
+
708
+ - `GET` and `DELETE /files/*gcsKey` find uploads by their full key. Express 5 passes the wildcard as path segments, so a key with slashes, such as the `uploads/<userId>/<ms>-<name>` key every upload gets, returned 404.
709
+
710
+ ## [57.9.0] - 2026-10-03
711
+
712
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.9.0.md`](mcp-server/src/docs/upgrades/57.9.0.md).
713
+
714
+ ### Added
715
+
716
+ - `@terreno/ui` can compose operations dashboards with scorecards, comparison sparklines, multi-series line and area charts, donut center labels, previous-period bar overlays, chart-card headers, and spanning dashboard items.
717
+ - File uploads can be turned off with the `file-uploads` feature flag. `POST /files/upload`, document storage uploads, and chat attachments return 403 when the flag is off. The example app hides the upload controls. A missing flag leaves uploads enabled.
718
+ - `/gpt/prompt` rejects client-only attachment URLs (`blob:`, `file:`, `content:`, `ph:`) with `400` before streaming. With `fileStorageService` configured, `data:` attachments are uploaded and history stores the storage `url` plus `gcsKey`; later turns send the model signed URLs.
719
+ - `/gpt/prompt` saves the turn before streaming, sends a first `{historyId, started: true, streamId}` event, and persists partial reply text about every second with `status: "streaming"`. The finished reply is saved with `status: "complete"`; a failed one keeps its partial text with `status: "error"`.
720
+ - New `GET /gpt/histories/:id/stream` re-attaches to an in-flight reply after a reload or remount (`offset` skips text the client already shows). The example app reopens the last chat after a reload and resumes its live reply.
721
+ - `store-assets` agent skill for Expo apps. It keeps a simulator/emulator dev client in sync with the native fingerprint (downloading a matching EAS build or starting one), captures App Store and Google Play screenshots on store-sized devices with Maestro flows that log in through the `@terreno/ui` LoginScreen, and renders the Play feature graphic and 512×512 icon from a repo's `storeAssets.config.json`. Install with `npx skills add TerrenoLabs/terreno --skill store-assets` or from the `terreno` Claude plugin.
722
+
723
+ ### Changed
724
+
725
+ - `GPTChat` history rows move rename and delete into a three-dot overflow menu. Rename uses an outlined pencil in the dark secondary color; titles truncate before the menu trigger at any sidebar width.
726
+ - `GPTChat` shows "Scroll to bottom" only when content sits below the viewport, never in an empty chat.
727
+ - The `GPTChat` composer grows with long text up to 200px, then scrolls. `TextField` / `TextArea` accept `maxHeight` to cap `grow`.
728
+ - Generated images in `GPTChat` offer copy-image and (web) download. Copying an image-only reply copies the image instead of text.
729
+ - `FilePickerButton` opens an anchored dropdown instead of a modal. On web, **Document** now opens the file picker, and both options return `data:` URLs instead of `blob:` URLs.
730
+
731
+ ### Fixed
732
+
733
+ - Web toasts now render above open modals and remain correctly centered in statically exported apps instead of inheriting a zero-width server-rendered viewport.
734
+
735
+ ## [57.8.0] - 2026-10-02
736
+
737
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.8.0.md`](mcp-server/src/docs/upgrades/57.8.0.md).
738
+
739
+ ### Added
740
+
741
+ - `@terreno/announcements` imports a versioned release pack (`pack.yaml` plus one Markdown file per announcement) through idempotent `POST /announcements/import-release`. Imports default to drafts; pass `publish: true` to publish on import. Re-importing the same product, release version, channel, and slug updates the existing row, and a matching soft-deleted row is restored. Admin auth or `ANNOUNCEMENTS_UPLOAD_TOKEN` can call the route. Request bodies for the import and for impression and click events are strict Zod schemas.
742
+ - `SplitPage` accepts opt-in web layout props. `desktopChildrenMinWidth` keeps one or two desktop children at least that many pixels wide and scrolls horizontally when the row is tighter. `narrowViewportChildLabels`, `narrowViewportSelectionActive`, `narrowViewportSelectionKey`, and `narrowViewportListButtonLabel` replace the dotted narrow swiper with a labeled pager. `narrowBelowWidth` uses the narrow layout at or below that pixel width; omit it to keep `isNarrowViewport()`. Native `SplitPage` ignores these props. `IconButton` accepts `backgroundOpacity` to tint only the button background.
743
+
744
+ ### Changed
745
+
746
+ - CircleCI `publish-release` compiles every lockstep package once and publishes them in parallel instead of recompiling and retesting each package in turn. Tagged commits already passed CI on master. Releases drop from 25+ minutes to about 4.
747
+ - `deploy-demo` runs alongside `publish-release` instead of after it.
748
+ - `scripts/ci/netlify-deploy.sh` passes `--no-build`, so Netlify deploys no longer rerun the root `netlify.toml` docs build (about 3.5 minutes per demo and frontend deploy).
749
+
750
+ ### Deprecated
751
+
752
+ - `isMobileDevice` is deprecated. Call `isNarrowViewport` instead. The old function stays exported and returns the same result until it is removed.
753
+
754
+ ### Fixed
755
+
756
+ - `DropdownPanel` now anchors to its trigger rather than to the row around it. The measured wrapper stretched to fill its container, so the panel opened at the start of that row — visible whenever the trigger is not at the start edge, such as a right-aligned button or a right-to-left layout, where the panel appeared on the opposite side of the screen.
757
+ - `/gpt/prompt` sends each generated image once. Image-output models no longer duplicate the SSE `image` event and saved content part, and image-only responses save empty text instead of the `(image)` placeholder. `GptHistory` prompt `text` is now required only when the prompt has no `content` parts.
758
+
759
+ ## [57.7.0] - 2026-09-28
760
+
761
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.7.0.md`](mcp-server/src/docs/upgrades/57.7.0.md).
762
+
763
+ ### Changed
764
+
765
+ - CircleCI is now the only continuous deployer for the Netlify demo, example frontend, and docs sites and for GCP terraform, Cloud Run backend/tasks, and MCP. The GitHub Actions deploy workflows are disabled for rollback only, and CircleCI deploy jobs fail instead of skipping when `terreno-netlify` or `terreno-gcp` is missing a value. CircleCI deploys record GitHub Deployments (same environment names as before, plus `docs` and `mcp-production`) when the `terreno-github-deployments` context holds `GITHUB_DEPLOYMENTS_TOKEN`.
766
+ - `@terreno/ui`'s `Filter` is now `DropdownPanel`, reflecting that it is a general compositional dropdown (trigger + anchored panel + optional Apply/Clear/Cancel footer), not a filter-only control. `Filter` and `FilterProps` stay exported as deprecated aliases until Terreno 58; the `FilterSelectMenu` / `FilterBoolean` / `FilterAccordion` / `FilterChangesBadge` controls keep their names.
767
+ - `DropdownPanel` no longer runs off screen. The panel right-aligns to its trigger when a left-aligned panel would cross the right viewport edge, clamps to an 8px screen margin, flips above the trigger when there is no usable room below, and scrolls its body (footer pinned) when the content is taller than the space available. `align` and `maxPanelHeight` override the automatic behavior, and `computeDropdownPanelLayout` is exported for callers that position their own panels.
768
+ - `DropdownPanel` renders through the `TerrenoProvider` portal host on native, so the panel is no longer clipped inside scroll views or cards. Without a host it falls back to the previous inline overlay.
769
+ - `DropdownPanel` triggers are now styleable: `triggerVariant` (any `Button` variant), `applyButtonVariant`, `triggerSize` on labeled triggers, `fullWidth`, and `renderTrigger` for a fully custom trigger.
770
+ - `bun run prepush` now runs the local mirror of every CI job the branch triggers, using the CircleCI path-filter mapping. That covers package `test:coverage` plus the new-file LCOV gate, `ui` types, example-backend and example-frontend tests, every `repo-policies` step, rulesync drift, and the typedoc API reference. It runs every step even after a failure and prints a summary. Use `--dry-run` to see the plan and `--all` to run everything.
771
+ - New `bun run check:test-isolation` blocks new `mock.module` calls in shared Bun test suites (see `docs/explanation/test-isolation.md`).
772
+ - The lifecycle plugin now makes one commit per task after Roast `PASS` with no bookkeeping commits, keeps pushes out of Pick and Roast, has Pick list edge cases before coding, has Roast run the CI-equivalent package gate, and has Taste merge `master` only when the PR conflicts, a failure traces to base drift, or the branch is merge-ready.
773
+ - Taste invoked directly by a human now repeats bounded reactions (at most 3 fix pushes and 3 hours of waiting) until `PASS`, `BLOCKED`, or `FAIL` instead of returning `PENDING`, and a directly invoked Brew starts Taste as its next stage.
774
+
775
+ ## [57.6.1] - 2026-09-25
776
+
777
+ ### Fixed
778
+
779
+ - `modelRouter` array subroutes now return a structured `400` error when the requested field is not an array, after preserving consumer update authorization hooks, instead of throwing an iterable `TypeError`.
780
+
781
+ ## [57.6.0] - 2026-09-24
782
+
783
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.6.0.md`](mcp-server/src/docs/upgrades/57.6.0.md).
784
+
785
+ ### Added
786
+
787
+ - `sendToSlack` accepts `mentionUserIds` (Slack member IDs) so incoming webhooks can @-mention people. Names and emails in the message text do not notify anyone. New helpers: `formatSlackUserMention`, `normalizeSlackUserId`, and `lookupSlackUserIdByEmail` (optional `SLACK_BOT_TOKEN` with `users:read.email` to resolve an id from email and store it on the staff/user record).
788
+ - Add full SyncDB support to `terreno-mcp-local`: inspect debugger and local-store
789
+ state, capture/compare/merge snapshots, mutate or directly edit local entities,
790
+ flush the outbox, reconcile/resync, resolve conflicts, retry failures, and
791
+ exercise offline transitions. State-changing operations require
792
+ `TERRENO_MCP_EVAL=1`; returned state redacts sensitive fields.
793
+ - Add `@terreno/cli` (`terreno`) for docs search, codegen (syncdb, RTK SDK, models, routes, screens, forms, admin), bootstrap, unified backend/browser/Metro/app logs, Redux/RTK state inspection, opt-in CDP evaluation/navigation, Bun 1.4 WebView automation and screenshot proof, read-only database tools, OpenAPI `api list|call|request`, and `generate rest-cli` to scaffold an app CLI from a backend spec.
794
+
795
+ ### Changed
796
+
797
+ - Terreno moved to the `TerrenoLabs` GitHub organization. The repository is now `TerrenoLabs/terreno`, the roadmap board is `github.com/orgs/TerrenoLabs/projects/1`, and package `repository` URLs, docs links, and plugin/skill install commands (`/plugin marketplace add TerrenoLabs/terreno`, `npx skills add TerrenoLabs/terreno`) point at the new location. Old `FlourishHealth/terreno` URLs redirect.
798
+ - Every tsconfig builds on TypeScript 6 without `ignoreDeprecations`. Node packages (`@terreno/api`, `ai`, `admin-backend`, `announcements`, `api-health`, `comms`, `feature-flags`, `jobs`, `test`, and the `admin-spa` server plugin) now compile with `module: nodenext` to ES2023 CommonJS instead of ES5. Their dynamic `import()` stays a real ESM import, so `loadMigrations` can load migration files by `file://` URL from compiled `dist/`. Frontend libraries (`ui`, `admin-frontend`, `rtk`, `syncdb`) emit ES2022 with bundler resolution and ship declaration maps. `@terreno/test` no longer publishes its compiled Bun tests. `create-terreno-app` scaffolds drop `ignoreDeprecations` and `moduleResolution: node`, and the generated backend moves to TypeScript 6. See `docs/explanation/typescript-configuration.md`.
799
+
800
+ ### Fixed
801
+
802
+ - Framework Mongoose models added in 57.3 and 57.4 now use `Terreno*` model names
803
+ without changing their MongoDB collections, preventing collisions with consumer
804
+ models such as `Notification`, `Membership`, and `Job`. Admin UI keys and
805
+ `admin<ModelName>` RBAC resources keep the pre-namespace names. JWT recovery
806
+ routes can be disabled with `authOptions.passwordReset`, `emailVerification`,
807
+ and `legacyResetPasswordRoute`.
808
+
809
+ ## [57.4.0] - 2026-09-22
810
+
811
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.4.0.md`](mcp-server/src/docs/upgrades/57.4.0.md).
812
+
813
+ ### Added
814
+
815
+ - CircleCI Playwright now shards `admin-home`, `admin-form`, `admin-table-search-filter`, `admin-table-bulk-actions`, `admin-custom-screens`, and `admin-comms-back` alongside `admin`.
816
+ - CircleCI now runs `admin-backend-ci` and `admin-frontend-ci` (lint, compile, 95% coverage) when those packages change. Use `bun run admin-frontend:test` and `bun run admin-backend:test` from the repo root.
817
+ - `@terreno/admin-frontend` now ships `adminRequest`, a native `fetch` helper (timeout, JSON/`FormData`, credentials) for admin RPC that is leaving RTK `injectEndpoints`.
818
+ - Example admin Todos now allow admin delete, and Playwright covers Todo create → edit → delete on embedded `/admin` and SPA `/console`.
819
+ - Built-in admin String-`_id` model CRUD can now run windowed local-first through
820
+ `@terreno/syncdb`: REST supplies membership, TinyBase supplies live rows,
821
+ create/update/delete use the durable outbox, bulk patch stays server-side, and
822
+ `ConflictSheet` resolves admin-loaded records. The embedded example and
823
+ standalone admin SPA demonstrate Bearer and same-origin cookie hosts.
824
+ - `GET /admin/config` model meta includes `adminBroadcast` (always) and `syncCollection` when the app sync registry has `adminBroadcast: true`. `AdminProvider` accepts optional `syncDb`. `AdminModelTable` then uses REST membership + a live TinyBase overlay with a visible Refresh control; hosts that pass only `api` keep the RTK list. Refresh treats RTK `refetch` error envelopes as failures, drops in-flight Refresh when list params change, and toasts rejected `hydrateWindow` calls. Fetch-RPC mutations invalidate mounted comms, scripts, and configuration queries just like their RTK counterparts.
825
+ - `BetterAuthConfig.disableRateLimit` turns off Better Auth's built-in limiter on a throwaway in-process seed instance. Preview container seeds that create several users no longer 429 on the fourth signup when `NODE_ENV=production`.
826
+ - `@terreno/ui` now ships owned-SVG `LineChart`, `BarChart`, `AreaChart`, and `DonutChart`
827
+ plus an eager `DashboardGrid` for wrapping caller `Card`s. Charts use `react-native-svg`
828
+ and private d3 helpers — not `victory-native`. Each chart sizes itself to its container
829
+ and clips to it, so a chart never widens the card it sits in, and `height` covers the whole
830
+ chart including its tick and tooltip rows. `bun run ui:charts:compare` diffs actually
831
+ rendered gallery PNGs against `demo/rendered-snapshots/` (not JSON snapshots).
832
+ Conditional `DashboardGrid` children no longer reserve blank cells, and an open chart
833
+ tooltip now updates or closes when live data changes.
834
+ - `create-terreno-app` — unscoped npm CLI (`bunx create-terreno-app` /
835
+ `npm create terreno-app`) that scaffolds a deployable full-stack Terreno app.
836
+ Published in lockstep with `@terreno/*`; generated scaffolds pin
837
+ `^<create-terreno-app version>` on Terreno dependencies.
838
+ - **@terreno/ui `DataTable`**: optional server-side search and column filters emit
839
+ modelRouter-shaped query params via `onQueryChange` and `buildDataTableListQuery`
840
+ (web per-column `Filter` popovers; native Filters sheet).
841
+ - **@terreno/admin-frontend `AdminModelTable`**: adopts DataTable filter/search UI;
842
+ list search still uses `q`; choice filters support multi-value `$in`. Admin list
843
+ URLs use `qs` bracket serialization so nested filters work with any RTK base query.
844
+ - **@terreno/admin-backend**: `parseAdminListFilters` accepts choice `{$in: string[]}`
845
+ and escaped-literal text `{$regex, $options: "i"}` while rejecting extra operators.
846
+ Optional choice filters auto-enable an **Empty** option (`__empty__` wire sentinel → Mongo
847
+ `null`) when the Mongoose field is not required; explicit `allowEmpty` overrides.
848
+ Empty and Empty-plus-value filters remain normalized when combined with toolbar search.
849
+ - **@terreno/api**: OpenAPI query validation accepts choice scalars, `{$in: [...]}`, and
850
+ the empty sentinel alongside enum values. Nested query operators reject extra keys and
851
+ executable regex patterns; `queryFilter` can remove consumed wire keys with `undefined`.
852
+ - **@terreno/ui `buildDataTableListQuery`**: single concrete choice values emit scalar
853
+ equality (validator-friendly); empty-only and empty+concrete use `$in` with `__empty__`.
854
+ - **@terreno/ui `Filter`**: new `iconOnly` and `triggerSize` props render a compact
855
+ icon trigger for dense chrome; DataTable column headers use it.
856
+ - **@terreno/ui `DataTable`**: single-column filter popovers no longer duplicate the
857
+ popover's own Clear with a per-field **Clear filter**; the boolean per-field clear
858
+ now appears only where one surface hosts several filters.
859
+ - **@terreno/ui `DataTable`**: date range filters use date inputs, so entering a
860
+ calendar date filters immediately and either bound works on its own. Previously the
861
+ datetime input emitted nothing until an hour and minute were also entered. The **to**
862
+ bound now closes the chosen UTC day instead of landing on midnight, so rows recorded
863
+ later that day stay in range.
864
+ - Opt-in append-only `AuditEvent` log. Register `AuditApp` and set `audit: true` (or
865
+ `{redact: ["ssn"]}`) on `modelRouter` so successful HTTP CRUD and array mutations
866
+ persist redacted changed-field diffs. Default redaction includes compound keys such as
867
+ `tokenHash`. AdminApp writes the same collection when the plugin is registered and omits
868
+ each model's `hiddenFields` / `excludeFields`; RBAC uses
869
+ `persistRbacAuditToAuditEvent`. List/read is admin-only at `GET /audit-events` and
870
+ `/admin/audit-events`; create/update/delete over HTTP return 405. Persist is
871
+ fire-and-forget; a non-24-hex `actorId` (including 12-character strings mongoose would
872
+ accept) is dropped from the event instead of dropping the event. Update/delete snapshots
873
+ run only when `audit` is on, and a throwing `toJSON` does not fail the mutation. Set `GCP_TASKS_AUDIT_QUEUE` and `AUDIT_TASKS_URL` to enqueue Cloud Tasks
874
+ instead of writing Mongo on the request. Default retention is forever; `retentionDays`
875
+ adds a Mongo TTL index on `created`. See `docs/how-to/audit-log.md`.
876
+ - # GCP durable-jobs worker infrastructure
877
+
878
+ Infra Manager now owns the example Cloud Tasks queue `terreno-example-jobs-v2`, the
879
+ private `terreno-backend-example-tasks` Cloud Run service, the
880
+ `terreno-backend-runtime` API identity (sole queue enqueuer / jobs-invoker
881
+ `actAs`), and the `terreno-jobs-invoker` OIDC callback identity. CD deploys
882
+ matching PR tags for the API and tasks services. The example API still uses
883
+ `MongoJobRunner` until a follow-up selects `JOBS_RUNNER=gcp-cloud-tasks`. The
884
+ example image compiles `@terreno/jobs` before `@terreno/admin-backend`.
885
+ - `GPTChat` accepts an optional `mascot` React node from the consumer. Terreno does not
886
+ ship a default character. The node renders on an empty chat and hides once messages
887
+ exist. The empty state (mascot plus suggested prompts) stays centered in the chat
888
+ panel inside a scrollable message area, and the composer row centers the
889
+ attachment, tools, and Send controls vertically against the input. The example
890
+ consumer bundles four plant-robot mascots and chooses one on each AI screen mount.
891
+ - Unattended agents can claim one unstarted GitHub issue labeled
892
+ `status:ready-for-dev`, Pick ⇄ Roast a posted plan, and Brew a draft PR via the
893
+ `implement-ready-for-dev` skill. Pickup skips open linked PRs via GraphQL (not
894
+ `linked:<number>`) and aborts if an untrusted author edits the issue body after
895
+ the label. Dashboard paste lives in that skill's `references/cursor-automation.md`.
896
+ Interactive `/work-github-issues` still requires chat confirmation.
897
+ - `@terreno/jobs` adds durable background work for Terreno backends: MongoDB-persisted jobs
898
+ with retries, dead-lettering, cron schedules (IANA timezones), admin list/detail/retry/requeue/cancel,
899
+ and pluggable runners (`MongoJobRunner`, GCP Cloud Tasks, Vercel Queues, custom). Workers start
900
+ only via explicit `startWorker()` — register `JobsApp` on `TerrenoApp`, define handlers, enqueue
901
+ with `getJobsService()`, and choose in-process or standalone worker processes. Admin UI widgets
902
+ ship in `@terreno/admin-frontend`. See `docs/how-to/background-jobs.md` and
903
+ `docs/reference/jobs.md`.
904
+ - MongoDB migrations tooling: `terreno-migrate` (`check` / `generate` / `status` / `up` / `down`), `TerrenoApp` `migrations.runOnStart`, admin **Migrations** page, and `example-backend/migrations/`. Admin HTTP is `modelRouter` collection actions `GET /admin/migrations/status` and `POST /admin/migrations/run` (`{data: ...}`), documented in `/openapi.json` under the `adminMigrations` tag. Production wet apply requires `ALLOW_MIGRATIONS=true`. History lives in `terreno_migrations` and is not wiped by seed `--reset`.
905
+ - The in-app notification center adds `NotificationsApp` in `@terreno/api` with
906
+ owner-scoped `Notification` and `NotificationPreference`
907
+ models, `getNotificationService().notify()`, `notificationsBeforeSend`, mark-all-read, and
908
+ optional `retainDays` tombstone sweep. `@terreno/ui` provides presentational
909
+ `NotificationBell`, `NotificationInbox`, and `NotificationPreferences` components.
910
+ The example app demonstrates syncdb collections, a todos-header bell that toggles a
911
+ right-side drawer, a full active and archived history page, notification preferences,
912
+ seeded notification examples, todo activity notifications, and a development notify
913
+ route. Inbox list, mark-read, and dismiss (archive via `archivedAt`) use syncdb only;
914
+ `POST /notifications/dev-notify` remains the development send path. `terreno-syncdb-codegen` now accepts hyphenated collection names such as
915
+ `notification-preferences`. Consumers can replace the bell icon and unread bubble with
916
+ `NotificationBell` render props while retaining its layout and accessibility behavior.
917
+ - Optional organization management: `OrgsApp` adds `Organization` / `Membership`, org context, isolation filters, and membership-scoped `org-admin` plus operator RBAC. Existing single-tenant apps stay unchanged. Admin hosts get an operator directory, org switcher, settings, and members screens. Newly bootstrapped apps enable an org by default.
918
+ - `terreno-pick-roast-loop` drives an approved plan through all actionable Pick/Roast
919
+ cycles without stopping on ordinary engineering failures. It accumulates task, attempt,
920
+ verification, artifact, docs, and risk evidence into one final report. When a genuine
921
+ human decision is required, it first explains the overall plan state, completed work,
922
+ decisive evidence, options and impact, and its recommendation, then asks one exact
923
+ question. The loop excludes Grow, Brew, Taste, and product-CI monitoring. Cursor,
924
+ Codex, Claude Code, and `npx skills` expose the loop.
925
+ - `@terreno/announcements` ships `AnnouncementsApp` for admin-managed modal, banner, and feed product updates. Targeting covers staff, patients, and all users, plus custom `audience` segments. Acknowledgements, impressions, frequency caps, minimum-build gates, and CTA click tracking are first-class. Admin screens include a guided overview with aggregate and per-row analytics. Frontend surfaces are `AnnouncementNavigator`, `AnnouncementBanner`, and `AnnouncementScreen` in `@terreno/ui`. See `docs/how-to/product-announcements.md`.
926
+ - CircleCI `publish-release` and the GitHub `publish-on-tag.yml` fallback now publish `@terreno/announcements` in lockstep with the other `@terreno/*` packages.
927
+ - `SyncConfig.adminBroadcast` (default `false`) is stored on collection registration. When `true`, change-stream `sync:delta` emission also targets `{collection}|admin`. Admin clients join that stream with `sync:subscribe {mode: "window"}` (`createSyncDb({windowCollections})`) and skip `GET /sync/snapshot` paging for those collections. Window subscribe requires admin panel and model-list access. `hydrateWindow` renders REST rows immediately, then refreshes canonical seq/deleted metadata for every requested id through `GET /sync/entities` (unknown ids ignored). AdminApp list/read permissions and `queryFilter` apply to hydrate and live deltas. `{collection}|admin` deltas apply only to ids already in the local window; Refresh is REST + `hydrateWindow`.
928
+ - Admin-window sync writes use an explicit `mutationMode: "adminWindow"` marker on `POST /sync/mutate`, `POST /sync/mutate/batch`, `sync:mutate`, and `sync:mutateBatch`. `createSyncDb({windowCollections})` tags matching outbox rows automatically. The server validates the marker together with `adminBroadcast`, admin-window access, and a registered AdminApp write scope, then runs the shared sync executor with AdminApp pre/post hooks (not product `modelRouter` hooks), applying the same permission, stripping, User admin-flag/role gates, and audit semantics as REST. Product clients without the marker keep existing product sync permissions and hooks.
929
+ - `@terreno/syncdb` exports `bridgeBetterAuthReactClient`, which adapts a Better Auth
930
+ **react** client to the session-subscription surface `betterAuthAdapter` watches. Without
931
+ it the adapter cannot see the client's nanostore session atom and falls back to polling
932
+ `getSession()`. The example app and the admin SPA both use it.
933
+ - `TextField` with `type="password"` now renders a show/hide eye control so users can check what
934
+ they typed. The value starts masked, a disabled field cannot be revealed, and
935
+ `showVisibilityToggle={false}` removes the control. The toggle is reachable in tests at
936
+ `{testID}.visibility-toggle` (override with `testIDs.visibilityToggle`). `Field`, `LoginScreen`,
937
+ and `SignUpScreen` password fields inherit it.
938
+ - Dependency updates go through the daily `update-dependencies` skill: one trusted,
939
+ same-repository rolling PR with a landed/failed ledger, a test that imports each
940
+ bumped package, and a freeze on Expo fingerprint changes until a release.
941
+
942
+ ### Changed
943
+
944
+ - `AdminProvider` accepts host-injected `credentials` and `getAuthHeaders` for native `adminRequest`. The admin SPA uses cookie `same-origin` credentials; the example app sends a Bearer token.
945
+ - Admin RPC hooks dual-run: hosts that inject `credentials` / `getAuthHeaders` on `AdminProvider` use native `adminRequest`; hosts that pass only `api` keep RTK `injectEndpoints`.
946
+ - Admin panel HTTP script runs enqueue durable job `admin/script` when `JobsApp` is registered.
947
+ The Scripts UI still polls `BackgroundTask`. If runner enqueue throws, the HTTP handler
948
+ marks that `BackgroundTask` failed and returns 500. CLI script runs stay in-process. See
949
+ `docs/how-to/background-jobs.md`.
950
+ - CircleCI starts fewer concurrent jobs: repository policies share one `repo-policies` check, Playwright runs five shards instead of one container per spec, and path filters no longer start example-backend / new-file-coverage for unrelated UI, RTK, or e2e-spec-only changes. `repo-policies` uses Node 22.14 for Knip. Require `repo-policies`; treat `e2e-*` shard names as path-filtered (config-only PRs post `e2e-auth`).
951
+ - CircleCI uses smaller Docker classes for Playwright e2e shards, skips empty
952
+ Netlify/GCP contexts before `bun install`, and turns off Docker Layer Caching.
953
+ - The `terreno-planning` plugin now includes reusable Terreno backend/API, UI, data,
954
+ schema, SDK, admin, prompt-governance, documentation, upgrade, deployment, and UI
955
+ verification skills alongside the lifecycle. It also ships `pre-commit` and
956
+ `ui-verifier` agents. The redundant `commit` and `create-pr` skills are removed in
957
+ favor of Brew. Conflicting or unused Expo skills (`native-data-fetching`,
958
+ `building-native-ui`, `expo-ui`, App Clip, brownfield, Observe, Tailwind setup,
959
+ EAS update insights, and Expo module authoring) are no longer distributed. Taste's
960
+ pre-push gate runs lint, typecheck, and affected tests for every supported host.
961
+ Rulesync now also generates native stop hooks for Cursor, Claude Code, GitHub Copilot,
962
+ and Devin that run repository lint and typecheck, emit each host's blocking JSON
963
+ protocol, and avoid repeated checks on structured Stop-hook retries. Plugin
964
+ `terreno-planning` is `2.8.0`.
965
+ - CircleCI Playwright shards now run only when a changed file can reach them. `e2e-prepare` resolves the affected shards with `bun run check:e2e-affected` (import graph with barrel, lazy-registry, and lockfile resolution) and each shard halts before `bun install` when it is unaffected. The gate fails open on anything it cannot resolve. See [circleci.md](../../docs/how-to/circleci.md#e2e-affected-gate).
966
+ - The deployed example backend selects `JOBS_RUNNER=gcp-cloud-tasks`, verifies Cloud
967
+ Tasks OIDC on `POST /jobs/execute`, and points each PR preview at its matching
968
+ tasks-service tag. GitHub Actions and CircleCI both deploy that tag, and production
969
+ deploys overwrite env vars so preview `MONGO_DB_NAME` / `PR_NUMBER` cannot stick.
970
+ The API
971
+ process starts the schedule ticker; the tasks service does not. The compiled Cloud Run binary enqueues through the Cloud Tasks REST
972
+ API (`google-auth-library`) because `@google-cloud/tasks` cannot load its JSON
973
+ config from a `bun build --compile` image. Enqueued HTTP tasks set
974
+ `dispatchDeadline` to protobuf `{seconds: 1800}` (REST JSON `"1800s"`) so Cloud
975
+ Tasks does not retry a still-running 30-minute handler at the 10-minute HTTP
976
+ default. The REST client encodes that Duration; the optional `@google-cloud/tasks`
977
+ peer receives the protobuf object. Queue and worker service resources live in Infra
978
+ Manager.
979
+ - Grow now ends with a standalone approval brief instead of a 15-line index. It opens
980
+ with a paragraph on where the repository is and where the change takes it, adds
981
+ background on current state when a reviewer needs it, then lays out the idea and the
982
+ plan (tasks, tracer, verification, out of scope, risks). The Decisions table follows
983
+ the plan and now pairs every settled human decision with the question that prompted
984
+ it, so a reviewer can see what was asked as well as what was chosen; it is still
985
+ omitted when grilling settled none. A reviewer should be able to approve from the
986
+ brief alone, without opening the IP, the ticket, or the interview history.
987
+ - `implement-ready-for-dev` now Grow-shapes the claimed issue without a chat pause
988
+ (assume answers unless a genuine human gate), then Pick ⇄ Roast, Brew, and Taste
989
+ to a mergeable PR. Dashboard paste is in that skill's `references/cursor-automation.md`.
990
+ There is no separate `autobot-ready-for-dev` skill.
991
+ - `new-file-coverage` no longer reruns a package's full test suite when that package's CI already produced LCOV. The 90% new-file gate runs against the package report instead. Remaining reruns use colocated tests, compile `@terreno/*` dist deps only when imported, and coverage-script unit tests run in a separate `coverage-scripts` job.
992
+ - Planning Brew titles are `[ticket] Short feature title`. The ticket uses the attached
993
+ Linear id (`[FH-1632]`) or GitHub issue (`[#412]`); the rest names the feature only.
994
+ Titles must not use `feat:` / `docs:` prefixes or lifecycle labels such as
995
+ `IP Approved` and `Task list`.
996
+
997
+ PR bodies now preserve the IP's initial justification and a brief overview of its
998
+ intended outcomes. Verification always includes reproducible testing instructions;
999
+ Brew updates that section as testing changes while keeping the rationale and overview
1000
+ stable.
1001
+ - Planning stages and outer loops now close every wait-for-human or done chat with the
1002
+ current PR's GitHub Deployment demo URLs as the last visible section when those URLs
1003
+ exist. Plugin `terreno-planning` is `2.11.0`.
1004
+ - Removed the `terreno_bootstrap_app` MCP tool. The only supported scaffold path
1005
+ is `bunx create-terreno-app` / `npm create terreno-app`. MCP still offers
1006
+ `terreno_bootstrap_ai_rules` for editor rules after the CLI runs.
1007
+ - Roast no longer asks two unconstrained subagents to each rediscover the repository
1008
+ (full-branch diff plus skill catalog). Pick, Roast, and Brew pass a task-scoped
1009
+ briefing (`plugins/terreno-planning/references/subagent-briefing.md`): this task's
1010
+ criteria, file list, and patch. Roast may spawn at most one UI/runtime verifier when
1011
+ this task lists UI files, and must not spawn a conventions reviewer. Installable
1012
+ lifecycle skills copy only the plugin references they link.
1013
+ - Taste records failed tests from the last product-CI run and re-verifies those exact
1014
+ local commands before any GitHub push. `prepush` does not substitute for that
1015
+ re-verify. Example-frontend now exposes `test:ci` so root `bun run test` includes it.
1016
+ Cursor Cloud test mapping lives in `docs/how-to/run-tests-locally.md`. Plugin
1017
+ `terreno-planning` is `2.12.0`.
1018
+ - Taste now runs a repository root's `prepush` package script, when present, in its fresh
1019
+ no-context subagent before pushing. Repositories own the exact local gate; when the
1020
+ script is absent, Taste retains its affected-package lint, typecheck, and test fallback.
1021
+ Terreno's root `prepush` gate runs workspace lint, TypeScript compilation, and full
1022
+ Knip/dependency-cruiser static analysis. Plugin `terreno-planning` is `2.10.0`.
1023
+ - The component demo and documentation site now use distinct Terreno Garden app
1024
+ icons. The demo home screen also shows a Terreno Garden banner that flows inline
1025
+ with the component grid, filling the first two card slots.
1026
+
1027
+ ### Deprecated
1028
+
1029
+ - `@terreno/admin-frontend`'s `useAdminApi` RTK `injectEndpoints` path and required
1030
+ `api` prop are deprecated. Terreno 57 retains them for ObjectId/API-only
1031
+ compatibility; Terreno 58 removes them. New admin RPC uses the host-bound fetch
1032
+ client, and eligible String-`_id` model CRUD uses windowed syncdb.
1033
+
1034
+ ### Removed
1035
+
1036
+ - The UI component demo no longer includes announcement or in-app notification
1037
+ stories. Exercise those surfaces in `example-frontend` instead.
1038
+
1039
+ ### Fixed
1040
+
1041
+ - The `cloud_run_service` Terraform module no longer ignores `template.revision`.
1042
+ Ignoring it pinned the live revision name in state, so any structural change
1043
+ failed with Cloud Run 409 `Revision named '<name>' with different configuration
1044
+ already exists`. The Cloud Tasks queue now also waits on the IAM module so the
1045
+ first apply cannot 403 on `cloudtasks.queues.create`.
1046
+ - `compile-workspace-deps` uses `tsconfig.server.json` when present so
1047
+ `@terreno/admin-spa` emits `src/dist` for example-backend coverage.
1048
+ - Compiled example-backend images (`bun build --compile`) no longer point admin migrations at `$bunfs`. The Docker image copies `migrations/` to `/app/migrations` and sets `MIGRATIONS_DIR`. A missing directory returns 404 `Migration directory not found` instead of a generic 500.
1049
+ - `test:coverage` and new-file coverage no longer fail when Bun 1.4.2+ exits 1 for bunfig `coverageThreshold` after every test passed. Isolated LCOV merges still enforce the 95% / 90% gates.
1050
+ - Terraform preview CI now prints Infra Manager `errorCode` / `errorLogs` when
1051
+ `previews create` fails, and `terraform-admin` gains `roles/logging.logWriter`
1052
+ so Cloud Build can write regional logs.
1053
+ - Admin Migrations re-enables Dry run / Apply when task polling fails. `terreno-migrate generate` keeps safe index operations as comments in fail-closed stubs that also include unsafe ops.
1054
+ - `terreno-pick-roast-loop` resumes only Pick or Roast. A stored `next: brew` finishes
1055
+ the loop without launching Brew, Taste, or Grow.
1056
+ - example-backend listens on `PORT` before connecting to MongoDB and attaching
1057
+ the Terreno Express app. If that boot later throws, the process closes the
1058
+ holder and exits so Cloud Run does not keep a 503 listener. GitHub Actions
1059
+ preview deploys overwrite Cloud Run revision secrets, smoke-test the same
1060
+ `JOBS_*` env as the revision, rebuild traffic from Ready revisions (omitting
1061
+ failed tags), deploy `--no-traffic` without `--tag`, then tag the new revision.
1062
+ - RBAC permission resolution now keys the in-memory grant cache by user id and
1063
+ sorted role names, so promoting a user (for example e2e `setUserAdmin`) takes
1064
+ effect on the next `/auth/me` instead of serving a 30s stale set.
1065
+ - Conflict resolution with **Keep mine** now preserves the admin-window mutation marker
1066
+ when cloning a durable outbox row, so its retry retains AdminApp authorization,
1067
+ protected-field stripping, hooks, and audit handling.
1068
+ - `Tooltip` no longer disappears the instant it opens on web. The portal layer that
1069
+ renders overlays (tooltips, modals, toasts) declared `pointerEvents: "box-none"` in an
1070
+ inline style, which react-native-web drops, so every mounted portal covered the app with
1071
+ a full-screen overlay that swallowed hover and press events. The portal layer now uses a
1072
+ registered `StyleSheet` style, and `Tooltip` stays off screen until its trigger has been
1073
+ measured instead of flashing in the top-left corner.
1074
+ - `TextField` on React Native Web no longer enters an update-depth loop when
1075
+ browser autocorrect alternates controlled values. Web autocorrect/spellcheck is
1076
+ disabled, the input handler stays stable, and rapid synthetic A→B→A reversals
1077
+ are suppressed without blocking normal typing or delayed backspace/retype.
1078
+
1079
+ ## [57.3.0] - 2026-09-02
1080
+
1081
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.3.0.md`](mcp-server/src/docs/upgrades/57.3.0.md).
1082
+
1083
+ ### Added
1084
+
1085
+ - The lifecycle stages now ship as a Claude Code plugin. Add the marketplace with
1086
+ `/plugin marketplace add TerrenoLabs/terreno`, install with
1087
+ `/plugin install terreno@terreno-plugins`, then invoke `/terreno:1-grow`. The marketplace
1088
+ name is `terreno-plugins` so it does not collide with the plugin name `terreno` (Claude
1089
+ Code's installer breaks when those names match). Claude Code takes a
1090
+ plugin skill's command from the frontmatter `name`, so its shortened stage names
1091
+ (`1-grow` … `5-taste`) ship as a generated copy at `plugins/terreno-claude/`
1092
+ (`bun run skills:sync`). Cursor and `npx skills` are unchanged: plugin
1093
+ `terreno-planning`, stages `terreno-1-grow` … `terreno-5-taste`.
1094
+ - The lifecycle stages now ship as a Codex plugin. Add the marketplace with
1095
+ `codex plugin marketplace add TerrenoLabs/terreno`, install with
1096
+ `codex plugin install terreno-planning --source terreno-plugins`, then invoke
1097
+ `$terreno-1-grow`. Codex uses the canonical `plugins/terreno-planning/` tree
1098
+ (`.codex-plugin/plugin.json`) and the repo marketplace at
1099
+ `.agents/plugins/marketplace.json`. Stage names match Cursor and `npx skills`.
1100
+ - `TwilioSmsProvider` at `@terreno/comms/adapters/twilioSms` (optional peer `twilio`). Sends
1101
+ prefer a messaging service SID over a from-number, require valid E.164 destinations, classify
1102
+ Twilio error codes, and store a console deep link on accepted sends. The example backend
1103
+ registers the adapter when Twilio env vars are complete.
1104
+ - `TwilioVerifyProvider` at `@terreno/comms/adapters/twilioVerify` (optional peer `twilio`).
1105
+ Starts and checks SMS/email OTP via a Verify service SID, classifies Twilio errors, redacts
1106
+ destinations, never stores codes, and marks verification rows non-retryable. The example
1107
+ backend registers the adapter when `TWILIO_VERIFY_SERVICE_SID` is set with account
1108
+ credentials.
1109
+ - Admin comms dashboard: filter and inspect delivery logs, retry failed sends (including bulk retry
1110
+ with a cap), and view per-provider failure rates. Created and attempt timestamps print in the
1111
+ operator locale. List, stats, and bulk retry share a trailing 7-day window when dates are omitted.
1112
+ Editing a filter while that window is implicit keeps both date bounds. Push
1113
+ `beforeSend` cancel returns `loggedMessageId`. Retry returns the log row created by that send. Routes live on `@terreno/comms`; screens ship in
1114
+ `@terreno/admin-frontend` as the `comms` custom screen.
1115
+ - `describeModel()` and `describeModelForRouter()` in `@terreno/api` walk Mongoose schemas once and expose a canonical `ModelDescription` field graph. OpenAPI (`getOpenApiSpecForModel`), admin `/admin/config` field metadata, and MCP Zod tool schemas now format that graph instead of independently walking `schema.paths` or mongoose-to-swagger. Map fields take their value kind from Mongoose `of` / `getEmbeddedSchemaType()` (not a date fallback).
1116
+
1117
+ Exports include `modelDescriptionToOpenApiSpec`, `modelDescriptionToAdminFields`, and `fieldDescriptionToZodType`. See `docs/explanation/schema-metadata.md`.
1118
+ - `ExpoPushProvider` at `@terreno/comms/adapters/expoPush` (optional peer
1119
+ `expo-server-sdk`). `sendPush` returns one `SendResult` per token, chunks Expo
1120
+ payloads, classifies ticket/receipt errors, and polls receipts. `DeviceNotRegistered`
1121
+ deactivates `PushToken` rows via `CommsService.deactivatePushToken`. `MessageTooBig` is
1122
+ `errorClass: config` and does not deactivate the token. `expo-server-sdk`
1123
+ moved off `@terreno/api`. The example app requests notification permission, registers the device token after
1124
+ login, and exposes a profile-screen test send (`POST /comms/dev/testPush`) in
1125
+ non-production.
1126
+ - Added `create-github-issue` and `work-github-issues` skills plus a lifecycle
1127
+ work-item GitHub form so agents can file pick-ready issues, confirm a plan,
1128
+ post it as the Roast contract, then implement with Pick ⇄ Roast.
1129
+ - Opt-in HTTP rate limiting on `TerrenoApp` via `rateLimit: {}` (memory default; `redis` or `mongo` for shared buckets). Login and related auth routes use 20 requests / 15 minutes; other framework HTTP uses 600 / 15 minutes. Credential-exchange JWT routes ignore a stale access token. Trailing slashes and Express `req.path` drive the auth/api bucket. `trustProxy` defaults off (set `1` on Cloud Run). Omitted `rateLimit` is a no-op until Terreno 58. 429 is `APIError` `code: "rate-limit-exceeded"`. See `docs/how-to/rate-limiting.md`.
1130
+ - Inbound webhooks on `WebhooksApp`: raw-body capture, HMAC/Stripe/Twilio/SendGrid
1131
+ verifiers (SendGrid ECDSA uses the same 300s timestamp window as Stripe), memory or
1132
+ Mongo `webhookReceipts` idempotency. `CommsApp` mounts Twilio status/inbound and
1133
+ SendGrid Event Webhook routes when passed the same plugin. `recordDeliveryEvent`
1134
+ rethrows a failed `CommsMessage` save so webhook claims release. See
1135
+ `docs/how-to/inbound-webhooks.md`.
1136
+ - `terreno_search_docs` and `terreno_get_component_docs` accept an optional
1137
+ `version` so agents can search retained docs snapshots for the consumer's
1138
+ `@terreno/*` lockstep version. Snapshot component pages match both hyphenated
1139
+ camelCase filenames and concatenated generator slugs.
1140
+ - Pull requests now fail when a newly added workspace `.ts` or `.tsx` implementation
1141
+ file is below 90% function coverage or 90% line coverage. Run
1142
+ `bun run check:new-file-coverage --base=origin/master --threshold=90` locally.
1143
+ The gate reuses each package's `bun test` file arguments so Playwright `*.spec.ts`
1144
+ files are not collected. Glob arguments are expanded before spawn so packages such
1145
+ as `example-frontend` still collect `*.test.ts` files. Globs that match no files
1146
+ are omitted.
1147
+ - JWT and Better Auth password reset plus email verification: `POST /auth/forgotPassword` (always 202), `POST /auth/resetPassword`, `POST /auth/sendVerification`, and `POST /auth/verifyEmail`. Opt in with `emailVerificationPlugin`, `tokenEpoch`, `authOptions.publicAppUrl` / `sendMail`, and `@terreno/comms` `renderAuthMail`. Better Auth uses the same `publicAppUrl` and `sendMail`. `LoginScreen` accepts `onForgotPassword`. See `docs/how-to/password-reset.md`.
1148
+ - The `terreno-planning` Cursor plugin (`2.3.0`) adds two outer-loop skills beside the
1149
+ five stages: `terreno-planning-loop` walks the approved task list (default Grow once,
1150
+ then Pick once — Pick owns the pick-roast inner loop; pass `phases=` to restrict to
1151
+ `grow`, `pick`, `roast`, `brew`, and/or `taste`), and `terreno-taste-sweep` finds the
1152
+ author's open non-draft PRs that are conflicting or failing and reinvokes Taste until
1153
+ each is mergeable or blocked.
1154
+ - `Popover` in `@terreno/ui` for previewing a document with loading, loaded, and error
1155
+ states, an open action, and optional thumbs up/down feedback.
1156
+ - CI now fails production TypeScript that uses `function` declarations, `Date`/`Date.now()`, `throw new Error`, `console.log`, Mongoose `findOne`, or unsuppressed `as any`. Run `bun run check:source-rules`.
1157
+ - Upgrade notes format, 0.21.0–0.30.0 backfill, `terreno_get_upgrade_guide` coverage headers, versioning policy, `upgrading-terreno` skill, and changelog links to `mcp-server/src/docs/upgrades/`.
1158
+
1159
+ ### Changed
1160
+
1161
+ - Brew and Taste now discover and observe product CI on every configured host, including
1162
+ CircleCI and Buildkite, not only GitHub check runs. Waits use provider-native hooks such
1163
+ as `gh pr checks --watch`, `circleci run watch`, and `bk build watch` where available,
1164
+ with bounded polling only as fallback. Plugin `terreno-planning` is `2.4.0`.
1165
+ - Brew and Taste now sleep until async review bots such as Bugbot and CodeQL finish on
1166
+ the current head, then continue so they can react in the same invocation. Ordinary
1167
+ product CI still uses Taste `PENDING` and the outer loop. Plugin `terreno-planning` is
1168
+ `2.2.0`.
1169
+ - PR GitHub Actions spend fewer minutes on docs, Playwright, Expo fingerprints,
1170
+ Maestro, and the example-backend Docker check. Docs previews build only the
1171
+ current version (unminified, no local search index) and reuse generated
1172
+ TypeDoc/component MDX when the source hashes match. Docusaurus Faster
1173
+ (Rspack) is on for PR and `master`; production still builds every versioned
1174
+ tree.
1175
+
1176
+ CI pins Bun `1.4.0` instead of `latest`. Playwright e2e compiles the workspace
1177
+ once per run and shares `dist/` with the spec shards. Example-backend CI
1178
+ compiles `@terreno/*` deps in one process and watches `api/**`. The backend
1179
+ Docker check rebuilds only when the image recipe changes; CD still builds
1180
+ preview images from source.
1181
+
1182
+ Backend startup now defers sync index creation until after MongoDB connects.
1183
+ This prevents import-time Mongoose buffering timeouts from blocking Cloud Run
1184
+ containers before they begin listening. The example backend also avoids the
1185
+ OpenTelemetry Mongoose patch that deadlocked index creation in Bun-compiled
1186
+ binaries, keeps startup logging on Cloud Run's captured stdout instead of a
1187
+ blocking network transport, and lets Better Auth-only realtime run without a
1188
+ legacy JWT secret.
1189
+ - Architectural PR review and Maestro web E2E now run on CircleCI. Matching GitHub
1190
+ workflows are retained with `on: []` for rollback. Cursor Approval Agent, Bugbot,
1191
+ and Security Agent stay on GitHub (Cursor GitHub App automations, not repo
1192
+ workflows).
1193
+ - CircleCI path filters start Netlify and GCP production and PR preview jobs.
1194
+ Those jobs skip (exit 0) until `terreno-netlify` and `terreno-gcp` are filled.
1195
+ GitHub Actions remains the live deployer in that window. After CircleCI
1196
+ secrets exist and a deploy succeeds, set the GHA deploy workflows back to
1197
+ `on: []` so terraform is not applied twice. Fork PRs skip CircleCI previews.
1198
+ Preview cleanup on PR close uses GitHub `preview-cleanup.yml`.
1199
+ - CircleCI provides Netlify and GCP deploy jobs, semver-tag npm releases, and
1200
+ manual preview cleanup, preview deploy, EAS development build, and
1201
+ single-package publish operations. Automatic production deploy path triggers
1202
+ stay paused until the Netlify contexts and GCP OIDC bootstrap pass manual
1203
+ verification. Matching GitHub CI/CD workflows are retained with `on: []` for
1204
+ rollback. CircleCI uses OIDC for GCP; no service-account JSON key is required.
1205
+ - `modelRouter` registers collections in one in-memory catalog keyed by route path. MCP, realtime, and sync read shared `ModelRouterOptions` from that catalog; `replaceCollectionOptions` updates every surface at once. Registry `clear*` helpers clear the whole catalog in tests.
1206
+ - `@terreno/api` RBAC routes now narrow path parameters at runtime instead of
1207
+ relying on implicit `any`. `@terreno/ui` Hyperlink props (`linkify`, styles,
1208
+ `injectViewProps`) and the Google Maps `window.google` global use concrete
1209
+ types instead of `any`.
1210
+ - Root lint now rejects explicit `any` suppressions without a `noExplicitAny:` rationale.
1211
+ - `improve-rulesync` now keeps user-facing guidance in `docs/` and agent-facing
1212
+ guidance in `.ai/` or rules, with cross-links instead of duplicated architecture.
1213
+ - Heavy optional `@terreno/ui` screens (`GPTChat`, `MarkdownEditor`, `ConsentFormScreen`, `AIRequestExplorer`, and the other named exports from `lazyBoundaries/heavyOptionalExports`) load through lazy boundaries so a root `@terreno/ui` import stays smaller. Named exports are unchanged.
1214
+ - MCP create, update, and delete tools run through the same `executeCreate` /
1215
+ `executeUpdate` / `executeDelete` pipeline as REST and Sync. Permission denials
1216
+ and hook failures use `APIError` titles in the MCP error envelope. User-role
1217
+ stripping happens after hooks in the executor (MCP uses the registry model
1218
+ name). `loadDocOr404` maps invalid document `_id` values to 404, not populate
1219
+ `CastError`s.
1220
+ - Pick and Roast now run as an automated inner loop: implement one unblocked task, roast
1221
+ it, then pick the next until the approved list is done. Roast never invokes Pick; Pick
1222
+ owns continuation. Exactly one driver continues after each Roast. Later tasks rediscover
1223
+ docs and skills. Brew starts only after every in-scope task has Roast `PASS`. Plugin
1224
+ `terreno-planning` is `2.3.0`.
1225
+ - Planning plugin skills (five stages plus planning-loop and taste-sweep) are model-invocable. The lifecycle checker requires those skills omit `disable-model-invocation`.
1226
+ - Aligned README, docs landing, docs site tagline, agent context, MCP overview, and npm package descriptions on the canonical "Django/Rails for TypeScript — with universal app support" positioning.
1227
+ - CircleCI is enabled again for package CI, repo policies, and Playwright e2e
1228
+ (`.circleci/config.yml` setup + path-filtering). Config-only PRs run a small
1229
+ smoke slice; mixed PRs skip that slice so path-filtered jobs are not doubled.
1230
+ E2E shards share one compile + `expo export`. `rulesync-check` runs only when
1231
+ rule sources change. CircleCI deploy jobs are available through manual
1232
+ parameters while automatic production path triggers remain paused. Matching
1233
+ GitHub deploy workflows are retained with `on: []`. See `docs/how-to/circleci.md`.
1234
+ - Taste waits in-process for product CI with a GitHub CLI or CircleCI CLI watch loop.
1235
+ Before any push it always pulls latest `master`, then runs `bun lint` (and affected
1236
+ tests) in a fresh subagent with no parent conversation, then pushes and watches CI.
1237
+ Plugin `terreno-planning` is `2.5.0`.
1238
+
1239
+ ### Deprecated
1240
+
1241
+ - `modelRouter` `realtime` and `@terreno/rtk` cache-patching helpers (`realtimeList`,
1242
+ `realtimeDocument`, `setRealtimeSocket`, `getRealtimeSocket`) are deprecated and will
1243
+ be removed in Terreno **58**. Migrate collection live updates to `sync` +
1244
+ `@terreno/syncdb`. `RealtimeApp` remains required for sync sockets. See
1245
+ [migrate-rtk-to-syncdb.md](docs/how-to/migrate-rtk-to-syncdb.md) and
1246
+ [remove-legacy-realtime.md](docs/tasks/remove-legacy-realtime.md).
1247
+
1248
+ ### Fixed
1249
+
1250
+ - Admin custom screens now show a clickable back arrow by default. The shared
1251
+ `AdminScreenPage` routes back to admin home reliably on web instead of depending on
1252
+ browser history, and `Page` supports an explicit `onBack` handler.
1253
+ - Better Auth lazy User create no longer sets `oauthProvider: null` on email/password sign-up, so `strict: "throw"` User schemas without that field get `req.user` on the first authenticated request instead of 401.
1254
+ - CircleCI Playwright chaos e2e no longer force-restarts the sync client after
1255
+ flaps. `goOnline` waiting for the Offline banner to hide is the reconnect
1256
+ signal; `client.stop()` hung for 30s on the production static export.
1257
+ - CircleCI automatic Netlify and GCP production path triggers are paused until
1258
+ their contexts and OIDC bootstrap pass manual verification. Netlify jobs now
1259
+ validate credentials before expensive builds, docs builds avoid minification to
1260
+ fit the available executor memory, and the Terraform configuration is formatted.
1261
+ - CircleCI no longer deploys the production demo from prerelease tags, Zoom
1262
+ release notify reports failure when publish fails, Maestro keeps Xvfb alive as
1263
+ a background step, and CircleCI OIDC can require the `terreno-gcp` context UUID
1264
+ before GCP impersonation.
1265
+ - Taste can query CircleCI with `CIRCLECI_TOKEN` on the GitHub App project slug. The
1266
+ new-file coverage job compiles the same workspace packages as example-backend CI.
1267
+ The example profile form no longer resets the name field when `/auth/me` is refetched.
1268
+ - Example backend Cloud Run images include `expo-server-sdk` in the compiled
1269
+ binary by injecting an `Expo` client into `ExpoPushProvider`. Preview deploys
1270
+ no longer crash at boot with a missing optional peer.
1271
+ - GitHub `cd.yml` GCP preview jobs (`terraform-preview`,
1272
+ `backend-deploy-preview`) and `preview-cleanup.yml` skip fork pull requests.
1273
+ OIDC `id-token` is granted only on jobs that authenticate to GCP, not the
1274
+ whole workflow. Fork PRs keep `repository: TerrenoLabs/terreno` on the
1275
+ token, so WIF would otherwise accept them.
1276
+ - Sync mutation date equality now parses date-only ISO strings as UTC and rejects invalid input instead of throwing. The unused datetime NumberPicker stores UTC ISO so Luxon can round-trip the picker value.
1277
+ - The `maestro-e2e` CI job now exports the example-frontend web bundle before starting the
1278
+ example backend. That executor shares its memory with the mongo service container, and
1279
+ bundling alongside a running backend got the export OOM-killed (`SIGKILL`) even though the
1280
+ same export succeeds in `e2e-prepare`.
1281
+ - `bun run check:new-file-coverage` now expands the glob arguments it reads from a package's
1282
+ `test` script before spawning `bun`, so packages without a `src/` directory (such as
1283
+ `example-frontend`) no longer fail with "filters did not match any test files". Expo Router
1284
+ route-structural entry files under `app/` (`index`, `_layout`, `+not-found`, and dynamic
1285
+ segments) are exempt from the gate; the router mounts them by file path and the screens they
1286
+ render are covered in their owning package.
1287
+ - JWT password reset now updates Better Auth credentials and sessions when both stacks are mounted, Better Auth password reset updates the JWT password and `tokenEpoch`, mailbox changes invalidate unused reset tokens even without `emailVerified`, authenticated verification resend returns 501 without a `publicAppUrl`, and Better Auth recovery hooks refuse to send relative links.
1288
+ - Select dropdown chevrons stay inside the field border in narrow selects on web. The `am`/`pm`
1289
+ and timezone pickers in `DateTimeField` no longer render their chevrons far to the right of
1290
+ their boxes.
1291
+ - Example backend Cloud Run images include `twilio` in the compiled binary by injecting
1292
+ a Twilio client into the SMS and Verify adapters. Invalid SMS destinations return a
1293
+ permanent `SendResult` instead of throwing, so the facade does not retry them. Partial
1294
+ Twilio env in the example backend throws `APIError` at boot.
1295
+
1296
+ ## [57.2.0] - 2026-08-24
1297
+
1298
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.2.0.md`](mcp-server/src/docs/upgrades/57.2.0.md).
1299
+
1300
+ ### Breaking
1301
+
1302
+ - Lifecycle stage handoff YAML is now compact `v: 2` (`v`, `stage`, `status`, `next`,
1303
+ `action`; omit empty keys). Outer loops that parse `schema_version` / `recommended_next_stage`
1304
+ must switch keys. Chat and PRs keep the YAML in a Details toggle. Grow lists every grilled
1305
+ decision in an unbounded table after the 15-line index, or omits that table when there were
1306
+ none, and stays on a question until the answer is executable.
1307
+
1308
+ ### Added
1309
+
1310
+ - Admin RBAC is now first-class: `admin:access` is the only permission that opens the admin
1311
+ page. Per-model access is `read` / `write` / `writeOwned` (with `isOwned` helpers and custom
1312
+ `authorize` callbacks). The shell hides models, custom screens, scripts, and Platform tools
1313
+ the caller cannot use; row update/delete follow per-record capabilities; roles use a
1314
+ one-select access level; denied forms are read-only.
1315
+
1316
+ ### Changed
1317
+
1318
+ - Upgraded the toolchain to Bun 1.4. The GitHub Actions jobs that pinned an exact
1319
+ Bun version now use `1.4.0`, `@types/bun` and `bun-types` move to `^1.4.0`, and
1320
+ apps scaffolded by `@terreno/mcp` get `@types/bun@^1.4.0`. Contributors should
1321
+ run Bun 1.4 or newer locally. EAS build profiles keep their existing Bun pin:
1322
+ `eas.json` is an Expo fingerprint input, so bumping it would change the native
1323
+ runtime version and force a rebuild for a toolchain-only change.
1324
+
1325
+ ## [57.1.0] - 2026-08-23
1326
+
1327
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.1.0.md`](mcp-server/src/docs/upgrades/57.1.0.md).
1328
+
1329
+ ### Added
1330
+
1331
+ - Admin configuration can now live beside `modelRouter` registrations or in plugin
1332
+ `adminContribution()` metadata. `@terreno/admin-frontend` adds a provider-backed widget registry,
1333
+ screen router, filter drawer, bulk actions, autocomplete references, and first-party Feature Flags,
1334
+ Consent, Documents, and AI admin integrations.
1335
+ - `terreno_bootstrap_app` scaffolds Better Auth + `@terreno/syncdb` (replica-set MongoDB,
1336
+ `SyncApp`/`RealtimeApp`, `SyncDbProvider`) instead of JWT `generateAuthSlice`
1337
+ - CircleCI dual-run for package CI, repo policies, and Playwright e2e (`.circleci/`;
1338
+ deploys still on GitHub Actions). See `docs/how-to/circleci.md`.
1339
+ - `@terreno/comms` Phase 1 gap-fill: `beforeSend` mutate/cancel, `recordDeliveryEvent` /
1340
+ `recordOptOut`, attempt history on `CommsMessage`, payload retention
1341
+ (`retainPayloadDays`, `redactPayload`), and channel-wide transient retry (SMS,
1342
+ verification start, per-token push). `onRetry` stays `(context, result)` with
1343
+ `context.attempt`. Push prune honors `errorClass: "permanent"` as well as
1344
+ `isPermanentFailure`.
1345
+ - `createCollectionHooks` in `@terreno/syncdb/react` and optional per-mutation
1346
+ `maxAttempts` on syncdb writes.
1347
+ - Model Context Protocol support in `modelRouter` via an `mcp` option: opted-in
1348
+ models expose their CRUD operations as MCP tools at `POST /mcp`, reusing the
1349
+ same permissions, query filters, population, and lifecycle hooks as REST
1350
+ ([#358](https://github.com/TerrenoLabs/terreno/pull/358)). `getMCPTools(user)`
1351
+ in `@terreno/ai` returns those tools as Vercel AI SDK objects for in-process
1352
+ chat. MCP list filters accept Mongo comparison operators (`$in`, `$gte`, `$ne`,
1353
+ and friends) and top-level `$and` / `$or` on `queryFields`; operators that can
1354
+ execute code (`$where`, `$expr`, `$function`) are rejected. `@terreno/rtk`
1355
+ adds `useMCPTools()` and `useTerrenoChat()`. MCP HTTP/stdio servers use the
1356
+ TypeScript SDK v2 (`2026-07-28`) with a how-to guide and structured tool-call
1357
+ logs. Lifecycle hooks and REST `responseHandler` receive a stub Express-shaped
1358
+ request from `createMCPRequest` (authenticated user, tool args as `body`, empty
1359
+ `headers`/`query`/`params`, `isMCPRequest: true`) rather than forwarded HTTP
1360
+ headers. `registerMCPTool` adds custom tools alongside modelRouter CRUD. The
1361
+ example backend exposes `users_todo_statuses` (admin-only: every user and their
1362
+ todo completed flags).
1363
+ - Example app profiles list every assigned role and link superadmins to role editing. The admin
1364
+ script runner includes a guarded `resetDatabase` action, and seed data includes
1365
+ `superadmin@example.com`. The admin roles page supports creating and editing roles by selecting
1366
+ from the server's available permissions. Admin script execution requires `admin:runScripts` when
1367
+ RBAC is configured, and live production resets require `ALLOW_ADMIN_DB_RESET=true`.
1368
+ - `SendGridMailProvider` at `@terreno/comms/adapters/sendgrid` (optional peer
1369
+ `@sendgrid/mail`) with sandbox mode, `errorCode`/`errorClass` taxonomy, Email Activity
1370
+ deep links, and one transient retry via `CommsService` hooks (`onError` / `onRetry` /
1371
+ `onSend`).
1372
+ - `terreno-syncdb-codegen` CLI in `@terreno/syncdb` generates typed collection hooks
1373
+ (`store/syncDbSdk.ts`) from OpenAPI `x-terreno-sync` list operations. The CLI is a
1374
+ bin of `@terreno/syncdb`, not a separate `@terreno/syncdb-codegen` package.
1375
+ - `@terreno/syncdb` documentation: reference (`docs/reference/syncdb.md`), migration guide
1376
+ (`docs/how-to/migrate-rtk-to-syncdb.md`), and local-first explainer
1377
+ (`docs/explanation/local-first-data.md`)
1378
+
1379
+ ### Changed
1380
+
1381
+ - User admin forms now load existing RBAC roles into an **Add role** dropdown. Assigned roles
1382
+ remain visible and can be removed without entering raw array values. Blank optional enum
1383
+ fields no longer block otherwise valid saves, and role-update responses include the new roles.
1384
+ - The admin sidebar groups Scripts, Roles, Version, Audit Log, Feature Flags, and Configuration
1385
+ under a consistently spaced **Platform** section at the bottom. Empty navigation sections are
1386
+ hidden, and Audit Log / Feature Flags no longer appear twice under Models.
1387
+ - The Terreno planning plugin 2.0 is a fresh-invocation loop-engineering lifecycle:
1388
+ Grow → Pick → Roast → Brew → Taste. Every bounded stage discovers repository skills,
1389
+ reads/writes a shared evidence-oriented result/state contract, and exits. Brew no longer
1390
+ executes Taste; Taste reacts once to current CI/mergeability/review state while the outer
1391
+ loop owns waiting and reinvocation. Terreno package commands and domain rules remain in
1392
+ repo-local skills. Grow retains interactive grilling and a 15-line approval summary; Pick
1393
+ retains vertical-slice TDD and independent implementation/test-quality review; Roast is
1394
+ the authoritative verifier. The former Grind behavior remains an outer-loop feature
1395
+ profile, and static validation enforces names, transitions, portability, and loop bounds.
1396
+ Deprecated repo-local routers (`ip`, `implement`, `submit`, `autobot`, `check-watcher`)
1397
+ are removed. Quiet package tests (`test:agent`) run with `AGENT=1`. Brew, Taste, and
1398
+ repository PR skills now enforce an attention-budgeted GitHub format: only `Why`, `What
1399
+ changed`, and `Verification` remain visible; optional detail is expandable; comments are
1400
+ reserved for blocked decisions or non-obvious review resolutions.
1401
+ - Unreleased notes are one file per feature in `changelog/unreleased/` instead of a shared
1402
+ `CHANGELOG.md` `## [Unreleased]` section. `docs/implementationPlans/PLAN_INDEX.md` is
1403
+ removed for the same reason — status lives on each IP's `**Status:**` header. Parallel
1404
+ PRs no longer conflict on those shared files.
1405
+ - GitHub Actions package CI (API, AI, UI, RTK, comms, syncdb, examples, E2E, Maestro, admin SPA) now runs on `pull_request` with path filters, and on `push` only to `master`. This stops the first push of a new branch from ignoring path filters and running unrelated jobs (the 13 Playwright shards were the main cost). Docs-only and rules-only PRs skip package CI, Rulesync, CD, and frontend/demo deploy workflows. Example Frontend Deploy PR path filters match the frontend build gate (backend-only PRs skip the workflow; mixed PRs still retarget preview `BASE_URL`).
1406
+ - CircleCI pipelines are disabled (`.circleci/config.yml` no-op). GitHub Actions
1407
+ remains the CI of record. Restore with `.circleci/config.setup.yml`. See
1408
+ `docs/how-to/circleci.md`.
1409
+ - Lifecycle stages now follow a documentation contract: read architecture docs before
1410
+ acting, update them in the same slice, and fail user-visible or architectural work that
1411
+ ships without matching docs. All agent skills are installable with
1412
+ `npx skills add TerrenoLabs/terreno`. The committed `skills/` tree is generated from
1413
+ `.rulesync/skills/`, the planning plugin stages, and `<package>/.ai/skills/` overlays via
1414
+ `bun run skills:sync`.
1415
+ - Role assignment **preview** no longer writes `RbacAudit` denied-assign rows. Denied
1416
+ mutation audits are recorded only for the escalation `403` (`Cannot grant permissions
1417
+ you do not hold`); other failures are not stored as denials, and a failed audit write
1418
+ does not replace that original `403`.
1419
+ - When a `PermissionSource` refresh fails with `staleOnFailure: "deny"` (the default),
1420
+ last-cached `deny` grants stay in force so IdP/ABAC restrictions do not lift. Additive
1421
+ `roles` / `permissions` from that source are still omitted.
1422
+ - RBAC `createView: "deny"` and unknown field-view names fail closed instead of granting a
1423
+ full mask. Nested field omits clone documents; write masks honor dotted paths. Bulk create
1424
+ and array mutations apply the same write mask as single-document writes.
1425
+ - AdminApp model CRUD requires resource actions in addition to `admin:access`. Self-service
1426
+ still cannot write User `admin`/`roles`. Without RBAC, admin CRUD may set `admin`.
1427
+ With RBAC, `roles` go through `RoleManager.assign`. Changing `admin` requires
1428
+ `rbac:assignRoles` plus an actor who already holds the legacy admin flag;
1429
+ `rbac:manageRoles` is not a substitute. Unchanged echoed `admin`
1430
+ values are allowed. If `assign` fails after an admin User update or bulk-patch,
1431
+ non-role fields are restored so the request does not keep a partial write.
1432
+ Role update requires the actor to hold the union of current and incoming
1433
+ permissions, and delete requires holding the role's current permissions, so
1434
+ `manageRoles` cannot empty or remove grants the actor lacks.
1435
+ assign/unassign require the actor to already hold the
1436
+ target user's current permissions. The seeded `auditor` role no longer receives
1437
+ `admin:access` via read-only expansion. Admin CRUD for a resource missing
1438
+ from statements fails closed for list/read, search, and writes. Framework statements now
1439
+ include `featureFlag`, `consentForm`, and `consentResponse` so superadmin `*`
1440
+ can list those admin models; example-backend adds `adminAuditLog` list/read.
1441
+ `POST /admin/background-tasks`
1442
+ requires `admin:runScripts`; version-config GET/PUT require `configuration:read` /
1443
+ `configuration:update`.
1444
+ - MCP and SyncDB registries use resolved RBAC options (TerrenoApp-injected and the
1445
+ documented `access` + `accessControl` path, including pathless `modelRouter`)
1446
+ instead of the pre-build legacy permission arrays. Create/update also apply
1447
+ `validateAccessWritePayload` (field views / `createView: "deny"`).
1448
+ User `roles` on modelRouter writes (HTTP, sync, and MCP) are dropped when
1449
+ `accessControl` is set.
1450
+ Example-backend `backfillAdmins` is dry-run unless `RBAC_BACKFILL_ADMINS=true`.
1451
+ - `runActionPermissions` combines legacy `action.permissions` with RBAC instead of replacing
1452
+ them. Actions without `access` inherit the router's `access.resource` and the mapped CRUD
1453
+ verb (`instance` POST → `update`, `collection` POST → `create`). Example todo
1454
+ `bulkComplete` / `markComplete` set `access: {resource: "todo", action: "update"}`
1455
+ so they cannot bypass `todo:update`. Empty action permissions and inherited CRUD actions
1456
+ mapped to `null` remain disabled. Assignment previews use uncached permission resolution
1457
+ on both the current and proposed sets, honor source `staleOnFailure` without writing
1458
+ caches, and reject role permissions the actor does not already hold. The admin role
1459
+ editor keeps Create/Save in the
1460
+ modal footer and scrolls the permission grid so a larger statement vocabulary cannot
1461
+ hide the save control. Create/list responses always apply the **read** field
1462
+ mask. Per-router `access.scope`
1463
+ extra PermissionSets are evaluated on HTTP and realtime reads. Sync and realtime
1464
+ serializers accept change-stream BSON post-images (no Mongoose `toObject`) so
1465
+ `sync:delta` is not dropped after RBAC wraps `defaultResponseHandler`. Invalid permission sets
1466
+ use a stable `APIError.title`.
1467
+ - Self-service signup and `PATCH /auth/me` strip `organizationIds` alongside `admin` and `roles`,
1468
+ preventing callers from assigning themselves tenant membership. Administrative organization
1469
+ membership changes must use a privileged server-side path.
1470
+ - Ordinary RBAC-enabled User modelRouter, sync, and MCP writes strip `admin`, `roles`, and
1471
+ `organizationIds`. AdminApp strips `organizationIds` the same way, and marks legacy
1472
+ `admin` writes only after `assignRoles` and an existing-admin check succeed.
1473
+ - `RoleManager` writes `RbacAudit` (with `permissionDelta`) on create, update, remove, assign,
1474
+ and unassign. Denied escalation attempts are stored with `denied: true`. HTTP `rbacRouter`
1475
+ no longer writes a second audit row. Apps can pass `auditSink` (one function or an array)
1476
+ to fan records into a consuming-app log; set `persistAudit: false` to skip the built-in
1477
+ collection (at least one sink is then required).
1478
+ - `RbacRole.seedDefaults` accepts `extraRoles` and shares `upsertSeededRole` with
1479
+ `RoleManager.seedDefaults`. `previewRoleChange` reports a real `affectedUserCount`.
1480
+ - Importing `@terreno/api` no longer registers `RbacRole` / `RbacAudit` on the default
1481
+ mongoose connection. Use `createAccess({connection})` or `createRbacRoleModel` /
1482
+ `createRbacAuditModel`. The `RbacRoleModel` / `RbacAuditModel` singletons are removed;
1483
+ the `RbacRoleModel` type remains.
1484
+ - The RBAC implementation plan is Complete (phases 1–6 shipped).
1485
+
1486
+ ### Deprecated
1487
+
1488
+ - **`@terreno/rtk` for data synchronization** — deprecated as of **56.0.0**. Still published
1489
+ through the current major line; will not ship in the next major. Migrate collection CRUD to
1490
+ [`@terreno/syncdb`](docs/reference/syncdb.md) using
1491
+ [migrate-rtk-to-syncdb.md](docs/how-to/migrate-rtk-to-syncdb.md). Continue using `@terreno/rtk`
1492
+ for the OpenAPI SDK, Better Auth Redux, feature flags, and sockets.
1493
+
1494
+ ### Fixed
1495
+
1496
+ - Admin lists no longer inherit public `queryFilter` scoping, and `adminFilter` Mongo operators
1497
+ are not rejected as client filters.
1498
+ - Admin mutations and responses consistently scrub excluded fields, including populated refs.
1499
+ - Plugin admin contributions forward `populatePaths`.
1500
+ - Document Storage clients use the contributed `/documents` API path.
1501
+ - Admin search applies the same `queryFilter`/`adminFilter` as list CRUD.
1502
+ - AI Request Explorer multi-type filters use `$in` instead of dropping the filter.
1503
+ - Admin list search (`q`) is a case-insensitive partial match across `searchFields`.
1504
+ - The filter drawer can clear all filters and disables Apply when the draft is unchanged.
1505
+ - Admin config `name` is unique when the same Mongoose model is mounted at more than one
1506
+ `routePath`, and list search/bulk-patch metadata is looked up by path.
1507
+ - Document download failures log a status code only — not storage paths or provider payloads.
1508
+ - The admin Roles screen scrolls its role cards and the "Available permissions" list instead
1509
+ of clipping them, and keeps the heading and "Add role" button pinned above the scroll area.
1510
+ - `@terreno/ui` no longer triggers the react-native-web warning `"shadow*" style props are
1511
+ deprecated. Use "boxShadow".` — `Filter`, `WebDropdownMenu` (select fields, timezone picker,
1512
+ address field), and `DraggableList` now use `boxShadow`, and the new `createBoxShadow` /
1513
+ `applyColorOpacity` helpers build the shadow value from a color plus opacity.
1514
+ - Patched `react-native-modalize` and `react-native-actions-sheet` to use `boxShadow` on
1515
+ iOS/web and `elevation` only on Android, so the two APIs do not stack. The modalize
1516
+ stylesheet ran at import time, so the deprecation warning appeared on every web page that
1517
+ imported `@terreno/ui`.
1518
+ - Android centered dropdowns (`WebDropdownMenu` `presentation="centered"`) keep `elevation` and
1519
+ omit `boxShadow`, so the two APIs do not stack.
1520
+ - Push hooks and retries are per-token; provider results are zipped to token strings
1521
+ after `beforeSend`. Mail payloads retain `replyTo` and `dynamicTemplateData`.
1522
+ `DeliveryEvent.errorClass` is persisted on the log row. `defaultFrom` is reapplied
1523
+ after `beforeSend`. Payload cleanup is best-effort and cannot fail `logSend` /
1524
+ `appendAttempt`. Hook exception text is logged only; `metadata.hookErrors` stores
1525
+ `hook-threw`, not `String(error)`.
1526
+ - Conflict `requeue` copies per-mutation `maxAttempts` onto the cloned outbox
1527
+ row so `retries: false` stays fail-fast after keepMine.
1528
+ - Expo native fingerprints for the demo and example frontend now exclude `package.json`
1529
+ scripts. Development and test command changes no longer trigger unnecessary native build
1530
+ acknowledgements or rebuilds.
1531
+ - MCP create/update apply REST `validation.excludeFromCreate` /
1532
+ `excludeFromUpdate` and MCP `excludeFields` as a write denylist on persist and
1533
+ hook request bodies, including nested/dot paths and literal dotted keys
1534
+ (`"metadata.nested.token"`). Invalid ObjectIds return a structured not-found
1535
+ instead of crashing on `CastError`; mixed-case 24-hex ids still work. List
1536
+ returns a structured error when `queryFilter` throws. A throwing
1537
+ `responseHandler` / `mcpResponseHandler` becomes a structured tool error instead
1538
+ of a protocol crash. List filters ignore queryFields that sit under an
1539
+ `excludeFields` parent path, matching tool schema generation. Lifecycle hooks
1540
+ that throw `APIError` return `error.title` to the MCP client, matching
1541
+ `queryFilter` handling.
1542
+ - `@terreno/ui` no longer triggers the React Native Web warning
1543
+ `props.pointerEvents is deprecated. Use style.pointerEvents`. Every component that set
1544
+ `pointerEvents` as a prop (`DateTimeField`, `Filter`, `SidebarNavigation`, `ToastNotifications`,
1545
+ `WebDropdownMenu`) now sets it in `style`.
1546
+ - The `react-native-portalize` dependency, which set the deprecated prop on every screen through
1547
+ `TerrenoProvider`, is replaced by an internal portal host. `Host` and `Portal` are now exported
1548
+ from `@terreno/ui` with the same API.
1549
+ - `seedDefaults` no longer overwrites customized unsealed roles on restart; sealed defaults still refresh from code.
1550
+ API tests clear `RbacRole` / `RbacAudit` in `setupDb` so leftover unsealed names cannot leak across cases.
1551
+ - Admin `/bulk-patch` authorizes each target document, so scoped `update` cannot patch out-of-scope ids.
1552
+ - Admin User CRUD can set the `admin` flag when RBAC is off. With RBAC, changing `admin` requires `rbac:assignRoles`; echoed unchanged `admin` values on create/update do not.
1553
+ - Permission resolver caches evict expired and overflow entries so distinct identities cannot grow unbounded.
1554
+ - Clearing a role description in the admin UI sends `null` so PATCH removes the field.
1555
+ - Admin User create rolls back the new row if `RoleManager.assign` fails after insert.
1556
+ - `assign` / `unassign` refuse to change a user whose current permissions the actor does not hold.
1557
+ - MCP model tools pick up TerrenoApp-injected `accessControl` (permissions, query filters, write masks) instead of keeping the pre-build legacy checks.
1558
+ - Example todos Sync Lab panel starts collapsed so the first list row stays
1559
+ above the tab bar on short web viewports.
1560
+ - `terreno-syncdb-codegen` rejects non-identifier collection/type names and
1561
+ JSON-escapes generated strings so a remote OpenAPI document cannot inject
1562
+ TypeScript.
1563
+ - `@terreno/syncdb` no longer misses server changes that land between its startup snapshot
1564
+ and the socket joining a stream's room: each `sync:subscribed` confirmation now pages the
1565
+ streams it names from their cursor, instead of leaving the client stale until the next
1566
+ periodic reconcile
1567
+
1568
+ ## [57.0.0] - 2026-08-20
1569
+
1570
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.0.0.md`](mcp-server/src/docs/upgrades/57.0.0.md).
1571
+
1572
+ First stable release of the Expo SDK 57 line, cut from `master` after
1573
+ [`#1065`](https://github.com/TerrenoLabs/terreno/pull/1065). Publishes to the npm
1574
+ `latest` dist-tag. Apps on `0.x` should stay pinned until they upgrade Expo.
1575
+
1576
+ ### Added
1577
+
1578
+ - `Filter` and its composable select, boolean, accordion, and change-badge controls in
1579
+ `@terreno/ui` for desktop web filtering flows
1580
+ ([#972](https://github.com/TerrenoLabs/terreno/pull/972))
1581
+
1582
+ ### Changed
1583
+
1584
+ - Terreno's version major now tracks Expo SDK 57 (`57.x.y`). `npm install @terreno/ui`
1585
+ resolves to this line; pin `0.x` if the app has not upgraded Expo yet
1586
+ ([#1065](https://github.com/TerrenoLabs/terreno/pull/1065))
1587
+ - Frontend peer/catalog stack moves to Expo SDK 57 / React Native 0.86.2
1588
+ (`expo ~57.0.14`, matching React Native DevTools and Hermes V1 fixes from
1589
+ `expo@57.0.9+`). React stays at `19.2.3`. Consuming apps should run
1590
+ `npx expo install expo@latest --fix` then rebuild native binaries
1591
+ ([#1065](https://github.com/TerrenoLabs/terreno/pull/1065))
1592
+ - `@terreno/syncdb` bumps `tinybase` to `^9.5.1` for Expo SDK 57 /
1593
+ `expo-sqlite` type compatibility ([#1065](https://github.com/TerrenoLabs/terreno/pull/1065))
1594
+
1595
+ ## [57.0.0-beta.1] - 2026-08-20
1596
+
1597
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.0.0-beta.1.md`](mcp-server/src/docs/upgrades/57.0.0-beta.1.md).
1598
+
1599
+ First beta of the Expo SDK 57 line, cut from `master` after merging
1600
+ [`#1065`](https://github.com/TerrenoLabs/terreno/pull/1065). Publishes under the npm
1601
+ `beta` dist-tag; `npm install @terreno/ui` still resolves to the stable `0.x` line.
1602
+
1603
+ ### Added
1604
+
1605
+ - `Filter` and its composable select, boolean, accordion, and change-badge controls in
1606
+ `@terreno/ui` for desktop web filtering flows
1607
+ ([#972](https://github.com/TerrenoLabs/terreno/pull/972))
1608
+
1609
+ ### Changed
1610
+
1611
+ - Terreno's version major now tracks Expo SDK 57 (`57.x.y`). The stable `0.x` packages are
1612
+ unaffected; this beta does not move `latest`
1613
+ ([#1065](https://github.com/TerrenoLabs/terreno/pull/1065))
1614
+ - Frontend peer/catalog stack moves to Expo SDK 57 / React Native 0.86.2
1615
+ (`expo ~57.0.14`, matching React Native DevTools and Hermes V1 fixes from
1616
+ `expo@57.0.9+`). React stays at `19.2.3`. Consuming apps should run
1617
+ `npx expo install expo@latest --fix` then rebuild native binaries
1618
+ ([#1065](https://github.com/TerrenoLabs/terreno/pull/1065))
1619
+ - `@terreno/syncdb` bumps `tinybase` to `^9.5.1` for Expo SDK 57 /
1620
+ `expo-sqlite` type compatibility ([#1065](https://github.com/TerrenoLabs/terreno/pull/1065))
1621
+
1622
+ ## [56.0.0-beta.2] - 2026-08-17
1623
+
1624
+ Upgrade note: [`mcp-server/src/docs/upgrades/56.0.0-beta.2.md`](mcp-server/src/docs/upgrades/56.0.0-beta.2.md).
1625
+
1626
+ Second beta of the Expo SDK 56 line, cut from `master` after merging
1627
+ [`#976`](https://github.com/TerrenoLabs/terreno/pull/976). Publishes under the npm
1628
+ `beta` dist-tag; `npm install @terreno/ui` still resolves to the stable `0.x` line.
1629
+
1630
+ ### Added
1631
+
1632
+ - Expo SDK 56 target for frontend packages: `expo ~56.0.12`, `react-native 0.85.3`,
1633
+ `react 19.2.3`, TypeScript 6
1634
+ ([#976](https://github.com/TerrenoLabs/terreno/pull/976))
1635
+ - `@terreno/syncdb` local-first data layer (TinyBase MergeableStore, durable outbox,
1636
+ websocket delta sync, encrypted web persistence) plus `SyncApp` / sync protocol support
1637
+ in `@terreno/api`
1638
+ ([#976](https://github.com/TerrenoLabs/terreno/pull/976))
1639
+ - `SyncStatusBanner` and `ConflictSheet` in `@terreno/ui` for sync UX
1640
+ ([#976](https://github.com/TerrenoLabs/terreno/pull/976))
1641
+ - `@terreno/comms` with pluggable mail, SMS, push, and verification contracts, console
1642
+ development providers, delivery logging, owner-scoped push-token routes, an admin delivery
1643
+ explorer, and generated RTK Query hooks
1644
+ ([#1037](https://github.com/TerrenoLabs/terreno/pull/1037))
1645
+
1646
+ ### Changed
1647
+
1648
+ - Terreno's version major now tracks the Expo SDK major it targets (`56.x.y` for Expo 56).
1649
+ The stable `0.x` packages are unaffected; this beta does not move `latest`
1650
+ ([#976](https://github.com/TerrenoLabs/terreno/pull/976))
1651
+ - Frontend peer/catalog stack moves to Expo 56 / React Native 0.85 / React 19.2 /
1652
+ TypeScript 6 — consuming apps must upgrade Expo before installing this beta
1653
+ ([#976](https://github.com/TerrenoLabs/terreno/pull/976))
1654
+ - `excludeArchivedPlugin` now filters `findOne` the same way as `find`, matching
1655
+ `isDeletedPlugin`
1656
+ - Sync/REST CRUD executors preserve kebab-case error `code` values
1657
+ (`create-not-allowed`, `update-not-allowed`, `delete-not-allowed`,
1658
+ `invalid-request-body`) and status-specific error names on hook-rejection paths
1659
+ - Publish workflow pins `@terreno/ui` to the release version for prerelease
1660
+ `admin-frontend` / `admin-spa` publishes (same as `ui` / `rtk`)
1661
+ - Catalog `@shopify/react-native-skia` bumped to `2.6.5` (drops the postinstall that
1662
+ could hang `bun install --frozen-lockfile`); removed unused payment/native deps from
1663
+ `example-frontend`
1664
+ - The `terreno-5-dialin` plugin skill now owns merge conflicts that appear after the
1665
+ Pour handoff: it checks PR mergeability each cycle, resolves conflicts by merging the
1666
+ base branch without rewriting pushed history, re-runs checks and frontend
1667
+ verification, and treats a conflicted PR as broken rather than mergeable
1668
+ ([#1039](https://github.com/TerrenoLabs/terreno/pull/1039))
1669
+
1670
+ ### Fixed
1671
+
1672
+ - `generateTokens` in `@terreno/api` now logs and falls back to its default expiration when
1673
+ `TOKEN_EXPIRES_IN` or `REFRESH_TOKEN_EXPIRES_IN` is not a valid duration, instead of throwing
1674
+ from `jwt.sign`
1675
+ - Sync seq claims under Mongoose 9: `claimSyncSeqs` passes `updatePipeline: true` for
1676
+ its aggregation-pipeline `findOneAndUpdate` (required after the master mongoose 9 merge)
1677
+ - `RealtimeApp` accepts injectable `SocketServer` / `startChangeStreamWatcher` so setup
1678
+ tests no longer use process-wide `mock.module` (which broke socketAuth + sync integration)
1679
+ - Mongoose 9 `insertMany` probe in `syncFrontier.test.ts` no longer calls removed `next()`
1680
+ - Sync save error middleware: `throw` instead of `next(error)` under Mongoose 9/Kareem 3,
1681
+ and release pending seq claims on any failed claimed save (not only VersionError)
1682
+ - Drop orphaned `ui` consent-history PDF test left after the move to `admin-frontend`
1683
+
1684
+ ## [0.31.0] - 2026-08-11
1685
+
1686
+ Upgrade note: [`mcp-server/src/docs/upgrades/0.31.0.md`](mcp-server/src/docs/upgrades/0.31.0.md).
1687
+
1688
+ ### Added
1689
+
1690
+ - `EditableCard` in `@terreno/ui`: a summary card with an optional icon, badge,
1691
+ description, helper text, edit button, and attention state
1692
+ ([#981](https://github.com/TerrenoLabs/terreno/pull/981))
1693
+ - MIT `LICENSE` file in every published package, plus contribution guide, changelog,
1694
+ and GitHub issue/PR templates ([#985](https://github.com/TerrenoLabs/terreno/pull/985))
1695
+ - Public roadmap generated from `docs/` into GitHub, with `roadmap:generate` /
1696
+ `roadmap:check` scripts and roadmap discussion setup
1697
+ ([#986](https://github.com/TerrenoLabs/terreno/pull/986), [#991](https://github.com/TerrenoLabs/terreno/pull/991))
1698
+ - `check:upgrade-docs` release gate that fails a tagged publish when a release
1699
+ documents breaking, changed, deprecated, or removed behavior without an
1700
+ `mcp-server/src/docs/upgrades/<version>.md` note ([#987](https://github.com/TerrenoLabs/terreno/pull/987))
1701
+ - Deployment foundation for GCP, deploy guides, and agentic SDLC plugin phase 1
1702
+ ([#988](https://github.com/TerrenoLabs/terreno/pull/988))
1703
+ - Reference documentation for `@terreno/ai`, `@terreno/admin-spa`, and `@terreno/test`,
1704
+ plus full READMEs for `admin-backend`, `admin-frontend`, `ai`, and `api-health`
1705
+ ([#995](https://github.com/TerrenoLabs/terreno/pull/995))
1706
+
1707
+ ### Changed
1708
+
1709
+ - Mongoose 9 support: the workspace runs on Mongoose 9.7.4 and every published
1710
+ package widens its `mongoose` peer dependency to `^8.0.0 || ^9.0.0`, so Mongoose 8
1711
+ consumers keep working ([#984](https://github.com/TerrenoLabs/terreno/pull/984))
1712
+ - All published packages are MIT licensed; they were previously Apache-2.0
1713
+ ([#985](https://github.com/TerrenoLabs/terreno/pull/985))
1714
+ - `modelRouter` accepts a wider Mongoose model generic, so models carrying custom
1715
+ query helpers, methods, or virtuals no longer need a cast ([#984](https://github.com/TerrenoLabs/terreno/pull/984))
1716
+ - `findOneOrNoneFor` takes `ModelQuery<T>` instead of Mongoose's `FilterQuery<T>`
1717
+ ([#984](https://github.com/TerrenoLabs/terreno/pull/984))
1718
+ - `@terreno/admin-backend` reads array field metadata through Mongoose's public
1719
+ `getEmbeddedSchemaType()` (falling back to the Mongoose 8 `caster`) and only emits
1720
+ `itemEnum` when the embedded enum is an array ([#984](https://github.com/TerrenoLabs/terreno/pull/984))
1721
+ - Local development requires Node >= 20.19.0 ([#984](https://github.com/TerrenoLabs/terreno/pull/984))
1722
+ - Positioning copy blocks and an honest framework comparison table
1723
+ ([#993](https://github.com/TerrenoLabs/terreno/pull/993))
1724
+ - Implementation plans and program docs for the infrastructure MCP server, the B2B
1725
+ platform program, the IP + roadmap flow, and the RTK-to-syncdb migration strategy,
1726
+ with merged plans linked to their roadmap tracking issues
1727
+ ([#990](https://github.com/TerrenoLabs/terreno/pull/990), [#996](https://github.com/TerrenoLabs/terreno/pull/996), [#998](https://github.com/TerrenoLabs/terreno/pull/998), [#999](https://github.com/TerrenoLabs/terreno/pull/999), [#1028](https://github.com/TerrenoLabs/terreno/pull/1028))
1728
+ - Test coverage, rule alignment, and explicit-any remediation across `api`, `ui`, and `rtk`
1729
+ ([#946](https://github.com/TerrenoLabs/terreno/pull/946), [#977](https://github.com/TerrenoLabs/terreno/pull/977), [#978](https://github.com/TerrenoLabs/terreno/pull/978), [#979](https://github.com/TerrenoLabs/terreno/pull/979), [#980](https://github.com/TerrenoLabs/terreno/pull/980), [#982](https://github.com/TerrenoLabs/terreno/pull/982), [#983](https://github.com/TerrenoLabs/terreno/pull/983), [#1000](https://github.com/TerrenoLabs/terreno/pull/1000), [#1001](https://github.com/TerrenoLabs/terreno/pull/1001), [#1002](https://github.com/TerrenoLabs/terreno/pull/1002))
1730
+
1731
+ ### Fixed
1732
+
1733
+ - CI queues `terreno-example` EAS native builds only for new fingerprints
1734
+ ([#965](https://github.com/TerrenoLabs/terreno/pull/965))
1735
+ - Keep the Dial In PR loop active through slow or pending CI, and only return
1736
+ broken checks when no autonomous action can advance them or user direction is
1737
+ required ([#1027](https://github.com/TerrenoLabs/terreno/pull/1027))
1738
+ - Dial In preserves existing PR descriptions instead of overwriting them
1739
+ ([#1030](https://github.com/TerrenoLabs/terreno/pull/1030))
1740
+
1741
+ ## [0.30.0] - 2026-08-03
1742
+
1743
+ Upgrade note: [`mcp-server/src/docs/upgrades/0.30.0.md`](mcp-server/src/docs/upgrades/0.30.0.md) (consolidated 0.21.0 → 0.30.0).
1744
+
1745
+ ### Added
1746
+
1747
+ - `ThumbsUpDownFeedback` UI component ([#948](https://github.com/TerrenoLabs/terreno/pull/948))
1748
+
1749
+ ### Fixed
1750
+
1751
+ - Pass `APIError` context through `modelRouter` without re-wrapping errors raised
1752
+ inside hooks or Mongoose middleware ([#967](https://github.com/TerrenoLabs/terreno/pull/967))
1753
+ - `modelRouter` no longer drops `status`, `title`, `detail`, `code`, and `meta`
1754
+ when re-throwing an `APIError` from create/update/delete handlers, populate,
1755
+ `queryFilter`, list serialization, or array-operation paths
1756
+ - Mongoose validation and cast errors from `modelRouter` writes surface per-field
1757
+ messages via `meta.fields` again instead of generic wrapper titles
1758
+ - `queryFilter` wrapper details use readable message extraction instead of
1759
+ `String(error)`
1760
+ - Framework-thrown errors follow the 0.28.0 contract: stable `title`, per-occurrence
1761
+ text in `detail`, kebab-case `code`, and status subclasses where applicable
1762
+ - `apiUnauthorizedMiddleware` only matches plain `Error("Unauthorized")`, so a
1763
+ `ForbiddenError` titled `"Unauthorized"` stays 403
1764
+
1765
+ ### Changed
1766
+
1767
+ - Add `errorDetail(error)` helper for nested `APIError` detail in framework wrappers
1768
+ - Test coverage, rule alignment, and explicit-any remediation commits ([#959](https://github.com/TerrenoLabs/terreno/pull/959), [#963](https://github.com/TerrenoLabs/terreno/pull/963), [#968](https://github.com/TerrenoLabs/terreno/pull/968), [#969](https://github.com/TerrenoLabs/terreno/pull/969))
1769
+
1770
+ ## [0.29.0] - 2026-07-31
1771
+
1772
+ ### Added
1773
+
1774
+ - Typed and draw/type signature capture fields in `@terreno/ui` ([#958](https://github.com/TerrenoLabs/terreno/pull/958))
1775
+ - Short-attention-span skill for action-first AI output ([#956](https://github.com/TerrenoLabs/terreno/pull/956))
1776
+
1777
+ ## [0.28.0] - 2026-07-30
1778
+
1779
+ ### Added
1780
+
1781
+ - Open source launch program: 15 implementation plans, task lists, and the
1782
+ `build-terreno-app` dogfooding skill ([#942](https://github.com/TerrenoLabs/terreno/pull/942))
1783
+
1784
+ ### Changed
1785
+
1786
+ - Redesign `APIError` to use standard `Error` fields for Sentry grouping ([#949](https://github.com/TerrenoLabs/terreno/pull/949))
1787
+ - Rename `SelectField` `searchable` prop to `disableSearch` ([#954](https://github.com/TerrenoLabs/terreno/pull/954))
1788
+
1789
+ ### Fixed
1790
+
1791
+ - Queue only missing platform dev builds on EAS PR slow path ([#955](https://github.com/TerrenoLabs/terreno/pull/955))
1792
+
1793
+ ## [0.27.0] - 2026-07-29
1794
+
1795
+ ### Added
1796
+
1797
+ - Terreno-native agent skills (`building-terreno-apps`, `terreno-data-fetching`,
1798
+ `terreno-backend-api`, `terreno-ui`) ([#941](https://github.com/TerrenoLabs/terreno/pull/941))
1799
+ - Explicit-any audit script for tracking `any` usage across the monorepo ([#927](https://github.com/TerrenoLabs/terreno/pull/927))
1800
+ - RBAC permissions API design doc ([#887](https://github.com/TerrenoLabs/terreno/pull/887))
1801
+
1802
+ ### Changed
1803
+
1804
+ - Document custom icon registration in `@terreno/ui` ([#910](https://github.com/TerrenoLabs/terreno/pull/910))
1805
+ - Change example-backend seed admin user to `admin@example.com` (password
1806
+ unchanged) ([#935](https://github.com/TerrenoLabs/terreno/pull/935))
1807
+ - Require frontend app login, feature exercise, and PR evidence in agent
1808
+ workflows ([#915](https://github.com/TerrenoLabs/terreno/pull/915))
1809
+
1810
+ ### Fixed
1811
+
1812
+ - Fix `Modal` Confirm button on native Android tablets ([#952](https://github.com/TerrenoLabs/terreno/pull/952))
1813
+ - Guard `@terreno/rtk` web tests against `IsWeb` platform mock leakage ([#934](https://github.com/TerrenoLabs/terreno/pull/934))
1814
+
1815
+ ## [0.26.0] - 2026-07-15
1816
+
1817
+ ### Added
1818
+
1819
+ - `IconButton` gains an `active` interaction state via `state?: "default" | "active"`
1820
+ ([#885](https://github.com/TerrenoLabs/terreno/pull/885))
1821
+
1822
+ ### Changed
1823
+
1824
+ - Remove internal barrel imports; ban new internal barrel `index.ts` files via
1825
+ Biome lint and `check:no-barrel-imports` ([#907](https://github.com/TerrenoLabs/terreno/pull/907))
1826
+ - Order Terreno planning skills by workflow step (`terreno-1-blend` through
1827
+ `terreno-5-dialin`) ([#900](https://github.com/TerrenoLabs/terreno/pull/900))
1828
+
1829
+ ### Fixed
1830
+
1831
+ - Fix `Table`/`DataTable` preview cards on the demo home page rendering as a
1832
+ floating overlay ([#911](https://github.com/TerrenoLabs/terreno/pull/911))
1833
+ - Fix demo, docs, and example-frontend production Netlify deploys silently
1834
+ no-opping on push to `master` ([#903](https://github.com/TerrenoLabs/terreno/pull/903))
1835
+ - Fix duplicate `if` keys in example-app E2E and Admin SPA integration workflows
1836
+ ([#901](https://github.com/TerrenoLabs/terreno/pull/901))
1837
+ - Stabilize AI and admin frontend test suites ([#902](https://github.com/TerrenoLabs/terreno/pull/902))
1838
+
1839
+ ## [0.25.0] - 2026-07-12
1840
+
1841
+ ### Added
1842
+
1843
+ - `@terreno/api`: HTTP client layer — `createAuthenticatedClient`,
1844
+ `normalizeApiError`, and `withApiErrorHandling` ([#870](https://github.com/TerrenoLabs/terreno/pull/870))
1845
+ - `SelectField` / `WebDropdownMenu`: type-to-filter searchable dropdown
1846
+ ([#615](https://github.com/TerrenoLabs/terreno/pull/615))
1847
+ - Demo AI palette generator (`/palette`) with WCAG contrast checks ([#863](https://github.com/TerrenoLabs/terreno/pull/863))
1848
+
1849
+ ### Changed
1850
+
1851
+ - Add repo subagents and upgrade rulesync to v9 ([#892](https://github.com/TerrenoLabs/terreno/pull/892))
1852
+ - Include run evidence (screenshots/videos) in PRs via submit and pour skills
1853
+ ([#871](https://github.com/TerrenoLabs/terreno/pull/871))
1854
+
1855
+ ### Fixed
1856
+
1857
+ - `@terreno/api`: return `401` (not `500`) for auth failures in `bun build --compile`
1858
+ binaries ([#894](https://github.com/TerrenoLabs/terreno/pull/894))
1859
+ - Website: prune docs versions correctly so `versions.json` stays valid ([#866](https://github.com/TerrenoLabs/terreno/pull/866))
1860
+
1861
+ ## [0.24.0] - 2026-07-03
1862
+
1863
+ ### Added
1864
+
1865
+ - `AiSuggestionBox`: `hidden` suggestion status, condensed collapsed states, race-safe
1866
+ expansion, and refreshed sparkles/thumbs visuals ([#865](https://github.com/TerrenoLabs/terreno/pull/865))
1867
+
1868
+ ### Changed
1869
+
1870
+ - Test coverage and explicit-any remediation commits ([#852](https://github.com/TerrenoLabs/terreno/pull/852), [#851](https://github.com/TerrenoLabs/terreno/pull/851), [#861](https://github.com/TerrenoLabs/terreno/pull/861), [#862](https://github.com/TerrenoLabs/terreno/pull/862))
1871
+
1872
+ ## [0.23.1] - 2026-07-02
1873
+
1874
+ ### Added
1875
+
1876
+ - Architectural PR review workflow ([#844](https://github.com/TerrenoLabs/terreno/pull/844))
1877
+
1878
+ ### Changed
1879
+
1880
+ - Document Cursor Cloud dev-environment setup for example-backend MongoDB ([#845](https://github.com/TerrenoLabs/terreno/pull/845))
1881
+ - Demo Appium CI non-blocking unless a mobile build feature changes ([#846](https://github.com/TerrenoLabs/terreno/pull/846))
1882
+ - Consolidate shared dependencies into Bun catalog ([#848](https://github.com/TerrenoLabs/terreno/pull/848))
1883
+ - Align dev API port with docs; clear required on warning ([#842](https://github.com/TerrenoLabs/terreno/pull/842))
1884
+
1885
+ ### Fixed
1886
+
1887
+ - Correct example-backend consent enum snapshot ordering ([#628](https://github.com/TerrenoLabs/terreno/pull/628))
1888
+ - Fix lint regression and iOS Appium smoke timeout ([#843](https://github.com/TerrenoLabs/terreno/pull/843))
1889
+ - Fix E2E todos web server startup in CI ([#811](https://github.com/TerrenoLabs/terreno/pull/811))
1890
+ - Resolve UI and demo formatting CI failures ([#789](https://github.com/TerrenoLabs/terreno/pull/789))
1891
+ - Stop Expo fingerprint churn forcing a native build on every PR ([#849](https://github.com/TerrenoLabs/terreno/pull/849))
1892
+ - `Button`: use transparent background for ghost variant ([#858](https://github.com/TerrenoLabs/terreno/pull/858))
1893
+
1894
+ ## [0.23.0] - 2026-06-26
1895
+
1896
+ ### Added
1897
+
1898
+ - `@terreno/ui` compound components expose predictable dot-suffixed test IDs ([#832](https://github.com/TerrenoLabs/terreno/pull/832), [#840](https://github.com/TerrenoLabs/terreno/pull/840))
1899
+ - Admin Script Runner CLI (`runScriptCli`, declared `args` on scripts) ([#828](https://github.com/TerrenoLabs/terreno/pull/828))
1900
+ - Consent response list/read populate `userId`; `ConsentResponseViewer` user section
1901
+ ([#833](https://github.com/TerrenoLabs/terreno/pull/833))
1902
+
1903
+ ### Changed
1904
+
1905
+ - Document `GCP_SA_*` service account secrets in cloud agent instructions ([#829](https://github.com/TerrenoLabs/terreno/pull/829))
1906
+ - Add example-backend Script Runner CI workflow and CLI docs ([#828](https://github.com/TerrenoLabs/terreno/pull/828))
1907
+
1908
+ ### Fixed
1909
+
1910
+ - Equalize `MarkdownEditor` edit and preview pane heights ([#831](https://github.com/TerrenoLabs/terreno/pull/831))
1911
+
1912
+ ## [0.22.2] - 2026-06-24
1913
+
1914
+ ### Fixed
1915
+
1916
+ - `@terreno/api`: declare `@terreno/test` as a runtime dependency so
1917
+ `@terreno/api/testing` resolves under isolated installs ([#827](https://github.com/TerrenoLabs/terreno/pull/827))
1918
+
1919
+ ### Changed
1920
+
1921
+ - Publish `@terreno/feature-flags` for the first time since `0.21.0`; all packages
1922
+ move to `0.22.2` to stay in lockstep
1923
+
1924
+ ## [0.22.1] - 2026-06-24
1925
+
1926
+ ### Fixed
1927
+
1928
+ - `@terreno/test`: pin `qs` to `^6.14.1` instead of a missing `catalog:` entry so
1929
+ the publish workflow succeeds ([#824](https://github.com/TerrenoLabs/terreno/pull/824))
1930
+
1931
+ ### Changed
1932
+
1933
+ - Publish `@terreno/test`, `@terreno/ai`, `@terreno/admin-backend`, and
1934
+ `@terreno/feature-flags` for the first time at `0.22.1`; all packages move to
1935
+ `0.22.1` to stay in lockstep
1936
+
1937
+ ## [0.22.0] - 2026-06-24
1938
+
1939
+ ### Added
1940
+
1941
+ - New `@terreno/test` package: shared Bun test helpers and in-memory MongoDB fixtures
1942
+ ([#822](https://github.com/TerrenoLabs/terreno/pull/822), [#823](https://github.com/TerrenoLabs/terreno/pull/823))
1943
+ - `Button`: `ghost` variant and `sm` size; fix `outline` button height ([#816](https://github.com/TerrenoLabs/terreno/pull/816))
1944
+ - `OpenApiMiddlewareBuilder.withOperationId()` for custom OpenAPI `operationId` ([#815](https://github.com/TerrenoLabs/terreno/pull/815))
1945
+
1946
+ ### Changed
1947
+
1948
+ - Add SyncDB local-first data layer plan and tasks ([#739](https://github.com/TerrenoLabs/terreno/pull/739))
1949
+
1950
+ ## [0.21.0] - 2026-06-22
1951
+
1952
+ Upgrade note: [`mcp-server/src/docs/upgrades/0.21.0.md`](mcp-server/src/docs/upgrades/0.21.0.md).
1953
+
1954
+ ### Changed
1955
+
1956
+ - **Breaking:** `setupServer` removed — use `TerrenoApp` instead ([#795](https://github.com/TerrenoLabs/terreno/pull/795))
1957
+ - **Breaking:** JSON object responses include `requestId` in the body ([#793](https://github.com/TerrenoLabs/terreno/pull/793))
1958
+
1959
+ ### Added
1960
+
1961
+ - Admin UI v2 backend: `schemaVersion: 2` config, bulk-patch, background tasks ([#782](https://github.com/TerrenoLabs/terreno/pull/782))
1962
+ - Traceable API logging: `createScopedLogger`, `createFeatureFlaggedLogger` ([#799](https://github.com/TerrenoLabs/terreno/pull/799))
1963
+ - `Card` redesign: `container` and `display` variants ([#375](https://github.com/TerrenoLabs/terreno/pull/375))
1964
+ - MCP documentation search: `terreno_search_docs`, `terreno_get_component_docs` ([#796](https://github.com/TerrenoLabs/terreno/pull/796))
1965
+ - `@terreno/rtk` exports `devStore` utilities ([#782](https://github.com/TerrenoLabs/terreno/pull/782))
1966
+
1967
+ ### Fixed
1968
+
1969
+ - Unblock Dependabot by upgrading `@terreno/ai` multer dependency ([#803](https://github.com/TerrenoLabs/terreno/pull/803))
1970
+ - Fix CI: install workspace deps before Trigger EAS Workflow dispatch ([#805](https://github.com/TerrenoLabs/terreno/pull/805))
1971
+
1972
+ ## [0.20.2] - 2026-06-17
1973
+
1974
+ ### Changed
1975
+
1976
+ - Normalize `Badge` height to 20px; add Badge vs SelectBadge demo ([#751](https://github.com/TerrenoLabs/terreno/pull/751))
1977
+
1978
+ ## [0.20.1] - 2026-06-16
1979
+
1980
+ ### Added
1981
+
1982
+ - `@terreno/ui`: support registering custom icons ([#771](https://github.com/TerrenoLabs/terreno/pull/771))
1983
+ - Expo skills for AI agents ([#777](https://github.com/TerrenoLabs/terreno/pull/777))
1984
+ - Admin UI v2 IP with Django-style `home.slots` ([#775](https://github.com/TerrenoLabs/terreno/pull/775))
1985
+ - `@terreno/ai`: export `./parseAiJson` subpath ([#783](https://github.com/TerrenoLabs/terreno/pull/783))
1986
+
1987
+ ### Changed
1988
+
1989
+ - Normalize skill descriptions to single-line UI summaries ([#772](https://github.com/TerrenoLabs/terreno/pull/772))
1990
+
1991
+ ### Fixed
1992
+
1993
+ - Restore demo CI ordering checks ([#770](https://github.com/TerrenoLabs/terreno/pull/770))
1994
+ - Fix Appium dev-client smoke tests on Android and iOS ([#773](https://github.com/TerrenoLabs/terreno/pull/773))
1995
+
1996
+ ## [0.16.0] - 2026-06-02
1997
+
1998
+ Historical `@terreno/api` release notes preserved from the former
1999
+ `api/CHANGELOG.md` (packages now version in lockstep from 0.20.0 onward).
2000
+
2001
+ ### Added
2002
+
2003
+ - **`modelRouter` actions** — `instanceActions` and `collectionActions` on
2004
+ `ModelRouterOptions` for named operations at `/resource/:id/action` and
2005
+ `/resource/action`
2006
+ - **`loadDocOr404`** — shared document loader used by permission middleware and
2007
+ instance actions (soft-delete-aware 404 metadata preserved)
2008
+
2009
+ ### Changed
2010
+
2011
+ - Permission middleware doc loading delegates to `loadDocOr404` (behavior-preserving)
2012
+
2013
+ ### Dependencies
2014
+
2015
+ - Added `@asteasolutions/zod-to-openapi` ^8.5.0
2016
+ - Added **`zod` ^4.3.6 as a peer dependency** for backends defining action Zod
2017
+ schemas
2018
+
2019
+ ### Migration
2020
+
2021
+ - Regenerate frontend SDKs after adding actions; `operationId` values follow
2022
+ `{tag}_{actionName}` (e.g. `todos_markComplete`)
2023
+
2024
+ ## [0.20.0] - 2026-06-14
2025
+
2026
+ Upgrade note: [`mcp-server/src/docs/upgrades/0.20.0.md`](mcp-server/src/docs/upgrades/0.20.0.md).
2027
+
2028
+ ### Changed
2029
+
2030
+ - **Breaking:** `@terreno/api` `ConfigurationApp`: `POST {basePath}/list-secrets` is
2031
+ now read-only validation/status — it no longer resolves or returns secret values;
2032
+ `PATCH {basePath}` strips `secret: true` fields ([#768](https://github.com/TerrenoLabs/terreno/pull/768))
2033
+ - **`configurationPlugin` no longer adds the `_singleton` unique index by default**
2034
+ — opt in via `enforceSingletonIndex: true`
2035
+ - **`configurationPlugin` singleton semantics are soft-delete aware**
2036
+ - **`configurationPlugin.updateConfig` applies updates via `findOneAndUpdate({$set})`
2037
+ with dotted paths** instead of `Object.assign` + `doc.save()`
2038
+ - Prefix `terreno-planning` Cursor plugin skills with `terreno-` ([#764](https://github.com/TerrenoLabs/terreno/pull/764))
2039
+ - Submit skill: merge-first PR body updates ([#765](https://github.com/TerrenoLabs/terreno/pull/765))
2040
+
2041
+ ### Added
2042
+
2043
+ - `@terreno/api` configuration and secret upgrades: `CompositeSecretProvider`,
2044
+ `CachingSecretProvider`, pluggable `permissions`, `preUpdate`/`postUpdate` hooks,
2045
+ optional `version` on secret resolution ([#768](https://github.com/TerrenoLabs/terreno/pull/768))
2046
+ - OpenFeature migration for feature flags: `MongoFeatureFlagProvider`,
2047
+ `GET …/flagConfiguration`, `useTerrenoFeatureFlags` hook ([#761](https://github.com/TerrenoLabs/terreno/pull/761))
2048
+ - `SecretProvider.getSecret(secretName, version?)` and `flattenToDotPaths` export
2049
+
2050
+ ### Deprecated
2051
+
2052
+ - Legacy `GET …/evaluate` feature-flag endpoint (sends `Deprecation`/`Sunset` headers)
2053
+ ([#761](https://github.com/TerrenoLabs/terreno/pull/761))
2054
+
2055
+ [57.0.0]: https://github.com/TerrenoLabs/terreno/releases/tag/57.0.0
2056
+ [57.0.0-beta.1]: https://github.com/TerrenoLabs/terreno/releases/tag/57.0.0-beta.1
2057
+ [56.0.0-beta.2]: https://github.com/TerrenoLabs/terreno/releases/tag/56.0.0-beta.2
2058
+ [0.30.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.30.0
2059
+ [0.29.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.29.0
2060
+ [0.28.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.28.0
2061
+ [0.27.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.27.0
2062
+ [0.26.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.26.0
2063
+ [0.25.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.25.0
2064
+ [0.24.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.24.0
2065
+ [0.23.1]: https://github.com/TerrenoLabs/terreno/releases/tag/0.23.1
2066
+ [0.23.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.23.0
2067
+ [0.22.2]: https://github.com/TerrenoLabs/terreno/releases/tag/0.22.2
2068
+ [0.22.1]: https://github.com/TerrenoLabs/terreno/releases/tag/0.22.1
2069
+ [0.22.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.22.0
2070
+ [0.21.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.21.0
2071
+ [0.20.2]: https://github.com/TerrenoLabs/terreno/releases/tag/0.20.2
2072
+ [0.20.1]: https://github.com/TerrenoLabs/terreno/releases/tag/0.20.1
2073
+ [0.20.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.20.0
2074
+ [0.16.0]: https://github.com/TerrenoLabs/terreno/releases/tag/0.16.0