@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.
- package/ARCHITECTURE.md +77 -38
- package/README.md +10 -12
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +108 -522
- package/src/__tests__/channel-approval-routes.test.ts +92 -239
- package/src/__tests__/channel-approval.test.ts +100 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
- package/src/__tests__/conversation-routes.test.ts +11 -4
- package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
- package/src/__tests__/mcp-health-check.test.ts +65 -0
- package/src/__tests__/permission-types.test.ts +33 -0
- package/src/__tests__/scan-result-store.test.ts +121 -0
- package/src/__tests__/session-agent-loop.test.ts +120 -0
- package/src/__tests__/session-approval-overrides.test.ts +205 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
- package/src/amazon/client.ts +8 -5
- package/src/approvals/guardian-decision-primitive.ts +14 -9
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/call-controller.ts +2 -2
- package/src/calls/twilio-routes.ts +2 -2
- package/src/cli/mcp.ts +3 -3
- package/src/cli.ts +24 -0
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +49 -14
- package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
- package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
- package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
- package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
- package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
- package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
- package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/approval-generators.ts +6 -3
- package/src/daemon/handlers/config-ingress.ts +2 -6
- package/src/daemon/handlers/guardian-actions.ts +1 -1
- package/src/daemon/handlers/sessions.ts +4 -1
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +32 -0
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-handler.ts +24 -0
- package/src/daemon/ipc-validate.ts +1 -1
- package/src/daemon/lifecycle.ts +6 -8
- package/src/daemon/server.ts +8 -3
- package/src/daemon/session-agent-loop.ts +19 -1
- package/src/daemon/session-attachments.ts +2 -1
- package/src/daemon/session-history.ts +2 -2
- package/src/daemon/session-process.ts +5 -9
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session-tool-setup.ts +216 -69
- package/src/daemon/session.ts +24 -1
- package/src/events/domain-events.ts +1 -1
- package/src/events/tool-domain-event-publisher.ts +5 -10
- package/src/influencer/client.ts +8 -7
- package/src/messaging/providers/gmail/client.ts +33 -1
- package/src/messaging/providers/gmail/mime-builder.ts +5 -1
- package/src/messaging/providers/sms/adapter.ts +3 -7
- package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
- package/src/messaging/providers/whatsapp/adapter.ts +3 -7
- package/src/notifications/adapters/sms.ts +2 -2
- package/src/notifications/adapters/telegram.ts +2 -2
- package/src/permissions/prompter.ts +2 -0
- package/src/permissions/types.ts +11 -1
- package/src/runtime/approval-conversation-turn.ts +4 -0
- package/src/runtime/auth/__tests__/context.test.ts +130 -0
- package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
- package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
- package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
- package/src/runtime/auth/__tests__/policy.test.ts +29 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
- package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
- package/src/runtime/auth/__tests__/subject.test.ts +149 -0
- package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
- package/src/runtime/auth/context.ts +62 -0
- package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
- package/src/runtime/auth/external-assistant-id.ts +69 -0
- package/src/runtime/auth/index.ts +37 -0
- package/src/runtime/auth/middleware.ts +127 -0
- package/src/runtime/auth/policy.ts +17 -0
- package/src/runtime/auth/route-policy.ts +261 -0
- package/src/runtime/auth/scopes.ts +64 -0
- package/src/runtime/auth/subject.ts +68 -0
- package/src/runtime/auth/token-service.ts +275 -0
- package/src/runtime/auth/types.ts +79 -0
- package/src/runtime/channel-approval-parser.ts +11 -5
- package/src/runtime/channel-approval-types.ts +1 -1
- package/src/runtime/channel-approvals.ts +22 -1
- package/src/runtime/guardian-action-followup-executor.ts +2 -2
- package/src/runtime/guardian-context-resolver.ts +15 -0
- package/src/runtime/guardian-decision-types.ts +23 -6
- package/src/runtime/guardian-outbound-actions.ts +4 -22
- package/src/runtime/guardian-reply-router.ts +5 -3
- package/src/runtime/http-server.ts +210 -182
- package/src/runtime/http-types.ts +11 -1
- package/src/runtime/local-actor-identity.ts +25 -0
- package/src/runtime/pending-interactions.ts +1 -0
- package/src/runtime/routes/approval-routes.ts +42 -59
- package/src/runtime/routes/channel-route-shared.ts +9 -41
- package/src/runtime/routes/channel-routes.ts +0 -2
- package/src/runtime/routes/conversation-routes.ts +39 -49
- package/src/runtime/routes/events-routes.ts +15 -22
- package/src/runtime/routes/guardian-action-routes.ts +46 -51
- package/src/runtime/routes/guardian-approval-interception.ts +6 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
- package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +39 -45
- package/src/runtime/routes/pairing-routes.ts +9 -9
- package/src/runtime/routes/secret-routes.ts +90 -45
- package/src/runtime/routes/surface-action-routes.ts +12 -2
- package/src/runtime/routes/trust-rules-routes.ts +13 -0
- package/src/runtime/routes/twilio-routes.ts +3 -3
- package/src/runtime/session-approval-overrides.ts +86 -0
- package/src/security/keychain-to-encrypted-migration.ts +8 -1
- package/src/skills/frontmatter.ts +44 -1
- package/src/tools/permission-checker.ts +226 -74
- package/src/runtime/actor-token-service.ts +0 -234
- 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
|
+
});
|