@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.
- package/ARCHITECTURE.md +19 -18
- package/README.md +8 -7
- package/package.json +1 -1
- package/src/__tests__/block-kit-builder.test.ts +58 -0
- package/src/__tests__/{ingress-control-plane-proxy.test.ts → contacts-control-plane-proxy.test.ts} +58 -41
- package/src/__tests__/contacts-control-plane-route-match.test.ts +86 -0
- package/src/__tests__/schema.test.ts +7 -26
- package/src/__tests__/slack-app-home.test.ts +155 -0
- package/src/__tests__/slack-deliver-ratelimit.test.ts +193 -0
- package/src/__tests__/slack-deliver.test.ts +632 -25
- package/src/__tests__/slack-display-name.test.ts +255 -0
- package/src/__tests__/slack-errors.test.ts +77 -0
- package/src/__tests__/slack-normalize.test.ts +193 -26
- package/src/__tests__/slack-reaction-normalize.test.ts +180 -0
- package/src/__tests__/text-to-blocks.test.ts +164 -0
- package/src/db/connection.ts +52 -0
- package/src/db/slack-store.ts +90 -0
- package/src/http/routes/{ingress-control-plane-proxy.ts → contacts-control-plane-proxy.ts} +21 -18
- package/src/http/routes/contacts-control-plane-route-match.ts +56 -0
- package/src/http/routes/slack-deliver.ts +649 -40
- package/src/index.ts +30 -22
- package/src/schema.ts +84 -51
- package/src/slack/app-home.ts +120 -0
- package/src/slack/block-kit-builder.test.ts +281 -0
- package/src/slack/block-kit-builder.ts +229 -0
- package/src/slack/errors.ts +67 -0
- package/src/slack/normalize.test.ts +254 -0
- package/src/slack/normalize.ts +461 -1
- package/src/slack/socket-mode.ts +283 -37
- package/src/slack/text-to-blocks.ts +244 -0
- package/src/__tests__/ingress-control-plane-route-match.test.ts +0 -79
- 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 +
|
|
125
|
+
### Telegram + Contacts Control-Plane Proxies
|
|
126
126
|
|
|
127
|
-
Telegram integration setup/config endpoints and
|
|
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
|
|
137
|
+
**Forwarded contact & invite endpoints:**
|
|
138
138
|
|
|
139
|
-
| Method | Path
|
|
140
|
-
| -------- |
|
|
141
|
-
| GET/POST | `/v1/
|
|
142
|
-
|
|
|
143
|
-
| POST | `/v1/
|
|
144
|
-
|
|
|
145
|
-
|
|
|
146
|
-
|
|
|
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/
|
|
160
|
-
| `gateway/src/index.ts` | Route registration and bearer-auth enforcement for `/v1/integrations/telegram/*` and `/v1/
|
|
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/
|
|
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
|
|
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/
|
|
227
|
-
| `/v1/
|
|
228
|
-
| `/v1/
|
|
229
|
-
| `/v1/
|
|
230
|
-
| `/v1/
|
|
231
|
-
| `/v1/
|
|
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/
|
|
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
|
@@ -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
|
+
});
|
package/src/__tests__/{ingress-control-plane-proxy.test.ts → contacts-control-plane-proxy.test.ts}
RENAMED
|
@@ -17,8 +17,8 @@ mock.module("../fetch.js", () => ({
|
|
|
17
17
|
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
|
-
const {
|
|
21
|
-
await import("../http/routes/
|
|
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("
|
|
75
|
-
test("forwards
|
|
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 =
|
|
85
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
86
86
|
|
|
87
|
-
await handler.
|
|
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.
|
|
93
|
-
new Request("http://localhost:7830/v1/
|
|
94
|
-
method: "POST",
|
|
95
|
-
}),
|
|
90
|
+
await handler.handleUpsertContact(
|
|
91
|
+
new Request("http://localhost:7830/v1/contacts", { method: "POST" }),
|
|
96
92
|
);
|
|
97
|
-
await handler.
|
|
98
|
-
new Request("http://localhost:7830/v1/
|
|
99
|
-
|
|
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.
|
|
104
|
-
new Request("http://localhost:7830/v1/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
131
|
-
"http://localhost:7821/v1/
|
|
132
|
-
"http://localhost:7821/v1/
|
|
133
|
-
"http://localhost:7821/v1/
|
|
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 =
|
|
151
|
-
const res = await handler.
|
|
152
|
-
new Request("http://localhost:7830/v1/
|
|
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 =
|
|
198
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
182
199
|
const res = await handler.handleCreateInvite(
|
|
183
|
-
new Request("http://localhost:7830/v1/
|
|
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 =
|
|
220
|
+
const handler = createContactsControlPlaneProxyHandler(
|
|
204
221
|
makeConfig({ runtimeTimeoutMs: 100 }),
|
|
205
222
|
);
|
|
206
|
-
const res = await handler.
|
|
207
|
-
new Request("http://localhost:7830/v1/
|
|
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/
|
|
68
|
-
expect(body.paths["/v1/
|
|
69
|
-
expect(body.paths["/v1/
|
|
70
|
-
expect(body.paths["/v1/
|
|
71
|
-
expect(body.paths["/v1/
|
|
72
|
-
expect(body.paths["/v1/
|
|
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
|
+
});
|