@vellumai/vellum-gateway 0.9.0 → 0.9.1-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -4
- package/ARCHITECTURE.md +5 -6
- package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
- package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
- package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
- package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
- package/package.json +1 -1
- package/src/__tests__/edge-auth.test.ts +23 -0
- package/src/__tests__/edge-guardian-auth.test.ts +12 -0
- package/src/__tests__/feature-flags-route.test.ts +58 -1
- package/src/__tests__/guardian-binding-channel-reuse.test.ts +3 -10
- package/src/__tests__/ipc-contact-routes.test.ts +2 -2
- package/src/__tests__/remote-web-ingress-denylist.test.ts +20 -1
- package/src/__tests__/remote-web-pairing-challenge.test.ts +1 -0
- package/src/__tests__/remote-web-pairing-token.test.ts +334 -0
- package/src/__tests__/remote-web-pairing-verification.test.ts +192 -0
- package/src/__tests__/route-schema-guard.test.ts +4 -0
- package/src/__tests__/upsert-verified-contact-channel.test.ts +4 -8
- package/src/auth/guardian-bootstrap.ts +52 -16
- package/src/auth/guardian-refresh.ts +78 -38
- package/src/credential-watcher.ts +2 -2
- package/src/db/connection.ts +82 -0
- package/src/db/contact-store.ts +45 -35
- package/src/db/data-migrations/m0005-normalize-contact-channel-addresses.ts +35 -6
- package/src/db/schema.ts +5 -4
- package/src/feature-flag-registry.json +1 -17
- package/src/http/browser-auth-cookies.ts +77 -0
- package/src/http/middleware/auth.ts +3 -0
- package/src/http/middleware/cors.ts +1 -1
- package/src/http/read-limited-body.ts +50 -0
- package/src/http/routes/channel-verification-session-proxy.test.ts +0 -15
- package/src/http/routes/channel-verification-session-proxy.ts +1 -71
- package/src/http/routes/contact-prompt.ts +16 -6
- package/src/http/routes/contacts-control-plane-proxy.ts +21 -11
- package/src/http/routes/feature-flags.ts +16 -3
- package/src/http/routes/guardian-channel-create.ts +12 -3
- package/src/http/routes/guardian-refresh.ts +125 -0
- package/src/http/routes/remote-web-pairing-token.ts +122 -0
- package/src/http/routes/remote-web-pairing-verification.ts +114 -0
- package/src/index.ts +30 -3
- package/src/ipc/contact-handlers.ts +15 -5
- package/src/remote-web/pairing-challenge-store.ts +131 -4
- package/src/remote-web/pairing-verification-rate-limit-store.ts +57 -0
- package/src/risk/command-registry/commands/assistant.ts +1 -18
- package/src/risk/command-registry.test.ts +1 -1
- package/src/telegram/send.test.ts +6 -6
- package/src/telegram/send.ts +3 -13
- package/src/verification/contact-helpers.ts +27 -34
- package/src/verification/text-verification.ts +28 -19
- package/src/whatsapp/send.ts +2 -12
package/AGENTS.md
CHANGED
|
@@ -16,10 +16,7 @@ Why: the gateway is the single point of ingress, handling TLS termination, auth,
|
|
|
16
16
|
|
|
17
17
|
All assistant API requests from clients, CLI, skills, and user-facing tooling **MUST** target gateway URLs. Never construct URLs using the daemon runtime port (`7821`) or `RUNTIME_HTTP_PORT` for external API consumption.
|
|
18
18
|
|
|
19
|
-
**Exception boundary:** The gateway service itself may call the runtime internally. Tests may use direct runtime URLs for isolated unit/integration scenarios. Intentional local daemon-control paths are exempt
|
|
20
|
-
|
|
21
|
-
- `clients/shared/Network/DaemonClient.swift`
|
|
22
|
-
- `clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift` (health probe)
|
|
19
|
+
**Exception boundary:** The gateway service itself may call the runtime internally. Tests may use direct runtime URLs for isolated unit/integration scenarios. Intentional local daemon-control paths (e.g. health probes) are exempt; the authoritative allowlist lives in `assistant/src/__tests__/gateway-only-guard.test.ts`.
|
|
23
20
|
|
|
24
21
|
**Migration rule:** If a needed endpoint is not available at the gateway, add a gateway route/proxy first, then consume it. Do not work around a missing gateway endpoint by hitting the runtime directly.
|
|
25
22
|
|
package/ARCHITECTURE.md
CHANGED
|
@@ -34,9 +34,9 @@ Internet
|
|
|
34
34
|
|
|
35
35
|
### STT Route Proxying (Assistant-Scoped Rewrite)
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Clients send speech-to-text transcription requests through the gateway to the daemon's STT service. Clients POST to the assistant-scoped path `/v1/assistants/:assistantId/stt/transcribe`, which the gateway's runtime proxy rewrites to the flat daemon path `/v1/stt/transcribe`. This follows the same assistant-scoped rewrite pattern used by other client-facing endpoints (feature flags, privacy config, etc.).
|
|
38
38
|
|
|
39
|
-
The request carries base64-encoded WAV audio and a MIME type. The daemon resolves the configured STT provider via `resolveBatchTranscriber()` and returns the transcribed text. Clients use the response to implement a service-first strategy: the service transcription takes precedence when available, with
|
|
39
|
+
The request carries base64-encoded WAV audio and a MIME type. The daemon resolves the configured STT provider via `resolveBatchTranscriber()` and returns the transcribed text. Clients use the response to implement a service-first strategy: the service transcription takes precedence when available, with a client-local fallback when the service returns 503 (not configured) or fails.
|
|
40
40
|
|
|
41
41
|
| Client path (gateway) | Daemon path (after rewrite) | Method |
|
|
42
42
|
| ----------------------------------- | --------------------------- | ------ |
|
|
@@ -48,12 +48,11 @@ The request carries base64-encoded WAV audio and a MIME type. The daemon resolve
|
|
|
48
48
|
| ------------------------------------------------ | ------------------------------------------------------------------------- |
|
|
49
49
|
| `gateway/src/http/routes/runtime-proxy.ts` | Assistant-scoped path rewriting (`/v1/assistants/:id/...` → `/v1/...`) |
|
|
50
50
|
| `assistant/src/runtime/routes/stt-routes.ts` | Daemon HTTP endpoint: validates audio, resolves transcriber, returns text |
|
|
51
|
-
| `clients/
|
|
52
|
-
| `clients/shared/Utilities/AudioWavEncoder.swift` | WAV encoding utility for PCM audio buffers |
|
|
51
|
+
| `clients/web/src/domains/chat/voice/stt-api.ts` | Web client: POSTs audio to the gateway, returns a typed result |
|
|
53
52
|
|
|
54
53
|
### STT Streaming WebSocket Proxy
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
Clients open WebSocket connections through the gateway to the daemon's real-time STT streaming endpoint for conversation chat message capture. The gateway authenticates the downstream client using an edge JWT (actor principal required), then opens an upstream WebSocket connection to the daemon's `/v1/stt/stream` endpoint with a short-lived gateway service token. This keeps the daemon's WebSocket endpoint unreachable from the public internet while allowing authenticated clients to stream audio for real-time transcription.
|
|
57
56
|
|
|
58
57
|
**Config-authoritative model:** The runtime always resolves the streaming transcriber from `services.stt.provider` in the assistant config, regardless of any `provider` query parameter. The `provider` parameter is optional compatibility metadata — when supplied and it disagrees with the configured provider, the runtime logs a mismatch warning for operator visibility.
|
|
59
58
|
|
|
@@ -80,7 +79,7 @@ Native clients (macOS, iOS) open WebSocket connections through the gateway to th
|
|
|
80
79
|
| `gateway/src/index.ts` | Route registration: wires upgrade handler to the gateway's Bun HTTP server |
|
|
81
80
|
| `assistant/src/runtime/http-server.ts` | Daemon-side WebSocket upgrade at `/v1/stt/stream`, session creation and registry |
|
|
82
81
|
| `assistant/src/stt/stt-stream-session.ts` | Runtime session orchestrator: drives the `StreamingTranscriber` from the WebSocket |
|
|
83
|
-
| `clients/
|
|
82
|
+
| `clients/web/src/domains/chat/voice/dictation-stream.ts` | Web client: opens the gateway WebSocket, parses transcript events, reports failures |
|
|
84
83
|
|
|
85
84
|
### Assistant Feature Flags API
|
|
86
85
|
|
|
@@ -74,7 +74,11 @@ function createMockTransport(): CesTransport & {
|
|
|
74
74
|
messages: string[];
|
|
75
75
|
messageHandler: ((msg: string) => void) | null;
|
|
76
76
|
alive: boolean;
|
|
77
|
+
/** Simulate the transport dying (e.g. a spurious stdout EOF): mark it dead
|
|
78
|
+
* and fire the registered close handlers, as the real transports do. */
|
|
79
|
+
die: () => void;
|
|
77
80
|
} {
|
|
81
|
+
const closeHandlers: Array<() => void> = [];
|
|
78
82
|
const transport = {
|
|
79
83
|
messages: [] as string[],
|
|
80
84
|
messageHandler: null as ((msg: string) => void) | null,
|
|
@@ -88,9 +92,16 @@ function createMockTransport(): CesTransport & {
|
|
|
88
92
|
isAlive() {
|
|
89
93
|
return transport.alive;
|
|
90
94
|
},
|
|
95
|
+
onClose(handler: () => void) {
|
|
96
|
+
closeHandlers.push(handler);
|
|
97
|
+
},
|
|
91
98
|
close() {
|
|
92
99
|
transport.alive = false;
|
|
93
100
|
},
|
|
101
|
+
die() {
|
|
102
|
+
transport.alive = false;
|
|
103
|
+
for (const handler of closeHandlers) handler();
|
|
104
|
+
},
|
|
94
105
|
};
|
|
95
106
|
return transport;
|
|
96
107
|
}
|
|
@@ -605,6 +616,42 @@ describe("CesRpcClient", () => {
|
|
|
605
616
|
expect(client.isReady()).toBe(false);
|
|
606
617
|
});
|
|
607
618
|
|
|
619
|
+
test("a pending request fails fast when the transport dies (no timeout wait)", async () => {
|
|
620
|
+
const transport = createMockTransport();
|
|
621
|
+
const client = createCesRpcClient(transport, {
|
|
622
|
+
handshakeTimeoutMs: 5000,
|
|
623
|
+
// Long timeout on purpose: the call must reject from the transport
|
|
624
|
+
// DEATH, not this timer. Without the fail-fast it would hang 60s.
|
|
625
|
+
requestTimeoutMs: 60_000,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Handshake
|
|
629
|
+
const hPromise = client.handshake();
|
|
630
|
+
const hSent = JSON.parse(transport.messages[0]!);
|
|
631
|
+
transport.messageHandler!(
|
|
632
|
+
JSON.stringify({
|
|
633
|
+
type: "handshake_ack",
|
|
634
|
+
protocolVersion: CES_PROTOCOL_VERSION,
|
|
635
|
+
sessionId: hSent.sessionId,
|
|
636
|
+
accepted: true,
|
|
637
|
+
}),
|
|
638
|
+
);
|
|
639
|
+
await hPromise;
|
|
640
|
+
|
|
641
|
+
const callPromise = client.call("list_credentials", {});
|
|
642
|
+
|
|
643
|
+
// The transport's read side dies (e.g. a spurious stdout EOF on a CES
|
|
644
|
+
// bounce) while the request is in flight — the response can never arrive.
|
|
645
|
+
transport.die();
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
await callPromise;
|
|
649
|
+
throw new Error("should have thrown");
|
|
650
|
+
} catch (err) {
|
|
651
|
+
expect(err).toBeInstanceOf(CesTransportError);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
608
655
|
test("subsequent handshake call returns immediately if already ready", async () => {
|
|
609
656
|
const transport = createMockTransport();
|
|
610
657
|
const client = createCesRpcClient(transport, { handshakeTimeoutMs: 5000 });
|
|
@@ -45,6 +45,14 @@ export interface CesTransport {
|
|
|
45
45
|
isAlive(): boolean;
|
|
46
46
|
/** Tear down the transport connection. */
|
|
47
47
|
close(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Register a callback that fires once when the transport dies (its read side
|
|
50
|
+
* ends, the process exits, or the socket closes). Lets the client fail-fast
|
|
51
|
+
* any in-flight requests instead of letting each wait out its timeout.
|
|
52
|
+
* Optional: a transport that doesn't implement it falls back to
|
|
53
|
+
* timeout-based failure.
|
|
54
|
+
*/
|
|
55
|
+
onClose?(handler: () => void): void;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
// ---------------------------------------------------------------------------
|
|
@@ -155,6 +163,16 @@ export function createCesRpcClient(
|
|
|
155
163
|
|
|
156
164
|
const pending = new Map<string, PendingRequest>();
|
|
157
165
|
|
|
166
|
+
/** Reject and clear every in-flight request. Shared by `close()` and the
|
|
167
|
+
* transport-death handler. */
|
|
168
|
+
function rejectAllPending(error: Error): void {
|
|
169
|
+
for (const [id, entry] of pending) {
|
|
170
|
+
clearTimeout(entry.timer);
|
|
171
|
+
pending.delete(id);
|
|
172
|
+
entry.reject(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
158
176
|
// -------------------------------------------------------------------------
|
|
159
177
|
// Incoming message dispatch
|
|
160
178
|
// -------------------------------------------------------------------------
|
|
@@ -209,6 +227,15 @@ export function createCesRpcClient(
|
|
|
209
227
|
}
|
|
210
228
|
});
|
|
211
229
|
|
|
230
|
+
// Fail-fast on transport death: when the transport's read side ends (e.g. a
|
|
231
|
+
// spurious stdout EOF on a CES bounce) or the connection closes, reject every
|
|
232
|
+
// in-flight request immediately. The response can never arrive on a dead
|
|
233
|
+
// transport, so without this a pending call hangs until `requestTimeoutMs`
|
|
234
|
+
// (observed: 30s stalls on credential reads that raced a CES restart).
|
|
235
|
+
transport.onClose?.(() => {
|
|
236
|
+
rejectAllPending(new CesTransportError("CES transport closed"));
|
|
237
|
+
});
|
|
238
|
+
|
|
212
239
|
// -------------------------------------------------------------------------
|
|
213
240
|
// Send helpers
|
|
214
241
|
// -------------------------------------------------------------------------
|
|
@@ -384,11 +411,7 @@ export function createCesRpcClient(
|
|
|
384
411
|
},
|
|
385
412
|
|
|
386
413
|
close(): void {
|
|
387
|
-
|
|
388
|
-
clearTimeout(entry.timer);
|
|
389
|
-
entry.reject(new CesTransportError("CES client closed"));
|
|
390
|
-
pending.delete(id);
|
|
391
|
-
}
|
|
414
|
+
rejectAllPending(new CesTransportError("CES client closed"));
|
|
392
415
|
ready = false;
|
|
393
416
|
transport.close();
|
|
394
417
|
},
|
|
@@ -19,20 +19,26 @@ export * from "./gateway-ipc-contracts.js";
|
|
|
19
19
|
|
|
20
20
|
export { ipcCall, IpcCallError, PersistentIpcClient } from "./ipc-client.js";
|
|
21
21
|
|
|
22
|
+
// Outbound delivery contract (daemon → gateway) — Zod schemas + derived types
|
|
23
|
+
export {
|
|
24
|
+
ApprovalActionOptionSchema,
|
|
25
|
+
ApprovalUIMetadataSchema,
|
|
26
|
+
AttachmentMetadataSchema,
|
|
27
|
+
ChannelDeliveryResultSchema,
|
|
28
|
+
ChannelReplyPayloadSchema,
|
|
29
|
+
PermissionRequestDetailsSchema,
|
|
30
|
+
} from "./outbound-contract.js";
|
|
31
|
+
|
|
22
32
|
export type {
|
|
23
33
|
ApprovalActionOption,
|
|
24
34
|
ApprovalUIMetadata,
|
|
25
35
|
AttachmentMetadata,
|
|
26
36
|
ChannelDeliveryResult,
|
|
27
37
|
ChannelReplyPayload,
|
|
28
|
-
IpcRequest,
|
|
29
|
-
IpcResponse,
|
|
30
|
-
Logger,
|
|
31
38
|
PermissionRequestDetails,
|
|
32
|
-
} from "./
|
|
33
|
-
|
|
34
|
-
export { noopLogger } from "./types.js";
|
|
39
|
+
} from "./outbound-contract.js";
|
|
35
40
|
|
|
41
|
+
// Inbound contract (gateway → daemon) — Zod schemas + derived types
|
|
36
42
|
export {
|
|
37
43
|
CommandIntentSchema,
|
|
38
44
|
RuntimeInboundPayloadSchema,
|
|
@@ -44,3 +50,8 @@ export type {
|
|
|
44
50
|
RuntimeInboundPayload,
|
|
45
51
|
SourceMetadata,
|
|
46
52
|
} from "./inbound-contract.js";
|
|
53
|
+
|
|
54
|
+
// IPC, logger, and utility types
|
|
55
|
+
export type { IpcRequest, IpcResponse, Logger } from "./types.js";
|
|
56
|
+
|
|
57
|
+
export { noopLogger } from "./types.js";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon → gateway outbound delivery contract.
|
|
3
|
+
*
|
|
4
|
+
* Zod schemas defining the wire format for channel replies delivered from
|
|
5
|
+
* the daemon to the gateway via `POST /deliver/{channel}`. Both services
|
|
6
|
+
* import from here so the contract is enforced at compile time.
|
|
7
|
+
*
|
|
8
|
+
* The daemon constructs these payloads in `deliverChannelReply()` and
|
|
9
|
+
* `deliverApprovalPrompt()`; the gateway validates and dispatches them
|
|
10
|
+
* to the target channel provider.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Attachment metadata
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const AttachmentMetadataSchema = z.object({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
filename: z.string(),
|
|
22
|
+
mimeType: z.string(),
|
|
23
|
+
sizeBytes: z.number(),
|
|
24
|
+
kind: z.string(),
|
|
25
|
+
data: z.string().optional(),
|
|
26
|
+
thumbnailData: z.string().optional(),
|
|
27
|
+
fileBacked: z.boolean().optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export type AttachmentMetadata = z.infer<typeof AttachmentMetadataSchema>;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Approval UI types
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export const ApprovalActionOptionSchema = z.object({
|
|
37
|
+
id: z.string(),
|
|
38
|
+
label: z.string(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export type ApprovalActionOption = z.infer<typeof ApprovalActionOptionSchema>;
|
|
42
|
+
|
|
43
|
+
export const PermissionRequestDetailsSchema = z.object({
|
|
44
|
+
toolName: z.string(),
|
|
45
|
+
riskLevel: z.string(),
|
|
46
|
+
toolInput: z.record(z.string(), z.unknown()),
|
|
47
|
+
requesterIdentifier: z.string().optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type PermissionRequestDetails = z.infer<
|
|
51
|
+
typeof PermissionRequestDetailsSchema
|
|
52
|
+
>;
|
|
53
|
+
|
|
54
|
+
export const ApprovalUIMetadataSchema = z.object({
|
|
55
|
+
requestId: z.string(),
|
|
56
|
+
actions: z.array(ApprovalActionOptionSchema),
|
|
57
|
+
plainTextFallback: z.string(),
|
|
58
|
+
permissionDetails: PermissionRequestDetailsSchema.optional(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export type ApprovalUIMetadata = z.infer<typeof ApprovalUIMetadataSchema>;
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Channel reply payload — the full outbound wire format
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export const ChannelReplyPayloadSchema = z.object({
|
|
68
|
+
chatId: z.string(),
|
|
69
|
+
text: z.string().optional(),
|
|
70
|
+
/** Pre-formatted Block Kit blocks for Slack delivery. */
|
|
71
|
+
blocks: z.array(z.unknown()).optional(),
|
|
72
|
+
assistantId: z.string().optional(),
|
|
73
|
+
attachments: z.array(AttachmentMetadataSchema).optional(),
|
|
74
|
+
approval: ApprovalUIMetadataSchema.optional(),
|
|
75
|
+
chatAction: z.literal("typing").optional(),
|
|
76
|
+
/**
|
|
77
|
+
* When true, deliver via `chat.postEphemeral` so only the target `user`
|
|
78
|
+
* sees the message.
|
|
79
|
+
*/
|
|
80
|
+
ephemeral: z.boolean().optional(),
|
|
81
|
+
/** Slack user ID — required when `ephemeral` is true. */
|
|
82
|
+
user: z.string().optional(),
|
|
83
|
+
/** When provided, update an existing message instead of posting a new one. */
|
|
84
|
+
messageTs: z.string().optional(),
|
|
85
|
+
/** When true, auto-generate Block Kit blocks from text via textToBlocks(). */
|
|
86
|
+
useBlocks: z.boolean().optional(),
|
|
87
|
+
/** When provided, add or remove an emoji reaction on a message. */
|
|
88
|
+
reaction: z
|
|
89
|
+
.object({
|
|
90
|
+
action: z.enum(["add", "remove"]),
|
|
91
|
+
name: z.string(),
|
|
92
|
+
messageTs: z.string(),
|
|
93
|
+
})
|
|
94
|
+
.optional(),
|
|
95
|
+
/** When provided, set or clear the Slack Assistants API thread status. */
|
|
96
|
+
assistantThreadStatus: z
|
|
97
|
+
.object({
|
|
98
|
+
channel: z.string(),
|
|
99
|
+
threadTs: z.string(),
|
|
100
|
+
status: z.string(),
|
|
101
|
+
/** Serialized to Slack as `loading_messages`. */
|
|
102
|
+
loadingMessages: z.array(z.string()).optional(),
|
|
103
|
+
})
|
|
104
|
+
.optional(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Channel delivery result — gateway response
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export const ChannelDeliveryResultSchema = z.object({
|
|
114
|
+
ok: z.boolean(),
|
|
115
|
+
/** The message timestamp returned by the delivery endpoint. */
|
|
116
|
+
ts: z.string().optional(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export type ChannelDeliveryResult = z.infer<typeof ChannelDeliveryResultSchema>;
|
|
@@ -4,92 +4,23 @@
|
|
|
4
4
|
* Type definitions for assistant-to-gateway communication. These are
|
|
5
5
|
* intentionally decoupled from the assistant's internal types so the
|
|
6
6
|
* package can be consumed without importing assistant internals.
|
|
7
|
+
*
|
|
8
|
+
* HTTP delivery types (ChannelReplyPayload, ApprovalUIMetadata, etc.)
|
|
9
|
+
* are defined as Zod schemas in `outbound-contract.ts` and re-exported
|
|
10
|
+
* from the barrel `index.ts`. This file retains only IPC and utility
|
|
11
|
+
* types that don't cross an HTTP wire boundary.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
kind: string;
|
|
20
|
-
data?: string;
|
|
21
|
-
thumbnailData?: string;
|
|
22
|
-
fileBacked?: boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** An action option presented to the user in an approval prompt. */
|
|
26
|
-
export interface ApprovalActionOption {
|
|
27
|
-
id: string;
|
|
28
|
-
label: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Tool-permission-specific details carried alongside the approval payload.
|
|
33
|
-
* Channels that support rich UI (e.g. Slack Block Kit) use these fields
|
|
34
|
-
* to render a detailed permission request card.
|
|
35
|
-
*/
|
|
36
|
-
export interface PermissionRequestDetails {
|
|
37
|
-
toolName: string;
|
|
38
|
-
riskLevel: string;
|
|
39
|
-
toolInput: Record<string, unknown>;
|
|
40
|
-
requesterIdentifier?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Metadata attached to gateway callback payloads for rendering approval
|
|
45
|
-
* UI and routing decisions back to the correct pending interaction.
|
|
46
|
-
*/
|
|
47
|
-
export interface ApprovalUIMetadata {
|
|
48
|
-
requestId: string;
|
|
49
|
-
actions: ApprovalActionOption[];
|
|
50
|
-
plainTextFallback: string;
|
|
51
|
-
permissionDetails?: PermissionRequestDetails;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Payload for a channel reply delivered via the gateway. */
|
|
55
|
-
export interface ChannelReplyPayload {
|
|
56
|
-
chatId: string;
|
|
57
|
-
text?: string;
|
|
58
|
-
/** Pre-formatted Block Kit blocks for Slack delivery. */
|
|
59
|
-
blocks?: unknown[];
|
|
60
|
-
assistantId?: string;
|
|
61
|
-
attachments?: AttachmentMetadata[];
|
|
62
|
-
approval?: ApprovalUIMetadata;
|
|
63
|
-
chatAction?: "typing";
|
|
64
|
-
/**
|
|
65
|
-
* When true, deliver via `chat.postEphemeral` so only the target `user`
|
|
66
|
-
* sees the message.
|
|
67
|
-
*/
|
|
68
|
-
ephemeral?: boolean;
|
|
69
|
-
/** Slack user ID — required when `ephemeral` is true. */
|
|
70
|
-
user?: string;
|
|
71
|
-
/** When provided, update an existing message instead of posting a new one. */
|
|
72
|
-
messageTs?: string;
|
|
73
|
-
/** When true, auto-generate Block Kit blocks from text via textToBlocks(). */
|
|
74
|
-
useBlocks?: boolean;
|
|
75
|
-
/** When provided, add or remove an emoji reaction on a message. */
|
|
76
|
-
reaction?: { action: "add" | "remove"; name: string; messageTs: string };
|
|
77
|
-
/** When provided, set or clear the Slack Assistants API thread status. */
|
|
78
|
-
assistantThreadStatus?: {
|
|
79
|
-
channel: string;
|
|
80
|
-
threadTs: string;
|
|
81
|
-
status: string;
|
|
82
|
-
/** Serialized to Slack as `loading_messages`. */
|
|
83
|
-
loadingMessages?: string[];
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Result from a channel delivery attempt. */
|
|
88
|
-
export interface ChannelDeliveryResult {
|
|
89
|
-
ok: boolean;
|
|
90
|
-
/** The message timestamp returned by the delivery endpoint. */
|
|
91
|
-
ts?: string;
|
|
92
|
-
}
|
|
14
|
+
// Re-export outbound delivery types for backward compatibility — consumers
|
|
15
|
+
// that import from "./types.js" continue to work.
|
|
16
|
+
export type {
|
|
17
|
+
ApprovalActionOption,
|
|
18
|
+
ApprovalUIMetadata,
|
|
19
|
+
AttachmentMetadata,
|
|
20
|
+
ChannelDeliveryResult,
|
|
21
|
+
ChannelReplyPayload,
|
|
22
|
+
PermissionRequestDetails,
|
|
23
|
+
} from "./outbound-contract.js";
|
|
93
24
|
|
|
94
25
|
// ---------------------------------------------------------------------------
|
|
95
26
|
// IPC types
|
package/package.json
CHANGED
|
@@ -194,6 +194,17 @@ describe("requireEdgeAuth — JWT mode", () => {
|
|
|
194
194
|
expect(mockValidateEdgeToken).not.toHaveBeenCalled();
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
test("rejects edge-forwarded loopback requests when Authorization header is absent", async () => {
|
|
198
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
199
|
+
const res = await requireEdgeAuth(
|
|
200
|
+
makeReq({ "x-vellum-edge-forwarded": "1" }),
|
|
201
|
+
makeLoopbackServer(),
|
|
202
|
+
);
|
|
203
|
+
expect(res?.status).toBe(401);
|
|
204
|
+
expect(mockValidateEdgeToken).not.toHaveBeenCalled();
|
|
205
|
+
expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
197
208
|
test("a loopback fallback is counted by (guard, path, failureKind)", async () => {
|
|
198
209
|
const { requireEdgeAuth } = makeMiddleware();
|
|
199
210
|
await requireEdgeAuth(makeReq(), makeLoopbackServer());
|
|
@@ -333,6 +344,18 @@ describe("requireEdgeAuthWithScope — JWT mode", () => {
|
|
|
333
344
|
expect(mockValidateEdgeToken).not.toHaveBeenCalled();
|
|
334
345
|
});
|
|
335
346
|
|
|
347
|
+
test("rejects edge-forwarded loopback requests when scoped Authorization is absent", async () => {
|
|
348
|
+
const { requireEdgeAuthWithScope } = makeMiddleware();
|
|
349
|
+
const res = await requireEdgeAuthWithScope(
|
|
350
|
+
makeReq({ "x-vellum-edge-forwarded": "1" }),
|
|
351
|
+
"settings.write",
|
|
352
|
+
makeLoopbackServer(),
|
|
353
|
+
);
|
|
354
|
+
expect(res?.status).toBe(401);
|
|
355
|
+
expect(mockValidateEdgeToken).not.toHaveBeenCalled();
|
|
356
|
+
expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
|
|
336
359
|
test("403 when token's scope_profile lacks the required scope", async () => {
|
|
337
360
|
// actor_client_v1 grants chat.* and settings.*, but NOT ingress.write
|
|
338
361
|
mockValidateEdgeToken = mock(() => ({
|
|
@@ -181,6 +181,18 @@ describe("requireEdgeGuardianAuth — actor principal mode", () => {
|
|
|
181
181
|
expect(mockFindVellumGuardian).not.toHaveBeenCalled();
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
+
test("rejects edge-forwarded loopback requests when Authorization header is absent", async () => {
|
|
185
|
+
const { requireEdgeGuardianAuth } = makeMiddleware();
|
|
186
|
+
const res = await requireEdgeGuardianAuth(
|
|
187
|
+
makeReq({ "x-vellum-edge-forwarded": "1" }),
|
|
188
|
+
makeLoopbackServer(),
|
|
189
|
+
);
|
|
190
|
+
expect(res?.status).toBe(401);
|
|
191
|
+
expect(mockValidateEdgeToken).not.toHaveBeenCalled();
|
|
192
|
+
expect(mockFindVellumGuardian).not.toHaveBeenCalled();
|
|
193
|
+
expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
184
196
|
test("invalid bearer token falls back to loopback", async () => {
|
|
185
197
|
mockValidateEdgeToken = mock(() => ({ ok: false, reason: "expired" }));
|
|
186
198
|
const { requireEdgeGuardianAuth } = makeMiddleware();
|
|
@@ -475,7 +475,8 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
475
475
|
|
|
476
476
|
test("string flag uses registry default when no override exists", async () => {
|
|
477
477
|
if (existsSync(featureFlagStorePath)) rmSync(featureFlagStorePath);
|
|
478
|
-
if (existsSync(remoteFeatureFlagStorePath))
|
|
478
|
+
if (existsSync(remoteFeatureFlagStorePath))
|
|
479
|
+
rmSync(remoteFeatureFlagStorePath);
|
|
479
480
|
clearFeatureFlagStoreCache();
|
|
480
481
|
clearRemoteFeatureFlagStoreCache();
|
|
481
482
|
|
|
@@ -893,4 +894,60 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
893
894
|
expect(persisted[key]).toBe(false);
|
|
894
895
|
}
|
|
895
896
|
});
|
|
897
|
+
|
|
898
|
+
test("invokes onFlagChanged once after a successful write", async () => {
|
|
899
|
+
let calls = 0;
|
|
900
|
+
const handler = createFeatureFlagsPatchHandler(() => {
|
|
901
|
+
calls += 1;
|
|
902
|
+
});
|
|
903
|
+
const res = await handler(
|
|
904
|
+
new Request("http://gateway.test/v1/feature-flags/browser", {
|
|
905
|
+
method: "PATCH",
|
|
906
|
+
headers: { "content-type": "application/json" },
|
|
907
|
+
body: JSON.stringify({ enabled: false }),
|
|
908
|
+
}),
|
|
909
|
+
"browser",
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
expect(res.status).toBe(200);
|
|
913
|
+
expect(calls).toBe(1);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("does not invoke onFlagChanged when the request is rejected", async () => {
|
|
917
|
+
let calls = 0;
|
|
918
|
+
const handler = createFeatureFlagsPatchHandler(() => {
|
|
919
|
+
calls += 1;
|
|
920
|
+
});
|
|
921
|
+
const res = await handler(
|
|
922
|
+
new Request("http://gateway.test/v1/feature-flags/totally-unknown-flag", {
|
|
923
|
+
method: "PATCH",
|
|
924
|
+
headers: { "content-type": "application/json" },
|
|
925
|
+
body: JSON.stringify({ enabled: true }),
|
|
926
|
+
}),
|
|
927
|
+
"totally-unknown-flag",
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
expect(res.status).toBe(400);
|
|
931
|
+
expect(calls).toBe(0);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("a throwing onFlagChanged does not fail an already-committed write", async () => {
|
|
935
|
+
const handler = createFeatureFlagsPatchHandler(() => {
|
|
936
|
+
throw new Error("notification boom");
|
|
937
|
+
});
|
|
938
|
+
const res = await handler(
|
|
939
|
+
new Request("http://gateway.test/v1/feature-flags/browser", {
|
|
940
|
+
method: "PATCH",
|
|
941
|
+
headers: { "content-type": "application/json" },
|
|
942
|
+
body: JSON.stringify({ enabled: false }),
|
|
943
|
+
}),
|
|
944
|
+
"browser",
|
|
945
|
+
);
|
|
946
|
+
|
|
947
|
+
expect(res.status).toBe(200);
|
|
948
|
+
|
|
949
|
+
clearFeatureFlagStoreCache();
|
|
950
|
+
const persisted = readPersistedFeatureFlags();
|
|
951
|
+
expect(persisted["browser"]).toBe(false);
|
|
952
|
+
});
|
|
896
953
|
});
|
|
@@ -184,9 +184,9 @@ describe("createGuardianBinding", () => {
|
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
test("
|
|
187
|
+
test("claims an existing Slack channel with matching address", async () => {
|
|
188
188
|
seedGuardianContact();
|
|
189
|
-
seedSlackContactChannel("
|
|
189
|
+
seedSlackContactChannel("U123EXAMPLE");
|
|
190
190
|
|
|
191
191
|
await createGuardianBinding({
|
|
192
192
|
channel: "slack",
|
|
@@ -224,10 +224,9 @@ describe("createGuardianBinding", () => {
|
|
|
224
224
|
]);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
-
test("
|
|
227
|
+
test("reactivates a revoked guardian channel instead of creating a new one", async () => {
|
|
228
228
|
seedGuardianContact();
|
|
229
229
|
seedRevokedGuardianSlackChannel();
|
|
230
|
-
seedSlackContactChannel("u123example");
|
|
231
230
|
|
|
232
231
|
const result = await createGuardianBinding({
|
|
233
232
|
channel: "slack",
|
|
@@ -264,12 +263,6 @@ describe("createGuardianBinding", () => {
|
|
|
264
263
|
address: "U123EXAMPLE",
|
|
265
264
|
status: "active",
|
|
266
265
|
},
|
|
267
|
-
{
|
|
268
|
-
id: "seed-channel",
|
|
269
|
-
contact_id: "seed-contact",
|
|
270
|
-
address: "u123example",
|
|
271
|
-
status: "unverified",
|
|
272
|
-
},
|
|
273
266
|
]);
|
|
274
267
|
});
|
|
275
268
|
});
|
|
@@ -114,7 +114,7 @@ function seedTestData(): void {
|
|
|
114
114
|
id: "ch1",
|
|
115
115
|
contactId: "c1",
|
|
116
116
|
type: "telegram",
|
|
117
|
-
address: "
|
|
117
|
+
address: "tg-fake-001",
|
|
118
118
|
isPrimary: true,
|
|
119
119
|
externalUserId: "tg-fake-001",
|
|
120
120
|
externalChatId: "chat-fake-001",
|
|
@@ -127,7 +127,7 @@ function seedTestData(): void {
|
|
|
127
127
|
id: "ch2",
|
|
128
128
|
contactId: "c1",
|
|
129
129
|
type: "slack",
|
|
130
|
-
address: "
|
|
130
|
+
address: "UFAKE00001",
|
|
131
131
|
isPrimary: false,
|
|
132
132
|
externalUserId: "UFAKE00001",
|
|
133
133
|
externalChatId: "DFAKE00001",
|