@vellumai/assistant 0.3.19 → 0.3.21
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 +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/bun.lock +139 -2
- package/docs/architecture/integrations.md +7 -11
- package/package.json +2 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- package/src/cli/core-commands.ts +0 -4
- package/src/cli/mcp.ts +58 -0
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +1 -1
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/mcp-schema.ts +46 -0
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +18 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/providers-setup.ts +26 -1
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +258 -432
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/shutdown-handlers.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +2 -2
- package/src/mcp/client.ts +152 -0
- package/src/mcp/manager.ts +139 -0
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +92 -35
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -190
- package/src/runtime/routes/identity-routes.ts +73 -0
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +1 -1
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/mcp/mcp-tool-factory.ts +100 -0
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/registry.ts +64 -1
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +10 -2
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -26,11 +26,16 @@ export type GuardianActionMessageScenario =
|
|
|
26
26
|
| 'guardian_followup_failed'
|
|
27
27
|
| 'guardian_followup_declined_ack'
|
|
28
28
|
| 'guardian_followup_clarification'
|
|
29
|
+
| 'guardian_pending_disambiguation'
|
|
29
30
|
| 'guardian_expired_disambiguation'
|
|
30
31
|
| 'guardian_followup_disambiguation'
|
|
31
32
|
| 'guardian_stale_answered'
|
|
32
33
|
| 'guardian_stale_expired'
|
|
33
34
|
| 'guardian_stale_followup'
|
|
35
|
+
| 'guardian_stale_superseded'
|
|
36
|
+
| 'guardian_superseded_remap'
|
|
37
|
+
| 'guardian_unknown_code'
|
|
38
|
+
| 'guardian_auto_matched'
|
|
34
39
|
| 'outbound_message_copy'
|
|
35
40
|
| 'followup_message_sent'
|
|
36
41
|
| 'followup_call_started'
|
|
@@ -48,6 +53,10 @@ export interface GuardianActionMessageContext {
|
|
|
48
53
|
failureReason?: string;
|
|
49
54
|
counterpartyPhone?: string;
|
|
50
55
|
requestCodes?: string[];
|
|
56
|
+
/** The code the guardian provided that was not recognized. */
|
|
57
|
+
unknownCode?: string;
|
|
58
|
+
/** The code of the active request that supersedes the one the guardian targeted. */
|
|
59
|
+
activeRequestCode?: string;
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
export interface ComposeGuardianActionMessageOptions {
|
|
@@ -181,6 +190,11 @@ export function getGuardianActionFallbackMessage(context: GuardianActionMessageC
|
|
|
181
190
|
case 'guardian_followup_clarification':
|
|
182
191
|
return "Sorry, I didn't quite catch that. Would you like to call them back, send them a message, or skip it for now?";
|
|
183
192
|
|
|
193
|
+
case 'guardian_pending_disambiguation':
|
|
194
|
+
return listedCodes
|
|
195
|
+
? `You have multiple pending guardian questions. Please prefix your reply with the reference code (${listedCodes}) so I know which question you're answering.`
|
|
196
|
+
: 'You have multiple pending guardian questions. Please prefix your reply with the reference code so I know which question you\'re answering.';
|
|
197
|
+
|
|
184
198
|
case 'guardian_expired_disambiguation':
|
|
185
199
|
return listedCodes
|
|
186
200
|
? `You have multiple expired guardian questions. Please prefix your reply with the reference code (${listedCodes}) so I know which question you're answering.`
|
|
@@ -200,6 +214,22 @@ export function getGuardianActionFallbackMessage(context: GuardianActionMessageC
|
|
|
200
214
|
case 'guardian_stale_followup':
|
|
201
215
|
return 'It looks like this follow-up has already been handled. No further action is needed.';
|
|
202
216
|
|
|
217
|
+
case 'guardian_stale_superseded':
|
|
218
|
+
return 'This request is no longer active. The call has ended and no further action is needed.';
|
|
219
|
+
|
|
220
|
+
case 'guardian_unknown_code':
|
|
221
|
+
return context.unknownCode
|
|
222
|
+
? `I don't recognize the code "${context.unknownCode}". Please check the reference code and try again.`
|
|
223
|
+
: "I don't recognize that reference code. Please check the code and try again.";
|
|
224
|
+
|
|
225
|
+
case 'guardian_auto_matched':
|
|
226
|
+
return 'Got it, routing your answer to the active request.';
|
|
227
|
+
|
|
228
|
+
case 'guardian_superseded_remap':
|
|
229
|
+
return context.questionText
|
|
230
|
+
? `Got it! Your answer has been applied to the current active request: "${context.questionText}"`
|
|
231
|
+
: 'Got it! Your answer has been applied to the current active request on the call.';
|
|
232
|
+
|
|
203
233
|
case 'outbound_message_copy':
|
|
204
234
|
// This SMS is sent TO the original caller relaying the guardian's answer.
|
|
205
235
|
// When lateAnswerText is available, include it — that's the whole point of message_back.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the guardian decision primitive.
|
|
3
|
+
*
|
|
4
|
+
* All decision entrypoints (callback buttons, conversational engine, legacy
|
|
5
|
+
* parser, requester self-cancel) use these types to route through the
|
|
6
|
+
* unified `applyGuardianDecision` primitive.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Guardian decision prompt
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Structured model for prompts shown to guardians. */
|
|
14
|
+
export interface GuardianDecisionPrompt {
|
|
15
|
+
requestId: string;
|
|
16
|
+
/** Short human-readable code for the request. */
|
|
17
|
+
requestCode: string;
|
|
18
|
+
state: 'pending' | 'followup_awaiting_choice' | 'expired_superseded_with_active_call';
|
|
19
|
+
questionText: string;
|
|
20
|
+
toolName: string | null;
|
|
21
|
+
actions: GuardianDecisionAction[];
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
conversationId: string;
|
|
24
|
+
callSessionId: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GuardianDecisionAction {
|
|
28
|
+
/** Canonical action identifier. */
|
|
29
|
+
action: string;
|
|
30
|
+
/** Human-readable label for the action. */
|
|
31
|
+
label: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Shared decision action constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** Canonical set of all guardian decision actions with their labels. */
|
|
39
|
+
export const GUARDIAN_DECISION_ACTIONS = {
|
|
40
|
+
approve_once: { action: 'approve_once', label: 'Approve once' },
|
|
41
|
+
approve_always: { action: 'approve_always', label: 'Approve always' },
|
|
42
|
+
reject: { action: 'reject', label: 'Reject' },
|
|
43
|
+
} as const satisfies Record<string, GuardianDecisionAction>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the set of `GuardianDecisionAction` items appropriate for a prompt,
|
|
47
|
+
* respecting whether persistent decisions (approve_always) are allowed.
|
|
48
|
+
*
|
|
49
|
+
* When `persistentDecisionsAllowed` is `false`, the `approve_always` action
|
|
50
|
+
* is excluded. When `forGuardianOnBehalf` is `true` (guardian acting on behalf
|
|
51
|
+
* of a requester), `approve_always` is also excluded since guardians cannot
|
|
52
|
+
* permanently allowlist tools on behalf of others.
|
|
53
|
+
*/
|
|
54
|
+
export function buildDecisionActions(opts?: {
|
|
55
|
+
persistentDecisionsAllowed?: boolean;
|
|
56
|
+
forGuardianOnBehalf?: boolean;
|
|
57
|
+
}): GuardianDecisionAction[] {
|
|
58
|
+
const showAlways = opts?.persistentDecisionsAllowed !== false && !opts?.forGuardianOnBehalf;
|
|
59
|
+
return [
|
|
60
|
+
GUARDIAN_DECISION_ACTIONS.approve_once,
|
|
61
|
+
...(showAlways ? [GUARDIAN_DECISION_ACTIONS.approve_always] : []),
|
|
62
|
+
GUARDIAN_DECISION_ACTIONS.reject,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the plain-text fallback instruction string that matches the given
|
|
68
|
+
* set of decision actions. Ensures the text always includes parser-compatible
|
|
69
|
+
* keywords (yes/always/no) so text-based fallback remains actionable.
|
|
70
|
+
*/
|
|
71
|
+
export function buildPlainTextFallback(
|
|
72
|
+
promptText: string,
|
|
73
|
+
actions: GuardianDecisionAction[],
|
|
74
|
+
): string {
|
|
75
|
+
const hasAlways = actions.some(a => a.action === 'approve_always');
|
|
76
|
+
return hasAlways
|
|
77
|
+
? `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`
|
|
78
|
+
: `${promptText}\n\nReply "yes" to approve or "no" to reject.`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Apply decision result
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export interface ApplyGuardianDecisionResult {
|
|
86
|
+
applied: boolean;
|
|
87
|
+
reason?: 'stale' | 'identity_mismatch' | 'invalid_action' | 'not_found' | 'expired';
|
|
88
|
+
requestId?: string;
|
|
89
|
+
/** Feedback text when the action was parsed from user text. */
|
|
90
|
+
userText?: string;
|
|
91
|
+
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
-
import {
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
10
11
|
|
|
11
12
|
import type { ServerWebSocket } from 'bun';
|
|
12
13
|
|
|
@@ -117,6 +118,10 @@ import {
|
|
|
117
118
|
} from './routes/conversation-routes.js';
|
|
118
119
|
import { handleDebug } from './routes/debug-routes.js';
|
|
119
120
|
import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
|
|
121
|
+
import {
|
|
122
|
+
handleGuardianActionDecision,
|
|
123
|
+
handleGuardianActionsPending,
|
|
124
|
+
} from './routes/guardian-action-routes.js';
|
|
120
125
|
import { handleGetIdentity,handleHealth } from './routes/identity-routes.js';
|
|
121
126
|
import {
|
|
122
127
|
handleBlockMember,
|
|
@@ -237,11 +242,24 @@ export class RuntimeHttpServer {
|
|
|
237
242
|
this.pairingBroadcast = fn;
|
|
238
243
|
}
|
|
239
244
|
|
|
245
|
+
/** Read the feature-flag client token from disk so it can be included in pairing approval responses. */
|
|
246
|
+
private readFeatureFlagToken(): string | undefined {
|
|
247
|
+
try {
|
|
248
|
+
const baseDir = process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
249
|
+
const tokenPath = join(baseDir, '.vellum', 'feature-flag-token');
|
|
250
|
+
const token = readFileSync(tokenPath, 'utf-8').trim();
|
|
251
|
+
return token || undefined;
|
|
252
|
+
} catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
240
257
|
private get pairingContext(): PairingHandlerContext {
|
|
241
258
|
const ipcBroadcast = this.pairingBroadcast;
|
|
242
259
|
return {
|
|
243
260
|
pairingStore: this.pairingStore,
|
|
244
261
|
bearerToken: this.bearerToken,
|
|
262
|
+
featureFlagToken: this.readFeatureFlagToken(),
|
|
245
263
|
pairingBroadcast: ipcBroadcast
|
|
246
264
|
? (msg) => {
|
|
247
265
|
// Broadcast to IPC socket clients (local Unix socket)
|
|
@@ -690,6 +708,10 @@ export class RuntimeHttpServer {
|
|
|
690
708
|
if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req);
|
|
691
709
|
if (endpoint === 'pending-interactions' && req.method === 'GET') return handleListPendingInteractions(url);
|
|
692
710
|
|
|
711
|
+
// Guardian action endpoints — deterministic button-based decisions
|
|
712
|
+
if (endpoint === 'guardian-actions/pending' && req.method === 'GET') return handleGuardianActionsPending(req);
|
|
713
|
+
if (endpoint === 'guardian-actions/decision' && req.method === 'POST') return await handleGuardianActionDecision(req);
|
|
714
|
+
|
|
693
715
|
// Contacts
|
|
694
716
|
if (endpoint === 'contacts' && req.method === 'GET') return handleListContacts(url);
|
|
695
717
|
if (endpoint === 'contacts/merge' && req.method === 'POST') return await handleMergeContacts(req);
|
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
revokeMember,
|
|
23
23
|
upsertMember,
|
|
24
24
|
} from '../memory/ingress-member-store.js';
|
|
25
|
+
import {
|
|
26
|
+
type InviteRedemptionOutcome,
|
|
27
|
+
redeemInvite as redeemInviteTyped,
|
|
28
|
+
} from './invite-redemption-service.js';
|
|
25
29
|
|
|
26
30
|
// ---------------------------------------------------------------------------
|
|
27
31
|
// Response shapes — used by both HTTP routes and IPC handlers
|
|
@@ -163,6 +167,24 @@ export function redeemIngressInvite(params: {
|
|
|
163
167
|
return { ok: true, data: inviteToResponse(result.invite) };
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Typed invite redemption — preferred entry point for new callers
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export { type InviteRedemptionOutcome } from './invite-redemption-service.js';
|
|
175
|
+
|
|
176
|
+
export function redeemIngressInviteTyped(params: {
|
|
177
|
+
rawToken: string;
|
|
178
|
+
sourceChannel: string;
|
|
179
|
+
externalUserId?: string;
|
|
180
|
+
externalChatId?: string;
|
|
181
|
+
displayName?: string;
|
|
182
|
+
username?: string;
|
|
183
|
+
assistantId?: string;
|
|
184
|
+
}): InviteRedemptionOutcome {
|
|
185
|
+
return redeemInviteTyped(params);
|
|
186
|
+
}
|
|
187
|
+
|
|
166
188
|
// ---------------------------------------------------------------------------
|
|
167
189
|
// Member operations
|
|
168
190
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed invite redemption engine.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the low-level invite store primitives with channel-scoped enforcement
|
|
5
|
+
* and a discriminated-union outcome type so callers can handle every case
|
|
6
|
+
* deterministically. The raw token is accepted as input but is never logged,
|
|
7
|
+
* persisted, or returned in the outcome.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getSqlite } from '../memory/db.js';
|
|
11
|
+
import { findByTokenHash, hashToken, markInviteExpired, recordInviteUse,redeemInvite as storeRedeemInvite } from '../memory/ingress-invite-store.js';
|
|
12
|
+
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Outcome type
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export type InviteRedemptionOutcome =
|
|
19
|
+
| { ok: true; type: 'redeemed'; memberId: string; inviteId: string }
|
|
20
|
+
| { ok: true; type: 'already_member'; memberId: string }
|
|
21
|
+
| { ok: false; reason: 'invalid_token' | 'expired' | 'revoked' | 'max_uses_reached' | 'channel_mismatch' | 'missing_identity' };
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Error-string to typed-reason mapping
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const STORE_ERROR_TO_REASON: Record<string, InviteRedemptionOutcome & { ok: false } | undefined> = {
|
|
28
|
+
invite_not_found: { ok: false, reason: 'invalid_token' },
|
|
29
|
+
invite_expired: { ok: false, reason: 'expired' },
|
|
30
|
+
invite_revoked: { ok: false, reason: 'revoked' },
|
|
31
|
+
invite_redeemed: { ok: false, reason: 'max_uses_reached' },
|
|
32
|
+
invite_max_uses_reached: { ok: false, reason: 'max_uses_reached' },
|
|
33
|
+
invite_channel_mismatch: { ok: false, reason: 'channel_mismatch' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// redeemInvite
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export function redeemInvite(params: {
|
|
41
|
+
rawToken: string;
|
|
42
|
+
sourceChannel: string;
|
|
43
|
+
externalUserId?: string;
|
|
44
|
+
externalChatId?: string;
|
|
45
|
+
displayName?: string;
|
|
46
|
+
username?: string;
|
|
47
|
+
assistantId?: string;
|
|
48
|
+
}): InviteRedemptionOutcome {
|
|
49
|
+
const { rawToken, sourceChannel, externalUserId, externalChatId, displayName, username, assistantId } = params;
|
|
50
|
+
|
|
51
|
+
if (!externalUserId && !externalChatId) {
|
|
52
|
+
return { ok: false, reason: 'missing_identity' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validate the invite token before any membership checks to prevent
|
|
56
|
+
// membership-status probing with arbitrary tokens.
|
|
57
|
+
const tokenHash = hashToken(rawToken);
|
|
58
|
+
const invite = findByTokenHash(tokenHash);
|
|
59
|
+
|
|
60
|
+
if (!invite) {
|
|
61
|
+
return { ok: false, reason: 'invalid_token' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (invite.status !== 'active') {
|
|
65
|
+
const mapped = STORE_ERROR_TO_REASON[`invite_${invite.status}`];
|
|
66
|
+
if (mapped) return mapped;
|
|
67
|
+
return { ok: false, reason: 'invalid_token' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (invite.expiresAt <= Date.now()) {
|
|
71
|
+
markInviteExpired(invite.id);
|
|
72
|
+
return { ok: false, reason: 'expired' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (invite.useCount >= invite.maxUses) {
|
|
76
|
+
return { ok: false, reason: 'max_uses_reached' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Enforce channel match: the token must belong to the channel the caller
|
|
80
|
+
// is redeeming from.
|
|
81
|
+
if (sourceChannel !== invite.sourceChannel) {
|
|
82
|
+
return { ok: false, reason: 'channel_mismatch' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Token is valid — now safe to check existing membership without leaking
|
|
86
|
+
// membership status to callers with bogus tokens.
|
|
87
|
+
const existingMember = findMember({
|
|
88
|
+
assistantId: assistantId ?? invite.assistantId,
|
|
89
|
+
sourceChannel,
|
|
90
|
+
externalUserId,
|
|
91
|
+
externalChatId,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (existingMember && existingMember.status === 'active') {
|
|
95
|
+
return { ok: true, type: 'already_member', memberId: existingMember.id };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Blocked members cannot bypass the guardian's explicit block via invite
|
|
99
|
+
// links. Return the same generic failure as an invalid token to avoid
|
|
100
|
+
// leaking membership status to the caller.
|
|
101
|
+
if (existingMember && existingMember.status === 'blocked') {
|
|
102
|
+
return { ok: false, reason: 'invalid_token' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Inactive member reactivation: when the user already has a member record
|
|
106
|
+
// in a non-active state (revoked/pending), reactivate it via upsertMember
|
|
107
|
+
// and consume an invite use atomically. Falling through to storeRedeemInvite
|
|
108
|
+
// would try to INSERT a new member row, hitting the unique-key constraint
|
|
109
|
+
// on the members table.
|
|
110
|
+
if (existingMember) {
|
|
111
|
+
// Sentinel error used to trigger a transaction rollback when the invite
|
|
112
|
+
// was concurrently revoked/expired between pre-validation and write time.
|
|
113
|
+
const STALE_INVITE = Symbol('stale_invite');
|
|
114
|
+
|
|
115
|
+
let reactivated: ReturnType<typeof upsertMember> | undefined;
|
|
116
|
+
try {
|
|
117
|
+
getSqlite().transaction(() => {
|
|
118
|
+
reactivated = upsertMember({
|
|
119
|
+
assistantId: assistantId ?? invite.assistantId,
|
|
120
|
+
sourceChannel,
|
|
121
|
+
externalUserId,
|
|
122
|
+
externalChatId,
|
|
123
|
+
displayName,
|
|
124
|
+
username,
|
|
125
|
+
status: 'active',
|
|
126
|
+
policy: 'allow',
|
|
127
|
+
inviteId: invite.id,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const recorded = recordInviteUse({
|
|
131
|
+
inviteId: invite.id,
|
|
132
|
+
externalUserId,
|
|
133
|
+
externalChatId,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// If the invite was revoked/expired between pre-validation and this
|
|
137
|
+
// write, recordInviteUse returns false — throw to roll back the
|
|
138
|
+
// member reactivation so the DB stays consistent.
|
|
139
|
+
if (!recorded) throw STALE_INVITE;
|
|
140
|
+
}).immediate();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err === STALE_INVITE) {
|
|
143
|
+
return { ok: false, reason: 'invalid_token' };
|
|
144
|
+
}
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
type: 'redeemed',
|
|
151
|
+
memberId: reactivated!.id,
|
|
152
|
+
inviteId: invite.id,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Delegate to the store-level redeem which handles token lookup, expiry,
|
|
157
|
+
// use-count, and transactional member creation. Channel enforcement is
|
|
158
|
+
// applied by passing sourceChannel so the store checks it.
|
|
159
|
+
const result = storeRedeemInvite({
|
|
160
|
+
rawToken,
|
|
161
|
+
sourceChannel,
|
|
162
|
+
externalUserId,
|
|
163
|
+
externalChatId,
|
|
164
|
+
displayName,
|
|
165
|
+
username,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if ('error' in result) {
|
|
169
|
+
const mapped = STORE_ERROR_TO_REASON[result.error];
|
|
170
|
+
if (mapped) return mapped;
|
|
171
|
+
// Fallback for any unrecognized store error
|
|
172
|
+
return { ok: false, reason: 'invalid_token' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
type: 'redeemed',
|
|
178
|
+
memberId: result.member.id,
|
|
179
|
+
inviteId: result.invite.id,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic reply templates for invite token redemption outcomes.
|
|
3
|
+
*
|
|
4
|
+
* These messages are returned directly to the user without passing through
|
|
5
|
+
* the LLM pipeline, ensuring consistent and predictable responses for
|
|
6
|
+
* every invite redemption outcome.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { InviteRedemptionOutcome } from './invite-redemption-service.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Template strings
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const INVITE_REPLY_TEMPLATES = {
|
|
16
|
+
redeemed: "Welcome! You've been granted access via invite link.",
|
|
17
|
+
already_member: 'You already have access.',
|
|
18
|
+
invalid_token: 'This invite link is no longer valid.',
|
|
19
|
+
expired: 'This invite link is no longer valid.',
|
|
20
|
+
revoked: 'This invite link is no longer valid.',
|
|
21
|
+
max_uses_reached: 'This invite link is no longer valid.',
|
|
22
|
+
channel_mismatch: 'This invite link is not valid for this channel.',
|
|
23
|
+
missing_identity: 'Unable to process this invite. Please contact the person who shared it.',
|
|
24
|
+
generic_failure: 'Unable to process this invite. Please contact the person who shared it.',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Outcome-to-reply resolver
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Map an `InviteRedemptionOutcome` to a deterministic reply string.
|
|
33
|
+
*/
|
|
34
|
+
export function getInviteRedemptionReply(outcome: InviteRedemptionOutcome): string {
|
|
35
|
+
if (outcome.ok) {
|
|
36
|
+
return INVITE_REPLY_TEMPLATES[outcome.type];
|
|
37
|
+
}
|
|
38
|
+
return INVITE_REPLY_TEMPLATES[outcome.reason] ?? INVITE_REPLY_TEMPLATES.generic_failure;
|
|
39
|
+
}
|
|
@@ -179,7 +179,7 @@ export async function handleCancelCall(req: Request, callSessionId: string): Pro
|
|
|
179
179
|
* Body: { answer: string }
|
|
180
180
|
*/
|
|
181
181
|
export async function handleAnswerCall(req: Request, callSessionId: string): Promise<Response> {
|
|
182
|
-
let body: { answer?: string };
|
|
182
|
+
let body: { answer?: string; pendingQuestionId?: string };
|
|
183
183
|
try {
|
|
184
184
|
body = await req.json() as typeof body;
|
|
185
185
|
} catch {
|
|
@@ -193,6 +193,7 @@ export async function handleAnswerCall(req: Request, callSessionId: string): Pro
|
|
|
193
193
|
const result = await answerCall({
|
|
194
194
|
callSessionId,
|
|
195
195
|
answer: body.answer ?? '',
|
|
196
|
+
pendingQuestionId: typeof body.pendingQuestionId === 'string' ? body.pendingQuestionId : undefined,
|
|
196
197
|
});
|
|
197
198
|
|
|
198
199
|
if (!result.ok) {
|