@abot-ai/socket 0.0.0-stage → 0.2.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +129 -2
  4. package/bin/abot-socket.mjs +15 -0
  5. package/config.example.json +14 -0
  6. package/docs/APP-REVIEW.md +184 -0
  7. package/docs/DEPLOYMENT.md +124 -0
  8. package/docs/INSTALLATION.md +177 -0
  9. package/docs/MIGRATION.md +84 -0
  10. package/docs/PROTOCOL.md +371 -0
  11. package/docs/SECURITY.md +116 -0
  12. package/docs/SELF-HOSTING.md +161 -0
  13. package/package.json +65 -4
  14. package/src/auth/auth-database.mjs +105 -0
  15. package/src/auth/auth-limits.mjs +17 -0
  16. package/src/auth/auth-pairing.mjs +95 -0
  17. package/src/auth/auth-sessions.mjs +119 -0
  18. package/src/auth/auth-storage-path.mjs +70 -0
  19. package/src/auth/auth.mjs +45 -0
  20. package/src/auth/runtime-credentials.mjs +39 -0
  21. package/src/cli/cli-arguments.mjs +23 -0
  22. package/src/cli/cli-entry.mjs +84 -0
  23. package/src/cli/cli-environment.mjs +31 -0
  24. package/src/cli/cli-help.mjs +34 -0
  25. package/src/cli/cli-output.mjs +35 -0
  26. package/src/cli/cli.mjs +58 -0
  27. package/src/cli/client-capabilities.mjs +14 -0
  28. package/src/cli/doctor.mjs +106 -0
  29. package/src/config/config-validation.mjs +41 -0
  30. package/src/config/config.mjs +57 -0
  31. package/src/deployment/deployment-command.mjs +42 -0
  32. package/src/deployment/deployment-input.mjs +76 -0
  33. package/src/deployment/deployment-plan.mjs +62 -0
  34. package/src/deployment/deployment-templates.mjs +109 -0
  35. package/src/main.mjs +26 -0
  36. package/src/notifications/notification-delivery-credentials.mjs +48 -0
  37. package/src/notifications/notification-delivery-fcm.mjs +100 -0
  38. package/src/notifications/notification-delivery-gateway.mjs +36 -0
  39. package/src/notifications/notification-delivery-http.mjs +53 -0
  40. package/src/notifications/notification-delivery-payload.mjs +45 -0
  41. package/src/notifications/notification-delivery.mjs +43 -0
  42. package/src/notifications/notification-gateway-command.mjs +48 -0
  43. package/src/notifications/notification-gateway-store.mjs +159 -0
  44. package/src/notifications/notification-gateway.mjs +181 -0
  45. package/src/notifications/notification-http.mjs +39 -0
  46. package/src/notifications/notification-input.mjs +82 -0
  47. package/src/notifications/notification-outbox.mjs +60 -0
  48. package/src/notifications/notification-preview-crypto.mjs +42 -0
  49. package/src/notifications/notification-preview-schema.mjs +7 -0
  50. package/src/notifications/notification-preview-store.mjs +63 -0
  51. package/src/notifications/notification-preview-text.mjs +20 -0
  52. package/src/notifications/notification-projection.mjs +20 -0
  53. package/src/notifications/notification-schema.mjs +22 -0
  54. package/src/notifications/notification-service.mjs +147 -0
  55. package/src/notifications/notification-store.mjs +132 -0
  56. package/src/pairing/pairing-qr-payload.mjs +32 -0
  57. package/src/pairing/pairing-qr.mjs +42 -0
  58. package/src/protocol/approval-protocol.mjs +39 -0
  59. package/src/protocol/protocol-validation.mjs +137 -0
  60. package/src/protocol/protocol.mjs +120 -0
  61. package/src/protocol/spark-chat-protocol.mjs +51 -0
  62. package/src/relay/relay-approvals.mjs +86 -0
  63. package/src/relay/relay-client-admission.mjs +40 -0
  64. package/src/relay/relay-correlations.mjs +101 -0
  65. package/src/relay/relay-model-catalog.mjs +69 -0
  66. package/src/relay/relay-request-adoption.mjs +97 -0
  67. package/src/relay/relay-requests.mjs +211 -0
  68. package/src/relay/relay-runtime-registration.mjs +49 -0
  69. package/src/relay/relay-session-mutations.mjs +122 -0
  70. package/src/relay/relay.mjs +222 -0
  71. package/src/relay/request-ownership-schema.mjs +18 -0
  72. package/src/relay/request-ownership-store.mjs +115 -0
  73. package/src/review/review-demo-isolation.mjs +132 -0
  74. package/src/review/review-demo-setup.mjs +97 -0
  75. package/src/review/review-portal-access.mjs +64 -0
  76. package/src/review/review-portal-form.mjs +24 -0
  77. package/src/review/review-portal-page.mjs +68 -0
  78. package/src/review/review-portal-server.mjs +105 -0
  79. package/src/setup/setup-files.mjs +37 -0
  80. package/src/setup/setup-questions.mjs +62 -0
  81. package/src/setup/setup-wizard.mjs +50 -0
  82. package/src/setup/setup.mjs +42 -0
  83. package/src/transport/http-api.mjs +32 -0
  84. package/src/transport/server.mjs +175 -0
  85. package/src/transport/transport-limits.mjs +85 -0
