@vellumai/vellum-gateway 0.6.0 → 0.6.2

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.
@@ -0,0 +1,94 @@
1
+ import type { GatewayInboundEvent } from "../types.js";
2
+
3
+ /**
4
+ * Shape of a normalized inbound email event as sent by the Vellum
5
+ * platform (or any upstream caller).
6
+ *
7
+ * The platform is responsible for provider-specific parsing (e.g.
8
+ * Mailgun multipart → JSON). By the time the payload reaches the
9
+ * gateway it should already be in this canonical shape.
10
+ */
11
+ export interface VellumEmailPayload {
12
+ /** Sender email address (e.g. "user@vellum.me"). */
13
+ from: string;
14
+ /** Sender display name (e.g. "Alice Smith"). Optional. */
15
+ fromName?: string;
16
+ /** Recipient email address (the assistant's address). */
17
+ to: string;
18
+ /** Email subject line. */
19
+ subject?: string;
20
+ /** Plain-text body content (latest reply only, quoted text stripped). */
21
+ strippedText?: string;
22
+ /** Full plain-text body (fallback when strippedText is unavailable). */
23
+ bodyText?: string;
24
+ /** RFC 5322 Message-ID header value. */
25
+ messageId: string;
26
+ /** Message-ID of the parent message (In-Reply-To header). */
27
+ inReplyTo?: string;
28
+ /** Space-separated chain of ancestor Message-IDs (References header). */
29
+ references?: string;
30
+ /** Stable conversation/thread identifier derived by the platform. */
31
+ conversationId: string;
32
+ /** ISO 8601 timestamp of the original email. */
33
+ timestamp?: string;
34
+ }
35
+
36
+ export interface NormalizedEmailEvent {
37
+ event: GatewayInboundEvent;
38
+ /** Unique event/message ID for dedup. */
39
+ eventId: string;
40
+ /** Original recipient address for routing. */
41
+ recipientAddress: string;
42
+ }
43
+
44
+ /**
45
+ * Normalize a Vellum email webhook payload into a GatewayInboundEvent.
46
+ *
47
+ * Returns null if required fields are missing.
48
+ */
49
+ export function normalizeEmailWebhook(
50
+ payload: Record<string, unknown>,
51
+ ): NormalizedEmailEvent | null {
52
+ const from = payload.from as string | undefined;
53
+ const to = payload.to as string | undefined;
54
+ const messageId = payload.messageId as string | undefined;
55
+ const conversationId = payload.conversationId as string | undefined;
56
+
57
+ if (!from || !to || !messageId || !conversationId) {
58
+ return null;
59
+ }
60
+
61
+ // Prefer strippedText (latest reply only) over full body
62
+ const content =
63
+ (payload.strippedText as string | undefined) ??
64
+ (payload.bodyText as string | undefined) ??
65
+ "";
66
+
67
+ const fromName = payload.fromName as string | undefined;
68
+
69
+ const event: GatewayInboundEvent = {
70
+ version: "v1",
71
+ sourceChannel: "email",
72
+ receivedAt: new Date().toISOString(),
73
+ message: {
74
+ content,
75
+ conversationExternalId: conversationId,
76
+ externalMessageId: messageId,
77
+ },
78
+ actor: {
79
+ actorExternalId: from,
80
+ displayName: fromName || from,
81
+ username: from,
82
+ },
83
+ source: {
84
+ updateId: messageId,
85
+ },
86
+ raw: payload,
87
+ };
88
+
89
+ return {
90
+ event,
91
+ eventId: messageId,
92
+ recipientAddress: to,
93
+ };
94
+ }
@@ -0,0 +1,96 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { describe, it, expect } from "bun:test";
3
+ import { verifyEmailWebhookSignature } from "./verify.js";
4
+
5
+ const SECRET = "test-webhook-secret-1234";
6
+
7
+ function computeSignature(body: string, secret: string): string {
8
+ return (
9
+ "sha256=" + createHmac("sha256", secret).update(body, "utf8").digest("hex")
10
+ );
11
+ }
12
+
13
+ describe("verifyEmailWebhookSignature", () => {
14
+ const body = '{"from":"sender@example.com","to":"bot@vellum.me"}';
15
+
16
+ it("accepts a valid HMAC signature", () => {
17
+ const headers = new Headers({
18
+ "vellum-signature": computeSignature(body, SECRET),
19
+ });
20
+ expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(true);
21
+ });
22
+
23
+ it("rejects a wrong secret", () => {
24
+ const headers = new Headers({
25
+ "vellum-signature": computeSignature(body, "wrong-secret"),
26
+ });
27
+ expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
28
+ });
29
+
30
+ it("rejects when header is missing", () => {
31
+ const headers = new Headers();
32
+ expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
33
+ });
34
+
35
+ it("rejects when secret is empty", () => {
36
+ const headers = new Headers({
37
+ "vellum-signature": computeSignature(body, SECRET),
38
+ });
39
+ expect(verifyEmailWebhookSignature(headers, body, "")).toBe(false);
40
+ });
41
+
42
+ it("rejects when header value is empty", () => {
43
+ const headers = new Headers({
44
+ "vellum-signature": "",
45
+ });
46
+ expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
47
+ });
48
+
49
+ it("rejects a signature without sha256= prefix", () => {
50
+ const digest = createHmac("sha256", SECRET)
51
+ .update(body, "utf8")
52
+ .digest("hex");
53
+ const headers = new Headers({
54
+ "vellum-signature": digest,
55
+ });
56
+ expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
57
+ });
58
+
59
+ it("rejects when body has been tampered with", () => {
60
+ const headers = new Headers({
61
+ "vellum-signature": computeSignature(body, SECRET),
62
+ });
63
+ expect(verifyEmailWebhookSignature(headers, "tampered body", SECRET)).toBe(
64
+ false,
65
+ );
66
+ });
67
+
68
+ it("produces different signatures for different bodies", () => {
69
+ const sig1 = computeSignature("body-a", SECRET);
70
+ const sig2 = computeSignature("body-b", SECRET);
71
+ expect(sig1).not.toBe(sig2);
72
+
73
+ const headers1 = new Headers({ "vellum-signature": sig1 });
74
+ expect(verifyEmailWebhookSignature(headers1, "body-a", SECRET)).toBe(true);
75
+ expect(verifyEmailWebhookSignature(headers1, "body-b", SECRET)).toBe(false);
76
+ });
77
+
78
+ it("rejects a truncated hex digest", () => {
79
+ const headers = new Headers({
80
+ "vellum-signature": "sha256=abcd1234",
81
+ });
82
+ expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
83
+ });
84
+
85
+ it("rejects non-ASCII hex values without throwing (Buffer byte length divergence)", () => {
86
+ // 64 non-ASCII characters whose UTF-16 .length === 64 (same as a valid
87
+ // hex digest) but whose Buffer byte length > 64, which would cause
88
+ // timingSafeEqual to throw if we only compared string lengths.
89
+ const nonAsciiHex = "\u00e9".repeat(64); // é is 2 bytes in UTF-8
90
+ const headers = new Headers({
91
+ "vellum-signature": `sha256=${nonAsciiHex}`,
92
+ });
93
+ // Must return false cleanly — not throw
94
+ expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
95
+ });
96
+ });
@@ -0,0 +1,41 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Verify the Vellum-Signature header sent by the Vellum platform.
5
+ *
6
+ * The platform computes: HMAC-SHA256(webhookSecret, rawBody) and sends it as
7
+ * Vellum-Signature: sha256=<hex-digest>
8
+ *
9
+ * We must compare with a constant-time comparison to avoid timing attacks.
10
+ *
11
+ * This mirrors the WhatsApp webhook signature verification pattern
12
+ * (`X-Hub-Signature-256` header).
13
+ */
14
+
15
+ const HEADER_NAME = "vellum-signature";
16
+
17
+ export function verifyEmailWebhookSignature(
18
+ headers: Headers,
19
+ rawBody: string,
20
+ webhookSecret: string,
21
+ ): boolean {
22
+ const signatureHeader = headers.get(HEADER_NAME);
23
+ if (!signatureHeader || !webhookSecret) return false;
24
+
25
+ // Header format: "sha256=<hex-digest>"
26
+ if (!signatureHeader.startsWith("sha256=")) return false;
27
+ const providedHex = signatureHeader.slice(7);
28
+
29
+ const expected = createHmac("sha256", webhookSecret)
30
+ .update(rawBody, "utf8")
31
+ .digest("hex");
32
+
33
+ // Compare Buffer byte lengths — not string .length — to avoid
34
+ // timingSafeEqual throwing on non-ASCII input where UTF-16 code unit
35
+ // count matches but byte length diverges.
36
+ const providedBuf = Buffer.from(providedHex);
37
+ const expectedBuf = Buffer.from(expected);
38
+ if (providedBuf.length !== expectedBuf.length) return false;
39
+
40
+ return timingSafeEqual(providedBuf, expectedBuf);
41
+ }
@@ -121,6 +121,14 @@
121
121
  "description": "Show the Billing tab in Settings when signed in, displaying credits balance and top-up",
