@checkstack/notification-backend 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/package.json +9 -9
- package/src/automations.test.ts +17 -11
- package/src/automations.ts +64 -71
- package/src/index.ts +7 -8
- package/src/migration-chain-contract.test.ts +37 -0
- package/src/resolve-user-config.test.ts +112 -0
- package/src/resolve-user-config.ts +34 -0
- package/src/router.ts +37 -10
- package/src/strategy-service.ts +27 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# @checkstack/notification-backend
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9dcc848: Plugin-owned AI tools: every domain plugin contributes its own AI tools (chat assistant + automation AI action), and `ai-backend` is platform-only.
|
|
8
|
+
|
|
9
|
+
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`.
|
|
10
|
+
|
|
11
|
+
Tools shipped:
|
|
12
|
+
|
|
13
|
+
- 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`).
|
|
14
|
+
- Catalog: `catalog.createSystem` / `updateSystem` / `createGroup` / `updateGroup` (`mutate`), `catalog.deleteSystem` / `deleteGroup` (`destructive`), membership tools (`mutate`), plus `catalog.listSystems` / `listGroups` read projections.
|
|
15
|
+
- Incident: `incident.create` / `update` / `addUpdate` / `resolve` / `addLink` (`mutate`), `incident.delete` / `removeLink` (`destructive`), and `incident.get` / `incident.list` read projections.
|
|
16
|
+
- Maintenance: `maintenance.create` / `update` / `addUpdate` / `close` / `addLink` (`mutate`), `maintenance.delete` / `removeLink` (`destructive`), and `maintenance.list` / `get` read projections.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
`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.
|
|
22
|
+
|
|
23
|
+
`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.
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
This is a beta minor.
|
|
28
|
+
|
|
29
|
+
- 9dcc848: Harden config-versioning so stored configs always migrate-then-validate and broken migration chains fail fast at boot.
|
|
30
|
+
|
|
31
|
+
- `@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`.
|
|
32
|
+
- `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.
|
|
33
|
+
- Auth strategy migration chains are validated at the `betterAuthExtensionPoint.addStrategy` chokepoint (`@checkstack/auth-backend`).
|
|
34
|
+
- 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.
|
|
35
|
+
- 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()`.
|
|
36
|
+
- 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.
|
|
37
|
+
|
|
38
|
+
BREAKING CHANGES (fail-fast at boot, intended):
|
|
39
|
+
|
|
40
|
+
- 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.
|
|
41
|
+
- An auth strategy declaring `configVersion > 1` without a complete chain throws at registration.
|
|
42
|
+
- 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.
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
This is a beta minor.
|
|
47
|
+
|
|
48
|
+
- 9dcc848: Align workspace dependency versions and migrate React Router to v7.
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
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`.
|
|
53
|
+
|
|
54
|
+
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).
|
|
55
|
+
|
|
56
|
+
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`.
|
|
57
|
+
|
|
58
|
+
### Patch Changes
|
|
59
|
+
|
|
60
|
+
- Updated dependencies [9dcc848]
|
|
61
|
+
- Updated dependencies [9dcc848]
|
|
62
|
+
- Updated dependencies [9dcc848]
|
|
63
|
+
- Updated dependencies [9dcc848]
|
|
64
|
+
- Updated dependencies [9dcc848]
|
|
65
|
+
- Updated dependencies [9dcc848]
|
|
66
|
+
- Updated dependencies [9dcc848]
|
|
67
|
+
- Updated dependencies [9dcc848]
|
|
68
|
+
- Updated dependencies [9dcc848]
|
|
69
|
+
- Updated dependencies [9dcc848]
|
|
70
|
+
- Updated dependencies [9dcc848]
|
|
71
|
+
- Updated dependencies [9dcc848]
|
|
72
|
+
- @checkstack/auth-backend@0.5.0
|
|
73
|
+
- @checkstack/auth-common@0.8.0
|
|
74
|
+
- @checkstack/backend-api@0.21.0
|
|
75
|
+
- @checkstack/automation-backend@0.5.0
|
|
76
|
+
- @checkstack/notification-common@1.3.0
|
|
77
|
+
- @checkstack/common@0.13.0
|
|
78
|
+
- @checkstack/cache-api@0.3.9
|
|
79
|
+
- @checkstack/queue-api@0.3.9
|
|
80
|
+
- @checkstack/signal-common@0.2.6
|
|
81
|
+
- @checkstack/cache-utils@0.2.14
|
|
82
|
+
|
|
3
83
|
## 1.4.2
|
|
4
84
|
|
|
5
85
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/notification-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -16,24 +16,24 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@checkstack/notification-common": "1.2.1",
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/automation-backend": "0.
|
|
21
|
-
"@checkstack/cache-api": "0.3.
|
|
22
|
-
"@checkstack/cache-utils": "0.2.
|
|
19
|
+
"@checkstack/backend-api": "0.20.0",
|
|
20
|
+
"@checkstack/automation-backend": "0.4.0",
|
|
21
|
+
"@checkstack/cache-api": "0.3.8",
|
|
22
|
+
"@checkstack/cache-utils": "0.2.13",
|
|
23
23
|
"@checkstack/signal-common": "0.2.5",
|
|
24
|
-
"@checkstack/queue-api": "0.3.
|
|
25
|
-
"@checkstack/auth-backend": "0.4.
|
|
24
|
+
"@checkstack/queue-api": "0.3.8",
|
|
25
|
+
"@checkstack/auth-backend": "0.4.33",
|
|
26
26
|
"@checkstack/auth-common": "0.7.2",
|
|
27
27
|
"drizzle-orm": "^0.45.0",
|
|
28
28
|
"zod": "^4.2.1",
|
|
29
29
|
"@checkstack/common": "0.12.0",
|
|
30
|
-
"@orpc/server": "^1.
|
|
30
|
+
"@orpc/server": "^1.14.4"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
34
34
|
"@checkstack/scripts": "0.3.4",
|
|
35
35
|
"@checkstack/tsconfig": "0.0.7",
|
|
36
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
36
|
+
"@checkstack/test-utils-backend": "0.1.33",
|
|
37
37
|
"@types/node": "^20.0.0",
|
|
38
38
|
"drizzle-kit": "^0.31.10",
|
|
39
39
|
"typescript": "^5.0.0"
|
package/src/automations.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
130
|
+
const send = notificationSendAction;
|
|
130
131
|
|
|
131
|
-
const result = await send
|
|
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
|
|
174
|
+
const send = notificationSendAction;
|
|
173
175
|
|
|
174
|
-
const result = await send
|
|
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
|
|
198
|
+
const send = notificationSendAction;
|
|
196
199
|
|
|
197
|
-
await send
|
|
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
|
|
227
|
+
const send = notificationSendAction;
|
|
224
228
|
|
|
225
|
-
await send
|
|
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
|
|
252
|
+
const send = notificationSendAction;
|
|
248
253
|
|
|
249
|
-
const result = await send
|
|
254
|
+
const result = await send.execute({
|
|
250
255
|
...ctxBase,
|
|
256
|
+
rpcClient,
|
|
251
257
|
consumedArtifacts: {},
|
|
252
258
|
config: {
|
|
253
259
|
userId: "user-1",
|
package/src/automations.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* ones.
|
|
16
16
|
*/
|
|
17
17
|
import { z } from "zod";
|
|
18
|
-
import { Versioned
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
const
|
|
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
|
|
279
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1707
|
+
userConfig,
|
|
1681
1708
|
layoutConfig,
|
|
1682
1709
|
logger,
|
|
1683
1710
|
};
|
package/src/strategy-service.ts
CHANGED
|
@@ -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 });
|