@abot-ai/socket 0.0.0-stage → 0.1.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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -2
  3. package/bin/abot-socket.mjs +15 -0
  4. package/config.example.json +14 -0
  5. package/docs/APP-REVIEW.md +184 -0
  6. package/docs/DEPLOYMENT.md +124 -0
  7. package/docs/INSTALLATION.md +166 -0
  8. package/docs/MIGRATION.md +48 -0
  9. package/docs/PROTOCOL.md +278 -0
  10. package/docs/SECURITY.md +98 -0
  11. package/docs/SELF-HOSTING.md +154 -0
  12. package/package.json +64 -4
  13. package/src/auth/auth-database.mjs +102 -0
  14. package/src/auth/auth-limits.mjs +17 -0
  15. package/src/auth/auth-pairing.mjs +95 -0
  16. package/src/auth/auth-sessions.mjs +119 -0
  17. package/src/auth/auth-storage-path.mjs +70 -0
  18. package/src/auth/auth.mjs +45 -0
  19. package/src/auth/runtime-credentials.mjs +39 -0
  20. package/src/cli/cli-arguments.mjs +23 -0
  21. package/src/cli/cli-entry.mjs +84 -0
  22. package/src/cli/cli-environment.mjs +31 -0
  23. package/src/cli/cli-help.mjs +34 -0
  24. package/src/cli/cli-output.mjs +35 -0
  25. package/src/cli/cli.mjs +58 -0
  26. package/src/cli/client-capabilities.mjs +4 -0
  27. package/src/cli/doctor.mjs +106 -0
  28. package/src/config/config-validation.mjs +41 -0
  29. package/src/config/config.mjs +57 -0
  30. package/src/deployment/deployment-command.mjs +42 -0
  31. package/src/deployment/deployment-input.mjs +76 -0
  32. package/src/deployment/deployment-plan.mjs +62 -0
  33. package/src/deployment/deployment-templates.mjs +109 -0
  34. package/src/main.mjs +26 -0
  35. package/src/notifications/notification-delivery-credentials.mjs +48 -0
  36. package/src/notifications/notification-delivery-fcm.mjs +100 -0
  37. package/src/notifications/notification-delivery-gateway.mjs +36 -0
  38. package/src/notifications/notification-delivery-http.mjs +53 -0
  39. package/src/notifications/notification-delivery-payload.mjs +45 -0
  40. package/src/notifications/notification-delivery.mjs +43 -0
  41. package/src/notifications/notification-gateway-command.mjs +48 -0
  42. package/src/notifications/notification-gateway-store.mjs +159 -0
  43. package/src/notifications/notification-gateway.mjs +181 -0
  44. package/src/notifications/notification-http.mjs +39 -0
  45. package/src/notifications/notification-input.mjs +82 -0
  46. package/src/notifications/notification-outbox.mjs +60 -0
  47. package/src/notifications/notification-preview-crypto.mjs +42 -0
  48. package/src/notifications/notification-preview-schema.mjs +7 -0
  49. package/src/notifications/notification-preview-store.mjs +63 -0
  50. package/src/notifications/notification-preview-text.mjs +20 -0
  51. package/src/notifications/notification-projection.mjs +20 -0
  52. package/src/notifications/notification-schema.mjs +22 -0
  53. package/src/notifications/notification-service.mjs +147 -0
  54. package/src/notifications/notification-store.mjs +132 -0
  55. package/src/pairing/pairing-qr-payload.mjs +32 -0
  56. package/src/pairing/pairing-qr.mjs +42 -0
  57. package/src/protocol/protocol-validation.mjs +137 -0
  58. package/src/protocol/protocol.mjs +110 -0
  59. package/src/relay/relay-client-admission.mjs +40 -0
  60. package/src/relay/relay-correlations.mjs +96 -0
  61. package/src/relay/relay-model-catalog.mjs +69 -0
  62. package/src/relay/relay-requests.mjs +155 -0
  63. package/src/relay/relay-runtime-registration.mjs +49 -0
  64. package/src/relay/relay-session-mutations.mjs +88 -0
  65. package/src/relay/relay.mjs +202 -0
  66. package/src/review/review-demo-isolation.mjs +132 -0
  67. package/src/review/review-demo-setup.mjs +97 -0
  68. package/src/review/review-portal-access.mjs +64 -0
  69. package/src/review/review-portal-form.mjs +24 -0
  70. package/src/review/review-portal-page.mjs +68 -0
  71. package/src/review/review-portal-server.mjs +105 -0
  72. package/src/setup/setup-files.mjs +37 -0
  73. package/src/setup/setup-questions.mjs +62 -0
  74. package/src/setup/setup-wizard.mjs +50 -0
  75. package/src/setup/setup.mjs +42 -0
  76. package/src/transport/http-api.mjs +31 -0
  77. package/src/transport/server.mjs +165 -0
  78. package/src/transport/transport-limits.mjs +85 -0
