@vellumai/vellum-gateway 0.4.29 → 0.4.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/ARCHITECTURE.md +19 -18
  2. package/README.md +8 -7
  3. package/package.json +1 -1
  4. package/src/__tests__/block-kit-builder.test.ts +58 -0
  5. package/src/__tests__/{ingress-control-plane-proxy.test.ts → contacts-control-plane-proxy.test.ts} +58 -41
  6. package/src/__tests__/contacts-control-plane-route-match.test.ts +86 -0
  7. package/src/__tests__/schema.test.ts +7 -26
  8. package/src/__tests__/slack-app-home.test.ts +155 -0
  9. package/src/__tests__/slack-deliver-ratelimit.test.ts +193 -0
  10. package/src/__tests__/slack-deliver.test.ts +632 -25
  11. package/src/__tests__/slack-display-name.test.ts +255 -0
  12. package/src/__tests__/slack-errors.test.ts +77 -0
  13. package/src/__tests__/slack-normalize.test.ts +193 -26
  14. package/src/__tests__/slack-reaction-normalize.test.ts +180 -0
  15. package/src/__tests__/text-to-blocks.test.ts +164 -0
  16. package/src/db/connection.ts +52 -0
  17. package/src/db/slack-store.ts +90 -0
  18. package/src/http/routes/{ingress-control-plane-proxy.ts → contacts-control-plane-proxy.ts} +21 -18
  19. package/src/http/routes/contacts-control-plane-route-match.ts +56 -0
  20. package/src/http/routes/slack-deliver.ts +649 -40
  21. package/src/index.ts +30 -22
  22. package/src/schema.ts +84 -51
  23. package/src/slack/app-home.ts +120 -0
  24. package/src/slack/block-kit-builder.test.ts +281 -0
  25. package/src/slack/block-kit-builder.ts +229 -0
  26. package/src/slack/errors.ts +67 -0
  27. package/src/slack/normalize.test.ts +254 -0
  28. package/src/slack/normalize.ts +461 -1
  29. package/src/slack/socket-mode.ts +283 -37
  30. package/src/slack/text-to-blocks.ts +244 -0
  31. package/src/__tests__/ingress-control-plane-route-match.test.ts +0 -79
  32. package/src/http/routes/ingress-control-plane-route-match.ts +0 -49
package/ARCHITECTURE.md CHANGED
@@ -122,9 +122,9 @@ Runtime health is exposed directly by the gateway at `GET /v1/health` and forwar
122
122
  | `gateway/src/http/routes/runtime-health-proxy.ts` | Runtime health proxy handler and upstream forwarding |
123
123
  | `gateway/src/index.ts` | Route registration and bearer-auth enforcement for `/v1/health` |
124
124
 
125
- ### Telegram + Ingress Control-Plane Proxies
125
+ ### Telegram + Contacts Control-Plane Proxies
126
126
 
127
- Telegram integration setup/config endpoints and ingress members/invites endpoints are also exposed directly by the gateway and forwarded to runtime handlers even when the broad runtime proxy is disabled.
127
+ Telegram integration setup/config endpoints and contacts/invites endpoints are also exposed directly by the gateway and forwarded to runtime handlers even when the broad runtime proxy is disabled.
128
128
 
129
129
  **Forwarded Telegram endpoints:**
130
130
 
@@ -134,16 +134,17 @@ Telegram integration setup/config endpoints and ingress members/invites endpoint
134
134
  | POST | `/v1/integrations/telegram/commands` |
135
135
  | POST | `/v1/integrations/telegram/setup` |
136
136
 
137
- **Forwarded ingress endpoints:**
137
+ **Forwarded contact & invite endpoints:**
138
138
 
139
- | Method | Path |
140
- | -------- | ------------------------------------- |
141
- | GET/POST | `/v1/ingress/members` |
142
- | DELETE | `/v1/ingress/members/:memberId` |
143
- | POST | `/v1/ingress/members/:memberId/block` |
144
- | GET/POST | `/v1/ingress/invites` |
145
- | DELETE | `/v1/ingress/invites/:inviteId` |
146
- | POST | `/v1/ingress/invites/redeem` |
139
+ | Method | Path |
140
+ | -------- | -------------------------------- |
141
+ | GET/POST | `/v1/contacts` |
142
+ | GET | `/v1/contacts/:contactId` |
143
+ | POST | `/v1/contacts/merge` |
144
+ | PATCH | `/v1/contacts/channels/:id` |
145
+ | GET/POST | `/v1/contacts/invites` |
146
+ | DELETE | `/v1/contacts/invites/:inviteId` |
147
+ | POST | `/v1/contacts/invites/redeem` |
147
148
 
