@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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/bin/abot-socket.mjs +15 -0
- package/config.example.json +14 -0
- package/docs/APP-REVIEW.md +184 -0
- package/docs/DEPLOYMENT.md +124 -0
- package/docs/INSTALLATION.md +177 -0
- package/docs/MIGRATION.md +84 -0
- package/docs/PROTOCOL.md +371 -0
- package/docs/SECURITY.md +116 -0
- package/docs/SELF-HOSTING.md +161 -0
- package/package.json +65 -4
- package/src/auth/auth-database.mjs +105 -0
- package/src/auth/auth-limits.mjs +17 -0
- package/src/auth/auth-pairing.mjs +95 -0
- package/src/auth/auth-sessions.mjs +119 -0
- package/src/auth/auth-storage-path.mjs +70 -0
- package/src/auth/auth.mjs +45 -0
- package/src/auth/runtime-credentials.mjs +39 -0
- package/src/cli/cli-arguments.mjs +23 -0
- package/src/cli/cli-entry.mjs +84 -0
- package/src/cli/cli-environment.mjs +31 -0
- package/src/cli/cli-help.mjs +34 -0
- package/src/cli/cli-output.mjs +35 -0
- package/src/cli/cli.mjs +58 -0
- package/src/cli/client-capabilities.mjs +14 -0
- package/src/cli/doctor.mjs +106 -0
- package/src/config/config-validation.mjs +41 -0
- package/src/config/config.mjs +57 -0
- package/src/deployment/deployment-command.mjs +42 -0
- package/src/deployment/deployment-input.mjs +76 -0
- package/src/deployment/deployment-plan.mjs +62 -0
- package/src/deployment/deployment-templates.mjs +109 -0
- package/src/main.mjs +26 -0
- package/src/notifications/notification-delivery-credentials.mjs +48 -0
- package/src/notifications/notification-delivery-fcm.mjs +100 -0
- package/src/notifications/notification-delivery-gateway.mjs +36 -0
- package/src/notifications/notification-delivery-http.mjs +53 -0
- package/src/notifications/notification-delivery-payload.mjs +45 -0
- package/src/notifications/notification-delivery.mjs +43 -0
- package/src/notifications/notification-gateway-command.mjs +48 -0
- package/src/notifications/notification-gateway-store.mjs +159 -0
- package/src/notifications/notification-gateway.mjs +181 -0
- package/src/notifications/notification-http.mjs +39 -0
- package/src/notifications/notification-input.mjs +82 -0
- package/src/notifications/notification-outbox.mjs +60 -0
- package/src/notifications/notification-preview-crypto.mjs +42 -0
- package/src/notifications/notification-preview-schema.mjs +7 -0
- package/src/notifications/notification-preview-store.mjs +63 -0
- package/src/notifications/notification-preview-text.mjs +20 -0
- package/src/notifications/notification-projection.mjs +20 -0
- package/src/notifications/notification-schema.mjs +22 -0
- package/src/notifications/notification-service.mjs +147 -0
- package/src/notifications/notification-store.mjs +132 -0
- package/src/pairing/pairing-qr-payload.mjs +32 -0
- package/src/pairing/pairing-qr.mjs +42 -0
- package/src/protocol/approval-protocol.mjs +39 -0
- package/src/protocol/protocol-validation.mjs +137 -0
- package/src/protocol/protocol.mjs +120 -0
- package/src/protocol/spark-chat-protocol.mjs +51 -0
- package/src/relay/relay-approvals.mjs +86 -0
- package/src/relay/relay-client-admission.mjs +40 -0
- package/src/relay/relay-correlations.mjs +101 -0
- package/src/relay/relay-model-catalog.mjs +69 -0
- package/src/relay/relay-request-adoption.mjs +97 -0
- package/src/relay/relay-requests.mjs +211 -0
- package/src/relay/relay-runtime-registration.mjs +49 -0
- package/src/relay/relay-session-mutations.mjs +122 -0
- package/src/relay/relay.mjs +222 -0
- package/src/relay/request-ownership-schema.mjs +18 -0
- package/src/relay/request-ownership-store.mjs +115 -0
- package/src/review/review-demo-isolation.mjs +132 -0
- package/src/review/review-demo-setup.mjs +97 -0
- package/src/review/review-portal-access.mjs +64 -0
- package/src/review/review-portal-form.mjs +24 -0
- package/src/review/review-portal-page.mjs +68 -0
- package/src/review/review-portal-server.mjs +105 -0
- package/src/setup/setup-files.mjs +37 -0
- package/src/setup/setup-questions.mjs +62 -0
- package/src/setup/setup-wizard.mjs +50 -0
- package/src/setup/setup.mjs +42 -0
- package/src/transport/http-api.mjs +32 -0
- package/src/transport/server.mjs +175 -0
- package/src/transport/transport-limits.mjs +85 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { AuthError } from './auth-limits.mjs';
|
|
3
|
+
|
|
4
|
+
const SESSION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
5
|
+
const MAX_SESSIONS = 10;
|
|
6
|
+
const tokenHash = (database, token) => createHash('sha256').update(database.tokenSalt).update(token).digest('hex');
|
|
7
|
+
|
|
8
|
+
/** Called only inside the pairing transaction, before consuming its challenge. */
|
|
9
|
+
export function issueDeviceSession(database, time, deviceName) {
|
|
10
|
+
database.prepare('DELETE FROM devices WHERE expires_at <= ?').run(time);
|
|
11
|
+
const count = database.prepare('SELECT count(*) AS count FROM devices WHERE expires_at > ?').get(time).count;
|
|
12
|
+
if (count >= MAX_SESSIONS) throw new AuthError('device_limit', 429);
|
|
13
|
+
const token = randomBytes(32).toString('base64url');
|
|
14
|
+
const session = { id: randomUUID(), subject: database.ownerId, deviceName };
|
|
15
|
+
const expiresAt = time + SESSION_MS;
|
|
16
|
+
database.prepare(`INSERT INTO devices(id, subject, device_name, created_at, expires_at, token_hash)
|
|
17
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(session.id, session.subject, deviceName, time, expiresAt, tokenHash(database, token));
|
|
18
|
+
return { token, expiresAt, session };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertDeviceId(id) {
|
|
22
|
+
if (typeof id !== 'string' || !id || id.length > 64) throw new AuthError('invalid_request', 400);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class AuthSessionStore {
|
|
26
|
+
#database;
|
|
27
|
+
#principals = new WeakSet();
|
|
28
|
+
#listeners = new Set();
|
|
29
|
+
#observed = new Set();
|
|
30
|
+
|
|
31
|
+
constructor({ database, now }) {
|
|
32
|
+
this.#database = database;
|
|
33
|
+
this.now = now;
|
|
34
|
+
this.#observed = new Set(database.prepare('SELECT id FROM devices').all().map(({ id }) => id));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
authenticate(token) {
|
|
38
|
+
this.prune();
|
|
39
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
|
40
|
+
const record = this.#database.prepare('SELECT id, subject, expires_at FROM devices WHERE token_hash = ? AND expires_at > ?')
|
|
41
|
+
.get(tokenHash(this.#database, token), this.now());
|
|
42
|
+
if (!record) return null;
|
|
43
|
+
const principal = Object.freeze({ sessionId: record.id, subject: record.subject, expiresAt: record.expires_at });
|
|
44
|
+
this.#principals.add(principal);
|
|
45
|
+
return principal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
list(principal) {
|
|
49
|
+
this.prune();
|
|
50
|
+
return this.#database.transaction(() => {
|
|
51
|
+
this.#assertPrincipal(principal);
|
|
52
|
+
return this.#records().map((record) => ({ ...record, current: record.id === principal.sessionId }));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
listDevices() {
|
|
57
|
+
this.prune();
|
|
58
|
+
return this.#records();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
revoke(principal, id) {
|
|
62
|
+
this.prune();
|
|
63
|
+
assertDeviceId(id);
|
|
64
|
+
const changed = this.#database.transaction(() => {
|
|
65
|
+
this.#assertPrincipal(principal);
|
|
66
|
+
return this.#database.prepare('DELETE FROM devices WHERE id = ?').run(id).changes > 0;
|
|
67
|
+
});
|
|
68
|
+
if (changed) this.#notifyRemoval(id);
|
|
69
|
+
return changed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
revokeDevice(id) {
|
|
73
|
+
assertDeviceId(id);
|
|
74
|
+
const changed = this.#database.prepare('DELETE FROM devices WHERE id = ?').run(id).changes > 0;
|
|
75
|
+
if (changed) this.#notifyRemoval(id);
|
|
76
|
+
return changed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
subscribeRevocations(listener) {
|
|
80
|
+
if (typeof listener !== 'function') throw new TypeError('A revocation listener is required');
|
|
81
|
+
this.#listeners.add(listener);
|
|
82
|
+
return () => this.#listeners.delete(listener);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
prune() {
|
|
86
|
+
const active = this.#database.transaction(() => {
|
|
87
|
+
this.#database.prepare('DELETE FROM devices WHERE expires_at <= ?').run(this.now());
|
|
88
|
+
return new Set(this.#database.prepare('SELECT id FROM devices').all().map(({ id }) => id));
|
|
89
|
+
});
|
|
90
|
+
const removed = [...this.#observed].filter((id) => !active.has(id));
|
|
91
|
+
this.#observed = active;
|
|
92
|
+
for (const id of removed) this.#notifyRemoval(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
close() {
|
|
96
|
+
this.#listeners.clear();
|
|
97
|
+
this.#observed.clear();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#records() {
|
|
101
|
+
return this.#database.prepare(`SELECT id, device_name AS deviceName, created_at AS createdAt,
|
|
102
|
+
expires_at AS expiresAt FROM devices WHERE expires_at > ? ORDER BY created_at, id`).all(this.now())
|
|
103
|
+
.map((record) => ({ ...record }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#assertPrincipal(principal) {
|
|
107
|
+
if (!principal || !this.#principals.has(principal)) throw new AuthError('unauthorized', 401);
|
|
108
|
+
const record = this.#database.prepare('SELECT subject FROM devices WHERE id = ? AND expires_at > ?')
|
|
109
|
+
.get(principal.sessionId, this.now());
|
|
110
|
+
if (!record || record.subject !== principal.subject) throw new AuthError('unauthorized', 401);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#notifyRemoval(id) {
|
|
114
|
+
this.#observed.delete(id);
|
|
115
|
+
for (const listener of this.#listeners) {
|
|
116
|
+
try { listener(id); } catch { /* A listener cannot restore a revoked device. */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { constants, closeSync, lstatSync, mkdirSync, openSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, parse, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DATABASE_NAME = 'auth.sqlite3';
|
|
5
|
+
const STATE_FILES = new Set([DATABASE_NAME, `${DATABASE_NAME}-journal`]);
|
|
6
|
+
|
|
7
|
+
function assertOwnedPrivateEntry(path, stat, mode, directory) {
|
|
8
|
+
const correctType = directory ? stat.isDirectory() : stat.isFile();
|
|
9
|
+
if (!correctType || stat.isSymbolicLink()) throw new Error(`Unsafe authentication state entry: ${basename(path)}`);
|
|
10
|
+
if (typeof process.getuid !== 'function' || stat.uid !== process.getuid()) {
|
|
11
|
+
throw new Error('Authentication state must belong to the current user');
|
|
12
|
+
}
|
|
13
|
+
if ((stat.mode & 0o7777) !== mode) throw new Error(`Authentication state requires mode ${mode.toString(8)}`);
|
|
14
|
+
if (!directory && stat.nlink !== 1) throw new Error('Authentication state files cannot have multiple links');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assertDirectoryAncestors(path) {
|
|
18
|
+
let current = parse(path).root;
|
|
19
|
+
for (const component of path.slice(current.length).split('/').filter(Boolean)) {
|
|
20
|
+
current = join(current, component);
|
|
21
|
+
const stat = lstatSync(current);
|
|
22
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('Authentication state path cannot contain symlinks');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertPrivateStateDirectory(stateDirectory) {
|
|
27
|
+
assertDirectoryAncestors(stateDirectory);
|
|
28
|
+
assertOwnedPrivateEntry(stateDirectory, lstatSync(stateDirectory), 0o700, true);
|
|
29
|
+
for (const name of readdirSync(stateDirectory)) {
|
|
30
|
+
if (!STATE_FILES.has(name)) throw new Error(`Unexpected authentication state entry: ${name}`);
|
|
31
|
+
const path = join(stateDirectory, name);
|
|
32
|
+
try {
|
|
33
|
+
assertOwnedPrivateEntry(path, lstatSync(path), 0o600, false);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// Another connection can finish a DELETE-mode transaction during inspection.
|
|
36
|
+
if (name.endsWith('-journal') && error.code === 'ENOENT') continue;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Create only the requested private directory and database, never repair existing paths. */
|
|
43
|
+
export function prepareAuthenticationDatabase(stateDirectory) {
|
|
44
|
+
if (typeof stateDirectory !== 'string' || !isAbsolute(stateDirectory)) {
|
|
45
|
+
throw new TypeError('An absolute authentication stateDirectory is required');
|
|
46
|
+
}
|
|
47
|
+
if (stateDirectory !== resolve(stateDirectory)) throw new TypeError('Authentication stateDirectory must be canonical');
|
|
48
|
+
assertDirectoryAncestors(dirname(stateDirectory));
|
|
49
|
+
try { mkdirSync(stateDirectory, { mode: 0o700 }); } catch (error) {
|
|
50
|
+
if (error.code !== 'EEXIST') throw error;
|
|
51
|
+
}
|
|
52
|
+
assertPrivateStateDirectory(stateDirectory);
|
|
53
|
+
const path = join(stateDirectory, DATABASE_NAME);
|
|
54
|
+
try {
|
|
55
|
+
const descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
56
|
+
closeSync(descriptor);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error.code !== 'EEXIST') throw error;
|
|
59
|
+
}
|
|
60
|
+
assertPrivateStateDirectory(stateDirectory);
|
|
61
|
+
return { path, stat: lstatSync(path) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function assertSameDatabaseFile(stateDirectory, prepared) {
|
|
65
|
+
assertPrivateStateDirectory(stateDirectory);
|
|
66
|
+
const stat = lstatSync(prepared.path);
|
|
67
|
+
if (stat.dev !== prepared.stat.dev || stat.ino !== prepared.stat.ino) {
|
|
68
|
+
throw new Error('Authentication database changed during opening');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { AuthDatabase } from './auth-database.mjs';
|
|
2
|
+
import { AuthPairingStore } from './auth-pairing.mjs';
|
|
3
|
+
import { AuthSessionStore } from './auth-sessions.mjs';
|
|
4
|
+
|
|
5
|
+
export { AuthError } from './auth-limits.mjs';
|
|
6
|
+
|
|
7
|
+
/** Single-owner local pairing; only redemption is exposed by the HTTP API. */
|
|
8
|
+
export class AuthService {
|
|
9
|
+
#database;
|
|
10
|
+
#pairing;
|
|
11
|
+
#sessions;
|
|
12
|
+
#ownsDatabase;
|
|
13
|
+
|
|
14
|
+
constructor({ stateDirectory, database, now = Date.now } = {}) {
|
|
15
|
+
if (typeof now !== 'function') throw new TypeError('An authentication clock is required');
|
|
16
|
+
this.#ownsDatabase = database === undefined;
|
|
17
|
+
this.#database = database ?? new AuthDatabase(stateDirectory);
|
|
18
|
+
try {
|
|
19
|
+
this.#pairing = new AuthPairingStore({ database: this.#database, now });
|
|
20
|
+
this.#sessions = new AuthSessionStore({ database: this.#database, now });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (this.#ownsDatabase) this.#database.close();
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
createPairing() { return this.#pairing.create(); }
|
|
28
|
+
redeemPairing(input, sourceId) { return this.#pairing.redeem(input, sourceId); }
|
|
29
|
+
authenticate(token) { return this.#sessions.authenticate(token); }
|
|
30
|
+
listSessions(principal) { return this.#sessions.list(principal); }
|
|
31
|
+
revoke(principal, sessionId) { return this.#sessions.revoke(principal, sessionId); }
|
|
32
|
+
listDevices() { return this.#sessions.listDevices(); }
|
|
33
|
+
revokeDevice(sessionId) { return this.#sessions.revokeDevice(sessionId); }
|
|
34
|
+
subscribeRevocations(listener) { return this.#sessions.subscribeRevocations(listener); }
|
|
35
|
+
|
|
36
|
+
prune() {
|
|
37
|
+
this.#pairing.prune();
|
|
38
|
+
this.#sessions.prune();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
close() {
|
|
42
|
+
this.#sessions.close();
|
|
43
|
+
if (this.#ownsDatabase) this.#database.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const digestToken = (token) => createHash('sha256').update(token).digest('hex');
|
|
4
|
+
|
|
5
|
+
export function validateRuntimeGrants(grants) {
|
|
6
|
+
if (!Array.isArray(grants) || grants.length < 1 || grants.length > 16) {
|
|
7
|
+
throw new Error('Configure 1 to 16 runtime grants');
|
|
8
|
+
}
|
|
9
|
+
const ids = new Set();
|
|
10
|
+
const digests = new Set();
|
|
11
|
+
return grants.map((grant) => {
|
|
12
|
+
if (!grant || !/^[A-Za-z0-9_.:-]{1,128}$/.test(grant.runtimeId ?? '') ||
|
|
13
|
+
!/^[a-f0-9]{64}$/.test(grant.tokenSha256 ?? '') ||
|
|
14
|
+
ids.has(grant.runtimeId) || digests.has(grant.tokenSha256)) {
|
|
15
|
+
throw new Error('Invalid or duplicate runtime grant');
|
|
16
|
+
}
|
|
17
|
+
const allowedModes = grant.allowedModes ?? ['ask'];
|
|
18
|
+
if (!Array.isArray(allowedModes) || !allowedModes.includes('ask') ||
|
|
19
|
+
allowedModes.some((mode) => !['ask', 'full_access', 'full_plus'].includes(mode))) {
|
|
20
|
+
throw new Error('Invalid runtime permission ceiling');
|
|
21
|
+
}
|
|
22
|
+
ids.add(grant.runtimeId);
|
|
23
|
+
digests.add(grant.tokenSha256);
|
|
24
|
+
return Object.freeze({ runtimeId: grant.runtimeId, tokenSha256: grant.tokenSha256,
|
|
25
|
+
allowedModes: Object.freeze([...new Set(allowedModes)]) });
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveRuntimeCredential(authorization, grants) {
|
|
30
|
+
if (typeof authorization !== 'string' || !/^Bearer [A-Za-z0-9_-]{43,128}$/.test(authorization)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const digest = Buffer.from(digestToken(authorization.slice(7)), 'hex');
|
|
34
|
+
let match = null;
|
|
35
|
+
for (const grant of grants) {
|
|
36
|
+
if (timingSafeEqual(digest, Buffer.from(grant.tokenSha256, 'hex'))) match = grant;
|
|
37
|
+
}
|
|
38
|
+
return match;
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const OPTION_NAMES = new Map([
|
|
2
|
+
['--output', 'output'], ['--origin', 'origin'],
|
|
3
|
+
['--runtime-id', 'runtimeId'], ['--port', 'port'],
|
|
4
|
+
]);
|
|
5
|
+
|
|
6
|
+
export function parseSetupArguments(args) {
|
|
7
|
+
const options = {};
|
|
8
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
9
|
+
const name = OPTION_NAMES.get(args[index]);
|
|
10
|
+
if (!name || Object.hasOwn(options, name)) throw new Error('Unknown or duplicate setup option');
|
|
11
|
+
const value = args[index + 1];
|
|
12
|
+
if (typeof value !== 'string' || !value || value.startsWith('--')) {
|
|
13
|
+
throw new Error('Every setup option needs a value');
|
|
14
|
+
}
|
|
15
|
+
if (name !== 'port') { options[name] = value; continue; }
|
|
16
|
+
if (!/^\d{1,5}$/.test(value)) throw new Error('Invalid port');
|
|
17
|
+
options.port = Number(value);
|
|
18
|
+
}
|
|
19
|
+
for (const name of ['output', 'origin', 'runtimeId']) {
|
|
20
|
+
if (!options[name]) throw new Error('Missing required setup option');
|
|
21
|
+
}
|
|
22
|
+
return options;
|
|
23
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir, userInfo } from 'node:os';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { runCli } from './cli.mjs';
|
|
5
|
+
import { resolveCliInvocation } from './cli-environment.mjs';
|
|
6
|
+
import { runSetupWizard } from '../setup/setup-wizard.mjs';
|
|
7
|
+
import { deploymentRuntimePaths, prepareDeployment } from '../deployment/deployment-command.mjs';
|
|
8
|
+
import { loadConfiguration } from '../config/config.mjs';
|
|
9
|
+
import { checkPublicEndpoint } from './doctor.mjs';
|
|
10
|
+
import { startConfiguredServer } from '../main.mjs';
|
|
11
|
+
import { startConfiguredNotificationGateway } from '../notifications/notification-gateway-command.mjs';
|
|
12
|
+
import { startConfiguredReviewPortal } from '../review/review-portal-server.mjs';
|
|
13
|
+
|
|
14
|
+
async function startForeground(env, out) {
|
|
15
|
+
const app = await startConfiguredServer(env);
|
|
16
|
+
out('ABot Socket listening on private loopback behind the configured HTTPS proxy.');
|
|
17
|
+
const stop = () => { void app.close(); };
|
|
18
|
+
process.once('SIGTERM', stop);
|
|
19
|
+
process.once('SIGINT', stop);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function interactiveSetup({ input, output, out, homeDirectory, pairingOutput }) {
|
|
24
|
+
if (!input.isTTY || !output.isTTY) {
|
|
25
|
+
out('Interactive setup requires a terminal. Use setup --output <new-directory> --origin <https-origin> --runtime-id <id>.');
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
const readline = createInterface({ input, output });
|
|
29
|
+
const abort = new AbortController();
|
|
30
|
+
readline.once('SIGINT', () => abort.abort());
|
|
31
|
+
readline.once('close', () => abort.abort());
|
|
32
|
+
try {
|
|
33
|
+
return await runSetupWizard({ ask: (prompt) => readline.question(prompt, { signal: abort.signal }),
|
|
34
|
+
out, homeDirectory, pairingOutput, platform: process.platform, username: userInfo().username, ...deploymentRuntimePaths() });
|
|
35
|
+
} finally { readline.close(); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runEntry(args, { env = process.env, input = process.stdin, output = process.stdout,
|
|
39
|
+
error = console.error, homeDirectory = homedir() } = {}) {
|
|
40
|
+
const out = (line) => output.write(`${line}\n`);
|
|
41
|
+
const pairingOutput = { isTTY: output.isTTY === true, columns: output.columns, term: env.TERM };
|
|
42
|
+
try {
|
|
43
|
+
const invocation = resolveCliInvocation(args, env, homeDirectory);
|
|
44
|
+
const { command, args: rest } = invocation;
|
|
45
|
+
if (command === '--version' && rest.length === 0) {
|
|
46
|
+
const metadata = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
47
|
+
out(`ABot Socket ${metadata.version}`);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
if (command === 'setup' && rest.length === 0) return await interactiveSetup({ input, output, out, homeDirectory, pairingOutput });
|
|
51
|
+
if (command === 'start' && rest.length === 0) return await startForeground(invocation.env, out);
|
|
52
|
+
if (command === 'review-portal' && rest.length === 0) {
|
|
53
|
+
const portal = await startConfiguredReviewPortal(invocation.env);
|
|
54
|
+
out('ABot review portal listening on private loopback behind its separate HTTPS proxy.');
|
|
55
|
+
const stop = () => { void portal.close(); };
|
|
56
|
+
process.once('SIGTERM', stop);
|
|
57
|
+
process.once('SIGINT', stop);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
if (command === 'push-gateway' && rest.length === 0) {
|
|
61
|
+
const gateway = await startConfiguredNotificationGateway(invocation.env);
|
|
62
|
+
out('ABot notification gateway listening on private loopback behind the configured HTTPS proxy.');
|
|
63
|
+
const stop = () => { void gateway.close(); };
|
|
64
|
+
process.once('SIGTERM', stop);
|
|
65
|
+
process.once('SIGINT', stop);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (command === 'doctor' && rest.length === 0) {
|
|
69
|
+
const config = loadConfiguration(invocation.env);
|
|
70
|
+
const report = await checkPublicEndpoint(config.publicOrigin);
|
|
71
|
+
out(JSON.stringify(report));
|
|
72
|
+
out('Runtime connectivity is not established by /health; verify it in the paired app.');
|
|
73
|
+
return report.ok ? 0 : 1;
|
|
74
|
+
}
|
|
75
|
+
if (command === 'deployment') {
|
|
76
|
+
prepareDeployment(rest, { env: invocation.env, out });
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
return runCli([command, ...rest], { env: invocation.env, out, error, pairingOutput });
|
|
80
|
+
} catch {
|
|
81
|
+
error('Command could not complete. Check arguments, private paths and platform requirements; existing installation files are preserved.');
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function defaultInstallationDirectory(homeDirectory = homedir()) {
|
|
5
|
+
return join(homeDirectory, '.abot-socket');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** An explicit CLI config wins; otherwise preserve the existing environment contract. */
|
|
9
|
+
export function resolveCliInvocation(args, env = process.env, homeDirectory = homedir()) {
|
|
10
|
+
const remaining = [];
|
|
11
|
+
let explicitConfig;
|
|
12
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
13
|
+
if (args[index] !== '--config') { remaining.push(args[index]); continue; }
|
|
14
|
+
const value = args[++index];
|
|
15
|
+
if (explicitConfig !== undefined || typeof value !== 'string' || !value.trim() || value.startsWith('--')) {
|
|
16
|
+
throw new Error('Provide --config once with a file path');
|
|
17
|
+
}
|
|
18
|
+
explicitConfig = resolve(value);
|
|
19
|
+
}
|
|
20
|
+
if (explicitConfig !== undefined && ['setup', 'review-setup'].includes(remaining[0])) {
|
|
21
|
+
throw new Error('Setup uses --output for a new installation; --config selects an existing installation');
|
|
22
|
+
}
|
|
23
|
+
const selected = { ...env };
|
|
24
|
+
if (explicitConfig !== undefined) {
|
|
25
|
+
selected.ABOT_SOCKET_CONFIG = explicitConfig;
|
|
26
|
+
delete selected.SOCKET_V2_CONFIG;
|
|
27
|
+
} else if (!selected.ABOT_SOCKET_CONFIG && !selected.SOCKET_V2_CONFIG) {
|
|
28
|
+
selected.ABOT_SOCKET_CONFIG = join(defaultInstallationDirectory(homeDirectory), 'config.json');
|
|
29
|
+
}
|
|
30
|
+
return { command: remaining[0], args: remaining.slice(1), env: selected };
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const USAGE = `ABot Socket — self-hosted relay
|
|
2
|
+
|
|
3
|
+
abot-socket setup
|
|
4
|
+
Interactive local setup wizard. Requires a terminal; never configures services automatically.
|
|
5
|
+
abot-socket setup --output <new-directory> --origin <https-origin> --runtime-id <id> [--port 8788]
|
|
6
|
+
abot-socket review-setup --output <new-directory> --origin <socket-https-origin> --runtime-id <id>
|
|
7
|
+
--review-origin <separate-https-origin> [--port 8788] [--review-port 8789]
|
|
8
|
+
abot-socket review-portal --config <demo-directory/config.json>
|
|
9
|
+
abot-socket start
|
|
10
|
+
abot-socket check-config
|
|
11
|
+
abot-socket doctor
|
|
12
|
+
abot-socket pair
|
|
13
|
+
abot-socket devices
|
|
14
|
+
abot-socket revoke <device-id>
|
|
15
|
+
abot-socket deployment --output <new-directory> --proxy nginx|apache|none [--user <current-user>]
|
|
16
|
+
[--certificate <absolute-fullchain-path> --private-key <absolute-key-path>]
|
|
17
|
+
|
|
18
|
+
Use --config <file> with any command except setup and review-setup. Otherwise use ABOT_SOCKET_CONFIG,
|
|
19
|
+
the legacy SOCKET_V2_CONFIG alias, or ~/.abot-socket/config.json.
|
|
20
|
+
|
|
21
|
+
Setup creates private files and one-time pairing details. Pair replaces only the
|
|
22
|
+
pending pairing. Device credentials remain valid for up to 30 days across restarts.
|
|
23
|
+
Setup and pair show a private QR in a capable terminal; manual details remain available.
|
|
24
|
+
The QR expires after five minutes and can be used once. Piped output stays text/JSON.
|
|
25
|
+
Doctor makes one public HTTPS request to your configured /health endpoint; it does
|
|
26
|
+
not verify a runtime or send authentication credentials. Start runs in foreground.
|
|
27
|
+
Deployment only writes reviewable Linux systemd/proxy files and instructions. It
|
|
28
|
+
does not install packages, obtain certificates, run sudo, or activate a service.
|
|
29
|
+
Runtime secrets belong only on the runtime computer, never in the mobile app.
|
|
30
|
+
Review setup creates a new isolated Socket identity and private review-access.txt;
|
|
31
|
+
it never adds a portal to an existing installation or starts a runtime/service.
|
|
32
|
+
Use a separate demo runtime with synthetic data. Reviewer credentials stay local;
|
|
33
|
+
the protected portal generates fresh standard pairing details when requested.
|
|
34
|
+
`;
|
|
@@ -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
|
+
}
|
package/src/cli/cli.mjs
ADDED
|
@@ -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,14 @@
|
|
|
1
|
+
import { supportsSparkChat } from '../protocol/spark-chat-protocol.mjs';
|
|
2
|
+
|
|
3
|
+
/** Socket-owned API support; runtime identity/features remain separately scoped. */
|
|
4
|
+
export function clientCapabilities({ ownership, runtime } = {}) {
|
|
5
|
+
return { cancel: false, durableApprovals: supportsDurableApprovals(ownership, runtime),
|
|
6
|
+
sessionMutation: true, notifications: true, sparkChat: supportsSparkChat(runtime) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function supportsDurableApprovals(ownership, runtime) {
|
|
10
|
+
if (!ownership) return false;
|
|
11
|
+
if (runtime === undefined) return true;
|
|
12
|
+
if (runtime.online !== true) return false;
|
|
13
|
+
return Array.isArray(runtime.features) && runtime.features.includes('request_approvals_v1');
|
|
14
|
+
}
|
|
@@ -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
|
+
}
|