@checkstack/notification-backend 1.4.2 → 1.5.1

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 CHANGED
@@ -1,5 +1,101 @@
1
1
  # @checkstack/notification-backend
2
2
 
3
+ ## 1.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [13373ce]
8
+ - @checkstack/common@0.14.0
9
+ - @checkstack/backend-api@0.21.1
10
+ - @checkstack/cache-api@0.3.10
11
+ - @checkstack/queue-api@0.3.10
12
+ - @checkstack/auth-backend@0.5.1
13
+ - @checkstack/auth-common@0.8.1
14
+ - @checkstack/automation-backend@0.5.1
15
+ - @checkstack/notification-common@1.3.1
16
+ - @checkstack/signal-common@0.2.7
17
+ - @checkstack/cache-utils@0.2.15
18
+
19
+ ## 1.5.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 9dcc848: Plugin-owned AI tools: every domain plugin contributes its own AI tools (chat assistant + automation AI action), and `ai-backend` is platform-only.
24
+
25
+ Every plugin-specific AI tool is owned by the plugin whose domain it acts on, registered via that plugin's own `aiToolExtensionPoint` / `aiToolProjectionExtensionPoint` from its init - the same path an external plugin author uses. `ai-backend` no longer imports or depends on any capability plugin's `*-common`; the dependency direction is strictly plugin -> ai-platform. Pure helpers (`computeFieldDiff`, capability-summary, `ScriptContextKind`) live in `@checkstack/ai-common`.
26
+
27
+ Tools shipped:
28
+
29
+ - Health checks and automations: full CRUD - `healthcheck.propose` / `automation.propose` and `*.update` (`mutate`, deep-validated) and `*.delete` (`destructive`, always confirm-gated). `healthcheck.propose`'s dry-run calls the new deep `validateConfiguration` so propose-time validation matches apply-time. Assertions are validated against the collector's result schema and the canonical operator vocabulary. Capability-catalog tools (`ai.listCapabilities`, `ai.getCapabilitySchema`), script context tools (`ai.getScriptContext`, `ai.testScript`), and notify-subscriber tools (`healthcheck.notifySystemSubscribers` / `...GroupSubscribers`).
30
+ - Catalog: `catalog.createSystem` / `updateSystem` / `createGroup` / `updateGroup` (`mutate`), `catalog.deleteSystem` / `deleteGroup` (`destructive`), membership tools (`mutate`), plus `catalog.listSystems` / `listGroups` read projections.
31
+ - Incident: `incident.create` / `update` / `addUpdate` / `resolve` / `addLink` (`mutate`), `incident.delete` / `removeLink` (`destructive`), and `incident.get` / `incident.list` read projections.
32
+ - Maintenance: `maintenance.create` / `update` / `addUpdate` / `close` / `addLink` (`mutate`), `maintenance.delete` / `removeLink` (`destructive`), and `maintenance.list` / `get` read projections.
33
+ - Read projections for SLO (`slo.listObjectives`), dependency (`dependency.list`), incident (`incident.list`), healthcheck (`healthcheck.status`), and anomaly (`anomaly.explain`), each gated by the source procedure's own access rule and routed as the principal.
34
+ - Documentation grounding: `ai.searchDocs` / `ai.getDoc` over a build-time bundled docs index (BM25-ish ranking), so the assistant grounds how-to answers in Checkstack's own docs offline.
35
+ - URL introspection: `ai.probeUrl`, an SSRF-guarded read tool the assistant uses to inspect a real endpoint before drafting a health check. Update tools compute a before -> after field diff rendered on the confirm card (approve mode) or an "Applied" card (auto mode), so a change is never silent.
36
+
37
+ `ai_analyze` automation action (automation-backend, with an editor connection picker + audited tool calls): runs a bounded AI agent on the run context as the automation's `runAs` service account, so it can never exceed that identity's permissions; destructive tools are never offered; mutating tools auto-apply through the service account's client. Produces an `automation.analysis` artifact downstream actions can branch on. The agent loop is exposed as a headless `aiAgentRunnerRef` service so automation-backend can drive it without depending on ai-backend.
38
+
39
+ `notification.notifyForSubscription` is now callable by user / application principals holding `notification.send` (previously service-only). Every tool routes through the user-scoped client, so handler-side authorization is enforced exactly as a direct UI/RPC action; the resolver gate plus the propose/apply re-check at propose AND apply are the additional authority. A systemic authz regression test asserts every registered tool falls into exactly one safe authorization category.
40
+
41
+ A new `ai_transport` enum value `automation` records the AI action's tool calls in the `ai_tool_calls` audit log. No new durable state beyond that; each tool is a thin, deterministic wrapper over an existing RPC, so every pod behaves identically.
42
+
43
+ This is a beta minor.
44
+
45
+ - 9dcc848: Harden config-versioning so stored configs always migrate-then-validate and broken migration chains fail fast at boot.
46
+
47
+ - `@checkstack/backend-api` `Versioned<T>` gains `parseAssumingV1` (migrate-from-v1 then validate leniently, runtime path), `parseStrictAssumingV1` (migrate then validate strictly, editor path), and `validateMigrationChainFromV1()`. A standalone pure helper `assertMigrationChainFromV1({ version, migrations })` is the single shared implementation behind the constructor guard and `validateMigrationChainFromV1`.
48
+ - `Versioned` now validates its own v1 -> `version` chain in the constructor, which runs at module import / plugin registration. A new `no-restricted-syntax` ESLint rule bans calling `parse` / `safeParse` / `parseAsync` / `strict` directly on a `Versioned`'s `.schema` member.
49
+ - Auth strategy migration chains are validated at the `betterAuthExtensionPoint.addStrategy` chokepoint (`@checkstack/auth-backend`).
50
+ - Automation action AND trigger configs migrate-then-validate (lenient at dispatch, strict in the editor validator, recursing into `choose`/`parallel`/`repeat`/`sequence` blocks). The `run_script` / `run_shell` action configs bump to `version: 2` dropping the removed `sandbox` key, fixing the editor's `Unrecognized key: sandbox` error.
51
+ - Anomaly read path now validates: `getAnomalyConfig` / `getAnomalyAssignmentConfig` run stored records through `Versioned.parseRecord`; `PartialAnomalySettingsSchema` moved to `@checkstack/anomaly-common`. Notification ConfigService reads thread the migrations argument, and per-strategy `userConfig` is migrate-then-validated before `send()`.
52
+ - gitops-apply migrate-then-validates authored health-check config; integration connection validation routes through `safeValidate`. The latent HTTP health-check `result` schema (at `version: 3` with no migrations) now ships a pass-through v1 -> v2 -> v3 chain.
53
+
54
+ BREAKING CHANGES (fail-fast at boot, intended):
55
+
56
+ - Any `Versioned` config with `version > 1` and an incomplete or non-contiguous migration chain now throws at construction (boot) instead of failing lazily on first read. This covers every `Versioned` instance repo-wide, including future plugin types. Out-of-tree plugins shipping such a config must add the missing migration step(s); all in-repo strategies already have complete chains.
57
+ - An auth strategy declaring `configVersion > 1` without a complete chain throws at registration.
58
+ - A trigger's per-automation config is now a versioned `config: Versioned<TConfig>` instead of a bare `configSchema?`. Plugins registering triggers with `configSchema:` must wrap it: `config: new Versioned({ version: 1, schema })`. The underlying schema stays reachable via `config.schema`; triggers without per-automation config are unaffected.
59
+
60
+ State and scale: all affected reads resolve from shared Postgres / in-process registries, so every pod sees the same migrated answer. No new framework-owned current-state store.
61
+
62
+ This is a beta minor.
63
+
64
+ - 9dcc848: Align workspace dependency versions and migrate React Router to v7.
65
+
66
+ BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
67
+
68
+ Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
69
+
70
+ Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
71
+
72
+ Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
73
+
74
+ ### Patch Changes
75
+
76
+ - Updated dependencies [9dcc848]
77
+ - Updated dependencies [9dcc848]
78
+ - Updated dependencies [9dcc848]
79
+ - Updated dependencies [9dcc848]
80
+ - Updated dependencies [9dcc848]
81
+ - Updated dependencies [9dcc848]
82
+ - Updated dependencies [9dcc848]
83
+ - Updated dependencies [9dcc848]
84
+ - Updated dependencies [9dcc848]
85
+ - Updated dependencies [9dcc848]
86
+ - Updated dependencies [9dcc848]
87
+ - Updated dependencies [9dcc848]
88
+ - @checkstack/auth-backend@0.5.0
89
+ - @checkstack/auth-common@0.8.0
90
+ - @checkstack/backend-api@0.21.0
91
+ - @checkstack/automation-backend@0.5.0
92
+ - @checkstack/notification-common@1.3.0
93
+ - @checkstack/common@0.13.0
94
+ - @checkstack/cache-api@0.3.9
95
+ - @checkstack/queue-api@0.3.9
96
+ - @checkstack/signal-common@0.2.6
97
+ - @checkstack/cache-utils@0.2.14
98
+
3
99
  ## 1.4.2