148
149
  **Authentication boundary:**
149
150
 
@@ -153,11 +154,11 @@ Telegram integration setup/config endpoints and ingress members/invites endpoint
153
154
 
154
155
  **Key source files:**
155
156
 
156
- | File | Purpose |
157
- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
158
- | `gateway/src/http/routes/telegram-control-plane-proxy.ts` | Telegram control-plane proxy handlers and upstream forwarding |
159
- | `gateway/src/http/routes/ingress-control-plane-proxy.ts` | Ingress control-plane proxy handlers and upstream forwarding |
160
- | `gateway/src/index.ts` | Route registration and bearer-auth enforcement for `/v1/integrations/telegram/*` and `/v1/ingress/*` |
157
+ | File | Purpose |
158
+ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
159
+ | `gateway/src/http/routes/telegram-control-plane-proxy.ts` | Telegram control-plane proxy handlers and upstream forwarding |
160
+ | `gateway/src/http/routes/contacts-control-plane-proxy.ts` | Contacts control-plane proxy handlers and upstream forwarding |
161
+ | `gateway/src/index.ts` | Route registration and bearer-auth enforcement for `/v1/integrations/telegram/*` and `/v1/contacts/invites/*` |
161
162
 
162
163
  ### Twilio Control-Plane Proxy
163
164
 
@@ -567,10 +568,10 @@ If no guardian binding exists for the channel, escalation fails closed -- the me
567
568
 
568
569
  | Module | Purpose |
569
570
  | ------------------------------------------------ | ------------------------------------------------------------------------- |
570
- | `assistant/src/memory/ingress-invite-store.ts` | CRUD for invite tokens with SHA-256 hashing and expiry |
571
+ | `assistant/src/memory/invite-store.ts` | CRUD for invite tokens with SHA-256 hashing and expiry |
571
572
  | `assistant/src/contacts/contact-store.ts` | Contact and channel lookups (findContactChannel, guardian bindings) |
572
573
  | `assistant/src/contacts/contacts-write.ts` | Contact and channel writes (upsert, policy changes, invite redemption) |
573
- | `assistant/src/daemon/handlers/config-inbox.ts` | IPC handlers for ingress invite and member contracts |
574
+ | `assistant/src/daemon/handlers/config-inbox.ts` | IPC handlers for invite and member contracts |
574
575
  | `assistant/src/runtime/routes/channel-routes.ts` | ACL enforcement point -- member lookup, policy check, escalation creation |
575
576
 
576
577
  ### Telegram Credential Flow
package/README.md CHANGED
@@ -223,12 +223,13 @@ The gateway serves as the single public ingress point for all external callbacks
223
223
  | `/v1/integrations/telegram/config` | GET/POST/DELETE | Authenticated control-plane proxy for Telegram integration config |
224
224
  | `/v1/integrations/telegram/commands` | POST | Authenticated control-plane proxy for Telegram command registration |
225
225
  | `/v1/integrations/telegram/setup` | POST | Authenticated control-plane proxy for Telegram setup orchestration |
