@checkstack/integration-teams-backend 0.0.35 → 0.1.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 CHANGED
@@ -1,5 +1,204 @@
1
1
  # @checkstack/integration-teams-backend
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e2d6f25: feat(automation): connection picker for integration actions + restore Integrations menu
8
+
9
+ Connection-backed automation actions (Jira, Teams, Webex) now render a
10
+ working connection picker plus cascading provider dropdowns in the
11
+ visual editor, and the Integrations entry is back in the user menu.
12
+
13
+ **Contract.** `ActionDefinition` gained an optional
14
+ `connectionProviderId` (and it is surfaced on `ActionInfoSchema` and
15
+ mapped in the `listActions` router). It carries the integration
16
+ provider's fully-qualified id, derived from the provider plugin's own
17
+ `pluginMetadata.pluginId` (never a hardcoded string), so the editor
18
+ knows which provider backs an action's dropdowns and it matches the
19
+ `qualifiedId` the integration provider registry assigns.
20
+
21
+ **Providers.** Jira, Teams and Webex each export
22
+ `*_PROVIDER_LOCAL_ID` / `*_PROVIDER_QUALIFIED_ID`, register their
23
+ provider with the local id, and add a `CONNECTION_OPTIONS`
24
+ (`"connectionOptions"`) resolver name. Their `post_message` /
25
+ issue actions set `connectionProviderId` and expose `connectionId`
26
+ as an `x-options-resolver` dropdown instead of a hidden field.
27
+
28
+ **Frontend bridge.** A new `useConnectionOptionResolvers` hook
29
+ (`@checkstack/automation-frontend`, which now depends on
30
+ `@checkstack/integration-common`) turns an action's
31
+ `x-options-resolver` schema fields into live data: the
32
+ `connectionOptions` resolver lists the provider's connections via
33
+ `listConnections`, and every other resolver name is forwarded to
34
+ `getConnectionOptions` for the selected `connectionId`, passing the
35
+ live form values as `context` for dependent fields. `ProviderActionBody`
36
+ now passes this map to `DynamicForm` (it was previously missing
37
+ entirely, so connection-backed actions had no working dropdowns).
38
+
39
+ **frontend-api.** `usePluginClient` procedures now also expose a typed
40
+ imperative `.call(input)` alongside `.useQuery` / `.useMutation`, for
41
+ async callbacks that cannot host a hook (such as a `DynamicForm`
42
+ options resolver). Additive, non-breaking.
43
+
44
+ **Integrations menu.** Re-added `IntegrationMenuItem` and a new
45
+ `IntegrationsLandingPage`, wired into `integration-frontend` as a list
46
+ route and a `UserMenuItemsSlot` entry under the "Configuration" group.
47
+
48
+ **Action card polish.** The action editor's secondary metadata (id,
49
+ description, failure behaviour) is now grouped into one quiet settings
50
+ panel with consistent small uppercase "eyebrow" labels, so the action's
51
+ own configuration stays the focal point. The raw failure checkbox was
52
+ replaced with the standard `Checkbox` control, and the provider action
53
+ picker / configuration sections gained consistent section headers and a
54
+ divider. The per-step "type" dropdown was removed: an action's kind is
55
+ fixed at creation, so changing it now means adding a new step and
56
+ deleting the old one (avoids the surprising full-config reset that
57
+ switching kinds used to trigger).
58
+
59
+ **Add-step picker.** Adding a step now opens a Home-Assistant-style
60
+ dialog where the operator decides the step type up front: an "Actions"
61
+ tab lists the registered provider actions grouped by category
62
+ (searchable; picking one presets the step's `action`), and a "Blocks"
63
+ tab lists the structural building blocks (choose / parallel / repeat /
64
+ etc.). Because the concrete action is chosen here, the in-card action
65
+ switcher was removed - a step's action is fixed once created. Composite
66
+ blocks now start with an empty child list (filled via the nested
67
+ add-step picker) instead of seeding an unconfigurable empty action.
68
+
69
+ - 41c77f4: feat(automation): one-time migration of webhook subscriptions + remove legacy integration backend
70
+
71
+ **BREAKING CHANGES** (platform is in BETA — no major bump):
72
+
73
+ - `IntegrationProvider` no longer carries `config` (subscription
74
+ config) or `deliver`. The interface now models a connection provider
75
+ only: connection schema + `getConnectionOptions` + `testConnection`.
76
+ - The legacy subscription / delivery-log / event endpoints
77
+ (`listSubscriptions`, `createSubscription`, `getDeliveryLogs`,
78
+ `listEventTypes`, …) are removed from `integrationContract`.
79
+ - `delivery-coordinator`, `hook-subscriber`, `event-registry`, and the
80
+ `integrationEventExtensionPoint` are deleted. Plugins that
81
+ previously called `integrationEvents.registerEvent(...)` now
82
+ register their hooks as automation triggers via
83
+ `automationTriggerExtensionPoint.registerTrigger(...)`.
84
+ - Frontend pages `IntegrationsPage` and `DeliveryLogsPage` are gone;
85
+ the integration plugin's only remaining UI is connection
86
+ management. Subscription management lives under `/automation/...`.
87
+ - `webhook_subscriptions` and `delivery_logs` tables stay in the
88
+ database for one release as a safety net (no code reads or writes
89
+ them), and will be dropped in a follow-up migration.
90
+
91
+ **New**:
92
+
93
+ - `jira.create_issue`, `teams.post_message`, `webex.post_message`,
94
+ `webhook.send`, `integration-script.run_shell`, and
95
+ `integration-script.run_script` actions registered against the
96
+ Automation Platform with matching `*.message`, `*.delivery`,
97
+ `shell.result`, and `script.result` artifact types. The script
98
+ plugin exposes **two** actions — `run_shell` runs bash via the
99
+ shared `ShellScriptRunner` (Monaco `shell` editor), `run_script`
100
+ runs an ESM module in a Bun subprocess via `EsmScriptRunner`
101
+ (Monaco `typescript` editor + `defineIntegration` helper) — to
102
+ preserve the legacy provider split. `jira.create_issue` keeps the
103
+ dynamic field-mapping dropdown (driven by
104
+ `JIRA_RESOLVERS.FIELD_OPTIONS`).
105
+ - One-time data migration runs on boot in
106
+ `automation-backend.afterPluginsReady`. It reads
107
+ `webhook_subscriptions` via a new service RPC
108
+ `IntegrationApi.listLegacySubscriptions`, translates each row into
109
+ a single-trigger / single-action automation (marked with
110
+ `managed_by = "migrated-subscription:<id>"`), and is idempotent
111
+ across restarts.
112
+ - Failed translations are recorded in a new
113
+ `automation_migration_failures` table and surfaced via
114
+ `AutomationApi.listMigrationFailures` /
115
+ `acknowledgeMigrationFailure` so admins can review and re-create
116
+ failed entries by hand.
117
+
118
+ - 41c77f4: fix(automation): qualify action `produces` / `consumes` with the owning plugin id
119
+
120
+ `context.artifacts` showed up untyped (no fields) in the script editor
121
+ because action `produces` / `consumes` were hand-written full strings
122
+ (`"jira.issue"`) that did not match the artifact-type registry's
123
+ qualified id. The registry derives `${pluginId}.${id}`, and the plugin's
124
+ id is the package name `integration-jira`, so the artifact type actually
125
+ registers as `integration-jira.issue` — the editor's schema lookup
126
+ (`produces` vs registered `qualifiedId`) missed, leaving the artifact's
127
+ fields unknown. (Runtime store/consume happened to agree with each other
128
+ on the short string, so it "worked" but typed nothing.)
129
+
130
+ The action registry now qualifies `produces` with the owning plugin id,
131
+ exactly as it already qualifies the action's own `id` and as the
132
+ artifact-type registry qualifies the artifact type id — so the three can
133
+ never drift. Actions declare the **local** artifact id:
134
+
135
+ - `produces: "issue"` → registered as `integration-jira.issue`,
136
+ - `consumes: ["issue"]` → resolved against the owning plugin's namespace
137
+ at run time; `consumedArtifacts` is keyed by the local id, so an
138
+ action's `execute` reads `consumedArtifacts["issue"]`.
139
+
140
+ All five artifact-producing integration plugins (jira / teams / webex /
141
+ webhook / script) now declare local ids. With `produces` matching the
142
+ registered artifact type, the editor types `context.artifacts[...]` with
143
+ the real schema (e.g. `issueKey`, `projectKey`, `issueUrl`).
144
+
145
+ **BREAKING (beta):** the fully-qualified artifact type ids change from
146
+ the short form to the plugin-prefixed form, e.g. `jira.issue` →
147
+ `integration-jira.issue`. This affects how artifacts are referenced in
148
+ templates (`{{ artifact.integration-jira.issue.issueKey }}`), the TS
149
+ script `context.artifacts["integration-jira.issue"]`, and shell env names
150
+ (`$CHECKSTACK_ARTIFACT_INTEGRATION_JIRA_ISSUE_ISSUEKEY`). Artifacts are
151
+ per-run and ephemeral, so no stored-data migration is needed.
152
+
153
+ Note: this keeps the same-plugin produce→consume handoff (the current
154
+ pattern). Cross-plugin artifact consumption would need a follow-up to
155
+ allow a fully-qualified `consumes` ref.
156
+
157
+ ### Patch Changes
158
+
159
+ - 41c77f4: fix(integration): resolve `connectionStoreRef` lazily inside action `execute`
160
+
161
+ The Phase 6/7/8 refactor wired every integration backend's
162
+ `registerInit` deps to include `connectionStore: connectionStoreRef`,
163
+ expecting `integration-backend` to register the service before the
164
+ sort. But `integration-backend` calls `env.registerService(connection
165
+ StoreRef, ...)` from inside its own `init()`, not at `register()`
166
+ time — so at topological-sort time the `providedBy` map doesn't know
167
+ the service exists yet, and the sort can put a consumer (e.g.
168
+ `integration-teams`) ahead of `integration-backend`. The dev server
169
+ then fails at boot with:
170
+
171
+ > Service 'integration.connectionStore' not found for plugin
172
+ > 'integration-teams'
173
+
174
+ This change drops the init-time dep from every integration plugin and
175
+ resolves the connection store **lazily at action-execute time** via
176
+ `context.getService(connectionStoreRef)`. By the time any action's
177
+ `execute` runs, every plugin has finished init + afterPluginsReady,
178
+ so the service is always available. Tests updated to thread a mock
179
+ store through a typed `getService` stub in the action context.
180
+
181
+ No behaviour change at runtime — the actions hit the connection store
182
+ at the same moment they always did (just inside `execute` rather than
183
+ through a captured init-time closure).
184
+
185
+ - Updated dependencies [e2d6f25]
186
+ - Updated dependencies [41c77f4]
187
+ - Updated dependencies [e1a2077]
188
+ - Updated dependencies [41c77f4]
189
+ - Updated dependencies [41c77f4]
190
+ - Updated dependencies [41c77f4]
191
+ - Updated dependencies [41c77f4]
192
+ - Updated dependencies [41c77f4]
193
+ - Updated dependencies [41c77f4]
194
+ - Updated dependencies [6d52276]
195
+ - Updated dependencies [6d52276]
196
+ - Updated dependencies [35bc682]
197
+ - @checkstack/automation-backend@0.2.0
198
+ - @checkstack/integration-backend@0.2.0
199
+ - @checkstack/common@0.12.0
200
+ - @checkstack/backend-api@0.18.0
201
+
3
202
  ## 0.0.35
4
203
 
5
204
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/integration-teams-backend",
3
- "version": "0.0.35",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -11,18 +11,21 @@
11
11
  "typecheck": "tsgo -b",
12
12
  "lint": "bun run lint:code",
13
13
  "lint:code": "eslint . --max-warnings 0",
14
+ "test": "bun test",
14
15
  "pack": "bunx @checkstack/scripts plugin-pack"
15
16
  },
