@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.
- package/LICENSE +21 -0
- package/README.md +116 -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 +166 -0
- package/docs/MIGRATION.md +48 -0
- package/docs/PROTOCOL.md +278 -0
- package/docs/SECURITY.md +98 -0
- package/docs/SELF-HOSTING.md +154 -0
- package/package.json +64 -4
- package/src/auth/auth-database.mjs +102 -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 +4 -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/protocol-validation.mjs +137 -0
- package/src/protocol/protocol.mjs +110 -0
- package/src/relay/relay-client-admission.mjs +40 -0
- package/src/relay/relay-correlations.mjs +96 -0
- package/src/relay/relay-model-catalog.mjs +69 -0
- package/src/relay/relay-requests.mjs +155 -0
- package/src/relay/relay-runtime-registration.mjs +49 -0
- package/src/relay/relay-session-mutations.mjs +88 -0
- package/src/relay/relay.mjs +202 -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 +31 -0
- package/src/transport/server.mjs +165 -0
- package/src/transport/transport-limits.mjs +85 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { assertSameDatabaseFile, prepareAuthenticationDatabase } from './auth-storage-path.mjs';
|
|
4
|
+
import { NOTIFICATION_SCHEMA } from '../notifications/notification-schema.mjs';
|
|
5
|
+
import { NOTIFICATION_PREVIEW_SCHEMA } from '../notifications/notification-preview-schema.mjs';
|
|
6
|
+
|
|
7
|
+
const APPLICATION_ID = 0x41425341;
|
|
8
|
+
const SCHEMA_VERSION = 3;
|
|
9
|
+
|
|
10
|
+
const SCHEMA = `
|
|
11
|
+
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
|
12
|
+
CREATE TABLE pairing (
|
|
13
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
14
|
+
id TEXT NOT NULL UNIQUE, digest BLOB NOT NULL, salt BLOB NOT NULL,
|
|
15
|
+
expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL CHECK (attempts BETWEEN 0 AND 5)
|
|
16
|
+
) STRICT;
|
|
17
|
+
CREATE TABLE devices (
|
|
18
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, device_name TEXT NOT NULL,
|
|
19
|
+
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
|
|
20
|
+
token_hash TEXT NOT NULL UNIQUE
|
|
21
|
+
) STRICT;
|
|
22
|
+
CREATE INDEX devices_expiry ON devices(expires_at);
|
|
23
|
+
CREATE TABLE pairing_attempts (source TEXT NOT NULL, attempted_at INTEGER NOT NULL) STRICT;
|
|
24
|
+
CREATE INDEX attempts_time ON pairing_attempts(attempted_at);
|
|
25
|
+
CREATE INDEX attempts_source ON pairing_attempts(source, attempted_at);
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/** One durable store shared by the operator CLI and relay processes. */
|
|
29
|
+
export class AuthDatabase {
|
|
30
|
+
#connection;
|
|
31
|
+
#closed = false;
|
|
32
|
+
|
|
33
|
+
constructor(stateDirectory) {
|
|
34
|
+
const prepared = prepareAuthenticationDatabase(stateDirectory);
|
|
35
|
+
this.#connection = new DatabaseSync(prepared.path);
|
|
36
|
+
try {
|
|
37
|
+
assertSameDatabaseFile(stateDirectory, prepared);
|
|
38
|
+
this.#connection.exec('PRAGMA busy_timeout = 5000; PRAGMA temp_store = MEMORY; PRAGMA foreign_keys = ON;');
|
|
39
|
+
const mode = this.#connection.prepare('PRAGMA journal_mode').get().journal_mode;
|
|
40
|
+
if (mode !== 'delete') throw new Error('Authentication database must use DELETE journaling');
|
|
41
|
+
this.transaction(() => this.#initialize());
|
|
42
|
+
this.#connection.exec('PRAGMA synchronous = FULL;');
|
|
43
|
+
assertSameDatabaseFile(stateDirectory, prepared);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
this.close();
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
prepare(sql) {
|
|
51
|
+
return this.#connection.prepare(sql);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
transaction(operation) {
|
|
55
|
+
this.#connection.exec('BEGIN IMMEDIATE');
|
|
56
|
+
try {
|
|
57
|
+
const result = operation();
|
|
58
|
+
this.#connection.exec('COMMIT');
|
|
59
|
+
return result;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.#connection.exec('ROLLBACK');
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get ownerId() {
|
|
67
|
+
return this.prepare("SELECT value FROM metadata WHERE key = 'owner_id'").get().value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get tokenSalt() {
|
|
71
|
+
return this.prepare("SELECT value FROM metadata WHERE key = 'token_salt'").get().value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
close() {
|
|
75
|
+
if (this.#closed) return;
|
|
76
|
+
this.#closed = true;
|
|
77
|
+
this.#connection.close();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#initialize() {
|
|
81
|
+
const applicationId = this.prepare('PRAGMA application_id').get().application_id;
|
|
82
|
+
const version = this.prepare('PRAGMA user_version').get().user_version;
|
|
83
|
+
if (applicationId === APPLICATION_ID && version === SCHEMA_VERSION) return;
|
|
84
|
+
if (applicationId === APPLICATION_ID && [1, 2].includes(version)) {
|
|
85
|
+
if (version === 1) this.#connection.exec(NOTIFICATION_SCHEMA);
|
|
86
|
+
this.#connection.exec(NOTIFICATION_PREVIEW_SCHEMA);
|
|
87
|
+
this.#connection.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const objects = this.prepare("SELECT name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").all();
|
|
91
|
+
if (applicationId !== 0 || version !== 0 || objects.length) {
|
|
92
|
+
throw new Error('Unrecognized authentication database; no changes were made');
|
|
93
|
+
}
|
|
94
|
+
this.#connection.exec(SCHEMA);
|
|
95
|
+
this.#connection.exec(NOTIFICATION_SCHEMA);
|
|
96
|
+
this.#connection.exec(NOTIFICATION_PREVIEW_SCHEMA);
|
|
97
|
+
const insert = this.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)');
|
|
98
|
+
insert.run('owner_id', randomUUID());
|
|
99
|
+
insert.run('token_salt', randomBytes(32).toString('hex'));
|
|
100
|
+
this.#connection.exec(`PRAGMA application_id = ${APPLICATION_ID}; PRAGMA user_version = ${SCHEMA_VERSION};`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export class AuthError extends Error {
|
|
4
|
+
constructor(code, status) {
|
|
5
|
+
super(code);
|
|
6
|
+
this.name = 'AuthError';
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sourceKey(sourceId) {
|
|
13
|
+
if (typeof sourceId !== 'string' || !sourceId || sourceId.length > 128) {
|
|
14
|
+
throw new AuthError('invalid_request', 400);
|
|
15
|
+
}
|
|
16
|
+
return createHash('sha256').update(sourceId).digest('hex');
|
|
17
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomBytes, randomInt, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { AuthError, sourceKey } from './auth-limits.mjs';
|
|
3
|
+
import { issueDeviceSession } from './auth-sessions.mjs';
|
|
4
|
+
|
|
5
|
+
const PAIRING_MS = 5 * 60 * 1000;
|
|
6
|
+
const MAX_ATTEMPTS = 5;
|
|
7
|
+
const RATE_WINDOW_MS = 15 * 60 * 1000;
|
|
8
|
+
const codeDigest = (code, salt) => scryptSync(code, salt, 32);
|
|
9
|
+
const invalidCode = () => ({ error: new AuthError('invalid_code', 401) });
|
|
10
|
+
|
|
11
|
+
function hasPairingInput(input) {
|
|
12
|
+
if (!input || typeof input !== 'object') return false;
|
|
13
|
+
return !Array.isArray(input);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasValidDeviceName(name) {
|
|
17
|
+
if (typeof name !== 'string' || !name.trim()) return false;
|
|
18
|
+
if (name.length > 80) return false;
|
|
19
|
+
return !/[\x00-\x1f\x7f]/.test(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function matchesPairingCode(code, pairing) {
|
|
23
|
+
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) return false;
|
|
24
|
+
return timingSafeEqual(pairing.digest, codeDigest(code, pairing.salt));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class AuthPairingStore {
|
|
28
|
+
constructor({ database, now }) {
|
|
29
|
+
this.database = database;
|
|
30
|
+
this.now = now;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
create() {
|
|
34
|
+
const pairingId = randomBytes(24).toString('base64url');
|
|
35
|
+
const code = randomInt(1_000_000).toString().padStart(6, '0');
|
|
36
|
+
const salt = randomBytes(32);
|
|
37
|
+
const digest = codeDigest(code, salt);
|
|
38
|
+
const expiresAt = this.now() + PAIRING_MS;
|
|
39
|
+
this.database.transaction(() => {
|
|
40
|
+
this.database.prepare('DELETE FROM pairing').run();
|
|
41
|
+
this.database.prepare(`INSERT INTO pairing(singleton, id, digest, salt, expires_at, attempts)
|
|
42
|
+
VALUES (1, ?, ?, ?, ?, 0)`).run(pairingId, digest, salt, expiresAt);
|
|
43
|
+
});
|
|
44
|
+
return { pairingId, code, expiresAt };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
redeem(input, sourceId) {
|
|
48
|
+
const source = sourceKey(sourceId);
|
|
49
|
+
const result = this.database.transaction(() => this.#redeemTransaction(input, source));
|
|
50
|
+
if (result.error) throw result.error;
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
prune() {
|
|
55
|
+
const time = this.now();
|
|
56
|
+
this.database.transaction(() => {
|
|
57
|
+
this.database.prepare('DELETE FROM pairing WHERE expires_at <= ?').run(time);
|
|
58
|
+
this.database.prepare('DELETE FROM pairing_attempts WHERE attempted_at <= ?').run(time - RATE_WINDOW_MS);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#redeemTransaction(input, source) {
|
|
63
|
+
const time = this.now();
|
|
64
|
+
if (!hasPairingInput(input)) return invalidCode();
|
|
65
|
+
const { deviceName = 'Device' } = input;
|
|
66
|
+
if (!hasValidDeviceName(deviceName)) return { error: new AuthError('invalid_request', 400) };
|
|
67
|
+
if (typeof input.pairingId !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(input.pairingId)) return invalidCode();
|
|
68
|
+
const pairing = this.database.prepare('SELECT * FROM pairing WHERE id = ? AND expires_at > ?')
|
|
69
|
+
.get(input.pairingId, time);
|
|
70
|
+
if (!pairing || pairing.attempts >= MAX_ATTEMPTS) return invalidCode();
|
|
71
|
+
// Unrelated or unusable challenges must not spend the verification quota.
|
|
72
|
+
if (!this.#consumeRateLimit(source, time)) return { error: new AuthError('rate_limited', 429) };
|
|
73
|
+
if (!matchesPairingCode(input.code, pairing)) {
|
|
74
|
+
this.database.prepare('UPDATE pairing SET attempts = attempts + 1 WHERE id = ?').run(pairing.id);
|
|
75
|
+
return invalidCode();
|
|
76
|
+
}
|
|
77
|
+
let session;
|
|
78
|
+
try { session = issueDeviceSession(this.database, time, deviceName.trim()); } catch (error) {
|
|
79
|
+
if (error instanceof AuthError) return { error };
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
this.database.prepare('DELETE FROM pairing WHERE id = ?').run(pairing.id);
|
|
83
|
+
return session;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#consumeRateLimit(source, time) {
|
|
87
|
+
this.database.prepare('DELETE FROM pairing_attempts WHERE attempted_at <= ?').run(time - RATE_WINDOW_MS);
|
|
88
|
+
const total = this.database.prepare('SELECT count(*) AS count FROM pairing_attempts').get().count;
|
|
89
|
+
if (total >= 100) return false;
|
|
90
|
+
const attempts = this.database.prepare('SELECT count(*) AS count FROM pairing_attempts WHERE source = ?').get(source).count;
|
|
91
|
+
if (attempts >= 20) return false;
|
|
92
|
+
this.database.prepare('INSERT INTO pairing_attempts(source, attempted_at) VALUES (?, ?)').run(source, time);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -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
|
+
`;
|