@canonmsg/backend-contracts 8.5.0 → 8.6.0

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.
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeEndpointUnreadProjection = computeEndpointUnreadProjection;
4
+ /** Metadata-only unread projection, shared by serving and the offline migration. */
5
+ function computeEndpointUnreadProjection(principalId, conversation, member) {
6
+ if (!conversation || !member || !Array.isArray(conversation.memberIds) || !conversation.memberIds.includes(principalId))
7
+ return null;
8
+ const message = conversation.lastMessage;
9
+ if (!message || typeof message.senderId !== 'string' || message.senderId === principalId)
10
+ return null;
11
+ const at = time(message.timestamp);
12
+ if (at === null)
13
+ return null;
14
+ const floor = time(member.historyStartAt), read = time(member.lastReadTimestamp), deleted = time(member.deletedAt);
15
+ if ((floor !== null && at < floor) || (read !== null && at <= read) || (deleted !== null && at <= deleted))
16
+ return null;
17
+ return { unmutedAtMs: time(member.mutedUntil) ?? 0 };
18
+ }
19
+ function time(value) {
20
+ if (value === null || value === undefined)
21
+ return null;
22
+ if (typeof value === 'string') {
23
+ const ms = Date.parse(value);
24
+ return Number.isFinite(ms) ? ms : null;
25
+ }
26
+ if (typeof value === 'number')
27
+ return Number.isFinite(value) ? value : null;
28
+ if (typeof value !== 'object')
29
+ return null;
30
+ const timestamp = value;
31
+ const ms = typeof timestamp.toMillis === 'function' ? timestamp.toMillis() : typeof timestamp.toDate === 'function' ? timestamp.toDate().getTime()
32
+ : typeof timestamp.seconds === 'number' ? timestamp.seconds * 1000 + (timestamp.nanoseconds ?? 0) / 1e6 : NaN;
33
+ return Number.isFinite(ms) ? ms : null;
34
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ENDPOINT_ACTION_PATTERN = exports.ENDPOINT_ID_PATTERN = exports.CANON_ENDPOINT_PROTOCOL = void 0;
4
+ exports.isEndpointOperationRequest = isEndpointOperationRequest;
5
+ exports.isEndpointOperationReceipt = isEndpointOperationReceipt;
6
+ /** Plaintext endpoint protocol. Identity and authority come from authenticated transport. */
7
+ exports.CANON_ENDPOINT_PROTOCOL = 'canon.endpoint.v1';
8
+ exports.ENDPOINT_ID_PATTERN = /^[A-Za-z0-9_.:~-]{1,200}$/;
9
+ exports.ENDPOINT_ACTION_PATTERN = /^[a-z][a-z0-9_.-]{0,95}$/;
10
+ function isEndpointOperationRequest(value) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value))
12
+ return false;
13
+ const request = value;
14
+ const body = request.body;
15
+ return request.protocol === exports.CANON_ENDPOINT_PROTOCOL
16
+ && typeof request.operationId === 'string' && exports.ENDPOINT_ID_PATTERN.test(request.operationId)
17
+ && typeof request.installationId === 'string' && exports.ENDPOINT_ID_PATTERN.test(request.installationId)
18
+ && typeof request.action === 'string' && exports.ENDPOINT_ACTION_PATTERN.test(request.action)
19
+ && (request.ownershipVersion === undefined || Number.isSafeInteger(request.ownershipVersion) && request.ownershipVersion >= 0)
20
+ && !!body && body.encoding === 'json'
21
+ && !!body.value && typeof body.value === 'object' && !Array.isArray(body.value);
22
+ }
23
+ function isEndpointOperationReceipt(value) {
24
+ if (!value || typeof value !== 'object' || Array.isArray(value))
25
+ return false;
26
+ const receipt = value;
27
+ return receipt.protocol === exports.CANON_ENDPOINT_PROTOCOL
28
+ && typeof receipt.operationId === 'string' && exports.ENDPOINT_ID_PATTERN.test(receipt.operationId)
29
+ && typeof receipt.installationId === 'string' && exports.ENDPOINT_ID_PATTERN.test(receipt.installationId)
30
+ && typeof receipt.action === 'string' && exports.ENDPOINT_ACTION_PATTERN.test(receipt.action)
31
+ && ['pending', 'succeeded', 'rejected', 'uncertain'].includes(String(receipt.status))
32
+ && typeof receipt.createdAt === 'string' && typeof receipt.updatedAt === 'string';
33
+ }
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EndpointContentPolicyError = exports.ENDPOINT_CONTENT_EVENTS = exports.ENDPOINT_CONTENT_EPHEMERAL_FAMILIES = exports.ENDPOINT_CONTENT_EPHEMERALS = exports.ENDPOINT_CONTENT_QUERIES = exports.ENDPOINT_CONTENT_FUNCTION_QUERIES = exports.ENDPOINT_CONTENT_ACTIONS = void 0;
4
+ exports.endpointOperationRequestRetention = endpointOperationRequestRetention;
5
+ exports.endpointContentBoundary = endpointContentBoundary;
6
+ const verbWire_js_1 = require("./verbWire.js");
7
+ exports.ENDPOINT_CONTENT_ACTIONS = [
8
+ 'initialize_endpoint',
9
+ 'sync_phone_contacts', 'request_work_session', 'resolve_work_session_request',
10
+ 'update_agent_behavior_policy', 'update_conversation_agent_behavior_policy',
11
+ 'send_message', 'delete_message', 'forward_message', 'react', 'verb_execute',
12
+ 'send_contextual_message', 'create_interaction', 'consume_interaction',
13
+ 'start_conversation', 'create_conversation', 'add_member', 'remove_member',
14
+ 'leave_conversation', 'update_member_role', 'update_conversation', 'rename_conversation',
15
+ 'update_topic', 'update_group_avatar', 'mark_read', 'cancel_queued_message',
16
+ 'set_conversation_preferences', 'set_block', 'save_contact', 'remove_contact',
17
+ 'respond_interaction', 'session_control', 'approve_contact_request',
18
+ 'reject_contact_request', 'cancel_direct_request', 'update_message_disposition',
19
+ 'create_upload', 'finalize_upload', 'delete_upload', 'retry_video_processing',
20
+ 'finalize_memo_stream', 'abort_memo_stream', 'create_voice_session',
21
+ 'join_voice_session', 'decline_voice_session', 'end_voice_session',
22
+ 'consume_runtime_signal', 'generate_image',
23
+ ];
24
+ exports.ENDPOINT_CONTENT_FUNCTION_QUERIES = [
25
+ 'discover_agents', 'get_unread_summary', 'peek_runtime_signal',
26
+ 'get_work_session_catalog', 'get_work_session_request', 'get_agent_behavior_policy',
27
+ 'get_conversation_agent_behavior_policy', 'get_ephemeral', 'list_voice_sessions',
28
+ 'list_contact_requests', 'list_conversations', 'list_messages', 'get_messages',
29
+ 'admission_fences', 'list_contacts', 'get_contact', 'get_members', 'resolve_admission',
30
+ 'resolve_group_admission', 'get_runtime_card_state', 'conversation',
31
+ 'conversation_changes', 'get_upload', 'get_voice_session', 'get_image_generation',
32
+ 'get_image_generation_status',
33
+ ];
34
+ exports.ENDPOINT_CONTENT_QUERIES = [...exports.ENDPOINT_CONTENT_FUNCTION_QUERIES, 'admit_messages'];
35
+ exports.ENDPOINT_CONTENT_EPHEMERALS = [
36
+ 'create_memo_stream', 'publish_presence', 'publish_typing', 'publish_streaming',
37
+ 'publish_turn', 'publish_runtime_status', 'append_memo_chunk',
38
+ ];
39
+ exports.ENDPOINT_CONTENT_EPHEMERAL_FAMILIES = [
40
+ 'typing', 'streaming', 'turn-state', 'agent-session', 'runtime-card-state',
41
+ 'memo-streams', 'runtime-info', 'runtime-activity', 'runtime-suppression',
42
+ 'runtime-silence', 'runtime-attention', 'call-attention', 'agent-runtime', 'presence',
43
+ ];
44
+ exports.ENDPOINT_CONTENT_EVENTS = [
45
+ 'connected', 'heartbeat', 'agent.context', 'replay.expired',
46
+ 'message.created', 'message.snapshot', 'message.updated', 'message.deleted',
47
+ 'message.media_terminal', 'conversation.updated', 'conversation.changed',
48
+ 'conversation.removed', 'conversations.changed', 'contacts.changed',
49
+ 'contact.added', 'contact.updated', 'contact.removed', 'presence', 'typing',
50
+ 'runtime.updated', 'runtime.control', 'turn.updated',
51
+ 'voice.session.started', 'voice.session.ended',
52
+ ...exports.ENDPOINT_CONTENT_EPHEMERAL_FAMILIES.map((family) => `ephemeral.${family}`),
53
+ ];
54
+ class EndpointContentPolicyError extends Error {
55
+ code = 'ENDPOINT_CONTENT_UNCLASSIFIED';
56
+ constructor(lane, name) {
57
+ super(`Unclassified endpoint lane: ${lane}/${name}`);
58
+ this.name = 'EndpointContentPolicyError';
59
+ }
60
+ }
61
+ exports.EndpointContentPolicyError = EndpointContentPolicyError;
62
+ const namesForLane = {
63
+ 'operation.request': exports.ENDPOINT_CONTENT_ACTIONS,
64
+ 'operation.receipt': exports.ENDPOINT_CONTENT_ACTIONS,
65
+ 'query.request': exports.ENDPOINT_CONTENT_QUERIES,
66
+ 'query.result': exports.ENDPOINT_CONTENT_QUERIES,
67
+ 'ephemeral.request': exports.ENDPOINT_CONTENT_EPHEMERALS,
68
+ 'ephemeral.result': exports.ENDPOINT_CONTENT_EPHEMERALS,
69
+ event: exports.ENDPOINT_CONTENT_EVENTS,
70
+ };
71
+ /** An explicit allowlist of routing/authorization fields, not a rule that all
72
+ * fields named `id` are public. Free text, previews, labels, media descriptors,
73
+ * paths, provider configuration, native snapshots and arbitrary metadata stay
74
+ * private. IDs/cursors/timing still reveal a communication graph. */
75
+ const protocolPaths = [
76
+ 'protocol', 'operationId', 'installationId', 'action', 'ownershipVersion',
77
+ 'preconditions.conversationId', 'preconditions.membershipRevision',
78
+ 'preconditions.admissionId', 'preconditions.targetAdmissionId', 'body.encoding',
79
+ ];
80
+ // Fields are reviewed for a particular lane: e.g. an unknown `contactId` added
81
+ // to send_message is still private, despite being a routing ID elsewhere.
82
+ const actionRouting = {
83
+ send_message: ['conversationId', 'messageId', 'replyTo', 'mentions.*'],
84
+ delete_message: ['conversationId', 'messageId'], forward_message: ['sourceConversationId', 'targetConversationId', 'messageId'],
85
+ react: ['conversationId', 'messageId'], send_contextual_message: ['conversationId'],
86
+ create_interaction: ['conversationId', 'requestId', 'inputId', 'approvalId', 'cardId', 'planId', 'responseUserId'],
87
+ consume_interaction: ['conversationId', 'inputId', 'approvalId', 'cardId', 'planId'],
88
+ respond_interaction: ['conversationId', 'requestId'],
89
+ start_conversation: ['targetUserId'], create_conversation: ['memberIds.*'],
90
+ add_member: ['conversationId', 'userId', 'agentId'], remove_member: ['conversationId', 'userId', 'agentId', 'targetUserId'],
91
+ leave_conversation: ['conversationId'], update_member_role: ['conversationId', 'userId', 'targetUserId'],
92
+ update_conversation: ['conversationId'], rename_conversation: ['conversationId'], update_topic: ['conversationId'],
93
+ update_group_avatar: ['conversationId'], mark_read: ['conversationId'], cancel_queued_message: ['conversationId', 'messageId'],
94
+ set_conversation_preferences: ['conversationId'], set_block: ['targetUserId'], save_contact: ['contactUserId'], remove_contact: ['contactId', 'contactUserId'],
95
+ session_control: ['conversationId', 'agentId'], approve_contact_request: ['requestId'], reject_contact_request: ['requestId'], cancel_direct_request: ['requestId'],
96
+ update_message_disposition: ['conversationId', 'messageId'], create_upload: ['conversationId'], finalize_upload: ['uploadId'], delete_upload: ['uploadId'], retry_video_processing: ['uploadId'],
97
+ finalize_memo_stream: ['conversationId', 'streamId'], abort_memo_stream: ['streamId'],
98
+ create_voice_session: ['conversationId'], join_voice_session: ['conversationId', 'sessionId'], decline_voice_session: ['conversationId', 'sessionId'], end_voice_session: ['conversationId', 'sessionId'],
99
+ consume_runtime_signal: ['conversationId', 'expectedSignalId'], generate_image: ['conversationId', 'requestId'],
100
+ request_work_session: ['conversationId', 'agentId', 'requestId'], resolve_work_session_request: ['requestId'],
101
+ update_agent_behavior_policy: ['agentId'], update_conversation_agent_behavior_policy: ['conversationId', 'agentId'],
102
+ };
103
+ const queryRouting = {
104
+ admit_messages: ['conversationId', 'messageIds.*', 'admissionId'], conversation_changes: ['conversationId', 'cursor', 'limit'],
105
+ conversation: ['conversationId'], list_conversations: ['limit', 'before'], list_messages: ['conversationId', 'before', 'limit'], get_messages: ['conversationId', 'messageIds.*'],
106
+ list_contacts: ['limit', 'before'], get_contact: ['contactId', 'contactUserId', 'userId'], get_members: ['conversationId'],
107
+ list_contact_requests: ['direction', 'limit', 'before'], admission_fences: ['conversationId', 'targetUserId'],
108
+ resolve_admission: ['targetUserId'], resolve_group_admission: ['conversationId', 'targetUserId'], get_runtime_card_state: ['conversationId', 'cardId'],
109
+ get_ephemeral: ['family', 'scopeId'], peek_runtime_signal: ['conversationId'], list_voice_sessions: ['conversationId', 'limit'],
110
+ get_upload: ['uploadId'], get_voice_session: ['conversationId', 'sessionId'], get_image_generation: ['requestId'],
111
+ get_work_session_catalog: ['agentId'], get_work_session_request: ['requestId'], get_agent_behavior_policy: ['agentId'], get_conversation_agent_behavior_policy: ['conversationId', 'agentId'],
112
+ };
113
+ const transientRouting = {
114
+ create_memo_stream: ['conversationId', 'streamId'], append_memo_chunk: ['streamId'],
115
+ publish_presence: [], publish_typing: ['conversationId'], publish_streaming: ['conversationId'], publish_turn: ['conversationId'], publish_runtime_status: ['conversationId'],
116
+ };
117
+ function eventRouting(name) {
118
+ if (name.startsWith('ephemeral.'))
119
+ return ['conversationId', 'scopeId'];
120
+ if (name === 'connected')
121
+ return ['protocol', 'connectionId', 'agentId', 'principalId', 'principalType'];
122
+ if (name === 'heartbeat')
123
+ return ['timestamp'];
124
+ if (name === 'agent.context')
125
+ return ['agentId', 'ownerId', 'ownershipVersion'];
126
+ if (name === 'conversations.changed')
127
+ return ['conversationIds.*', 'removedConversationIds.*'];
128
+ if (name.startsWith('message.'))
129
+ return ['conversationId', 'messageId', 'eventId', 'id', 'message.id', 'message.conversationId', 'message.senderId', 'message.senderType', 'message.createdAt', 'message.replyTo', 'message.mentions.*'];
130
+ if (name.startsWith('conversation.'))
131
+ return ['conversationId'];
132
+ if (name.startsWith('contact.'))
133
+ return ['contactId'];
134
+ if (name.startsWith('voice.session.'))
135
+ return ['conversationId', 'sessionId'];
136
+ if (/^(runtime\.|turn\.|typing$|presence$)/.test(name))
137
+ return ['conversationId', 'agentId', 'userId', 'memberId'];
138
+ return [];
139
+ }
140
+ const secretSegments = new Set(['token', 'accessToken', 'refreshToken', 'uploadUrl', 'signedUrl', 'livekitToken', 'replyAuthority', 'credentials', 'password', 'secret', 'authorization', 'answer', 'answers', 'response', 'submittedValue']);
141
+ const matches = (path, pattern) => {
142
+ const parts = pattern.split('.');
143
+ return parts.length === path.length && parts.every((part, index) => part === path[index] || part === '*' && /^\d+$/.test(path[index]));
144
+ };
145
+ const publicMatch = (path, patterns) => patterns.some((pattern) => matches(path, pattern));
146
+ /** Local journal policy is separate from wire classification. Input answers and
147
+ * card values can carry passwords even when the response omits the original
148
+ * request's secret/sudo/field metadata. Only reviewed response shapes without
149
+ * those value bags may be retried from durable storage. Unknown response fields
150
+ * fail closed; a caller's `sensitive: false` cannot weaken this policy. */
151
+ function endpointOperationRequestRetention(action, body, envelope) {
152
+ if (action !== 'respond_interaction')
153
+ return 'journal';
154
+ // Interaction services use the body exclusively. Unknown envelope additions
155
+ // must not bypass retention, including conflicting fields rejected by server.
156
+ if (envelope && Object.keys(envelope).length > 0)
157
+ return 'memory';
158
+ const routing = ['kind', 'conversationId', 'agentId', 'requestId'];
159
+ const hasOnly = (fields) => Object.keys(body).every((field) => fields.includes(field));
160
+ if (routing.some((field) => body[field] !== undefined && typeof body[field] !== 'string'))
161
+ return 'memory';
162
+ if ((body.kind === 'input' || body.kind === 'card') && hasOnly([...routing, 'status', 'actionId'])) {
163
+ if (!['submitted', 'cancelled'].includes(String(body.status)) || (body.actionId !== undefined && typeof body.actionId !== 'string'))
164
+ return 'memory';
165
+ // A card button or cancellation with no submitted values contains no answer
166
+ // plaintext. Input/card requests with value, answers or values stay in RAM,
167
+ // including non-secret clarification: that distinction is absent on wire.
168
+ return 'journal';
169
+ }
170
+ if (body.kind === 'approval' && hasOnly([...routing, 'decision', 'sessionRule'])) {
171
+ if (body.decision !== 'allow' && body.decision !== 'deny')
172
+ return 'memory';
173
+ const rule = body.sessionRule;
174
+ if (rule !== undefined && (!rule || typeof rule !== 'object' || Array.isArray(rule)
175
+ || !Object.entries(rule).every(([field, value]) => ['type', 'toolPattern', 'expiresAt'].includes(field)
176
+ && (typeof value === 'string' || field === 'expiresAt' && value === null))))
177
+ return 'memory';
178
+ return 'journal';
179
+ }
180
+ if (body.kind === 'plan' && hasOnly([...routing, 'decision', 'feedback', 'grantedPrompts'])) {
181
+ if (!['approve', 'revise', 'reject'].includes(String(body.decision)) || body.feedback !== undefined && typeof body.feedback !== 'string')
182
+ return 'memory';
183
+ // Plan feedback and granted tool prompts are ordinary authored instructions,
184
+ // not secret-entry fields. Preserve their established offline semantics.
185
+ const prompts = body.grantedPrompts;
186
+ if (prompts !== undefined && (!Array.isArray(prompts) || prompts.some((prompt) => !prompt || typeof prompt !== 'object'
187
+ || Array.isArray(prompt) || !Object.entries(prompt).every(([field, value]) => ['tool', 'prompt'].includes(field) && typeof value === 'string'))))
188
+ return 'memory';
189
+ return 'journal';
190
+ }
191
+ return 'memory';
192
+ }
193
+ /** Validate the lane before accepting/transmitting its payload. No body values
194
+ * are logged, copied into metadata, or claimed to be encrypted by this API. */
195
+ function endpointContentBoundary(lane, name, payload) {
196
+ if (!namesForLane[lane]?.includes(name))
197
+ throw new EndpointContentPolicyError(lane, name);
198
+ const record = payload && typeof payload === 'object' ? payload : undefined;
199
+ if (lane === 'query.request' && name === 'get_ephemeral' && record && !exports.ENDPOINT_CONTENT_EPHEMERAL_FAMILIES.includes(record.family)) {
200
+ throw new EndpointContentPolicyError(lane, `${name}/unknown-family`);
201
+ }
202
+ const requestRoutingPaths = (actionRouting[name] ?? []).flatMap((field) => [`body.value.${field}`, `envelope.${field}`]);
203
+ const classify = (path) => {
204
+ if (path.some((part) => secretSegments.has(part)))
205
+ return 'restricted-secret';
206
+ if (lane === 'operation.request' && ['respond_interaction', 'sync_phone_contacts'].includes(name) && path[0] === 'body' && path[1] === 'value' && !publicMatch(path, requestRoutingPaths))
207
+ return 'restricted-secret';
208
+ if (lane === 'operation.receipt' && path[0] === 'result' && (['consume_interaction', 'respond_interaction'].includes(name) || record?.resultReference?.kind === 'interaction'))
209
+ return 'restricted-secret';
210
+ let selected = payload;
211
+ for (const part of path)
212
+ selected = selected && typeof selected === 'object' && Object.hasOwn(selected, part) ? selected[part] : undefined;
213
+ // An allowed scalar path cannot make arbitrary nested content public.
214
+ if (selected !== null && typeof selected === 'object')
215
+ return 'private-content';
216
+ if (lane === 'operation.request') {
217
+ if (publicMatch(path, protocolPaths) || publicMatch(path, requestRoutingPaths))
218
+ return 'public-metadata';
219
+ // All response content is treated as restricted, even for a non-secret
220
+ // interaction; classification cannot depend on a stale local kind flag.
221
+ if (name === 'respond_interaction' && path[0] === 'body' && path[1] === 'value')
222
+ return 'restricted-secret';
223
+ if (name === 'sync_phone_contacts' && path[0] === 'body' && path[1] === 'value')
224
+ return 'restricted-secret';
225
+ if (name === 'verb_execute') {
226
+ const wire = record?.body?.value;
227
+ const spec = verbWire_js_1.VERB_WIRE_ENVELOPE_FIELDS[wire?.verb];
228
+ if (path[0] === 'body' && path[1] === 'value') {
229
+ if (path.length === 3 && ['wire', 'verb'].includes(path[2]))
230
+ return 'public-metadata';
231
+ if (path[2] === 'envelope' && spec) {
232
+ const field = path[3];
233
+ const allowed = [...spec.required, ...spec.optional];
234
+ // Reuse verbWire's existing split. Nested metadata is deliberately
235
+ // conservative: only declared scalar fields and numeric array IDs.
236
+ if (field && allowed.includes(field) && (path.length === 4 || path.length === 5 && ['mentions', 'memberIds'].includes(field) && /^\d+$/.test(path[4])))
237
+ return 'public-metadata';
238
+ }
239
+ }
240
+ }
241
+ }
242
+ else if (lane === 'operation.receipt') {
243
+ if (publicMatch(path, [...protocolPaths, 'status', 'createdAt', 'updatedAt', 'error.code', 'error.status', 'error.retryable', 'error.retryAfterMs', 'resultError.code', 'resultError.status', 'resultReference.kind', 'resultReference.conversationId', 'resultReference.uploadId', 'resultReference.sessionId', 'resultReference.requestId']))
244
+ return 'public-metadata';
245
+ if (path[0] === 'result' && (name === 'consume_interaction' || name === 'respond_interaction' || record?.resultReference?.kind === 'interaction'))
246
+ return 'restricted-secret';
247
+ }
248
+ else if (lane === 'query.request') {
249
+ if (publicMatch(path, queryRouting[name] ?? []))
250
+ return 'public-metadata';
251
+ }
252
+ else if (lane === 'query.result' || lane === 'ephemeral.result') {
253
+ if (name === 'conversation_changes' && publicMatch(path, ['cursor', 'hasMore', 'reset', 'admissionId']))
254
+ return 'public-metadata';
255
+ if (name === 'get_ephemeral' && path[0] === 'value')
256
+ return 'private-content';
257
+ }
258
+ else if (lane === 'ephemeral.request') {
259
+ if (publicMatch(path, transientRouting[name] ?? []))
260
+ return 'public-metadata';
261
+ }
262
+ else if (lane === 'event') {
263
+ if (publicMatch(path, eventRouting(name)))
264
+ return 'public-metadata';
265
+ }
266
+ return 'private-content';
267
+ };
268
+ return Object.freeze({ lane, name, defaultClass: 'private-content', classify });
269
+ }
package/dist/cjs/index.js CHANGED
@@ -14,6 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.computeEndpointUnreadProjection = void 0;
17
18
  __exportStar(require("./diffRedaction.js"), exports);
