@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,132 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from 'node:fs';
3
+ import { basename, dirname, join, parse, resolve } from 'node:path';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { assertPrivateStateDirectory, assertSameDatabaseFile } from '../auth/auth-storage-path.mjs';
6
+ import { validateConfiguration } from '../config/config-validation.mjs';
7
+
8
+ const MAX_PRIVATE_BYTES = 32_768;
9
+ const PORTAL_KEYS = ['version', 'purpose', 'demoId', 'ownerId', 'configSha256',
10
+ 'publicOrigin', 'port', 'username', 'passwordSha256'];
11
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
12
+
13
+ /** Bind only the demo identity; operators may configure its modes and push later. */
14
+ export function reviewDemoIdentitySha256(config) {
15
+ const runtimeGrants = config.runtimeGrants.map(({ runtimeId, tokenSha256 }) => ({ runtimeId, tokenSha256 }))
16
+ .sort((left, right) => left.runtimeId.localeCompare(right.runtimeId));
17
+ return digest(JSON.stringify({ publicOrigin: config.publicOrigin,
18
+ stateDirectory: config.stateDirectory, runtimeGrants }));
19
+ }
20
+
21
+ export function assertReviewEndpointSeparation(socketConfig, publicOrigin, port) {
22
+ let origin;
23
+ try { origin = new URL(publicOrigin); } catch { /* Validated below. */ }
24
+ if (!origin || origin.protocol !== 'https:' || origin.origin !== publicOrigin || origin.username || origin.password) {
25
+ throw new Error('Review portal requires a canonical HTTPS origin');
26
+ }
27
+ if (publicOrigin === socketConfig.publicOrigin) throw new Error('Socket and review origins must differ');
28
+ if (!Number.isInteger(port) || port < 1024 || port > 65535 || port === socketConfig.port) {
29
+ throw new Error('Socket and review listener ports must differ and be unprivileged');
30
+ }
31
+ }
32
+
33
+ export function assertReviewDemoParentPath(directory) {
34
+ let current = parse(directory).root;
35
+ for (const component of directory.slice(current.length).split('/').filter(Boolean)) {
36
+ current = join(current, component);
37
+ const entry = lstatSync(current);
38
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error('Unsafe demo installation path');
39
+ }
40
+ }
41
+
42
+ function assertPrivateDemoDirectory(directory) {
43
+ assertReviewDemoParentPath(directory);
44
+ const stat = lstatSync(directory);
45
+ if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o7777) !== 0o700) {
46
+ throw new Error('Demo installation must be private and owned by the current user');
47
+ }
48
+ }
49
+
50
+ function readPrivateDemoJson(path) {
51
+ let descriptor;
52
+ try {
53
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
54
+ const stat = fstatSync(descriptor);
55
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() ||
56
+ (stat.mode & 0o7777) !== 0o600 || stat.size > MAX_PRIVATE_BYTES) throw new Error();
57
+ const bytes = Buffer.alloc(MAX_PRIVATE_BYTES + 1);
58
+ let length = 0;
59
+ while (length < bytes.length) {
60
+ const count = readSync(descriptor, bytes, length, bytes.length - length, null);
61
+ if (count === 0) break;
62
+ length += count;
63
+ }
64
+ if (length > MAX_PRIVATE_BYTES) throw new Error();
65
+ return JSON.parse(bytes.toString('utf8', 0, length));
66
+ } finally { if (descriptor !== undefined) closeSync(descriptor); }
67
+ }
68
+
69
+ function validatePortalRecord(value, socketConfig) {
70
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
71
+ Object.keys(value).length !== PORTAL_KEYS.length || Object.keys(value).some(key => !PORTAL_KEYS.includes(key))) {
72
+ throw new Error('Invalid review portal configuration');
73
+ }
74
+ if (value.version !== 1 || value.purpose !== 'review-demo' || value.username !== 'reviewer' ||
75
+ typeof value.demoId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value.demoId) ||
76
+ typeof value.ownerId !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value.ownerId)) {
77
+ throw new Error('Invalid review demo identity');
78
+ }
79
+ for (const key of ['configSha256', 'passwordSha256']) {
80
+ if (typeof value[key] !== 'string' || !/^[a-f0-9]{64}$/.test(value[key])) throw new Error('Invalid review credential binding');
81
+ }
82
+ assertReviewEndpointSeparation(socketConfig, value.publicOrigin, value.port);
83
+ if (value.configSha256 !== reviewDemoIdentitySha256(socketConfig)) throw new Error('Demo socket identity changed');
84
+ }
85
+
86
+ function assertMarkedDemoDatabase(stateDirectory, portal) {
87
+ assertPrivateStateDirectory(stateDirectory);
88
+ const path = join(stateDirectory, 'auth.sqlite3');
89
+ const prepared = { path, stat: lstatSync(path) };
90
+ // Never instantiate AuthDatabase here: it can initialize or migrate state.
91
+ const database = new DatabaseSync(path, { readOnly: true });
92
+ try {
93
+ assertSameDatabaseFile(stateDirectory, prepared);
94
+ if (database.prepare('PRAGMA application_id').get().application_id !== 0x41425341) throw new Error();
95
+ const read = database.prepare('SELECT value FROM metadata WHERE key = ?');
96
+ if (read.get('review_demo_id')?.value !== portal.demoId || read.get('owner_id')?.value !== portal.ownerId) {
97
+ throw new Error('State does not belong to this review demo');
98
+ }
99
+ assertSameDatabaseFile(stateDirectory, prepared);
100
+ } finally { database.close(); }
101
+ }
102
+
103
+ function selectedDemoConfiguration(env) {
104
+ const primary = env.ABOT_SOCKET_CONFIG;
105
+ const legacy = env.SOCKET_V2_CONFIG;
106
+ if (primary && legacy && primary !== legacy) throw new Error('Conflicting demo configuration paths');
107
+ const value = primary || legacy;
108
+ if (typeof value !== 'string' || !value.trim() || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value)) {
109
+ throw new Error('Choose a private demo config.json');
110
+ }
111
+ const path = resolve(value);
112
+ if (basename(path) !== 'config.json') throw new Error('Choose the demo config.json');
113
+ return path;
114
+ }
115
+
116
+ /** Separate-process gate: fixed siblings and existing marked state only. */
117
+ export function loadReviewDemoConfiguration(env = {}) {
118
+ try {
119
+ const configPath = selectedDemoConfiguration(env);
120
+ const directory = dirname(configPath);
121
+ assertPrivateDemoDirectory(directory);
122
+ const socketConfig = validateConfiguration(readPrivateDemoJson(configPath));
123
+ if (socketConfig.stateDirectory !== './state') throw new Error('Demo state must remain its fixed sibling');
124
+ const portal = readPrivateDemoJson(join(directory, 'review-portal.json'));
125
+ validatePortalRecord(portal, socketConfig);
126
+ const stateDirectory = join(directory, 'state');
127
+ assertMarkedDemoDatabase(stateDirectory, portal);
128
+ return Object.freeze({ ...portal, socketOrigin: socketConfig.publicOrigin, stateDirectory });
129
+ } catch {
130
+ throw new Error('Review portal requires a private marked demo installation with matching socket and state identities');
131
+ }
132
+ }
@@ -0,0 +1,97 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import * as filesystem from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { AuthDatabase } from '../auth/auth-database.mjs';
5
+ import { prepareInstallation } from '../setup/setup.mjs';
6
+ import { writePrivateInstallation } from '../setup/setup-files.mjs';
7
+ import { assertReviewDemoParentPath, assertReviewEndpointSeparation, reviewDemoIdentitySha256 } from './review-demo-isolation.mjs';
8
+
9
+ const OPTIONS = new Map([['--output', 'output'], ['--origin', 'origin'], ['--runtime-id', 'runtimeId'],
10
+ ['--review-origin', 'reviewOrigin'], ['--port', 'port'], ['--review-port', 'reviewPort']]);
11
+
12
+ export function parseReviewDemoArguments(args) {
13
+ const options = {};
14
+ for (let index = 0; index < args.length; index += 2) {
15
+ const name = OPTIONS.get(args[index]);
16
+ const value = args[index + 1];
17
+ if (!name || Object.hasOwn(options, name) || typeof value !== 'string' || !value || value.startsWith('--')) {
18
+ throw new Error('Provide each review setup option once with a value');
19
+ }
20
+ if (name === 'port' || name === 'reviewPort') {
21
+ if (!/^\d{1,5}$/.test(value)) throw new Error('Invalid review listener port');
22
+ options[name] = Number(value);
23
+ continue;
24
+ }
25
+ options[name] = value;
26
+ }
27
+ for (const name of ['output', 'origin', 'runtimeId', 'reviewOrigin']) {
28
+ if (!options[name]) throw new Error('Missing required review setup option');
29
+ }
30
+ return options;
31
+ }
32
+
33
+ function writePrivateReviewFile(path, content, created, io) {
34
+ const descriptor = io.openSync(path, 'wx', 0o600);
35
+ created.push(path);
36
+ try { io.writeFileSync(descriptor, content); io.fsyncSync(descriptor); }
37
+ finally { io.closeSync(descriptor); }
38
+ }
39
+
40
+ function removeOwnedFailedDemo(directory, created, stateCreated, io) {
41
+ const failures = [];
42
+ for (const path of created.reverse()) {
43
+ try { io.unlinkSync(path); } catch (error) { failures.push(error); }
44
+ }
45
+ if (stateCreated) {
46
+ const state = join(directory, 'state');
47
+ for (const name of ['auth.sqlite3-journal', 'auth.sqlite3']) {
48
+ try { io.unlinkSync(join(state, name)); } catch (error) { if (error.code !== 'ENOENT') failures.push(error); }
49
+ }
50
+ try { io.rmdirSync(state); } catch (error) { failures.push(error); }
51
+ }
52
+ try { io.rmdirSync(directory); } catch (error) { failures.push(error); }
53
+ return failures;
54
+ }
55
+
56
+ /** Local file preparation only; no existing installation, services or runtime is reused. */
57
+ export function createReviewDemoInstallation(options, { io = filesystem } = {}) {
58
+ if (typeof options.output !== 'string' || !options.output.trim()) throw new Error('Choose a new private demo directory');
59
+ const bundle = prepareInstallation(options);
60
+ const port = options.reviewPort ?? 8789;
61
+ assertReviewEndpointSeparation(bundle.config, options.reviewOrigin, port);
62
+ const directory = resolve(options.output);
63
+ assertReviewDemoParentPath(dirname(directory));
64
+ writePrivateInstallation(directory, bundle, io);
65
+ const created = [join(directory, 'config.json'), join(directory, 'runtime-connection.json')];
66
+ let stateCreated = false;
67
+ try {
68
+ const stateDirectory = join(directory, 'state');
69
+ io.mkdirSync(stateDirectory, { mode: 0o700 });
70
+ stateCreated = true;
71
+ const database = new AuthDatabase(stateDirectory);
72
+ const demoId = randomBytes(32).toString('base64url');
73
+ let ownerId;
74
+ try {
75
+ ownerId = database.ownerId;
76
+ database.transaction(() => database.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)')
77
+ .run('review_demo_id', demoId));
78
+ } finally { database.close(); }
79
+ const password = randomBytes(32).toString('base64url');
80
+ const portal = { version: 1, purpose: 'review-demo', demoId, ownerId,
81
+ configSha256: reviewDemoIdentitySha256(bundle.config), publicOrigin: options.reviewOrigin,
82
+ port, username: 'reviewer', passwordSha256: createHash('sha256').update(password).digest('hex') };
83
+ writePrivateReviewFile(join(directory, 'review-portal.json'), `${JSON.stringify(portal, null, 2)}\n`, created, io);
84
+ writePrivateReviewFile(join(directory, 'review-access.txt'),
85
+ `ABot isolated review demo\nPortal: ${portal.publicOrigin}/review\nUsername: reviewer\nPassword: ${password}\n\n` +
86
+ 'Keep these reviewer credentials private. The portal issues normal single-use 5-minute pairing details.\n' +
87
+ 'The demo runtime must use separate storage and synthetic data; this setup does not create or isolate a runtime.\n', created, io);
88
+ return { directory, configPath: join(directory, 'config.json'),
89
+ runtimeConnectionPath: join(directory, 'runtime-connection.json'),
90
+ reviewConfigurationPath: join(directory, 'review-portal.json'),
91
+ reviewAccessPath: join(directory, 'review-access.txt') };
92
+ } catch (error) {
93
+ const failures = removeOwnedFailedDemo(directory, created, stateCreated, io);
94
+ if (failures.length) throw new AggregateError([error, ...failures], 'Review setup failed and owned cleanup was incomplete');
95
+ throw error;
96
+ }
97
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { WindowLimiter } from '../transport/transport-limits.mjs';
3
+
4
+ const CSRF_MS = 10 * 60 * 1000;
5
+
6
+ function matchesReviewerAuthorization(value, username, passwordSha256) {
7
+ if (typeof value !== 'string' || value.length > 512) return false;
8
+ const match = /^Basic ([A-Za-z0-9+/]+={0,2})$/.exec(value);
9
+ if (!match) return false;
10
+ const bytes = Buffer.from(match[1], 'base64');
11
+ if (bytes.toString('base64') !== match[1]) return false;
12
+ const decoded = bytes.toString('utf8');
13
+ const separator = decoded.indexOf(':');
14
+ if (separator < 0 || decoded.slice(0, separator) !== username) return false;
15
+ const password = decoded.slice(separator + 1);
16
+ if (!/^[A-Za-z0-9_-]{43}$/.test(password)) return false;
17
+ const digest = createHash('sha256').update(password).digest();
18
+ return timingSafeEqual(digest, Buffer.from(passwordSha256, 'hex'));
19
+ }
20
+
21
+ /** Browser access grants pairing issuance only, never a runtime or device token. */
22
+ export class ReviewPortalAccess {
23
+ constructor({ username, passwordSha256, now = Date.now }) {
24
+ if (username !== 'reviewer' || !/^[a-f0-9]{64}$/.test(passwordSha256 ?? '')) {
25
+ throw new Error('Invalid reviewer access configuration');
26
+ }
27
+ this.username = username;
28
+ this.passwordSha256 = passwordSha256;
29
+ this.now = now;
30
+ this.csrfKey = randomBytes(32);
31
+ this.failedSources = new WindowLimiter({ limit: 10, windowMs: 900_000, now });
32
+ this.failedTotal = new WindowLimiter({ limit: 200, windowMs: 900_000, capacity: 1, now });
33
+ this.pairSources = new WindowLimiter({ limit: 5, windowMs: 60_000, now });
34
+ this.pairTotal = new WindowLimiter({ limit: 20, windowMs: 60_000, capacity: 1, now });
35
+ }
36
+
37
+ authenticate(authorization, sourceId) {
38
+ if (matchesReviewerAuthorization(authorization, this.username, this.passwordSha256)) return 200;
39
+ if (!this.failedSources.allow(sourceId)) return 429;
40
+ if (!this.failedTotal.allow('failures')) return 429;
41
+ return 401;
42
+ }
43
+
44
+ createCsrf() {
45
+ const value = `${this.now()}.${randomBytes(16).toString('base64url')}`;
46
+ return `${value}.${createHmac('sha256', this.csrfKey).update(value).digest('base64url')}`;
47
+ }
48
+
49
+ acceptsCsrf(token) {
50
+ if (typeof token !== 'string' || token.length > 128) return false;
51
+ const match = /^(\d{1,16})\.([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{43})$/.exec(token);
52
+ if (!match) return false;
53
+ const issuedAt = Number(match[1]);
54
+ if (!Number.isSafeInteger(issuedAt) || issuedAt > this.now()) return false;
55
+ if (this.now() - issuedAt >= CSRF_MS) return false;
56
+ const expected = createHmac('sha256', this.csrfKey).update(`${match[1]}.${match[2]}`).digest('base64url');
57
+ return timingSafeEqual(Buffer.from(match[3]), Buffer.from(expected));
58
+ }
59
+
60
+ allowPairing(sourceId) {
61
+ if (!this.pairSources.allow(sourceId)) return false;
62
+ return this.pairTotal.allow('pairings');
63
+ }
64
+ }
@@ -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
+ }