@terreno/announcements 57.21.0 → 58.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/dist/announcementsApp.js +110 -6
- package/package.json +3 -3
- package/src/announcementsApp.ts +128 -1
- package/src/tests/openApi.test.ts +248 -0
package/CHANGELOG.md
CHANGED
|
@@ -20,6 +20,95 @@ Upgrade notes for consumer action live in [`mcp-server/src/docs/upgrades/`](mcp-
|
|
|
20
20
|
|
|
21
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
22
|
|
|
23
|
+
## [58.0.0-beta.2] - 2026-11-03
|
|
24
|
+
|
|
25
|
+
Upgrade note: [`mcp-server/src/docs/upgrades/58.0.0-beta.2.md`](mcp-server/src/docs/upgrades/58.0.0-beta.2.md).
|
|
26
|
+
|
|
27
|
+
### Breaking
|
|
28
|
+
|
|
29
|
+
- Terreno 58 targets Expo SDK 58 (`~58.0.14`), React Native `0.88.0`, and React `19.3.0`. `@terreno/ui` peers `react-native` `0.88.0`. Native catalog moves with the SDK: `react-native-gesture-handler` `~3.3.0`, `react-native-reanimated` `4.7.1` + `react-native-worklets` `0.13.0`, `react-native-screens` `~4.30.0`, `react-native-webview` `14.0.1`, `@sentry/react-native` `~8.28.0`, `@shopify/flash-list` `2.3.2`, `@shopify/react-native-skia` `2.14.0`. Rebuild dev clients and store binaries.
|
|
30
|
+
- Expo Router 58: set `export const unstable_settings = {initialRouteName}` on the layout instead of passing `initialRouteName` to `<Slot>` / navigators. `SidebarNavigation` ignores the prop.
|
|
31
|
+
- Expo Router 58 static rendering: a custom `app/+html.tsx` must render `headNodes` and `bodyNodes` from `useServerDocumentContext()` (`expo-router/html`), or exported pages ship without their JavaScript. `example-frontend` is updated.
|
|
32
|
+
- React Native 0.88 generated types: type refs as `ViewInstance`, `TextInstance`, `ScrollViewInstance`, and `TextInputInstance` (`Box.scrollRef`, `TextField.inputRef`, `Text` `ref`).
|
|
33
|
+
- `create-terreno-app` scaffolds Expo `~58.0.14`, React Native `0.88.0`, and React `19.3.0`.
|
|
34
|
+
- The Terreno 58 native module baseline lands in `example-frontend` and `demo`: `@stripe/stripe-react-native` 0.81.0, `react-native-purchases` and `react-native-purchases-ui` 10.12.2, `expo-device`, `expo-crypto`, `expo-local-authentication`, `expo-system-ui`, and `expo-build-properties` (Android Kotlin 2.2.21 and `compileSdkVersion` 37 pins). Rebuild every dev client and store binary. `react-native-otp-verify` is left out because it does not support the New Architecture. The demo has a hidden **Native Modules** page that shows each module's version and whether the running binary links it. See the 58.0.0 upgrade note.
|
|
35
|
+
- `TerrenoApp` turns HTTP rate limiting on by default. Omitting `rateLimit` now mounts the limiter with the memory store (auth 20 / 15 min, api 600 / 15 min). Pass `rateLimit: false` to turn it off. On Cloud Run pass `rateLimit: {trustProxy: 1}` so unauthenticated traffic keys by client IP. See the 58.0.0 upgrade note.
|
|
36
|
+
- `@terreno/admin-frontend` no longer takes an RTK `api`. The `api` prop is removed from every admin component (`AdminProvider`, `AdminShellLayout`, `AdminHome`, `AdminModelList`, `AdminModelTable`, `AdminModelForm`, `AdminScreenRouter`, `ConfigurationScreen`, `DocumentStorageBrowser`, `ConsentHistory`, comms, jobs, org, consent, and announcement screens, and widget props), and admin hooks drop their `api` argument (`useAdminConfig(apiBase)`, `useAdminScripts(apiBase)`, `useAdminRoles(apiBase)`, `useConfigurationApi({basePath})`, `useDocumentStorageApi(basePath)`, `useConsentHistory(baseUrl)`, `useOrganizationsApi(basePath)`, `useCommsDashboardApi()`, `useJobsDashboardApi()`, and the `useAiObservability*Api()` hooks).
|
|
37
|
+
- `useAdminApi(api, routePath, modelName)` is removed. Use `useAdminModelApi(routePath, modelName)`, which keeps the same hook names and calls the same `/admin/{routePath}` URLs through the admin fetch client. `AdminApi`, `EndpointBuilder`, and admin `injectEndpoints` are gone; `@reduxjs/toolkit` and `react-redux` are no longer peers of admin-frontend.
|
|
38
|
+
- `AdminProvider` is now the required auth binding for every admin request, including ObjectId and API-only model CRUD. Cookie SPAs pass `credentials="same-origin"`; embedded apps pass `apiOrigin` and `getAuthHeaders`. Product screens that reuse admin components wrap them in `AdminProvider`. See the 58.0.0 upgrade guide.
|
|
39
|
+
- Removed the legacy RTK realtime path. Use `sync` on `modelRouter` with `@terreno/syncdb` for live collection updates. `RealtimeApp`, `SyncApp`, socket auth, `sync:delta`, and admin `admin:model.changed` events are unchanged. See the 58.0.0 upgrade note.
|
|
40
|
+
- `@terreno/api`: `modelRouter` no longer accepts `realtime` (now a TypeScript error), and the change stream no longer emits the `sync` websocket event. Removed `RealtimeConfig`, `RealtimeEvent`, `DocumentSubscription`, `QuerySubscription`, `registerRealtime`, `getRealtimeRegistry`, `findRegistryEntryByCollection`, `findRegistryEntryByRoutePath`, `updateRealtimeRegistryOptions`, `clearRealtimeRegistry`, `RealtimeRegistryEntry`, the query-subscription store (`addQuerySubscription`, `removeQuerySubscription`, `removeAllSocketQueries`, `getQuerySubscriptionsForCollection`, `computeQueryId`, `clearQueryStore`), and `MAX_MODEL_SUBSCRIPTIONS` / `MAX_DOCUMENT_SUBSCRIPTIONS` / `MAX_QUERY_SUBSCRIPTIONS`. Sockets no longer handle `subscribe:model`, `subscribe:document`, or `subscribe:query`.
|
|
41
|
+
- `@terreno/rtk`: removed `realtimeList`, `realtimeDocument`, `setRealtimeSocket`, `getRealtimeSocket`, `useSyncConnection`, and the `RealtimeEvent` type. The rest of `@terreno/rtk` (including `useSocketConnection` and `useRealtimeDebug`) is removed in the same release; see `remove-rtk.md`.
|
|
42
|
+
- Removed `@terreno/rtk` and every Redux dependency (`@reduxjs/toolkit`, `react-redux`, `redux-persist`, `@rtk-query/codegen-openapi`). `@terreno/rtk` is not published for 58. `@terreno/syncdb` is the only frontend data, request, and session layer. Follow `docs/how-to/migrate-rtk-to-syncdb.md`, which maps every removed export to its replacement, and the 58.0.0 upgrade note.
|
|
43
|
+
- `@terreno/syncdb` adds `@terreno/syncdb/api` (`createApiClient`, `ApiError`), request hooks in `@terreno/syncdb/react` (`ApiClientProvider`, `useApiQuery`, `useLazyApiQuery`, `useApiMutation`, `useSocketEvent`), `@terreno/syncdb/config` (`baseUrl`, `baseWebsocketsUrl`, `appHeaders`), `@terreno/syncdb/auth` (`createBetterAuthClient`, `SessionProvider`, `useSession`, `jwtAuthAdapter`, `useJwtAuth`, token-expiry helpers), `@terreno/syncdb/flags` (`useFeatureFlags`, `useTerrenoFeatureFlags` on the sync socket), `@terreno/syncdb/app` (`useUpgradeCheck`, `hasPermission`, `canOpenAdminPage`, `installDevConsoleLogger`, `useMCPTools`), and `@terreno/syncdb/buildNumber`.
|
|
44
|
+
- `terreno-syncdb-codegen --api-out` generates typed hooks for every non-synced OpenAPI operation (same names as the RTK codegen), replacing `@rtk-query/codegen-openapi`. `bun run sdk` writes `store/apiSdk.ts`.
|
|
45
|
+
- `@terreno/ui` `ConsentNavigator`, `AnnouncementNavigator`, `useConsentForms`, `useSubmitConsent`, `useAnnouncements`, and `useAcknowledgeAnnouncement` take a request `client` instead of an RTK `api`.
|
|
46
|
+
- `GptHistory` (`gpt/histories`) is a synced collection and keeps its ObjectId `_id` (no migration). `McpServiceToken` (`mcp/service-tokens`, read-only) is a synced collection with String ids; run `mcpServiceTokenStringIdsMigration` (`@terreno/api`) before deploying 58 frontends.
|
|
47
|
+
- `create-terreno-app`, the `terreno_bootstrap` prompt, and the `terreno_generate_screen` tool scaffold apps without Redux or `@terreno/rtk`. The terreno-mcp-local `get_rtk_state` tool, the MCP resource `terreno://docs/rtk`, and `terreno state --slice rtk` are removed; use `get_syncdb_state` and `terreno state`.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- `DataTable` columns size themselves when `width` is omitted. Each auto column starts at the wider of its header and its content (sampled from at most 50 rows, measured with a canvas on web and estimated on native), clamped to 80–360px, and auto columns grow to fill the table's container. Columns that set `width` keep it. `DataTableColumn.width` is now optional.
|
|
52
|
+
- `@terreno/syncdb` `createSyncDb({idFormats})` mints 24-hex ObjectIds for a collection set to `"objectId"`, so a model that keeps Mongoose's default ObjectId `_id` can sync without migrating its ids. `mutate()` throws on an explicit non-hex id for such a collection. `generateObjectId` and the `SyncIdFormat` type are exported.
|
|
53
|
+
- Sync `validation` nacks carry an optional `reason`: `invalid_id` when an ObjectId-keyed collection gets a non-hex id (rejected before the mutation is claimed), and `id_collision` when a create names an `_id` another document holds. A collision never touches the existing document and its message never names the holder. The ledger records the reason (`SyncMutation.nackReason`) so a redelivery replays it.
|
|
54
|
+
- `GET /sync/entities` leaves out non-hex ids for an ObjectId-keyed model instead of answering 400, so a rejected create queued for repair cannot fail the rest of the repair batch.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- The repository's agent rules now generate with rulesync 17 (up from 9). The root rule `.rulesync/rules/00-root.md` now generates `AGENTS.md`, which Devin and other AGENTS.md readers load, in place of `.devin/rules/00-root.md`. Never edit `AGENTS.md` by hand: edit the root rule and run `bun run rules`. The examples that existed only in the old hand-edited `AGENTS.md` (syncdb and RTK SDK usage) moved into the root rule, so `CLAUDE.md`, the Cursor root rule, and Copilot instructions get them too. Links in the root rule now resolve from the repository root. Generated Copilot hooks use the portable `command` field. Generated Copilot subagents no longer list `agent/runSubagent` as their only tool, so they get every default tool. Generated skills carry their `license`, `disable-model-invocation`, and Devin `triggers` frontmatter.
|
|
59
|
+
- `@terreno/syncdb/auth` `SessionProvider`: `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 (`useSession().isSigningOut`) 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.
|
|
60
|
+
|
|
61
|
+
### Removed
|
|
62
|
+
|
|
63
|
+
- `@terreno/ui` no longer exports the `Filter` and `FilterProps` aliases deprecated in 57.7.0. Import `DropdownPanel` and `DropdownPanelProps` instead. `FilterSelectMenu`, `FilterBoolean`, `FilterAccordion`, and `FilterChangesBadge` keep their names.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- `@terreno/syncdb`: logging out while `syncDb.start()` is still running no longer loses local writes or logs errors. `start()` now resolves the web encryption key from `GET /sync/key` before it loads the store, so the flush in `stop()` never needs the network after the session has ended. A `stop()` or `signOut()` call now cancels a `start()` that is running or queued ahead of it, so the transport never connects without a session. A failed key fetch is now retried on the next save instead of failing every save for the rest of the session.
|
|
68
|
+
|
|
69
|
+
## [58.0.0-beta.1] - 2026-11-02
|
|
70
|
+
|
|
71
|
+
Upgrade note: [`mcp-server/src/docs/upgrades/58.0.0-beta.1.md`](mcp-server/src/docs/upgrades/58.0.0-beta.1.md).
|
|
72
|
+
|
|
73
|
+
### Breaking
|
|
74
|
+
|
|
75
|
+
- Terreno 58 targets Expo SDK 58 (`~58.0.14`), React Native `0.88.0`, and React `19.3.0`. `@terreno/ui` peers `react-native` `0.88.0`. Native catalog moves with the SDK: `react-native-gesture-handler` `~3.3.0`, `react-native-reanimated` `4.7.1` + `react-native-worklets` `0.13.0`, `react-native-screens` `~4.30.0`, `react-native-webview` `14.0.1`, `@sentry/react-native` `~8.28.0`, `@shopify/flash-list` `2.3.2`, `@shopify/react-native-skia` `2.14.0`. Rebuild dev clients and store binaries.
|
|
76
|
+
- Expo Router 58: set `export const unstable_settings = {initialRouteName}` on the layout instead of passing `initialRouteName` to `<Slot>` / navigators. `SidebarNavigation` ignores the prop.
|
|
77
|
+
- Expo Router 58 static rendering: a custom `app/+html.tsx` must render `headNodes` and `bodyNodes` from `useServerDocumentContext()` (`expo-router/html`), or exported pages ship without their JavaScript. `example-frontend` is updated.
|
|
78
|
+
- React Native 0.88 generated types: type refs as `ViewInstance`, `TextInstance`, `ScrollViewInstance`, and `TextInputInstance` (`Box.scrollRef`, `TextField.inputRef`, `Text` `ref`).
|
|
79
|
+
- `create-terreno-app` scaffolds Expo `~58.0.14`, React Native `0.88.0`, and React `19.3.0`.
|
|
80
|
+
- The Terreno 58 native module baseline lands in `example-frontend` and `demo`: `@stripe/stripe-react-native` 0.81.0, `react-native-purchases` and `react-native-purchases-ui` 10.12.2, `expo-device`, `expo-crypto`, `expo-local-authentication`, `expo-system-ui`, and `expo-build-properties` (Android Kotlin 2.2.21 and `compileSdkVersion` 37 pins). Rebuild every dev client and store binary. `react-native-otp-verify` is left out because it does not support the New Architecture. The demo has a hidden **Native Modules** page that shows each module's version and whether the running binary links it. See the 58.0.0 upgrade note.
|
|
81
|
+
- `TerrenoApp` turns HTTP rate limiting on by default. Omitting `rateLimit` now mounts the limiter with the memory store (auth 20 / 15 min, api 600 / 15 min). Pass `rateLimit: false` to turn it off. On Cloud Run pass `rateLimit: {trustProxy: 1}` so unauthenticated traffic keys by client IP. See the 58.0.0 upgrade note.
|
|
82
|
+
- `@terreno/admin-frontend` no longer takes an RTK `api`. The `api` prop is removed from every admin component (`AdminProvider`, `AdminShellLayout`, `AdminHome`, `AdminModelList`, `AdminModelTable`, `AdminModelForm`, `AdminScreenRouter`, `ConfigurationScreen`, `DocumentStorageBrowser`, `ConsentHistory`, comms, jobs, org, consent, and announcement screens, and widget props), and admin hooks drop their `api` argument (`useAdminConfig(apiBase)`, `useAdminScripts(apiBase)`, `useAdminRoles(apiBase)`, `useConfigurationApi({basePath})`, `useDocumentStorageApi(basePath)`, `useConsentHistory(baseUrl)`, `useOrganizationsApi(basePath)`, `useCommsDashboardApi()`, `useJobsDashboardApi()`, and the `useAiObservability*Api()` hooks).
|
|
83
|
+
- `useAdminApi(api, routePath, modelName)` is removed. Use `useAdminModelApi(routePath, modelName)`, which keeps the same hook names and calls the same `/admin/{routePath}` URLs through the admin fetch client. `AdminApi`, `EndpointBuilder`, and admin `injectEndpoints` are gone; `@reduxjs/toolkit` and `react-redux` are no longer peers of admin-frontend.
|
|
84
|
+
- `AdminProvider` is now the required auth binding for every admin request, including ObjectId and API-only model CRUD. Cookie SPAs pass `credentials="same-origin"`; embedded apps pass `apiOrigin` and `getAuthHeaders`. Product screens that reuse admin components wrap them in `AdminProvider`. See the 58.0.0 upgrade guide.
|
|
85
|
+
- Removed the legacy RTK realtime path. Use `sync` on `modelRouter` with `@terreno/syncdb` for live collection updates. `RealtimeApp`, `SyncApp`, socket auth, `sync:delta`, and admin `admin:model.changed` events are unchanged. See the 58.0.0 upgrade note.
|
|
86
|
+
- `@terreno/api`: `modelRouter` no longer accepts `realtime` (now a TypeScript error), and the change stream no longer emits the `sync` websocket event. Removed `RealtimeConfig`, `RealtimeEvent`, `DocumentSubscription`, `QuerySubscription`, `registerRealtime`, `getRealtimeRegistry`, `findRegistryEntryByCollection`, `findRegistryEntryByRoutePath`, `updateRealtimeRegistryOptions`, `clearRealtimeRegistry`, `RealtimeRegistryEntry`, the query-subscription store (`addQuerySubscription`, `removeQuerySubscription`, `removeAllSocketQueries`, `getQuerySubscriptionsForCollection`, `computeQueryId`, `clearQueryStore`), and `MAX_MODEL_SUBSCRIPTIONS` / `MAX_DOCUMENT_SUBSCRIPTIONS` / `MAX_QUERY_SUBSCRIPTIONS`. Sockets no longer handle `subscribe:model`, `subscribe:document`, or `subscribe:query`.
|
|
87
|
+
- `@terreno/rtk`: removed `realtimeList`, `realtimeDocument`, `setRealtimeSocket`, `getRealtimeSocket`, `useSyncConnection`, and the `RealtimeEvent` type. The rest of `@terreno/rtk` (including `useSocketConnection` and `useRealtimeDebug`) is removed in the same release; see `remove-rtk.md`.
|
|
88
|
+
- Removed `@terreno/rtk` and every Redux dependency (`@reduxjs/toolkit`, `react-redux`, `redux-persist`, `@rtk-query/codegen-openapi`). `@terreno/rtk` is not published for 58. `@terreno/syncdb` is the only frontend data, request, and session layer. Follow `docs/how-to/migrate-rtk-to-syncdb.md`, which maps every removed export to its replacement, and the 58.0.0 upgrade note.
|
|
89
|
+
- `@terreno/syncdb` adds `@terreno/syncdb/api` (`createApiClient`, `ApiError`), request hooks in `@terreno/syncdb/react` (`ApiClientProvider`, `useApiQuery`, `useLazyApiQuery`, `useApiMutation`, `useSocketEvent`), `@terreno/syncdb/config` (`baseUrl`, `baseWebsocketsUrl`, `appHeaders`), `@terreno/syncdb/auth` (`createBetterAuthClient`, `SessionProvider`, `useSession`, `jwtAuthAdapter`, `useJwtAuth`, token-expiry helpers), `@terreno/syncdb/flags` (`useFeatureFlags`, `useTerrenoFeatureFlags` on the sync socket), `@terreno/syncdb/app` (`useUpgradeCheck`, `hasPermission`, `canOpenAdminPage`, `installDevConsoleLogger`, `useMCPTools`), and `@terreno/syncdb/buildNumber`.
|
|
90
|
+
- `terreno-syncdb-codegen --api-out` generates typed hooks for every non-synced OpenAPI operation (same names as the RTK codegen), replacing `@rtk-query/codegen-openapi`. `bun run sdk` writes `store/apiSdk.ts`.
|
|
91
|
+
- `@terreno/ui` `ConsentNavigator`, `AnnouncementNavigator`, `useConsentForms`, `useSubmitConsent`, `useAnnouncements`, and `useAcknowledgeAnnouncement` take a request `client` instead of an RTK `api`.
|
|
92
|
+
- `GptHistory` (`gpt/histories`) and `McpServiceToken` (`mcp/service-tokens`, read-only) are synced collections with String ids. Run the `gptHistoryStringIdsMigration` (`@terreno/ai`) and `mcpServiceTokenStringIdsMigration` (`@terreno/api`) before deploying 58 frontends.
|
|
93
|
+
- `create-terreno-app`, the `terreno_bootstrap` prompt, and the `terreno_generate_screen` tool scaffold apps without Redux or `@terreno/rtk`. The terreno-mcp-local `get_rtk_state` tool, the MCP resource `terreno://docs/rtk`, and `terreno state --slice rtk` are removed; use `get_syncdb_state` and `terreno state`.
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- `DataTable` columns size themselves when `width` is omitted. Each auto column starts at the wider of its header and its content (sampled from at most 50 rows, measured with a canvas on web and estimated on native), clamped to 80–360px, and auto columns grow to fill the table's container. Columns that set `width` keep it. `DataTableColumn.width` is now optional.
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
- The repository's agent rules now generate with rulesync 17 (up from 9). The root rule `.rulesync/rules/00-root.md` now generates `AGENTS.md`, which Devin and other AGENTS.md readers load, in place of `.devin/rules/00-root.md`. Never edit `AGENTS.md` by hand: edit the root rule and run `bun run rules`. The examples that existed only in the old hand-edited `AGENTS.md` (syncdb and RTK SDK usage) moved into the root rule, so `CLAUDE.md`, the Cursor root rule, and Copilot instructions get them too. Links in the root rule now resolve from the repository root. Generated Copilot hooks use the portable `command` field. Generated Copilot subagents no longer list `agent/runSubagent` as their only tool, so they get every default tool. Generated skills carry their `license`, `disable-model-invocation`, and Devin `triggers` frontmatter.
|
|
102
|
+
- `@terreno/syncdb/auth` `SessionProvider`: `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 (`useSession().isSigningOut`) 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.
|
|
103
|
+
|
|
104
|
+
### Removed
|
|
105
|
+
|
|
106
|
+
- `@terreno/ui` no longer exports the `Filter` and `FilterProps` aliases deprecated in 57.7.0. Import `DropdownPanel` and `DropdownPanelProps` instead. `FilterSelectMenu`, `FilterBoolean`, `FilterAccordion`, and `FilterChangesBadge` keep their names.
|
|
107
|
+
|
|
108
|
+
### Fixed
|
|
109
|
+
|
|
110
|
+
- `@terreno/syncdb`: logging out while `syncDb.start()` is still running no longer loses local writes or logs errors. `start()` now resolves the web encryption key from `GET /sync/key` before it loads the store, so the flush in `stop()` never needs the network after the session has ended. A `stop()` or `signOut()` call now cancels a `start()` that is running or queued ahead of it, so the transport never connects without a session. A failed key fetch is now retried on the next save instead of failing every save for the rest of the session.
|
|
111
|
+
|
|
23
112
|
## [57.21.0] - 2026-11-01
|
|
24
113
|
|
|
25
114
|
Upgrade note: [`mcp-server/src/docs/upgrades/57.21.0.md`](mcp-server/src/docs/upgrades/57.21.0.md).
|
package/dist/announcementsApp.js
CHANGED
|
@@ -74,6 +74,40 @@ const requireVisibleAnnouncement = async ({ announcement, isStaff, matchAudience
|
|
|
74
74
|
}
|
|
75
75
|
return announcement;
|
|
76
76
|
};
|
|
77
|
+
/** `AnnouncementPublic`: the shape user-facing announcement routes return. */
|
|
78
|
+
const announcementPublicOpenApiSchema = {
|
|
79
|
+
properties: {
|
|
80
|
+
body: { description: "Markdown body", type: "string" },
|
|
81
|
+
displayMode: { enum: ["modal", "banner", "feed"], type: "string" },
|
|
82
|
+
id: { type: "string" },
|
|
83
|
+
primaryAction: {
|
|
84
|
+
properties: { label: { type: "string" }, url: { type: "string" } },
|
|
85
|
+
required: ["label", "url"],
|
|
86
|
+
type: "object",
|
|
87
|
+
},
|
|
88
|
+
priority: { type: "number" },
|
|
89
|
+
publishedAt: { format: "date-time", type: "string" },
|
|
90
|
+
requiresAcknowledgement: { type: "boolean" },
|
|
91
|
+
title: { type: "string" },
|
|
92
|
+
version: { type: "number" },
|
|
93
|
+
},
|
|
94
|
+
required: [
|
|
95
|
+
"body",
|
|
96
|
+
"displayMode",
|
|
97
|
+
"id",
|
|
98
|
+
"priority",
|
|
99
|
+
"requiresAcknowledgement",
|
|
100
|
+
"title",
|
|
101
|
+
"version",
|
|
102
|
+
],
|
|
103
|
+
type: "object",
|
|
104
|
+
};
|
|
105
|
+
const platformQueryParameter = {
|
|
106
|
+
enum: ["ios", "android", "web"],
|
|
107
|
+
type: "string",
|
|
108
|
+
};
|
|
109
|
+
const PLATFORM_QUERY_DESCRIPTION = "Client platform. Defaults from the User-Agent, then web.";
|
|
110
|
+
const VERSION_QUERY_DESCRIPTION = "Client build number, compared with each announcement's minimum.";
|
|
77
111
|
class AnnouncementsApp {
|
|
78
112
|
options;
|
|
79
113
|
constructor(options) {
|
|
@@ -205,8 +239,35 @@ class AnnouncementsApp {
|
|
|
205
239
|
queryFields: ["status", "priority", "title"],
|
|
206
240
|
sort: "-priority,-publishedAt",
|
|
207
241
|
};
|
|
242
|
+
const routeOpenApi = openApi ? { openApi: openApi } : undefined;
|
|
243
|
+
// User routes: documentation only. Validation stays off; the handlers parse their own input.
|
|
244
|
+
const userRouteDocs = () => (0, api_1.createOpenApiBuilder)(routeOpenApi ?? {})
|
|
245
|
+
.withTags(["announcements"])
|
|
246
|
+
.withValidation({ enabled: false });
|
|
247
|
+
const recordedResponse = (field) => ({
|
|
248
|
+
data: { properties: { [field]: { type: "boolean" } }, required: [field], type: "object" },
|
|
249
|
+
});
|
|
208
250
|
const userRouter = (0, express_1.Router)();
|
|
209
|
-
userRouter.get("/pending", (0, api_1.authenticateMiddleware)(), (
|
|
251
|
+
userRouter.get("/pending", (0, api_1.authenticateMiddleware)(), userRouteDocs()
|
|
252
|
+
.withSummary("Get the next announcement the current user should see")
|
|
253
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
254
|
+
description: PLATFORM_QUERY_DESCRIPTION,
|
|
255
|
+
})
|
|
256
|
+
.withQueryParameter("version", { type: "integer" }, { description: VERSION_QUERY_DESCRIPTION })
|
|
257
|
+
.withResponse(200, {
|
|
258
|
+
data: {
|
|
259
|
+
properties: {
|
|
260
|
+
current: { ...announcementPublicOpenApiSchema, nullable: true },
|
|
261
|
+
remainingCount: {
|
|
262
|
+
description: "Pending announcements after the current one",
|
|
263
|
+
type: "number",
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
required: ["current", "remainingCount"],
|
|
267
|
+
type: "object",
|
|
268
|
+
},
|
|
269
|
+
}, { required: ["data"] })
|
|
270
|
+
.build(), (0, api_1.asyncHandler)(async (req, res) => {
|
|
210
271
|
const user = req.user;
|
|
211
272
|
if (!user) {
|
|
212
273
|
throw new api_1.APIError({ status: 401, title: "Authentication required" });
|
|
@@ -250,7 +311,21 @@ class AnnouncementsApp {
|
|
|
250
311
|
});
|
|
251
312
|
return res.json({ data: { current, remainingCount } });
|
|
252
313
|
}));
|
|
253
|
-
userRouter.get("/feed", (0, api_1.authenticateMiddleware)(), (
|
|
314
|
+
userRouter.get("/feed", (0, api_1.authenticateMiddleware)(), userRouteDocs()
|
|
315
|
+
.withSummary("List announcements visible to the current user")
|
|
316
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
317
|
+
description: PLATFORM_QUERY_DESCRIPTION,
|
|
318
|
+
})
|
|
319
|
+
.withQueryParameter("version", { type: "integer" }, { description: VERSION_QUERY_DESCRIPTION })
|
|
320
|
+
.withQueryParameter("limit", { type: "integer" }, { description: "Page size, 1 to 100 (default 20)" })
|
|
321
|
+
.withQueryParameter("page", { type: "integer" }, { description: "Page number, from 1" })
|
|
322
|
+
.withResponse(200, {
|
|
323
|
+
data: { items: announcementPublicOpenApiSchema, type: "array" },
|
|
324
|
+
more: { description: "Whether a later page has more announcements", type: "boolean" },
|
|
325
|
+
page: { type: "number" },
|
|
326
|
+
total: { description: "Visible announcements across all pages", type: "number" },
|
|
327
|
+
}, { required: ["data", "more", "page", "total"] })
|
|
328
|
+
.build(), (0, api_1.asyncHandler)(async (req, res) => {
|
|
254
329
|
const user = req.user;
|
|
255
330
|
if (!user) {
|
|
256
331
|
throw new api_1.APIError({ status: 401, title: "Authentication required" });
|
|
@@ -291,11 +366,20 @@ class AnnouncementsApp {
|
|
|
291
366
|
.map((announcement) => (0, announcement_1.toAnnouncementPublic)(announcement, defaultAcknowledgementPolicy));
|
|
292
367
|
return res.json({
|
|
293
368
|
data: pageItems,
|
|
369
|
+
more: start + limit < sorted.length,
|
|
294
370
|
page,
|
|
295
371
|
total: sorted.length,
|
|
296
372
|
});
|
|
297
373
|
}));
|
|
298
|
-
userRouter.post("/:id/acknowledge", (0, api_1.authenticateMiddleware)(), (
|
|
374
|
+
userRouter.post("/:id/acknowledge", (0, api_1.authenticateMiddleware)(), userRouteDocs()
|
|
375
|
+
.withSummary("Acknowledge an announcement for the current user")
|
|
376
|
+
.withPathParameter("id", { type: "string" }, { description: "Announcement id" })
|
|
377
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
378
|
+
description: PLATFORM_QUERY_DESCRIPTION,
|
|
379
|
+
})
|
|
380
|
+
.withQueryParameter("version", { type: "integer" }, { description: VERSION_QUERY_DESCRIPTION })
|
|
381
|
+
.withResponse(200, recordedResponse("acknowledged"), { required: ["data"] })
|
|
382
|
+
.build(), (0, api_1.asyncHandler)(async (req, res) => {
|
|
299
383
|
const user = req.user;
|
|
300
384
|
if (!user) {
|
|
301
385
|
throw new api_1.APIError({ status: 401, title: "Authentication required" });
|
|
@@ -324,7 +408,16 @@ class AnnouncementsApp {
|
|
|
324
408
|
}
|
|
325
409
|
return res.json({ data: { acknowledged: true } });
|
|
326
410
|
}));
|
|
327
|
-
userRouter.post("/:id/impression", (0, api_1.authenticateMiddleware)(), (
|
|
411
|
+
userRouter.post("/:id/impression", (0, api_1.authenticateMiddleware)(), userRouteDocs()
|
|
412
|
+
.withSummary("Record that the current user saw an announcement")
|
|
413
|
+
.withPathParameter("id", { type: "string" }, { description: "Announcement id" })
|
|
414
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
415
|
+
description: "Used when the body has no platform. Defaults from the User-Agent.",
|
|
416
|
+
})
|
|
417
|
+
.withQueryParameter("version", { type: "integer" }, { description: VERSION_QUERY_DESCRIPTION })
|
|
418
|
+
.withRequestBody({ platform: platformQueryParameter }, { required: false })
|
|
419
|
+
.withResponse(200, recordedResponse("recorded"), { required: ["data"] })
|
|
420
|
+
.build(), (0, api_1.asyncHandler)(async (req, res) => {
|
|
328
421
|
const user = req.user;
|
|
329
422
|
if (!user) {
|
|
330
423
|
throw new api_1.APIError({ status: 401, title: "Authentication required" });
|
|
@@ -352,7 +445,19 @@ class AnnouncementsApp {
|
|
|
352
445
|
});
|
|
353
446
|
return res.json({ data: { recorded: true } });
|
|
354
447
|
}));
|
|
355
|
-
userRouter.post("/:id/click", (0, api_1.authenticateMiddleware)(), (
|
|
448
|
+
userRouter.post("/:id/click", (0, api_1.authenticateMiddleware)(), userRouteDocs()
|
|
449
|
+
.withSummary("Record that the current user clicked an announcement's primary action")
|
|
450
|
+
.withPathParameter("id", { type: "string" }, { description: "Announcement id" })
|
|
451
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
452
|
+
description: "Used when the body has no platform. Defaults from the User-Agent.",
|
|
453
|
+
})
|
|
454
|
+
.withQueryParameter("version", { type: "integer" }, { description: VERSION_QUERY_DESCRIPTION })
|
|
455
|
+
.withRequestBody({
|
|
456
|
+
action: { enum: ["primaryAction"], required: true, type: "string" },
|
|
457
|
+
platform: platformQueryParameter,
|
|
458
|
+
})
|
|
459
|
+
.withResponse(200, recordedResponse("recorded"), { required: ["data"] })
|
|
460
|
+
.build(), (0, api_1.asyncHandler)(async (req, res) => {
|
|
356
461
|
const user = req.user;
|
|
357
462
|
if (!user) {
|
|
358
463
|
throw new api_1.APIError({ status: 401, title: "Authentication required" });
|
|
@@ -386,7 +491,6 @@ class AnnouncementsApp {
|
|
|
386
491
|
return res.json({ data: { recorded: true } });
|
|
387
492
|
}));
|
|
388
493
|
const adminRouter = (0, express_1.Router)();
|
|
389
|
-
const routeOpenApi = openApi ? { openApi: openApi } : undefined;
|
|
390
494
|
adminRouter.get("/config", [
|
|
391
495
|
(0, api_1.authenticateMiddleware)(),
|
|
392
496
|
...(routeOpenApi
|
package/package.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"url": "https://github.com/TerrenoLabs/terreno/issues"
|
|
5
5
|
},
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@terreno/api": "
|
|
7
|
+
"@terreno/api": "58.0.0-beta.2",
|
|
8
8
|
"express": "^5.2.1",
|
|
9
9
|
"luxon": "^3.7.2"
|
|
10
10
|
},
|
|
11
11
|
"description": "In-app product update announcements plugin for @terreno/api — part of Terreno, the batteries-included TypeScript framework for universal apps.",
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@biomejs/biome": "^2.3.6",
|
|
14
|
-
"@terreno/test": "
|
|
14
|
+
"@terreno/test": "58.0.0-beta.2",
|
|
15
15
|
"@types/bun": "^1.4.0",
|
|
16
16
|
"@types/express": "^5.0.6",
|
|
17
17
|
"@types/supertest": "^7.2.0",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"typecheck": "tsc -p tsconfig.typecheck.json"
|
|
56
56
|
},
|
|
57
57
|
"types": "dist/index.d.ts",
|
|
58
|
-
"version": "
|
|
58
|
+
"version": "58.0.0-beta.2"
|
|
59
59
|
}
|
package/src/announcementsApp.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ModelRouterOptions,
|
|
10
10
|
modelRouter,
|
|
11
11
|
type OpenApiMiddleware,
|
|
12
|
+
type OpenApiSchemaProperty,
|
|
12
13
|
type PermissionMethod,
|
|
13
14
|
Permissions,
|
|
14
15
|
type TerrenoPlugin,
|
|
@@ -141,6 +142,43 @@ const requireVisibleAnnouncement = async ({
|
|
|
141
142
|
return announcement;
|
|
142
143
|
};
|
|
143
144
|
|
|
145
|
+
/** `AnnouncementPublic`: the shape user-facing announcement routes return. */
|
|
146
|
+
const announcementPublicOpenApiSchema: OpenApiSchemaProperty = {
|
|
147
|
+
properties: {
|
|
148
|
+
body: {description: "Markdown body", type: "string"},
|
|
149
|
+
displayMode: {enum: ["modal", "banner", "feed"], type: "string"},
|
|
150
|
+
id: {type: "string"},
|
|
151
|
+
primaryAction: {
|
|
152
|
+
properties: {label: {type: "string"}, url: {type: "string"}},
|
|
153
|
+
required: ["label", "url"],
|
|
154
|
+
type: "object",
|
|
155
|
+
},
|
|
156
|
+
priority: {type: "number"},
|
|
157
|
+
publishedAt: {format: "date-time", type: "string"},
|
|
158
|
+
requiresAcknowledgement: {type: "boolean"},
|
|
159
|
+
title: {type: "string"},
|
|
160
|
+
version: {type: "number"},
|
|
161
|
+
},
|
|
162
|
+
required: [
|
|
163
|
+
"body",
|
|
164
|
+
"displayMode",
|
|
165
|
+
"id",
|
|
166
|
+
"priority",
|
|
167
|
+
"requiresAcknowledgement",
|
|
168
|
+
"title",
|
|
169
|
+
"version",
|
|
170
|
+
],
|
|
171
|
+
type: "object",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const platformQueryParameter: OpenApiSchemaProperty = {
|
|
175
|
+
enum: ["ios", "android", "web"],
|
|
176
|
+
type: "string",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const PLATFORM_QUERY_DESCRIPTION = "Client platform. Defaults from the User-Agent, then web.";
|
|
180
|
+
const VERSION_QUERY_DESCRIPTION = "Client build number, compared with each announcement's minimum.";
|
|
181
|
+
|
|
144
182
|
export class AnnouncementsApp implements TerrenoPlugin {
|
|
145
183
|
private options: AnnouncementsOptions;
|
|
146
184
|
|
|
@@ -283,11 +321,45 @@ export class AnnouncementsApp implements TerrenoPlugin {
|
|
|
283
321
|
sort: "-priority,-publishedAt",
|
|
284
322
|
};
|
|
285
323
|
|
|
324
|
+
const routeOpenApi = openApi ? {openApi: openApi as OpenApiMiddleware} : undefined;
|
|
325
|
+
// User routes: documentation only. Validation stays off; the handlers parse their own input.
|
|
326
|
+
const userRouteDocs = (): ReturnType<typeof createOpenApiBuilder> =>
|
|
327
|
+
createOpenApiBuilder(routeOpenApi ?? {})
|
|
328
|
+
.withTags(["announcements"])
|
|
329
|
+
.withValidation({enabled: false});
|
|
330
|
+
const recordedResponse = (field: string): Record<string, OpenApiSchemaProperty> => ({
|
|
331
|
+
data: {properties: {[field]: {type: "boolean"}}, required: [field], type: "object"},
|
|
332
|
+
});
|
|
333
|
+
|
|
286
334
|
const userRouter = Router();
|
|
287
335
|
|
|
288
336
|
userRouter.get(
|
|
289
337
|
"/pending",
|
|
290
338
|
authenticateMiddleware(),
|
|
339
|
+
userRouteDocs()
|
|
340
|
+
.withSummary("Get the next announcement the current user should see")
|
|
341
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
342
|
+
description: PLATFORM_QUERY_DESCRIPTION,
|
|
343
|
+
})
|
|
344
|
+
.withQueryParameter("version", {type: "integer"}, {description: VERSION_QUERY_DESCRIPTION})
|
|
345
|
+
.withResponse(
|
|
346
|
+
200,
|
|
347
|
+
{
|
|
348
|
+
data: {
|
|
349
|
+
properties: {
|
|
350
|
+
current: {...announcementPublicOpenApiSchema, nullable: true},
|
|
351
|
+
remainingCount: {
|
|
352
|
+
description: "Pending announcements after the current one",
|
|
353
|
+
type: "number",
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
required: ["current", "remainingCount"],
|
|
357
|
+
type: "object",
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{required: ["data"]}
|
|
361
|
+
)
|
|
362
|
+
.build(),
|
|
291
363
|
asyncHandler(async (req: Request, res: Response) => {
|
|
292
364
|
const user = req.user;
|
|
293
365
|
if (!user) {
|
|
@@ -349,6 +421,29 @@ export class AnnouncementsApp implements TerrenoPlugin {
|
|
|
349
421
|
userRouter.get(
|
|
350
422
|
"/feed",
|
|
351
423
|
authenticateMiddleware(),
|
|
424
|
+
userRouteDocs()
|
|
425
|
+
.withSummary("List announcements visible to the current user")
|
|
426
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
427
|
+
description: PLATFORM_QUERY_DESCRIPTION,
|
|
428
|
+
})
|
|
429
|
+
.withQueryParameter("version", {type: "integer"}, {description: VERSION_QUERY_DESCRIPTION})
|
|
430
|
+
.withQueryParameter(
|
|
431
|
+
"limit",
|
|
432
|
+
{type: "integer"},
|
|
433
|
+
{description: "Page size, 1 to 100 (default 20)"}
|
|
434
|
+
)
|
|
435
|
+
.withQueryParameter("page", {type: "integer"}, {description: "Page number, from 1"})
|
|
436
|
+
.withResponse(
|
|
437
|
+
200,
|
|
438
|
+
{
|
|
439
|
+
data: {items: announcementPublicOpenApiSchema, type: "array"},
|
|
440
|
+
more: {description: "Whether a later page has more announcements", type: "boolean"},
|
|
441
|
+
page: {type: "number"},
|
|
442
|
+
total: {description: "Visible announcements across all pages", type: "number"},
|
|
443
|
+
},
|
|
444
|
+
{required: ["data", "more", "page", "total"]}
|
|
445
|
+
)
|
|
446
|
+
.build(),
|
|
352
447
|
asyncHandler(async (req: Request, res: Response) => {
|
|
353
448
|
const user = req.user;
|
|
354
449
|
if (!user) {
|
|
@@ -397,6 +492,7 @@ export class AnnouncementsApp implements TerrenoPlugin {
|
|
|
397
492
|
|
|
398
493
|
return res.json({
|
|
399
494
|
data: pageItems,
|
|
495
|
+
more: start + limit < sorted.length,
|
|
400
496
|
page,
|
|
401
497
|
total: sorted.length,
|
|
402
498
|
});
|
|
@@ -406,6 +502,15 @@ export class AnnouncementsApp implements TerrenoPlugin {
|
|
|
406
502
|
userRouter.post(
|
|
407
503
|
"/:id/acknowledge",
|
|
408
504
|
authenticateMiddleware(),
|
|
505
|
+
userRouteDocs()
|
|
506
|
+
.withSummary("Acknowledge an announcement for the current user")
|
|
507
|
+
.withPathParameter("id", {type: "string"}, {description: "Announcement id"})
|
|
508
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
509
|
+
description: PLATFORM_QUERY_DESCRIPTION,
|
|
510
|
+
})
|
|
511
|
+
.withQueryParameter("version", {type: "integer"}, {description: VERSION_QUERY_DESCRIPTION})
|
|
512
|
+
.withResponse(200, recordedResponse("acknowledged"), {required: ["data"]})
|
|
513
|
+
.build(),
|
|
409
514
|
asyncHandler(async (req: Request, res: Response) => {
|
|
410
515
|
const user = req.user;
|
|
411
516
|
if (!user) {
|
|
@@ -444,6 +549,16 @@ export class AnnouncementsApp implements TerrenoPlugin {
|
|
|
444
549
|
userRouter.post(
|
|
445
550
|
"/:id/impression",
|
|
446
551
|
authenticateMiddleware(),
|
|
552
|
+
userRouteDocs()
|
|
553
|
+
.withSummary("Record that the current user saw an announcement")
|
|
554
|
+
.withPathParameter("id", {type: "string"}, {description: "Announcement id"})
|
|
555
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
556
|
+
description: "Used when the body has no platform. Defaults from the User-Agent.",
|
|
557
|
+
})
|
|
558
|
+
.withQueryParameter("version", {type: "integer"}, {description: VERSION_QUERY_DESCRIPTION})
|
|
559
|
+
.withRequestBody({platform: platformQueryParameter}, {required: false})
|
|
560
|
+
.withResponse(200, recordedResponse("recorded"), {required: ["data"]})
|
|
561
|
+
.build(),
|
|
447
562
|
asyncHandler(async (req: Request, res: Response) => {
|
|
448
563
|
const user = req.user;
|
|
449
564
|
if (!user) {
|
|
@@ -480,6 +595,19 @@ export class AnnouncementsApp implements TerrenoPlugin {
|
|
|
480
595
|
userRouter.post(
|
|
481
596
|
"/:id/click",
|
|
482
597
|
authenticateMiddleware(),
|
|
598
|
+
userRouteDocs()
|
|
599
|
+
.withSummary("Record that the current user clicked an announcement's primary action")
|
|
600
|
+
.withPathParameter("id", {type: "string"}, {description: "Announcement id"})
|
|
601
|
+
.withQueryParameter("platform", platformQueryParameter, {
|
|
602
|
+
description: "Used when the body has no platform. Defaults from the User-Agent.",
|
|
603
|
+
})
|
|
604
|
+
.withQueryParameter("version", {type: "integer"}, {description: VERSION_QUERY_DESCRIPTION})
|
|
605
|
+
.withRequestBody({
|
|
606
|
+
action: {enum: ["primaryAction"], required: true, type: "string"},
|
|
607
|
+
platform: platformQueryParameter,
|
|
608
|
+
})
|
|
609
|
+
.withResponse(200, recordedResponse("recorded"), {required: ["data"]})
|
|
610
|
+
.build(),
|
|
483
611
|
asyncHandler(async (req: Request, res: Response) => {
|
|
484
612
|
const user = req.user;
|
|
485
613
|
if (!user) {
|
|
@@ -521,7 +649,6 @@ export class AnnouncementsApp implements TerrenoPlugin {
|
|
|
521
649
|
);
|
|
522
650
|
|
|
523
651
|
const adminRouter = Router();
|
|
524
|
-
const routeOpenApi = openApi ? {openApi: openApi as OpenApiMiddleware} : undefined;
|
|
525
652
|
|
|
526
653
|
adminRouter.get(
|
|
527
654
|
"/config",
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/** Verifies `/openapi.json` documents the user-facing announcement routes. */
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
3
|
+
import {TerrenoApp, type UserModel as UserModelType} from "@terreno/api";
|
|
4
|
+
import {
|
|
5
|
+
authAsUser,
|
|
6
|
+
expectDocumentedResponse,
|
|
7
|
+
type OpenApiResponseSpec,
|
|
8
|
+
setupDb,
|
|
9
|
+
UserModel,
|
|
10
|
+
} from "@terreno/api/testing";
|
|
11
|
+
import type express from "express";
|
|
12
|
+
import {DateTime} from "luxon";
|
|
13
|
+
import supertest from "supertest";
|
|
14
|
+
import {AnnouncementsApp} from "../announcementsApp";
|
|
15
|
+
import {Announcement} from "../models/announcement";
|
|
16
|
+
import {AnnouncementAcknowledgement} from "../models/announcementAcknowledgement";
|
|
17
|
+
import {AnnouncementClickEvent} from "../models/announcementClickEvent";
|
|
18
|
+
import {AnnouncementImpression} from "../models/announcementImpression";
|
|
19
|
+
|
|
20
|
+
interface SchemaNode {
|
|
21
|
+
enum?: string[];
|
|
22
|
+
items?: SchemaNode;
|
|
23
|
+
nullable?: boolean;
|
|
24
|
+
properties?: Record<string, SchemaNode>;
|
|
25
|
+
required?: string[];
|
|
26
|
+
type?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Operation {
|
|
30
|
+
parameters?: {in: string; name: string; schema: SchemaNode}[];
|
|
31
|
+
requestBody?: {content: Record<string, {schema: SchemaNode}>; required?: boolean};
|
|
32
|
+
responses: Record<string, {content?: Record<string, {schema: SchemaNode}>}>;
|
|
33
|
+
tags?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const buildApp = (): express.Application =>
|
|
37
|
+
new TerrenoApp({
|
|
38
|
+
rateLimit: false,
|
|
39
|
+
skipListen: true,
|
|
40
|
+
userModel: UserModel as unknown as UserModelType,
|
|
41
|
+
})
|
|
42
|
+
.register(new AnnouncementsApp())
|
|
43
|
+
.build();
|
|
44
|
+
|
|
45
|
+
const response200 = (operation: Operation | undefined): SchemaNode | undefined =>
|
|
46
|
+
operation?.responses["200"]?.content?.["application/json"]?.schema;
|
|
47
|
+
|
|
48
|
+
const parameterNames = (operation: Operation | undefined): string[] =>
|
|
49
|
+
(operation?.parameters ?? []).map((parameter) => `${parameter.in}:${parameter.name}`).sort();
|
|
50
|
+
|
|
51
|
+
const clearAnnouncements = async (): Promise<void> => {
|
|
52
|
+
await Announcement.deleteMany({});
|
|
53
|
+
await AnnouncementAcknowledgement.deleteMany({});
|
|
54
|
+
await AnnouncementImpression.deleteMany({});
|
|
55
|
+
await AnnouncementClickEvent.deleteMany({});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("AnnouncementsApp OpenAPI", () => {
|
|
59
|
+
let app: express.Application;
|
|
60
|
+
let paths: Record<string, Record<string, Operation>>;
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
await setupDb();
|
|
64
|
+
await clearAnnouncements();
|
|
65
|
+
app = buildApp();
|
|
66
|
+
const res = await supertest(app).get("/openapi.json").expect(200);
|
|
67
|
+
paths = res.body.paths;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(async () => {
|
|
71
|
+
await clearAnnouncements();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("documents GET /announcements/pending", () => {
|
|
75
|
+
const operation = paths["/announcements/pending"]?.get;
|
|
76
|
+
expect(operation?.tags).toEqual(["announcements"]);
|
|
77
|
+
expect(parameterNames(operation)).toEqual(["query:platform", "query:version"]);
|
|
78
|
+
const body = response200(operation);
|
|
79
|
+
expect(body?.required).toEqual(["data"]);
|
|
80
|
+
const current = body?.properties?.data?.properties?.current;
|
|
81
|
+
expect(current?.nullable).toBe(true);
|
|
82
|
+
expect(current?.properties?.displayMode?.enum).toEqual(["modal", "banner", "feed"]);
|
|
83
|
+
expect(body?.properties?.data?.properties?.remainingCount?.type).toBe("number");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("documents GET /announcements/feed with its pagination fields", () => {
|
|
87
|
+
const operation = paths["/announcements/feed"]?.get;
|
|
88
|
+
expect(parameterNames(operation)).toEqual([
|
|
89
|
+
"query:limit",
|
|
90
|
+
"query:page",
|
|
91
|
+
"query:platform",
|
|
92
|
+
"query:version",
|
|
93
|
+
]);
|
|
94
|
+
const body = response200(operation);
|
|
95
|
+
expect(body?.required).toEqual(["data", "more", "page", "total"]);
|
|
96
|
+
expect(body?.properties?.more?.type).toBe("boolean");
|
|
97
|
+
expect(body?.properties?.data?.type).toBe("array");
|
|
98
|
+
expect(body?.properties?.data?.items?.properties?.id?.type).toBe("string");
|
|
99
|
+
expect(body?.properties?.data?.items?.nullable).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("documents the acknowledge, impression, and click actions", () => {
|
|
103
|
+
const acknowledge = paths["/announcements/{id}/acknowledge"]?.post;
|
|
104
|
+
expect(parameterNames(acknowledge)).toEqual(["path:id", "query:platform", "query:version"]);
|
|
105
|
+
expect(acknowledge?.requestBody).toBeUndefined();
|
|
106
|
+
expect(response200(acknowledge)?.properties?.data?.properties?.acknowledged?.type).toBe(
|
|
107
|
+
"boolean"
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const impression = paths["/announcements/{id}/impression"]?.post;
|
|
111
|
+
expect(parameterNames(impression)).toEqual(["path:id", "query:platform", "query:version"]);
|
|
112
|
+
expect(impression?.requestBody?.required).toBe(false);
|
|
113
|
+
expect(
|
|
114
|
+
impression?.requestBody?.content["application/json"]?.schema.properties?.platform?.enum
|
|
115
|
+
).toEqual(["ios", "android", "web"]);
|
|
116
|
+
expect(response200(impression)?.properties?.data?.properties?.recorded?.type).toBe("boolean");
|
|
117
|
+
|
|
118
|
+
const click = paths["/announcements/{id}/click"]?.post;
|
|
119
|
+
const clickBody = click?.requestBody?.content["application/json"]?.schema;
|
|
120
|
+
expect(click?.requestBody?.required).toBe(true);
|
|
121
|
+
expect(clickBody?.required).toEqual(["action"]);
|
|
122
|
+
expect(clickBody?.properties?.action?.enum).toEqual(["primaryAction"]);
|
|
123
|
+
expect(response200(click)?.properties?.data?.required).toEqual(["recorded"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("documents every field pending and feed return", async () => {
|
|
127
|
+
await Announcement.create({
|
|
128
|
+
body: "Body",
|
|
129
|
+
primaryAction: {label: "Open", url: "https://example.com"},
|
|
130
|
+
publishedAt: DateTime.utc().minus({minutes: 1}).toJSDate(),
|
|
131
|
+
status: "published",
|
|
132
|
+
title: "Documented",
|
|
133
|
+
});
|
|
134
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
135
|
+
const announcementProperties = Object.keys(
|
|
136
|
+
response200(paths["/announcements/feed"]?.get)?.properties?.data?.items?.properties ?? {}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const pending = await agent.get("/announcements/pending?platform=web").expect(200);
|
|
140
|
+
expect(announcementProperties).toEqual(
|
|
141
|
+
expect.arrayContaining(Object.keys(pending.body.data.current))
|
|
142
|
+
);
|
|
143
|
+
const pendingDataFields = Object.keys(
|
|
144
|
+
response200(paths["/announcements/pending"]?.get)?.properties?.data?.properties ?? {}
|
|
145
|
+
);
|
|
146
|
+
expect(pendingDataFields).toEqual(expect.arrayContaining(Object.keys(pending.body.data)));
|
|
147
|
+
|
|
148
|
+
const feed = await agent.get("/announcements/feed?platform=web").expect(200);
|
|
149
|
+
expect(announcementProperties).toEqual(expect.arrayContaining(Object.keys(feed.body.data[0])));
|
|
150
|
+
const feedFields = Object.keys(
|
|
151
|
+
response200(paths["/announcements/feed"]?.get)?.properties ?? {}
|
|
152
|
+
);
|
|
153
|
+
expect([...feedFields, "requestId"]).toEqual(expect.arrayContaining(Object.keys(feed.body)));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns bodies that match the documented schemas for every user route", async () => {
|
|
157
|
+
const spec = (await supertest(app).get("/openapi.json").expect(200))
|
|
158
|
+
.body as OpenApiResponseSpec;
|
|
159
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
160
|
+
const empty = await agent.get("/announcements/pending?platform=web").expect(200);
|
|
161
|
+
expect(empty.body.data.current).toBeNull();
|
|
162
|
+
expectDocumentedResponse({
|
|
163
|
+
method: "get",
|
|
164
|
+
path: "/announcements/pending",
|
|
165
|
+
response: empty,
|
|
166
|
+
spec,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const publishedAt = DateTime.utc().minus({minutes: 1}).toJSDate();
|
|
170
|
+
const withAction = await Announcement.create({
|
|
171
|
+
acknowledgementPolicy: "required",
|
|
172
|
+
body: "Body",
|
|
173
|
+
primaryAction: {label: "Open", url: "https://example.com"},
|
|
174
|
+
publishedAt,
|
|
175
|
+
status: "published",
|
|
176
|
+
title: "With action",
|
|
177
|
+
});
|
|
178
|
+
await Announcement.create({body: "Body", publishedAt, status: "published", title: "Plain"});
|
|
179
|
+
const id = withAction._id.toString();
|
|
180
|
+
|
|
181
|
+
const pending = await agent.get("/announcements/pending?platform=web").expect(200);
|
|
182
|
+
expectDocumentedResponse({
|
|
183
|
+
method: "get",
|
|
184
|
+
path: "/announcements/pending",
|
|
185
|
+
response: pending,
|
|
186
|
+
spec,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const impression = await agent
|
|
190
|
+
.post(`/announcements/${id}/impression`)
|
|
191
|
+
.send({platform: "web"})
|
|
192
|
+
.expect(200);
|
|
193
|
+
expectDocumentedResponse({
|
|
194
|
+
method: "post",
|
|
195
|
+
path: "/announcements/{id}/impression",
|
|
196
|
+
response: impression,
|
|
197
|
+
spec,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const click = await agent
|
|
201
|
+
.post(`/announcements/${id}/click?platform=web`)
|
|
202
|
+
.send({action: "primaryAction"})
|
|
203
|
+
.expect(200);
|
|
204
|
+
expectDocumentedResponse({
|
|
205
|
+
method: "post",
|
|
206
|
+
path: "/announcements/{id}/click",
|
|
207
|
+
response: click,
|
|
208
|
+
spec,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const acknowledge = await agent
|
|
212
|
+
.post(`/announcements/${id}/acknowledge?platform=web`)
|
|
213
|
+
.expect(200);
|
|
214
|
+
expectDocumentedResponse({
|
|
215
|
+
method: "post",
|
|
216
|
+
path: "/announcements/{id}/acknowledge",
|
|
217
|
+
response: acknowledge,
|
|
218
|
+
spec,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
for (const page of [1, 2]) {
|
|
222
|
+
const feed = await agent
|
|
223
|
+
.get(`/announcements/feed?platform=web&limit=1&page=${page}`)
|
|
224
|
+
.expect(200);
|
|
225
|
+
expectDocumentedResponse({method: "get", path: "/announcements/feed", response: feed, spec});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("reports more on every feed page but the last so clients keep the list envelope", async () => {
|
|
230
|
+
const publishedAt = DateTime.utc().minus({minutes: 1}).toJSDate();
|
|
231
|
+
for (const title of ["One", "Two", "Three"]) {
|
|
232
|
+
await Announcement.create({body: "Body", publishedAt, status: "published", title});
|
|
233
|
+
}
|
|
234
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
235
|
+
|
|
236
|
+
const pages: {count: number; more: boolean; total: number}[] = [];
|
|
237
|
+
for (const page of [1, 2, 3]) {
|
|
238
|
+
const res = await agent.get(`/announcements/feed?platform=web&limit=2&page=${page}`);
|
|
239
|
+
pages.push({count: res.body.data.length, more: res.body.more, total: res.body.total});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
expect(pages).toEqual([
|
|
243
|
+
{count: 2, more: true, total: 3},
|
|
244
|
+
{count: 1, more: false, total: 3},
|
|
245
|
+
{count: 0, more: false, total: 3},
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
});
|