@vellumai/assistant 0.4.6 → 0.4.8
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 +23 -6
- package/bun.lock +51 -0
- package/docs/trusted-contact-access.md +8 -0
- package/package.json +2 -1
- package/src/__tests__/actor-token-service.test.ts +4 -4
- package/src/__tests__/call-controller.test.ts +37 -0
- package/src/__tests__/channel-delivery-store.test.ts +2 -2
- package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
- package/src/__tests__/guardian-dispatch.test.ts +39 -1
- package/src/__tests__/guardian-routing-state.test.ts +8 -30
- package/src/__tests__/non-member-access-request.test.ts +7 -0
- package/src/__tests__/notification-decision-fallback.test.ts +232 -0
- package/src/__tests__/notification-decision-strategy.test.ts +304 -8
- package/src/__tests__/notification-guardian-path.test.ts +38 -1
- package/src/__tests__/relay-server.test.ts +65 -5
- package/src/__tests__/send-endpoint-busy.test.ts +29 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +1 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -2
- package/src/__tests__/trusted-contact-multichannel.test.ts +1 -1
- package/src/calls/call-controller.ts +15 -0
- package/src/calls/relay-server.ts +45 -11
- package/src/calls/types.ts +1 -0
- package/src/daemon/providers-setup.ts +0 -8
- package/src/daemon/session-slash.ts +35 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +1 -1
- package/src/memory/schema.ts +19 -0
- package/src/notifications/README.md +8 -1
- package/src/notifications/copy-composer.ts +160 -30
- package/src/notifications/decision-engine.ts +98 -1
- package/src/runtime/actor-refresh-token-service.ts +309 -0
- package/src/runtime/actor-refresh-token-store.ts +157 -0
- package/src/runtime/actor-token-service.ts +3 -3
- package/src/runtime/gateway-client.ts +239 -0
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +10 -24
- package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
- package/src/runtime/routes/pairing-routes.ts +60 -50
- package/src/types/qrcode.d.ts +10 -0
package/src/memory/schema.ts
CHANGED
|
@@ -1162,6 +1162,25 @@ export const actorTokenRecords = sqliteTable('actor_token_records', {
|
|
|
1162
1162
|
updatedAt: integer('updated_at').notNull(),
|
|
1163
1163
|
});
|
|
1164
1164
|
|
|
1165
|
+
// ── Actor Refresh Token Records ──────────────────────────────────────
|
|
1166
|
+
|
|
1167
|
+
export const actorRefreshTokenRecords = sqliteTable('actor_refresh_token_records', {
|
|
1168
|
+
id: text('id').primaryKey(),
|
|
1169
|
+
tokenHash: text('token_hash').notNull(),
|
|
1170
|
+
familyId: text('family_id').notNull(),
|
|
1171
|
+
assistantId: text('assistant_id').notNull(),
|
|
1172
|
+
guardianPrincipalId: text('guardian_principal_id').notNull(),
|
|
1173
|
+
hashedDeviceId: text('hashed_device_id').notNull(),
|
|
1174
|
+
platform: text('platform').notNull(),
|
|
1175
|
+
status: text('status').notNull().default('active'),
|
|
1176
|
+
issuedAt: integer('issued_at').notNull(),
|
|
1177
|
+
absoluteExpiresAt: integer('absolute_expires_at').notNull(),
|
|
1178
|
+
inactivityExpiresAt: integer('inactivity_expires_at').notNull(),
|
|
1179
|
+
lastUsedAt: integer('last_used_at'),
|
|
1180
|
+
createdAt: integer('created_at').notNull(),
|
|
1181
|
+
updatedAt: integer('updated_at').notNull(),
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1165
1184
|
// ── Scoped Approval Grants ──────────────────────────────────────────
|
|
1166
1185
|
|
|
1167
1186
|
export const scopedApprovalGrants = sqliteTable('scoped_approval_grants', {
|
|
@@ -51,7 +51,14 @@ When the LLM is unavailable or returns invalid output, a deterministic fallback
|
|
|
51
51
|
|
|
52
52
|
### 4. Deterministic Checks
|
|
53
53
|
|
|
54
|
-
Hard invariants that the LLM cannot override
|
|
54
|
+
Hard invariants that the LLM cannot override:
|
|
55
|
+
|
|
56
|
+
**Post-generation enforcement** (`decision-engine.ts`):
|
|
57
|
+
|
|
58
|
+
- **Guardian question request-code enforcement** — `enforceGuardianRequestCode()` ensures request-code instructions (approve/reject or free-text answer) appear in all `guardian.question` notification copy, even when the LLM omits them.
|
|
59
|
+
- **Access-request instruction enforcement** — `enforceAccessRequestInstructions()` validates that `ingress.access_request` copy contains: (1) the request-code approve/reject directive, (2) the exact "open invite flow" phrase. If any required element is missing, the full deterministic contract text is appended. This prevents model-generated copy from dropping security-critical action directives.
|
|
60
|
+
|
|
61
|
+
**Pre-send gate checks** (`deterministic-checks.ts`):
|
|
55
62
|
|
|
56
63
|
- **Schema validity** -- fail-closed if the decision is malformed
|
|
57
64
|
- **Source-active suppression** -- if the user is already viewing the source context, suppress
|
|
@@ -29,6 +29,162 @@ export function nonEmpty(value: string | undefined): string | undefined {
|
|
|
29
29
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// ── Access-request copy contract ─────────────────────────────────────────────
|
|
33
|
+
//
|
|
34
|
+
// Deterministic helpers for building guardian-facing access-request copy.
|
|
35
|
+
// These are used both by the fallback template and the decision-engine
|
|
36
|
+
// post-generation enforcement to ensure required directives always appear.
|
|
37
|
+
|
|
38
|
+
const IDENTITY_FIELD_MAX_LENGTH = 120;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sanitize an untrusted identity field for inclusion in notification copy.
|
|
42
|
+
*
|
|
43
|
+
* - Strips control characters (U+0000–U+001F, U+007F–U+009F) and newlines.
|
|
44
|
+
* - Clamps to IDENTITY_FIELD_MAX_LENGTH characters.
|
|
45
|
+
* - Wraps in quotes to neutralize instruction-like payload text.
|
|
46
|
+
*/
|
|
47
|
+
export function sanitizeIdentityField(value: string): string {
|
|
48
|
+
const stripped = value.replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, ' ').trim();
|
|
49
|
+
const clamped = stripped.length > IDENTITY_FIELD_MAX_LENGTH
|
|
50
|
+
? stripped.slice(0, IDENTITY_FIELD_MAX_LENGTH) + '…'
|
|
51
|
+
: stripped;
|
|
52
|
+
return clamped;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildAccessRequestIdentityLine(payload: Record<string, unknown>): string {
|
|
56
|
+
const requester = sanitizeIdentityField(str(payload.senderIdentifier, 'Someone'));
|
|
57
|
+
const sourceChannel = typeof payload.sourceChannel === 'string' ? payload.sourceChannel : undefined;
|
|
58
|
+
const callerName = nonEmpty(typeof payload.actorDisplayName === 'string' ? payload.actorDisplayName : undefined);
|
|
59
|
+
const actorUsername = nonEmpty(typeof payload.actorUsername === 'string' ? payload.actorUsername : undefined);
|
|
60
|
+
const actorExternalId = nonEmpty(typeof payload.actorExternalId === 'string' ? payload.actorExternalId : undefined);
|
|
61
|
+
|
|
62
|
+
if (sourceChannel === 'voice' && callerName) {
|
|
63
|
+
const safeName = sanitizeIdentityField(callerName);
|
|
64
|
+
const safeId = sanitizeIdentityField(str(payload.actorExternalId, requester));
|
|
65
|
+
return `${safeName} (${safeId}) is calling and requesting access to the assistant.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// For non-voice, include extra context when available.
|
|
69
|
+
// Sanitize before comparing to avoid deduplication failures when identity
|
|
70
|
+
// fields contain control characters that are stripped from `requester`.
|
|
71
|
+
const sanitizedUsername = actorUsername ? sanitizeIdentityField(actorUsername) : undefined;
|
|
72
|
+
const sanitizedExternalId = actorExternalId ? sanitizeIdentityField(actorExternalId) : undefined;
|
|
73
|
+
const parts = [requester];
|
|
74
|
+
if (sanitizedUsername && sanitizedUsername !== requester) {
|
|
75
|
+
parts.push(`@${sanitizedUsername}`);
|
|
76
|
+
}
|
|
77
|
+
if (sanitizedExternalId && sanitizedExternalId !== requester && sanitizedExternalId !== sanitizedUsername) {
|
|
78
|
+
parts.push(`[${sanitizedExternalId}]`);
|
|
79
|
+
}
|
|
80
|
+
if (sourceChannel) {
|
|
81
|
+
parts.push(`via ${sourceChannel}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return `${parts.join(' ')} is requesting access to the assistant.`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildAccessRequestDecisionDirective(requestCode: string): string {
|
|
88
|
+
const code = requestCode.toUpperCase();
|
|
89
|
+
return `Reply "${code} approve" to grant access or "${code} reject" to deny.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildAccessRequestInviteDirective(): string {
|
|
93
|
+
return 'Reply "open invite flow" to start Trusted Contacts invite flow.';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildAccessRequestRevokedNote(): string {
|
|
97
|
+
return 'Note: this user was previously revoked.';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalize text before running directive-matching regexes.
|
|
102
|
+
*
|
|
103
|
+
* - Replaces smart/curly apostrophes (\u2018, \u2019, \u201B) with ASCII `'`
|
|
104
|
+
* so contractions like "Don\u2019t" are matched by the `n't` lookbehind.
|
|
105
|
+
* - Collapses runs of whitespace into a single space so "Do not reply"
|
|
106
|
+
* is matched by the single-space negative lookbehind.
|
|
107
|
+
* - Trims leading/trailing whitespace.
|
|
108
|
+
*/
|
|
109
|
+
export function normalizeForDirectiveMatching(text: string): string {
|
|
110
|
+
return text
|
|
111
|
+
.replace(/[\u2018\u2019\u201B]/g, "'")
|
|
112
|
+
.replace(/\s+/g, ' ')
|
|
113
|
+
.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check whether a text contains the required access-request instruction elements:
|
|
118
|
+
* 1. Approve directive: Reply "CODE approve"
|
|
119
|
+
* 2. Reject directive: Reply "CODE reject"
|
|
120
|
+
* 3. Invite directive: Reply "open invite flow"
|
|
121
|
+
*
|
|
122
|
+
* Each directive is matched independently using negative lookbehind to reject
|
|
123
|
+
* matches preceded by negation words ("not", "n't", "never"). This prevents
|
|
124
|
+
* contradictory copy like `Do not reply "CODE reject"` from satisfying the
|
|
125
|
+
* check even when a positive approve directive exists nearby.
|
|
126
|
+
*
|
|
127
|
+
* The text is normalized before matching to handle smart apostrophes and
|
|
128
|
+
* multiple whitespace characters that would otherwise bypass negation detection.
|
|
129
|
+
*/
|
|
130
|
+
export function hasAccessRequestInstructions(
|
|
131
|
+
text: string | undefined,
|
|
132
|
+
requestCode: string,
|
|
133
|
+
): boolean {
|
|
134
|
+
if (typeof text !== 'string') return false;
|
|
135
|
+
const normalized = normalizeForDirectiveMatching(text);
|
|
136
|
+
const escapedCode = requestCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
137
|
+
// Each directive must follow "reply" without a preceding negation word.
|
|
138
|
+
// Negative lookbehinds reject "do not reply", "don't reply", "never reply".
|
|
139
|
+
const approveRe = new RegExp(
|
|
140
|
+
`(?<!not\\s)(?<!n't\\s)(?<!never\\s)reply\\b[^.!?\\n]*?"${escapedCode}\\s+approve"`,
|
|
141
|
+
'i',
|
|
142
|
+
);
|
|
143
|
+
const rejectRe = new RegExp(
|
|
144
|
+
`(?<!not\\s)(?<!n't\\s)(?<!never\\s)reply\\b[^.!?\\n]*?"${escapedCode}\\s+reject"`,
|
|
145
|
+
'i',
|
|
146
|
+
);
|
|
147
|
+
const inviteRe = /(?<!not\s)(?<!n't\s)(?<!never\s)reply\b[^.!?\n]*?"open invite flow"/i;
|
|
148
|
+
|
|
149
|
+
return approveRe.test(normalized) && rejectRe.test(normalized) && inviteRe.test(normalized);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check whether text contains the invite-flow directive ("open invite flow")
|
|
154
|
+
* using the same normalized negative-lookbehind pattern as the full check.
|
|
155
|
+
* This is used for enforcement when requestCode is absent but the invite
|
|
156
|
+
* directive should still be present.
|
|
157
|
+
*/
|
|
158
|
+
export function hasInviteFlowDirective(text: string | undefined): boolean {
|
|
159
|
+
if (typeof text !== 'string') return false;
|
|
160
|
+
const normalized = normalizeForDirectiveMatching(text);
|
|
161
|
+
const inviteRe = /(?<!not\s)(?<!n't\s)(?<!never\s)reply\b[^.!?\n]*?"open invite flow"/i;
|
|
162
|
+
return inviteRe.test(normalized);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build the deterministic access-request contract text from payload fields.
|
|
167
|
+
* This is the canonical baseline that enforcement can append when generated
|
|
168
|
+
* copy is missing required elements.
|
|
169
|
+
*/
|
|
170
|
+
export function buildAccessRequestContractText(payload: Record<string, unknown>): string {
|
|
171
|
+
const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
|
|
172
|
+
const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
|
|
173
|
+
? payload.previousMemberStatus
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
const lines: string[] = [];
|
|
177
|
+
lines.push(buildAccessRequestIdentityLine(payload));
|
|
178
|
+
if (previousMemberStatus === 'revoked') {
|
|
179
|
+
lines.push(buildAccessRequestRevokedNote());
|
|
180
|
+
}
|
|
181
|
+
if (requestCode) {
|
|
182
|
+
lines.push(buildAccessRequestDecisionDirective(requestCode));
|
|
183
|
+
}
|
|
184
|
+
lines.push(buildAccessRequestInviteDirective());
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
32
188
|
// Templates keyed by dot-separated sourceEventName strings matching producers.
|
|
33
189
|
const TEMPLATES: Record<string, CopyTemplate> = {
|
|
34
190
|
'reminder.fired': (payload) => ({
|
|
@@ -60,36 +216,10 @@ const TEMPLATES: Record<string, CopyTemplate> = {
|
|
|
60
216
|
};
|
|
61
217
|
},
|
|
62
218
|
|
|
63
|
-
'ingress.access_request': (payload) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const callerName = nonEmpty(typeof payload.actorDisplayName === 'string' ? payload.actorDisplayName : undefined);
|
|
68
|
-
const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
|
|
69
|
-
? payload.previousMemberStatus
|
|
70
|
-
: undefined;
|
|
71
|
-
const lines: string[] = [];
|
|
72
|
-
|
|
73
|
-
// Voice-originated access requests include caller name context
|
|
74
|
-
if (sourceChannel === 'voice' && callerName) {
|
|
75
|
-
lines.push(`${callerName} (${str(payload.actorExternalId, requester)}) is calling and requesting access to the assistant.`);
|
|
76
|
-
} else {
|
|
77
|
-
lines.push(`${requester} is requesting access to the assistant.`);
|
|
78
|
-
}
|
|
79
|
-
if (previousMemberStatus === 'revoked') {
|
|
80
|
-
lines.push('Note: this user was previously revoked.');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (requestCode) {
|
|
84
|
-
const code = requestCode.toUpperCase();
|
|
85
|
-
lines.push(`Reply "${code} approve" to grant access or "${code} reject" to deny.`);
|
|
86
|
-
}
|
|
87
|
-
lines.push('Reply "open invite flow" to start Trusted Contacts invite flow.');
|
|
88
|
-
return {
|
|
89
|
-
title: 'Access Request',
|
|
90
|
-
body: lines.join('\n'),
|
|
91
|
-
};
|
|
92
|
-
},
|
|
219
|
+
'ingress.access_request': (payload) => ({
|
|
220
|
+
title: 'Access Request',
|
|
221
|
+
body: buildAccessRequestContractText(payload),
|
|
222
|
+
}),
|
|
93
223
|
|
|
94
224
|
'ingress.access_request.callback_handoff': (payload) => {
|
|
95
225
|
const callerName = nonEmpty(typeof payload.callerName === 'string' ? payload.callerName : undefined);
|
|
@@ -16,7 +16,13 @@ import { getConfig } from '../config/loader.js';
|
|
|
16
16
|
import { createTimeout, extractToolUse, getConfiguredProvider, userMessage } from '../providers/provider-send-message.js';
|
|
17
17
|
import type { ModelIntent } from '../providers/types.js';
|
|
18
18
|
import { getLogger } from '../util/logger.js';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
buildAccessRequestContractText,
|
|
21
|
+
buildAccessRequestInviteDirective,
|
|
22
|
+
composeFallbackCopy,
|
|
23
|
+
hasAccessRequestInstructions,
|
|
24
|
+
hasInviteFlowDirective,
|
|
25
|
+
} from './copy-composer.js';
|
|
20
26
|
import { createDecision } from './decisions-store.js';
|
|
21
27
|
import {
|
|
22
28
|
buildGuardianRequestCodeInstruction,
|
|
@@ -474,6 +480,95 @@ function enforceGuardianRequestCode(
|
|
|
474
480
|
};
|
|
475
481
|
}
|
|
476
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Access-request notifications require deterministic instruction elements:
|
|
485
|
+
* - Request-code approve/reject directive (when requestCode is present)
|
|
486
|
+
* - Exact "open invite flow" phrase (always required)
|
|
487
|
+
*
|
|
488
|
+
* When requestCode IS present: use the full hasAccessRequestInstructions
|
|
489
|
+
* check (approve+reject+invite) and append the complete contract text if
|
|
490
|
+
* any element is missing.
|
|
491
|
+
*
|
|
492
|
+
* When requestCode is NOT present: still check for the invite-flow
|
|
493
|
+
* directive and append it if missing. Per the documented contract, the
|
|
494
|
+
* invite directive should always be present in access-request copy.
|
|
495
|
+
*/
|
|
496
|
+
function enforceAccessRequestInstructions(
|
|
497
|
+
decision: NotificationDecision,
|
|
498
|
+
signal: NotificationSignal,
|
|
499
|
+
): NotificationDecision {
|
|
500
|
+
if (signal.sourceEventName !== 'ingress.access_request') return decision;
|
|
501
|
+
|
|
502
|
+
const rawCode = signal.contextPayload.requestCode;
|
|
503
|
+
const hasRequestCode = typeof rawCode === 'string' && rawCode.trim().length > 0;
|
|
504
|
+
|
|
505
|
+
const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
|
|
506
|
+
...decision.renderedCopy,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
if (hasRequestCode) {
|
|
510
|
+
const requestCode = rawCode.trim().toUpperCase();
|
|
511
|
+
const contractText = buildAccessRequestContractText(signal.contextPayload);
|
|
512
|
+
|
|
513
|
+
for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
|
|
514
|
+
const copy = nextCopy[channel];
|
|
515
|
+
if (!copy) continue;
|
|
516
|
+
nextCopy[channel] = ensureAccessRequestInstructionsInCopy(copy, requestCode, contractText);
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
// No requestCode — still enforce the invite-flow directive.
|
|
520
|
+
const inviteDirective = buildAccessRequestInviteDirective();
|
|
521
|
+
|
|
522
|
+
for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
|
|
523
|
+
const copy = nextCopy[channel];
|
|
524
|
+
if (!copy) continue;
|
|
525
|
+
nextCopy[channel] = ensureInviteFlowDirectiveInCopy(copy, inviteDirective);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
...decision,
|
|
531
|
+
renderedCopy: nextCopy,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function ensureAccessRequestInstructionsInCopy(
|
|
536
|
+
copy: RenderedChannelCopy,
|
|
537
|
+
requestCode: string,
|
|
538
|
+
contractText: string,
|
|
539
|
+
): RenderedChannelCopy {
|
|
540
|
+
const ensureText = (text: string | undefined): string => {
|
|
541
|
+
const base = typeof text === 'string' ? text.trim() : '';
|
|
542
|
+
if (hasAccessRequestInstructions(base, requestCode)) return base;
|
|
543
|
+
return base.length > 0 ? `${base}\n\n${contractText}` : contractText;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
...copy,
|
|
548
|
+
body: ensureText(copy.body),
|
|
549
|
+
deliveryText: copy.deliveryText ? ensureText(copy.deliveryText) : copy.deliveryText,
|
|
550
|
+
threadSeedMessage: copy.threadSeedMessage ? ensureText(copy.threadSeedMessage) : copy.threadSeedMessage,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function ensureInviteFlowDirectiveInCopy(
|
|
555
|
+
copy: RenderedChannelCopy,
|
|
556
|
+
inviteDirective: string,
|
|
557
|
+
): RenderedChannelCopy {
|
|
558
|
+
const ensureText = (text: string | undefined): string => {
|
|
559
|
+
const base = typeof text === 'string' ? text.trim() : '';
|
|
560
|
+
if (hasInviteFlowDirective(base)) return base;
|
|
561
|
+
return base.length > 0 ? `${base}\n\n${inviteDirective}` : inviteDirective;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
...copy,
|
|
566
|
+
body: ensureText(copy.body),
|
|
567
|
+
deliveryText: copy.deliveryText ? ensureText(copy.deliveryText) : copy.deliveryText,
|
|
568
|
+
threadSeedMessage: copy.threadSeedMessage ? ensureText(copy.threadSeedMessage) : copy.threadSeedMessage,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
477
572
|
// ── Core evaluation function ───────────────────────────────────────────
|
|
478
573
|
|
|
479
574
|
export async function evaluateSignal(
|
|
@@ -513,6 +608,7 @@ export async function evaluateSignal(
|
|
|
513
608
|
log.warn('Configured provider unavailable for notification decision, using fallback');
|
|
514
609
|
let decision = buildFallbackDecision(signal, availableChannels);
|
|
515
610
|
decision = enforceGuardianRequestCode(decision, signal);
|
|
611
|
+
decision = enforceAccessRequestInstructions(decision, signal);
|
|
516
612
|
decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
|
|
517
613
|
decision.persistedDecisionId = persistDecision(signal, decision);
|
|
518
614
|
return decision;
|
|
@@ -528,6 +624,7 @@ export async function evaluateSignal(
|
|
|
528
624
|
}
|
|
529
625
|
|
|
530
626
|
decision = enforceGuardianRequestCode(decision, signal);
|
|
627
|
+
decision = enforceAccessRequestInstructions(decision, signal);
|
|
531
628
|
decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
|
|
532
629
|
decision.persistedDecisionId = persistDecision(signal, decision);
|
|
533
630
|
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refresh token service — mint, rotate, and validate refresh tokens.
|
|
3
|
+
*
|
|
4
|
+
* Implements rotating single-use refresh tokens with:
|
|
5
|
+
* - Absolute expiry (365 days)
|
|
6
|
+
* - Inactivity expiry (90 days since last refresh)
|
|
7
|
+
* - Replay detection (reuse of rotated token revokes entire family)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
import { getDb } from '../memory/db.js';
|
|
13
|
+
import { getLogger } from '../util/logger.js';
|
|
14
|
+
import {
|
|
15
|
+
createRefreshTokenRecord,
|
|
16
|
+
findByTokenHash as findRefreshByHash,
|
|
17
|
+
markRotated,
|
|
18
|
+
revokeByDeviceBinding as revokeRefreshTokensByDevice,
|
|
19
|
+
revokeFamily,
|
|
20
|
+
} from './actor-refresh-token-store.js';
|
|
21
|
+
import { hashToken, mintActorToken } from './actor-token-service.js';
|
|
22
|
+
import {
|
|
23
|
+
createActorTokenRecord,
|
|
24
|
+
revokeByDeviceBinding as revokeActorTokensByDevice,
|
|
25
|
+
} from './actor-token-store.js';
|
|
26
|
+
|
|
27
|
+
const log = getLogger('actor-refresh-token-service');
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Constants
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Access token TTL: 30 days (reduced from 90). */
|
|
34
|
+
const ACCESS_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/** Refresh token absolute expiry: 365 days from issuance. */
|
|
37
|
+
const REFRESH_ABSOLUTE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/** Refresh token inactivity expiry: 90 days since last successful refresh. */
|
|
40
|
+
const REFRESH_INACTIVITY_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
/** Proactive refresh hint: suggest refreshing when 80% of access token TTL has elapsed. */
|
|
43
|
+
const REFRESH_AFTER_FRACTION = 0.8;
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Types
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export type RefreshErrorCode =
|
|
50
|
+
| 'refresh_invalid'
|
|
51
|
+
| 'refresh_expired'
|
|
52
|
+
| 'refresh_reuse_detected'
|
|
53
|
+
| 'device_binding_mismatch'
|
|
54
|
+
| 'revoked';
|
|
55
|
+
|
|
56
|
+
export interface RefreshResult {
|
|
57
|
+
guardianPrincipalId: string;
|
|
58
|
+
actorToken: string;
|
|
59
|
+
actorTokenExpiresAt: number;
|
|
60
|
+
refreshToken: string;
|
|
61
|
+
refreshTokenExpiresAt: number;
|
|
62
|
+
refreshAfter: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MintRefreshTokenResult {
|
|
66
|
+
refreshToken: string;
|
|
67
|
+
refreshTokenHash: string;
|
|
68
|
+
refreshTokenExpiresAt: number;
|
|
69
|
+
refreshAfter: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Mint a fresh refresh token (used by bootstrap/pairing)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Hash a raw refresh token for storage. Reuses the actor-token hash function. */
|
|
77
|
+
function hashRefreshToken(token: string): string {
|
|
78
|
+
return hashToken(token);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Generate a cryptographically random refresh token. */
|
|
82
|
+
function generateRefreshToken(): string {
|
|
83
|
+
return randomBytes(32).toString('base64url');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Mint a new refresh token and persist its hash.
|
|
88
|
+
* Called during bootstrap, pairing, and rotation.
|
|
89
|
+
*/
|
|
90
|
+
export function mintRefreshToken(params: {
|
|
91
|
+
assistantId: string;
|
|
92
|
+
guardianPrincipalId: string;
|
|
93
|
+
hashedDeviceId: string;
|
|
94
|
+
platform: string;
|
|
95
|
+
familyId?: string;
|
|
96
|
+
/** When provided (during rotation), inherit the parent token's absolute expiry
|
|
97
|
+
* instead of computing a fresh one. This ensures refresh rotation resets the
|
|
98
|
+
* inactivity window but does NOT extend the absolute session lifetime. */
|
|
99
|
+
absoluteExpiresAt?: number;
|
|
100
|
+
}): MintRefreshTokenResult {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const familyId = params.familyId ?? randomBytes(16).toString('hex');
|
|
103
|
+
const refreshToken = generateRefreshToken();
|
|
104
|
+
const refreshTokenHash = hashRefreshToken(refreshToken);
|
|
105
|
+
const absoluteExpiresAt = params.absoluteExpiresAt ?? now + REFRESH_ABSOLUTE_TTL_MS;
|
|
106
|
+
const inactivityExpiresAt = now + REFRESH_INACTIVITY_TTL_MS;
|
|
107
|
+
|
|
108
|
+
createRefreshTokenRecord({
|
|
109
|
+
tokenHash: refreshTokenHash,
|
|
110
|
+
familyId,
|
|
111
|
+
assistantId: params.assistantId,
|
|
112
|
+
guardianPrincipalId: params.guardianPrincipalId,
|
|
113
|
+
hashedDeviceId: params.hashedDeviceId,
|
|
114
|
+
platform: params.platform,
|
|
115
|
+
issuedAt: now,
|
|
116
|
+
absoluteExpiresAt,
|
|
117
|
+
inactivityExpiresAt,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
refreshToken,
|
|
122
|
+
refreshTokenHash,
|
|
123
|
+
refreshTokenExpiresAt: Math.min(absoluteExpiresAt, inactivityExpiresAt),
|
|
124
|
+
refreshAfter: now + Math.floor(ACCESS_TOKEN_TTL_MS * REFRESH_AFTER_FRACTION),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mint both an access token and a refresh token for initial credential issuance.
|
|
130
|
+
* Used by bootstrap and pairing flows.
|
|
131
|
+
*/
|
|
132
|
+
export function mintCredentialPair(params: {
|
|
133
|
+
assistantId: string;
|
|
134
|
+
platform: string;
|
|
135
|
+
deviceId: string;
|
|
136
|
+
guardianPrincipalId: string;
|
|
137
|
+
hashedDeviceId: string;
|
|
138
|
+
}): {
|
|
139
|
+
actorToken: string;
|
|
140
|
+
actorTokenExpiresAt: number;
|
|
141
|
+
refreshToken: string;
|
|
142
|
+
refreshTokenExpiresAt: number;
|
|
143
|
+
refreshAfter: number;
|
|
144
|
+
guardianPrincipalId: string;
|
|
145
|
+
} {
|
|
146
|
+
// Revoke any existing credentials for this device
|
|
147
|
+
revokeActorTokensByDevice(params.assistantId, params.guardianPrincipalId, params.hashedDeviceId);
|
|
148
|
+
revokeRefreshTokensByDevice(params.assistantId, params.guardianPrincipalId, params.hashedDeviceId);
|
|
149
|
+
|
|
150
|
+
// Mint new access token with 30-day TTL
|
|
151
|
+
const { token: actorToken, tokenHash: actorTokenHash, claims } = mintActorToken({
|
|
152
|
+
assistantId: params.assistantId,
|
|
153
|
+
platform: params.platform,
|
|
154
|
+
deviceId: params.deviceId,
|
|
155
|
+
guardianPrincipalId: params.guardianPrincipalId,
|
|
156
|
+
ttlMs: ACCESS_TOKEN_TTL_MS,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
createActorTokenRecord({
|
|
160
|
+
tokenHash: actorTokenHash,
|
|
161
|
+
assistantId: params.assistantId,
|
|
162
|
+
guardianPrincipalId: params.guardianPrincipalId,
|
|
163
|
+
hashedDeviceId: params.hashedDeviceId,
|
|
164
|
+
platform: params.platform,
|
|
165
|
+
issuedAt: claims.iat,
|
|
166
|
+
expiresAt: claims.exp,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Mint new refresh token
|
|
170
|
+
const refresh = mintRefreshToken({
|
|
171
|
+
assistantId: params.assistantId,
|
|
172
|
+
guardianPrincipalId: params.guardianPrincipalId,
|
|
173
|
+
hashedDeviceId: params.hashedDeviceId,
|
|
174
|
+
platform: params.platform,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
actorToken,
|
|
179
|
+
actorTokenExpiresAt: claims.exp!,
|
|
180
|
+
refreshToken: refresh.refreshToken,
|
|
181
|
+
refreshTokenExpiresAt: refresh.refreshTokenExpiresAt,
|
|
182
|
+
refreshAfter: refresh.refreshAfter,
|
|
183
|
+
guardianPrincipalId: params.guardianPrincipalId,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Rotate (the core refresh operation)
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Rotate credentials: validate refresh token, revoke old, mint new pair.
|
|
193
|
+
*
|
|
194
|
+
* Returns either a successful result or an error code.
|
|
195
|
+
*/
|
|
196
|
+
export function rotateCredentials(params: {
|
|
197
|
+
refreshToken: string;
|
|
198
|
+
platform: string;
|
|
199
|
+
deviceId: string;
|
|
200
|
+
}): { ok: true; result: RefreshResult } | { ok: false; error: RefreshErrorCode } {
|
|
201
|
+
const refreshTokenHash = hashRefreshToken(params.refreshToken);
|
|
202
|
+
const hashedDeviceId = createHash('sha256').update(params.deviceId).digest('hex');
|
|
203
|
+
|
|
204
|
+
// Look up the refresh token by hash (any status)
|
|
205
|
+
const record = findRefreshByHash(refreshTokenHash);
|
|
206
|
+
|
|
207
|
+
if (!record) {
|
|
208
|
+
return { ok: false, error: 'refresh_invalid' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check if this is a reuse of an already-rotated token (replay detection)
|
|
212
|
+
if (record.status === 'rotated') {
|
|
213
|
+
log.warn(
|
|
214
|
+
{ familyId: record.familyId, hashedDeviceId: record.hashedDeviceId },
|
|
215
|
+
'Refresh token reuse detected — revoking entire family',
|
|
216
|
+
);
|
|
217
|
+
revokeFamily(record.familyId);
|
|
218
|
+
revokeActorTokensByDevice(record.assistantId, record.guardianPrincipalId, record.hashedDeviceId);
|
|
219
|
+
return { ok: false, error: 'refresh_reuse_detected' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (record.status === 'revoked') {
|
|
223
|
+
return { ok: false, error: 'revoked' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// At this point status === 'active'
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
|
|
229
|
+
// Check absolute expiry
|
|
230
|
+
if (now > record.absoluteExpiresAt) {
|
|
231
|
+
return { ok: false, error: 'refresh_expired' };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check inactivity expiry
|
|
235
|
+
if (now > record.inactivityExpiresAt) {
|
|
236
|
+
return { ok: false, error: 'refresh_expired' };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Verify device binding
|
|
240
|
+
if (record.hashedDeviceId !== hashedDeviceId) {
|
|
241
|
+
return { ok: false, error: 'device_binding_mismatch' };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (record.platform !== params.platform) {
|
|
245
|
+
return { ok: false, error: 'device_binding_mismatch' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Wrap the entire rotate-revoke-remint sequence in a transaction so that
|
|
249
|
+
// partial failures (e.g., DB write error after revoking old tokens) roll back
|
|
250
|
+
// atomically instead of stranding device credentials.
|
|
251
|
+
const db = getDb();
|
|
252
|
+
return db.transaction(() => {
|
|
253
|
+
// Mark old refresh token as rotated (atomic CAS — fails if a concurrent request already consumed it)
|
|
254
|
+
const didRotate = markRotated(refreshTokenHash);
|
|
255
|
+
if (!didRotate) {
|
|
256
|
+
return { ok: false as const, error: 'refresh_reuse_detected' as const };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Revoke old access tokens for this device
|
|
260
|
+
revokeActorTokensByDevice(record.assistantId, record.guardianPrincipalId, record.hashedDeviceId);
|
|
261
|
+
|
|
262
|
+
// Mint new access token
|
|
263
|
+
const { token: actorToken, tokenHash: actorTokenHash, claims } = mintActorToken({
|
|
264
|
+
assistantId: record.assistantId,
|
|
265
|
+
platform: params.platform,
|
|
266
|
+
deviceId: params.deviceId,
|
|
267
|
+
guardianPrincipalId: record.guardianPrincipalId,
|
|
268
|
+
ttlMs: ACCESS_TOKEN_TTL_MS,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
createActorTokenRecord({
|
|
272
|
+
tokenHash: actorTokenHash,
|
|
273
|
+
assistantId: record.assistantId,
|
|
274
|
+
guardianPrincipalId: record.guardianPrincipalId,
|
|
275
|
+
hashedDeviceId: record.hashedDeviceId,
|
|
276
|
+
platform: params.platform,
|
|
277
|
+
issuedAt: claims.iat,
|
|
278
|
+
expiresAt: claims.exp,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Mint new refresh token in the same family, inheriting the parent's absolute
|
|
282
|
+
// expiry so rotation resets inactivity but never extends the session lifetime.
|
|
283
|
+
const refresh = mintRefreshToken({
|
|
284
|
+
assistantId: record.assistantId,
|
|
285
|
+
guardianPrincipalId: record.guardianPrincipalId,
|
|
286
|
+
hashedDeviceId: record.hashedDeviceId,
|
|
287
|
+
platform: params.platform,
|
|
288
|
+
familyId: record.familyId,
|
|
289
|
+
absoluteExpiresAt: record.absoluteExpiresAt,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
log.info(
|
|
293
|
+
{ familyId: record.familyId, platform: params.platform },
|
|
294
|
+
'Credential rotation completed',
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
ok: true as const,
|
|
299
|
+
result: {
|
|
300
|
+
guardianPrincipalId: record.guardianPrincipalId,
|
|
301
|
+
actorToken,
|
|
302
|
+
actorTokenExpiresAt: claims.exp!,
|
|
303
|
+
refreshToken: refresh.refreshToken,
|
|
304
|
+
refreshTokenExpiresAt: refresh.refreshTokenExpiresAt,
|
|
305
|
+
refreshAfter: refresh.refreshAfter,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
}
|