226
- | `/v1/ingress/members` | GET/POST | Authenticated control-plane proxy for listing/upserting ingress members |
227
- | `/v1/ingress/members/:id` | DELETE | Authenticated control-plane proxy for revoking an ingress member |
228
- | `/v1/ingress/members/:id/block` | POST | Authenticated control-plane proxy for blocking an ingress member |
229
- | `/v1/ingress/invites` | GET/POST | Authenticated control-plane proxy for listing/creating ingress invites |
230
- | `/v1/ingress/invites/:id` | DELETE | Authenticated control-plane proxy for revoking an ingress invite |
231
- | `/v1/ingress/invites/redeem` | POST | Authenticated control-plane proxy for redeeming an ingress invite |
226
+ | `/v1/contacts` | GET/POST | Authenticated control-plane proxy for listing/searching and creating/updating contacts |
227
+ | `/v1/contacts/:id` | GET | Authenticated control-plane proxy for retrieving a contact by ID |
228
+ | `/v1/contacts/merge` | POST | Authenticated control-plane proxy for merging two contacts |
229
+ | `/v1/contacts/channels/:id` | PATCH | Authenticated control-plane proxy for updating a contact channel's status/policy |
230
+ | `/v1/contacts/invites` | GET/POST | Authenticated control-plane proxy for listing/creating contact invites |
231
+ | `/v1/contacts/invites/:id` | DELETE | Authenticated control-plane proxy for revoking a contact invite |
232
+ | `/v1/contacts/invites/redeem` | POST | Authenticated control-plane proxy for redeeming a contact invite |
232
233
  | `/v1/health` | GET | Authenticated runtime health proxy (`/v1/health` on runtime) |
233
234
  | `/healthz` | GET | Liveness probe |
234
235
  | `/readyz` | GET | Readiness probe |
@@ -299,7 +300,7 @@ When `INGRESS_PUBLIC_BASE_URL` is configured, the gateway prioritizes it as the
299
300
 
300
301
  ## Default Mode: Dedicated Routes Only
301
302
 
302
- By default, the broad runtime proxy is disabled. Dedicated gateway-managed routes (webhooks, delivery endpoints, explicit control-plane proxies such as `/v1/integrations/guardian/*`, `/v1/integrations/telegram/*`, and `/v1/ingress/*`, plus the authenticated runtime health route `/v1/health`) remain available, but arbitrary runtime passthrough routes return `404` unless `GATEWAY_RUNTIME_PROXY_ENABLED=true`.
303
+ By default, the broad runtime proxy is disabled. Dedicated gateway-managed routes (webhooks, delivery endpoints, explicit control-plane proxies such as `/v1/integrations/guardian/*`, `/v1/integrations/telegram/*`, and `/v1/contacts/invites/*`, plus the authenticated runtime health route `/v1/health`) remain available, but arbitrary runtime passthrough routes return `404` unless `GATEWAY_RUNTIME_PROXY_ENABLED=true`.
303
304
 
304
305
  ## Runtime Proxy Mode
305
306
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.29",
3
+ "version": "0.4.30",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -0,0 +1,58 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { BlockKitBuilder } from "../slack/block-kit-builder.js";
3
+
4
+ describe("block-kit-builder", () => {
5
+ describe("static entry points", () => {
6
+ test("BlockKitBuilder.section() creates a mrkdwn section block", () => {
7
+ expect(BlockKitBuilder.section("hello").toBlocks()).toEqual([
8
+ { type: "section", text: { type: "mrkdwn", text: "hello" } },
9
+ ]);
10
+ });
11
+
12
+ test("BlockKitBuilder.divider() creates a divider block", () => {
13
+ expect(BlockKitBuilder.divider().toBlocks()).toEqual([
14
+ { type: "divider" },
15
+ ]);
16
+ });
17
+
18
+ test("BlockKitBuilder.header() creates a plain_text header block", () => {
19
+ expect(BlockKitBuilder.header("Title").toBlocks()).toEqual([
20
+ {
21
+ type: "header",
22
+ text: { type: "plain_text", text: "Title", emoji: true },
23
+ },
24
+ ]);
25
+ });
26
+ });
27
+
28
+ describe("BlockKitBuilder", () => {
29
+ test("builds blocks via fluent API", () => {
30
+ const blocks = new BlockKitBuilder()
31
+ .header("Welcome")
32
+ .section("Some *bold* text")
33
+ .divider()
34
+ .section("More content")
35
+ .toBlocks();
36
+
37
+ expect(blocks).toEqual([
38
+ {
39
+ type: "header",
40
+ text: { type: "plain_text", text: "Welcome", emoji: true },
41
+ },
42
+ {
43
+ type: "section",
44
+ text: { type: "mrkdwn", text: "Some *bold* text" },
45
+ },
46
+ { type: "divider" },
47
+ { type: "section", text: { type: "mrkdwn", text: "More content" } },
48
+ ]);
49
+ });
50
+
51
+ test("toBlocks() returns consistent results", () => {
52
+ const builder = new BlockKitBuilder().section("test");
53
+ const blocks1 = builder.toBlocks();
54
+ const blocks2 = builder.toBlocks();
55
+ expect(blocks1).toEqual(blocks2);
56
+ });
57
+ });
58
+ });
@@ -17,8 +17,8 @@ mock.module("../fetch.js", () => ({
17
17
  fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
18
18
  }));
