@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,35 @@
1
+ import { renderPairingQr } from '../pairing/pairing-qr.mjs';
2
+
3
+ function canDisplayPairingQr({ isTTY, term }) {
4
+ return isTTY === true && term !== 'dumb';
5
+ }
6
+
7
+ function printTerminalPairing(pairing, out, { columns, renderQr = renderPairingQr }) {
8
+ try {
9
+ const qr = renderQr(pairing, { columns });
10
+ if (!qr) {
11
+ out('The terminal is too narrow for this QR. Widen it and run pair again, or use the details below.');
12
+ return;
13
+ }
14
+ out('Scan this QR inside ABot, then confirm your server address. Keep it private.');
15
+ out(qr);
16
+ } catch {
17
+ // Rendering cannot replace an issued challenge or hide the manual path.
18
+ out('QR display is unavailable. Use the same pairing details below.');
19
+ }
20
+ }
21
+
22
+ export function printPairing(pairing, out = console.log, options = {}) {
23
+ if (canDisplayPairingQr(options)) printTerminalPairing(pairing, out, options);
24
+ out('Pair your app: enter the 6-digit code, then the server URL and temporary pairing key.');
25
+ out(JSON.stringify(pairing, null, 2));
26
+ out('One use, valid for 5 minutes. If needed, run abot-socket pair again for fresh details.');
27
+ }
28
+
29
+ export function printInstallation(result, out = console.log, options = {}) {
30
+ out(`Created private installation files in ${JSON.stringify(result.directory)}`);
31
+ out('Keep runtime-connection.json private and transfer it only to its runtime computer.');
32
+ out(`Configuration: ${JSON.stringify(result.configPath)}`);
33
+ out('For a custom location, pass --config <that-file> to future commands.');
34
+ printPairing(result.pairing, out, options);
35
+ }
@@ -0,0 +1,58 @@
1
+ import { resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createInstallation } from '../setup/setup.mjs';
4
+ import { parseSetupArguments } from './cli-arguments.mjs';
5
+ import { loadConfiguration } from '../config/config.mjs';
6
+ import { AuthService } from '../auth/auth.mjs';
7
+ import { USAGE } from './cli-help.mjs';
8
+ import { printInstallation, printPairing } from './cli-output.mjs';
9
+ import { createReviewDemoInstallation, parseReviewDemoArguments } from '../review/review-demo-setup.mjs';
10
+
11
+ export function runCli(args, { env = process.env, out = console.log, error = console.error, pairingOutput = {} } = {}) {
12
+ const [command, ...rest] = args;
13
+ if (!command || command === '--help' || command === 'help') { out(USAGE); return 0; }
14
+ try {
15
+ if (command === 'review-setup') {
16
+ const result = createReviewDemoInstallation(parseReviewDemoArguments(rest));
17
+ out(`Created isolated review demo files in ${JSON.stringify(result.directory)}.`);
18
+ out(`Socket and portal configuration: ${JSON.stringify(result.configPath)}`);
19
+ out(`Private reviewer credentials: ${JSON.stringify(result.reviewAccessPath)}`);
20
+ out(`Demo runtime connection: ${JSON.stringify(result.runtimeConnectionPath)}`);
21
+ out('No service or runtime was started. Use a separate runtime workspace containing only synthetic data.');
22
+ return 0;
23
+ }
24
+ if (command === 'setup') {
25
+ const result = createInstallation(parseSetupArguments(rest));
26
+ printInstallation(result, out, pairingOutput);
27
+ return 0;
28
+ }
29
+ if (command === 'check-config' && rest.length === 0) {
30
+ loadConfiguration(env);
31
+ out('Configuration is valid locally. HTTPS, state storage and runtime reachability were not tested.');
32
+ return 0;
33
+ }
34
+ if (['pair', 'devices'].includes(command) && rest.length === 0 ||
35
+ command === 'revoke' && rest.length === 1 && /^[A-Za-z0-9_-]{16,128}$/.test(rest[0])) {
36
+ const config = loadConfiguration(env);
37
+ const auth = new AuthService({ stateDirectory: config.stateDirectory });
38
+ try {
39
+ if (command === 'pair') printPairing({ serverUrl: config.publicOrigin, ...auth.createPairing() }, out, pairingOutput);
40
+ if (command === 'devices') out(JSON.stringify({ devices: auth.listDevices() }, null, 2));
41
+ if (command === 'revoke') out(JSON.stringify({ revoked: auth.revokeDevice(rest[0]) }));
42
+ } finally { auth.close(); }
43
+ return 0;
44
+ }
45
+ error('Unknown command or arguments. Run with --help.');
46
+ return 2;
47
+ } catch {
48
+ // Never echo provider, filesystem or parser error values into operator logs.
49
+ error(command === 'setup'
50
+ ? 'Setup failed. Check arguments and private output/state permissions. If installation files exist, use pair to retry without overwriting them.'
51
+ : 'Command failed. Check the private configuration, state permissions, HTTPS origin and runtime grants.');
52
+ return 1;
53
+ }
54
+ }
55
+
56
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
57
+ process.exitCode = runCli(process.argv.slice(2));
58
+ }
@@ -0,0 +1,4 @@
1
+ /** Socket-owned API support; runtime identity/features remain separately scoped. */
2
+ export function clientCapabilities() {
3
+ return { cancel: false, durableApprovals: false, sessionMutation: true, notifications: true };
4
+ }
@@ -0,0 +1,106 @@
1
+ import { request as httpsRequest } from 'node:https';
2
+
3
+ const MAX_BODY_BYTES = 4096;
4
+ const MAX_ORIGIN_LENGTH = 2048;
5
+ const DEADLINE_MS = 5000;
6
+
7
+ function parseDoctorOrigin(value) {
8
+ if (typeof value !== 'string' || value.length > MAX_ORIGIN_LENGTH) {
9
+ throw new TypeError('Doctor requires a canonical HTTPS origin');
10
+ }
11
+ let origin;
12
+ try { origin = new URL(value); } catch {
13
+ throw new TypeError('Doctor requires a canonical HTTPS origin');
14
+ }
15
+ if (origin.protocol !== 'https:' || origin.origin !== value) {
16
+ throw new TypeError('Doctor requires a canonical HTTPS origin');
17
+ }
18
+ return origin;
19
+ }
20
+
21
+ function hasOversizedDeclaredBody(response) {
22
+ const length = response.headers['content-length'];
23
+ if (length === undefined) return false;
24
+ if (typeof length !== 'string' || !/^\d+$/.test(length)) return true;
25
+ return Number(length) > MAX_BODY_BYTES;
26
+ }
27
+
28
+ function isRelayHealthResponse(chunks) {
29
+ try {
30
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
31
+ if (!value || Array.isArray(value) || typeof value !== 'object') return false;
32
+ return value.ok === true && value.version === 2;
33
+ } catch { return false; }
34
+ }
35
+
36
+ /** Explicit public TLS check only. It never authenticates or probes a runtime. */
37
+ export async function checkPublicEndpoint(publicOrigin, {
38
+ requestImpl = httpsRequest, timeoutMs = DEADLINE_MS,
39
+ } = {}) {
40
+ const origin = parseDoctorOrigin(publicOrigin);
41
+ if (typeof requestImpl !== 'function') throw new TypeError('An HTTPS request function is required');
42
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEADLINE_MS) {
43
+ throw new TypeError('Doctor deadline must be between 1 and 5000 milliseconds');
44
+ }
45
+ return new Promise((resolve) => {
46
+ let request;
47
+ let response;
48
+ let settled = false;
49
+ let bytes = 0;
50
+ const chunks = [];
51
+ const finish = (code) => {
52
+ if (settled) return;
53
+ settled = true;
54
+ clearTimeout(deadline);
55
+ if (response) {
56
+ response.off('data', receiveData);
57
+ response.off('end', receiveEnd);
58
+ response.destroy();
59
+ }
60
+ request?.destroy();
61
+ resolve({ ok: code === 'reachable', code, runtimeVerified: false });
62
+ };
63
+ const receiveData = (chunk) => {
64
+ if (settled) return;
65
+ if (!Buffer.isBuffer(chunk)) { finish('unexpected_response'); return; }
66
+ bytes += chunk.length;
67
+ if (bytes > MAX_BODY_BYTES) { finish('unexpected_response'); return; }
68
+ chunks.push(chunk);
69
+ };
70
+ const receiveEnd = () => {
71
+ if (response.complete === false) { finish('unexpected_response'); return; }
72
+ finish(isRelayHealthResponse(chunks) ? 'reachable' : 'unexpected_response');
73
+ };
74
+ const deadline = setTimeout(() => finish('timeout'), timeoutMs);
75
+ const receiveResponse = (incoming) => {
76
+ response = incoming;
77
+ response.on('error', () => finish('https_unavailable'));
78
+ response.on('aborted', () => finish('https_unavailable'));
79
+ if (settled) { response.destroy(); return; }
80
+ if (response.statusCode >= 300 && response.statusCode < 400) {
81
+ finish('redirect_rejected'); return;
82
+ }
83
+ if (response.statusCode !== 200) { finish('unexpected_response'); return; }
84
+ const contentType = response.headers['content-type'];
85
+ if (typeof contentType !== 'string' || contentType.split(';')[0].trim().toLowerCase() !== 'application/json') {
86
+ finish('unexpected_response'); return;
87
+ }
88
+ if (hasOversizedDeclaredBody(response)) { finish('unexpected_response'); return; }
89
+ response.on('data', receiveData);
90
+ response.once('end', receiveEnd);
91
+ };
92
+ try {
93
+ request = requestImpl(new URL('/health', origin), {
94
+ method: 'GET', headers: { Accept: 'application/json' },
95
+ rejectUnauthorized: true, agent: false, maxHeaderSize: 8192,
96
+ }, receiveResponse);
97
+ request.on('error', () => finish('https_unavailable'));
98
+ request.on('upgrade', (_incoming, socket) => {
99
+ socket.destroy();
100
+ finish('unexpected_response');
101
+ });
102
+ if (settled) { request.destroy(); return; }
103
+ request.end();
104
+ } catch { finish('https_unavailable'); }
105
+ });
106
+ }
@@ -0,0 +1,41 @@
1
+ import { validateRuntimeGrants } from '../auth/runtime-credentials.mjs';
2
+ import { validateNotificationDeliveryConfiguration } from '../notifications/notification-delivery.mjs';
3
+
4
+ function isCanonicalHttpsOrigin(value) {
5
+ if (typeof value !== 'string') return false;
6
+ try {
7
+ const origin = new URL(value);
8
+ return origin.protocol === 'https:' && origin.origin === value;
9
+ } catch { return false; }
10
+ }
11
+
12
+ /** Pure preflight: no listener, credentials or state are created here. */
13
+ export function validateConfiguration(config) {
14
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
15
+ throw new Error('Invalid configuration');
16
+ }
17
+ if (!isCanonicalHttpsOrigin(config.publicOrigin)) {
18
+ throw new Error('Configure a canonical HTTPS publicOrigin');
19
+ }
20
+ const port = config.port ?? 8788;
21
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
22
+ throw new Error('Invalid listener port');
23
+ }
24
+ if (typeof config.stateDirectory !== 'string' || !config.stateDirectory.trim() ||
25
+ config.stateDirectory.length > 4096 || /[\x00-\x1f\x7f]/.test(config.stateDirectory)) {
26
+ throw new Error('Configure a private stateDirectory');
27
+ }
28
+ if (Object.hasOwn(config, 'mail') || Object.hasOwn(config, 'allowedEmail')) {
29
+ throw new Error('Email configuration is unsupported; create a pairing installation');
30
+ }
31
+ const notifications = config.notifications ?? {};
32
+ if (!notifications || typeof notifications !== 'object' || Array.isArray(notifications) ||
33
+ Object.keys(notifications).some(key => key !== 'delivery')) {
34
+ throw new Error('Invalid notification configuration');
35
+ }
36
+ return {
37
+ publicOrigin: config.publicOrigin, port, stateDirectory: config.stateDirectory,
38
+ runtimeGrants: validateRuntimeGrants(config.runtimeGrants),
39
+ notifications: { delivery: validateNotificationDeliveryConfiguration(notifications.delivery) },
40
+ };
41
+ }
@@ -0,0 +1,57 @@
1
+ import { openSync, closeSync, fstatSync, readSync, constants } from 'node:fs';
2
+ import { validateConfiguration } from './config-validation.mjs';
3
+ import { dirname, resolve } from 'node:path';
4
+
5
+ const MAX_CONFIG_BYTES = 32_768;
6
+
7
+ function selectEnvironmentValue(env, primary, legacy) {
8
+ const preferred = env[primary];
9
+ const fallback = env[legacy];
10
+ if (preferred && fallback && preferred !== fallback) {
11
+ throw new Error(`Conflicting ${primary} and ${legacy} settings`);
12
+ }
13
+ const value = preferred || fallback;
14
+ if (value !== undefined && typeof value !== 'string') {
15
+ throw new Error(`Invalid ${primary} setting`);
16
+ }
17
+ return value;
18
+ }
19
+
20
+ function isPrivateConfigurationFile(stat) {
21
+ if (!stat.isFile()) return false;
22
+ if (stat.size > MAX_CONFIG_BYTES) return false;
23
+ return (stat.mode & 0o077) === 0;
24
+ }
25
+
26
+ export function readPrivateConfiguration(path) {
27
+ let descriptor;
28
+ try {
29
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
30
+ if (!isPrivateConfigurationFile(fstatSync(descriptor))) throw new Error();
31
+ const buffer = Buffer.alloc(MAX_CONFIG_BYTES + 1);
32
+ let bytes = 0;
33
+ while (bytes < buffer.length) {
34
+ const count = readSync(descriptor, buffer, bytes, buffer.length - bytes, null);
35
+ if (count === 0) break;
36
+ bytes += count;
37
+ }
38
+ if (bytes > MAX_CONFIG_BYTES) throw new Error();
39
+ return JSON.parse(buffer.toString('utf8', 0, bytes));
40
+ } catch {
41
+ // Filesystem and JSON errors may contain paths or configuration values.
42
+ throw new Error('Configuration must be a small private file with mode 0600 and valid JSON');
43
+ } finally {
44
+ if (descriptor !== undefined) closeSync(descriptor);
45
+ }
46
+ }
47
+
48
+ export function loadConfiguration(env = process.env) {
49
+ const path = selectEnvironmentValue(env, 'ABOT_SOCKET_CONFIG', 'SOCKET_V2_CONFIG');
50
+ if (!path) throw new Error('Set ABOT_SOCKET_CONFIG to a private JSON configuration file');
51
+ const config = validateConfiguration(readPrivateConfiguration(path));
52
+ const delivery = config.notifications.delivery;
53
+ if (delivery.type === 'fcm') {
54
+ delivery.serviceAccountFile = resolve(dirname(resolve(path)), delivery.serviceAccountFile);
55
+ }
56
+ return { ...config, stateDirectory: resolve(dirname(resolve(path)), config.stateDirectory) };
57
+ }
@@ -0,0 +1,42 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { userInfo } from 'node:os';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { resolve } from 'node:path';
5
+ import { loadConfiguration } from '../config/config.mjs';
6
+ import { writeDeploymentPlan } from './deployment-plan.mjs';
7
+
8
+ const flags = new Map([['--output', 'output'], ['--proxy', 'proxy'], ['--user', 'username'],
9
+ ['--certificate', 'certificatePath'], ['--private-key', 'keyPath']]);
10
+
11
+ export function parseDeploymentArguments(args) {
12
+ const result = {};
13
+ for (let i = 0; i < args.length; i += 2) {
14
+ const name = flags.get(args[i]);
15
+ const value = args[i + 1];
16
+ if (!name || Object.hasOwn(result, name) || !value || value.startsWith('--')) {
17
+ throw new Error('Invalid deployment options');
18
+ }
19
+ result[name] = value;
20
+ }
21
+ if (!result.output || !result.proxy) throw new Error('Deployment requires output and proxy selection');
22
+ result.output = resolve(result.output);
23
+ return result;
24
+ }
25
+
26
+ export function deploymentRuntimePaths() {
27
+ return { nodePath: realpathSync(process.execPath),
28
+ entryPath: fileURLToPath(new URL('../../bin/abot-socket.mjs', import.meta.url)) };
29
+ }
30
+
31
+ export function prepareDeployment(args, { env, out, platform = process.platform, username = userInfo().username }) {
32
+ const options = parseDeploymentArguments(args);
33
+ if (options.username && options.username !== username) {
34
+ throw new Error('Generate deployment files as the installation owner');
35
+ }
36
+ const config = loadConfiguration(env);
37
+ const output = writeDeploymentPlan({ ...options, ...deploymentRuntimePaths(), config,
38
+ configPath: resolve(env.ABOT_SOCKET_CONFIG || env.SOCKET_V2_CONFIG), platform, username });
39
+ out(`Deployment files prepared in ${JSON.stringify(options.output)}. Review the instructions before applying them.`);
40
+ out('No service, certificate, proxy configuration or package was installed.');
41
+ return output;
42
+ }
@@ -0,0 +1,76 @@
1
+ import { posix } from 'node:path';
2
+ import { validateConfiguration } from '../config/config-validation.mjs';
3
+
4
+ function requireLinuxPlatform(platform) {
5
+ if (platform !== 'linux') throw new Error('Deployment plans target Linux with systemd only');
6
+ }
7
+
8
+ function requireServiceUsername(username) {
9
+ if (typeof username !== 'string' || !/^[a-z_][a-z0-9_-]{0,31}$/.test(username)) {
10
+ throw new Error('Choose an unprivileged Linux service username');
11
+ }
12
+ if (username === 'root') throw new Error('The relay service must not run as root');
13
+ return username;
14
+ }
15
+
16
+ function requireDeploymentPath(value, label) {
17
+ // Spaces and percent signs are handled by each target syntax. Other shell,
18
+ // proxy-variable and control characters are deliberately unsupported.
19
+ if (typeof value !== 'string' || value.length > 4096 ||
20
+ !/^\/[A-Za-z0-9_./ %+-]+$/.test(value) || value === '/' ||
21
+ posix.normalize(value) !== value || value.endsWith('/')) {
22
+ throw new Error(`${label} requires an absolute normalized path containing letters, numbers, spaces, . _ - + or %`);
23
+ }
24
+ return value;
25
+ }
26
+
27
+ function requireProxyHost(origin) {
28
+ const labels = origin.hostname.split('.');
29
+ if (origin.hostname.length > 253 || labels.length < 2 ||
30
+ labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
31
+ throw new Error('HTTPS deployment requires a canonical DNS hostname');
32
+ }
33
+ if (labels.every((label) => /^\d+$/.test(label))) {
34
+ throw new Error('HTTPS deployment requires a DNS hostname, not an IP address');
35
+ }
36
+ }
37
+
38
+ function requireHttpsListenerPort(origin, relayPort) {
39
+ const port = origin.port ? Number(origin.port) : 443;
40
+ if (port < 1 || port > 65535) throw new Error('HTTPS requires a valid listener port');
41
+ if (port === relayPort) throw new Error('HTTPS and the private relay require different listener ports');
42
+ return port;
43
+ }
44
+
45
+ function requirePersistentServicePath(path) {
46
+ const temporaryRoots = ['/tmp', '/var/tmp', '/private/tmp', '/private/var/tmp'];
47
+ if (temporaryRoots.some((root) => path === root || path.startsWith(`${root}/`))) {
48
+ throw new Error('The system service requires persistent paths outside temporary storage');
49
+ }
50
+ }
51
+
52
+ export function validateDeploymentInput(options) {
53
+ requireLinuxPlatform(options.platform ?? 'linux');
54
+ const config = validateConfiguration(options.config);
55
+ const origin = new URL(config.publicOrigin);
56
+ requireProxyHost(origin);
57
+ const proxy = options.proxy ?? 'nginx';
58
+ if (!['nginx', 'apache', 'none'].includes(proxy)) throw new Error('Choose nginx, apache or none for the HTTPS plan');
59
+ const configPath = requireDeploymentPath(options.configPath, 'Configuration');
60
+ const statePath = requireDeploymentPath(posix.resolve(posix.dirname(configPath), config.stateDirectory), 'State directory');
61
+ const nodePath = requireDeploymentPath(options.nodePath, 'Node executable');
62
+ const entryPath = requireDeploymentPath(options.entryPath, 'Installed command');
63
+ for (const path of [configPath, statePath, nodePath, entryPath]) requirePersistentServicePath(path);
64
+ if (entryPath.includes('/_npx/')) throw new Error('Install the package persistently before preparing a service');
65
+ return {
66
+ config, configPath, statePath, nodePath, entryPath, proxy,
67
+ username: requireServiceUsername(options.username),
68
+ certificatePath: proxy === 'none' ? undefined : requireDeploymentPath(options.certificatePath, 'TLS certificate'),
69
+ keyPath: proxy === 'none' ? undefined : requireDeploymentPath(options.keyPath, 'TLS private key'),
70
+ hostname: origin.hostname, authority: origin.host,
71
+ httpsPort: requireHttpsListenerPort(origin, config.port),
72
+ };
73
+ }
74
+
75
+ export const systemdQuoted = (value) => `"${value.replaceAll('%', '%%')}"`;
76
+ export const proxyQuoted = (value) => `"${value}"`;
@@ -0,0 +1,62 @@
1
+ import * as fs from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { validateDeploymentInput } from './deployment-input.mjs';
4
+ import { deploymentCommands, renderApacheProxy, renderNginxProxy, renderSystemdService } from './deployment-templates.mjs';
5
+
6
+ export function renderDeploymentPlan(options) {
7
+ const input = validateDeploymentInput(options);
8
+ const notes = [
9
+ 'This is a reviewable Linux deployment plan. No service, proxy, certificate or system setting has been changed.',
10
+ 'Run the listed commands manually from the generated directory, one at a time; stop if any command fails.',
11
+ 'Inspect existing destination files before copying; these sample commands are for a new installation, not an automatic update.',
12
+ `The existing unprivileged service user must be ${input.username} and must own the private configuration and state directory.`,
13
+ 'The service user needs execute/read access to the installed Node and package paths. Do not use an npx cache path.',
14
+ 'State must already exist with mode 0700, and its database/configuration files must remain 0600. Persistent writes are limited to that directory.',
15
+ 'TLS certificate/key files, DNS and firewall access must be configured separately. This plan never requests or copies a private key.',
16
+ 'Use a local direct HTTPS proxy. CDN or additional proxy chains require a separately reviewed trusted-client-IP policy.',
17
+ 'Keep request bodies, pairing details and Authorization headers out of proxy logs; review inherited logging settings.',
18
+ 'Regenerate the plan if the Node executable, package installation or private configuration location changes.',
19
+ 'After service and HTTPS checks pass, run the pairing command for fresh credentials; these files contain no pairing code.',
20
+ ];
21
+ if (input.proxy === 'apache') notes.push(
22
+ 'The Apache command paths target Debian/Ubuntu. Other distributions require their local module/site and service commands.',
23
+ 'Apache requires version 2.4.47+ and an existing Listen directive for the HTTPS port; do not add a duplicate listener.',
24
+ );
25
+ if (input.proxy === 'none') notes.push(
26
+ 'No proxy configuration was generated. Your existing HTTPS proxy must satisfy the documented Host, trusted-IP and WebSocket forwarding contract.',
27
+ );
28
+ const commands = deploymentCommands(input.proxy);
29
+ const files = {
30
+ 'abot-socket.service': renderSystemdService(input),
31
+ 'DEPLOYMENT.txt': [...notes, '', 'Review and run individually:', ...commands, ''].join('\n'),
32
+ };
33
+ if (input.proxy === 'nginx') files['nginx.conf'] = renderNginxProxy(input);
34
+ if (input.proxy === 'apache') files['apache.conf'] = renderApacheProxy(input);
35
+ return { files, notes, commands };
36
+ }
37
+
38
+ /** Only writes a new operator-selected directory. Never executes plan commands. */
39
+ export function writeDeploymentPlan({ output, ...options }, io = fs) {
40
+ if (typeof output !== 'string' || !output.trim()) throw new Error('Choose a new deployment output directory');
41
+ const plan = renderDeploymentPlan(options);
42
+ const directory = resolve(output);
43
+ io.mkdirSync(directory, { mode: 0o700 });
44
+ const created = [];
45
+ try {
46
+ for (const [name, content] of Object.entries(plan.files)) {
47
+ const path = join(directory, name);
48
+ const descriptor = io.openSync(path, 'wx', 0o600);
49
+ created.push(path);
50
+ try { io.writeFileSync(descriptor, content); } finally { io.closeSync(descriptor); }
51
+ }
52
+ } catch (error) {
53
+ const failures = [];
54
+ for (const path of created.reverse()) {
55
+ try { io.unlinkSync(path); } catch (failure) { failures.push(failure); }
56
+ }
57
+ try { io.rmdirSync(directory); } catch (failure) { failures.push(failure); }
58
+ if (failures.length) throw new AggregateError([error, ...failures], 'Deployment write failed and cleanup was incomplete');
59
+ throw error;
60
+ }
61
+ return { directory, ...plan };
62
+ }
@@ -0,0 +1,109 @@
1
+ import { posix } from 'node:path';
2
+ import { proxyQuoted, systemdQuoted } from './deployment-input.mjs';
3
+
4
+ export function renderSystemdService(input) {
5
+ return `[Unit]
6
+ Description=ABot Socket relay
7
+ After=network.target
8
+
9
+ [Service]
10
+ Type=simple
11
+ User=${input.username}
12
+ WorkingDirectory=${systemdQuoted(posix.dirname(input.configPath))}
13
+ ExecStart=${systemdQuoted(input.nodePath)} ${systemdQuoted(input.entryPath)} start --config ${systemdQuoted(input.configPath)}
14
+ Restart=on-failure
15
+ RestartSec=5s
16
+ UMask=0077
17
+ NoNewPrivileges=true
18
+ PrivateTmp=true
19
+ ProtectSystem=strict
20
+ ReadWritePaths=${systemdQuoted(input.statePath)}
21
+ StandardOutput=journal
22
+ StandardError=journal
23
+
24
+ [Install]
25
+ WantedBy=multi-user.target
26
+ `;
27
+ }
28
+
29
+ export function renderNginxProxy(input) {
30
+ return `# Include inside nginx's http context. Existing TLS files are required.
31
+ # Review global real_ip/CDN configuration before using this direct-proxy plan.
32
+ server {
33
+ listen ${input.httpsPort} ssl;
34
+ server_name ${input.hostname};
35
+ ssl_certificate ${proxyQuoted(input.certificatePath)};
36
+ ssl_certificate_key ${proxyQuoted(input.keyPath)};
37
+ ssl_protocols TLSv1.2 TLSv1.3;
38
+ client_max_body_size 4k;
39
+ client_body_timeout 15s;
40
+ access_log off;
41
+
42
+ location / {
43
+ proxy_pass http://127.0.0.1:${input.config.port};
44
+ proxy_http_version 1.1;
45
+ # Preserve the real authority, including any explicit port. The relay
46
+ # rejects an unknown Host; never replace it with a configured constant.
47
+ proxy_set_header Host $http_host;
48
+ proxy_set_header Origin $http_origin;
49
+ proxy_set_header Authorization $http_authorization;
50
+ proxy_set_header X-Forwarded-Proto $scheme;
51
+ proxy_set_header X-Socket-Client-IP $remote_addr;
52
+ proxy_set_header Upgrade $http_upgrade;
53
+ proxy_set_header Connection "upgrade";
54
+ proxy_connect_timeout 5s;
55
+ proxy_read_timeout 90s;
56
+ proxy_send_timeout 90s;
57
+ proxy_buffering off;
58
+ }
59
+ }
60
+ `;
61
+ }
62
+
63
+ export function renderApacheProxy(input) {
64
+ return `# Apache 2.4.47+; enable ssl, headers, proxy and proxy_http modules.
65
+ # Add a matching Listen directive only if this HTTPS port is not already active.
66
+ # This direct-proxy plan uses the actual connection peer, not forwarded headers.
67
+ <VirtualHost *:${input.httpsPort}>
68
+ ServerName ${input.authority}
69
+ SSLEngine on
70
+ SSLCertificateFile ${proxyQuoted(input.certificatePath)}
71
+ SSLCertificateKeyFile ${proxyQuoted(input.keyPath)}
72
+ SSLProtocol -all +TLSv1.2 +TLSv1.3
73
+ ProxyRequests Off
74
+ ProxyPreserveHost On
75
+ ProxyTimeout 90
76
+ LimitRequestBody 4096
77
+
78
+ RequestHeader unset X-Forwarded-Proto
79
+ RequestHeader set X-Forwarded-Proto "https"
80
+ RequestHeader unset X-Socket-Client-IP
81
+ RequestHeader set X-Socket-Client-IP "expr=%{CONN_REMOTE_ADDR}"
82
+ # Host, Authorization and Origin are forwarded unchanged. Unknown Host
83
+ # values reach the relay unchanged and are rejected by its authority check.
84
+ ProxyPass "/" "http://127.0.0.1:${input.config.port}/" upgrade=websocket connectiontimeout=5 timeout=90
85
+ ProxyPassReverse "/" "http://127.0.0.1:${input.config.port}/"
86
+ </VirtualHost>
87
+ `;
88
+ }
89
+
90
+ export function deploymentCommands(proxy) {
91
+ const commands = [
92
+ 'sudo install -m 0644 -- ./abot-socket.service /etc/systemd/system/abot-socket.service',
93
+ 'sudo systemctl daemon-reload',
94
+ 'sudo systemctl enable --now abot-socket.service',
95
+ ];
96
+ if (proxy === 'none') return commands;
97
+ if (proxy === 'nginx') return [...commands,
98
+ 'sudo install -m 0644 -- ./nginx.conf /etc/nginx/conf.d/abot-socket.conf',
99
+ 'sudo nginx -t',
100
+ 'sudo systemctl reload nginx',
101
+ ];
102
+ return [...commands,
103
+ 'sudo install -m 0644 -- ./apache.conf /etc/apache2/sites-available/abot-socket.conf',
104
+ 'sudo a2enmod ssl headers proxy proxy_http',
105
+ 'sudo a2ensite abot-socket.conf',
106
+ 'sudo apachectl configtest',
107
+ 'sudo systemctl reload apache2',
108
+ ];
109
+ }
package/src/main.mjs ADDED
@@ -0,0 +1,26 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { resolve } from 'node:path';
3
+ import { loadConfiguration } from './config/config.mjs';
4
+ import { createSocketServer } from './transport/server.mjs';
5
+
6
+ export async function startConfiguredServer(env = process.env) {
7
+ const config = loadConfiguration(env);
8
+ const app = createSocketServer(config);
9
+ try { await app.listen(config.port); }
10
+ catch (error) { await app.close(); throw error; }
11
+ return app;
12
+ }
13
+
14
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
15
+ try {
16
+ const app = await startConfiguredServer();
17
+ console.log('ABot Socket listening on loopback behind the configured HTTPS reverse proxy.');
18
+ const stop = () => { void app.close(); };
19
+ process.once('SIGTERM', stop);
20
+ process.once('SIGINT', stop);
21
+ } catch {
22
+ // Configuration/provider errors can contain secrets; never print them here.
23
+ console.error('ABot Socket could not start. Check private configuration, state permissions and listener availability.');
24
+ process.exitCode = 1;
25
+ }
26
+ }