@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,24 @@
1
+ function formError(code, status) {
2
+ return Object.assign(new Error(code), { code, status });
3
+ }
4
+
5
+ export async function readReviewPairingForm(req) {
6
+ const contentType = (req.headers['content-type'] ?? '').split(';')[0].trim();
7
+ if (contentType !== 'application/x-www-form-urlencoded') throw formError('invalid_content_type', 415);
8
+ const length = req.headers['content-length'];
9
+ if (length !== undefined && (!/^\d+$/.test(length) || Number(length) > 4096)) {
10
+ throw formError('payload_too_large', 413);
11
+ }
12
+ const chunks = [];
13
+ let size = 0;
14
+ for await (const chunk of req) {
15
+ size += chunk.length;
16
+ if (size > 4096) throw formError('payload_too_large', 413);
17
+ chunks.push(chunk);
18
+ }
19
+ const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
20
+ if ([...form.keys()].some(key => key !== 'csrf') || form.getAll('csrf').length !== 1) {
21
+ throw formError('invalid_form', 400);
22
+ }
23
+ return form.get('csrf');
24
+ }
@@ -0,0 +1,68 @@
1
+ import qrcode from 'qrcode-generator';
2
+ import { pairingQrPayload } from '../pairing/pairing-qr-payload.mjs';
3
+
4
+ const escapeHtml = value => String(value).replace(/[&<>"']/g, char =>
5
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
6
+
7
+ const STYLES = `
8
+ :root{color-scheme:light dark;font-family:system-ui,-apple-system,sans-serif;background:#0b0b0b;color:#eee}
9
+ *{box-sizing:border-box}body{margin:0}main{max-width:860px;margin:auto;padding:32px 24px 56px}
10
+ header{display:flex;align-items:center;gap:12px;margin-bottom:52px;font-size:14px;color:#aaa}
11
+ .brand{font-size:25px;font-weight:800;letter-spacing:-3px;color:#baf4e0;padding-right:4px}
12
+ h1{font-size:clamp(30px,5vw,42px);letter-spacing:-1px;line-height:1.15;margin:0 0 16px}
13
+ p,li{line-height:1.65;color:#bbb}p{max-width:65ch}h2{font-size:18px;margin:0 0 14px}
14
+ .panel{margin-top:28px;padding:24px;border:1px solid #303030;border-radius:18px;background:#141414}
15
+ .pairing{display:grid;grid-template-columns:minmax(180px,240px) minmax(0,1fr);align-items:start;gap:28px}
16
+ .qr{background:white;color:black;padding:10px;border-radius:12px}.qr svg{display:block;width:100%;height:auto}
17
+ dl{margin:0}dt{font-size:12px;color:#aaa;margin-top:18px}dt:first-child{margin-top:0}
18
+ dd{margin:5px 0 0;font:14px ui-monospace,monospace;overflow-wrap:anywhere;user-select:all}
19
+ #pairing-code{font-size:28px;letter-spacing:5px}button{font:inherit;font-weight:600;border:0;border-radius:12px;
20
+ padding:14px 20px;background:#baf4e0;color:#10251e;cursor:pointer;margin-top:18px}
21
+ button:focus-visible,a:focus-visible{outline:3px solid #8fbaef;outline-offset:4px}a{color:#baf4e0}
22
+ .note{font-size:14px;color:#aaa}.error{color:#ffb9ad}.expiry{font-size:13px;color:#ccc}
23
+ footer{margin-top:40px;border-top:1px solid #303030;padding-top:20px;color:#888;font-size:13px}
24
+ @media(max-width:560px){main{padding:24px 18px}.pairing{grid-template-columns:1fr}.qr{max-width:280px;margin:auto}}
25
+ @media(prefers-color-scheme:light){:root{background:#f7f7f7;color:#161616}.panel{background:white;border-color:#ddd}
26
+ p,li{color:#505050}header,.note,dt,footer{color:#666}.brand,a{color:#13664c}.expiry{color:#555}.error{color:#9f2618}}
27
+ `;
28
+
29
+ function renderPairingDetails(pairing) {
30
+ if (!pairing) return '';
31
+ const qr = qrcode(0, 'M');
32
+ qr.addData(pairingQrPayload(pairing), 'Byte');
33
+ qr.make();
34
+ return `<section class="panel" aria-labelledby="pairing-title">
35
+ <h2 id="pairing-title">Your temporary pairing details</h2>
36
+ <div class="pairing"><div class="qr" role="img" aria-label="ABot pairing QR code">
37
+ ${qr.createSvgTag({ cellSize: 4, margin: 16, scalable: true })}</div><dl>
38
+ <dt>One-time code</dt><dd id="pairing-code">${escapeHtml(pairing.code)}</dd>
39
+ <dt>Server URL</dt><dd id="pairing-server-url">${escapeHtml(pairing.serverUrl)}</dd>
40
+ <dt>Temporary pairing key</dt><dd id="pairing-id">${escapeHtml(pairing.pairingId)}</dd>
41
+ </dl></div><p class="expiry">Use once before ${escapeHtml(new Date(pairing.expiresAt).toUTCString())}.
42
+ After five minutes, generate new details.</p><span id="pairing-expires-at" hidden>${pairing.expiresAt}</span></section>`;
43
+ }
44
+
45
+ export function renderReviewPage({ socketOrigin, csrf, nonce, pairing, error }) {
46
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
47
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>ABot — Review workspace</title>
48
+ <style nonce="${escapeHtml(nonce)}">${STYLES}</style></head><body><main>
49
+ <header><span class="brand" aria-label="ABot">AB</span><span>Review workspace</span></header>
50
+ <h1>Pair your app.<br>Explore ABot.</h1>
51
+ <p>This access page connects the regular ABot app to a dedicated demonstration workspace.
52
+ Only use demonstration data in this environment.</p>
53
+ ${error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : ''}
54
+ ${renderPairingDetails(pairing)}
55
+ <section class="panel"><h2>${pairing ? 'Need another pairing?' : 'Get started'}</h2>
56
+ <ol><li>Open ABot and unlock it with your device authentication.</li>
57
+ <li>Choose <strong>Scan pairing code</strong> and scan the QR from another screen.
58
+ On one device, choose <strong>Enter details manually</strong> and use the code, server URL and key above.</li>
59
+ <li>Confirm <strong>${escapeHtml(socketOrigin)}</strong>, then choose the review computer.</li></ol>
60
+ ${csrf ? `<form method="post" action="/review/pair"><input type="hidden" name="csrf" value="${escapeHtml(csrf)}">
61
+ <button type="submit">${pairing ? 'Generate new pairing details' : 'Generate pairing details'}</button></form>`
62
+ : '<p><a href="/review">Return to the review page</a> to continue.</p>'}
63
+ <p class="note">Generating new details replaces any unused QR. Already paired devices stay connected.
64
+ Keep the details private. This page never needs your device passcode.</p></section>
65
+ <footer>If the review computer is offline or a feature is unavailable, contact the developer using
66
+ the contact details in App Store Connect. This page does not confirm runtime or model availability.</footer>
67
+ </main></body></html>`;
68
+ }
@@ -0,0 +1,105 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { AuthService } from '../auth/auth.mjs';
4
+ import { sameAuthority, trustedClientAddress } from '../transport/transport-limits.mjs';
5
+ import { ReviewPortalAccess } from './review-portal-access.mjs';
6
+ import { readReviewPairingForm } from './review-portal-form.mjs';
7
+ import { renderReviewPage } from './review-portal-page.mjs';
8
+ import { loadReviewDemoConfiguration } from './review-demo-isolation.mjs';
9
+
10
+ function requirePortalOrigin(value) {
11
+ const url = new URL(value);
12
+ if (url.protocol !== 'https:' || url.origin !== value) throw new Error('Canonical HTTPS origin required');
13
+ return url;
14
+ }
15
+
16
+ function acceptsPortalTransport(req, origin) {
17
+ if (!sameAuthority(req, origin)) return false;
18
+ return req.headers['x-forwarded-proto'] === 'https';
19
+ }
20
+
21
+ function acceptsPairingSubmissionOrigin(req, origin) {
22
+ if (req.headers.origin !== origin.origin) return false;
23
+ const site = req.headers['sec-fetch-site'];
24
+ return site === undefined || site === 'same-origin';
25
+ }
26
+
27
+ function sendPortalPage(res, status, details, headers = {}) {
28
+ const nonce = randomBytes(18).toString('base64url');
29
+ const page = renderReviewPage({ ...details, nonce });
30
+ res.writeHead(status, {
31
+ 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(page),
32
+ 'cache-control': 'no-store, private', pragma: 'no-cache',
33
+ 'content-security-policy': `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
34
+ 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff',
35
+ 'x-frame-options': 'DENY', 'x-robots-tag': 'noindex, nofollow, noarchive',
36
+ 'permissions-policy': 'camera=(), microphone=(), geolocation=()', ...headers,
37
+ });
38
+ res.end(page);
39
+ }
40
+
41
+ /** Internal constructor; the CLI must load and verify a fresh demo installation first. */
42
+ export function createReviewPortalServer({ publicOrigin, socketOrigin, username, passwordSha256,
43
+ stateDirectory, now = Date.now }) {
44
+ const origin = requirePortalOrigin(publicOrigin);
45
+ requirePortalOrigin(socketOrigin);
46
+ if (publicOrigin === socketOrigin) throw new Error('A distinct review origin is required');
47
+ const access = new ReviewPortalAccess({ username, passwordSha256, now });
48
+ const auth = new AuthService({ stateDirectory, now });
49
+ const send = (res, status, details = {}, headers) => sendPortalPage(res, status,
50
+ { socketOrigin, csrf: '', ...details }, headers);
51
+ const server = createServer(async (req, res) => {
52
+ try {
53
+ if (!acceptsPortalTransport(req, origin)) return send(res, 403, { error: 'Request not allowed.' });
54
+ const source = trustedClientAddress(req);
55
+ if (!source) return send(res, 403, { error: 'Request not allowed.' });
56
+ if (!['/review', '/review/pair'].includes(req.url)) return send(res, 404, { error: 'Page not found.' });
57
+ const status = access.authenticate(req.headers.authorization, source);
58
+ if (status === 401) return send(res, 401, { error: 'Use the review access details supplied by the developer.' },
59
+ { 'www-authenticate': 'Basic realm="ABot review workspace", charset="UTF-8"' });
60
+ if (status === 429) return send(res, 429, { error: 'Too many attempts. Try again later.' }, { 'retry-after': '900' });
61
+ if (req.method === 'GET' && req.url === '/review') return send(res, 200, { csrf: access.createCsrf() });
62
+ if (req.method !== 'POST' || req.url !== '/review/pair') {
63
+ return send(res, 405, { error: 'Method not allowed.' });
64
+ }
65
+ if (!acceptsPairingSubmissionOrigin(req, origin)) return send(res, 403, { error: 'Open the review page to continue.' });
66
+ const csrf = await readReviewPairingForm(req);
67
+ if (!access.acceptsCsrf(csrf)) return send(res, 403, { error: 'Reload the review page and try again.' });
68
+ if (!access.allowPairing(source)) {
69
+ return send(res, 429, { error: 'Please wait a minute before generating more pairing details.' }, { 'retry-after': '60' });
70
+ }
71
+ const pairing = { serverUrl: socketOrigin, ...auth.createPairing() };
72
+ return send(res, 200, { csrf: access.createCsrf(), pairing });
73
+ } catch (error) {
74
+ const status = [400, 413, 415].includes(error.status) ? error.status : 500;
75
+ if (!res.headersSent && !res.destroyed) send(res, status, { error: 'Unable to process this request. Reload the review page or contact the developer.' });
76
+ }
77
+ });
78
+ server.requestTimeout = 15_000;
79
+ server.headersTimeout = 5_000;
80
+ server.keepAliveTimeout = 5_000;
81
+ server.maxHeadersCount = 32;
82
+ server.maxConnections = 32;
83
+ server.on('upgrade', (_req, socket) => socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'));
84
+ let stopped = false;
85
+ return {
86
+ listen: (port = 8789) => new Promise((resolve, reject) => {
87
+ server.once('error', reject);
88
+ server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(server.address()); });
89
+ }),
90
+ close: () => new Promise(resolve => {
91
+ if (stopped) { resolve(); return; }
92
+ stopped = true;
93
+ server.closeAllConnections();
94
+ server.close(() => { auth.close(); resolve(); });
95
+ }),
96
+ };
97
+ }
98
+
99
+ export async function startConfiguredReviewPortal(env = process.env) {
100
+ const config = loadReviewDemoConfiguration(env);
101
+ const portal = createReviewPortalServer(config);
102
+ try { await portal.listen(config.port); }
103
+ catch (error) { await portal.close(); throw error; }
104
+ return portal;
105
+ }
@@ -0,0 +1,37 @@
1
+ import * as filesystem from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ function removeFailedInstallation(directory, created, io) {
5
+ const failures = [];
6
+ for (const path of created.reverse()) {
7
+ try { io.unlinkSync(path); } catch (error) { failures.push(error); }
8
+ }
9
+ try { io.rmdirSync(directory); } catch (error) { failures.push(error); }
10
+ return failures;
11
+ }
12
+
13
+ export function writePrivateInstallation(directory, bundle, io = filesystem) {
14
+ // Serialize before creating anything. Names are fixed, never caller-supplied paths.
15
+ const files = [
16
+ ['config.json', JSON.stringify(bundle.config, null, 2)],
17
+ ['runtime-connection.json', JSON.stringify(bundle.runtimeConnection, null, 2)],
18
+ ];
19
+ // Exclusive/non-recursive: existing directories and symlinks are not reused.
20
+ io.mkdirSync(directory, { mode: 0o700 });
21
+ const created = [];
22
+ try {
23
+ for (const [name, content] of files) {
24
+ const path = join(directory, name);
25
+ const descriptor = io.openSync(path, 'wx', 0o600);
26
+ created.push(path); // Own the file even if a later write fails partially.
27
+ try { io.writeFileSync(descriptor, `${content}\n`); }
28
+ finally { io.closeSync(descriptor); }
29
+ }
30
+ } catch (error) {
31
+ const failures = removeFailedInstallation(directory, created, io);
32
+ if (failures.length) {
33
+ throw new AggregateError([error, ...failures], 'Setup write failed and cleanup was incomplete');
34
+ }
35
+ throw error;
36
+ }
37
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, lstatSync } from 'node:fs';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
3
+ import { defaultInstallationDirectory } from '../cli/cli-environment.mjs';
4
+ import { validateConfiguration } from '../config/config-validation.mjs';
5
+
6
+ const fixtureGrant = [{ runtimeId: 'workstation', tokenSha256: '0'.repeat(64) }];
7
+
8
+ export async function askValidated(ask, out, prompt, fallback, validate) {
9
+ while (true) {
10
+ const suffix = fallback ? ` [${fallback}]` : '';
11
+ const answer = (await ask(`${prompt}${suffix}: `)).trim() || fallback;
12
+ try { return validate(answer); }
13
+ catch { out(`Invalid ${prompt.toLowerCase()}. Please try again.`); }
14
+ }
15
+ }
16
+
17
+ export function validateSetupDirectory(value) {
18
+ if (!value || /[\x00-\x1f\x7f]/.test(value)) throw new Error('Choose an output directory');
19
+ const path = resolve(value);
20
+ if (existsSync(path)) throw new Error('The output directory must be new');
21
+ const parent = lstatSync(dirname(path));
22
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error('The parent must be an existing directory');
23
+ return path;
24
+ }
25
+
26
+ function validateAnswer(field, value) {
27
+ const baseline = { publicOrigin: 'https://socket.example.invalid', stateDirectory: './state', runtimeGrants: fixtureGrant };
28
+ const candidate = field === 'runtimeId'
29
+ ? { ...baseline, runtimeGrants: [{ ...fixtureGrant[0], runtimeId: value }] }
30
+ : { ...baseline, [field]: value };
31
+ validateConfiguration(candidate);
32
+ return value;
33
+ }
34
+
35
+ export async function collectSetupAnswers({ ask, out, homeDirectory }) {
36
+ const output = await askValidated(ask, out, 'New private installation directory',
37
+ defaultInstallationDirectory(homeDirectory), validateSetupDirectory);
38
+ const origin = await askValidated(ask, out, 'HTTPS server origin', '', (value) => validateAnswer('publicOrigin', value));
39
+ const runtimeId = await askValidated(ask, out, 'Runtime identifier', 'workstation', (value) => validateAnswer('runtimeId', value));
40
+ const port = await askValidated(ask, out, 'Local listener port', '8788', (value) => {
41
+ if (!/^\d{4,5}$/.test(value)) throw new Error('Invalid port');
42
+ return validateAnswer('port', Number(value));
43
+ });
44
+ return { output, origin, runtimeId, port };
45
+ }
46
+
47
+ export async function collectProxyAnswers({ ask, out }) {
48
+ const proxy = await askValidated(ask, out, 'HTTPS proxy (nginx, apache, or none for existing setup)', 'none', (value) => {
49
+ if (!['nginx', 'apache', 'none'].includes(value)) throw new Error('Unknown proxy');
50
+ return value;
51
+ });
52
+ if (proxy === 'none') return { proxy };
53
+ out('The following paths refer to your existing TLS certificate and key; their contents are never copied.');
54
+ const certificatePath = await askValidated(ask, out, 'Full-chain certificate path', '', validateCertificatePath);
55
+ const keyPath = await askValidated(ask, out, 'TLS private key path', '', validateCertificatePath);
56
+ return { proxy, certificatePath, keyPath };
57
+ }
58
+
59
+ function validateCertificatePath(value) {
60
+ if (!isAbsolute(value) || /[\x00-\x1f\x7f]/.test(value)) throw new Error('An absolute path is required');
61
+ return value;
62
+ }
@@ -0,0 +1,50 @@
1
+ import { join } from 'node:path';
2
+ import { createInstallation, prepareInstallation } from './setup.mjs';
3
+ import { collectSetupAnswers, collectProxyAnswers } from './setup-questions.mjs';
4
+ import { renderDeploymentPlan, writeDeploymentPlan } from '../deployment/deployment-plan.mjs';
5
+ import { printInstallation } from '../cli/cli-output.mjs';
6
+ import { checkPublicEndpoint } from '../cli/doctor.mjs';
7
+
8
+ const confirmed = (value) => /^(y|yes)$/i.test(value.trim());
9
+
10
+ /** Every OS integration is prepared as files; only an explicit doctor answer contacts HTTPS. */
11
+ export async function runSetupWizard({ ask, out, homeDirectory, platform, username, nodePath, entryPath,
12
+ pairingOutput = {},
13
+ create = createInstallation, render = renderDeploymentPlan, writePlan = writeDeploymentPlan,
14
+ checkEndpoint = checkPublicEndpoint }) {
15
+ out('ABot Socket setup — prepare a private installation on your own server.');
16
+ out('This wizard writes local files. It does not install dependencies, alter DNS/TLS, or activate services.');
17
+ const options = await collectSetupAnswers({ ask, out, homeDirectory });
18
+ let deployment;
19
+ if (platform === 'linux' && username !== 'root') {
20
+ const proxy = await collectProxyAnswers({ ask, out });
21
+ deployment = { ...proxy, platform, username, nodePath, entryPath,
22
+ configPath: join(options.output, 'config.json'), config: prepareInstallation(options).config,
23
+ output: join(options.output, 'deployment') };
24
+ render(deployment); // Reject unsafe deployment values before creating private state.
25
+ } else {
26
+ out('Linux service files require setup as an unprivileged Linux user. Foreground operation is available separately.');
27
+ }
28
+ out(`Installation: ${JSON.stringify(options.output)}`);
29
+ out(`Server: ${options.origin}; runtime: ${options.runtimeId}; listener: 127.0.0.1:${options.port}`);
30
+ out(deployment ? 'A reviewable systemd/proxy plan will also be written, without activating it.' : 'No service or proxy files will be written.');
31
+ if (!confirmed(await ask('Create these files? [y/N]: '))) {
32
+ out('Setup cancelled; no installation files were created.');
33
+ return 0;
34
+ }
35
+ const result = create(options);
36
+ printInstallation(result, out, pairingOutput);
37
+ if (deployment) {
38
+ writePlan(deployment);
39
+ out(`Review the deployment instructions in ${JSON.stringify(deployment.output)} before applying them.`);
40
+ }
41
+ out('The relay is not started by setup. Connect its runtime using the private runtime-connection.json details.');
42
+ out('After the service and HTTPS are ready, use abot-socket doctor, then abot-socket pair for fresh details.');
43
+ if (confirmed(await ask('Check the public HTTPS endpoint now? [y/N]: '))) {
44
+ const report = await checkEndpoint(options.origin);
45
+ out(report.ok ? 'HTTPS relay health check passed.' : `HTTPS relay check: ${report.code}.`);
46
+ out('Runtime connectivity is verified after pairing in the app; it was not tested by this health check.');
47
+ return report.ok ? 0 : 1;
48
+ }
49
+ return 0;
50
+ }
@@ -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,32 @@
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, ownership }) {
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,
28
+ capabilities: clientCapabilities({ ownership, runtime: snapshots[index] }),
29
+ })) });
30
+ }
31
+ return jsonResponse(res, 404, { error: 'not_found' });
32
+ }
@@ -0,0 +1,175 @@
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 { RequestOwnershipStore } from '../relay/request-ownership-store.mjs';
7
+ import { handleHttpApi } from './http-api.mjs';
8
+ import { WindowLimiter, sameAuthority, jsonResponse, bearerToken, trustedClientAddress } from './transport-limits.mjs';
9
+ import { resolveRuntimeCredential, validateRuntimeGrants } from '../auth/runtime-credentials.mjs';
10
+ import { clientCapabilities } from '../cli/client-capabilities.mjs';
11
+ import { AuthDatabase } from '../auth/auth-database.mjs';
12
+ import { NotificationService } from '../notifications/notification-service.mjs';
13
+ import { createNotificationDelivery } from '../notifications/notification-delivery.mjs';
14
+
15
+ const MAX_FRAME = 16 * 1024 * 1024;
16
+ const MAX_BUFFER = 32 * 1024 * 1024;
17
+ const MAX_CONNECTIONS = 32;
18
+
19
+ export function createSocketServer({ stateDirectory, runtimeGrants: grants,
20
+ publicOrigin, tls, now = Date.now, notifications: notificationConfig, notificationDelivery }) {
21
+ const origin = new URL(publicOrigin);
22
+ if (origin.protocol !== 'https:' || origin.origin !== publicOrigin) throw new Error('HTTPS origin required');
23
+ const runtimeGrants = validateRuntimeGrants(grants);
24
+ const database = new AuthDatabase(stateDirectory);
25
+ let auth;
26
+ let ownership;
27
+ let notifications;
28
+ try {
29
+ auth = new AuthService({ database, now });
30
+ ownership = new RequestOwnershipStore({ database, now });
31
+ notifications = new NotificationService({ database, now,
32
+ delivery: notificationDelivery ?? createNotificationDelivery(notificationConfig?.delivery ?? { type: 'disabled' }) });
33
+ } catch (error) {
34
+ auth?.close(); database.close(); throw error;
35
+ }
36
+ const peers = new Map();
37
+ const upgrades = new WindowLimiter({ limit: 30, windowMs: 60_000, now });
38
+ const requests = new WindowLimiter({ limit: 120, windowMs: 60_000, now });
39
+ const close = (peer, code = 1008, reason = 'Access denied') => {
40
+ if (peer.readyState === WebSocket.OPEN) peer.close(code, reason);
41
+ else peer.terminate();
42
+ };
43
+ const send = (peer, payload) => {
44
+ const context = peers.get(peer);
45
+ if (context?.role === 'client' && !auth.authenticate(context.token)) {
46
+ close(peer); return false;
47
+ }
48
+ if (peer.readyState !== WebSocket.OPEN) return false;
49
+ let encoded;
50
+ try { encoded = JSON.stringify(payload); } catch { return false; }
51
+ if (Buffer.byteLength(encoded) > MAX_FRAME || peer.bufferedAmount + Buffer.byteLength(encoded) > MAX_BUFFER) {
52
+ close(peer, 1013, 'Receiver capacity exceeded'); return false;
53
+ }
54
+ peer.send(encoded, (error) => { if (error) peer.terminate(); });
55
+ return true;
56
+ };
57
+ const relay = new Relay({ now, send, close, ownership,
58
+ isClientAuthorized: client => {
59
+ const context = peers.get(client.peer);
60
+ if (context?.role !== 'client') return false;
61
+ const principal = auth.authenticate(context.token);
62
+ return principal?.subject === client.subject && principal.sessionId === client.sessionId;
63
+ },
64
+ observeRequest: (identity, body) => notifications.observe(identity, body) });
65
+ const authorityAccepted = (req) => sameAuthority(req, origin) &&
66
+ (Boolean(req.socket.encrypted) || req.headers['x-forwarded-proto'] === 'https');
67
+ const handler = async (req, res) => {
68
+ try {
69
+ if (!authorityAccepted(req)) return jsonResponse(res, 403, { error: 'forbidden' });
70
+ const sourceId = trustedClientAddress(req);
71
+ if (!sourceId) return jsonResponse(res, 403, { error: 'untrusted_proxy' });
72
+ if (!requests.allow(sourceId)) return jsonResponse(res, 429, { error: 'rate_limited' });
73
+ await handleHttpApi(req, res, { auth, runtimeGrants, relay, sourceId, notifications, ownership });
74
+ } catch (error) {
75
+ const status = [400, 401, 403, 404, 409, 413, 415, 429, 503].includes(error.status) ? error.status : 500;
76
+ const code = status === 500 ? 'internal_error' :
77
+ (/^[a-z_]{1,64}$/.test(error.code ?? '') ? error.code : 'request_failed');
78
+ if (!res.headersSent && !res.destroyed) jsonResponse(res, status, { error: code });
79
+ }
80
+ };
81
+ const server = tls ? createHttpsServer(tls, handler) : createHttpServer(handler);
82
+ server.requestTimeout = 15_000;
83
+ server.headersTimeout = 5_000;
84
+ server.keepAliveTimeout = 5_000;
85
+ server.maxHeadersCount = 32;
86
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME,
87
+ perMessageDeflate: false, clientTracking: true });
88
+ const reject = (socket, status = 401) => socket.end(
89
+ `HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
90
+
91
+ const acceptClient = (peer, context, token) => {
92
+ const principal = auth.authenticate(token);
93
+ if (!principal) { close(peer); return; }
94
+ context.role = 'client'; context.token = token; context.sessionId = principal.sessionId;
95
+ const admitted = relay.attachClient(peer, { ...principal,
96
+ allowedRuntimeIds: runtimeGrants.map((grant) => grant.runtimeId) });
97
+ if (admitted === false) { close(peer, 1013, 'Capacity exceeded'); return; }
98
+ send(peer, { type: 'authenticated', sessionId: principal.sessionId, expiresAt: principal.expiresAt,
99
+ capabilities: clientCapabilities({ ownership }) });
100
+ };
101
+
102
+ server.on('upgrade', (req, socket, head) => {
103
+ socket.on('error', () => socket.destroy());
104
+ if (!authorityAccepted(req)) return reject(socket, 403);
105
+ const sourceId = trustedClientAddress(req);
106
+ if (!sourceId) return reject(socket, 403);
107
+ if (peers.size >= MAX_CONNECTIONS || !upgrades.allow(sourceId)) {
108
+ return reject(socket, 429);
109
+ }
110
+ const isRuntime = req.url === '/agent-bridge';
111
+ if (!isRuntime && req.url !== '/v2/realtime') return reject(socket, 404);
112
+ const grant = isRuntime ? resolveRuntimeCredential(req.headers.authorization, runtimeGrants) : null;
113
+ if (isRuntime && !grant) return reject(socket);
114
+ const clientToken = isRuntime ? '' : bearerToken(req);
115
+ if (!isRuntime && !auth.authenticate(clientToken)) return reject(socket);
116
+ wss.handleUpgrade(req, socket, head, (peer) => {
117
+ const context = { role: isRuntime ? 'runtime' : 'client', alive: true };
118
+ peers.set(peer, context);
119
+ peer.on('error', () => peer.terminate());
120
+ peer.on('close', () => { relay.disconnect(peer); peers.delete(peer); });
121
+ peer.on('pong', () => { context.alive = true; });
122
+ if (grant && relay.attachRuntime(peer, grant) === false) close(peer, 1008, 'Runtime unavailable');
123
+ if (!isRuntime) acceptClient(peer, context, clientToken);
124
+ peer.on('message', (data, binary) => {
125
+ if (binary) { close(peer, 1008, 'Text messages required'); return; }
126
+ let message;
127
+ try { message = JSON.parse(data.toString('utf8')); } catch { close(peer); return; }
128
+ if (!message || Array.isArray(message) || typeof message !== 'object') { close(peer); return; }
129
+ try {
130
+ if (context.role === 'runtime') { relay.runtimeMessage(peer, message); return; }
131
+ if (!auth.authenticate(context.token)) { close(peer); return; }
132
+ relay.clientMessage(peer, message);
133
+ } catch { close(peer, 1008, 'Invalid message'); }
134
+ });
135
+ });
136
+ });
137
+
138
+ const unsubscribe = auth.subscribeRevocations((sessionId) => {
139
+ notifications.revoke(sessionId);
140
+ for (const [peer, context] of peers) if (context.sessionId === sessionId) close(peer);
141
+ });
142
+ const sweep = setInterval(() => {
143
+ auth.prune(); relay.prune(); upgrades.prune(); requests.prune();
144
+ for (const [peer, context] of peers) {
145
+ if (context.role === 'client' && !auth.authenticate(context.token)) close(peer);
146
+ }
147
+ }, 1_000);
148
+ sweep.unref();
149
+ const heartbeat = setInterval(() => {
150
+ for (const [peer, context] of peers) {
151
+ if (!context.alive) { peer.terminate(); continue; }
152
+ context.alive = false;
153
+ if (peer.readyState === WebSocket.OPEN) peer.ping();
154
+ }
155
+ }, 30_000);
156
+ heartbeat.unref();
157
+ notifications.start();
158
+ let stopped = false;
159
+ return {
160
+ listen: (port = 8788) => new Promise((resolve, rejectListen) => {
161
+ server.once('error', rejectListen);
162
+ server.listen(port, '127.0.0.1', () => { server.off('error', rejectListen); resolve(server.address()); });
163
+ }),
164
+ close: () => new Promise((resolve) => {
165
+ if (stopped) { resolve(); return; }
166
+ stopped = true;
167
+ clearInterval(sweep); clearInterval(heartbeat); unsubscribe();
168
+ for (const peer of peers.keys()) peer.terminate();
169
+ wss.close(); server.closeAllConnections();
170
+ server.close(() => {
171
+ void notifications.close().finally(() => { auth.close(); database.close(); resolve(); });
172
+ });
173
+ }),
174
+ };
175
+ }