16
17
  "dependencies": {
17
- "@checkstack/backend-api": "0.17.0",
18
- "@checkstack/integration-backend": "0.1.29",
18
+ "@checkstack/backend-api": "0.17.1",
19
+ "@checkstack/integration-backend": "0.1.30",
20
+ "@checkstack/automation-backend": "0.1.0",
19
21
  "@checkstack/common": "0.11.0",
20
22
  "zod": "^4.2.1"
21
23
  },
22
24
  "devDependencies": {
23
25
  "@types/bun": "^1.0.0",
24
26
  "typescript": "^5.0.0",
25
- "@checkstack/tsconfig": "0.0.7"
27
+ "@checkstack/tsconfig": "0.0.7",
28
+ "@checkstack/test-utils-backend": "0.1.30"
26
29
  },
27
30
  "description": "Checkstack integration-teams-backend plugin",
28
31
  "author": {
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Behaviour tests for the Teams automation action.
3
+ *
4
+ * The ConnectionStore is faked; fetch is stubbed in-process so we can
5
+ * drive `teams.post_message.execute` through:
6
+ * 1. OAuth client-credentials token fetch
7
+ * 2. Microsoft Graph POST to `/teams/.../channels/.../messages`
8
+ *
9
+ * The action wraps the operator-authored body in a minimal Adaptive
10
+ * Card before posting — we assert that wrapping here.
11
+ */
12
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
13
+ import type { Logger } from "@checkstack/backend-api";
14
+ import { createMockLogger } from "@checkstack/test-utils-backend";
15
+ import {
16
+ connectionStoreRef,
17
+ type ConnectionStore,
18
+ } from "@checkstack/integration-backend";
19
+ import type { ServiceRef } from "@checkstack/backend-api";
20
+
21
+ import {
22
+ createTeamsActions,
23
+ teamsMessageArtifactType,
24
+ } from "./automations";
25
+
26
+ const logger = createMockLogger() as Logger;
27
+
28
+ interface CapturedFetchCall {
29
+ url: string;
30
+ init: RequestInit;
31
+ }
32
+
33
+ interface StagedResponse {
34
+ body?: unknown;
35
+ status?: number;
36
+ raw?: string;
37
+ }
38
+
39
+ function setupFetchSequence(responses: StagedResponse[]) {
40
+ const calls: CapturedFetchCall[] = [];
41
+ const originalFetch = globalThis.fetch;
42
+ let i = 0;
43
+ globalThis.fetch = (async (
44
+ input: string | URL | Request,
45
+ init?: RequestInit,
46
+ ) => {
47
+ calls.push({ url: String(input), init: init ?? {} });
48
+ const next = responses[i++] ?? { body: {}, status: 200 };
49
+ return new Response(
50
+ next.raw !== undefined ? next.raw : JSON.stringify(next.body ?? {}),
51
+ {
52
+ status: next.status ?? 200,
53
+ headers: { "Content-Type": "application/json" },
54
+ },
55
+ );
56
+ }) as typeof fetch;
57
+ return {
58
+ calls,
59
+ restore: () => {
60
+ globalThis.fetch = originalFetch;
61
+ },
62
+ };
63
+ }
64
+
65
+ function makeConnectionStore(): ConnectionStore {
66
+ return {
67
+ listConnections: mock(async () => []),
68
+ getConnection: mock(async () => undefined),
69
+ getConnectionWithCredentials: mock(async (id: string) =>
70
+ id === "conn-1"
71
+ ? {
72
+ id: "conn-1",
73
+ providerId: "teams",
74
+ name: "Tenant",
75
+ config: {
76
+ tenantId: "tenant-1",
77
+ clientId: "client-1",
78
+ clientSecret: "client-secret",
79
+ },
80
+ createdAt: new Date(),
81
+ updatedAt: new Date(),
82
+ }
83
+ : undefined,
84
+ ),
85
+ createConnection: mock(async () => {
86
+ throw new Error("not implemented in stub");
87
+ }),
88
+ updateConnection: mock(async () => {
89
+ throw new Error("not implemented in stub");
90
+ }),
91
+ deleteConnection: mock(async () => false),
92
+ findConnectionProvider: mock(async () => undefined),
93
+ } as unknown as ConnectionStore;
94
+ }
95
+
96
+ let fetchFixture: ReturnType<typeof setupFetchSequence> | undefined;
97
+
98
+ beforeEach(() => {
99
+ fetchFixture?.restore();
100
+ fetchFixture = undefined;
101
+ });
102
+
103
+ function makeCtx(store: ConnectionStore) {
104
+ return {
105
+ runId: "run-1",
106
+ automationId: "auto-1",
107
+ contextKey: null,
108
+ logger,
109
+ getService: async <T,>(ref: ServiceRef<T>): Promise<T> => {
110
+ if (ref.id === connectionStoreRef.id) {
111
+ return store as unknown as T;
112
+ }
113
+ throw new Error(`Unstubbed service requested: ${ref.id}`);
114
+ },
115
+ };
116
+ }
117
+
118
+ const ctxBase = makeCtx(makeConnectionStore());
119
+
120
+ describe("teamsMessageArtifactType", () => {
121
+ it("validates the canonical artifact shape", () => {
122
+ const ok = teamsMessageArtifactType.schema.safeParse({
123
+ messageId: "msg-1",
124
+ teamId: "team-1",
125
+ channelId: "ch-1",
126
+ });
127
+ expect(ok.success).toBe(true);
128
+ });
129
+
130
+ it("rejects when teamId is missing", () => {
131
+ const bad = teamsMessageArtifactType.schema.safeParse({
132
+ messageId: "msg-1",
133
+ channelId: "ch-1",
134
+ });
135
+ expect(bad.success).toBe(false);
136
+ });
137
+ });
138
+
139
+ describe("teams.post_message", () => {
140
+ it("posts an adaptive card and emits a teams.message artifact", async () => {
141
+ fetchFixture = setupFetchSequence([
142
+ { body: { access_token: "tok-1", expires_in: 3600 } },
143
+ { body: { id: "msg-42" } },
144
+ ]);
145
+ const actions = createTeamsActions();
146
+ const post = actions[0]!;
147
+ const result = await post.execute({
148
+ ...ctxBase,
149
+ consumedArtifacts: {},
150
+ config: {
151
+ connectionId: "conn-1",
152
+ teamId: "team-1",
153
+ channelId: "ch-1",
154
+ title: "Heads up",
155
+ body: "Something happened",
156
+ } as never,
157
+ });
158
+ expect(result.success).toBe(true);
159
+ if (!result.success) return;
160
+ expect(result.externalId).toBe("msg-42");
161
+ const artifact = result.artifact as {
162
+ messageId: string;
163
+ teamId: string;
164
+ channelId: string;
165
+ };
166
+ expect(artifact).toEqual({
167
+ messageId: "msg-42",
168
+ teamId: "team-1",
169
+ channelId: "ch-1",
170
+ });
171
+
172
+ // First call: token endpoint.
173
+ const tokenCall = fetchFixture.calls[0]!;
174
+ expect(tokenCall.url).toContain(
175
+ "https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token",
176
+ );
177
+ expect(tokenCall.init.method).toBe("POST");
178
+
179
+ // Second call: Graph messages endpoint with the bearer token.
180
+ const graphCall = fetchFixture.calls[1]!;
181
+ expect(graphCall.url).toBe(
182
+ "https://graph.microsoft.com/v1.0/teams/team-1/channels/ch-1/messages",
183
+ );
184
+ const headers = graphCall.init.headers as Record<string, string>;
185
+ expect(headers.Authorization).toBe("Bearer tok-1");
186
+ const body = JSON.parse(graphCall.init.body as string) as {
187
+ attachments: Array<{ content: string }>;
188
+ };
189
+ const card = JSON.parse(body.attachments[0]!.content) as {
190
+ type: string;
191
+ body: Array<{ text: string }>;
192
+ };
193
+ expect(card.type).toBe("AdaptiveCard");
194
+ expect(card.body[0]!.text).toBe("Heads up");
195
+ expect(card.body[1]!.text).toBe("Something happened");
196
+ });
197
+
198
+ it("omits the title TextBlock when title is not provided", async () => {
199
+ fetchFixture = setupFetchSequence([
200
+ { body: { access_token: "tok-1", expires_in: 3600 } },
201
+ { body: { id: "msg-99" } },
202
+ ]);
203
+ const actions = createTeamsActions();
204
+ const post = actions[0]!;
205
+ const result = await post.execute({
206
+ ...ctxBase,
207
+ consumedArtifacts: {},
208
+ config: {
209
+ connectionId: "conn-1",
210
+ teamId: "team-1",
211
+ channelId: "ch-1",
212
+ body: "Just the body",
213
+ } as never,
214
+ });
215
+ expect(result.success).toBe(true);
216
+ const graphCall = fetchFixture.calls[1]!;
217
+ const body = JSON.parse(graphCall.init.body as string) as {
218
+ attachments: Array<{ content: string }>;
219
+ };
220
+ const card = JSON.parse(body.attachments[0]!.content) as {
221
+ body: Array<{ text: string }>;
222
+ };
223
+ expect(card.body).toHaveLength(1);
224
+ expect(card.body[0]!.text).toBe("Just the body");
225
+ });
226
+
227
+ it("returns failure when the connection is missing", async () => {
228
+ const actions = createTeamsActions();
229
+ const post = actions[0]!;
230
+ const result = await post.execute({
231
+ ...ctxBase,
232
+ consumedArtifacts: {},
233
+ config: {
234
+ connectionId: "missing",
235
+ teamId: "team-1",
236
+ channelId: "ch-1",
237
+ body: "x",
238
+ } as never,
239
+ });
240
+ expect(result.success).toBe(false);
241
+ if (result.success) return;
242
+ expect(result.error).toMatch(/connection not found/i);
243
+ });
244
+
245
+ it("returns failure when the token request fails", async () => {
246
+ fetchFixture = setupFetchSequence([
247
+ { status: 401, raw: "invalid_client" },
248
+ ]);
249
+ const actions = createTeamsActions();
250
+ const post = actions[0]!;
251
+ const result = await post.execute({
252
+ ...ctxBase,
253
+ consumedArtifacts: {},
254
+ config: {
255
+ connectionId: "conn-1",
256
+ teamId: "team-1",
257
+ channelId: "ch-1",
258
+ body: "x",
259
+ } as never,
260
+ });
261
+ expect(result.success).toBe(false);
262
+ if (result.success) return;
263
+ expect(result.error).toMatch(/Authentication failed/);
264
+ expect(result.error).toMatch(/Token request failed \(401\)/);
265
+ });
266
+
267
+ it("returns failure when the Graph POST responds non-OK", async () => {
268
+ fetchFixture = setupFetchSequence([
269
+ { body: { access_token: "tok-1", expires_in: 3600 } },
270
+ { status: 403, raw: "forbidden" },
271
+ ]);
272
+ const actions = createTeamsActions();
273
+ const post = actions[0]!;
274
+ const result = await post.execute({
275
+ ...ctxBase,
276
+ consumedArtifacts: {},
277
+ config: {
278
+ connectionId: "conn-1",
279
+ teamId: "team-1",
280
+ channelId: "ch-1",
281
+ body: "x",
282
+ } as never,
283
+ });
284
+ expect(result.success).toBe(false);
285
+ if (result.success) return;
286
+ expect(result.error).toMatch(/Graph API error \(403\)/);
287
+ expect(result.error).toMatch(/forbidden/);
288
+ });
289
+ });