18
19
  __exportStar(require("./environment.js"), exports);
19
20
  __exportStar(require("./media.js"), exports);
@@ -38,3 +39,8 @@ __exportStar(require("./replyAuthority.js"), exports);
38
39
  __exportStar(require("./runtimeDescriptor.js"), exports);
39
40
  __exportStar(require("./workSessions.js"), exports);
40
41
  __exportStar(require("./conversationMembership.js"), exports);
42
+ __exportStar(require("./endpoint.js"), exports);
43
+ __exportStar(require("./endpointContentPolicy.js"), exports);
44
+ __exportStar(require("./plaintextProcessors.js"), exports);
45
+ var endpoint_unread_js_1 = require("./endpoint-unread.js");
46
+ Object.defineProperty(exports, "computeEndpointUnreadProjection", { enumerable: true, get: function () { return endpoint_unread_js_1.computeEndpointUnreadProjection; } });
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLAINTEXT_PROCESSORS = void 0;
4
+ exports.runPlaintextProcessor = runPlaintextProcessor;
5
+ /** Existing server content readers. Classification is not encryption: these
6
+ * processors intentionally inspect plaintext in the current JSON release.
7
+ * Their input boundary must be redesigned before enabling encrypted rooms. */
8
+ exports.PLAINTEXT_PROCESSORS = {
9
+ preview: { current: 'Read message/display text to derive conversation and notification previews.', beforeMls: 'Derive on authorized endpoints; clear/reconcile deleted-message previews there.' },
10
+ push: { current: 'Send visible titles and message previews to FCM/APNs.', beforeMls: 'Use generic wake notifications or endpoint-decrypted notification extensions.' },
11
+ topic: { current: 'Derive the initial topic from message text.', beforeMls: 'Derive on an authorized endpoint and publish as encrypted conversation content.' },
12
+ forward: { current: 'Read source content and copy plaintext attachments into the target room.', beforeMls: 'Decrypt and re-encrypt on an authorized endpoint; keep server target admission checks.' },
13
+ message_validation: { current: 'Inspect text, attachment descriptors and rich content before storing messages.', beforeMls: 'Validate decrypted content on endpoints; retain bounded ciphertext/envelope checks on the server.' },
14
+ interaction_validation: { current: 'Validate card/input/approval/plan render contracts and submitted responses.', beforeMls: 'Validate content on authorized requesting/responding endpoints; retain public admission and lifecycle checks.' },
15
+ image_generation: { current: 'Send prompts/source image bytes to Vertex and persist generated output.', beforeMls: 'Require explicit provider disclosure and an authorized endpoint/processing participant; no silent server decryption.' },
16
+ video_processing: { current: 'Download plaintext video, normalize it and create a poster.', beforeMls: 'Process on an endpoint or an explicitly authorized processing participant before encrypted upload.' },
17
+ owner_observation: { current: 'Allow authenticated owners to inspect managed agent conversations without room membership.', beforeMls: 'Explicitly enroll authorized owner keyholders or retire content observation; ownership alone supplies no decryption keys.' },
18
+ moderation_evidence: { current: 'Accept user-selected plaintext message evidence for moderation.', beforeMls: 'Design explicit selective disclosure/authenticity (including franking policy); never infer access to room keys.' },
19
+ };
20
+ /** The callback is the actual content processor, not a telemetry hook. Keeping
21
+ * the codec explicit ensures an opaque body cannot accidentally be processed by
22
+ * the JSON path. This is not authorization; callers still enforce their domain
23
+ * policy and every enabled transport currently requires JSON. */
24
+ function runPlaintextProcessor(name, input, process) {
25
+ if (!Object.hasOwn(exports.PLAINTEXT_PROCESSORS, name) || input.encoding !== 'json') {
26
+ throw new Error('PLAINTEXT_PROCESSOR_CODEC_REQUIRED');
27
+ }
28
+ return process(input.value);
29
+ }
@@ -0,0 +1,4 @@
1
+ /** Metadata-only unread projection, shared by serving and the offline migration. */
2
+ export declare function computeEndpointUnreadProjection(principalId: string, conversation: Record<string, unknown> | undefined, member: Record<string, unknown> | undefined): {
3
+ unmutedAtMs: number;
4
+ } | null;
@@ -0,0 +1,31 @@
1
+ /** Metadata-only unread projection, shared by serving and the offline migration. */
2
+ export function computeEndpointUnreadProjection(principalId, conversation, member) {
3
+ if (!conversation || !member || !Array.isArray(conversation.memberIds) || !conversation.memberIds.includes(principalId))
4
+ return null;
5
+ const message = conversation.lastMessage;
6
+ if (!message || typeof message.senderId !== 'string' || message.senderId === principalId)
7
+ return null;
8
+ const at = time(message.timestamp);
9
+ if (at === null)
10
+ return null;
11
+ const floor = time(member.historyStartAt), read = time(member.lastReadTimestamp), deleted = time(member.deletedAt);
12
+ if ((floor !== null && at < floor) || (read !== null && at <= read) || (deleted !== null && at <= deleted))
13
+ return null;
14
+ return { unmutedAtMs: time(member.mutedUntil) ?? 0 };
15
+ }
16
+ function time(value) {
17
+ if (value === null || value === undefined)
18
+ return null;
19
+ if (typeof value === 'string') {
20
+ const ms = Date.parse(value);
21
+ return Number.isFinite(ms) ? ms : null;
22
+ }
23
+ if (typeof value === 'number')
24
+ return Number.isFinite(value) ? value : null;
25
+ if (typeof value !== 'object')
26
+ return null;
27
+ const timestamp = value;
28
+ const ms = typeof timestamp.toMillis === 'function' ? timestamp.toMillis() : typeof timestamp.toDate === 'function' ? timestamp.toDate().getTime()
29
+ : typeof timestamp.seconds === 'number' ? timestamp.seconds * 1000 + (timestamp.nanoseconds ?? 0) / 1e6 : NaN;
30
+ return Number.isFinite(ms) ? ms : null;
31
+ }
@@ -0,0 +1,88 @@
1
+ /** Plaintext endpoint protocol. Identity and authority come from authenticated transport. */
2
+ export declare const CANON_ENDPOINT_PROTOCOL: "canon.endpoint.v1";
3
+ export interface EndpointPreconditions {
4
+ conversationId?: string;
5
+ membershipRevision?: number;
6
+ admissionId?: string;
7
+ targetAdmissionId?: string;
8
+ }
9
+ export interface EndpointOperationRequest {
10
+ protocol: typeof CANON_ENDPOINT_PROTOCOL;
11
+ operationId: string;
12
+ installationId: string;
13
+ action: string;
14
+ /** Agent ownership generation selected when authored; absent for humans. */
15
+ ownershipVersion?: number;
16
+ envelope?: Record<string, unknown>;
17
+ preconditions?: EndpointPreconditions;
18
+ body: {
19
+ encoding: 'json';
20
+ value: Record<string, unknown>;
21
+ };
22
+ }
23
+ export interface EndpointOperationError {
24
+ code: string;
25
+ message: string;
26
+ status?: number;
27
+ retryable?: boolean;
28
+ retryAfterMs?: number;
29
+ }
30
+ export type EndpointReceiptStatus = 'pending' | 'succeeded' | 'rejected' | 'uncertain';
31
+ export type EndpointResultReference = {
32
+ kind: 'interaction';
33
+ conversationId: string;
34
+ interactionKind: 'input' | 'approval' | 'card' | 'plan';
35
+ requestId: string;
36
+ operationKey: string;
37
+ } | {
38
+ kind: 'upload';
39
+ uploadId: string;
40
+ } | {
41
+ kind: 'voice';
42
+ conversationId: string;
43
+ sessionId: string;
44
+ };
45
+ export interface EndpointOperationReceipt<T = unknown> {
46
+ protocol: typeof CANON_ENDPOINT_PROTOCOL;
47
+ operationId: string;
48
+ installationId: string;
49
+ action: string;
50
+ status: EndpointReceiptStatus;
51
+ result?: T;
52
+ /** Sensitive/bearer results are hydrated for the response, never journaled. */
53
+ resultReference?: EndpointResultReference;
54
+ /** Transient hydration failure: the durable mutation remains succeeded. */
55
+ resultError?: EndpointOperationError;
56
+ error?: EndpointOperationError;
57
+ createdAt: string;
58
+ updatedAt: string;
59
+ }
60
+ export interface EndpointQueryRequest {
61
+ protocol: typeof CANON_ENDPOINT_PROTOCOL;
62
+ query: string;
63
+ parameters: Record<string, unknown>;
64
+ }
65
+ export interface EndpointQueryResponse<T = unknown> {
66
+ protocol: typeof CANON_ENDPOINT_PROTOCOL;
67
+ result: T;
68
+ }
69
+ /** Durable application cursors differ from the live transport's reconnect token. */
70
+ export interface EndpointEvent {
71
+ id: string;
72
+ kind: string;
73
+ conversationId?: string;
74
+ revision?: number;
75
+ cursor?: string;
76
+ durable: boolean;
77
+ data: Record<string, unknown>;
78
+ }
79
+ export interface EndpointCapabilities {
80
+ protocol: typeof CANON_ENDPOINT_PROTOCOL;
81
+ actions: string[];
82
+ queries: string[];
83
+ maintenance: boolean;
84
+ }
85
+ export declare const ENDPOINT_ID_PATTERN: RegExp;
86
+ export declare const ENDPOINT_ACTION_PATTERN: RegExp;
87
+ export declare function isEndpointOperationRequest(value: unknown): value is EndpointOperationRequest;
88
+ export declare function isEndpointOperationReceipt(value: unknown): value is EndpointOperationReceipt;
@@ -0,0 +1,28 @@
1
+ /** Plaintext endpoint protocol. Identity and authority come from authenticated transport. */
2
+ export const CANON_ENDPOINT_PROTOCOL = 'canon.endpoint.v1';
3
+ export const ENDPOINT_ID_PATTERN = /^[A-Za-z0-9_.:~-]{1,200}$/;
4
+ export const ENDPOINT_ACTION_PATTERN = /^[a-z][a-z0-9_.-]{0,95}$/;
5
+ export function isEndpointOperationRequest(value) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value))
7
+ return false;
8
+ const request = value;
9
+ const body = request.body;
10
+ return request.protocol === CANON_ENDPOINT_PROTOCOL
11
+ && typeof request.operationId === 'string' && ENDPOINT_ID_PATTERN.test(request.operationId)
12
+ && typeof request.installationId === 'string' && ENDPOINT_ID_PATTERN.test(request.installationId)
13
+ && typeof request.action === 'string' && ENDPOINT_ACTION_PATTERN.test(request.action)
14
+ && (request.ownershipVersion === undefined || Number.isSafeInteger(request.ownershipVersion) && request.ownershipVersion >= 0)
15
+ && !!body && body.encoding === 'json'
16
+ && !!body.value && typeof body.value === 'object' && !Array.isArray(body.value);
17
+ }
18
+ export function isEndpointOperationReceipt(value) {
19
+ if (!value || typeof value !== 'object' || Array.isArray(value))
20
+ return false;
21
+ const receipt = value;
22
+ return receipt.protocol === CANON_ENDPOINT_PROTOCOL
23
+ && typeof receipt.operationId === 'string' && ENDPOINT_ID_PATTERN.test(receipt.operationId)
24
+ && typeof receipt.installationId === 'string' && ENDPOINT_ID_PATTERN.test(receipt.installationId)
25
+ && typeof receipt.action === 'string' && ENDPOINT_ACTION_PATTERN.test(receipt.action)
26
+ && ['pending', 'succeeded', 'rejected', 'uncertain'].includes(String(receipt.status))
27
+ && typeof receipt.createdAt === 'string' && typeof receipt.updatedAt === 'string';
28
+ }
@@ -0,0 +1,33 @@
1
+ /** Classification, not confidentiality: every enabled endpoint codec is JSON.
2
+ * Unknown fields are private content, including inside an `envelope`. Adding a
3
+ * lane requires review; adding content to an existing schema does not make it
4
+ * public. These definitions refer to existing payload schemas, not copies. */
5
+ export type EndpointContentClass = 'public-metadata' | 'private-content' | 'restricted-secret';
6
+ export type EndpointContentLane = 'operation.request' | 'operation.receipt' | 'query.request' | 'query.result' | 'ephemeral.request' | 'ephemeral.result' | 'event';
7
+ export declare const ENDPOINT_CONTENT_ACTIONS: readonly ["initialize_endpoint", "sync_phone_contacts", "request_work_session", "resolve_work_session_request", "update_agent_behavior_policy", "update_conversation_agent_behavior_policy", "send_message", "delete_message", "forward_message", "react", "verb_execute", "send_contextual_message", "create_interaction", "consume_interaction", "start_conversation", "create_conversation", "add_member", "remove_member", "leave_conversation", "update_member_role", "update_conversation", "rename_conversation", "update_topic", "update_group_avatar", "mark_read", "cancel_queued_message", "set_conversation_preferences", "set_block", "save_contact", "remove_contact", "respond_interaction", "session_control", "approve_contact_request", "reject_contact_request", "cancel_direct_request", "update_message_disposition", "create_upload", "finalize_upload", "delete_upload", "retry_video_processing", "finalize_memo_stream", "abort_memo_stream", "create_voice_session", "join_voice_session", "decline_voice_session", "end_voice_session", "consume_runtime_signal", "generate_image"];
8
+ export declare const ENDPOINT_CONTENT_FUNCTION_QUERIES: readonly ["discover_agents", "get_unread_summary", "peek_runtime_signal", "get_work_session_catalog", "get_work_session_request", "get_agent_behavior_policy", "get_conversation_agent_behavior_policy", "get_ephemeral", "list_voice_sessions", "list_contact_requests", "list_conversations", "list_messages", "get_messages", "admission_fences", "list_contacts", "get_contact", "get_members", "resolve_admission", "resolve_group_admission", "get_runtime_card_state", "conversation", "conversation_changes", "get_upload", "get_voice_session", "get_image_generation", "get_image_generation_status"];
9
+ export declare const ENDPOINT_CONTENT_QUERIES: readonly ["discover_agents", "get_unread_summary", "peek_runtime_signal", "get_work_session_catalog", "get_work_session_request", "get_agent_behavior_policy", "get_conversation_agent_behavior_policy", "get_ephemeral", "list_voice_sessions", "list_contact_requests", "list_conversations", "list_messages", "get_messages", "admission_fences", "list_contacts", "get_contact", "get_members", "resolve_admission", "resolve_group_admission", "get_runtime_card_state", "conversation", "conversation_changes", "get_upload", "get_voice_session", "get_image_generation", "get_image_generation_status", "admit_messages"];
10
+ export declare const ENDPOINT_CONTENT_EPHEMERALS: readonly ["create_memo_stream", "publish_presence", "publish_typing", "publish_streaming", "publish_turn", "publish_runtime_status", "append_memo_chunk"];
11
+ export declare const ENDPOINT_CONTENT_EPHEMERAL_FAMILIES: readonly ["typing", "streaming", "turn-state", "agent-session", "runtime-card-state", "memo-streams", "runtime-info", "runtime-activity", "runtime-suppression", "runtime-silence", "runtime-attention", "call-attention", "agent-runtime", "presence"];
12
+ export declare const ENDPOINT_CONTENT_EVENTS: readonly ["connected", "heartbeat", "agent.context", "replay.expired", "message.created", "message.snapshot", "message.updated", "message.deleted", "message.media_terminal", "conversation.updated", "conversation.changed", "conversation.removed", "conversations.changed", "contacts.changed", "contact.added", "contact.updated", "contact.removed", "presence", "typing", "runtime.updated", "runtime.control", "turn.updated", "voice.session.started", "voice.session.ended", ...string[]];
13
+ export declare class EndpointContentPolicyError extends Error {
14
+ readonly code = "ENDPOINT_CONTENT_UNCLASSIFIED";
15
+ constructor(lane: string, name: string);
16
+ }
17
+ export interface EndpointContentBoundary {
18
+ readonly lane: EndpointContentLane;
19
+ readonly name: string;
20
+ readonly defaultClass: 'private-content';
21
+ /** Paths address the actual payload. Query/result/event paths are relative to
22
+ * their parameters/result/data; operation paths include body.value. */
23
+ classify(path: readonly string[]): EndpointContentClass;
24
+ }
25
+ /** Local journal policy is separate from wire classification. Input answers and
26
+ * card values can carry passwords even when the response omits the original
27
+ * request's secret/sudo/field metadata. Only reviewed response shapes without
28
+ * those value bags may be retried from durable storage. Unknown response fields
29
+ * fail closed; a caller's `sensitive: false` cannot weaken this policy. */
30
+ export declare function endpointOperationRequestRetention(action: string, body: Record<string, unknown>, envelope?: Record<string, unknown>): 'journal' | 'memory';
31
+ /** Validate the lane before accepting/transmitting its payload. No body values
32
+ * are logged, copied into metadata, or claimed to be encrypted by this API. */
33
+ export declare function endpointContentBoundary(lane: EndpointContentLane, name: string, payload?: unknown): EndpointContentBoundary;
@@ -0,0 +1,263 @@
1
+ import { VERB_WIRE_ENVELOPE_FIELDS } from './verbWire.js';
2
+ export const ENDPOINT_CONTENT_ACTIONS = [
3
+ 'initialize_endpoint',
4
+ 'sync_phone_contacts', 'request_work_session', 'resolve_work_session_request',
5
+ 'update_agent_behavior_policy', 'update_conversation_agent_behavior_policy',
6
+ 'send_message', 'delete_message', 'forward_message', 'react', 'verb_execute',
7
+ 'send_contextual_message', 'create_interaction', 'consume_interaction',
8
+ 'start_conversation', 'create_conversation', 'add_member', 'remove_member',
9
+ 'leave_conversation', 'update_member_role', 'update_conversation', 'rename_conversation',
10
+ 'update_topic', 'update_group_avatar', 'mark_read', 'cancel_queued_message',
11
+ 'set_conversation_preferences', 'set_block', 'save_contact', 'remove_contact',
12
+ 'respond_interaction', 'session_control', 'approve_contact_request',
13
+ 'reject_contact_request', 'cancel_direct_request', 'update_message_disposition',
14
+ 'create_upload', 'finalize_upload', 'delete_upload', 'retry_video_processing',
15
+ 'finalize_memo_stream', 'abort_memo_stream', 'create_voice_session',
16
+ 'join_voice_session', 'decline_voice_session', 'end_voice_session',
17
+ 'consume_runtime_signal', 'generate_image',
18
+ ];
19
+ export const ENDPOINT_CONTENT_FUNCTION_QUERIES = [
20
+ 'discover_agents', 'get_unread_summary', 'peek_runtime_signal',
21
+ 'get_work_session_catalog', 'get_work_session_request', 'get_agent_behavior_policy',
22
+ 'get_conversation_agent_behavior_policy', 'get_ephemeral', 'list_voice_sessions',
23
+ 'list_contact_requests', 'list_conversations', 'list_messages', 'get_messages',
24
+ 'admission_fences', 'list_contacts', 'get_contact', 'get_members', 'resolve_admission',
25
+ 'resolve_group_admission', 'get_runtime_card_state', 'conversation',
26
+ 'conversation_changes', 'get_upload', 'get_voice_session', 'get_image_generation',
27
+ 'get_image_generation_status',
28
+ ];
29
+ export const ENDPOINT_CONTENT_QUERIES = [...ENDPOINT_CONTENT_FUNCTION_QUERIES, 'admit_messages'];
30
+ export const ENDPOINT_CONTENT_EPHEMERALS = [
31
+ 'create_memo_stream', 'publish_presence', 'publish_typing', 'publish_streaming',
32
+ 'publish_turn', 'publish_runtime_status', 'append_memo_chunk',
33
+ ];
34
+ export const ENDPOINT_CONTENT_EPHEMERAL_FAMILIES = [
35
+ 'typing', 'streaming', 'turn-state', 'agent-session', 'runtime-card-state',
36
+ 'memo-streams', 'runtime-info', 'runtime-activity', 'runtime-suppression',
37
+ 'runtime-silence', 'runtime-attention', 'call-attention', 'agent-runtime', 'presence',
38
+ ];
39
+ export const ENDPOINT_CONTENT_EVENTS = [
40
+ 'connected', 'heartbeat', 'agent.context', 'replay.expired',
41
+ 'message.created', 'message.snapshot', 'message.updated', 'message.deleted',
42
+ 'message.media_terminal', 'conversation.updated', 'conversation.changed',
43
+ 'conversation.removed', 'conversations.changed', 'contacts.changed',
44
+ 'contact.added', 'contact.updated', 'contact.removed', 'presence', 'typing',
45
+ 'runtime.updated', 'runtime.control', 'turn.updated',
46
+ 'voice.session.started', 'voice.session.ended',
47
+ ...ENDPOINT_CONTENT_EPHEMERAL_FAMILIES.map((family) => `ephemeral.${family}`),
48
+ ];
49
+ export class EndpointContentPolicyError extends Error {
50
+ code = 'ENDPOINT_CONTENT_UNCLASSIFIED';
51
+ constructor(lane, name) {
52
+ super(`Unclassified endpoint lane: ${lane}/${name}`);
53
+ this.name = 'EndpointContentPolicyError';
54
+ }
55
+ }
56
+ const namesForLane = {
57
+ 'operation.request': ENDPOINT_CONTENT_ACTIONS,
58
+ 'operation.receipt': ENDPOINT_CONTENT_ACTIONS,
59
+ 'query.request': ENDPOINT_CONTENT_QUERIES,
60
+ 'query.result': ENDPOINT_CONTENT_QUERIES,
61
+ 'ephemeral.request': ENDPOINT_CONTENT_EPHEMERALS,
62
+ 'ephemeral.result': ENDPOINT_CONTENT_EPHEMERALS,
63
+ event: ENDPOINT_CONTENT_EVENTS,
64
+ };
65
+ /** An explicit allowlist of routing/authorization fields, not a rule that all
66
+ * fields named `id` are public. Free text, previews, labels, media descriptors,
67
+ * paths, provider configuration, native snapshots and arbitrary metadata stay
68
+ * private. IDs/cursors/timing still reveal a communication graph. */
69
+ const protocolPaths = [
70
+ 'protocol', 'operationId', 'installationId', 'action', 'ownershipVersion',
71
+ 'preconditions.conversationId', 'preconditions.membershipRevision',
72
+ 'preconditions.admissionId', 'preconditions.targetAdmissionId', 'body.encoding',
73
+ ];
74
+ // Fields are reviewed for a particular lane: e.g. an unknown `contactId` added
75
+ // to send_message is still private, despite being a routing ID elsewhere.
76
+ const actionRouting = {
77
+ send_message: ['conversationId', 'messageId', 'replyTo', 'mentions.*'],
78
+ delete_message: ['conversationId', 'messageId'], forward_message: ['sourceConversationId', 'targetConversationId', 'messageId'],
79
+ react: ['conversationId', 'messageId'], send_contextual_message: ['conversationId'],
80
+ create_interaction: ['conversationId', 'requestId', 'inputId', 'approvalId', 'cardId', 'planId', 'responseUserId'],
81
+ consume_interaction: ['conversationId', 'inputId', 'approvalId', 'cardId', 'planId'],
82
+ respond_interaction: ['conversationId', 'requestId'],
83
+ start_conversation: ['targetUserId'], create_conversation: ['memberIds.*'],
84
+ add_member: ['conversationId', 'userId', 'agentId'], remove_member: ['conversationId', 'userId', 'agentId', 'targetUserId'],
85
+ leave_conversation: ['conversationId'], update_member_role: ['conversationId', 'userId', 'targetUserId'],
86
+ update_conversation: ['conversationId'], rename_conversation: ['conversationId'], update_topic: ['conversationId'],
87
+ update_group_avatar: ['conversationId'], mark_read: ['conversationId'], cancel_queued_message: ['conversationId', 'messageId'],
88
+ set_conversation_preferences: ['conversationId'], set_block: ['targetUserId'], save_contact: ['contactUserId'], remove_contact: ['contactId', 'contactUserId'],
89
+ session_control: ['conversationId', 'agentId'], approve_contact_request: ['requestId'], reject_contact_request: ['requestId'], cancel_direct_request: ['requestId'],
90
+ update_message_disposition: ['conversationId', 'messageId'], create_upload: ['conversationId'], finalize_upload: ['uploadId'], delete_upload: ['uploadId'], retry_video_processing: ['uploadId'],
91
+ finalize_memo_stream: ['conversationId', 'streamId'], abort_memo_stream: ['streamId'],
92
+ create_voice_session: ['conversationId'], join_voice_session: ['conversationId', 'sessionId'], decline_voice_session: ['conversationId', 'sessionId'], end_voice_session: ['conversationId', 'sessionId'],
93
+ consume_runtime_signal: ['conversationId', 'expectedSignalId'], generate_image: ['conversationId', 'requestId'],
94
+ request_work_session: ['conversationId', 'agentId', 'requestId'], resolve_work_session_request: ['requestId'],
95
+ update_agent_behavior_policy: ['agentId'], update_conversation_agent_behavior_policy: ['conversationId', 'agentId'],
96
+ };
97
+ const queryRouting = {
98
+ admit_messages: ['conversationId', 'messageIds.*', 'admissionId'], conversation_changes: ['conversationId', 'cursor', 'limit'],
99
+ conversation: ['conversationId'], list_conversations: ['limit', 'before'], list_messages: ['conversationId', 'before', 'limit'], get_messages: ['conversationId', 'messageIds.*'],
100
+ list_contacts: ['limit', 'before'], get_contact: ['contactId', 'contactUserId', 'userId'], get_members: ['conversationId'],
101
+ list_contact_requests: ['direction', 'limit', 'before'], admission_fences: ['conversationId', 'targetUserId'],
102
+ resolve_admission: ['targetUserId'], resolve_group_admission: ['conversationId', 'targetUserId'], get_runtime_card_state: ['conversationId', 'cardId'],
103
+ get_ephemeral: ['family', 'scopeId'], peek_runtime_signal: ['conversationId'], list_voice_sessions: ['conversationId', 'limit'],
104
+ get_upload: ['uploadId'], get_voice_session: ['conversationId', 'sessionId'], get_image_generation: ['requestId'],
105
+ get_work_session_catalog: ['agentId'], get_work_session_request: ['requestId'], get_agent_behavior_policy: ['agentId'], get_conversation_agent_behavior_policy: ['conversationId', 'agentId'],
106
+ };
107
+ const transientRouting = {
108
+ create_memo_stream: ['conversationId', 'streamId'], append_memo_chunk: ['streamId'],
109
+ publish_presence: [], publish_typing: ['conversationId'], publish_streaming: ['conversationId'], publish_turn: ['conversationId'], publish_runtime_status: ['conversationId'],
110
+ };
111
+ function eventRouting(name) {
112
+ if (name.startsWith('ephemeral.'))
113
+ return ['conversationId', 'scopeId'];
114
+ if (name === 'connected')
115
+ return ['protocol', 'connectionId', 'agentId', 'principalId', 'principalType'];
116
+ if (name === 'heartbeat')
117
+ return ['timestamp'];
118
+ if (name === 'agent.context')
119
+ return ['agentId', 'ownerId', 'ownershipVersion'];
120
+ if (name === 'conversations.changed')
121
+ return ['conversationIds.*', 'removedConversationIds.*'];
122
+ if (name.startsWith('message.'))
123
+ return ['conversationId', 'messageId', 'eventId', 'id', 'message.id', 'message.conversationId', 'message.senderId', 'message.senderType', 'message.createdAt', 'message.replyTo', 'message.mentions.*'];
124
+ if (name.startsWith('conversation.'))
125
+ return ['conversationId'];
126
+ if (name.startsWith('contact.'))
127
+ return ['contactId'];
128
+ if (name.startsWith('voice.session.'))
129
+ return ['conversationId', 'sessionId'];
130
+ if (/^(runtime\.|turn\.|typing$|presence$)/.test(name))
131
+ return ['conversationId', 'agentId', 'userId', 'memberId'];
132
+ return [];
133
+ }
134
+ const secretSegments = new Set(['token', 'accessToken', 'refreshToken', 'uploadUrl', 'signedUrl', 'livekitToken', 'replyAuthority', 'credentials', 'password', 'secret', 'authorization', 'answer', 'answers', 'response', 'submittedValue']);
135
+ const matches = (path, pattern) => {
136
+ const parts = pattern.split('.');
137
+ return parts.length === path.length && parts.every((part, index) => part === path[index] || part === '*' && /^\d+$/.test(path[index]));
138
+ };
139
+ const publicMatch = (path, patterns) => patterns.some((pattern) => matches(path, pattern));
140
+ /** Local journal policy is separate from wire classification. Input answers and
141
+ * card values can carry passwords even when the response omits the original
142
+ * request's secret/sudo/field metadata. Only reviewed response shapes without
143
+ * those value bags may be retried from durable storage. Unknown response fields
144
+ * fail closed; a caller's `sensitive: false` cannot weaken this policy. */
145
+ export function endpointOperationRequestRetention(action, body, envelope) {
146
+ if (action !== 'respond_interaction')
147
+ return 'journal';
148
+ // Interaction services use the body exclusively. Unknown envelope additions
149
+ // must not bypass retention, including conflicting fields rejected by server.
150
+ if (envelope && Object.keys(envelope).length > 0)
151
+ return 'memory';
152
+ const routing = ['kind', 'conversationId', 'agentId', 'requestId'];
153
+ const hasOnly = (fields) => Object.keys(body).every((field) => fields.includes(field));
154
+ if (routing.some((field) => body[field] !== undefined && typeof body[field] !== 'string'))
155
+ return 'memory';
156
+ if ((body.kind === 'input' || body.kind === 'card') && hasOnly([...routing, 'status', 'actionId'])) {
157
+ if (!['submitted', 'cancelled'].includes(String(body.status)) || (body.actionId !== undefined && typeof body.actionId !== 'string'))
158
+ return 'memory';
159
+ // A card button or cancellation with no submitted values contains no answer
160
+ // plaintext. Input/card requests with value, answers or values stay in RAM,
161
+ // including non-secret clarification: that distinction is absent on wire.
162
+ return 'journal';
163
+ }
164
+ if (body.kind === 'approval' && hasOnly([...routing, 'decision', 'sessionRule'])) {
165
+ if (body.decision !== 'allow' && body.decision !== 'deny')
166
+ return 'memory';
167
+ const rule = body.sessionRule;
168
+ if (rule !== undefined && (!rule || typeof rule !== 'object' || Array.isArray(rule)
169
+ || !Object.entries(rule).every(([field, value]) => ['type', 'toolPattern', 'expiresAt'].includes(field)
170
+ && (typeof value === 'string' || field === 'expiresAt' && value === null))))
171
+ return 'memory';
172
+ return 'journal';
173
+ }
174
+ if (body.kind === 'plan' && hasOnly([...routing, 'decision', 'feedback', 'grantedPrompts'])) {
175
+ if (!['approve', 'revise', 'reject'].includes(String(body.decision)) || body.feedback !== undefined && typeof body.feedback !== 'string')
176
+ return 'memory';
177
+ // Plan feedback and granted tool prompts are ordinary authored instructions,
178
+ // not secret-entry fields. Preserve their established offline semantics.
179
+ const prompts = body.grantedPrompts;
180
+ if (prompts !== undefined && (!Array.isArray(prompts) || prompts.some((prompt) => !prompt || typeof prompt !== 'object'
181
+ || Array.isArray(prompt) || !Object.entries(prompt).every(([field, value]) => ['tool', 'prompt'].includes(field) && typeof value === 'string'))))
182
+ return 'memory';
183
+ return 'journal';
184
+ }
185
+ return 'memory';
186
+ }
187
+ /** Validate the lane before accepting/transmitting its payload. No body values
188
+ * are logged, copied into metadata, or claimed to be encrypted by this API. */
189
+ export function endpointContentBoundary(lane, name, payload) {
190
+ if (!namesForLane[lane]?.includes(name))
191
+ throw new EndpointContentPolicyError(lane, name);
192
+ const record = payload && typeof payload === 'object' ? payload : undefined;
193
+ if (lane === 'query.request' && name === 'get_ephemeral' && record && !ENDPOINT_CONTENT_EPHEMERAL_FAMILIES.includes(record.family)) {
194
+ throw new EndpointContentPolicyError(lane, `${name}/unknown-family`);
195
+ }
196
+ const requestRoutingPaths = (actionRouting[name] ?? []).flatMap((field) => [`body.value.${field}`, `envelope.${field}`]);
197
+ const classify = (path) => {
198
+ if (path.some((part) => secretSegments.has(part)))
199
+ return 'restricted-secret';
200
+ if (lane === 'operation.request' && ['respond_interaction', 'sync_phone_contacts'].includes(name) && path[0] === 'body' && path[1] === 'value' && !publicMatch(path, requestRoutingPaths))
201
+ return 'restricted-secret';
202
+ if (lane === 'operation.receipt' && path[0] === 'result' && (['consume_interaction', 'respond_interaction'].includes(name) || record?.resultReference?.kind === 'interaction'))
203
+ return 'restricted-secret';
204
+ let selected = payload;
205
+ for (const part of path)
206
+ selected = selected && typeof selected === 'object' && Object.hasOwn(selected, part) ? selected[part] : undefined;
207
+ // An allowed scalar path cannot make arbitrary nested content public.
208
+ if (selected !== null && typeof selected === 'object')
209
+ return 'private-content';
210
+ if (lane === 'operation.request') {
211
+ if (publicMatch(path, protocolPaths) || publicMatch(path, requestRoutingPaths))
212
+ return 'public-metadata';
213
+ // All response content is treated as restricted, even for a non-secret
214
+ // interaction; classification cannot depend on a stale local kind flag.
215
+ if (name === 'respond_interaction' && path[0] === 'body' && path[1] === 'value')
216
+ return 'restricted-secret';
217
+ if (name === 'sync_phone_contacts' && path[0] === 'body' && path[1] === 'value')
218
+ return 'restricted-secret';
219
+ if (name === 'verb_execute') {
220
+ const wire = record?.body?.value;
221
+ const spec = VERB_WIRE_ENVELOPE_FIELDS[wire?.verb];
222
+ if (path[0] === 'body' && path[1] === 'value') {
223
+ if (path.length === 3 && ['wire', 'verb'].includes(path[2]))
224
+ return 'public-metadata';
225
+ if (path[2] === 'envelope' && spec) {
226
+ const field = path[3];
227
+ const allowed = [...spec.required, ...spec.optional];
228
+ // Reuse verbWire's existing split. Nested metadata is deliberately
229
+ // conservative: only declared scalar fields and numeric array IDs.
230
+ if (field && allowed.includes(field) && (path.length === 4 || path.length === 5 && ['mentions', 'memberIds'].includes(field) && /^\d+$/.test(path[4])))
231
+ return 'public-metadata';
232
+ }
233
+ }
234
+ }
235
+ }
236
+ else if (lane === 'operation.receipt') {
237
+ if (publicMatch(path, [...protocolPaths, 'status', 'createdAt', 'updatedAt', 'error.code', 'error.status', 'error.retryable', 'error.retryAfterMs', 'resultError.code', 'resultError.status', 'resultReference.kind', 'resultReference.conversationId', 'resultReference.uploadId', 'resultReference.sessionId', 'resultReference.requestId']))
238
+ return 'public-metadata';
239
+ if (path[0] === 'result' && (name === 'consume_interaction' || name === 'respond_interaction' || record?.resultReference?.kind === 'interaction'))
240
+ return 'restricted-secret';
241
+ }
242
+ else if (lane === 'query.request') {
243
+ if (publicMatch(path, queryRouting[name] ?? []))
244
+ return 'public-metadata';
245
+ }
246
+ else if (lane === 'query.result' || lane === 'ephemeral.result') {
247
+ if (name === 'conversation_changes' && publicMatch(path, ['cursor', 'hasMore', 'reset', 'admissionId']))
248
+ return 'public-metadata';
249
+ if (name === 'get_ephemeral' && path[0] === 'value')
250
+ return 'private-content';
251
+ }
252
+ else if (lane === 'ephemeral.request') {
253
+ if (publicMatch(path, transientRouting[name] ?? []))
254
+ return 'public-metadata';
255
+ }
256
+ else if (lane === 'event') {
257
+ if (publicMatch(path, eventRouting(name)))
258
+ return 'public-metadata';
259
+ }
260
+ return 'private-content';
261
+ };
262
+ return Object.freeze({ lane, name, defaultClass: 'private-content', classify });
263
+ }
package/dist/index.d.ts CHANGED
@@ -22,3 +22,7 @@ export * from './replyAuthority.js';
22
22
  export * from './runtimeDescriptor.js';