122
122
  "defaultEnabled": true
123
123
  },
124
+ {
125
+ "id": "referral-codes",
126
+ "scope": "macos",
127
+ "key": "referral-codes",
128
+ "label": "Referral Codes",
129
+ "description": "Surface the Earn Credits referral entry points (sidebar drawer row and Billing tab button) that open the referral modal",
130
+ "defaultEnabled": true
131
+ },
124
132
  {
125
133
  "id": "managed-sign-in",
126
134
  "scope": "macos",
@@ -199,7 +207,7 @@
199
207
  "key": "show-thinking-blocks",
200
208
  "label": "Show Thinking Blocks",
201
209
  "description": "Display the assistant's thinking/reasoning inline in chat messages as collapsible blocks",
202
- "defaultEnabled": false
210
+ "defaultEnabled": true
203
211
  },
204
212
  {
205
213
  "id": "inline-skill-commands",
@@ -272,6 +280,14 @@
272
280
  "label": "Teleport",
273
281
  "description": "Enable teleport UI in General settings for moving assistants between hosting environments",
274
282
  "defaultEnabled": false
283
+ },
284
+ {
285
+ "id": "permission-controls-v2",
286
+ "scope": "assistant",
287
+ "key": "permission-controls-v2",
288
+ "label": "Permission Controls V2",
289
+ "description": "Replace risk-level permission system with two independent controls: 'Ask before acting' (LLM behavior toggle) and 'Host access' (system-enforced gate)",
290
+ "defaultEnabled": false
275
291
  }
