@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +129 -2
  4. package/bin/abot-socket.mjs +15 -0
  5. package/config.example.json +14 -0
  6. package/docs/APP-REVIEW.md +184 -0
  7. package/docs/DEPLOYMENT.md +124 -0
  8. package/docs/INSTALLATION.md +177 -0
  9. package/docs/MIGRATION.md +84 -0
  10. package/docs/PROTOCOL.md +371 -0
  11. package/docs/SECURITY.md +116 -0
  12. package/docs/SELF-HOSTING.md +161 -0
  13. package/package.json +65 -4
  14. package/src/auth/auth-database.mjs +105 -0
  15. package/src/auth/auth-limits.mjs +17 -0
  16. package/src/auth/auth-pairing.mjs +95 -0
  17. package/src/auth/auth-sessions.mjs +119 -0
  18. package/src/auth/auth-storage-path.mjs +70 -0
  19. package/src/auth/auth.mjs +45 -0
  20. package/src/auth/runtime-credentials.mjs +39 -0
  21. package/src/cli/cli-arguments.mjs +23 -0
  22. package/src/cli/cli-entry.mjs +84 -0
  23. package/src/cli/cli-environment.mjs +31 -0
  24. package/src/cli/cli-help.mjs +34 -0
  25. package/src/cli/cli-output.mjs +35 -0
  26. package/src/cli/cli.mjs +58 -0
  27. package/src/cli/client-capabilities.mjs +14 -0
  28. package/src/cli/doctor.mjs +106 -0
  29. package/src/config/config-validation.mjs +41 -0
  30. package/src/config/config.mjs +57 -0
  31. package/src/deployment/deployment-command.mjs +42 -0
  32. package/src/deployment/deployment-input.mjs +76 -0
  33. package/src/deployment/deployment-plan.mjs +62 -0
  34. package/src/deployment/deployment-templates.mjs +109 -0
  35. package/src/main.mjs +26 -0
  36. package/src/notifications/notification-delivery-credentials.mjs +48 -0
  37. package/src/notifications/notification-delivery-fcm.mjs +100 -0
  38. package/src/notifications/notification-delivery-gateway.mjs +36 -0
  39. package/src/notifications/notification-delivery-http.mjs +53 -0
  40. package/src/notifications/notification-delivery-payload.mjs +45 -0
  41. package/src/notifications/notification-delivery.mjs +43 -0
  42. package/src/notifications/notification-gateway-command.mjs +48 -0
  43. package/src/notifications/notification-gateway-store.mjs +159 -0
  44. package/src/notifications/notification-gateway.mjs +181 -0
  45. package/src/notifications/notification-http.mjs +39 -0
  46. package/src/notifications/notification-input.mjs +82 -0
  47. package/src/notifications/notification-outbox.mjs +60 -0
  48. package/src/notifications/notification-preview-crypto.mjs +42 -0
  49. package/src/notifications/notification-preview-schema.mjs +7 -0
  50. package/src/notifications/notification-preview-store.mjs +63 -0
  51. package/src/notifications/notification-preview-text.mjs +20 -0
  52. package/src/notifications/notification-projection.mjs +20 -0
  53. package/src/notifications/notification-schema.mjs +22 -0
  54. package/src/notifications/notification-service.mjs +147 -0
  55. package/src/notifications/notification-store.mjs +132 -0
  56. package/src/pairing/pairing-qr-payload.mjs +32 -0
  57. package/src/pairing/pairing-qr.mjs +42 -0
  58. package/src/protocol/approval-protocol.mjs +39 -0
  59. package/src/protocol/protocol-validation.mjs +137 -0
  60. package/src/protocol/protocol.mjs +120 -0
  61. package/src/protocol/spark-chat-protocol.mjs +51 -0
  62. package/src/relay/relay-approvals.mjs +86 -0
  63. package/src/relay/relay-client-admission.mjs +40 -0
  64. package/src/relay/relay-correlations.mjs +101 -0
  65. package/src/relay/relay-model-catalog.mjs +69 -0
  66. package/src/relay/relay-request-adoption.mjs +97 -0
  67. package/src/relay/relay-requests.mjs +211 -0
  68. package/src/relay/relay-runtime-registration.mjs +49 -0
  69. package/src/relay/relay-session-mutations.mjs +122 -0
  70. package/src/relay/relay.mjs +222 -0
  71. package/src/relay/request-ownership-schema.mjs +18 -0
  72. package/src/relay/request-ownership-store.mjs +115 -0
  73. package/src/review/review-demo-isolation.mjs +132 -0
  74. package/src/review/review-demo-setup.mjs +97 -0
  75. package/src/review/review-portal-access.mjs +64 -0
  76. package/src/review/review-portal-form.mjs +24 -0
  77. package/src/review/review-portal-page.mjs +68 -0
  78. package/src/review/review-portal-server.mjs +105 -0
  79. package/src/setup/setup-files.mjs +37 -0
  80. package/src/setup/setup-questions.mjs +62 -0
  81. package/src/setup/setup-wizard.mjs +50 -0
  82. package/src/setup/setup.mjs +42 -0
  83. package/src/transport/http-api.mjs +32 -0
  84. package/src/transport/server.mjs +175 -0
  85. package/src/transport/transport-limits.mjs +85 -0
