@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.
Files changed (51) hide show
  1. package/AGENTS.md +1 -4
  2. package/ARCHITECTURE.md +5 -6
  3. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  4. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  5. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  6. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  7. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  8. package/package.json +1 -1
  9. package/src/__tests__/edge-auth.test.ts +23 -0
  10. package/src/__tests__/edge-guardian-auth.test.ts +12 -0
  11. package/src/__tests__/feature-flags-route.test.ts +58 -1
  12. package/src/__tests__/guardian-binding-channel-reuse.test.ts +3 -10
  13. package/src/__tests__/ipc-contact-routes.test.ts +2 -2
  14. package/src/__tests__/remote-web-ingress-denylist.test.ts +20 -1
  15. package/src/__tests__/remote-web-pairing-challenge.test.ts +1 -0
  16. package/src/__tests__/remote-web-pairing-token.test.ts +334 -0
  17. package/src/__tests__/remote-web-pairing-verification.test.ts +192 -0
  18. package/src/__tests__/route-schema-guard.test.ts +4 -0
  19. package/src/__tests__/upsert-verified-contact-channel.test.ts +4 -8
  20. package/src/auth/guardian-bootstrap.ts +52 -16
  21. package/src/auth/guardian-refresh.ts +78 -38
  22. package/src/credential-watcher.ts +2 -2
  23. package/src/db/connection.ts +82 -0
  24. package/src/db/contact-store.ts +45 -35
  25. package/src/db/data-migrations/m0005-normalize-contact-channel-addresses.ts +35 -6
  26. package/src/db/schema.ts +5 -4
  27. package/src/feature-flag-registry.json +1 -17
  28. package/src/http/browser-auth-cookies.ts +77 -0
  29. package/src/http/middleware/auth.ts +3 -0
  30. package/src/http/middleware/cors.ts +1 -1
  31. package/src/http/read-limited-body.ts +50 -0
  32. package/src/http/routes/channel-verification-session-proxy.test.ts +0 -15
  33. package/src/http/routes/channel-verification-session-proxy.ts +1 -71
  34. package/src/http/routes/contact-prompt.ts +16 -6
  35. package/src/http/routes/contacts-control-plane-proxy.ts +21 -11
  36. package/src/http/routes/feature-flags.ts +16 -3
  37. package/src/http/routes/guardian-channel-create.ts +12 -3
  38. package/src/http/routes/guardian-refresh.ts +125 -0
  39. package/src/http/routes/remote-web-pairing-token.ts +122 -0
  40. package/src/http/routes/remote-web-pairing-verification.ts +114 -0
  41. package/src/index.ts +30 -3
  42. package/src/ipc/contact-handlers.ts +15 -5
  43. package/src/remote-web/pairing-challenge-store.ts +131 -4
  44. package/src/remote-web/pairing-verification-rate-limit-store.ts +57 -0
  45. package/src/risk/command-registry/commands/assistant.ts +1 -18
  46. package/src/risk/command-registry.test.ts +1 -1
  47. package/src/telegram/send.test.ts +6 -6
  48. package/src/telegram/send.ts +3 -13
  49. package/src/verification/contact-helpers.ts +27 -34
  50. package/src/verification/text-verification.ts +28 -19
  51. 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
- Native clients (macOS, iOS) 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.).
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 Apple-native `SFSpeechRecognizer` as fallback when the service returns 503 (not configured) or fails.
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/shared/Network/STTClient.swift` | Shared client: POSTs audio to the gateway, returns typed `STTResult` |
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
- Native clients (macOS, iOS) 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.
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/shared/Network/STTStreamingClient.swift` | Swift client: builds the gateway WS URL via `GatewayHTTPClient.buildWebSocketRequest` |
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
- for (const [id, entry] of pending) {
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 "./types.js";
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
- // HTTP delivery types
11
- // ---------------------------------------------------------------------------
12
-
13
- /** Metadata for a file attachment delivered alongside a channel reply. */
14
- export interface AttachmentMetadata {
15
- id: string;
16
- filename: string;
17
- mimeType: string;
18
- sizeBytes: number;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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)) rmSync(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("repairs an old lowercase Slack address by matching the preserved external user ID", async () => {
187
+ test("claims an existing Slack channel with matching address", async () => {
188
188
  seedGuardianContact();
189
- seedSlackContactChannel("u123example");
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("prefers the cased guardian channel over a lowercase seed duplicate", async () => {
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: "test-tg-user",
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: "test-slack-user",
130
+ address: "UFAKE00001",
131
131
  isPrimary: false,
132
132
  externalUserId: "UFAKE00001",
133
133
  externalChatId: "DFAKE00001",