@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,147 @@
|
|
|
1
|
+
import { NotificationStore } from './notification-store.mjs';
|
|
2
|
+
import { NotificationOutbox } from './notification-outbox.mjs';
|
|
3
|
+
import { closesApprovalNotifications, projectNotificationEvent } from './notification-projection.mjs';
|
|
4
|
+
import { notificationInputError } from './notification-input.mjs';
|
|
5
|
+
|
|
6
|
+
const PRESENCE_MS = 45_000;
|
|
7
|
+
|
|
8
|
+
/** Notification lifecycle is independent of a selected runtime or a live mobile socket. */
|
|
9
|
+
export class NotificationService {
|
|
10
|
+
constructor({ database, delivery, now = Date.now, onChanged = () => {} }) {
|
|
11
|
+
this.store = new NotificationStore({ database, now });
|
|
12
|
+
this.outbox = new NotificationOutbox({ database, now });
|
|
13
|
+
this.delivery = delivery;
|
|
14
|
+
this.now = now;
|
|
15
|
+
this.onChanged = onChanged;
|
|
16
|
+
this.presence = new Map();
|
|
17
|
+
this.closed = false;
|
|
18
|
+
this.pending = null;
|
|
19
|
+
this.active = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
changed(deviceId) {
|
|
23
|
+
try { this.onChanged(deviceId); } catch { /* Invalidation is best effort; HTTP is canonical. */ }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
observe(identity, body) {
|
|
27
|
+
let changed = false;
|
|
28
|
+
if (closesApprovalNotifications(body)) {
|
|
29
|
+
changed = this.store.closeApprovals(identity, body.type === 'event' ? body.approvalId : undefined);
|
|
30
|
+
if (this.active?.entry.notification.kind === 'approval_required' &&
|
|
31
|
+
this.active.entry.deviceId === identity.deviceId &&
|
|
32
|
+
this.active.entry.notification.runtimeId === identity.runtimeId &&
|
|
33
|
+
this.active.entry.notification.requestId === identity.requestId) this.active.controller.abort();
|
|
34
|
+
}
|
|
35
|
+
const projected = projectNotificationEvent(identity, body, this.now());
|
|
36
|
+
if (projected) {
|
|
37
|
+
const registration = this.store.registration(identity.deviceId);
|
|
38
|
+
const queue = this.delivery.configured && registration?.provider === this.delivery.mode;
|
|
39
|
+
const item = this.store.record(projected, queue);
|
|
40
|
+
if (item && queue) this.store.previews.attach(item, body.output);
|
|
41
|
+
changed = Boolean(item) || changed;
|
|
42
|
+
}
|
|
43
|
+
if (changed) this.changed(identity.deviceId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
list(deviceId, options) {
|
|
47
|
+
const registration = this.store.registration(deviceId);
|
|
48
|
+
return { version: 1, ...this.store.list(deviceId, options),
|
|
49
|
+
delivery: { mode: this.delivery.mode, configured: this.delivery.configured },
|
|
50
|
+
registration: { enabled: this.delivery.configured && registration?.provider === this.delivery.mode },
|
|
51
|
+
preview: this.store.previews.status(deviceId) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
markRead(deviceId, ids) {
|
|
55
|
+
if (this.active?.entry.deviceId === deviceId && ids.includes(this.active.entry.notification.id)) {
|
|
56
|
+
this.active.controller.abort();
|
|
57
|
+
}
|
|
58
|
+
if (this.store.markRead(deviceId, ids)) this.changed(deviceId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
updatePresence(deviceId, presence) {
|
|
62
|
+
if (!presence.foreground) { this.presence.delete(deviceId); return; }
|
|
63
|
+
this.presence.set(deviceId, { ...presence, expiresAt: this.now() + PRESENCE_MS });
|
|
64
|
+
// Advisory only: iOS may suspend before a background update is delivered.
|
|
65
|
+
// Only an explicit read acknowledgement can consume an unseen notification.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
register(deviceId, registration) {
|
|
69
|
+
if (!this.delivery.configured || registration.provider !== this.delivery.mode) {
|
|
70
|
+
throw notificationInputError('push_unavailable', 409);
|
|
71
|
+
}
|
|
72
|
+
if (this.active?.entry.deviceId === deviceId) this.active.controller.abort();
|
|
73
|
+
if (!this.store.register(deviceId, registration)) throw notificationInputError('unauthorized', 401);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
enablePreview(deviceId, key) {
|
|
77
|
+
const registration = this.store.registration(deviceId);
|
|
78
|
+
if (!this.delivery.configured || registration?.provider !== this.delivery.mode) {
|
|
79
|
+
throw notificationInputError('push_unavailable', 409);
|
|
80
|
+
}
|
|
81
|
+
if (this.active?.entry.deviceId === deviceId) this.active.controller.abort();
|
|
82
|
+
if (!this.store.previews.enable(deviceId, key)) throw notificationInputError('unauthorized', 401);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
disablePreview(deviceId) {
|
|
86
|
+
if (this.active?.entry.deviceId === deviceId) this.active.controller.abort();
|
|
87
|
+
this.store.previews.disable(deviceId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
unregister(deviceId) {
|
|
91
|
+
if (this.active?.entry.deviceId === deviceId) this.active.controller.abort();
|
|
92
|
+
this.store.unregister(deviceId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
revoke(deviceId) {
|
|
96
|
+
if (this.active?.entry.deviceId === deviceId) this.active.controller.abort();
|
|
97
|
+
this.presence.delete(deviceId);
|
|
98
|
+
this.store.forgetDevice(deviceId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
start() {
|
|
102
|
+
if (this.timer || this.closed) return;
|
|
103
|
+
this.timer = setInterval(() => { void this.tick(); }, 1_000);
|
|
104
|
+
this.timer.unref();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
tick() {
|
|
108
|
+
if (this.closed) return Promise.resolve();
|
|
109
|
+
if (this.pending) return this.pending;
|
|
110
|
+
this.pending = this.drain().catch(() => { /* Durable entries remain for a later bounded attempt. */ })
|
|
111
|
+
.finally(() => { this.pending = null; });
|
|
112
|
+
return this.pending;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async drain() {
|
|
116
|
+
this.store.prune();
|
|
117
|
+
for (const [deviceId, presence] of this.presence) {
|
|
118
|
+
if (presence.expiresAt <= this.now()) this.presence.delete(deviceId);
|
|
119
|
+
}
|
|
120
|
+
if (!this.delivery.configured) return;
|
|
121
|
+
for (let index = 0; index < 10 && !this.closed; index += 1) {
|
|
122
|
+
const entry = this.outbox.claim(this.delivery.mode);
|
|
123
|
+
if (!entry) return;
|
|
124
|
+
if (!this.outbox.canDeliver(entry)) continue;
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
this.active = { entry, controller };
|
|
127
|
+
let result;
|
|
128
|
+
try {
|
|
129
|
+
result = await this.delivery.send({ registration: entry.registration, notification: entry.notification,
|
|
130
|
+
badge: this.store.counts(entry.deviceId).unreadCount }, controller.signal);
|
|
131
|
+
} catch { result = { status: 'retry' }; }
|
|
132
|
+
this.active = null;
|
|
133
|
+
if (controller.signal.aborted) { this.outbox.cancel(entry); continue; }
|
|
134
|
+
const status = ['delivered', 'invalid', 'discarded', 'retry'].includes(result?.status) ? result.status : 'retry';
|
|
135
|
+
if (status === 'invalid') this.store.unregister(entry.deviceId, entry.registration);
|
|
136
|
+
this.outbox.finish(entry, status);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async close() {
|
|
141
|
+
this.closed = true;
|
|
142
|
+
clearInterval(this.timer);
|
|
143
|
+
this.active?.controller.abort();
|
|
144
|
+
await this.pending;
|
|
145
|
+
this.presence.clear();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { notificationCursor } from './notification-input.mjs';
|
|
3
|
+
import { NotificationPreviewStore } from './notification-preview-store.mjs';
|
|
4
|
+
|
|
5
|
+
const MAX_ITEMS_PER_DEVICE = 500;
|
|
6
|
+
const SELECT_ITEM = `id, runtime_id AS runtimeId, session_id AS sessionId,
|
|
7
|
+
request_id AS requestId, kind, created_at AS createdAt, read_at AS readAt, expires_at AS expiresAt`;
|
|
8
|
+
|
|
9
|
+
export class NotificationStore {
|
|
10
|
+
constructor({ database, now = Date.now }) {
|
|
11
|
+
this.database = database; this.now = now;
|
|
12
|
+
this.previews = new NotificationPreviewStore({ database, now });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
hasDevice(deviceId) {
|
|
16
|
+
return Boolean(this.database.prepare('SELECT id FROM devices WHERE id = ? AND expires_at > ?')
|
|
17
|
+
.get(deviceId, this.now()));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
record(event, queueDelivery) {
|
|
21
|
+
return this.database.transaction(() => {
|
|
22
|
+
const device = this.database.prepare('SELECT expires_at FROM devices WHERE id = ? AND expires_at > ?')
|
|
23
|
+
.get(event.deviceId, this.now());
|
|
24
|
+
if (!device) return null;
|
|
25
|
+
const item = { id: randomUUID(), ...event, readAt: null,
|
|
26
|
+
expiresAt: Math.min(event.expiresAt, device.expires_at) };
|
|
27
|
+
const added = this.database.prepare(`INSERT OR IGNORE INTO notifications
|
|
28
|
+
(id, device_id, runtime_id, session_id, request_id, kind, approval_id, created_at, read_at, expires_at)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(item.id, item.deviceId, item.runtimeId,
|
|
30
|
+
item.sessionId, item.requestId, item.kind, item.approvalId, item.createdAt, item.readAt, item.expiresAt);
|
|
31
|
+
if (!added.changes) return null;
|
|
32
|
+
if (queueDelivery) {
|
|
33
|
+
this.database.prepare('INSERT INTO notification_outbox(notification_id, next_at) VALUES (?, ?)')
|
|
34
|
+
.run(item.id, this.now());
|
|
35
|
+
}
|
|
36
|
+
this.database.prepare(`DELETE FROM notifications WHERE device_id = ? AND id IN (
|
|
37
|
+
SELECT id FROM notifications WHERE device_id = ? ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)`)
|
|
38
|
+
.run(item.deviceId, item.deviceId, MAX_ITEMS_PER_DEVICE);
|
|
39
|
+
return item;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
list(deviceId, { limit = 50, before = null } = {}) {
|
|
44
|
+
this.prune();
|
|
45
|
+
const condition = before ? 'AND (created_at < ? OR (created_at = ? AND id < ?))' : '';
|
|
46
|
+
const parameters = before ? [deviceId, before.createdAt, before.createdAt, before.id, limit + 1] : [deviceId, limit + 1];
|
|
47
|
+
const rows = this.database.prepare(`SELECT ${SELECT_ITEM} FROM notifications WHERE device_id = ?
|
|
48
|
+
${condition} ORDER BY created_at DESC, id DESC LIMIT ?`).all(...parameters);
|
|
49
|
+
const items = rows.slice(0, limit).map(row => ({ ...row }));
|
|
50
|
+
const counts = this.counts(deviceId);
|
|
51
|
+
return { items, nextCursor: rows.length > limit ? notificationCursor(items.at(-1)) : null, ...counts };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
counts(deviceId) {
|
|
55
|
+
const rows = this.database.prepare(`SELECT runtime_id, session_id, COUNT(*) AS count FROM notifications
|
|
56
|
+
WHERE device_id = ? AND read_at IS NULL AND expires_at > ? GROUP BY runtime_id, session_id`).all(deviceId, this.now());
|
|
57
|
+
const unreadByRuntime = Object.create(null);
|
|
58
|
+
const unreadBySession = Object.create(null);
|
|
59
|
+
let unreadCount = 0;
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
unreadCount += row.count;
|
|
62
|
+
unreadByRuntime[row.runtime_id] = (unreadByRuntime[row.runtime_id] ?? 0) + row.count;
|
|
63
|
+
unreadBySession[row.runtime_id] ??= Object.create(null);
|
|
64
|
+
unreadBySession[row.runtime_id][row.session_id] = row.count;
|
|
65
|
+
}
|
|
66
|
+
return { unreadCount, unreadByRuntime, unreadBySession };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
markRead(deviceId, ids) {
|
|
70
|
+
return this.database.transaction(() => {
|
|
71
|
+
const update = this.database.prepare('UPDATE notifications SET read_at = ? WHERE device_id = ? AND id = ? AND read_at IS NULL');
|
|
72
|
+
let changes = 0;
|
|
73
|
+
for (const id of ids) changes += update.run(this.now(), deviceId, id).changes;
|
|
74
|
+
this.removeReadDeliveries();
|
|
75
|
+
return changes > 0;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
closeApprovals(identity, approvalId) {
|
|
80
|
+
const conditions = approvalId ? 'AND approval_id = ?' : '';
|
|
81
|
+
const params = [this.now(), identity.deviceId, identity.runtimeId, identity.requestId];
|
|
82
|
+
if (approvalId) params.push(approvalId);
|
|
83
|
+
const changed = this.database.prepare(`UPDATE notifications SET read_at = COALESCE(read_at, ?)
|
|
84
|
+
WHERE device_id = ? AND runtime_id = ? AND request_id = ? AND kind = 'approval_required' ${conditions}`)
|
|
85
|
+
.run(...params).changes;
|
|
86
|
+
this.removeReadDeliveries();
|
|
87
|
+
return changed > 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
registration(deviceId) {
|
|
91
|
+
const row = this.database.prepare('SELECT registration FROM notification_registrations WHERE device_id = ?').get(deviceId);
|
|
92
|
+
return row ? JSON.parse(row.registration) : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
register(deviceId, registration) {
|
|
96
|
+
if (!this.hasDevice(deviceId)) return false;
|
|
97
|
+
this.database.prepare(`INSERT INTO notification_registrations(device_id, provider, registration, updated_at)
|
|
98
|
+
VALUES (?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET provider = excluded.provider,
|
|
99
|
+
registration = excluded.registration, updated_at = excluded.updated_at`)
|
|
100
|
+
.run(deviceId, registration.provider, JSON.stringify(registration), this.now());
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
unregister(deviceId, expectedRegistration) {
|
|
105
|
+
this.database.transaction(() => {
|
|
106
|
+
if (expectedRegistration && JSON.stringify(this.registration(deviceId)) !== JSON.stringify(expectedRegistration)) return;
|
|
107
|
+
this.previews.removeKey(deviceId);
|
|
108
|
+
this.database.prepare('DELETE FROM notification_registrations WHERE device_id = ?').run(deviceId);
|
|
109
|
+
this.database.prepare(`DELETE FROM notification_outbox WHERE notification_id IN
|
|
110
|
+
(SELECT id FROM notifications WHERE device_id = ?)` ).run(deviceId);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
removeReadDeliveries() {
|
|
115
|
+
this.database.prepare(`DELETE FROM notification_outbox WHERE notification_id IN
|
|
116
|
+
(SELECT id FROM notifications WHERE read_at IS NOT NULL)` ).run();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
prune() {
|
|
120
|
+
this.database.prepare('DELETE FROM notifications WHERE expires_at <= ?').run(this.now());
|
|
121
|
+
this.database.prepare('DELETE FROM notifications WHERE NOT EXISTS (SELECT 1 FROM devices WHERE devices.id = notifications.device_id)').run();
|
|
122
|
+
this.database.prepare('DELETE FROM notification_registrations WHERE NOT EXISTS (SELECT 1 FROM devices WHERE devices.id = notification_registrations.device_id)').run();
|
|
123
|
+
this.database.prepare('DELETE FROM notification_registrations WHERE device_id IN (SELECT id FROM devices WHERE expires_at <= ?)').run(this.now());
|
|
124
|
+
this.database.prepare('DELETE FROM notifications WHERE device_id IN (SELECT id FROM devices WHERE expires_at <= ?)').run(this.now());
|
|
125
|
+
this.previews.prune();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
forgetDevice(deviceId) {
|
|
129
|
+
this.unregister(deviceId);
|
|
130
|
+
this.database.prepare('DELETE FROM notifications WHERE device_id = ?').run(deviceId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Public format for an operator-issued, short-lived pairing QR. Never a URL. */
|
|
2
|
+
export const PAIRING_QR_TYPE = 'abot-pairing';
|
|
3
|
+
export const PAIRING_QR_VERSION = 1;
|
|
4
|
+
|
|
5
|
+
function hasCanonicalPairingOrigin(value) {
|
|
6
|
+
if (typeof value !== 'string' || value.length > 2048) return false;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
return url.protocol === 'https:' && url.origin === value;
|
|
10
|
+
} catch { return false; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hasPairingCredentials(value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
15
|
+
if (!hasCanonicalPairingOrigin(value.serverUrl)) return false;
|
|
16
|
+
if (typeof value.pairingId !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(value.pairingId)) return false;
|
|
17
|
+
if (typeof value.code !== 'string' || !/^\d{6}$/.test(value.code)) return false;
|
|
18
|
+
return Number.isSafeInteger(value.expiresAt) && value.expiresAt > 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function pairingQrPayload(pairing) {
|
|
22
|
+
if (!hasPairingCredentials(pairing)) throw new Error('Invalid pairing QR details');
|
|
23
|
+
// Explicit selection keeps runtime tokens and future operator-only fields out.
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
type: PAIRING_QR_TYPE,
|
|
26
|
+
version: PAIRING_QR_VERSION,
|
|
27
|
+
serverUrl: pairing.serverUrl,
|
|
28
|
+
pairingId: pairing.pairingId,
|
|
29
|
+
code: pairing.code,
|
|
30
|
+
expiresAt: pairing.expiresAt,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import qrcode from 'qrcode-generator';
|
|
2
|
+
import { pairingQrPayload } from './pairing-qr-payload.mjs';
|
|
3
|
+
|
|
4
|
+
const QUIET_MODULES = 4;
|
|
5
|
+
const BLACK_ON_WHITE = '\u001b[30;47m';
|
|
6
|
+
const RESET = '\u001b[0m';
|
|
7
|
+
|
|
8
|
+
function fitsTerminal(width, columns) {
|
|
9
|
+
// Leave one column free: some terminals wrap immediately at the last column.
|
|
10
|
+
return Number.isInteger(columns) && columns > width;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isDarkModule(qr, row, column) {
|
|
14
|
+
const size = qr.getModuleCount();
|
|
15
|
+
if (row < 0 || column < 0 || row >= size || column >= size) return false;
|
|
16
|
+
return qr.isDark(row, column);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function halfBlock(top, bottom) {
|
|
20
|
+
if (top && bottom) return '█';
|
|
21
|
+
if (top) return '▀';
|
|
22
|
+
if (bottom) return '▄';
|
|
23
|
+
return ' ';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Fixed black/white terminal QR, independent of the operator's terminal theme. */
|
|
27
|
+
export function renderPairingQr(pairing, { columns = 80 } = {}) {
|
|
28
|
+
const qr = qrcode(0, 'M');
|
|
29
|
+
qr.addData(pairingQrPayload(pairing), 'Byte');
|
|
30
|
+
qr.make();
|
|
31
|
+
const size = qr.getModuleCount();
|
|
32
|
+
if (!fitsTerminal(size + QUIET_MODULES * 2, columns)) return null;
|
|
33
|
+
const lines = [];
|
|
34
|
+
for (let row = -QUIET_MODULES; row < size + QUIET_MODULES; row += 2) {
|
|
35
|
+
let line = '';
|
|
36
|
+
for (let column = -QUIET_MODULES; column < size + QUIET_MODULES; column++) {
|
|
37
|
+
line += halfBlock(isDarkModule(qr, row, column), isDarkModule(qr, row + 1, column));
|
|
38
|
+
}
|
|
39
|
+
lines.push(`${BLACK_ON_WHITE}${line}${RESET}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
|
|
3
|
+
export const MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
4
|
+
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
5
|
+
export const MAX_TEXT_BYTES = 128 * 1024;
|
|
6
|
+
const MAX_BASE64_LENGTH = 4 * Math.ceil(MAX_ATTACHMENT_BYTES / 3);
|
|
7
|
+
const MIME_TYPES = new Map([
|
|
8
|
+
['image/png', 'image'], ['image/jpeg', 'image'], ['image/webp', 'image'],
|
|
9
|
+
['application/pdf', 'file'],
|
|
10
|
+
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'file'],
|
|
11
|
+
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'file'],
|
|
12
|
+
['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'file'],
|
|
13
|
+
['text/plain', 'file'], ['text/csv', 'file'], ['text/markdown', 'file'],
|
|
14
|
+
['application/json', 'file'], ['application/rtf', 'file'],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function fail(code = 'invalid_command') {
|
|
18
|
+
throw Object.assign(new Error(code), { code });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function record(value, allowed) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail();
|
|
23
|
+
const prototype = Object.getPrototypeOf(value);
|
|
24
|
+
if (prototype !== Object.prototype && prototype !== null) fail();
|
|
25
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
26
|
+
if (Reflect.ownKeys(descriptors).some((key) => typeof key !== 'string')) fail();
|
|
27
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
28
|
+
if (!allowed.includes(key) || !descriptor.enumerable || !('value' in descriptor)) fail();
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function string(value, maximum, { nonempty = true, controls = false } = {}) {
|
|
34
|
+
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > maximum) fail();
|
|
35
|
+
if (nonempty && !value.trim()) fail();
|
|
36
|
+
if (!controls && /[\u0000-\u001f\u007f]/u.test(value)) fail();
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function id(value) {
|
|
41
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/u.test(value)) fail();
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function approvalId(value) {
|
|
46
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_.:-]{1,512}$/u.test(value)) fail();
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function integer(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
|
|
51
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) fail();
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function boolean(value) {
|
|
56
|
+
if (typeof value !== 'boolean') fail();
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function optional(source, target, key, validate) {
|
|
61
|
+
if (Object.hasOwn(source, key)) target[key] = validate(source[key]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mime(value) {
|
|
65
|
+
const normalized = string(value, 160).split(';', 1)[0].trim().toLowerCase();
|
|
66
|
+
if (!MIME_TYPES.has(normalized)) fail();
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function kind(value) {
|
|
71
|
+
if (value !== 'image' && value !== 'file') fail();
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function attachment(value) {
|
|
76
|
+
record(value, ['id', 'kind', 'mimeType', 'storageRef', 'name', 'size']);
|
|
77
|
+
const result = {
|
|
78
|
+
id: id(value.id), kind: kind(value.kind), mimeType: mime(value.mimeType),
|
|
79
|
+
storageRef: string(value.storageRef, 2048),
|
|
80
|
+
};
|
|
81
|
+
if (MIME_TYPES.get(result.mimeType) !== result.kind) fail();
|
|
82
|
+
optional(value, result, 'name', (name) => string(name, 1024));
|
|
83
|
+
optional(value, result, 'size', (size) => integer(size, 0, MAX_ATTACHMENT_BYTES));
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function attachments(value) {
|
|
88
|
+
if (!Array.isArray(value) || value.length > 32) fail();
|
|
89
|
+
// Sparse arrays and custom/accessor properties are not a JSON attachment list.
|
|
90
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) fail();
|
|
91
|
+
const keys = Reflect.ownKeys(value);
|
|
92
|
+
if (keys.length !== value.length + 1) fail();
|
|
93
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
94
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
95
|
+
if (!descriptor || !('value' in descriptor)) fail();
|
|
96
|
+
}
|
|
97
|
+
return Array.from(value, attachment);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function base64(value) {
|
|
101
|
+
if (typeof value !== 'string' || !value || value.length > MAX_BASE64_LENGTH) fail();
|
|
102
|
+
if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) fail();
|
|
103
|
+
const bytes = Buffer.from(value, 'base64');
|
|
104
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES || bytes.toString('base64') !== value) fail();
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function modelPreference(value) {
|
|
109
|
+
record(value, ['profileId', 'scope']);
|
|
110
|
+
const result = { profileId: string(value.profileId, 256) };
|
|
111
|
+
optional(value, result, 'scope', (scope) => {
|
|
112
|
+
if (scope !== 'main' && scope !== 'all') fail();
|
|
113
|
+
return scope;
|
|
114
|
+
});
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function reasoningPreference(value) {
|
|
119
|
+
record(value, ['profileId', 'mode', 'budgetTokens']);
|
|
120
|
+
const result = { profileId: string(value.profileId, 256), mode: value.mode };
|
|
121
|
+
if (!['off', 'server-default', 'budget'].includes(value.mode)) fail();
|
|
122
|
+
if (value.mode !== 'budget' && Object.hasOwn(value, 'budgetTokens')) fail();
|
|
123
|
+
if (value.mode === 'budget') result.budgetTokens = integer(value.budgetTokens, 0, 2_147_483_647);
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function upload(value) {
|
|
128
|
+
const result = {
|
|
129
|
+
type: value.type, sessionId: id(value.sessionId), mimeType: mime(value.mimeType),
|
|
130
|
+
data: base64(value.data),
|
|
131
|
+
};
|
|
132
|
+
optional(value, result, 'requestId', id);
|
|
133
|
+
optional(value, result, 'kind', kind);
|
|
134
|
+
optional(value, result, 'name', (name) => string(name, 1024));
|
|
135
|
+
if (result.kind && MIME_TYPES.get(result.mimeType) !== result.kind) fail();
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import {
|
|
3
|
+
MAX_MESSAGE_BYTES, MAX_TEXT_BYTES, fail, record, string, id, approvalId, integer,
|
|
4
|
+
boolean, optional, attachment, attachments, modelPreference, reasoningPreference, upload,
|
|
5
|
+
} from './protocol-validation.mjs';
|
|
6
|
+
|
|
7
|
+
export { MAX_MESSAGE_BYTES, MAX_ATTACHMENT_BYTES, MAX_TEXT_BYTES } from './protocol-validation.mjs';
|
|
8
|
+
|
|
9
|
+
const FIELDS = Object.freeze({
|
|
10
|
+
run_request: ['requestId', 'sessionId', 'input', 'text', 'agentMode', 'modelPreference', 'reasoningPreference', 'toolPermissionMode', 'attachments'],
|
|
11
|
+
steer_request: ['requestId', 'steerId', 'text'],
|
|
12
|
+
tool_approval_response: ['approvalId', 'approved', 'reason'],
|
|
13
|
+
'request.events.request': ['requestId', 'afterSeq'],
|
|
14
|
+
'session.list.request': ['limit', 'cursor'],
|
|
15
|
+
'session.snapshot.request': ['sessionId', 'afterMessageId', 'includeRequests'],
|
|
16
|
+
'session.delete.request': ['sessionId'],
|
|
17
|
+
'session.messages.clear.request': ['sessionId'],
|
|
18
|
+
'session.message.delete.request': ['sessionId', 'messageId'],
|
|
19
|
+
'attachment.upload.request': ['sessionId', 'requestId', 'mimeType', 'kind', 'name', 'data'],
|
|
20
|
+
'attachment.fetch.request': ['sessionId', 'attachment'],
|
|
21
|
+
subscribe_request: ['requestId'],
|
|
22
|
+
});
|
|
23
|
+
const RESPONSES = Object.freeze({
|
|
24
|
+
'request.events.request': 'request.events.response',
|
|
25
|
+
'session.list.request': 'session.list.response',
|
|
26
|
+
'session.snapshot.request': 'session.snapshot.response',
|
|
27
|
+
'session.delete.request': 'session.delete.response',
|
|
28
|
+
'session.messages.clear.request': 'session.messages.clear.response',
|
|
29
|
+
'session.message.delete.request': 'session.message.delete.response',
|
|
30
|
+
'attachment.upload.request': 'attachment.upload.response',
|
|
31
|
+
'attachment.fetch.request': 'attachment.fetch.response',
|
|
32
|
+
});
|
|
33
|
+
const MODES = ['ask', 'full_access', 'full_plus'];
|
|
34
|
+
const text = (value) => string(value, MAX_TEXT_BYTES, { controls: true });
|
|
35
|
+
|
|
36
|
+
export function expectedResponseType(type) {
|
|
37
|
+
return typeof type === 'string' && Object.hasOwn(RESPONSES, type) ? RESPONSES[type] : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function run(value, allowedModes) {
|
|
41
|
+
const result = { type: value.type, requestId: id(value.requestId), sessionId: id(value.sessionId) };
|
|
42
|
+
if (!Object.hasOwn(value, 'input') && !Object.hasOwn(value, 'text')) fail();
|
|
43
|
+
optional(value, result, 'input', text);
|
|
44
|
+
optional(value, result, 'text', text);
|
|
45
|
+
optional(value, result, 'agentMode', (mode) => {
|
|
46
|
+
if (!['fast', 'reasoning', 'deep'].includes(mode)) fail();
|
|
47
|
+
return mode;
|
|
48
|
+
});
|
|
49
|
+
optional(value, result, 'modelPreference', modelPreference);
|
|
50
|
+
optional(value, result, 'reasoningPreference', reasoningPreference);
|
|
51
|
+
const mode = Object.hasOwn(value, 'toolPermissionMode') ? value.toolPermissionMode : 'ask';
|
|
52
|
+
if (!MODES.includes(mode)) fail();
|
|
53
|
+
if (!Array.isArray(allowedModes) || !allowedModes.includes(mode)) fail('forbidden');
|
|
54
|
+
result.toolPermissionMode = mode;
|
|
55
|
+
optional(value, result, 'attachments', attachments);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalize(value, allowedModes) {
|
|
60
|
+
const result = { type: value.type };
|
|
61
|
+
switch (value.type) {
|
|
62
|
+
case 'run_request': return run(value, allowedModes);
|
|
63
|
+
case 'subscribe_request': return { ...result, requestId: id(value.requestId) };
|
|
64
|
+
case 'steer_request': return { ...result, requestId: id(value.requestId), steerId: id(value.steerId), text: text(value.text) };
|
|
65
|
+
case 'tool_approval_response':
|
|
66
|
+
result.approvalId = approvalId(value.approvalId);
|
|
67
|
+
result.approved = boolean(value.approved);
|
|
68
|
+
optional(value, result, 'reason', text);
|
|
69
|
+
return result;
|
|
70
|
+
case 'request.events.request':
|
|
71
|
+
result.requestId = id(value.requestId);
|
|
72
|
+
optional(value, result, 'afterSeq', (seq) => integer(seq));
|
|
73
|
+
return result;
|
|
74
|
+
case 'session.list.request':
|
|
75
|
+
optional(value, result, 'limit', (limit) => integer(limit, 1, 100));
|
|
76
|
+
optional(value, result, 'cursor', (cursor) => cursor === null ? null : string(cursor, 4096));
|
|
77
|
+
return result;
|
|
78
|
+
case 'session.snapshot.request':
|
|
79
|
+
result.sessionId = id(value.sessionId);
|
|
80
|
+
optional(value, result, 'afterMessageId', (cursor) => cursor === null ? null : typeof cursor === 'number' ? integer(cursor) : id(cursor));
|
|
81
|
+
optional(value, result, 'includeRequests', boolean);
|
|
82
|
+
return result;
|
|
83
|
+
case 'session.delete.request':
|
|
84
|
+
case 'session.messages.clear.request':
|
|
85
|
+
return { ...result, sessionId: id(value.sessionId) };
|
|
86
|
+
case 'session.message.delete.request':
|
|
87
|
+
return { ...result, sessionId: id(value.sessionId),
|
|
88
|
+
messageId: typeof value.messageId === 'number' ? String(integer(value.messageId)) : id(value.messageId) };
|
|
89
|
+
case 'attachment.upload.request': return upload(value);
|
|
90
|
+
case 'attachment.fetch.request': return { ...result, sessionId: id(value.sessionId), attachment: attachment(value.attachment) };
|
|
91
|
+
default: return fail('unsupported');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Validate client-owned fields only; the relay adds its private correlation ID. */
|
|
96
|
+
export function normalizeClientCommand(body, allowedModes = ['ask']) {
|
|
97
|
+
// Validate descriptors before reading even the command type.
|
|
98
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) fail();
|
|
99
|
+
const descriptor = Object.getOwnPropertyDescriptor(body, 'type');
|
|
100
|
+
if (!descriptor || !('value' in descriptor)) fail();
|
|
101
|
+
const type = string(descriptor.value, 80);
|
|
102
|
+
if (!Object.hasOwn(FIELDS, type)) {
|
|
103
|
+
if (/^(?:runtime\.|admin\.|cron\.|schedule\.|session\.(?:delete|messages\.clear|message\.delete)\.)/u.test(type)) fail('forbidden');
|
|
104
|
+
fail('unsupported');
|
|
105
|
+
}
|
|
106
|
+
record(body, ['type', ...FIELDS[type]]);
|
|
107
|
+
const result = normalize(body, allowedModes);
|
|
108
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > MAX_MESSAGE_BYTES) fail();
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isRecord, isRelayId, relayError } from './relay-runtime-registration.mjs';
|
|
2
|
+
|
|
3
|
+
const MAX_COMMANDS = 512;
|
|
4
|
+
const COMMANDS_PER_MINUTE = 60;
|
|
5
|
+
|
|
6
|
+
export function createRelayClient(peer, identity, now) {
|
|
7
|
+
if (!isRecord(identity) || typeof identity.subject !== 'string' || identity.subject.length > 320 || !identity.subject) {
|
|
8
|
+
throw relayError('invalid_client_identity');
|
|
9
|
+
}
|
|
10
|
+
if (typeof identity.sessionId !== 'string' || !identity.sessionId || identity.sessionId.length > 256) {
|
|
11
|
+
throw relayError('invalid_client_identity');
|
|
12
|
+
}
|
|
13
|
+
if (!Array.isArray(identity.allowedRuntimeIds) || identity.allowedRuntimeIds.length > 32) {
|
|
14
|
+
throw relayError('invalid_client_identity');
|
|
15
|
+
}
|
|
16
|
+
if (identity.allowedRuntimeIds.some(id => !isRelayId(id))) throw relayError('invalid_client_identity');
|
|
17
|
+
return {
|
|
18
|
+
peer, subject: identity.subject, sessionId: identity.sessionId,
|
|
19
|
+
allowedRuntimeIds: new Set(identity.allowedRuntimeIds), commands: new Set(),
|
|
20
|
+
rateStartedAt: now, rateCount: 0,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function admitRelayCommand(client, message, now) {
|
|
25
|
+
// Charge failed commands too. Authentication and IP rate limits are transport-owned.
|
|
26
|
+
if (now - client.rateStartedAt >= 60_000) {
|
|
27
|
+
client.rateStartedAt = now;
|
|
28
|
+
client.rateCount = 0;
|
|
29
|
+
}
|
|
30
|
+
client.rateCount += 1;
|
|
31
|
+
if (client.rateCount > COMMANDS_PER_MINUTE) throw relayError('rate_limited');
|
|
32
|
+
if (!isRecord(message) || !isRelayId(message.id) || !isRelayId(message.runtimeId)) {
|
|
33
|
+
throw relayError('invalid_command_envelope');
|
|
34
|
+
}
|
|
35
|
+
if (!client.allowedRuntimeIds.has(message.runtimeId)) throw relayError('runtime_forbidden');
|
|
36
|
+
if (client.commands.has(message.id)) throw relayError('duplicate_command');
|
|
37
|
+
if (client.commands.size >= MAX_COMMANDS) throw relayError('command_capacity');
|
|
38
|
+
// IDs are not evicted while a connection is alive; a retry never re-dispatches.
|
|
39
|
+
client.commands.add(message.id);
|
|
40
|
+
}
|