23
23
  export * from './workSessions.js';
24
24
  export * from './conversationMembership.js';
25
+ export * from './endpoint.js';
26
+ export * from './endpointContentPolicy.js';
27
+ export * from './plaintextProcessors.js';
28
+ export { computeEndpointUnreadProjection } from './endpoint-unread.js';
package/dist/index.js CHANGED
@@ -22,3 +22,7 @@ export * from './replyAuthority.js';
22
22
  export * from './runtimeDescriptor.js';
23
23
  export * from './workSessions.js';
24
24
  export * from './conversationMembership.js';
25
+ export * from './endpoint.js';
26
+ export * from './endpointContentPolicy.js';
27
+ export * from './plaintextProcessors.js';
28
+ export { computeEndpointUnreadProjection } from './endpoint-unread.js';
@@ -0,0 +1,55 @@
1
+ /** Existing server content readers. Classification is not encryption: these
2
+ * processors intentionally inspect plaintext in the current JSON release.
3
+ * Their input boundary must be redesigned before enabling encrypted rooms. */
4
+ export declare const PLAINTEXT_PROCESSORS: {
5
+ readonly preview: {
6
+ readonly current: "Read message/display text to derive conversation and notification previews.";
7
+ readonly beforeMls: "Derive on authorized endpoints; clear/reconcile deleted-message previews there.";
8
+ };
9
+ readonly push: {
10
+ readonly current: "Send visible titles and message previews to FCM/APNs.";
11
+ readonly beforeMls: "Use generic wake notifications or endpoint-decrypted notification extensions.";
12
+ };
13
+ readonly topic: {
14
+ readonly current: "Derive the initial topic from message text.";
15
+ readonly beforeMls: "Derive on an authorized endpoint and publish as encrypted conversation content.";
16
+ };
17
+ readonly forward: {
18
+ readonly current: "Read source content and copy plaintext attachments into the target room.";
19
+ readonly beforeMls: "Decrypt and re-encrypt on an authorized endpoint; keep server target admission checks.";
20
+ };
21
+ readonly message_validation: {
22
+ readonly current: "Inspect text, attachment descriptors and rich content before storing messages.";
23
+ readonly beforeMls: "Validate decrypted content on endpoints; retain bounded ciphertext/envelope checks on the server.";
24
+ };
25
+ readonly interaction_validation: {
26
+ readonly current: "Validate card/input/approval/plan render contracts and submitted responses.";
27
+ readonly beforeMls: "Validate content on authorized requesting/responding endpoints; retain public admission and lifecycle checks.";
28
+ };
29
+ readonly image_generation: {
30
+ readonly current: "Send prompts/source image bytes to Vertex and persist generated output.";
31
+ readonly beforeMls: "Require explicit provider disclosure and an authorized endpoint/processing participant; no silent server decryption.";
32
+ };
33
+ readonly video_processing: {
34
+ readonly current: "Download plaintext video, normalize it and create a poster.";
35
+ readonly beforeMls: "Process on an endpoint or an explicitly authorized processing participant before encrypted upload.";
36
+ };
37
+ readonly owner_observation: {
38
+ readonly current: "Allow authenticated owners to inspect managed agent conversations without room membership.";
39
+ readonly beforeMls: "Explicitly enroll authorized owner keyholders or retire content observation; ownership alone supplies no decryption keys.";
40
+ };
41
+ readonly moderation_evidence: {
42
+ readonly current: "Accept user-selected plaintext message evidence for moderation.";
43
+ readonly beforeMls: "Design explicit selective disclosure/authenticity (including franking policy); never infer access to room keys.";
44
+ };
45
+ };
46
+ export type PlaintextProcessorName = keyof typeof PLAINTEXT_PROCESSORS;
47
+ export interface PlaintextProcessorInput<T> {
48
+ encoding: 'json';
49
+ value: T;
50
+ }
51
+ /** The callback is the actual content processor, not a telemetry hook. Keeping
52
+ * the codec explicit ensures an opaque body cannot accidentally be processed by
53
+ * the JSON path. This is not authorization; callers still enforce their domain
54
+ * policy and every enabled transport currently requires JSON. */
55
+ export declare function runPlaintextProcessor<T, R>(name: PlaintextProcessorName, input: PlaintextProcessorInput<T>, process: (value: T) => R): R;
@@ -0,0 +1,25 @@
1
+ /** Existing server content readers. Classification is not encryption: these
2
+ * processors intentionally inspect plaintext in the current JSON release.
3
+ * Their input boundary must be redesigned before enabling encrypted rooms. */
4
+ export const PLAINTEXT_PROCESSORS = {
5
+ preview: { current: 'Read message/display text to derive conversation and notification previews.', beforeMls: 'Derive on authorized endpoints; clear/reconcile deleted-message previews there.' },
6
+ push: { current: 'Send visible titles and message previews to FCM/APNs.', beforeMls: 'Use generic wake notifications or endpoint-decrypted notification extensions.' },
7
+ topic: { current: 'Derive the initial topic from message text.', beforeMls: 'Derive on an authorized endpoint and publish as encrypted conversation content.' },
8
+ forward: { current: 'Read source content and copy plaintext attachments into the target room.', beforeMls: 'Decrypt and re-encrypt on an authorized endpoint; keep server target admission checks.' },
9
+ message_validation: { current: 'Inspect text, attachment descriptors and rich content before storing messages.', beforeMls: 'Validate decrypted content on endpoints; retain bounded ciphertext/envelope checks on the server.' },
10
+ interaction_validation: { current: 'Validate card/input/approval/plan render contracts and submitted responses.', beforeMls: 'Validate content on authorized requesting/responding endpoints; retain public admission and lifecycle checks.' },
11
+ image_generation: { current: 'Send prompts/source image bytes to Vertex and persist generated output.', beforeMls: 'Require explicit provider disclosure and an authorized endpoint/processing participant; no silent server decryption.' },
12
+ video_processing: { current: 'Download plaintext video, normalize it and create a poster.', beforeMls: 'Process on an endpoint or an explicitly authorized processing participant before encrypted upload.' },
13
+ owner_observation: { current: 'Allow authenticated owners to inspect managed agent conversations without room membership.', beforeMls: 'Explicitly enroll authorized owner keyholders or retire content observation; ownership alone supplies no decryption keys.' },
14
+ moderation_evidence: { current: 'Accept user-selected plaintext message evidence for moderation.', beforeMls: 'Design explicit selective disclosure/authenticity (including franking policy); never infer access to room keys.' },
15
+ };
16
+ /** The callback is the actual content processor, not a telemetry hook. Keeping
17
+ * the codec explicit ensures an opaque body cannot accidentally be processed by
18
+ * the JSON path. This is not authorization; callers still enforce their domain
19
+ * policy and every enabled transport currently requires JSON. */
20
+ export function runPlaintextProcessor(name, input, process) {
21
+ if (!Object.hasOwn(PLAINTEXT_PROCESSORS, name) || input.encoding !== 'json') {
22
+ throw new Error('PLAINTEXT_PROCESSOR_CODEC_REQUIRED');
23
+ }
24
+ return process(input.value);
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/backend-contracts",
3
- "version": "8.5.0",
3
+ "version": "8.6.0",
4
4
  "description": "Canon backend contract helpers shared by Functions and stream-service",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "devDependencies": {
41
- "@canonmsg/rich-cards": "^0.10.5",
41
+ "@canonmsg/rich-cards": "^0.10.6",
42
42
  "@types/node": "^22.0.0",
43
43
  "ajv": "^8.20.0",
44
44
  "typescript": "~5.7.0",