@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,181 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { bearerToken, jsonResponse, readJson, sameAuthority, trustedClientAddress, WindowLimiter } from '../transport/transport-limits.mjs';
|
|
3
|
+
import { validatedNotificationPreview } from './notification-preview-crypto.mjs';
|
|
4
|
+
import { NotificationGatewayStore } from './notification-gateway-store.mjs';
|
|
5
|
+
import { isFcmDeviceToken, notificationEnvelope } from './notification-delivery-payload.mjs';
|
|
6
|
+
|
|
7
|
+
const IDENTIFIER = /^[A-Za-z0-9_-]{16,128}$/;
|
|
8
|
+
const SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
9
|
+
const failure = (code, status = 400) => Object.assign(new Error(code), { code, status });
|
|
10
|
+
const isSecret = (value) => typeof value === 'string' && SECRET.test(value);
|
|
11
|
+
const isIdentifier = (value) => typeof value === 'string' && IDENTIFIER.test(value);
|
|
12
|
+
|
|
13
|
+
function hasExactKeys(body, keys) {
|
|
14
|
+
return Object.keys(body).length === keys.length && Object.keys(body).every((key) => keys.includes(key));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function canonicalOrigin(value) {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(value);
|
|
20
|
+
if (url.protocol === 'https:' && url.origin === value) return url;
|
|
21
|
+
} catch { /* Reject malformed origins without exposing them. */ }
|
|
22
|
+
throw new Error('Gateway requires a canonical HTTPS publicOrigin');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Separate trusted sender process. This is not the user's chat relay. */
|
|
26
|
+
export function createNotificationGateway({ stateDirectory, publicOrigin, sendProviderEnvelope, now = Date.now }) {
|
|
27
|
+
const origin = canonicalOrigin(publicOrigin);
|
|
28
|
+
if (typeof sendProviderEnvelope !== 'function') throw new Error('Gateway requires a notification provider');
|
|
29
|
+
const store = new NotificationGatewayStore({ stateDirectory, now });
|
|
30
|
+
const shutdown = new AbortController();
|
|
31
|
+
const sources = new WindowLimiter({ limit: 120, windowMs: 60_000, now });
|
|
32
|
+
const enrollment = new WindowLimiter({ limit: 5, windowMs: 60 * 60_000, now });
|
|
33
|
+
const enrollmentTotal = new WindowLimiter({ limit: 1000, windowMs: 86_400_000, capacity: 1, now });
|
|
34
|
+
const endpointRate = new WindowLimiter({ limit: 30, windowMs: 60_000, now });
|
|
35
|
+
const activeDeliveries = new Map();
|
|
36
|
+
const activeHandlers = new Set();
|
|
37
|
+
|
|
38
|
+
async function sendEnvelope(token, envelope, signal = shutdown.signal) {
|
|
39
|
+
const deadline = new AbortController();
|
|
40
|
+
const combined = AbortSignal.any([signal, deadline.signal]);
|
|
41
|
+
const timer = setTimeout(() => deadline.abort(), 10_000);
|
|
42
|
+
let onAbort;
|
|
43
|
+
try {
|
|
44
|
+
if (combined.aborted) return { status: 'retry' };
|
|
45
|
+
const interrupted = new Promise((resolve) => {
|
|
46
|
+
onAbort = () => resolve({ status: 'retry' });
|
|
47
|
+
combined.addEventListener('abort', onAbort, { once: true });
|
|
48
|
+
});
|
|
49
|
+
return await Promise.race([sendProviderEnvelope(token, envelope, combined), interrupted]);
|
|
50
|
+
} catch { return { status: 'retry' }; }
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (onAbort) combined.removeEventListener('abort', onAbort);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function enroll(body, sourceId) {
|
|
58
|
+
if (!hasExactKeys(body, ['token']) || !isFcmDeviceToken(body.token)) throw failure('invalid_request');
|
|
59
|
+
if (!enrollment.allow(sourceId) || !enrollmentTotal.allow('gateway')) throw failure('rate_limited', 429);
|
|
60
|
+
const challenge = store.createChallenge(body.token);
|
|
61
|
+
const result = await sendEnvelope(body.token, {
|
|
62
|
+
data: { abotPushChallenge: challenge.challengeId, proof: challenge.proof },
|
|
63
|
+
android: { priority: 'NORMAL',
|
|
64
|
+
ttl: `${Math.max(0, Math.floor((challenge.expiresAt - now()) / 1000))}s` },
|
|
65
|
+
apns: { headers: { 'apns-push-type': 'background', 'apns-priority': '5',
|
|
66
|
+
'apns-expiration': String(Math.floor(challenge.expiresAt / 1000)) },
|
|
67
|
+
payload: { aps: { 'content-available': 1 } } },
|
|
68
|
+
});
|
|
69
|
+
if (result.status !== 'delivered') {
|
|
70
|
+
store.removeChallenge(challenge.challengeId);
|
|
71
|
+
throw failure('provider_unavailable', 503);
|
|
72
|
+
}
|
|
73
|
+
return { challengeId: challenge.challengeId, expiresAt: challenge.expiresAt };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function confirm(body) {
|
|
77
|
+
if (!hasExactKeys(body, ['challengeId', 'proof']) || !isSecret(body.challengeId) || !isSecret(body.proof)) {
|
|
78
|
+
throw failure('invalid_request');
|
|
79
|
+
}
|
|
80
|
+
const grant = store.confirm(body.challengeId, body.proof);
|
|
81
|
+
if (!grant) throw failure('unauthorized', 401);
|
|
82
|
+
return grant;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function deliver(body, credential) {
|
|
86
|
+
const keys = ['endpointId', 'notificationId', 'kind', 'expiresAt', 'badge'];
|
|
87
|
+
if (Object.hasOwn(body, 'preview')) keys.push('preview');
|
|
88
|
+
if (!hasExactKeys(body, keys) ||
|
|
89
|
+
!isSecret(body.endpointId) || !isIdentifier(body.notificationId)) throw failure('invalid_request');
|
|
90
|
+
const endpoint = store.authorize(body.endpointId, credential);
|
|
91
|
+
if (!endpoint) throw failure('unauthorized', 401);
|
|
92
|
+
const notification = { id: body.notificationId, kind: body.kind, expiresAt: body.expiresAt };
|
|
93
|
+
if (body.expiresAt > now() + 7 * 86_400_000) throw failure('invalid_request');
|
|
94
|
+
if (Object.hasOwn(body, 'preview')) {
|
|
95
|
+
notification.preview = validatedNotificationPreview(body.preview, notification, now());
|
|
96
|
+
if (!notification.preview) throw failure('invalid_request');
|
|
97
|
+
}
|
|
98
|
+
const envelope = notificationEnvelope(notification, body.badge, now());
|
|
99
|
+
if (!envelope) throw failure('invalid_request');
|
|
100
|
+
if (!endpointRate.allow(endpoint.id)) throw failure('rate_limited', 429);
|
|
101
|
+
const claim = store.claimDelivery(endpoint.id, notification.id, notification.expiresAt);
|
|
102
|
+
if (claim === 'done') return { ok: true };
|
|
103
|
+
if (claim === 'busy') throw failure('delivery_pending', 503);
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const signal = AbortSignal.any([controller.signal, shutdown.signal]);
|
|
106
|
+
const deliveryKey = `${endpoint.id}:${notification.id}`;
|
|
107
|
+
activeDeliveries.set(deliveryKey, { endpointId: endpoint.id, controller });
|
|
108
|
+
let result;
|
|
109
|
+
try { result = await sendEnvelope(endpoint.token, envelope, signal); }
|
|
110
|
+
finally { activeDeliveries.delete(deliveryKey); }
|
|
111
|
+
if (result.status === 'invalid') {
|
|
112
|
+
store.removeInvalidEndpoint(endpoint.id);
|
|
113
|
+
throw failure('endpoint_unavailable', 410);
|
|
114
|
+
}
|
|
115
|
+
store.finishDelivery(endpoint.id, notification.id, result.status === 'delivered');
|
|
116
|
+
if (result.status !== 'delivered') throw failure('provider_unavailable', 503);
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function revoke(body, credential) {
|
|
121
|
+
if (!hasExactKeys(body, ['endpointId']) || !isSecret(body.endpointId)) throw failure('invalid_request');
|
|
122
|
+
if (!store.revoke(body.endpointId, credential)) throw failure('unauthorized', 401);
|
|
123
|
+
for (const delivery of activeDeliveries.values()) {
|
|
124
|
+
if (delivery.endpointId === body.endpointId) delivery.controller.abort();
|
|
125
|
+
}
|
|
126
|
+
return { ok: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleRequest(req, res) {
|
|
130
|
+
try {
|
|
131
|
+
if (shutdown.signal.aborted) throw failure('unavailable', 503);
|
|
132
|
+
if (!sameAuthority(req, origin) || req.headers['x-forwarded-proto'] !== 'https') throw failure('forbidden', 403);
|
|
133
|
+
const sourceId = trustedClientAddress(req);
|
|
134
|
+
if (!sourceId) throw failure('forbidden', 403);
|
|
135
|
+
if (!sources.allow(sourceId)) throw failure('rate_limited', 429);
|
|
136
|
+
if (req.method === 'GET' && req.url === '/health') return jsonResponse(res, 200, { ok: true });
|
|
137
|
+
if (req.method !== 'POST') throw failure('not_found', 404);
|
|
138
|
+
const body = await readJson(req, 8192);
|
|
139
|
+
if (req.url === '/v1/enroll') return jsonResponse(res, 200, await enroll(body, sourceId));
|
|
140
|
+
if (req.url === '/v1/confirm') return jsonResponse(res, 200, confirm(body));
|
|
141
|
+
if (req.url === '/v1/deliver') return jsonResponse(res, 200, await deliver(body, bearerToken(req)));
|
|
142
|
+
if (req.url === '/v1/revoke') return jsonResponse(res, 200, revoke(body, bearerToken(req)));
|
|
143
|
+
throw failure('not_found', 404);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const status = [400, 401, 403, 404, 410, 413, 415, 429, 503].includes(error.status) ? error.status : 500;
|
|
146
|
+
const code = status === 500 ? 'internal_error' : error.code;
|
|
147
|
+
if (!res.headersSent && !res.destroyed) jsonResponse(res, status, { error: code });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function handler(req, res) {
|
|
152
|
+
const operation = handleRequest(req, res);
|
|
153
|
+
activeHandlers.add(operation);
|
|
154
|
+
void operation.finally(() => activeHandlers.delete(operation));
|
|
155
|
+
return operation;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const server = createServer(handler);
|
|
159
|
+
server.requestTimeout = 15_000; server.headersTimeout = 5_000;
|
|
160
|
+
server.keepAliveTimeout = 5_000; server.maxHeadersCount = 32; server.maxConnections = 32;
|
|
161
|
+
const sweep = setInterval(() => {
|
|
162
|
+
store.prune(); sources.prune(); enrollment.prune(); enrollmentTotal.prune(); endpointRate.prune();
|
|
163
|
+
}, 30_000);
|
|
164
|
+
sweep.unref();
|
|
165
|
+
let closed = false;
|
|
166
|
+
return {
|
|
167
|
+
handler,
|
|
168
|
+
listen: (port = 8789) => new Promise((resolve, reject) => {
|
|
169
|
+
server.once('error', reject);
|
|
170
|
+
server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(server.address()); });
|
|
171
|
+
}),
|
|
172
|
+
close: () => new Promise((resolve) => {
|
|
173
|
+
if (closed) { resolve(); return; }
|
|
174
|
+
closed = true; clearInterval(sweep); shutdown.abort();
|
|
175
|
+
server.closeAllConnections();
|
|
176
|
+
server.close(() => {
|
|
177
|
+
void Promise.allSettled([...activeHandlers]).then(() => { store.close(); resolve(); });
|
|
178
|
+
});
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsonResponse, readJson } from '../transport/transport-limits.mjs';
|
|
2
|
+
import { notificationListOptions, notificationPresence, notificationReadIds, notificationRegistration, notificationPreviewKey } from './notification-input.mjs';
|
|
3
|
+
|
|
4
|
+
/** Authenticated device identity comes from the caller, never from request fields. */
|
|
5
|
+
export async function handleNotificationHttp(req, res, { principal, notifications, runtimeGrants }) {
|
|
6
|
+
const url = new URL(req.url, 'https://socket.invalid');
|
|
7
|
+
const path = url.pathname;
|
|
8
|
+
if (req.method === 'GET' && path === '/v2/notifications') {
|
|
9
|
+
jsonResponse(res, 200, notifications.list(principal.sessionId, notificationListOptions(url.searchParams)));
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
if (url.search) return false;
|
|
13
|
+
if (req.method === 'POST' && path === '/v2/notifications/read') {
|
|
14
|
+
notifications.markRead(principal.sessionId, notificationReadIds(await readJson(req)));
|
|
15
|
+
jsonResponse(res, 200, { ok: true }); return true;
|
|
16
|
+
}
|
|
17
|
+
if (req.method === 'POST' && path === '/v2/notifications/presence') {
|
|
18
|
+
const value = notificationPresence(await readJson(req), runtimeGrants.map(grant => grant.runtimeId));
|
|
19
|
+
notifications.updatePresence(principal.sessionId, value);
|
|
20
|
+
jsonResponse(res, 200, { ok: true }); return true;
|
|
21
|
+
}
|
|
22
|
+
if (req.method === 'PUT' && path === '/v2/notifications/push/preview') {
|
|
23
|
+
notifications.enablePreview(principal.sessionId, notificationPreviewKey(await readJson(req, 512)));
|
|
24
|
+
jsonResponse(res, 200, { ok: true }); return true;
|
|
25
|
+
}
|
|
26
|
+
if (req.method === 'DELETE' && path === '/v2/notifications/push/preview') {
|
|
27
|
+
notifications.disablePreview(principal.sessionId);
|
|
28
|
+
jsonResponse(res, 200, { ok: true }); return true;
|
|
29
|
+
}
|
|
30
|
+
if (req.method === 'PUT' && path === '/v2/notifications/push') {
|
|
31
|
+
notifications.register(principal.sessionId, notificationRegistration(await readJson(req, 8192)));
|
|
32
|
+
jsonResponse(res, 200, { ok: true }); return true;
|
|
33
|
+
}
|
|
34
|
+
if (req.method === 'DELETE' && path === '/v2/notifications/push') {
|
|
35
|
+
notifications.unregister(principal.sessionId);
|
|
36
|
+
jsonResponse(res, 200, { ok: true }); return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { isRelayId } from '../relay/relay-runtime-registration.mjs';
|
|
2
|
+
import { hasPreviewKey } from './notification-preview-crypto.mjs';
|
|
3
|
+
|
|
4
|
+
export function notificationInputError(code = 'invalid_request', status = 400) {
|
|
5
|
+
return Object.assign(new Error(code), { code, status });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function exactRecord(value, allowed) {
|
|
9
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw notificationInputError();
|
|
10
|
+
if (Object.keys(value).some(key => !allowed.includes(key))) throw notificationInputError();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function notificationReadIds(value) {
|
|
14
|
+
exactRecord(value, ['ids']);
|
|
15
|
+
if (!Array.isArray(value.ids) || value.ids.length > 100 || !value.ids.every(isRelayId)) {
|
|
16
|
+
throw notificationInputError();
|
|
17
|
+
}
|
|
18
|
+
return [...new Set(value.ids)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function notificationPresence(value, allowedRuntimeIds) {
|
|
22
|
+
exactRecord(value, ['foreground', 'runtimeId', 'sessionId']);
|
|
23
|
+
if (typeof value.foreground !== 'boolean') throw notificationInputError();
|
|
24
|
+
for (const key of ['runtimeId', 'sessionId']) {
|
|
25
|
+
if (value[key] !== undefined && !isRelayId(value[key])) throw notificationInputError();
|
|
26
|
+
}
|
|
27
|
+
if (value.sessionId !== undefined && value.runtimeId === undefined) throw notificationInputError();
|
|
28
|
+
if (value.runtimeId !== undefined && !allowedRuntimeIds.includes(value.runtimeId)) {
|
|
29
|
+
throw notificationInputError('forbidden', 403);
|
|
30
|
+
}
|
|
31
|
+
return value.foreground ? { ...value } : { foreground: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function boundedCredential(value, maxLength) {
|
|
35
|
+
return typeof value === 'string' && value.length > 0 && value.length <= maxLength &&
|
|
36
|
+
!/[\s\x00-\x1f\x7f]/u.test(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function notificationRegistration(value) {
|
|
40
|
+
if (value?.provider === 'fcm') {
|
|
41
|
+
exactRecord(value, ['provider', 'token']);
|
|
42
|
+
if (!boundedCredential(value.token, 4096)) throw notificationInputError();
|
|
43
|
+
return { provider: 'fcm', token: value.token };
|
|
44
|
+
}
|
|
45
|
+
if (value?.provider === 'gateway') {
|
|
46
|
+
exactRecord(value, ['provider', 'endpointId', 'sendToken']);
|
|
47
|
+
if (!boundedCredential(value.endpointId, 256) || !boundedCredential(value.sendToken, 2048)) {
|
|
48
|
+
throw notificationInputError();
|
|
49
|
+
}
|
|
50
|
+
return { provider: 'gateway', endpointId: value.endpointId, sendToken: value.sendToken };
|
|
51
|
+
}
|
|
52
|
+
throw notificationInputError();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function notificationPreviewKey(value) {
|
|
56
|
+
if (!hasPreviewKey(value)) throw notificationInputError();
|
|
57
|
+
return { keyId: value.keyId, key: value.key };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function notificationListOptions(params) {
|
|
61
|
+
const keys = [...params.keys()];
|
|
62
|
+
if (new Set(keys).size !== keys.length || keys.some(key => !['limit', 'before'].includes(key))) {
|
|
63
|
+
throw notificationInputError();
|
|
64
|
+
}
|
|
65
|
+
const limitValue = params.get('limit') ?? '50';
|
|
66
|
+
if (!/^\d{1,3}$/.test(limitValue)) throw notificationInputError();
|
|
67
|
+
const limit = Number(limitValue);
|
|
68
|
+
if (limit < 1 || limit > 100) throw notificationInputError();
|
|
69
|
+
const cursor = params.get('before');
|
|
70
|
+
if (cursor === null) return { limit, before: null };
|
|
71
|
+
if (!/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) throw notificationInputError();
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
74
|
+
if (!Array.isArray(value) || value.length !== 2 || !Number.isSafeInteger(value[0]) ||
|
|
75
|
+
value[0] < 0 || !isRelayId(value[1])) throw new Error();
|
|
76
|
+
return { limit, before: { createdAt: value[0], id: value[1] } };
|
|
77
|
+
} catch { throw notificationInputError(); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function notificationCursor(item) {
|
|
81
|
+
return Buffer.from(JSON.stringify([item.createdAt, item.id])).toString('base64url');
|
|
82
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { validatedNotificationPreview } from './notification-preview-crypto.mjs';
|
|
2
|
+
|
|
3
|
+
const MAX_ATTEMPTS = 5;
|
|
4
|
+
const CLAIM_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
/** Durable bounded retries for already-recorded events; never executes a runtime request. */
|
|
7
|
+
export class NotificationOutbox {
|
|
8
|
+
constructor({ database, now = Date.now }) { this.database = database; this.now = now; }
|
|
9
|
+
|
|
10
|
+
claim(provider) {
|
|
11
|
+
return this.database.transaction(() => {
|
|
12
|
+
this.database.prepare('DELETE FROM notification_outbox WHERE attempts >= ?').run(MAX_ATTEMPTS);
|
|
13
|
+
const row = this.database.prepare(`SELECT n.id, n.device_id AS deviceId, n.runtime_id AS runtimeId,
|
|
14
|
+
n.session_id AS sessionId, n.request_id AS requestId, n.kind, n.created_at AS createdAt,
|
|
15
|
+
n.read_at AS readAt, n.expires_at AS expiresAt, o.attempts, o.encrypted_preview, r.registration
|
|
16
|
+
FROM notification_outbox o JOIN notifications n ON n.id = o.notification_id
|
|
17
|
+
JOIN devices d ON d.id = n.device_id JOIN notification_registrations r ON r.device_id = n.device_id
|
|
18
|
+
WHERE o.next_at <= ? AND n.read_at IS NULL AND n.expires_at > ? AND d.expires_at > ?
|
|
19
|
+
AND r.provider = ? ORDER BY o.next_at, n.created_at, n.id LIMIT 1`)
|
|
20
|
+
.get(this.now(), this.now(), this.now(), provider);
|
|
21
|
+
if (!row) return null;
|
|
22
|
+
this.database.prepare('UPDATE notification_outbox SET attempts = attempts + 1, next_at = ? WHERE notification_id = ?')
|
|
23
|
+
.run(this.now() + CLAIM_MS, row.id);
|
|
24
|
+
const { deviceId, registration, attempts, encrypted_preview: encodedPreview, ...notification } = row;
|
|
25
|
+
if (encodedPreview) {
|
|
26
|
+
try {
|
|
27
|
+
const preview = validatedNotificationPreview(JSON.parse(encodedPreview), notification, this.now());
|
|
28
|
+
if (preview) notification.preview = preview;
|
|
29
|
+
} catch { /* Corrupt previews fall back to the generic notification. */ }
|
|
30
|
+
}
|
|
31
|
+
return { deviceId, registration: JSON.parse(registration), attempts: attempts + 1, notification, encodedPreview };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
canDeliver(entry) {
|
|
36
|
+
const row = this.database.prepare(`SELECT r.registration, o.encrypted_preview FROM notification_outbox o
|
|
37
|
+
JOIN notifications n ON n.id = o.notification_id JOIN devices d ON d.id = n.device_id
|
|
38
|
+
JOIN notification_registrations r ON r.device_id = n.device_id
|
|
39
|
+
WHERE n.id = ? AND n.device_id = ? AND n.read_at IS NULL AND n.expires_at > ? AND d.expires_at > ?`)
|
|
40
|
+
.get(entry.notification.id, entry.deviceId, this.now(), this.now());
|
|
41
|
+
return row?.registration === JSON.stringify(entry.registration) && row.encrypted_preview === entry.encodedPreview;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
cancel(entry) {
|
|
45
|
+
// A cancelled send spends no attempt and cannot recreate a read/revoked item.
|
|
46
|
+
this.database.prepare(`UPDATE notification_outbox SET attempts = MAX(0, attempts - 1), next_at = ?
|
|
47
|
+
WHERE notification_id = ? AND attempts = ?`)
|
|
48
|
+
.run(this.now() + CLAIM_MS, entry.notification.id, entry.attempts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
finish(entry, status) {
|
|
52
|
+
if (['delivered', 'invalid', 'discarded'].includes(status) || entry.attempts >= MAX_ATTEMPTS) {
|
|
53
|
+
this.database.prepare('DELETE FROM notification_outbox WHERE notification_id = ?').run(entry.notification.id);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const backoff = Math.min(600_000, 30_000 * 2 ** (entry.attempts - 1));
|
|
57
|
+
this.database.prepare('UPDATE notification_outbox SET next_at = ? WHERE notification_id = ?')
|
|
58
|
+
.run(this.now() + backoff, entry.notification.id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -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
|
+
`;
|