@vellumai/assistant 0.4.13 → 0.4.15

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 (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Guard tests for the single-header JWT auth system.
3
+ *
4
+ * These tests enforce architectural invariants that protect the auth
5
+ * system from regressions:
6
+ *
7
+ * 1. Route policy coverage — every dispatched endpoint has a policy.
8
+ * 2. No X-Actor-Token references in production code.
9
+ * 3. No ~/.vellum/http-token file-path references in production code
10
+ * (the file itself is still used; the guard prevents new code from
11
+ * reading it directly instead of using the platform utility).
12
+ * 4. Scope profile contract — every profile resolves to the expected scopes.
13
+ */
14
+
15
+ import { execSync } from 'node:child_process';
16
+ import { readFileSync } from 'node:fs';
17
+ import { resolve } from 'node:path';
18
+
19
+ import { describe, expect, test } from 'bun:test';
20
+
21
+ import { resolveScopeProfile } from '../scopes.js';
22
+ import type { Scope, ScopeProfile } from '../types.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Project root (one level above assistant/). */
29
+ const PROJECT_ROOT = resolve(import.meta.dir, '../../../../..');
30
+
31
+ function isTestFile(filePath: string): boolean {
32
+ return (
33
+ filePath.includes('/__tests__/') ||
34
+ filePath.endsWith('.test.ts') ||
35
+ filePath.endsWith('.test.js') ||
36
+ filePath.endsWith('.spec.ts') ||
37
+ filePath.endsWith('.spec.js')
38
+ );
39
+ }
40
+
41
+ function isDocFile(filePath: string): boolean {
42
+ return filePath.endsWith('.md');
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // 1. Route policy coverage
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe('route policy coverage', () => {
50
+ test('every endpoint dispatched in http-server.ts has a policy entry in route-policy.ts', () => {
51
+ // Read both files as source text.
52
+ const httpServerPath = resolve(import.meta.dir, '../../http-server.ts');
53
+ const routePolicyPath = resolve(import.meta.dir, '../route-policy.ts');
54
+
55
+ const httpServerSrc = readFileSync(httpServerPath, 'utf-8');
56
+ const routePolicySrc = readFileSync(routePolicyPath, 'utf-8');
57
+
58
+ // Extract endpoint strings from dispatchEndpoint. We look for patterns
59
+ // like `endpoint === 'foo'` which is the dispatch pattern.
60
+ const endpointMatches = httpServerSrc.matchAll(
61
+ /endpoint\s*===\s*'([^']+)'/g,
62
+ );
63
+ const dispatchedEndpoints = new Set<string>();
64
+ for (const m of endpointMatches) {
65
+ dispatchedEndpoints.add(m[1]);
66
+ }
67
+
68
+ // These endpoints are handled in dispatchEndpoint but intentionally
69
+ // don't need a route policy (they are unprotected utility endpoints).
70
+ const UNPROTECTED_ENDPOINTS = new Set([
71
+ 'health',
72
+ ]);
73
+
74
+ // Extract registered policy endpoint strings from route-policy.ts.
75
+ // Match: `{ endpoint: 'foo' }` entries, `registerPolicy('foo', ...)`
76
+ // calls, and bare string literals in arrays like INTERNAL_ENDPOINTS.
77
+ const policyEndpointMatches = routePolicySrc.matchAll(
78
+ /endpoint:\s*'([^']+)'|registerPolicy\(\s*'([^']+)'/g,
79
+ );
80
+ const registeredPolicies = new Set<string>();
81
+ for (const m of policyEndpointMatches) {
82
+ registeredPolicies.add(m[1] ?? m[2]);
83
+ }
84
+
85
+ // Also extract string literals from the INTERNAL_ENDPOINTS array,
86
+ // which uses a loop to register policies dynamically.
87
+ const internalArrayMatch = routePolicySrc.match(
88
+ /INTERNAL_ENDPOINTS\s*=\s*\[([\s\S]*?)\]/,
89
+ );
90
+ if (internalArrayMatch) {
91
+ const arrayLiterals = internalArrayMatch[1].matchAll(/'([^']+)'/g);
92
+ for (const m of arrayLiterals) {
93
+ registeredPolicies.add(m[1]);
94
+ }
95
+ }
96
+
97
+ // For method-specific dispatches like `endpoint === 'messages' && req.method === 'POST'`,
98
+ // the policy key might be `messages:POST` or just `messages`. We need to
99
+ // check that either the plain endpoint key or a method-qualified key exists.
100
+ const missingPolicies: string[] = [];
101
+ for (const endpoint of dispatchedEndpoints) {
102
+ if (UNPROTECTED_ENDPOINTS.has(endpoint)) continue;
103
+
104
+ // Check if the plain endpoint or any method-qualified variant is registered
105
+ const hasPlainPolicy = registeredPolicies.has(endpoint);
106
+ const hasMethodPolicy = [...registeredPolicies].some(
107
+ (p) => p.startsWith(endpoint + ':'),
108
+ );
109
+
110
+ if (!hasPlainPolicy && !hasMethodPolicy) {
111
+ missingPolicies.push(endpoint);
112
+ }
113
+ }
114
+
115
+ if (missingPolicies.length > 0) {
116
+ const message = [
117
+ 'Endpoints dispatched in http-server.ts have no route policy in route-policy.ts:',
118
+ '',
119
+ ...missingPolicies.map((e) => ` - ${e}`),
120
+ '',
121
+ 'Every protected endpoint must have a policy entry.',
122
+ 'Add a registerPolicy() call or ACTOR_ENDPOINTS entry in route-policy.ts.',
123
+ 'If truly unprotected, add to UNPROTECTED_ENDPOINTS in this guard test.',
124
+ ].join('\n');
125
+ expect(missingPolicies, message).toEqual([]);
126
+ }
127
+ });
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // 2. No X-Actor-Token references in production code
132
+ // ---------------------------------------------------------------------------
133
+
134
+ describe('no X-Actor-Token in production code', () => {
135
+ test('production files do not reference X-Actor-Token', () => {
136
+ let grepOutput = '';
137
+ try {
138
+ grepOutput = execSync(
139
+ `git grep -liE "X-Actor-Token" -- '*.ts' '*.tsx' '*.js' '*.swift'`,
140
+ { encoding: 'utf-8', cwd: PROJECT_ROOT },
141
+ ).trim();
142
+ } catch (err) {
143
+ // Exit code 1 means no matches — that's the happy path.
144
+ if ((err as { status?: number }).status === 1) return;
145
+ throw err;
146
+ }
147
+
148
+ const files = grepOutput.split('\n').filter((f) => f.length > 0);
149
+
150
+ // Files that are allowed to mention X-Actor-Token (comments explaining
151
+ // the migration, or this guard test itself).
152
+ const ALLOWLIST = new Set([
153
+ // This guard test references it by definition
154
+ 'assistant/src/runtime/auth/__tests__/guard-tests.test.ts',
155
+ ]);
156
+
157
+ const violations = files.filter((f) => {
158
+ if (isTestFile(f)) return false;
159
+ if (isDocFile(f)) return false;
160
+ if (ALLOWLIST.has(f)) return false;
161
+ return true;
162
+ });
163
+
164
+ if (violations.length > 0) {
165
+ const message = [
166
+ 'Production files still reference X-Actor-Token.',
167
+ 'The old two-header auth model has been replaced by single JWT auth.',
168
+ '',
169
+ 'Violations:',
170
+ ...violations.map((f) => ` - ${f}`),
171
+ '',
172
+ 'Remove or update these references.',
173
+ 'If a comment explains the migration, that is fine — add the file to the ALLOWLIST.',
174
+ ].join('\n');
175
+ expect(violations, message).toEqual([]);
176
+ }
177
+ });
178
+ });
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // 3. No legacy GATEWAY_ORIGIN_HEADER / verifyGatewayOrigin in production code
182
+ // ---------------------------------------------------------------------------
183
+
184
+ describe('no legacy gateway-origin proof in production code', () => {
185
+ test('production files do not import or use GATEWAY_ORIGIN_HEADER or verifyGatewayOrigin', () => {
186
+ let grepOutput = '';
187
+ try {
188
+ grepOutput = execSync(
189
+ `git grep -lE "GATEWAY_ORIGIN_HEADER|verifyGatewayOrigin" -- '*.ts' '*.tsx'`,
190
+ { encoding: 'utf-8', cwd: PROJECT_ROOT },
191
+ ).trim();
192
+ } catch (err) {
193
+ if ((err as { status?: number }).status === 1) return;
194
+ throw err;
195
+ }
196
+
197
+ const files = grepOutput.split('\n').filter((f) => f.length > 0);
198
+
199
+ const ALLOWLIST = new Set([
200
+ 'assistant/src/runtime/auth/__tests__/guard-tests.test.ts',
201
+ ]);
202
+
203
+ const violations = files.filter((f) => {
204
+ if (isTestFile(f)) return false;
205
+ if (isDocFile(f)) return false;
206
+ if (ALLOWLIST.has(f)) return false;
207
+ return true;
208
+ });
209
+
210
+ if (violations.length > 0) {
211
+ const message = [
212
+ 'Production files still reference GATEWAY_ORIGIN_HEADER or verifyGatewayOrigin.',
213
+ 'Gateway origin is now proven by JWT principal type (svc_gateway), not a separate header.',
214
+ '',
215
+ 'Violations:',
216
+ ...violations.map((f) => ` - ${f}`),
217
+ '',
218
+ 'Remove or update these references.',
219
+ ].join('\n');
220
+ expect(violations, message).toEqual([]);
221
+ }
222
+ });
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // 4. Scope profile contract
227
+ // ---------------------------------------------------------------------------
228
+
229
+ describe('scope profile contract', () => {
230
+ const EXPECTED_PROFILES: Record<ScopeProfile, Scope[]> = {
231
+ actor_client_v1: [
232
+ 'chat.read',
233
+ 'chat.write',
234
+ 'approval.read',
235
+ 'approval.write',
236
+ 'settings.read',
237
+ 'settings.write',
238
+ 'attachments.read',
239
+ 'attachments.write',
240
+ 'calls.read',
241
+ 'calls.write',
242
+ 'feature_flags.read',
243
+ 'feature_flags.write',
244
+ ],
245
+ gateway_ingress_v1: [
246
+ 'ingress.write',
247
+ 'internal.write',
248
+ ],
249
+ gateway_service_v1: [
250
+ 'chat.write',
251
+ 'settings.read',
252
+ 'settings.write',
253
+ 'attachments.read',
254
+ 'attachments.write',
255
+ 'internal.write',
256
+ ],
257
+ ipc_v1: [
258
+ 'ipc.all',
259
+ ],
260
+ };
261
+
262
+ for (const [profile, expectedScopes] of Object.entries(EXPECTED_PROFILES)) {
263
+ test(`${profile} resolves to exactly the expected scopes`, () => {
264
+ const resolved = resolveScopeProfile(profile as ScopeProfile);
265
+ const resolvedArray = [...resolved].sort();
266
+ const expectedSorted = [...expectedScopes].sort();
267
+
268
+ expect(resolvedArray).toEqual(expectedSorted);
269
+ expect(resolved.size).toBe(expectedScopes.length);
270
+ });
271
+ }
272
+
273
+ test('all ScopeProfile values are covered by the contract test', () => {
274
+ // The type system ensures EXPECTED_PROFILES covers all ScopeProfile
275
+ // values via the Record<ScopeProfile, ...> type. This test verifies
276
+ // that resolveScopeProfile returns a non-empty set for each.
277
+ const profiles: ScopeProfile[] = [
278
+ 'actor_client_v1',
279
+ 'gateway_ingress_v1',
280
+ 'gateway_service_v1',
281
+ 'ipc_v1',
282
+ ];
283
+
284
+ for (const profile of profiles) {
285
+ const scopes = resolveScopeProfile(profile);
286
+ expect(scopes.size).toBeGreaterThan(0);
287
+ }
288
+ });
289
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { buildIpcAuthContext } from '../../../daemon/ipc-handler.js';
4
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../../assistant-scope.js';
5
+ import { CURRENT_POLICY_EPOCH } from '../policy.js';
6
+ import { resolveScopeProfile } from '../scopes.js';
7
+
8
+ describe('buildIpcAuthContext', () => {
9
+ test('produces correct subject pattern', () => {
10
+ const ctx = buildIpcAuthContext('session-abc');
11
+ expect(ctx.subject).toBe('ipc:self:session-abc');
12
+ });
13
+
14
+ test('sets principalType to ipc', () => {
15
+ const ctx = buildIpcAuthContext('session-abc');
16
+ expect(ctx.principalType).toBe('ipc');
17
+ });
18
+
19
+ test('uses DAEMON_INTERNAL_ASSISTANT_ID for assistantId', () => {
20
+ const ctx = buildIpcAuthContext('session-abc');
21
+ expect(ctx.assistantId).toBe(DAEMON_INTERNAL_ASSISTANT_ID);
22
+ expect(ctx.assistantId).toBe('self');
23
+ });
24
+
25
+ test('includes sessionId from argument', () => {
26
+ const ctx = buildIpcAuthContext('my-session-123');
27
+ expect(ctx.sessionId).toBe('my-session-123');
28
+ });
29
+
30
+ test('uses ipc_v1 scope profile', () => {
31
+ const ctx = buildIpcAuthContext('session-abc');
32
+ expect(ctx.scopeProfile).toBe('ipc_v1');
33
+ });
34
+
35
+ test('resolves scopes from ipc_v1 profile', () => {
36
+ const ctx = buildIpcAuthContext('session-abc');
37
+ const expectedScopes = resolveScopeProfile('ipc_v1');
38
+ expect(ctx.scopes).toBe(expectedScopes);
39
+ expect(ctx.scopes.has('ipc.all')).toBe(true);
40
+ });
41
+
42
+ test('uses current policy epoch', () => {
43
+ const ctx = buildIpcAuthContext('session-abc');
44
+ expect(ctx.policyEpoch).toBe(CURRENT_POLICY_EPOCH);
45
+ });
46
+
47
+ test('does not set actorPrincipalId', () => {
48
+ const ctx = buildIpcAuthContext('session-abc');
49
+ expect(ctx.actorPrincipalId).toBeUndefined();
50
+ });
51
+
52
+ test('matches AuthContext shape from HTTP JWT-derived contexts', () => {
53
+ const ctx = buildIpcAuthContext('session-xyz');
54
+
55
+ // Verify all required AuthContext fields are present
56
+ expect(typeof ctx.subject).toBe('string');
57
+ expect(typeof ctx.principalType).toBe('string');
58
+ expect(typeof ctx.assistantId).toBe('string');
59
+ expect(typeof ctx.scopeProfile).toBe('string');
60
+ expect(typeof ctx.policyEpoch).toBe('number');
61
+ expect(ctx.scopes).toBeDefined();
62
+ expect(typeof ctx.scopes.has).toBe('function');
63
+ });
64
+
65
+ test('different session IDs produce different subjects', () => {
66
+ const ctx1 = buildIpcAuthContext('session-1');
67
+ const ctx2 = buildIpcAuthContext('session-2');
68
+ expect(ctx1.subject).not.toBe(ctx2.subject);
69
+ expect(ctx1.sessionId).not.toBe(ctx2.sessionId);
70
+ });
71
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Tests for the JWT bearer auth middleware (authenticateRequest).
3
+ *
4
+ * Covers:
5
+ * - Missing Authorization header returns 401
6
+ * - Invalid/expired JWT returns 401
7
+ * - Stale policy epoch returns 401 with refresh_required code
8
+ * - Valid JWT returns AuthContext
9
+ * - Dev bypass returns synthetic AuthContext
10
+ */
11
+
12
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
17
+
18
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'auth-middleware-test-')));
19
+
20
+ mock.module('../../../util/logger.js', () => ({
21
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
22
+ get: () => () => {},
23
+ }),
24
+ }));
25
+
26
+ // Track auth bypass state for tests
27
+ let authDisabled = false;
28
+ mock.module('../../../config/env.js', () => ({
29
+ isHttpAuthDisabled: () => authDisabled,
30
+ hasUngatedHttpAuthDisabled: () => false,
31
+ getGatewayInternalBaseUrl: () => 'http://localhost:7822',
32
+ }));
33
+
34
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../../assistant-scope.js';
35
+ import { authenticateRequest } from '../middleware.js';
36
+ import { initAuthSigningKey, mintToken } from '../token-service.js';
37
+ import type { ScopeProfile,TokenAudience } from '../types.js';
38
+
39
+ const TEST_KEY = Buffer.from('test-signing-key-32-bytes-long!!');
40
+
41
+ function mintValidToken(overrides?: {
42
+ aud?: TokenAudience;
43
+ sub?: string;
44
+ scope_profile?: ScopeProfile;
45
+ policy_epoch?: number;
46
+ exp?: number;
47
+ ttlSeconds?: number;
48
+ }): string {
49
+ // When exp is provided explicitly, compute ttlSeconds from it.
50
+ // Otherwise use a default 300-second TTL.
51
+ let ttl = overrides?.ttlSeconds ?? 300;
52
+ if (overrides?.exp !== undefined) {
53
+ const now = Math.floor(Date.now() / 1000);
54
+ ttl = overrides.exp - now;
55
+ }
56
+ return mintToken({
57
+ aud: overrides?.aud ?? 'vellum-daemon',
58
+ sub: overrides?.sub ?? 'actor:self:principal-test',
59
+ scope_profile: overrides?.scope_profile ?? 'actor_client_v1',
60
+ policy_epoch: overrides?.policy_epoch ?? 1,
61
+ ttlSeconds: ttl,
62
+ });
63
+ }
64
+
65
+ beforeEach(() => {
66
+ initAuthSigningKey(TEST_KEY);
67
+ authDisabled = false;
68
+ });
69
+
70
+ afterAll(() => {
71
+ try { rmSync(testDir, { recursive: true, force: true }); } catch {}
72
+ });
73
+
74
+ describe('authenticateRequest', () => {
75
+ test('returns 401 when Authorization header is missing', () => {
76
+ const req = new Request('http://localhost/v1/messages', {
77
+ method: 'POST',
78
+ });
79
+
80
+ const result = authenticateRequest(req);
81
+ expect(result.ok).toBe(false);
82
+ if (!result.ok) {
83
+ expect(result.response.status).toBe(401);
84
+ }
85
+ });
86
+
87
+ test('returns 401 when Authorization header has wrong scheme', () => {
88
+ const req = new Request('http://localhost/v1/messages', {
89
+ method: 'POST',
90
+ headers: { Authorization: 'Basic dXNlcjpwYXNz' },
91
+ });
92
+
93
+ const result = authenticateRequest(req);
94
+ expect(result.ok).toBe(false);
95
+ if (!result.ok) {
96
+ expect(result.response.status).toBe(401);
97
+ }
98
+ });
99
+
100
+ test('returns 401 when JWT is invalid', () => {
101
+ const req = new Request('http://localhost/v1/messages', {
102
+ method: 'POST',
103
+ headers: { Authorization: 'Bearer invalid.token.here' },
104
+ });
105
+
106
+ const result = authenticateRequest(req);
107
+ expect(result.ok).toBe(false);
108
+ if (!result.ok) {
109
+ expect(result.response.status).toBe(401);
110
+ }
111
+ });
112
+
113
+ test('returns 401 when JWT has expired', () => {
114
+ const now = Math.floor(Date.now() / 1000);
115
+ const token = mintValidToken({ exp: now - 100 });
116
+
117
+ const req = new Request('http://localhost/v1/messages', {
118
+ method: 'POST',
119
+ headers: { Authorization: `Bearer ${token}` },
120
+ });
121
+
122
+ const result = authenticateRequest(req);
123
+ expect(result.ok).toBe(false);
124
+ if (!result.ok) {
125
+ expect(result.response.status).toBe(401);
126
+ }
127
+ });
128
+
129
+ test('returns AuthContext for valid JWT', () => {
130
+ const token = mintValidToken();
131
+
132
+ const req = new Request('http://localhost/v1/messages', {
133
+ method: 'POST',
134
+ headers: { Authorization: `Bearer ${token}` },
135
+ });
136
+
137
+ const result = authenticateRequest(req);
138
+ expect(result.ok).toBe(true);
139
+ if (result.ok) {
140
+ expect(result.context.subject).toBe('actor:self:principal-test');
141
+ expect(result.context.principalType).toBe('actor');
142
+ expect(result.context.assistantId).toBe(DAEMON_INTERNAL_ASSISTANT_ID);
143
+ expect(result.context.actorPrincipalId).toBe('principal-test');
144
+ expect(result.context.scopeProfile).toBe('actor_client_v1');
145
+ expect(result.context.scopes.has('chat.read')).toBe(true);
146
+ expect(result.context.scopes.has('chat.write')).toBe(true);
147
+ }
148
+ });
149
+
150
+ test('returns AuthContext for svc_gateway JWT', () => {
151
+ const token = mintValidToken({
152
+ sub: 'svc:gateway:self',
153
+ scope_profile: 'gateway_ingress_v1',
154
+ });
155
+
156
+ const req = new Request('http://localhost/v1/channels/inbound', {
157
+ method: 'POST',
158
+ headers: { Authorization: `Bearer ${token}` },
159
+ });
160
+
161
+ const result = authenticateRequest(req);
162
+ expect(result.ok).toBe(true);
163
+ if (result.ok) {
164
+ expect(result.context.principalType).toBe('svc_gateway');
165
+ expect(result.context.scopes.has('ingress.write')).toBe(true);
166
+ }
167
+ });
168
+
169
+ test('dev bypass returns synthetic AuthContext without Authorization header', () => {
170
+ authDisabled = true;
171
+
172
+ const req = new Request('http://localhost/v1/messages', {
173
+ method: 'POST',
174
+ });
175
+
176
+ const result = authenticateRequest(req);
177
+ expect(result.ok).toBe(true);
178
+ if (result.ok) {
179
+ expect(result.context.principalType).toBe('actor');
180
+ expect(result.context.actorPrincipalId).toBe('dev-bypass');
181
+ expect(result.context.scopeProfile).toBe('actor_client_v1');
182
+ expect(result.context.scopes.has('chat.read')).toBe(true);
183
+ }
184
+ });
185
+
186
+ test('returns 401 with refresh_required when policy epoch is stale', async () => {
187
+ // Mint a token with a very old policy epoch. The token service checks
188
+ // isStaleEpoch which compares against CURRENT_POLICY_EPOCH.
189
+ const token = mintValidToken({ policy_epoch: 0 });
190
+
191
+ const req = new Request('http://localhost/v1/messages', {
192
+ method: 'POST',
193
+ headers: { Authorization: `Bearer ${token}` },
194
+ });
195
+
196
+ const result = authenticateRequest(req);
197
+ // This test depends on whether CURRENT_POLICY_EPOCH > 0.
198
+ // If CURRENT_POLICY_EPOCH is 1 and the token has epoch 0, it should be stale.
199
+ // If CURRENT_POLICY_EPOCH is 0, then epoch 0 is not stale and the token is valid.
200
+ // We test the behavior regardless -- either it's valid or it reports stale_epoch.
201
+ if (!result.ok) {
202
+ const body = await result.response.json() as { error: { code: string } };
203
+ expect(body.error.code).toBe('refresh_required');
204
+ expect(result.response.status).toBe(401);
205
+ }
206
+ // If the current epoch is 0, the token is valid, which is also correct behavior
207
+ });
208
+
209
+ test('rejects token with wrong audience', () => {
210
+ // Mint a token with audience vellum-gateway instead of vellum-daemon
211
+ const token = mintValidToken({ aud: 'vellum-gateway' });
212
+
213
+ const req = new Request('http://localhost/v1/messages', {
214
+ method: 'POST',
215
+ headers: { Authorization: `Bearer ${token}` },
216
+ });
217
+
218
+ const result = authenticateRequest(req);
219
+ expect(result.ok).toBe(false);
220
+ if (!result.ok) {
221
+ expect(result.response.status).toBe(401);
222
+ }
223
+ });
224
+
225
+ test('rejects token with unparseable sub', () => {
226
+ const token = mintValidToken({ sub: 'garbage' });
227
+
228
+ const req = new Request('http://localhost/v1/messages', {
229
+ method: 'POST',
230
+ headers: { Authorization: `Bearer ${token}` },
231
+ });
232
+
233
+ const result = authenticateRequest(req);
234
+ expect(result.ok).toBe(false);
235
+ if (!result.ok) {
236
+ expect(result.response.status).toBe(401);
237
+ }
238
+ });
239
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { CURRENT_POLICY_EPOCH, isStaleEpoch } from '../policy.js';
4
+
5
+ describe('policy epoch', () => {
6
+ test('CURRENT_POLICY_EPOCH is 1', () => {
7
+ expect(CURRENT_POLICY_EPOCH).toBe(1);
8
+ });
9
+
10
+ test('epoch equal to current is not stale', () => {
11
+ expect(isStaleEpoch(CURRENT_POLICY_EPOCH)).toBe(false);
12
+ });
13
+
14
+ test('epoch greater than current is not stale', () => {
15
+ expect(isStaleEpoch(CURRENT_POLICY_EPOCH + 1)).toBe(false);
16
+ });
17
+
18
+ test('epoch less than current is stale', () => {
19
+ expect(isStaleEpoch(CURRENT_POLICY_EPOCH - 1)).toBe(true);
20
+ });
21
+
22
+ test('epoch 0 is stale', () => {
23
+ expect(isStaleEpoch(0)).toBe(true);
24
+ });
25
+
26
+ test('negative epoch is stale', () => {
27
+ expect(isStaleEpoch(-1)).toBe(true);
28
+ });
29
+ });