@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route policy enforcement for the runtime HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Each protected endpoint declares the scopes and principal types it
|
|
5
|
+
* requires. `enforcePolicy` checks the AuthContext against these
|
|
6
|
+
* requirements and returns an error Response when access is denied.
|
|
7
|
+
*
|
|
8
|
+
* When auth is bypassed in dev mode, policies are still evaluated for
|
|
9
|
+
* type safety but always allow the request through.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isHttpAuthDisabled } from '../../config/env.js';
|
|
13
|
+
import { getLogger } from '../../util/logger.js';
|
|
14
|
+
import type { AuthContext, PrincipalType, Scope } from './types.js';
|
|
15
|
+
|
|
16
|
+
const log = getLogger('route-policy');
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Policy definition
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface RoutePolicy {
|
|
23
|
+
requiredScopes: Scope[];
|
|
24
|
+
allowedPrincipalTypes: PrincipalType[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Policy registry
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const policyRegistry = new Map<string, RoutePolicy>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register a route policy. Called at module load time to populate the
|
|
35
|
+
* registry with all protected endpoint policies.
|
|
36
|
+
*/
|
|
37
|
+
export function registerPolicy(endpoint: string, policy: RoutePolicy): void {
|
|
38
|
+
policyRegistry.set(endpoint, policy);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Look up the policy for an endpoint. Returns undefined for unregistered
|
|
43
|
+
* (unprotected) endpoints.
|
|
44
|
+
*/
|
|
45
|
+
export function getPolicy(endpoint: string): RoutePolicy | undefined {
|
|
46
|
+
return policyRegistry.get(endpoint);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Enforcement
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Enforce the route policy for the given endpoint against the AuthContext.
|
|
55
|
+
*
|
|
56
|
+
* Returns an error Response if the request should be denied, or null if
|
|
57
|
+
* the request is allowed to proceed.
|
|
58
|
+
*
|
|
59
|
+
* When auth is bypassed (dev mode), the policy is still checked against
|
|
60
|
+
* the synthetic context for type safety but always returns null (allowed).
|
|
61
|
+
*/
|
|
62
|
+
export function enforcePolicy(
|
|
63
|
+
endpoint: string,
|
|
64
|
+
authCtx: AuthContext,
|
|
65
|
+
): Response | null {
|
|
66
|
+
const policy = policyRegistry.get(endpoint);
|
|
67
|
+
if (!policy) {
|
|
68
|
+
// No policy registered — unprotected endpoint (e.g. health, debug)
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Dev bypass: log but allow everything through
|
|
73
|
+
if (isHttpAuthDisabled()) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check principal type
|
|
78
|
+
if (!policy.allowedPrincipalTypes.includes(authCtx.principalType)) {
|
|
79
|
+
log.warn(
|
|
80
|
+
{ endpoint, principalType: authCtx.principalType, allowed: policy.allowedPrincipalTypes },
|
|
81
|
+
'Route policy denied: principal type not allowed',
|
|
82
|
+
);
|
|
83
|
+
return Response.json(
|
|
84
|
+
{ error: { code: 'FORBIDDEN', message: 'Principal type not permitted for this endpoint' } },
|
|
85
|
+
{ status: 403 },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check required scopes
|
|
90
|
+
for (const scope of policy.requiredScopes) {
|
|
91
|
+
if (!authCtx.scopes.has(scope)) {
|
|
92
|
+
log.warn(
|
|
93
|
+
{ endpoint, missingScope: scope, principalType: authCtx.principalType },
|
|
94
|
+
'Route policy denied: missing required scope',
|
|
95
|
+
);
|
|
96
|
+
return Response.json(
|
|
97
|
+
{ error: { code: 'FORBIDDEN', message: `Missing required scope: ${scope}` } },
|
|
98
|
+
{ status: 403 },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Policy registrations for all protected routes
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
// Standard actor endpoints — chat, approvals, settings, etc.
|
|
111
|
+
const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
|
|
112
|
+
// Conversation / messaging
|
|
113
|
+
{ endpoint: 'messages:GET', scopes: ['chat.read'] },
|
|
114
|
+
{ endpoint: 'messages:POST', scopes: ['chat.write'] },
|
|
115
|
+
{ endpoint: 'conversations', scopes: ['chat.read'] },
|
|
116
|
+
{ endpoint: 'conversations/attention', scopes: ['chat.read'] },
|
|
117
|
+
{ endpoint: 'conversations/seen', scopes: ['chat.write'] },
|
|
118
|
+
{ endpoint: 'search', scopes: ['chat.read'] },
|
|
119
|
+
{ endpoint: 'suggestion', scopes: ['chat.read'] },
|
|
120
|
+
|
|
121
|
+
// Approvals
|
|
122
|
+
{ endpoint: 'confirm', scopes: ['approval.write'] },
|
|
123
|
+
{ endpoint: 'secret', scopes: ['approval.write'] },
|
|
124
|
+
{ endpoint: 'trust-rules', scopes: ['approval.write'] },
|
|
125
|
+
{ endpoint: 'pending-interactions', scopes: ['approval.read'] },
|
|
126
|
+
|
|
127
|
+
// Guardian actions
|
|
128
|
+
{ endpoint: 'guardian-actions/pending', scopes: ['approval.read'] },
|
|
129
|
+
{ endpoint: 'guardian-actions/decision', scopes: ['approval.write'] },
|
|
130
|
+
|
|
131
|
+
// Events (SSE)
|
|
132
|
+
{ endpoint: 'events', scopes: ['chat.read'] },
|
|
133
|
+
|
|
134
|
+
// Attachments
|
|
135
|
+
{ endpoint: 'attachments:POST', scopes: ['attachments.write'] },
|
|
136
|
+
{ endpoint: 'attachments:DELETE', scopes: ['attachments.write'] },
|
|
137
|
+
{ endpoint: 'attachments:GET', scopes: ['attachments.read'] },
|
|
138
|
+
{ endpoint: 'attachments/content:GET', scopes: ['attachments.read'] },
|
|
139
|
+
|
|
140
|
+
// Calls
|
|
141
|
+
{ endpoint: 'calls/start', scopes: ['calls.write'] },
|
|
142
|
+
{ endpoint: 'calls:GET', scopes: ['calls.read'] },
|
|
143
|
+
{ endpoint: 'calls/cancel', scopes: ['calls.write'] },
|
|
144
|
+
{ endpoint: 'calls/answer', scopes: ['calls.write'] },
|
|
145
|
+
{ endpoint: 'calls/instruction', scopes: ['calls.write'] },
|
|
146
|
+
|
|
147
|
+
// Settings / integrations / identity
|
|
148
|
+
{ endpoint: 'identity', scopes: ['settings.read'] },
|
|
149
|
+
{ endpoint: 'brain-graph', scopes: ['settings.read'] },
|
|
150
|
+
{ endpoint: 'brain-graph-ui', scopes: ['settings.read'] },
|
|
151
|
+
{ endpoint: 'home-base-ui', scopes: ['settings.read'] },
|
|
152
|
+
{ endpoint: 'contacts', scopes: ['settings.read'] },
|
|
153
|
+
{ endpoint: 'contacts/merge', scopes: ['settings.write'] },
|
|
154
|
+
{ endpoint: 'contacts:GET', scopes: ['settings.read'] },
|
|
155
|
+
{ endpoint: 'ingress/members', scopes: ['settings.read'] },
|
|
156
|
+
{ endpoint: 'ingress/members:POST', scopes: ['settings.write'] },
|
|
157
|
+
{ endpoint: 'ingress/members/block', scopes: ['settings.write'] },
|
|
158
|
+
{ endpoint: 'ingress/members:DELETE', scopes: ['settings.write'] },
|
|
159
|
+
{ endpoint: 'ingress/invites', scopes: ['settings.read'] },
|
|
160
|
+
{ endpoint: 'ingress/invites:POST', scopes: ['settings.write'] },
|
|
161
|
+
{ endpoint: 'ingress/invites/redeem', scopes: ['settings.write'] },
|
|
162
|
+
{ endpoint: 'ingress/invites:DELETE', scopes: ['settings.write'] },
|
|
163
|
+
{ endpoint: 'integrations/telegram/config', scopes: ['settings.read'] },
|
|
164
|
+
{ endpoint: 'integrations/telegram/config:POST', scopes: ['settings.write'] },
|
|
165
|
+
{ endpoint: 'integrations/telegram/config:DELETE', scopes: ['settings.write'] },
|
|
166
|
+
{ endpoint: 'integrations/telegram/commands', scopes: ['settings.write'] },
|
|
167
|
+
{ endpoint: 'integrations/telegram/setup', scopes: ['settings.write'] },
|
|
168
|
+
{ endpoint: 'integrations/slack/channel/config', scopes: ['settings.read'] },
|
|
169
|
+
{ endpoint: 'integrations/slack/channel/config:POST', scopes: ['settings.write'] },
|
|
170
|
+
{ endpoint: 'integrations/slack/channel/config:DELETE', scopes: ['settings.write'] },
|
|
171
|
+
{ endpoint: 'integrations/guardian/challenge', scopes: ['settings.write'] },
|
|
172
|
+
{ endpoint: 'integrations/guardian/status', scopes: ['settings.read'] },
|
|
173
|
+
{ endpoint: 'integrations/guardian/outbound/start', scopes: ['settings.write'] },
|
|
174
|
+
{ endpoint: 'integrations/guardian/outbound/resend', scopes: ['settings.write'] },
|
|
175
|
+
{ endpoint: 'integrations/guardian/outbound/cancel', scopes: ['settings.write'] },
|
|
176
|
+
{ endpoint: 'integrations/twilio/config', scopes: ['settings.read'] },
|
|
177
|
+
{ endpoint: 'integrations/twilio/credentials:POST', scopes: ['settings.write'] },
|
|
178
|
+
{ endpoint: 'integrations/twilio/credentials:DELETE', scopes: ['settings.write'] },
|
|
179
|
+
{ endpoint: 'integrations/twilio/numbers', scopes: ['settings.read'] },
|
|
180
|
+
{ endpoint: 'integrations/twilio/numbers/provision', scopes: ['settings.write'] },
|
|
181
|
+
{ endpoint: 'integrations/twilio/numbers/assign', scopes: ['settings.write'] },
|
|
182
|
+
{ endpoint: 'integrations/twilio/numbers/release', scopes: ['settings.write'] },
|
|
183
|
+
{ endpoint: 'integrations/twilio/sms/compliance', scopes: ['settings.read'] },
|
|
184
|
+
{ endpoint: 'integrations/twilio/sms/compliance/tollfree', scopes: ['settings.write'] },
|
|
185
|
+
{ endpoint: 'integrations/twilio/sms/compliance/tollfree:PATCH', scopes: ['settings.write'] },
|
|
186
|
+
{ endpoint: 'integrations/twilio/sms/compliance/tollfree:DELETE', scopes: ['settings.write'] },
|
|
187
|
+
{ endpoint: 'integrations/twilio/sms/test', scopes: ['settings.write'] },
|
|
188
|
+
{ endpoint: 'integrations/twilio/sms/doctor', scopes: ['settings.write'] },
|
|
189
|
+
|
|
190
|
+
// Channel readiness
|
|
191
|
+
{ endpoint: 'channels/readiness', scopes: ['settings.read'] },
|
|
192
|
+
{ endpoint: 'channels/readiness/refresh', scopes: ['settings.write'] },
|
|
193
|
+
|
|
194
|
+
// Dead letters
|
|
195
|
+
{ endpoint: 'channels/dead-letters', scopes: ['settings.read'] },
|
|
196
|
+
{ endpoint: 'channels/replay', scopes: ['settings.write'] },
|
|
197
|
+
|
|
198
|
+
// Secrets
|
|
199
|
+
{ endpoint: 'secrets', scopes: ['settings.write'] },
|
|
200
|
+
|
|
201
|
+
// Pairing (authenticated)
|
|
202
|
+
{ endpoint: 'pairing/register', scopes: ['settings.write'] },
|
|
203
|
+
|
|
204
|
+
// Apps
|
|
205
|
+
{ endpoint: 'apps/share', scopes: ['settings.write'] },
|
|
206
|
+
{ endpoint: 'apps/shared:GET', scopes: ['settings.read'] },
|
|
207
|
+
{ endpoint: 'apps/shared:DELETE', scopes: ['settings.write'] },
|
|
208
|
+
{ endpoint: 'apps/shared/metadata', scopes: ['settings.read'] },
|
|
209
|
+
|
|
210
|
+
// Debug
|
|
211
|
+
{ endpoint: 'debug', scopes: ['settings.read'] },
|
|
212
|
+
|
|
213
|
+
// Browser relay
|
|
214
|
+
{ endpoint: 'browser-relay/status', scopes: ['settings.read'] },
|
|
215
|
+
{ endpoint: 'browser-relay/command', scopes: ['settings.write'] },
|
|
216
|
+
|
|
217
|
+
// Interfaces
|
|
218
|
+
{ endpoint: 'interfaces', scopes: ['settings.read'] },
|
|
219
|
+
|
|
220
|
+
// Trust rule CRUD management
|
|
221
|
+
{ endpoint: 'trust-rules/manage:GET', scopes: ['settings.read'] },
|
|
222
|
+
{ endpoint: 'trust-rules/manage:POST', scopes: ['settings.write'] },
|
|
223
|
+
{ endpoint: 'trust-rules/manage:DELETE', scopes: ['settings.write'] },
|
|
224
|
+
{ endpoint: 'trust-rules/manage:PATCH', scopes: ['settings.write'] },
|
|
225
|
+
|
|
226
|
+
// Surface actions
|
|
227
|
+
{ endpoint: 'surface-actions', scopes: ['chat.write'] },
|
|
228
|
+
|
|
229
|
+
// Conversation deletion (channel-facing)
|
|
230
|
+
{ endpoint: 'channels/conversation:DELETE', scopes: ['chat.write'] },
|
|
231
|
+
|
|
232
|
+
// Delivery ack
|
|
233
|
+
{ endpoint: 'channels/delivery-ack', scopes: ['internal.write'] },
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const { endpoint, scopes } of ACTOR_ENDPOINTS) {
|
|
237
|
+
registerPolicy(endpoint, {
|
|
238
|
+
requiredScopes: scopes,
|
|
239
|
+
allowedPrincipalTypes: ['actor', 'svc_gateway', 'ipc'],
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Channel inbound: gateway-only
|
|
244
|
+
registerPolicy('channels/inbound', {
|
|
245
|
+
requiredScopes: ['ingress.write'],
|
|
246
|
+
allowedPrincipalTypes: ['svc_gateway'],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Internal forwarding endpoints: gateway-only
|
|
250
|
+
const INTERNAL_ENDPOINTS = [
|
|
251
|
+
'internal/twilio/voice-webhook',
|
|
252
|
+
'internal/twilio/status',
|
|
253
|
+
'internal/twilio/connect-action',
|
|
254
|
+
'internal/oauth/callback',
|
|
255
|
+
];
|
|
256
|
+
for (const endpoint of INTERNAL_ENDPOINTS) {
|
|
257
|
+
registerPolicy(endpoint, {
|
|
258
|
+
requiredScopes: ['internal.write'],
|
|
259
|
+
allowedPrincipalTypes: ['svc_gateway'],
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope profile resolver and scope-check utilities.
|
|
3
|
+
*
|
|
4
|
+
* Each scope profile maps to a fixed set of permission scopes. The
|
|
5
|
+
* mapping is intentionally hard-coded — profile definitions are a
|
|
6
|
+
* policy decision, not a runtime configuration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AuthContext, Scope, ScopeProfile } from './types.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Profile -> scope mapping
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const PROFILE_SCOPES: Record<ScopeProfile, ReadonlySet<Scope>> = {
|
|
16
|
+
actor_client_v1: new Set<Scope>([
|
|
17
|
+
'chat.read',
|
|
18
|
+
'chat.write',
|
|
19
|
+
'approval.read',
|
|
20
|
+
'approval.write',
|
|
21
|
+
'settings.read',
|
|
22
|
+
'settings.write',
|
|
23
|
+
'attachments.read',
|
|
24
|
+
'attachments.write',
|
|
25
|
+
'calls.read',
|
|
26
|
+
'calls.write',
|
|
27
|
+
'feature_flags.read',
|
|
28
|
+
'feature_flags.write',
|
|
29
|
+
]),
|
|
30
|
+
gateway_ingress_v1: new Set<Scope>([
|
|
31
|
+
'ingress.write',
|
|
32
|
+
'internal.write',
|
|
33
|
+
]),
|
|
34
|
+
gateway_service_v1: new Set<Scope>([
|
|
35
|
+
'chat.write',
|
|
36
|
+
'settings.read',
|
|
37
|
+
'settings.write',
|
|
38
|
+
'attachments.read',
|
|
39
|
+
'attachments.write',
|
|
40
|
+
'internal.write',
|
|
41
|
+
]),
|
|
42
|
+
ipc_v1: new Set<Scope>([
|
|
43
|
+
'ipc.all',
|
|
44
|
+
]),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Public API
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** Resolve a scope profile name to its set of granted scopes. */
|
|
52
|
+
export function resolveScopeProfile(profile: ScopeProfile): ReadonlySet<Scope> {
|
|
53
|
+
return PROFILE_SCOPES[profile];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check whether the auth context includes a specific scope. */
|
|
57
|
+
export function hasScope(ctx: AuthContext, scope: Scope): boolean {
|
|
58
|
+
return ctx.scopes.has(scope);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Check whether the auth context includes all of the given scopes. */
|
|
62
|
+
export function hasAllScopes(ctx: AuthContext, ...scopes: Scope[]): boolean {
|
|
63
|
+
return scopes.every((s) => ctx.scopes.has(s));
|
|
64
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub (subject) pattern parser for JWT tokens.
|
|
3
|
+
*
|
|
4
|
+
* The sub claim encodes principal type, assistant scope, and optional
|
|
5
|
+
* actor/session identifiers in a colon-delimited string.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PrincipalType } from './types.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Result types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export type ParseSubResult =
|
|
15
|
+
| {
|
|
16
|
+
ok: true;
|
|
17
|
+
principalType: PrincipalType;
|
|
18
|
+
assistantId: string;
|
|
19
|
+
actorPrincipalId?: string;
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
}
|
|
22
|
+
| { ok: false; reason: string };
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Parser
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a JWT sub claim into its constituent parts.
|
|
30
|
+
*
|
|
31
|
+
* Supported patterns:
|
|
32
|
+
* actor:<assistantId>:<actorPrincipalId>
|
|
33
|
+
* svc:gateway:<assistantId>
|
|
34
|
+
* ipc:<assistantId>:<sessionId>
|
|
35
|
+
*/
|
|
36
|
+
export function parseSub(sub: string): ParseSubResult {
|
|
37
|
+
if (!sub || typeof sub !== 'string') {
|
|
38
|
+
return { ok: false, reason: 'sub is empty or not a string' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parts = sub.split(':');
|
|
42
|
+
|
|
43
|
+
if (parts[0] === 'actor' && parts.length === 3) {
|
|
44
|
+
const [, assistantId, actorPrincipalId] = parts;
|
|
45
|
+
if (!assistantId || !actorPrincipalId) {
|
|
46
|
+
return { ok: false, reason: 'actor sub has empty assistantId or actorPrincipalId' };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, principalType: 'actor', assistantId, actorPrincipalId };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (parts[0] === 'svc' && parts[1] === 'gateway' && parts.length === 3) {
|
|
52
|
+
const assistantId = parts[2];
|
|
53
|
+
if (!assistantId) {
|
|
54
|
+
return { ok: false, reason: 'svc:gateway sub has empty assistantId' };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, principalType: 'svc_gateway', assistantId };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (parts[0] === 'ipc' && parts.length === 3) {
|
|
60
|
+
const [, assistantId, sessionId] = parts;
|
|
61
|
+
if (!assistantId || !sessionId) {
|
|
62
|
+
return { ok: false, reason: 'ipc sub has empty assistantId or sessionId' };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, principalType: 'ipc', assistantId, sessionId };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { ok: false, reason: `unrecognized sub pattern: ${sub}` };
|
|
68
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT token service for the single-header auth system.
|
|
3
|
+
*
|
|
4
|
+
* Mints and verifies standard JWTs (header.payload.signature) using
|
|
5
|
+
* HMAC-SHA256. Owns the signing key lifecycle (load/create/persist).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { getLogger } from '../../util/logger.js';
|
|
13
|
+
import { getRootDir } from '../../util/platform.js';
|
|
14
|
+
import { CURRENT_POLICY_EPOCH, isStaleEpoch } from './policy.js';
|
|
15
|
+
import type { ScopeProfile, TokenAudience, TokenClaims } from './types.js';
|
|
16
|
+
|
|
17
|
+
const log = getLogger('token-service');
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Signing key management
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
let _authSigningKey: Buffer | undefined;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Path to the persisted signing key file.
|
|
27
|
+
* Stored in the protected directory alongside other sensitive material.
|
|
28
|
+
*/
|
|
29
|
+
function getSigningKeyPath(): string {
|
|
30
|
+
return join(getRootDir(), 'protected', 'actor-token-signing-key');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load a signing key from disk or generate and persist a new one.
|
|
35
|
+
* Uses atomic-write + chmod 0o600 for safe persistence.
|
|
36
|
+
*/
|
|
37
|
+
export function loadOrCreateSigningKey(): Buffer {
|
|
38
|
+
const keyPath = getSigningKeyPath();
|
|
39
|
+
|
|
40
|
+
// Try to load existing key
|
|
41
|
+
if (existsSync(keyPath)) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(keyPath);
|
|
44
|
+
if (raw.length === 32) {
|
|
45
|
+
log.info('Auth signing key loaded from disk');
|
|
46
|
+
return raw;
|
|
47
|
+
}
|
|
48
|
+
log.warn('Signing key file has unexpected length, regenerating');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
log.warn({ err }, 'Failed to read signing key file, regenerating');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Generate and persist a new key
|
|
55
|
+
const newKey = randomBytes(32);
|
|
56
|
+
const dir = dirname(keyPath);
|
|
57
|
+
if (!existsSync(dir)) {
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
const tmpPath = keyPath + '.tmp.' + process.pid;
|
|
61
|
+
writeFileSync(tmpPath, newKey, { mode: 0o600 });
|
|
62
|
+
renameSync(tmpPath, keyPath);
|
|
63
|
+
chmodSync(keyPath, 0o600);
|
|
64
|
+
|
|
65
|
+
log.info('Auth signing key generated and persisted');
|
|
66
|
+
return newKey;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getSigningKey(): Buffer {
|
|
70
|
+
if (!_authSigningKey) {
|
|
71
|
+
throw new Error('Auth signing key not initialized — call initAuthSigningKey() during startup');
|
|
72
|
+
}
|
|
73
|
+
return _authSigningKey;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Initialize the auth signing key. Called at daemon startup with a key
|
|
78
|
+
* loaded from disk via loadOrCreateSigningKey(), or by tests with a
|
|
79
|
+
* deterministic key.
|
|
80
|
+
*/
|
|
81
|
+
export function initAuthSigningKey(key: Buffer): void {
|
|
82
|
+
_authSigningKey = key;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check whether the auth signing key has been initialized.
|
|
87
|
+
*
|
|
88
|
+
* Useful for out-of-process contexts (CLI) that may run without
|
|
89
|
+
* daemon startup, where callers need to decide whether they can
|
|
90
|
+
* mint JWTs or must fall back to the legacy shared-secret token.
|
|
91
|
+
*/
|
|
92
|
+
export function isSigningKeyInitialized(): boolean {
|
|
93
|
+
return _authSigningKey !== undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Base64url helpers
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function base64urlEncode(data: Buffer | string): string {
|
|
101
|
+
const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
|
|
102
|
+
return buf.toString('base64url');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function base64urlDecode(str: string): Buffer {
|
|
106
|
+
return Buffer.from(str, 'base64url');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Result types
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export type VerifyResult =
|
|
114
|
+
| { ok: true; claims: TokenClaims }
|
|
115
|
+
| { ok: false; reason: string };
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// JWT header — static for HMAC-SHA256
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const JWT_HEADER = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Mint
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Mint a new JWT token with the given parameters.
|
|
129
|
+
*
|
|
130
|
+
* Returns the complete JWT string (header.payload.signature).
|
|
131
|
+
*/
|
|
132
|
+
export function mintToken(params: {
|
|
133
|
+
aud: TokenAudience;
|
|
134
|
+
sub: string;
|
|
135
|
+
scope_profile: ScopeProfile;
|
|
136
|
+
policy_epoch: number;
|
|
137
|
+
ttlSeconds: number;
|
|
138
|
+
}): string {
|
|
139
|
+
const now = Math.floor(Date.now() / 1000);
|
|
140
|
+
const claims: TokenClaims = {
|
|
141
|
+
iss: 'vellum-auth',
|
|
142
|
+
aud: params.aud,
|
|
143
|
+
sub: params.sub,
|
|
144
|
+
scope_profile: params.scope_profile,
|
|
145
|
+
exp: now + params.ttlSeconds,
|
|
146
|
+
policy_epoch: params.policy_epoch,
|
|
147
|
+
iat: now,
|
|
148
|
+
jti: randomBytes(16).toString('hex'),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const payload = base64urlEncode(JSON.stringify(claims));
|
|
152
|
+
const sigInput = JWT_HEADER + '.' + payload;
|
|
153
|
+
const sig = createHmac('sha256', getSigningKey())
|
|
154
|
+
.update(sigInput)
|
|
155
|
+
.digest();
|
|
156
|
+
|
|
157
|
+
return sigInput + '.' + base64urlEncode(sig);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Verify
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Verify a JWT token's structural integrity, signature, expiration,
|
|
166
|
+
* audience, and policy epoch.
|
|
167
|
+
*
|
|
168
|
+
* Does NOT check revocation — callers must additionally verify the
|
|
169
|
+
* token hash against a revocation store if needed.
|
|
170
|
+
*/
|
|
171
|
+
export function verifyToken(token: string, expectedAud: TokenAudience): VerifyResult {
|
|
172
|
+
const parts = token.split('.');
|
|
173
|
+
if (parts.length !== 3) {
|
|
174
|
+
return { ok: false, reason: 'malformed_token: expected 3 dot-separated parts' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const [headerPart, payloadPart, sigPart] = parts;
|
|
178
|
+
|
|
179
|
+
// Recompute HMAC over header.payload
|
|
180
|
+
const sigInput = headerPart + '.' + payloadPart;
|
|
181
|
+
const expectedSig = createHmac('sha256', getSigningKey())
|
|
182
|
+
.update(sigInput)
|
|
183
|
+
.digest();
|
|
184
|
+
const actualSig = base64urlDecode(sigPart);
|
|
185
|
+
|
|
186
|
+
if (expectedSig.length !== actualSig.length) {
|
|
187
|
+
return { ok: false, reason: 'invalid_signature' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!timingSafeEqual(expectedSig, actualSig)) {
|
|
191
|
+
return { ok: false, reason: 'invalid_signature' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Decode and parse claims
|
|
195
|
+
let claims: TokenClaims;
|
|
196
|
+
try {
|
|
197
|
+
const decoded = base64urlDecode(payloadPart).toString('utf-8');
|
|
198
|
+
claims = JSON.parse(decoded) as TokenClaims;
|
|
199
|
+
} catch {
|
|
200
|
+
return { ok: false, reason: 'malformed_claims' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Audience check
|
|
204
|
+
if (claims.aud !== expectedAud) {
|
|
205
|
+
return { ok: false, reason: `audience_mismatch: expected ${expectedAud}, got ${claims.aud}` };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Expiration check (claims.exp is in seconds)
|
|
209
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
210
|
+
if (claims.exp <= nowSeconds) {
|
|
211
|
+
return { ok: false, reason: 'token_expired' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Policy epoch check
|
|
215
|
+
if (isStaleEpoch(claims.policy_epoch)) {
|
|
216
|
+
return { ok: false, reason: 'stale_policy_epoch' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { ok: true, claims };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Daemon delivery token
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Mint a short-lived JWT for daemon-to-gateway delivery callbacks.
|
|
228
|
+
*
|
|
229
|
+
* Used when the daemon needs to call gateway /deliver/* endpoints. The
|
|
230
|
+
* gateway's deliver-auth middleware validates aud=vellum-daemon, so both
|
|
231
|
+
* sides share the same signing key and audience convention.
|
|
232
|
+
*
|
|
233
|
+
* sub=svc:daemon:self, scope_profile=gateway_service_v1
|
|
234
|
+
*/
|
|
235
|
+
export function mintDaemonDeliveryToken(): string {
|
|
236
|
+
return mintToken({
|
|
237
|
+
aud: 'vellum-daemon',
|
|
238
|
+
sub: 'svc:daemon:self',
|
|
239
|
+
scope_profile: 'gateway_service_v1',
|
|
240
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
241
|
+
ttlSeconds: 60,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Edge relay token
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Mint a short-lived JWT for relay WebSocket connections through the gateway.
|
|
251
|
+
*
|
|
252
|
+
* The gateway's relay WS handler validates tokens with validateEdgeToken(),
|
|
253
|
+
* which expects aud=vellum-gateway. This is distinct from daemon delivery
|
|
254
|
+
* tokens (aud=vellum-daemon) used for gateway /deliver/* endpoints.
|
|
255
|
+
*
|
|
256
|
+
* sub=svc:daemon:self, scope_profile=gateway_service_v1
|
|
257
|
+
*/
|
|
258
|
+
export function mintEdgeRelayToken(): string {
|
|
259
|
+
return mintToken({
|
|
260
|
+
aud: 'vellum-gateway',
|
|
261
|
+
sub: 'svc:daemon:self',
|
|
262
|
+
scope_profile: 'gateway_service_v1',
|
|
263
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
264
|
+
ttlSeconds: 60,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Hash
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/** SHA-256 hex digest of a raw token string (for revocation store lookups). */
|
|
273
|
+
export function hashToken(token: string): string {
|
|
274
|
+
return createHash('sha256').update(token).digest('hex');
|
|
275
|
+
}
|