19
19
 
20
- const { createIngressControlPlaneProxyHandler } =
21
- await import("../http/routes/ingress-control-plane-proxy.js");
20
+ const { createContactsControlPlaneProxyHandler } =
21
+ await import("../http/routes/contacts-control-plane-proxy.js");
22
22
 
23
23
  function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
24
24
  const merged: GatewayConfig = {
@@ -71,8 +71,8 @@ afterEach(() => {
71
71
  fetchMock = mock(async () => new Response());
72
72
  });
73
73
 
74
- describe("ingress control-plane proxy", () => {
75
- test("forwards ingress endpoints to the runtime", async () => {
74
+ describe("contacts control-plane proxy", () => {
75
+ test("forwards contact endpoints to the runtime", async () => {
76
76
  const captured: string[] = [];
77
77
  fetchMock = mock(async (input: string | URL | Request) => {
78
78
  captured.push(String(input));
@@ -82,59 +82,76 @@ describe("ingress control-plane proxy", () => {
82
82
  });
83
83
  });
84
84
 
85
- const handler = createIngressControlPlaneProxyHandler(makeConfig());
85
+ const handler = createContactsControlPlaneProxyHandler(makeConfig());
86
86
 
87
- await handler.handleListMembers(
88
- new Request(
89
- "http://localhost:7830/v1/ingress/members?sourceChannel=telegram",
90
- ),
87
+ await handler.handleListContacts(
88
+ new Request("http://localhost:7830/v1/contacts?limit=10"),
91
89
  );
92
- await handler.handleUpsertMember(
93
- new Request("http://localhost:7830/v1/ingress/members", {
94
- method: "POST",
95
- }),
90
+ await handler.handleUpsertContact(
91
+ new Request("http://localhost:7830/v1/contacts", { method: "POST" }),
96
92
  );
97
- await handler.handleRevokeMember(
98
- new Request("http://localhost:7830/v1/ingress/members/mbr_123", {
99
- method: "DELETE",
100
- }),
101
- "mbr_123",
93
+ await handler.handleGetContact(
94
+ new Request("http://localhost:7830/v1/contacts/ct_1"),
95
+ "ct_1",
102
96
  );
103
- await handler.handleBlockMember(
104
- new Request("http://localhost:7830/v1/ingress/members/mbr_123/block", {
97
+ await handler.handleMergeContacts(
98
+ new Request("http://localhost:7830/v1/contacts/merge", {
105
99
  method: "POST",
106
100
  }),
107
- "mbr_123",
108
101
  );
102
+ await handler.handleUpdateContactChannel(
103
+ new Request("http://localhost:7830/v1/contacts/channels/ch_1", {
104
+ method: "PATCH",
105
+ }),
106
+ "ch_1",
107
+ );
108
+
109
+ expect(captured).toEqual([
110
+ "http://localhost:7821/v1/contacts?limit=10",
111
+ "http://localhost:7821/v1/contacts",
112
+ "http://localhost:7821/v1/contacts/ct_1",
113
+ "http://localhost:7821/v1/contacts/merge",
114
+ "http://localhost:7821/v1/contacts/channels/ch_1",
115
+ ]);
116
+ });
117
+
118
+ test("forwards invite endpoints to the runtime", async () => {
119
+ const captured: string[] = [];
120
+ fetchMock = mock(async (input: string | URL | Request) => {
121
+ captured.push(String(input));
122
+ return new Response(JSON.stringify({ ok: true }), {
123
+ status: 200,
124
+ headers: { "content-type": "application/json" },
125
+ });
126
+ });
127
+
128
+ const handler = createContactsControlPlaneProxyHandler(makeConfig());
129
+
109
130
  await handler.handleListInvites(
110
- new Request("http://localhost:7830/v1/ingress/invites?status=active"),
131
+ new Request("http://localhost:7830/v1/contacts/invites?status=active"),
111
132
  );
112
133
  await handler.handleCreateInvite(
113
- new Request("http://localhost:7830/v1/ingress/invites", {
134
+ new Request("http://localhost:7830/v1/contacts/invites", {
114
135
  method: "POST",
115
136
  }),
116
137
  );
117
138
  await handler.handleRedeemInvite(
118
- new Request("http://localhost:7830/v1/ingress/invites/redeem", {
139
+ new Request("http://localhost:7830/v1/contacts/invites/redeem", {
119
140
  method: "POST",
120
141
  }),
121
142
  );
122
143
  await handler.handleRevokeInvite(
123
- new Request("http://localhost:7830/v1/ingress/invites/inv_123", {
144
+ new Request("http://localhost:7830/v1/contacts/invites/inv_123", {
124
145
  method: "DELETE",
125
146
  }),
126
147
  "inv_123",
127
148
  );
128
149
 
129
150
  expect(captured).toEqual([
130
- "http://localhost:7821/v1/ingress/members?sourceChannel=telegram",
131
- "http://localhost:7821/v1/ingress/members",
132
- "http://localhost:7821/v1/ingress/members/mbr_123",
133
- "http://localhost:7821/v1/ingress/members/mbr_123/block",
134
- "http://localhost:7821/v1/ingress/invites?status=active",
135
- "http://localhost:7821/v1/ingress/invites",
136
- "http://localhost:7821/v1/ingress/invites/redeem",
137
- "http://localhost:7821/v1/ingress/invites/inv_123",
151
+ "http://localhost:7821/v1/contacts/invites?status=active",
152
+ "http://localhost:7821/v1/contacts/invites",
153
+ "http://localhost:7821/v1/contacts/invites/redeem",
154
+ "http://localhost:7821/v1/contacts/invites/inv_123",
138
155
  ]);
139
156
  });
140
157
 
@@ -147,9 +164,9 @@ describe("ingress control-plane proxy", () => {
147
164
  },
148
165
  );
149
166
 
150
- const handler = createIngressControlPlaneProxyHandler(makeConfig());
151
- const res = await handler.handleUpsertMember(
152
- new Request("http://localhost:7830/v1/ingress/members", {
167
+ const handler = createContactsControlPlaneProxyHandler(makeConfig());
168
+ const res = await handler.handleCreateInvite(
169
+ new Request("http://localhost:7830/v1/contacts/invites", {
153
170
  method: "POST",
154
171
  headers: {
155
172
  authorization: "Bearer caller-token",
@@ -178,9 +195,9 @@ describe("ingress control-plane proxy", () => {
178
195
  );
179
196
  });
180
197
 
181
- const handler = createIngressControlPlaneProxyHandler(makeConfig());
198
+ const handler = createContactsControlPlaneProxyHandler(makeConfig());
182
199
  const res = await handler.handleCreateInvite(
183
- new Request("http://localhost:7830/v1/ingress/invites", {
200
+ new Request("http://localhost:7830/v1/contacts/invites", {
184
201
  method: "POST",
185
202
  }),
186
203
  );
@@ -200,11 +217,11 @@ describe("ingress control-plane proxy", () => {
200
217
  );
201
218
  });
202
219
 
203
- const handler = createIngressControlPlaneProxyHandler(
220
+ const handler = createContactsControlPlaneProxyHandler(
204
221
  makeConfig({ runtimeTimeoutMs: 100 }),
205
222
  );
206
- const res = await handler.handleListMembers(
207
- new Request("http://localhost:7830/v1/ingress/members"),
223
+ const res = await handler.handleListInvites(
224
+ new Request("http://localhost:7830/v1/contacts/invites"),
208
225
  );
209
226
 
210
227
  expect(res.status).toBe(504);
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { matchContactsControlPlaneRoute } from "../http/routes/contacts-control-plane-route-match.js";
3
+
4
+ describe("matchContactsControlPlaneRoute", () => {
5
+ test("matches contact CRUD routes", () => {
6
+ expect(matchContactsControlPlaneRoute("/v1/contacts", "GET")).toEqual({
7
+ kind: "listContacts",
8
+ });
9
+ expect(matchContactsControlPlaneRoute("/v1/contacts", "POST")).toEqual({
10
+ kind: "upsertContact",
11
+ });
12
+ expect(matchContactsControlPlaneRoute("/v1/contacts/merge", "POST")).toEqual(
13
+ { kind: "mergeContacts" },
14
+ );
15
+ expect(
16
+ matchContactsControlPlaneRoute("/v1/contacts/channels/ch_1", "PATCH"),
17
+ ).toEqual({ kind: "updateContactChannel", channelId: "ch_1" });
18
+ expect(matchContactsControlPlaneRoute("/v1/contacts/ct_1", "GET")).toEqual({
19
+ kind: "getContact",
20
+ contactId: "ct_1",
21
+ });
22
+ });
23
+
24
+ test("returns null for unsupported methods on contact routes", () => {
25
+ expect(matchContactsControlPlaneRoute("/v1/contacts", "DELETE")).toBeNull();
26
+ // GET /v1/contacts/channels/ch_1 does not match (PATCH only)
27
+ expect(
28
+ matchContactsControlPlaneRoute("/v1/contacts/channels/ch_1", "GET"),
29
+ ).toBeNull();
30
+ });
31
+
32
+ test("GET /v1/contacts/merge falls through to getContact", () => {
33
+ // No GET handler for /merge, so the contactId catch-all picks it up
34
+ expect(matchContactsControlPlaneRoute("/v1/contacts/merge", "GET")).toEqual({
35
+ kind: "getContact",
36
+ contactId: "merge",
37
+ });
38
+ });
39
+
40
+ test("matches redeem invite only for POST", () => {
41
+ expect(
42
+ matchContactsControlPlaneRoute("/v1/contacts/invites/redeem", "POST"),
43
+ ).toEqual({
44
+ kind: "redeemInvite",
45
+ });
46
+
47
+ // DELETE should treat `redeem` as an invite ID so revoke routing still works.
48
+ expect(
49
+ matchContactsControlPlaneRoute("/v1/contacts/invites/redeem", "DELETE"),
50
+ ).toEqual({
51
+ kind: "revokeInvite",
52
+ inviteId: "redeem",
53
+ });
54
+ });
55
+
56
+ test("matches contacts invite routes", () => {
57
+ expect(
58
+ matchContactsControlPlaneRoute("/v1/contacts/invites", "GET"),
59
+ ).toEqual({
60
+ kind: "listInvites",
61
+ });
62
+ expect(
63
+ matchContactsControlPlaneRoute("/v1/contacts/invites", "POST"),
64
+ ).toEqual({
65
+ kind: "createInvite",
66
+ });
67
+ expect(
68
+ matchContactsControlPlaneRoute("/v1/contacts/invites/inv_1", "DELETE"),
69
+ ).toEqual({
70
+ kind: "revokeInvite",
71
+ inviteId: "inv_1",
72
+ });
73
+ });
74
+
75
+ test("returns null for unsupported method/path combinations", () => {
76
+ expect(
77
+ matchContactsControlPlaneRoute("/v1/contacts/invites/redeem", "GET"),
78
+ ).toBeNull();
79
+ expect(
80
+ matchContactsControlPlaneRoute("/v1/contacts/invites/inv_1", "POST"),
81
+ ).toBeNull();
82
+ expect(
83
+ matchContactsControlPlaneRoute("/v1/ingress/unknown", "GET"),
84
+ ).toBeNull();
85
+ });
86
+ });
@@ -64,12 +64,13 @@ describe("/schema route", () => {
64
64
  expect(body.paths["/v1/integrations/telegram/config"]).toBeDefined();
65
65
  expect(body.paths["/v1/integrations/telegram/commands"]).toBeDefined();
66
66
  expect(body.paths["/v1/integrations/telegram/setup"]).toBeDefined();
67
- expect(body.paths["/v1/ingress/members"]).toBeDefined();
68
- expect(body.paths["/v1/ingress/members/{memberId}"]).toBeDefined();
69
- expect(body.paths["/v1/ingress/members/{memberId}/block"]).toBeDefined();
70
- expect(body.paths["/v1/ingress/invites"]).toBeDefined();
71
- expect(body.paths["/v1/ingress/invites/redeem"]).toBeDefined();
72
- expect(body.paths["/v1/ingress/invites/{inviteId}"]).toBeDefined();
67
+ expect(body.paths["/v1/contacts"]).toBeDefined();
68
+ expect(body.paths["/v1/contacts/merge"]).toBeDefined();
69
+ expect(body.paths["/v1/contacts/channels/{channelId}"]).toBeDefined();
70
+ expect(body.paths["/v1/contacts/{contactId}"]).toBeDefined();
71
+ expect(body.paths["/v1/contacts/invites"]).toBeDefined();
72
+ expect(body.paths["/v1/contacts/invites/redeem"]).toBeDefined();
73
+ expect(body.paths["/v1/contacts/invites/{inviteId}"]).toBeDefined();
73
74
  expect(body.paths["/v1/integrations/guardian/challenge"]).toBeDefined();
74
75
  expect(body.paths["/v1/integrations/guardian/status"]).toBeDefined();
75
76
  expect(
@@ -170,24 +171,4 @@ describe("buildSchema()", () => {
170
171
  });
171
172
  expect(telegramDeliver.anyOf).toContainEqual({ required: ["chatAction"] });
172
173
  });
173
-
174
- test("ingress member block request body is optional", () => {
175
- const schema = buildSchema() as {
176
- paths: Record<
177
- string,
178
- {
179
- post?: {
180
- requestBody?: {
181
- required?: boolean;
182
- };
183
- };
184
- }
185
- >;
186
- };
187
-
188
- const ingressMemberBlockPost =
189
- schema.paths["/v1/ingress/members/{memberId}/block"]?.post;
190
- expect(ingressMemberBlockPost).toBeDefined();
191
- expect(ingressMemberBlockPost?.requestBody?.required).toBe(false);
192
- });
193
174
  });
@@ -0,0 +1,155 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { buildAppHomeView, type AppHomeContext } from "../slack/app-home.js";
3
+
4
+ describe("buildAppHomeView", () => {
5
+ test("returns a home-type view", () => {
6
+ const ctx: AppHomeContext = { connected: true };
7
+ const view = buildAppHomeView(ctx);
8
+ expect(view.type).toBe("home");
9
+ });
10
+
11
+ test("includes header block with 'Vellum Assistant'", () => {
12
+ const ctx: AppHomeContext = { connected: true };
13
+ const view = buildAppHomeView(ctx);
14
+ const header = view.blocks.find((b) => b.type === "header");
15
+ expect(header).toBeDefined();
16
+ expect(header!.type === "header" && header!.text.text).toBe(
17
+ "Vellum Assistant",
18
+ );
19
+ });
20
+
21
+ test("shows green status when connected", () => {
22
+ const ctx: AppHomeContext = { connected: true };
23
+ const view = buildAppHomeView(ctx);
24
+ const statusBlock = view.blocks.find(
25
+ (b) =>
26
+ b.type === "section" &&
27
+ b.text.type === "mrkdwn" &&
28
+ b.text.text.includes("Status"),
29
+ );
30
+ expect(statusBlock).toBeDefined();
31
+ if (statusBlock && statusBlock.type === "section") {
32
+ expect(statusBlock.text.text).toContain(":large_green_circle:");
33
+ expect(statusBlock.text.text).toContain("Connected");
34
+ }
35
+ });
36
+
37
+ test("shows red status when disconnected", () => {
38
+ const ctx: AppHomeContext = { connected: false };
39
+ const view = buildAppHomeView(ctx);
40
+ const statusBlock = view.blocks.find(
41
+ (b) =>
42
+ b.type === "section" &&
43
+ b.text.type === "mrkdwn" &&
44
+ b.text.text.includes("Status"),
45
+ );
46
+ expect(statusBlock).toBeDefined();
47
+ if (statusBlock && statusBlock.type === "section") {
48
+ expect(statusBlock.text.text).toContain(":red_circle:");
49
+ expect(statusBlock.text.text).toContain("Disconnected");
50
+ }
51
+ });
52
+
53
+ test("includes workspace name when provided", () => {
54
+ const ctx: AppHomeContext = {
55
+ connected: true,
56
+ workspaceName: "My Workspace",
57
+ };
58
+ const view = buildAppHomeView(ctx);
59
+ const connectionBlock = view.blocks.find(
60
+ (b) =>
61
+ b.type === "section" &&
62
+ b.text.type === "mrkdwn" &&
63
+ b.text.text.includes("Connection Info"),
64
+ );
65
+ expect(connectionBlock).toBeDefined();
66
+ if (connectionBlock && connectionBlock.type === "section") {
67
+ expect(connectionBlock.text.text).toContain("My Workspace");
68
+ }
69
+ });
70
+
71
+ test("includes bot username when provided", () => {
72
+ const ctx: AppHomeContext = {
73
+ connected: true,
74
+ botUsername: "vellum-bot",
75
+ };
76
+ const view = buildAppHomeView(ctx);
77
+ const connectionBlock = view.blocks.find(
78
+ (b) =>
79
+ b.type === "section" &&
80
+ b.text.type === "mrkdwn" &&
81
+ b.text.text.includes("Connection Info"),
82
+ );
83
+ expect(connectionBlock).toBeDefined();
84
+ if (connectionBlock && connectionBlock.type === "section") {
85
+ expect(connectionBlock.text.text).toContain("@vellum-bot");
86
+ }
87
+ });
88
+
89
+ test("omits workspace line when not provided", () => {
90
+ const ctx: AppHomeContext = { connected: true };
91
+ const view = buildAppHomeView(ctx);
92
+ const connectionBlock = view.blocks.find(
93
+ (b) =>
94
+ b.type === "section" &&
95
+ b.text.type === "mrkdwn" &&
96
+ b.text.text.includes("Connection Info"),
97
+ );
98
+ expect(connectionBlock).toBeDefined();
99
+ if (connectionBlock && connectionBlock.type === "section") {
100
+ expect(connectionBlock.text.text).not.toContain("Workspace:");
101
+ }
102
+ });
103
+
104
+ test("omits bot line when not provided", () => {
105
+ const ctx: AppHomeContext = { connected: true };
106
+ const view = buildAppHomeView(ctx);
107
+ const connectionBlock = view.blocks.find(
108
+ (b) =>
109
+ b.type === "section" &&
110
+ b.text.type === "mrkdwn" &&
111
+ b.text.text.includes("Connection Info"),
112
+ );
113
+ expect(connectionBlock).toBeDefined();
114
+ if (connectionBlock && connectionBlock.type === "section") {
115
+ expect(connectionBlock.text.text).not.toContain("Bot:");
116
+ }
117
+ });
118
+
119
+ test("includes capabilities section", () => {
120
+ const ctx: AppHomeContext = { connected: true };
121
+ const view = buildAppHomeView(ctx);
122
+ const capBlock = view.blocks.find(
123
+ (b) =>
124
+ b.type === "section" &&
125
+ b.text.type === "mrkdwn" &&
126
+ b.text.text.includes("Capabilities"),
127
+ );
128
+ expect(capBlock).toBeDefined();
129
+ if (capBlock && capBlock.type === "section") {
130
+ expect(capBlock.text.text).toContain("Mention me");
131
+ expect(capBlock.text.text).toContain("direct message");
132
+ expect(capBlock.text.text).toContain("threads");
133
+ }
134
+ });
135
+
136
+ test("includes dividers between sections", () => {
137
+ const ctx: AppHomeContext = { connected: true };
138
+ const view = buildAppHomeView(ctx);
139
+ const dividers = view.blocks.filter((b) => b.type === "divider");
140
+ expect(dividers.length).toBeGreaterThanOrEqual(2);
141
+ });
142
+
143
+ test("all blocks have valid types", () => {
144
+ const ctx: AppHomeContext = {
145
+ connected: true,
146
+ botUsername: "bot",
147
+ workspaceName: "ws",
148
+ };
149
+ const view = buildAppHomeView(ctx);
150
+ const validTypes = new Set(["header", "section", "divider", "actions"]);
151
+ for (const block of view.blocks) {
152
+ expect(validTypes.has(block.type)).toBe(true);
153
+ }
154
+ });
155
+ });