276
292
  ]
277
293
  }
@@ -114,6 +114,25 @@ export function writeRemoteFeatureFlags(values: Record<string, boolean>): void {
114
114
  log.info({ count: Object.keys(values).length }, "Wrote remote feature flags");
115
115
  }
116
116
 
117
+ /**
118
+ * Clear the in-memory cache so the next `readRemoteFeatureFlags()` call
119
+ * re-reads from disk. Useful in tests for resetting state between cases.
120
+ */
117
121
  export function clearRemoteFeatureFlagStoreCache(): void {
118
122
  cachedRemoteValues = null;
119
123
  }
124
+
125
+ /**
126
+ * Re-read the remote feature flag file from disk into the in-memory cache.
127
+ *
128
+ * Called by the file watcher when `feature-flags-remote.json` changes on
129
+ * disk (e.g. written by a separate process or a previous gateway instance).
130
+ * This ensures the next `readRemoteFeatureFlags()` call returns fresh data
131
+ * without requiring every read to hit disk.
132
+ */
133
+ export function refreshRemoteFeatureFlagStoreCache(): void {
134
+ cachedRemoteValues = null;
135
+ // Force a re-read into cache immediately so the next
136
+ // readRemoteFeatureFlags() call picks up the new values.
137
+ readRemoteFeatureFlags();
138
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Watches feature-flags.json for external modifications and invalidates the
3
- * module-level cache in feature-flag-store.ts.
2
+ * Watches feature flag files for external modifications and invalidates /
3
+ * refreshes the corresponding module-level caches.
4
4
  *
5
5
  * Uses the same fs.watch() + debounce pattern as CredentialWatcher and
6
6
  * ConfigFileWatcher. Watches the parent directory (not the file itself)
@@ -15,6 +15,10 @@ import {
15
15
  clearFeatureFlagStoreCache,
16
16
  getFeatureFlagStorePath,
17
17
  } from "./feature-flag-store.js";
18
+ import {
19
+ refreshRemoteFeatureFlagStoreCache,
20
+ getRemoteFeatureFlagStorePath,
21
+ } from "./feature-flag-remote-store.js";
18
22
  import { getLogger } from "./logger.js";
19
23
 
20
24
  const log = getLogger("feature-flag-watcher");
@@ -24,16 +28,18 @@ const DEBOUNCE_MS = 500;
24
28
  export class FeatureFlagWatcher {
25
29
  private watcher: FSWatcher | null = null;
26
30
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
27
- private flagPath: string;
28
- private flagFilename: string;
31
+ private localFlagFilename: string;
32
+ private remoteFlagFilename: string;
33
+ /** Accumulates which files changed during the debounce window. */
34
+ private pendingFilenames = new Set<string>();
29
35
 
30
36
  constructor() {
31
- this.flagPath = getFeatureFlagStorePath();
32
- this.flagFilename = basename(this.flagPath);
37
+ this.localFlagFilename = basename(getFeatureFlagStorePath());
38
+ this.remoteFlagFilename = basename(getRemoteFeatureFlagStorePath());
33
39
  }
34
40
 
35
41
  start(): void {
36
- const dir = dirname(this.flagPath);
42
+ const dir = dirname(getFeatureFlagStorePath());
37
43
 
38
44
  // Ensure the directory exists so fs.watch() doesn't throw ENOENT
39
45
  // on a fresh instance where no flags have been persisted yet.
@@ -43,10 +49,14 @@ export class FeatureFlagWatcher {
43
49
 
44
50
  try {
45
51
  this.watcher = watch(dir, { persistent: false }, (_event, filename) => {
46
- if (filename && filename !== this.flagFilename) {
52
+ if (
53
+ filename &&
54
+ filename !== this.localFlagFilename &&
55
+ filename !== this.remoteFlagFilename
56
+ ) {
47
57
  return;
48
58
  }
49
- this.scheduleInvalidation();
59
+ this.scheduleInvalidation(filename ?? undefined);
50
60
  });
51
61
 
52
62
  log.info({ path: dir }, "Watching for feature flag file changes");
@@ -66,14 +76,30 @@ export class FeatureFlagWatcher {
66
76
  }
67
77
  }
68
78
 
69
- private scheduleInvalidation(): void {
79
+ private scheduleInvalidation(filename?: string): void {
80
+ if (filename) {
81
+ this.pendingFilenames.add(filename);
82
+ }
83
+
70
84
  if (this.debounceTimer) {
71
85
  clearTimeout(this.debounceTimer);
72
86
  }
73
87
  this.debounceTimer = setTimeout(() => {
74
88
  this.debounceTimer = null;
75
- clearFeatureFlagStoreCache();
76
- log.info("Feature flag cache invalidated due to file change");
89
+
90
+ const filenames = this.pendingFilenames;
91
+ this.pendingFilenames = new Set<string>();
92
+
93
+ if (filenames.has(this.localFlagFilename)) {
94
+ clearFeatureFlagStoreCache();
95
+ }
96
+ if (filenames.has(this.remoteFlagFilename)) {
97
+ refreshRemoteFeatureFlagStoreCache();
98
+ }
99
+ log.info(
100
+ { filenames: [...filenames] },
101
+ "Feature flag cache invalidated due to file change",
102
+ );
77
103
  }, DEBOUNCE_MS);
78
104
  }
79
105
  }