4
100
 
5
101
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-backend",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,25 +15,25 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/notification-common": "1.2.1",
19
- "@checkstack/backend-api": "0.18.0",
20
- "@checkstack/automation-backend": "0.2.0",
21
- "@checkstack/cache-api": "0.3.6",
22
- "@checkstack/cache-utils": "0.2.11",
23
- "@checkstack/signal-common": "0.2.5",
24
- "@checkstack/queue-api": "0.3.6",
25
- "@checkstack/auth-backend": "0.4.31",
26
- "@checkstack/auth-common": "0.7.2",
18
+ "@checkstack/notification-common": "1.3.0",
19
+ "@checkstack/backend-api": "0.21.0",
20
+ "@checkstack/automation-backend": "0.5.0",
21
+ "@checkstack/cache-api": "0.3.9",
22
+ "@checkstack/cache-utils": "0.2.14",
23
+ "@checkstack/signal-common": "0.2.6",
24
+ "@checkstack/queue-api": "0.3.9",
25
+ "@checkstack/auth-backend": "0.5.0",
26
+ "@checkstack/auth-common": "0.8.0",
27
27
  "drizzle-orm": "^0.45.0",
28
28
  "zod": "^4.2.1",
29
- "@checkstack/common": "0.12.0",
30
- "@orpc/server": "^1.13.2"
29
+ "@checkstack/common": "0.13.0",
30
+ "@orpc/server": "^1.14.4"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@checkstack/drizzle-helper": "0.0.5",
34
- "@checkstack/scripts": "0.3.4",
34
+ "@checkstack/scripts": "0.4.0",
35
35
  "@checkstack/tsconfig": "0.0.7",