@@ -0,0 +1,42 @@
1
+ import { createCipheriv, randomBytes } from 'node:crypto';
2
+ import { MAX_PREVIEW_BYTES } from './notification-preview-text.mjs';
3
+
4
+ const MAX_AGE_MS = 7 * 86_400_000;
5
+ const ENVELOPE_KEYS = ['v', 'keyId', 'nonce', 'ciphertext', 'expiresAt'];
6
+ export function previewBase64Bytes(value, size, maximum = size) {
7
+ if (typeof value !== 'string' || value.length > Math.ceil(maximum * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value)) return null;
8
+ const bytes = Buffer.from(value, 'base64url');
9
+ if (bytes.length < size || bytes.length > maximum || bytes.toString('base64url') !== value) return null;
10
+ return bytes;
11
+ }
12
+
13
+ export function hasPreviewKey(value) {
14
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value) &&
15
+ Object.keys(value).length === 2 && Object.keys(value).every(key => ['keyId', 'key'].includes(key)) &&
16
+ previewBase64Bytes(value.keyId, 16) && previewBase64Bytes(value.key, 32));
17
+ }
18
+
19
+ export function previewAdditionalData(keyId, notificationId, expiresAt) {
20
+ return Buffer.from(`ABot push preview v1\n${keyId}\n${notificationId}\n${expiresAt}`, 'utf8');
21
+ }
22
+
23
+ export function validatedNotificationPreview(value, notification, now) {
24
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
25
+ if (Object.keys(value).length !== ENVELOPE_KEYS.length || Object.keys(value).some(key => !ENVELOPE_KEYS.includes(key))) return null;
26
+ if (value.v !== 1 || notification?.kind !== 'completed') return null;
27
+ if (!Number.isSafeInteger(value.expiresAt) || value.expiresAt !== notification.expiresAt ||
28
+ value.expiresAt <= now || value.expiresAt > now + MAX_AGE_MS) return null;
29
+ if (!previewBase64Bytes(value.keyId, 16) || !previewBase64Bytes(value.nonce, 12) ||
30
+ !previewBase64Bytes(value.ciphertext, 17, MAX_PREVIEW_BYTES + 16)) return null;
31
+ return { v: 1, keyId: value.keyId, nonce: value.nonce, ciphertext: value.ciphertext, expiresAt: value.expiresAt };
32
+ }
33
+
34
+ export function sealNotificationPreview(keyId, key, notification, text, nonce = randomBytes(12)) {
35
+ if (!previewBase64Bytes(keyId, 16) || key.length !== 32 || nonce.length !== 12 || typeof text !== 'string' ||
36
+ !text || Buffer.byteLength(text, 'utf8') > MAX_PREVIEW_BYTES) throw new Error('Invalid notification preview');
37
+ const cipher = createCipheriv('aes-256-gcm', key, nonce);
38
+ cipher.setAAD(previewAdditionalData(keyId, notification.id, notification.expiresAt));
39
+ const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final(), cipher.getAuthTag()]);
40
+ return { v: 1, keyId, nonce: nonce.toString('base64url'), ciphertext: ciphertext.toString('base64url'),
41
+ expiresAt: notification.expiresAt };
42
+ }
@@ -0,0 +1,7 @@
1
+ export const NOTIFICATION_PREVIEW_SCHEMA = `
2
+ CREATE TABLE notification_preview_keys (
3
+ device_id TEXT PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE,
4
+ key_id TEXT NOT NULL, key_bytes BLOB NOT NULL CHECK(length(key_bytes) = 32)
5
+ ) STRICT;
6
+ ALTER TABLE notification_outbox ADD COLUMN encrypted_preview TEXT;
7
+ `;
@@ -0,0 +1,63 @@
1
+ import { notificationPreviewText } from './notification-preview-text.mjs';
2
+ import { sealNotificationPreview } from './notification-preview-crypto.mjs';
3
+
4
+ /** Keys stay device-scoped; only ciphertext enters the delivery queue. */
5
+ export class NotificationPreviewStore {
6
+ constructor({ database, now = Date.now }) { this.database = database; this.now = now; }
7
+
8
+ status(deviceId) {
9
+ const row = this.database.prepare(`SELECT p.key_id FROM notification_preview_keys p
10
+ JOIN devices d ON d.id = p.device_id JOIN notification_registrations r ON r.device_id = p.device_id
11
+ WHERE p.device_id = ? AND d.expires_at > ?`).get(deviceId, this.now());
12
+ return { supported: true, enabled: Boolean(row), keyId: row?.key_id ?? null };
13
+ }
14
+
15
+ enable(deviceId, value) {
16
+ return this.database.transaction(() => {
17
+ const registered = this.database.prepare(`SELECT d.id FROM devices d
18
+ JOIN notification_registrations r ON r.device_id = d.id WHERE d.id = ? AND d.expires_at > ?`)
19
+ .get(deviceId, this.now());
20
+ if (!registered) return false;
21
+ this.database.prepare(`INSERT INTO notification_preview_keys(device_id, key_id, key_bytes) VALUES (?, ?, ?)
22
+ ON CONFLICT(device_id) DO UPDATE SET key_id = excluded.key_id, key_bytes = excluded.key_bytes`)
23
+ .run(deviceId, value.keyId, Buffer.from(value.key, 'base64url'));
24
+ this.clearQueued(deviceId);
25
+ return true;
26
+ });
27
+ }
28
+
29
+ clearQueued(deviceId) {
30
+ this.database.prepare(`UPDATE notification_outbox SET encrypted_preview = NULL WHERE notification_id IN
31
+ (SELECT id FROM notifications WHERE device_id = ?)`).run(deviceId);
32
+ }
33
+
34
+ removeKey(deviceId) {
35
+ this.database.prepare('DELETE FROM notification_preview_keys WHERE device_id = ?').run(deviceId);
36
+ this.clearQueued(deviceId);
37
+ }
38
+
39
+ disable(deviceId) { this.database.transaction(() => this.removeKey(deviceId)); }
40
+
41
+ attach(item, output) {
42
+ if (item.kind !== 'completed') return;
43
+ try {
44
+ const key = this.database.prepare(`SELECT p.key_id, p.key_bytes FROM notification_preview_keys p
45
+ JOIN devices d ON d.id = p.device_id JOIN notification_registrations r ON r.device_id = p.device_id
46
+ WHERE p.device_id = ? AND d.expires_at > ?`).get(item.deviceId, this.now());
47
+ if (!key) return;
48
+ const text = notificationPreviewText(output);
49
+ if (!text) return;
50
+ const preview = sealNotificationPreview(key.key_id, Buffer.from(key.key_bytes), item, text);
51
+ this.database.prepare(`UPDATE notification_outbox SET encrypted_preview = ? WHERE notification_id = ?
52
+ AND EXISTS (SELECT 1 FROM notification_preview_keys WHERE device_id = ? AND key_id = ? AND key_bytes = ?)`)
53
+ .run(JSON.stringify(preview), item.id, item.deviceId, key.key_id, key.key_bytes);
54
+ } catch { /* The already-durable generic notification remains deliverable. */ }
55
+ }
56
+
57
+ prune() {
58
+ this.database.prepare(`DELETE FROM notification_preview_keys WHERE NOT EXISTS
59
+ (SELECT 1 FROM devices WHERE id = device_id AND expires_at > ?) OR NOT EXISTS
60
+ (SELECT 1 FROM notification_registrations r WHERE r.device_id = notification_preview_keys.device_id)`)
61
+ .run(this.now());
62
+ }
63
+ }
@@ -0,0 +1,20 @@
1
+ export const MAX_PREVIEW_BYTES = 1024;
2
+ const SOURCE_PREFIX = 8192;
3
+ const UNSAFE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
4
+
5
+ /** A bounded display excerpt, never a transcript or a claim to redact secrets. */
6
+ export function notificationPreviewText(output) {
7
+ if (typeof output !== 'string') return null;
8
+ const clean = [...output.slice(0, SOURCE_PREFIX).replace(/\r\n?/gu, '\n').replace(UNSAFE, '')]
9
+ .filter(char => { const code = char.codePointAt(0); return code < 0xd800 || code > 0xdfff; }).join('').trim();
10
+ if (!clean) return null;
11
+ const truncated = output.length > SOURCE_PREFIX || Buffer.byteLength(clean, 'utf8') > MAX_PREVIEW_BYTES;
12
+ if (!truncated) return clean;
13
+ let text = ''; let bytes = 0;
14
+ for (const char of clean) {
15
+ const length = Buffer.byteLength(char, 'utf8');
16
+ if (bytes + length > MAX_PREVIEW_BYTES - 3) break;
17
+ text += char; bytes += length;
18
+ }
19
+ return `${text.trimEnd()}…`;
20
+ }
@@ -0,0 +1,20 @@
1
+ export const NOTIFICATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
2
+ export const APPROVAL_NOTIFICATION_MS = 5 * 60 * 1000;
3
+
4
+ /** Select only final results and live approval requests, never token/progress text. */
5
+ export function projectNotificationEvent(identity, body, now) {
6
+ if (body.type === 'completed' || body.type === 'failed') {
7
+ return { ...identity, kind: body.type, approvalId: '', createdAt: now,
8
+ expiresAt: now + NOTIFICATION_RETENTION_MS };
9
+ }
10
+ if (body.type !== 'event' || body.name !== 'tool.approval.required') return null;
11
+ if (typeof body.approvalId !== 'string' || !body.approvalId || body.approvalId.length > 512) return null;
12
+ return { ...identity, kind: 'approval_required', approvalId: body.approvalId,
13
+ createdAt: now, expiresAt: now + APPROVAL_NOTIFICATION_MS };
14
+ }
15
+
16
+ export function closesApprovalNotifications(body) {
17
+ if (body.type === 'completed' || body.type === 'failed') return true;
18
+ if (body.type !== 'event' || !['tool.approval.granted', 'tool.approval.rejected'].includes(body.name)) return false;
19
+ return typeof body.approvalId === 'string' && body.approvalId.length > 0 && body.approvalId.length <= 512;
20
+ }
@@ -0,0 +1,22 @@
1
+ /** Metadata only: conversation content and authentication credentials never enter this ledger. */
2
+ export const NOTIFICATION_SCHEMA = `
3
+ CREATE TABLE notifications (
4
+ id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
5
+ runtime_id TEXT NOT NULL, session_id TEXT NOT NULL, request_id TEXT NOT NULL,
6
+ kind TEXT NOT NULL CHECK (kind IN ('completed', 'failed', 'approval_required')),
7
+ approval_id TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL,
8
+ read_at INTEGER, expires_at INTEGER NOT NULL,
9
+ UNIQUE(device_id, runtime_id, request_id, kind, approval_id)
10
+ ) STRICT;
11
+ CREATE INDEX notifications_device_time ON notifications(device_id, created_at DESC, id DESC);
12
+ CREATE INDEX notifications_expiry ON notifications(expires_at);
13
+ CREATE TABLE notification_registrations (
14
+ device_id TEXT PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE,
15
+ provider TEXT NOT NULL CHECK (provider IN ('fcm', 'gateway')),
16
+ registration TEXT NOT NULL, updated_at INTEGER NOT NULL
17
+ ) STRICT;
18
+ CREATE TABLE notification_outbox (
19
+ notification_id TEXT PRIMARY KEY REFERENCES notifications(id) ON DELETE CASCADE,
20
+ attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL
21
+ ) STRICT;
22
+ `;
@@ -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,39 @@
1
+ import { MAX_TEXT_BYTES, approvalId, boolean, fail, id, integer, optional, string } from './protocol-validation.mjs';
2
+
3
+ export const APPROVAL_FIELDS = Object.freeze({
4
+ 'request.approvals.request': ['sessionId', 'requestId'],
5
+ 'request.approval.decide.request': ['sessionId', 'requestId', 'approvalId', 'approved', 'reason',
6
+ 'generation', 'waitId', 'revision', 'commandId'],
7
+ });
8
+
9
+ export const APPROVAL_RESPONSES = Object.freeze({
10
+ 'request.approvals.request': 'request.approvals.response',
11
+ 'request.approval.decide.request': 'request.approval.decide.response',
12
+ });
13
+
14
+ const WAIT_FIELDS = ['generation', 'waitId', 'revision', 'commandId'];
15
+
16
+ export function isApprovalCommand(type) {
17
+ return typeof type === 'string' && Object.hasOwn(APPROVAL_FIELDS, type);
18
+ }
19
+
20
+ export function durableApprovalMode(value) {
21
+ if (value !== 'durable_v1') fail();
22
+ return value;
23
+ }
24
+
25
+ /** Validate transport input only; current approval state and decisions belong to the runtime. */
26
+ export function normalizeApprovalCommand(value) {
27
+ const result = { type: value.type, sessionId: id(value.sessionId), requestId: id(value.requestId) };
28
+ if (value.type === 'request.approvals.request') return result;
29
+ result.approvalId = approvalId(value.approvalId);
30
+ result.approved = boolean(value.approved);
31
+ optional(value, result, 'reason', reason => string(reason, MAX_TEXT_BYTES, { controls: true }));
32
+ if (!WAIT_FIELDS.some(key => Object.hasOwn(value, key))) return result;
33
+ if (!WAIT_FIELDS.every(key => Object.hasOwn(value, key))) fail();
34
+ result.generation = id(value.generation);
35
+ result.waitId = id(value.waitId);
36
+ result.revision = integer(value.revision);
37
+ result.commandId = id(value.commandId);
38
+ return result;
39
+ }