@@ -0,0 +1,42 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { resolve, join } from 'node:path';
3
+ import { digestToken } from '../auth/runtime-credentials.mjs';
4
+ import { validateConfiguration } from '../config/config-validation.mjs';
5
+ import { writePrivateInstallation } from './setup-files.mjs';
6
+ import { AuthService } from '../auth/auth.mjs';
7
+
8
+ export function prepareInstallation(options) {
9
+ const runtimeToken = randomBytes(32).toString('base64url');
10
+ const config = validateConfiguration({
11
+ publicOrigin: options.origin,
12
+ port: options.port ?? 8788,
13
+ stateDirectory: './state',
14
+ runtimeGrants: [{ runtimeId: options.runtimeId, tokenSha256: digestToken(runtimeToken),
15
+ allowedModes: ['ask'] }],
16
+ });
17
+ const bridge = new URL('/agent-bridge', config.publicOrigin);
18
+ bridge.protocol = 'wss:';
19
+ return {
20
+ config,
21
+ runtimeConnection: { runtimeId: config.runtimeGrants[0].runtimeId,
22
+ agentBridgeUrl: bridge.href, runtimeToken },
23
+ };
24
+ }
25
+
26
+ // Local operator only. No files, accounts, servers, or mail are modified elsewhere.
27
+ export function createInstallation(options) {
28
+ if (typeof options.output !== 'string' || !options.output.trim()) {
29
+ throw new Error('Choose a new private output directory');
30
+ }
31
+ const bundle = prepareInstallation(options);
32
+ const directory = resolve(options.output);
33
+ writePrivateInstallation(directory, bundle);
34
+ // A failed state initialization leaves the private installation intact for
35
+ // operator diagnosis; a later `pair` command can issue its first pairing.
36
+ const auth = new AuthService({ stateDirectory: join(directory, 'state') });
37
+ let pairing;
38
+ try { pairing = auth.createPairing(); } finally { auth.close(); }
39
+ return { directory, configPath: join(directory, 'config.json'),
40
+ runtimeConnectionPath: join(directory, 'runtime-connection.json'),
41
+ pairing: { serverUrl: bundle.config.publicOrigin, ...pairing } };
42
+ }
@@ -0,0 +1,31 @@
1
+ import { bearerToken, jsonResponse, readJson } from './transport-limits.mjs';
2
+ import { clientCapabilities } from '../cli/client-capabilities.mjs';
3
+ import { handleNotificationHttp } from '../notifications/notification-http.mjs';
4
+
5
+ export async function handleHttpApi(req, res, { auth, runtimeGrants, relay, sourceId, notifications }) {
6
+ const path = req.url;
7
+ if (req.method === 'GET' && path === '/health') return jsonResponse(res, 200, { ok: true, version: 2 });
8
+ if (req.method === 'POST' && path === '/v2/pairing/redeem') {
9
+ return jsonResponse(res, 200, auth.redeemPairing(await readJson(req), sourceId));
10
+ }
11
+ const principal = auth.authenticate(bearerToken(req));
12
+ if (!principal) return jsonResponse(res, 401, { error: 'unauthorized' });
13
+ if (notifications && await handleNotificationHttp(req, res, { principal, notifications, runtimeGrants })) return;
14
+ if (req.method === 'GET' && path === '/v2/auth/sessions') {
15
+ return jsonResponse(res, 200, { sessions: auth.listSessions(principal) });
16
+ }
17
+ if (req.method === 'POST' && path === '/v2/auth/logout') {
18
+ auth.revoke(principal, principal.sessionId);
19
+ return jsonResponse(res, 200, { ok: true });
20
+ }
21
+ if (req.method === 'DELETE' && /^\/v2\/auth\/sessions\/[A-Za-z0-9_-]{16,128}$/.test(path)) {
22
+ return jsonResponse(res, 200, { revoked: auth.revoke(principal, path.split('/').at(-1)) });
23
+ }
24
+ if (req.method === 'GET' && path === '/v2/runtimes') {
25
+ const snapshots = relay.runtimeSnapshot(runtimeGrants.map((grant) => grant.runtimeId));
26
+ return jsonResponse(res, 200, { runtimes: runtimeGrants.map(({ runtimeId, allowedModes }, index) => ({
27
+ ...snapshots[index], runtimeId, allowedModes, capabilities: clientCapabilities(),
28
+ })) });
29
+ }
30
+ return jsonResponse(res, 404, { error: 'not_found' });
31
+ }
@@ -0,0 +1,165 @@
1
+ import { createServer as createHttpServer } from 'node:http';
2
+ import { createServer as createHttpsServer } from 'node:https';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { AuthService } from '../auth/auth.mjs';
5
+ import { Relay } from '../relay/relay.mjs';
6
+ import { handleHttpApi } from './http-api.mjs';
7
+ import { WindowLimiter, sameAuthority, jsonResponse, bearerToken, trustedClientAddress } from './transport-limits.mjs';
8
+ import { resolveRuntimeCredential, validateRuntimeGrants } from '../auth/runtime-credentials.mjs';
9
+ import { clientCapabilities } from '../cli/client-capabilities.mjs';
10
+ import { AuthDatabase } from '../auth/auth-database.mjs';
11
+ import { NotificationService } from '../notifications/notification-service.mjs';
12
+ import { createNotificationDelivery } from '../notifications/notification-delivery.mjs';
13
+
14
+ const MAX_FRAME = 16 * 1024 * 1024;
15
+ const MAX_BUFFER = 32 * 1024 * 1024;
16
+ const MAX_CONNECTIONS = 32;
17
+
18
+ export function createSocketServer({ stateDirectory, runtimeGrants: grants,
19
+ publicOrigin, tls, now = Date.now, notifications: notificationConfig, notificationDelivery }) {
20
+ const origin = new URL(publicOrigin);
21
+ if (origin.protocol !== 'https:' || origin.origin !== publicOrigin) throw new Error('HTTPS origin required');
22
+ const runtimeGrants = validateRuntimeGrants(grants);
23
+ const database = new AuthDatabase(stateDirectory);
24
+ let auth;
25
+ let notifications;
26
+ try {
27
+ auth = new AuthService({ database, now });
28
+ notifications = new NotificationService({ database, now,
29
+ delivery: notificationDelivery ?? createNotificationDelivery(notificationConfig?.delivery ?? { type: 'disabled' }) });
30
+ } catch (error) {
31
+ auth?.close(); database.close(); throw error;
32
+ }
33
+ const peers = new Map();
34
+ const upgrades = new WindowLimiter({ limit: 30, windowMs: 60_000, now });
35
+ const requests = new WindowLimiter({ limit: 120, windowMs: 60_000, now });
36
+ const close = (peer, code = 1008, reason = 'Access denied') => {
37
+ if (peer.readyState === WebSocket.OPEN) peer.close(code, reason);
38
+ else peer.terminate();
39
+ };
40
+ const send = (peer, payload) => {
41
+ const context = peers.get(peer);
42
+ if (context?.role === 'client' && !auth.authenticate(context.token)) {
43
+ close(peer); return false;
44
+ }
45
+ if (peer.readyState !== WebSocket.OPEN) return false;
46
+ let encoded;
47
+ try { encoded = JSON.stringify(payload); } catch { return false; }
48
+ if (Buffer.byteLength(encoded) > MAX_FRAME || peer.bufferedAmount + Buffer.byteLength(encoded) > MAX_BUFFER) {
49
+ close(peer, 1013, 'Receiver capacity exceeded'); return false;
50
+ }
51
+ peer.send(encoded, (error) => { if (error) peer.terminate(); });
52
+ return true;
53
+ };
54
+ const relay = new Relay({ now, send, close, observeRequest: (identity, body) => notifications.observe(identity, body) });
55
+ const authorityAccepted = (req) => sameAuthority(req, origin) &&
56
+ (Boolean(req.socket.encrypted) || req.headers['x-forwarded-proto'] === 'https');
57
+ const handler = async (req, res) => {
58
+ try {
59
+ if (!authorityAccepted(req)) return jsonResponse(res, 403, { error: 'forbidden' });
60
+ const sourceId = trustedClientAddress(req);
61
+ if (!sourceId) return jsonResponse(res, 403, { error: 'untrusted_proxy' });
62
+ if (!requests.allow(sourceId)) return jsonResponse(res, 429, { error: 'rate_limited' });
63
+ await handleHttpApi(req, res, { auth, runtimeGrants, relay, sourceId, notifications });
64
+ } catch (error) {
65
+ const status = [400, 401, 403, 404, 409, 413, 415, 429, 503].includes(error.status) ? error.status : 500;
66
+ const code = status === 500 ? 'internal_error' :
67
+ (/^[a-z_]{1,64}$/.test(error.code ?? '') ? error.code : 'request_failed');
68
+ if (!res.headersSent && !res.destroyed) jsonResponse(res, status, { error: code });
69
+ }
70
+ };
71
+ const server = tls ? createHttpsServer(tls, handler) : createHttpServer(handler);
72
+ server.requestTimeout = 15_000;
73
+ server.headersTimeout = 5_000;
74
+ server.keepAliveTimeout = 5_000;
75
+ server.maxHeadersCount = 32;
76
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME,
77
+ perMessageDeflate: false, clientTracking: true });
78
+ const reject = (socket, status = 401) => socket.end(
79
+ `HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
80
+
81
+ const acceptClient = (peer, context, token) => {
82
+ const principal = auth.authenticate(token);
83
+ if (!principal) { close(peer); return; }
84
+ context.role = 'client'; context.token = token; context.sessionId = principal.sessionId;
85
+ const admitted = relay.attachClient(peer, { ...principal,
86
+ allowedRuntimeIds: runtimeGrants.map((grant) => grant.runtimeId) });
87
+ if (admitted === false) { close(peer, 1013, 'Capacity exceeded'); return; }
88
+ send(peer, { type: 'authenticated', sessionId: principal.sessionId, expiresAt: principal.expiresAt,
89
+ capabilities: clientCapabilities() });
90
+ };
91
+
92
+ server.on('upgrade', (req, socket, head) => {
93
+ socket.on('error', () => socket.destroy());
94
+ if (!authorityAccepted(req)) return reject(socket, 403);
95
+ const sourceId = trustedClientAddress(req);
96
+ if (!sourceId) return reject(socket, 403);
97
+ if (peers.size >= MAX_CONNECTIONS || !upgrades.allow(sourceId)) {
98
+ return reject(socket, 429);
99
+ }
100
+ const isRuntime = req.url === '/agent-bridge';
101
+ if (!isRuntime && req.url !== '/v2/realtime') return reject(socket, 404);
102
+ const grant = isRuntime ? resolveRuntimeCredential(req.headers.authorization, runtimeGrants) : null;
103
+ if (isRuntime && !grant) return reject(socket);
104
+ const clientToken = isRuntime ? '' : bearerToken(req);
105
+ if (!isRuntime && !auth.authenticate(clientToken)) return reject(socket);
106
+ wss.handleUpgrade(req, socket, head, (peer) => {
107
+ const context = { role: isRuntime ? 'runtime' : 'client', alive: true };
108
+ peers.set(peer, context);
109
+ peer.on('error', () => peer.terminate());
110
+ peer.on('close', () => { relay.disconnect(peer); peers.delete(peer); });
111
+ peer.on('pong', () => { context.alive = true; });
112
+ if (grant && relay.attachRuntime(peer, grant) === false) close(peer, 1008, 'Runtime unavailable');
113
+ if (!isRuntime) acceptClient(peer, context, clientToken);
114
+ peer.on('message', (data, binary) => {
115
+ if (binary) { close(peer, 1008, 'Text messages required'); return; }
116
+ let message;
117
+ try { message = JSON.parse(data.toString('utf8')); } catch { close(peer); return; }
118
+ if (!message || Array.isArray(message) || typeof message !== 'object') { close(peer); return; }
119
+ try {
120
+ if (context.role === 'runtime') { relay.runtimeMessage(peer, message); return; }
121
+ if (!auth.authenticate(context.token)) { close(peer); return; }
122
+ relay.clientMessage(peer, message);
123
+ } catch { close(peer, 1008, 'Invalid message'); }
124
+ });
125
+ });
126
+ });
127
+
128
+ const unsubscribe = auth.subscribeRevocations((sessionId) => {
129
+ notifications.revoke(sessionId);
130
+ for (const [peer, context] of peers) if (context.sessionId === sessionId) close(peer);
131
+ });
132
+ const sweep = setInterval(() => {
133
+ auth.prune(); relay.prune(); upgrades.prune(); requests.prune();
134
+ for (const [peer, context] of peers) {
135
+ if (context.role === 'client' && !auth.authenticate(context.token)) close(peer);
136
+ }
137
+ }, 1_000);
138
+ sweep.unref();
139
+ const heartbeat = setInterval(() => {
140
+ for (const [peer, context] of peers) {
141
+ if (!context.alive) { peer.terminate(); continue; }
142
+ context.alive = false;
143
+ if (peer.readyState === WebSocket.OPEN) peer.ping();
144
+ }
145
+ }, 30_000);
146
+ heartbeat.unref();
147
+ notifications.start();
148
+ let stopped = false;
149
+ return {
150
+ listen: (port = 8788) => new Promise((resolve, rejectListen) => {
151
+ server.once('error', rejectListen);
152
+ server.listen(port, '127.0.0.1', () => { server.off('error', rejectListen); resolve(server.address()); });
153
+ }),
154
+ close: () => new Promise((resolve) => {
155
+ if (stopped) { resolve(); return; }
156
+ stopped = true;
157
+ clearInterval(sweep); clearInterval(heartbeat); unsubscribe();
158
+ for (const peer of peers.keys()) peer.terminate();
159
+ wss.close(); server.closeAllConnections();
160
+ server.close(() => {
161
+ void notifications.close().finally(() => { auth.close(); database.close(); resolve(); });
162
+ });
163
+ }),
164
+ };
165
+ }
@@ -0,0 +1,85 @@
1
+ export class WindowLimiter {
2
+ constructor({ limit, windowMs, capacity = 1024, now = Date.now }) {
3
+ this.limit = limit;
4
+ this.windowMs = windowMs;
5
+ this.capacity = capacity;
6
+ this.now = now;
7
+ this.entries = new Map();
8
+ }
9
+ allow(key) {
10
+ this.prune();
11
+ const entry = this.entries.get(key);
12
+ if (entry) {
13
+ if (entry.count >= this.limit) return false;
14
+ entry.count += 1;
15
+ return true;
16
+ }
17
+ if (this.entries.size >= this.capacity) return false;
18
+ this.entries.set(key, { count: 1, until: this.now() + this.windowMs });
19
+ return true;
20
+ }
21
+ prune() {
22
+ const now = this.now();
23
+ for (const [key, value] of this.entries) if (value.until <= now) this.entries.delete(key);
24
+ }
25
+ }
26
+
27
+ export function bearerToken(req) {
28
+ const value = req.headers.authorization;
29
+ return typeof value === 'string' && /^Bearer [A-Za-z0-9_-]{43,128}$/.test(value)
30
+ ? value.slice(7) : '';
31
+ }
32
+
33
+ export function sameAuthority(req, origin) {
34
+ if (req.headers.host !== origin.host) return false;
35
+ return req.headers.origin === undefined || req.headers.origin === origin.origin;
36
+ }
37
+
38
+ // Listener is loopback-only. The local reverse proxy MUST strip/overwrite this
39
+ // header using its verified client address, never copy arbitrary forwarded input.
40
+ export function trustedClientAddress(req) {
41
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress)) return null;
42
+ const address = req.headers['x-socket-client-ip'];
43
+ return typeof address === 'string' && isIP(address) ? address : null;
44
+ }
45
+
46
+ export function jsonResponse(res, status, body) {
47
+ const encoded = JSON.stringify(body);
48
+ res.writeHead(status, {
49
+ 'content-type': 'application/json; charset=utf-8',
50
+ 'cache-control': 'no-store',
51
+ 'pragma': 'no-cache',
52
+ 'x-content-type-options': 'nosniff',
53
+ 'content-security-policy': "default-src 'none'; frame-ancestors 'none'",
54
+ 'referrer-policy': 'no-referrer',
55
+ 'content-length': Buffer.byteLength(encoded),
56
+ });
57
+ res.end(encoded);
58
+ }
59
+
60
+ export async function readJson(req, maxBytes = 4096) {
61
+ if ((req.headers['content-type'] ?? '').split(';')[0].trim() !== 'application/json') {
62
+ throw Object.assign(new Error('invalid_content_type'), { code: 'invalid_content_type', status: 415 });
63
+ }
64
+ const declared = req.headers['content-length'];
65
+ if (declared !== undefined && (!/^\d+$/.test(declared) || Number(declared) > maxBytes)) {
66
+ throw Object.assign(new Error('payload_too_large'), { code: 'payload_too_large', status: 413 });
67
+ }
68
+ const chunks = [];
69
+ let size = 0;
70
+ for await (const chunk of req) {
71
+ size += chunk.length;
72
+ if (size > maxBytes) {
73
+ throw Object.assign(new Error('payload_too_large'), { code: 'payload_too_large', status: 413 });
74
+ }
75
+ chunks.push(chunk);
76
+ }
77
+ try {
78
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
79
+ if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error();
80
+ return value;
81
+ } catch {
82
+ throw Object.assign(new Error('invalid_json'), { code: 'invalid_json', status: 400 });
83
+ }
84
+ }
85
+ import { isIP } from 'node:net';