36
- "@checkstack/test-utils-backend": "0.1.31",
36
+ "@checkstack/test-utils-backend": "0.1.34",
37
37
  "@types/node": "^20.0.0",
38
38
  "drizzle-kit": "^0.31.10",
39
39
  "typescript": "^5.0.0"
@@ -15,7 +15,7 @@ import type { Logger, RpcClient } from "@checkstack/backend-api";
15
15
  import { createMockLogger } from "@checkstack/test-utils-backend";
16
16
 
17
17
  import {
18
- createNotificationActions,
18
+ notificationSendAction,
19
19
  notificationDeliveredTrigger,
20
20
  notificationFailedTrigger,
21
21
  notificationSendArtifactType,
@@ -32,6 +32,7 @@ const ctxBase = {
32
32
  getService: async <T,>(): Promise<T> => {
33
33
  throw new Error("not used");
34
34
  },
35
+ rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
35
36
  };
36
37
 
37
38
  // ─── Triggers ──────────────────────────────────────────────────────────
@@ -126,10 +127,11 @@ describe("notification.send", () => {
126
127
  ],
127
128
  },
128
129
  });
129
- const [send] = createNotificationActions({ rpcClient });
130
+ const send = notificationSendAction;
130
131
 
131
- const result = await send!.execute({
132
+ const result = await send.execute({
132
133
  ...ctxBase,
134
+ rpcClient,
133
135
  consumedArtifacts: {},
134
136
  config: {
135
137
  userId: "user-1",
@@ -169,10 +171,11 @@ describe("notification.send", () => {
169
171
  ],
170
172
  },
171
173
  });
172
- const [send] = createNotificationActions({ rpcClient });
174
+ const send = notificationSendAction;
173
175
 
174
- const result = await send!.execute({
176
+ const result = await send.execute({
175
177
  ...ctxBase,
178
+ rpcClient,
176
179
  consumedArtifacts: {},
177
180
  config: {
178
181
  userId: "user-1",
@@ -192,10 +195,11 @@ describe("notification.send", () => {
192
195
  ok: true,
193
196
  result: { deliveredCount: 1, results: [] },
194
197
  });
195
- const [send] = createNotificationActions({ rpcClient });
198
+ const send = notificationSendAction;
196
199
 
197
- await send!.execute({
200
+ await send.execute({
198
201
  ...ctxBase,
202
+ rpcClient,
199
203
  consumedArtifacts: {},
200
204
  config: {
201
205
  userId: "user-1",
@@ -220,10 +224,11 @@ describe("notification.send", () => {
220
224
  ok: true,
221
225
  result: { deliveredCount: 1, results: [] },
222
226
  });
223
- const [send] = createNotificationActions({ rpcClient });
227
+ const send = notificationSendAction;
224
228
 
225
- await send!.execute({
229
+ await send.execute({
226
230
  ...ctxBase,
231
+ rpcClient,
227
232
  consumedArtifacts: {},
228
233
  config: {
229
234
  userId: "user-1",
@@ -244,10 +249,11 @@ describe("notification.send", () => {
244
249
  ok: false,
245
250
  error: new Error("rpc unreachable"),
246
251
  });
247
- const [send] = createNotificationActions({ rpcClient });
252
+ const send = notificationSendAction;
248
253
 
249
- const result = await send!.execute({
254
+ const result = await send.execute({
250
255
  ...ctxBase,
256
+ rpcClient,
251
257
  consumedArtifacts: {},
252
258
  config: {
253
259
  userId: "user-1",
@@ -15,7 +15,7 @@
15
15
  * ones.
16
16
  */
17
17
  import { z } from "zod";
18
- import { Versioned, type RpcClient } from "@checkstack/backend-api";
18
+ import { Versioned } from "@checkstack/backend-api";
19
19
  import type {
20
20
  ActionDefinition,
21
21
  TriggerDefinition,
@@ -127,74 +127,67 @@ export const notificationSendArtifactType = {
127
127
  schema: notificationSendArtifactSchema,
128
128
  } as const;
129
129
 
130
- // ─── Action factory ────────────────────────────────────────────────────
131
-
132
- export interface NotificationActionDeps {
133
- /**
134
- * Plugin rpcClient. The action invokes
135
- * `sendTransactional` (service-mode) so notification-backend's
136
- * existing dispatch loop runs same strategy fan-out + per-attempt
137
- * persistence + (now) delivered/failed hook emission as a
138
- * code-driven send.
139
- */
140
- rpcClient: RpcClient;
141
- }
142
-
143
- export function createNotificationActions(
144
- deps: NotificationActionDeps,
145
- ): ActionDefinition<unknown, unknown>[] {
146
- const sendAction: ActionDefinition<
147
- NotificationSendConfig,
148
- NotificationSendArtifact
149
- > = {
150
- id: "send",
151
- displayName: "Send Notification",
152
- description:
153
- "Send a transactional notification to a specific user via all enabled strategies",
154
- category: "Notifications",
155
- icon: "BellRing",
156
- config: new Versioned({
157
- version: 1,
158
- schema: notificationSendConfigSchema,
159
- }),
160
- produces: "notification.send_result",
161
- execute: async ({ config, logger }) => {
162
- const notificationClient = deps.rpcClient.forPlugin(NotificationApi);
163
- const action =
164
- config.actionLabel && config.actionUrl
165
- ? { label: config.actionLabel, url: config.actionUrl }
166
- : undefined;
167
- try {
168
- const result = await notificationClient.sendTransactional({
130
+ // ─── Action: notification.send ─────────────────────────────────────────
131
+
132
+ /**
133
+ * Sends a transactional notification via `sendTransactional` through the run's
134
+ * `rpcClient` (the automation's `runAs` service account). Same strategy
135
+ * fan-out + per-attempt persistence + delivered/failed hook emission as a
136
+ * code-driven send. The service account must hold `notification.send`.
137
+ */
138
+ export const notificationSendAction: ActionDefinition<
139
+ NotificationSendConfig,
140
+ NotificationSendArtifact
141
+ > = {
142
+ id: "send",
143
+ displayName: "Send Notification",
144
+ description:
145
+ "Send a transactional notification to a specific user via all enabled strategies",
146
+ category: "Notifications",
147
+ icon: "BellRing",
148
+ config: new Versioned({
149
+ version: 1,
150
+ schema: notificationSendConfigSchema,
151
+ }),
152
+ produces: "notification.send_result",
153
+ execute: async ({ config, logger, rpcClient }) => {
154
+ const notificationClient = rpcClient.forPlugin(NotificationApi);
155
+ const action =
156
+ config.actionLabel && config.actionUrl
157
+ ? { label: config.actionLabel, url: config.actionUrl }
158
+ : undefined;
159
+ try {
160
+ const result = await notificationClient.sendTransactional({
161
+ userId: config.userId,
162
+ notification: {
163
+ title: config.title,
164
+ body: config.body,
165
+ importance: config.importance,
166
+ action,
167
+ },
168
+ });
169
+ logger.info(
170
+ `Automation sent notification to ${config.userId} (${result.deliveredCount} delivered)`,
171
+ );
172
+ return {
173
+ success: result.deliveredCount > 0,
174
+ // No single externalId — but recording the user keeps the
175
+ // run-detail UI useful when there are several send steps.
176
+ externalId: config.userId,
177
+ artifact: {
169
178
  userId: config.userId,
170
- notification: {
171
- title: config.title,
172
- body: config.body,
173
- importance: config.importance,
174
- action,
175
- },
176
- });
177
- logger.info(
178
- `Automation sent notification to ${config.userId} (${result.deliveredCount} delivered)`,
179
- );
180
- return {
181
- success: result.deliveredCount > 0,
182
- // No single externalId but recording the user keeps the
183
- // run-detail UI useful when there are several send steps.
184
- externalId: config.userId,
185
- artifact: {
186
- userId: config.userId,
187
- deliveredCount: result.deliveredCount,
188
- results: result.results,
189
- },
190
- };
191
- } catch (error) {
192
- const message = extractErrorMessage(error);
193
- logger.error(`Notification send failed: ${message}`);
194
- return { success: false, error: message };
195
- }
196
- },
197
- };
198
-
199
- return [sendAction as ActionDefinition<unknown, unknown>];
200
- }
179
+ deliveredCount: result.deliveredCount,
180
+ results: result.results,
181
+ },
182
+ };
183
+ } catch (error) {
184
+ const message = extractErrorMessage(error);
185
+ logger.error(`Notification send failed: ${message}`);
186
+ return { success: false, error: message };
187
+ }
188
+ },
189
+ };
190
+
191
+ export const notificationActions: ActionDefinition<unknown, unknown>[] = [
192
+ notificationSendAction as ActionDefinition<unknown, unknown>,
193
+ ];
package/src/index.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  automationTriggerExtensionPoint,
32
32
  } from "@checkstack/automation-backend";
33
33
  import {
34
- createNotificationActions,
34
+ notificationActions,
35
35
  notificationSendArtifactType,
36
36
  notificationTriggers,
37
37
  } from "./automations";
@@ -255,7 +255,6 @@ export default createBackendPlugin({
255
255
  logger,
256
256
  onHook,
257
257
  emitHook,
258
- rpcClient,
259
258
  }) => {
260
259
  const db = database;
261
260
 
@@ -269,14 +268,14 @@ export default createBackendPlugin({
269
268
  emitHook(notificationHooks.failed, event),
270
269
  };
271
270
 
272
- // Register automation actions now that `rpcClient` (= the
273
- // service-mode caller for `sendTransactional`) is available
274
- // and all other plugins have registered their access rules.
275
- const automationActions = env.getExtensionPoint(
271
+ // Register automation actions. The send action calls
272
+ // `sendTransactional` through the run's `rpcClient` (the automation's
273
+ // `runAs` service account), so no client is captured here.
274
+ const automationActionsExt = env.getExtensionPoint(
276
275
  automationActionExtensionPoint,
277
276
  );
278
- for (const action of createNotificationActions({ rpcClient })) {
279
- automationActions.registerAction(action, pluginMetadata);
277
+ for (const action of notificationActions) {
278
+ automationActionsExt.registerAction(action, pluginMetadata);
280
279
  }
281
280
 
282
281
  // Log registered strategies
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Contract test: every notification Versioned config that this package
3
+ * registers MUST have a COMPLETE, contiguous migration chain from version 1 to
4
+ * its current `version`. Pure STRUCTURAL check (`validateMigrationChainFromV1`
5
+ * — no `migrate()` is run), so it carries zero per-config upkeep: the day
6
+ * someone bumps a config's `version` without shipping a covering migration,
7
+ * `parseAssumingV1` would silently fail at runtime on a genuinely-v1 stored
8
+ * blob — this test turns that into a CI failure instead. See the HTTP plugin's
9
+ * equivalent test for the full rationale.
10
+ *
11
+ * It enumerates the automation actions this package registers, so a new
12
+ * built-in action config is covered automatically.
13
+ *
14
+ * NOTE: notification strategies (and their `config` / `userConfig` /
15
+ * `layoutConfig` Versioned wrappers) are registered by the strategy plugins
16
+ * (e.g. notification-smtp-backend), NOT by this core package, so they are
17
+ * guarded by an equivalent contract test in their owning plugin package — the
18
+ * test lives where the registration lives to keep the dependency direction
19
+ * intact.
20
+ */
21
+ import { describe, expect, it } from "bun:test";
22
+ import { notificationActions } from "./automations";
23
+
24
+ describe("notification config migration-chain contract", () => {
25
+ it("every registered action config has a complete v1->version chain", () => {
26
+ const actions = notificationActions;
27
+ expect(actions.length).toBeGreaterThan(0);
28
+
29
+ for (const action of actions) {
30
+ const problem = action.config.validateMigrationChainFromV1();
31
+ expect(
32
+ problem,
33
+ `Action "${action.id}" config (version ${action.config.version}) has a broken migration chain: ${problem}`,
34
+ ).toBeUndefined();
35
+ }
36
+ });
37
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { z } from "zod";
3
+ import { Versioned } from "@checkstack/backend-api";
4
+ import { resolveStrategyUserConfig } from "./resolve-user-config";
5
+
6
+ /**
7
+ * The per-strategy `userConfig` is stored as a bare blob in the user-preference
8
+ * record and was previously handed to `send()` verbatim, skipping the
9
+ * strategy's own `userConfig` schema. `resolveStrategyUserConfig` closes that
10
+ * gap by treating the stored blob as a `version: 1` record and running it
11
+ * through `strategy.userConfig.parseAssumingV1` (migrate-then-validate).
12
+ */
13
+ describe("resolveStrategyUserConfig", () => {
14
+ test("validates the stored blob against the strategy schema (strips stray keys)", async () => {
15
+ const userConfigSchema = new Versioned({
16
+ version: 1,
17
+ schema: z.object({ phoneNumber: z.string() }),
18
+ });
19
+
20
+ const result = await resolveStrategyUserConfig({
21
+ userConfigSchema,
22
+ storedUserConfig: {
23
+ phoneNumber: "+15551234567",
24
+ // Not part of the schema — must be stripped by validation.
25
+ legacy: "drop-me",
26
+ },
27
+ });
28
+
29
+ expect(result).toEqual({ phoneNumber: "+15551234567" });
30
+ });
31
+
32
+ test("applies schema defaults during validation", async () => {
33
+ const userConfigSchema = new Versioned({
34
+ version: 1,
35
+ schema: z.object({
36
+ phoneNumber: z.string(),
37
+ verbose: z.boolean().default(false),
38
+ }),
39
+ });
40
+
41
+ const result = await resolveStrategyUserConfig({
42
+ userConfigSchema,
43
+ storedUserConfig: { phoneNumber: "+1" },
44
+ });
45
+
46
+ expect(result).toEqual({ phoneNumber: "+1", verbose: false });
47
+ });
48
+
49
+ test("migrates a stored v1 blob forward via the strategy migration chain", async () => {
50
+ // A v2 schema that renamed `phone` -> `phoneNumber`. Stored data is the
51
+ // bare v1 shape; assume-v1 + migration must reshape it before validation.
52
+ const userConfigSchema = new Versioned({
53
+ version: 2,
54
+ schema: z.object({ phoneNumber: z.string() }),
55
+ migrations: [
56
+ {
57
+ fromVersion: 1,
58
+ toVersion: 2,
59
+ description: "rename phone -> phoneNumber",
60
+ migrate: (data: unknown) => {
61
+ const old = data as { phone?: string };
62
+ return { phoneNumber: old.phone ?? "" };
63
+ },
64
+ },
65
+ ],
66
+ });
67
+
68
+ const result = await resolveStrategyUserConfig({
69
+ userConfigSchema,
70
+ storedUserConfig: { phone: "+15550000000" },
71
+ });
72
+
73
+ expect(result).toEqual({ phoneNumber: "+15550000000" });
74
+ });
75
+
76
+ test("rejects a stored blob that fails validation", async () => {
77
+ const userConfigSchema = new Versioned({
78
+ version: 1,
79
+ schema: z.object({ phoneNumber: z.string() }),
80
+ });
81
+
82
+ await expect(
83
+ resolveStrategyUserConfig({
84
+ userConfigSchema,
85
+ storedUserConfig: { phoneNumber: 123 },
86
+ }),
87
+ ).rejects.toThrow();
88
+ });
89
+
90
+ test("returns undefined when the strategy declares no userConfig schema", async () => {
91
+ const result = await resolveStrategyUserConfig({
92
+ userConfigSchema: undefined,
93
+ storedUserConfig: { anything: true },
94
+ });
95
+
96
+ expect(result).toBeUndefined();
97
+ });
98
+
99
+ test("returns undefined when no userConfig was stored", async () => {
100
+ const userConfigSchema = new Versioned({
101
+ version: 1,
102
+ schema: z.object({ phoneNumber: z.string() }),
103
+ });
104
+
105
+ const result = await resolveStrategyUserConfig({
106
+ userConfigSchema,
107
+ storedUserConfig: undefined,
108
+ });
109
+
110
+ expect(result).toBeUndefined();
111
+ });
112
+ });
@@ -0,0 +1,34 @@
1
+ import type { Versioned } from "@checkstack/backend-api";
2
+
3
+ /**
4
+ * Migrate-then-validate a stored per-strategy `userConfig` blob against the
5
+ * strategy's own {@link Versioned} schema before it is handed to `send()`.
6
+ *
7
+ * The user-preference record stores `userConfig` as a bare object (see
8
+ * `UserPreferenceConfigSchema.userConfig`), so it is never validated against
9
+ * the strategy's `userConfig` schema on the read path. This helper closes that
10
+ * gap: the raw blob is treated as a `version: 1` record and run through
11
+ * `strategy.userConfig.parseAssumingV1`, which migrates it forward and
12
+ * validates it. The result is the value `send()` actually receives.
13
+ *
14
+ * Returns `undefined` when the strategy declares no `userConfig` schema or the
15
+ * user has not stored one yet, matching the previous pass-through behaviour.
16
+ */
17
+ export async function resolveStrategyUserConfig({
18
+ userConfigSchema,
19
+ storedUserConfig,
20
+ }: {
21
+ /** The strategy's own per-user config schema, if it declares one. */
22
+ userConfigSchema: Versioned<unknown> | undefined;
23
+ /** The raw `userConfig` blob read from the user-preference record. */
24
+ storedUserConfig: unknown;
25
+ }): Promise<unknown> {
26
+ if (!userConfigSchema || storedUserConfig === undefined) {
27
+ return undefined;
28
+ }
29
+
30
+ // Stored userConfig blobs are unversioned today, so assume-v1: wrap as
31
+ // { version: 1, data } and run migrate-then-validate. The shared
32
+ // `parseAssumingV1` helper lives on `Versioned`.
33
+ return userConfigSchema.parseAssumingV1(storedUserConfig);
34
+ }
package/src/router.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  type StrategyService,
51
51
  } from "./strategy-service";
52
52
  import { dispatchWithAttempt } from "./delivery-attempts";
53
+ import { resolveStrategyUserConfig } from "./resolve-user-config";
53
54
  import { extractErrorMessage } from "@checkstack/common";
54
55
 
55
56
  /**
@@ -282,6 +283,13 @@ export const createNotificationRouter = (
282
283
  type: "notification",
283
284
  };
284
285
 
286
+ // Migrate-then-validate the stored per-strategy userConfig against the
287
+ // strategy's own schema before handing it to send().
288
+ const userConfig = await resolveStrategyUserConfig({
289
+ userConfigSchema: strategy.userConfig,
290
+ storedUserConfig: pref?.userConfig,
291
+ });
292
+
285
293
  // Build send context
286
294
  const sendContext: NotificationSendContext<unknown, unknown, unknown> =
287
295
  {
@@ -293,7 +301,7 @@ export const createNotificationRouter = (
293
301
  contact,
294
302
  notification: payload,
295
303
  strategyConfig,
296
- userConfig: pref?.userConfig,
304
+ userConfig,
297
305
  layoutConfig,
298
306
  logger,
299
307
  };
@@ -1014,11 +1022,6 @@ export const createNotificationRouter = (
1014
1022
  notifyForSubscription: os.notifyForSubscription.handler(
1015
1023
  async ({ input, context }) => {
1016
1024
  const caller = context.user as { type: string; pluginId?: string };
1017
- if (caller.type !== "service" || !caller.pluginId) {
1018
- throw new ORPCError("FORBIDDEN", {
1019
- message: "notifyForSubscription is only callable from a service",
1020
- });
1021
- }
1022
1025
 
1023
1026
  const [spec] = await database
1024
1027
  .select()
@@ -1030,9 +1033,19 @@ export const createNotificationRouter = (
1030
1033
  message: `Subscription spec ${input.specId} is not registered`,
1031
1034
  });
1032
1035
  }
1033
- if (spec.ownerPlugin !== caller.pluginId) {
1036
+ // Authorization:
1037
+ // - SERVICE callers are trusted but may dispatch ONLY under their own
1038
+ // spec (a plugin cannot notify under another plugin's spec).
1039
+ // - USER / APPLICATION callers (e.g. an automation's `runAs` service
1040
+ // account) are authorized by the `notification.send` access rule,
1041
+ // enforced by autoAuthMiddleware before this handler runs; the
1042
+ // spec-ownership rule does not apply to them (they own no specs).
1043
+ if (
1044
+ caller.type === "service" &&
1045
+ (!caller.pluginId || spec.ownerPlugin !== caller.pluginId)
1046
+ ) {
1034
1047
  throw new ORPCError("FORBIDDEN", {
1035
- message: `Plugin ${caller.pluginId} cannot dispatch under spec ${input.specId} (owned by ${spec.ownerPlugin})`,
1048
+ message: `Plugin ${caller.pluginId ?? "(unknown)"} cannot dispatch under spec ${input.specId} (owned by ${spec.ownerPlugin})`,
1036
1049
  });
1037
1050
  }
1038
1051
 
@@ -1265,6 +1278,13 @@ export const createNotificationRouter = (
1265
1278
  type: "transactional",
1266
1279
  };
1267
1280
 
1281
+ // Migrate-then-validate the stored per-strategy userConfig against the
1282
+ // strategy's own schema before handing it to send().
1283
+ const userConfig = await resolveStrategyUserConfig({
1284
+ userConfigSchema: strategy.userConfig,
1285
+ storedUserConfig: userPref?.userConfig,
1286
+ });
1287
+
1268
1288
  // Build send context
1269
1289
  const sendContext: NotificationSendContext<unknown, unknown, unknown> =
1270
1290
  {
@@ -1276,7 +1296,7 @@ export const createNotificationRouter = (
1276
1296
  contact,
1277
1297
  notification: payload,
1278
1298
  strategyConfig,
1279
- userConfig: userPref?.userConfig,
1299
+ userConfig,
1280
1300
  layoutConfig,
1281
1301
  logger,
1282
1302
  };
@@ -1666,6 +1686,13 @@ export const createNotificationRouter = (
1666
1686
  }
1667
1687
  }
1668
1688
 
1689
+ // Migrate-then-validate the stored per-strategy userConfig against the
1690
+ // strategy's own schema before handing it to send().
1691
+ const userConfig = await resolveStrategyUserConfig({
1692
+ userConfigSchema: strategy.userConfig,
1693
+ storedUserConfig: pref?.userConfig,
1694
+ });
1695
+
1669
1696
  // Build send context
1670
1697
  const sendContext: NotificationSendContext<unknown, unknown, unknown> =
1671
1698
  {
@@ -1677,7 +1704,7 @@ export const createNotificationRouter = (
1677
1704
  contact,
1678
1705
  notification: testNotification,
1679
1706
  strategyConfig,
1680
- userConfig: pref?.userConfig,
1707
+ userConfig,
1681
1708
  layoutConfig,
1682
1709
  logger,
1683
1710
  };
@@ -10,6 +10,7 @@ import {
10
10
  configBoolean,
11
11
  configString,
12
12
  type ConfigService,
13
+ type Migration,
13
14
  } from "@checkstack/backend-api";
14
15
  import type { SafeDatabase } from "@checkstack/backend-api";
15
16
  import type { NotificationStrategyRegistry } from "@checkstack/backend-api";
@@ -58,6 +59,15 @@ export type UserPreferenceConfig = z.infer<typeof UserPreferenceConfigSchema>;
58
59
 
59
60
  const USER_PREFERENCE_VERSION = 1;
60
61
 
62
+ /**
63
+ * Migration chain for user-preference records. Empty today (`version: 1`), but
64
+ * threaded through every {@link ConfigService.get}/`getRedacted` read so a
65
+ * future reshape only needs to append a step here instead of touching every
66
+ * call site. Omitting the argument would silently drop the migrate-then-validate
67
+ * capability and leave reads validate-only.
68
+ */
69
+ const USER_PREFERENCE_MIGRATIONS: Migration<unknown, unknown>[] = [];
70
+
61
71
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
62
72
  // Strategy Config Schema (admin settings)
63
73
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -73,6 +83,13 @@ export type StrategyMetaConfig = z.infer<typeof StrategyMetaConfigSchema>;
73
83
 
74
84
  const STRATEGY_META_VERSION = 1;
75
85
 
86
+ /**
87
+ * Migration chain for strategy meta-config records. Empty today (`version: 1`),
88
+ * threaded through the read so future reshapes migrate-then-validate. See
89
+ * {@link USER_PREFERENCE_MIGRATIONS}.
90
+ */
91
+ const STRATEGY_META_MIGRATIONS: Migration<unknown, unknown>[] = [];
92
+
76
93
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
77
94
  // Service Interface
78
95
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -224,7 +241,8 @@ export function createStrategyService(
224
241
  const meta = await configService.get(
225
242
  strategyMetaId(strategyId),
226
243
  StrategyMetaConfigSchema,
227
- STRATEGY_META_VERSION
244
+ STRATEGY_META_VERSION,
245
+ STRATEGY_META_MIGRATIONS
228
246
  );
229
247
  return meta ?? { enabled: false };
230
248
  },
@@ -346,7 +364,8 @@ export function createStrategyService(
346
364
  return configService.get(
347
365
  userPreferenceId(userId, strategyId),
348
366
  UserPreferenceConfigSchema,
349
- USER_PREFERENCE_VERSION
367
+ USER_PREFERENCE_VERSION,
368
+ USER_PREFERENCE_MIGRATIONS
350
369
  );
351
370
  },
352
371
 
@@ -357,7 +376,8 @@ export function createStrategyService(
357
376
  return configService.getRedacted(
358
377
  userPreferenceId(userId, strategyId),
359
378
  UserPreferenceConfigSchema,
360
- USER_PREFERENCE_VERSION
379
+ USER_PREFERENCE_VERSION,
380
+ USER_PREFERENCE_MIGRATIONS
361
381
  );
362
382
  },
363
383
 
@@ -402,7 +422,8 @@ export function createStrategyService(
402
422
  const pref = await configService.get(
403
423
  id,
404
424
  UserPreferenceConfigSchema,
405
- USER_PREFERENCE_VERSION
425
+ USER_PREFERENCE_VERSION,
426
+ USER_PREFERENCE_MIGRATIONS
406
427
  );
407
428
  if (pref) {
408
429
  results.push({ strategyId, preference: pref });
@@ -433,7 +454,8 @@ export function createStrategyService(
433
454
  const pref = await configService.getRedacted(
434
455
  id,
435
456
  UserPreferenceConfigSchema,
436
- USER_PREFERENCE_VERSION
457
+ USER_PREFERENCE_VERSION,
458
+ USER_PREFERENCE_MIGRATIONS
437
459
  );
438
460
  if (pref) {
439
461
  results.push({ strategyId, preference: pref });