@@ -0,0 +1,137 @@
1
+ import { Buffer } from 'node:buffer';
2
+
3
+ export const MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
4
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
5
+ export const MAX_TEXT_BYTES = 128 * 1024;
6
+ const MAX_BASE64_LENGTH = 4 * Math.ceil(MAX_ATTACHMENT_BYTES / 3);
7
+ const MIME_TYPES = new Map([
8
+ ['image/png', 'image'], ['image/jpeg', 'image'], ['image/webp', 'image'],
9
+ ['application/pdf', 'file'],
10
+ ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'file'],
11
+ ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'file'],
12
+ ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'file'],
13
+ ['text/plain', 'file'], ['text/csv', 'file'], ['text/markdown', 'file'],
14
+ ['application/json', 'file'], ['application/rtf', 'file'],
15
+ ]);
16
+
17
+ export function fail(code = 'invalid_command') {
18
+ throw Object.assign(new Error(code), { code });
19
+ }
20
+
21
+ export function record(value, allowed) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail();
23
+ const prototype = Object.getPrototypeOf(value);
24
+ if (prototype !== Object.prototype && prototype !== null) fail();
25
+ const descriptors = Object.getOwnPropertyDescriptors(value);
26
+ if (Reflect.ownKeys(descriptors).some((key) => typeof key !== 'string')) fail();
27
+ for (const [key, descriptor] of Object.entries(descriptors)) {
28
+ if (!allowed.includes(key) || !descriptor.enumerable || !('value' in descriptor)) fail();
29
+ }
30
+ return value;
31
+ }
32
+
33
+ export function string(value, maximum, { nonempty = true, controls = false } = {}) {
34
+ if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > maximum) fail();
35
+ if (nonempty && !value.trim()) fail();
36
+ if (!controls && /[\u0000-\u001f\u007f]/u.test(value)) fail();
37
+ return value;
38
+ }
39
+
40
+ export function id(value) {
41
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/u.test(value)) fail();
42
+ return value;
43
+ }
44
+
45
+ export function approvalId(value) {
46
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_.:-]{1,512}$/u.test(value)) fail();
47
+ return value;
48
+ }
49
+
50
+ export function integer(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
51
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) fail();
52
+ return value;
53
+ }
54
+
55
+ export function boolean(value) {
56
+ if (typeof value !== 'boolean') fail();
57
+ return value;
58
+ }
59
+
60
+ export function optional(source, target, key, validate) {
61
+ if (Object.hasOwn(source, key)) target[key] = validate(source[key]);
62
+ }
63
+
64
+ export function mime(value) {
65
+ const normalized = string(value, 160).split(';', 1)[0].trim().toLowerCase();
66
+ if (!MIME_TYPES.has(normalized)) fail();
67
+ return normalized;
68
+ }
69
+
70
+ export function kind(value) {
71
+ if (value !== 'image' && value !== 'file') fail();
72
+ return value;
73
+ }
74
+
75
+ export function attachment(value) {
76
+ record(value, ['id', 'kind', 'mimeType', 'storageRef', 'name', 'size']);
77
+ const result = {
78
+ id: id(value.id), kind: kind(value.kind), mimeType: mime(value.mimeType),
79
+ storageRef: string(value.storageRef, 2048),
80
+ };
81
+ if (MIME_TYPES.get(result.mimeType) !== result.kind) fail();
82
+ optional(value, result, 'name', (name) => string(name, 1024));
83
+ optional(value, result, 'size', (size) => integer(size, 0, MAX_ATTACHMENT_BYTES));
84
+ return result;
85
+ }
86
+
87
+ export function attachments(value) {
88
+ if (!Array.isArray(value) || value.length > 32) fail();
89
+ // Sparse arrays and custom/accessor properties are not a JSON attachment list.
90
+ if (Object.getPrototypeOf(value) !== Array.prototype) fail();
91
+ const keys = Reflect.ownKeys(value);
92
+ if (keys.length !== value.length + 1) fail();
93
+ for (let index = 0; index < value.length; index += 1) {
94
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
95
+ if (!descriptor || !('value' in descriptor)) fail();
96
+ }
97
+ return Array.from(value, attachment);
98
+ }
99
+
100
+ export function base64(value) {
101
+ if (typeof value !== 'string' || !value || value.length > MAX_BASE64_LENGTH) fail();
102
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) fail();
103
+ const bytes = Buffer.from(value, 'base64');
104
+ if (bytes.length > MAX_ATTACHMENT_BYTES || bytes.toString('base64') !== value) fail();
105
+ return value;
106
+ }
107
+
108
+ export function modelPreference(value) {
109
+ record(value, ['profileId', 'scope']);
110
+ const result = { profileId: string(value.profileId, 256) };
111
+ optional(value, result, 'scope', (scope) => {
112
+ if (scope !== 'main' && scope !== 'all') fail();
113
+ return scope;
114
+ });
115
+ return result;
116
+ }
117
+
118
+ export function reasoningPreference(value) {
119
+ record(value, ['profileId', 'mode', 'budgetTokens']);
120
+ const result = { profileId: string(value.profileId, 256), mode: value.mode };
121
+ if (!['off', 'server-default', 'budget'].includes(value.mode)) fail();
122
+ if (value.mode !== 'budget' && Object.hasOwn(value, 'budgetTokens')) fail();
123
+ if (value.mode === 'budget') result.budgetTokens = integer(value.budgetTokens, 0, 2_147_483_647);
124
+ return result;
125
+ }
126
+
127
+ export function upload(value) {
128
+ const result = {
129
+ type: value.type, sessionId: id(value.sessionId), mimeType: mime(value.mimeType),
130
+ data: base64(value.data),
131
+ };
132
+ optional(value, result, 'requestId', id);
133
+ optional(value, result, 'kind', kind);
134
+ optional(value, result, 'name', (name) => string(name, 1024));
135
+ if (result.kind && MIME_TYPES.get(result.mimeType) !== result.kind) fail();
136
+ return result;
137
+ }
@@ -0,0 +1,120 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import {
3
+ MAX_MESSAGE_BYTES, MAX_TEXT_BYTES, fail, record, string, id, approvalId, integer,
4
+ boolean, optional, attachment, attachments, modelPreference, reasoningPreference, upload,
5
+ } from './protocol-validation.mjs';
6
+ import { APPROVAL_FIELDS, APPROVAL_RESPONSES, durableApprovalMode,
7
+ isApprovalCommand, normalizeApprovalCommand } from './approval-protocol.mjs';
8
+ import { SPARK_OPEN_REQUEST, SPARK_OPEN_RESPONSE } from './spark-chat-protocol.mjs';
9
+
10
+ export { MAX_MESSAGE_BYTES, MAX_ATTACHMENT_BYTES, MAX_TEXT_BYTES } from './protocol-validation.mjs';
11
+
12
+ const FIELDS = Object.freeze({
13
+ [SPARK_OPEN_REQUEST]: [],
14
+ run_request: ['requestId', 'sessionId', 'input', 'text', 'agentMode', 'modelPreference', 'reasoningPreference', 'toolPermissionMode', 'attachments', 'approvalMode'],
15
+ ...APPROVAL_FIELDS,
16
+ steer_request: ['requestId', 'steerId', 'text'],
17
+ tool_approval_response: ['approvalId', 'approved', 'reason'],
18
+ 'request.events.request': ['requestId', 'afterSeq'],
19
+ 'session.list.request': ['limit', 'cursor'],
20
+ 'session.snapshot.request': ['sessionId', 'afterMessageId', 'includeRequests'],
21
+ 'session.delete.request': ['sessionId'],
22
+ 'session.messages.clear.request': ['sessionId'],
23
+ 'session.message.delete.request': ['sessionId', 'messageId'],
24
+ 'attachment.upload.request': ['sessionId', 'requestId', 'mimeType', 'kind', 'name', 'data'],
25
+ 'attachment.fetch.request': ['sessionId', 'attachment'],
26
+ subscribe_request: ['requestId'],
27
+ });
28
+ const RESPONSES = Object.freeze({
29
+ [SPARK_OPEN_REQUEST]: SPARK_OPEN_RESPONSE,
30
+ ...APPROVAL_RESPONSES,
31
+ 'request.events.request': 'request.events.response',
32
+ 'session.list.request': 'session.list.response',
33
+ 'session.snapshot.request': 'session.snapshot.response',
34
+ 'session.delete.request': 'session.delete.response',
35
+ 'session.messages.clear.request': 'session.messages.clear.response',
36
+ 'session.message.delete.request': 'session.message.delete.response',
37
+ 'attachment.upload.request': 'attachment.upload.response',
38
+ 'attachment.fetch.request': 'attachment.fetch.response',
39
+ });
40
+ const MODES = ['ask', 'full_access', 'full_plus'];
41
+ const text = (value) => string(value, MAX_TEXT_BYTES, { controls: true });
42
+
43
+ export function expectedResponseType(type) {
44
+ return typeof type === 'string' && Object.hasOwn(RESPONSES, type) ? RESPONSES[type] : undefined;
45
+ }
46
+
47
+ function run(value, allowedModes) {
48
+ const result = { type: value.type, requestId: id(value.requestId), sessionId: id(value.sessionId) };
49
+ if (!Object.hasOwn(value, 'input') && !Object.hasOwn(value, 'text')) fail();
50
+ optional(value, result, 'input', text);
51
+ optional(value, result, 'text', text);
52
+ optional(value, result, 'agentMode', (mode) => {
53
+ if (!['fast', 'reasoning', 'deep'].includes(mode)) fail();
54
+ return mode;
55
+ });
56
+ optional(value, result, 'modelPreference', modelPreference);
57
+ optional(value, result, 'reasoningPreference', reasoningPreference);
58
+ optional(value, result, 'approvalMode', durableApprovalMode);
59
+ const mode = Object.hasOwn(value, 'toolPermissionMode') ? value.toolPermissionMode : 'ask';
60
+ if (!MODES.includes(mode)) fail();
61
+ if (!Array.isArray(allowedModes) || !allowedModes.includes(mode)) fail('forbidden');
62
+ result.toolPermissionMode = mode;
63
+ optional(value, result, 'attachments', attachments);
64
+ return result;
65
+ }
66
+
67
+ function normalize(value, allowedModes) {
68
+ if (isApprovalCommand(value.type)) return normalizeApprovalCommand(value);
69
+ const result = { type: value.type };
70
+ switch (value.type) {
71
+ case SPARK_OPEN_REQUEST: return result;
72
+ case 'run_request': return run(value, allowedModes);
73
+ case 'subscribe_request': return { ...result, requestId: id(value.requestId) };
74
+ case 'steer_request': return { ...result, requestId: id(value.requestId), steerId: id(value.steerId), text: text(value.text) };
75
+ case 'tool_approval_response':
76
+ result.approvalId = approvalId(value.approvalId);
77
+ result.approved = boolean(value.approved);
78
+ optional(value, result, 'reason', text);
79
+ return result;
80
+ case 'request.events.request':
81
+ result.requestId = id(value.requestId);
82
+ optional(value, result, 'afterSeq', (seq) => integer(seq));
83
+ return result;
84
+ case 'session.list.request':
85
+ optional(value, result, 'limit', (limit) => integer(limit, 1, 100));
86
+ optional(value, result, 'cursor', (cursor) => cursor === null ? null : string(cursor, 4096));
87
+ return result;
88
+ case 'session.snapshot.request':
89
+ result.sessionId = id(value.sessionId);
90
+ optional(value, result, 'afterMessageId', (cursor) => cursor === null ? null : typeof cursor === 'number' ? integer(cursor) : id(cursor));
91
+ optional(value, result, 'includeRequests', boolean);
92
+ return result;
93
+ case 'session.delete.request':
94
+ case 'session.messages.clear.request':
95
+ return { ...result, sessionId: id(value.sessionId) };
96
+ case 'session.message.delete.request':
97
+ return { ...result, sessionId: id(value.sessionId),
98
+ messageId: typeof value.messageId === 'number' ? String(integer(value.messageId)) : id(value.messageId) };
99
+ case 'attachment.upload.request': return upload(value);
100
+ case 'attachment.fetch.request': return { ...result, sessionId: id(value.sessionId), attachment: attachment(value.attachment) };
101
+ default: return fail('unsupported');
102
+ }
103
+ }
104
+
105
+ /** Validate client-owned fields only; the relay adds its private correlation ID. */
106
+ export function normalizeClientCommand(body, allowedModes = ['ask']) {
107
+ // Validate descriptors before reading even the command type.
108
+ if (!body || typeof body !== 'object' || Array.isArray(body)) fail();
109
+ const descriptor = Object.getOwnPropertyDescriptor(body, 'type');
110
+ if (!descriptor || !('value' in descriptor)) fail();
111
+ const type = string(descriptor.value, 80);
112
+ if (!Object.hasOwn(FIELDS, type)) {
113
+ if (/^(?:runtime\.|admin\.|cron\.|schedule\.|session\.(?:delete|messages\.clear|message\.delete)\.)/u.test(type)) fail('forbidden');
114
+ fail('unsupported');
115
+ }
116
+ record(body, ['type', ...FIELDS[type]]);
117
+ const result = normalize(body, allowedModes);
118
+ if (Buffer.byteLength(JSON.stringify(result), 'utf8') > MAX_MESSAGE_BYTES) fail();
119
+ return result;
120
+ }
@@ -0,0 +1,51 @@
1
+ import { id, string, fail } from './protocol-validation.mjs';
2
+
3
+ export const SPARK_SESSION_ID = 'spark-companion';
4
+ export const SPARK_CHAT_FEATURE = 'spark_chat_v1';
5
+ export const SPARK_OPEN_REQUEST = 'spark.chat.open.request';
6
+ export const SPARK_OPEN_RESPONSE = 'spark.chat.open.response';
7
+
8
+ const MODES = new Set(['ask', 'full_access', 'full_plus']);
9
+ const READINESS = new Set(['ready', 'model_required', 'model_unavailable']);
10
+ const ERRORS = new Set(['invalid_spark_chat_request', 'spark_chat_session_unavailable',
11
+ 'spark_chat_model_required', 'spark_chat_model_unavailable', 'spark_chat_permission_mismatch',
12
+ 'spark_chat_unsupported', 'spark_chat_unavailable']);
13
+
14
+ export function supportsSparkChat(runtime) {
15
+ if (runtime === undefined) return true;
16
+ if (runtime.online !== true) return false;
17
+ return Array.isArray(runtime.features) && runtime.features.includes(SPARK_CHAT_FEATURE);
18
+ }
19
+
20
+ function isSparkChatCommand(command) {
21
+ if (command.type === SPARK_OPEN_REQUEST) return true;
22
+ return command.type === 'run_request' && command.sessionId === SPARK_SESSION_ID;
23
+ }
24
+
25
+ /** The existing authenticated runtime grant remains the authority ceiling. */
26
+ export function assertSparkChatSupported(command, runtime) {
27
+ if (!isSparkChatCommand(command)) return;
28
+ if (!runtime.features.includes(SPARK_CHAT_FEATURE)) fail('spark_chat_unsupported');
29
+ }
30
+
31
+ function projectSparkChatState(body) {
32
+ if (body.sessionId !== SPARK_SESSION_ID) fail();
33
+ if (!MODES.has(body.toolPermissionMode) || !READINESS.has(body.readiness)) fail();
34
+ const modelProfileId = body.modelProfileId === null ? null : string(body.modelProfileId, 256);
35
+ if ((modelProfileId === null) !== (body.readiness === 'model_required')) fail();
36
+ return { sessionId: SPARK_SESSION_ID, modelProfileId,
37
+ toolPermissionMode: body.toolPermissionMode, readiness: body.readiness };
38
+ }
39
+
40
+ /** Only chat state crosses the relay; preferences and private diagnostics never do. */
41
+ export function projectSparkOpenResponse(body) {
42
+ try {
43
+ if (!body || body.type !== SPARK_OPEN_RESPONSE || typeof body.ok !== 'boolean') return null;
44
+ const result = { type: SPARK_OPEN_RESPONSE, correlationId: id(body.correlationId), ok: body.ok };
45
+ if (body.ok) return { ...result, ...projectSparkChatState(body) };
46
+ if (!ERRORS.has(body.error)) return null;
47
+ return { ...result, error: body.error };
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
@@ -0,0 +1,86 @@
1
+ import { expectedResponseType } from '../protocol/protocol.mjs';
2
+ import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
3
+ import { isRecord, isRelayId, relayError } from './relay-runtime-registration.mjs';
4
+ import { RelayRequestAdoption } from './relay-request-adoption.mjs';
5
+
6
+ function validWait(wait) {
7
+ return isRecord(wait) && isRelayId(wait.generation) && isRelayId(wait.waitId)
8
+ && Number.isSafeInteger(wait.revision) && wait.revision >= 0;
9
+ }
10
+
11
+ function validResponse(command, body) {
12
+ if (!body.ok) return typeof body.error === 'string' && body.error.length <= 256;
13
+ if (command.type === 'request.approval.decide.request') {
14
+ return isRecord(body.result) && typeof body.result.accepted === 'boolean'
15
+ && (body.result.duplicate === undefined || typeof body.result.duplicate === 'boolean')
16
+ && (body.result.reason === undefined || typeof body.result.reason === 'string');
17
+ }
18
+ if (!Array.isArray(body.approvals) || body.approvals.length > 64) return false;
19
+ const seen = new Set();
20
+ for (const approval of body.approvals) {
21
+ const request = approval?.request;
22
+ if (!isRecord(approval) || !isRecord(request) || request.requestId !== command.requestId) return false;
23
+ if (typeof request.approvalId !== 'string' || !request.approvalId || request.approvalId.length > 512) return false;
24
+ if (seen.has(request.approvalId)) return false;
25
+ seen.add(request.approvalId);
26
+ if (approval.wait !== undefined && !validWait(approval.wait)) return false;
27
+ }
28
+ if (body.lifecycle !== null && !isRecord(body.lifecycle)) return false;
29
+ if (body.lifecycle?.sessionId !== undefined && body.lifecycle.sessionId !== command.sessionId) return false;
30
+ if (body.lifecycle?.requestId !== undefined && body.lifecycle.requestId !== command.requestId) return false;
31
+ if (body.finalState === null) return true;
32
+ return isRecord(body.finalState) && ['completed', 'failed'].includes(body.finalState.status)
33
+ && body.approvals.length === 0;
34
+ }
35
+
36
+ /** An owner-scoped transport view. The runtime alone stores and decides approvals. */
37
+ export class RelayApprovals {
38
+ constructor({ ownership, requests, correlations, transmit, respond, error,
39
+ connected, currentRuntime, assertSessionAvailable }) {
40
+ Object.assign(this, { ownership, requests, correlations, transmit, respond, error });
41
+ this.adoption = new RelayRequestAdoption({ ownership, requests, correlations, transmit, error,
42
+ connected, currentRuntime, assertSessionAvailable,
43
+ recover: (client, runtime, id, command) => this.dispatch(client, runtime, id, command) });
44
+ }
45
+
46
+ owned(client, runtime, command) {
47
+ if (!this.ownership) throw relayError('approval_access_unavailable');
48
+ if (!runtime.features.includes('request_approvals_v1')) throw relayError('approvals_unsupported');
49
+ const owner = this.ownership.get(runtime.runtimeId, command.requestId);
50
+ if (!owner || owner.subject !== client.subject || owner.sessionId !== command.sessionId) {
51
+ throw relayError('request_not_owned');
52
+ }
53
+ return owner;
54
+ }
55
+
56
+ dispatch(client, runtime, id, command) {
57
+ if (this.adoption.beginIfUnowned(client, runtime, id, command)) return;
58
+ this.adoption.assertNoPendingRequest(runtime, command.requestId);
59
+ const owner = this.owned(client, runtime, command);
60
+ for (const entry of this.correlations.pending.values()) {
61
+ if (entry.runtime === runtime && entry.command.requestId === command.requestId
62
+ && isApprovalCommand(entry.command.type)) throw relayError('approval_request_pending');
63
+ }
64
+ let view;
65
+ const entry = this.correlations.create(client, runtime, id, command, expectedResponseType(command.type), {
66
+ matches: body => validResponse(command, body),
67
+ respond: (_entry, body) => {
68
+ if (!body.ok || body.result?.accepted === false) view.rollback();
69
+ else if (body.finalState) this.requests.markTerminal(view.record);
70
+ this.respond(client, runtime, id, body);
71
+ },
72
+ unknown: () => {
73
+ view.rollback();
74
+ this.error(client, runtime, id, 'outcome_unknown');
75
+ },
76
+ // A sent decision can still take effect after its client closes. Keep the
77
+ // mutation guard until its reply/deadline, but disconnect removes the view.
78
+ retainOnClientDisconnect: true,
79
+ });
80
+ try { view = this.requests.restore(client, runtime, owner); }
81
+ catch (error) { this.correlations.remove(entry); throw error; }
82
+ if (!this.transmit(runtime.peer, { ...command, correlationId: entry.correlationId })) {
83
+ this.correlations.fail(entry);
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,40 @@
1
+ import { isRecord, isRelayId, relayError } from './relay-runtime-registration.mjs';
2
+
3
+ const MAX_COMMANDS = 512;
4
+ const COMMANDS_PER_MINUTE = 60;
5
+
6
+ export function createRelayClient(peer, identity, now) {
7
+ if (!isRecord(identity) || typeof identity.subject !== 'string' || identity.subject.length > 320 || !identity.subject) {
8
+ throw relayError('invalid_client_identity');
9
+ }
10
+ if (typeof identity.sessionId !== 'string' || !identity.sessionId || identity.sessionId.length > 256) {
11
+ throw relayError('invalid_client_identity');
12
+ }
13
+ if (!Array.isArray(identity.allowedRuntimeIds) || identity.allowedRuntimeIds.length > 32) {
14
+ throw relayError('invalid_client_identity');
15
+ }
16
+ if (identity.allowedRuntimeIds.some(id => !isRelayId(id))) throw relayError('invalid_client_identity');
17
+ return {
18
+ peer, subject: identity.subject, sessionId: identity.sessionId,
19
+ allowedRuntimeIds: new Set(identity.allowedRuntimeIds), commands: new Set(),
20
+ rateStartedAt: now, rateCount: 0,
21
+ };
22
+ }
23
+
24
+ export function admitRelayCommand(client, message, now) {
25
+ // Charge failed commands too. Authentication and IP rate limits are transport-owned.
26
+ if (now - client.rateStartedAt >= 60_000) {
27
+ client.rateStartedAt = now;
28
+ client.rateCount = 0;
29
+ }
30
+ client.rateCount += 1;
31
+ if (client.rateCount > COMMANDS_PER_MINUTE) throw relayError('rate_limited');
32
+ if (!isRecord(message) || !isRelayId(message.id) || !isRelayId(message.runtimeId)) {
33
+ throw relayError('invalid_command_envelope');
34
+ }
35
+ if (!client.allowedRuntimeIds.has(message.runtimeId)) throw relayError('runtime_forbidden');
36
+ if (client.commands.has(message.id)) throw relayError('duplicate_command');
37
+ if (client.commands.size >= MAX_COMMANDS) throw relayError('command_capacity');
38
+ // IDs are not evicted while a connection is alive; a retry never re-dispatches.
39
+ client.commands.add(message.id);
40
+ }
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isRecord, relayError } from './relay-runtime-registration.mjs';
3
+ import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
4
+ import { SPARK_OPEN_REQUEST, projectSparkOpenResponse } from '../protocol/spark-chat-protocol.mjs';
5
+
6
+ const MAX_PENDING = 128;
7
+ const RPC_TIMEOUT_MS = 30_000;
8
+
9
+ function steeringKey(runtime, body) {
10
+ return JSON.stringify([runtime.generation, body.requestId, body.steerId]);
11
+ }
12
+
13
+ function matchesResponseIdentity(entry, body) {
14
+ const command = entry.command;
15
+ if (command.type === SPARK_OPEN_REQUEST) return projectSparkOpenResponse(body) !== null;
16
+ if (command.type === 'steer_request') {
17
+ return body.requestId === command.requestId && body.steerId === command.steerId
18
+ && typeof body.accepted === 'boolean';
19
+ }
20
+ if (typeof body.ok !== 'boolean') return false;
21
+ if ((command.type === 'request.events.request' || isApprovalCommand(command.type))
22
+ && body.requestId !== command.requestId) return false;
23
+ // Upload responses intentionally do not echo requestId in the existing runtime.
24
+ if (body.requestId !== undefined && body.requestId !== command.requestId) return false;
25
+ if (command.sessionId !== undefined && body.sessionId !== command.sessionId) return false;
26
+ if (body.sessionId !== undefined && command.sessionId === undefined) return false;
27
+ if (command.type === 'session.message.delete.request' && body.messageId !== command.messageId) return false;
28
+ if (command.type === 'session.messages.clear.request' && typeof body.cleared !== 'boolean') return false;
29
+ if (['session.delete.request', 'session.message.delete.request'].includes(command.type)
30
+ && typeof body.deleted !== 'boolean') return false;
31
+ if (command.type === 'request.events.request' && body.ok) {
32
+ if (!Array.isArray(body.events) || body.events.length > 8192) return false;
33
+ if (body.events.some(event => !isRecord(event) || (event.requestId !== undefined && event.requestId !== command.requestId))) return false;
34
+ }
35
+ return entry.lifecycle.matches?.(body) ?? true;
36
+ }
37
+
38
+ export class RelayCorrelations {
39
+ constructor({ now, respond, unknown }) {
40
+ this.now = now;
41
+ this.respond = respond;
42
+ this.unknown = unknown;
43
+ this.pending = new Map();
44
+ this.steering = new Map();
45
+ }
46
+
47
+ create(client, runtime, id, command, expectedType, lifecycle = {}) {
48
+ if (this.pending.size >= MAX_PENDING) throw relayError('pending_capacity');
49
+ const steerKey = command.type === 'steer_request' ? steeringKey(runtime, command) : null;
50
+ if (steerKey && this.steering.has(steerKey)) throw relayError('duplicate_steer_id');
51
+ const correlationId = randomUUID();
52
+ // Retain routing identity, never attachment bytes, prompts or conversation data.
53
+ const identity = { type: command.type, requestId: command.requestId,
54
+ sessionId: command.sessionId, steerId: command.steerId, messageId: command.messageId };
55
+ const entry = { client, runtime, id, command: identity, expectedType, correlationId,
56
+ expiresAt: this.now() + RPC_TIMEOUT_MS, steerKey, lifecycle };
57
+ this.pending.set(correlationId, entry);
58
+ if (steerKey) this.steering.set(steerKey, correlationId);
59
+ return entry;
60
+ }
61
+
62
+ remove(entry) {
63
+ this.pending.delete(entry.correlationId);
64
+ if (entry.steerKey) this.steering.delete(entry.steerKey);
65
+ entry.lifecycle.removed?.();
66
+ }
67
+
68
+ receive(runtime, body) {
69
+ const correlationId = body.type === 'steer_ack'
70
+ ? this.steering.get(steeringKey(runtime, body)) : body.correlationId;
71
+ const entry = this.pending.get(correlationId);
72
+ if (!entry || entry.runtime !== runtime || entry.expectedType !== body.type) return false;
73
+ if (entry.expiresAt <= this.now() || !matchesResponseIdentity(entry, body)) return false;
74
+ // An invalid reply must never consume the genuine request's pending record.
75
+ this.remove(entry);
76
+ const response = entry.command.type === SPARK_OPEN_REQUEST ? projectSparkOpenResponse(body) : body;
77
+ (entry.lifecycle.respond ?? this.respond)(entry, response);
78
+ return true;
79
+ }
80
+
81
+ fail(entry) {
82
+ if (!this.pending.has(entry.correlationId)) return;
83
+ this.remove(entry);
84
+ (entry.lifecycle.unknown ?? this.unknown)(entry);
85
+ }
86
+
87
+ disconnectRuntime(runtime) {
88
+ for (const entry of this.pending.values()) if (entry.runtime === runtime) this.fail(entry);
89
+ }
90
+
91
+ disconnectClient(client) {
92
+ for (const entry of this.pending.values()) {
93
+ if (entry.client !== client || entry.lifecycle.retainOnClientDisconnect) continue;
94
+ this.remove(entry);
95
+ }
96
+ }
97
+
98
+ prune() {
99
+ for (const entry of this.pending.values()) if (entry.expiresAt <= this.now()) this.fail(entry);
100
+ }
101
+ }
@@ -0,0 +1,69 @@
1
+ const MODALITIES = new Set(['text', 'image', 'audio']);
2
+ const EXECUTION_POLICIES = new Set(['supervisor-worker-v1', 'execution-agent-v1']);
3
+
4
+ function invalidCatalog() {
5
+ throw Object.assign(new Error('invalid_model_catalog'), { code: 'invalid_model_catalog' });
6
+ }
7
+
8
+ function catalogRecord(value) {
9
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) invalidCatalog();
10
+ return value;
11
+ }
12
+
13
+ function catalogString(value, maximum) {
14
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum) invalidCatalog();
15
+ if (/[\u0000-\u001f\u007f]/u.test(value)) invalidCatalog();
16
+ return value;
17
+ }
18
+
19
+ function catalogBoolean(value) {
20
+ if (typeof value !== 'boolean') invalidCatalog();
21
+ return value;
22
+ }
23
+
24
+ function catalogModalities(value) {
25
+ if (!Array.isArray(value) || value.length > 3 || value.some(item => !MODALITIES.has(item))) invalidCatalog();
26
+ return [...new Set(value)];
27
+ }
28
+
29
+ function catalogCapabilities(value) {
30
+ catalogRecord(value);
31
+ const result = {};
32
+ for (const key of ['inputModalities', 'outputModalities']) {
33
+ if (value[key] !== undefined) result[key] = catalogModalities(value[key]);
34
+ }
35
+ return result;
36
+ }
37
+
38
+ function catalogProfile(value) {
39
+ catalogRecord(value);
40
+ const result = { id: catalogString(value.id, 160), label: catalogString(value.label, 240) };
41
+ // Mirror display/capability fields projected by loadRuntimeModelCatalog, never provider config.
42
+ for (const [field, maximum] of [['providerId', 160], ['provider', 160], ['model', 512]]) {
43
+ if (value[field] !== undefined) result[field] = catalogString(value[field], maximum);
44
+ }
45
+ for (const field of ['supportsThinking', 'supportsImageInput']) {
46
+ if (value[field] !== undefined) result[field] = catalogBoolean(value[field]);
47
+ }
48
+ if (value.executionPolicy !== undefined) {
49
+ if (!EXECUTION_POLICIES.has(value.executionPolicy)) invalidCatalog();
50
+ result.executionPolicy = value.executionPolicy;
51
+ }
52
+ if (value.reasoningControls !== undefined) {
53
+ catalogRecord(value.reasoningControls);
54
+ result.reasoningControls = { budgetTokens: catalogBoolean(value.reasoningControls.budgetTokens) };
55
+ }
56
+ if (value.capabilities !== undefined) result.capabilities = catalogCapabilities(value.capabilities);
57
+ return result;
58
+ }
59
+
60
+ export function parseRuntimeModelCatalog(value) {
61
+ catalogRecord(value);
62
+ if (value.schemaVersion !== 1) invalidCatalog();
63
+ const defaultProfileId = catalogString(value.defaultProfileId, 160);
64
+ if (!Array.isArray(value.profiles) || value.profiles.length === 0 || value.profiles.length > 128) invalidCatalog();
65
+ const profiles = value.profiles.map(catalogProfile);
66
+ const ids = new Set(profiles.map(profile => profile.id));
67
+ if (ids.size !== profiles.length || !ids.has(defaultProfileId)) invalidCatalog();
68
+ return { schemaVersion: 1, defaultProfileId, profiles };
69
+ }