@abot-ai/socket 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/bin/abot-socket.mjs +15 -0
- package/config.example.json +14 -0
- package/docs/APP-REVIEW.md +184 -0
- package/docs/DEPLOYMENT.md +124 -0
- package/docs/INSTALLATION.md +177 -0
- package/docs/MIGRATION.md +84 -0
- package/docs/PROTOCOL.md +371 -0
- package/docs/SECURITY.md +116 -0
- package/docs/SELF-HOSTING.md +161 -0
- package/package.json +65 -4
- package/src/auth/auth-database.mjs +105 -0
- package/src/auth/auth-limits.mjs +17 -0
- package/src/auth/auth-pairing.mjs +95 -0
- package/src/auth/auth-sessions.mjs +119 -0
- package/src/auth/auth-storage-path.mjs +70 -0
- package/src/auth/auth.mjs +45 -0
- package/src/auth/runtime-credentials.mjs +39 -0
- package/src/cli/cli-arguments.mjs +23 -0
- package/src/cli/cli-entry.mjs +84 -0
- package/src/cli/cli-environment.mjs +31 -0
- package/src/cli/cli-help.mjs +34 -0
- package/src/cli/cli-output.mjs +35 -0
- package/src/cli/cli.mjs +58 -0
- package/src/cli/client-capabilities.mjs +14 -0
- package/src/cli/doctor.mjs +106 -0
- package/src/config/config-validation.mjs +41 -0
- package/src/config/config.mjs +57 -0
- package/src/deployment/deployment-command.mjs +42 -0
- package/src/deployment/deployment-input.mjs +76 -0
- package/src/deployment/deployment-plan.mjs +62 -0
- package/src/deployment/deployment-templates.mjs +109 -0
- package/src/main.mjs +26 -0
- package/src/notifications/notification-delivery-credentials.mjs +48 -0
- package/src/notifications/notification-delivery-fcm.mjs +100 -0
- package/src/notifications/notification-delivery-gateway.mjs +36 -0
- package/src/notifications/notification-delivery-http.mjs +53 -0
- package/src/notifications/notification-delivery-payload.mjs +45 -0
- package/src/notifications/notification-delivery.mjs +43 -0
- package/src/notifications/notification-gateway-command.mjs +48 -0
- package/src/notifications/notification-gateway-store.mjs +159 -0
- package/src/notifications/notification-gateway.mjs +181 -0
- package/src/notifications/notification-http.mjs +39 -0
- package/src/notifications/notification-input.mjs +82 -0
- package/src/notifications/notification-outbox.mjs +60 -0
- package/src/notifications/notification-preview-crypto.mjs +42 -0
- package/src/notifications/notification-preview-schema.mjs +7 -0
- package/src/notifications/notification-preview-store.mjs +63 -0
- package/src/notifications/notification-preview-text.mjs +20 -0
- package/src/notifications/notification-projection.mjs +20 -0
- package/src/notifications/notification-schema.mjs +22 -0
- package/src/notifications/notification-service.mjs +147 -0
- package/src/notifications/notification-store.mjs +132 -0
- package/src/pairing/pairing-qr-payload.mjs +32 -0
- package/src/pairing/pairing-qr.mjs +42 -0
- package/src/protocol/approval-protocol.mjs +39 -0
- package/src/protocol/protocol-validation.mjs +137 -0
- package/src/protocol/protocol.mjs +120 -0
- package/src/protocol/spark-chat-protocol.mjs +51 -0
- package/src/relay/relay-approvals.mjs +86 -0
- package/src/relay/relay-client-admission.mjs +40 -0
- package/src/relay/relay-correlations.mjs +101 -0
- package/src/relay/relay-model-catalog.mjs +69 -0
- package/src/relay/relay-request-adoption.mjs +97 -0
- package/src/relay/relay-requests.mjs +211 -0
- package/src/relay/relay-runtime-registration.mjs +49 -0
- package/src/relay/relay-session-mutations.mjs +122 -0
- package/src/relay/relay.mjs +222 -0
- package/src/relay/request-ownership-schema.mjs +18 -0
- package/src/relay/request-ownership-store.mjs +115 -0
- package/src/review/review-demo-isolation.mjs +132 -0
- package/src/review/review-demo-setup.mjs +97 -0
- package/src/review/review-portal-access.mjs +64 -0
- package/src/review/review-portal-form.mjs +24 -0
- package/src/review/review-portal-page.mjs +68 -0
- package/src/review/review-portal-server.mjs +105 -0
- package/src/setup/setup-files.mjs +37 -0
- package/src/setup/setup-questions.mjs +62 -0
- package/src/setup/setup-wizard.mjs +50 -0
- package/src/setup/setup.mjs +42 -0
- package/src/transport/http-api.mjs +32 -0
- package/src/transport/server.mjs +175 -0
- package/src/transport/transport-limits.mjs +85 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { validatedNotificationPreview } from './notification-preview-crypto.mjs';
|
|
2
|
+
|
|
3
|
+
const TEXT = Object.freeze({
|
|
4
|
+
completed: 'Your response is ready.',
|
|
5
|
+
failed: 'Your request needs attention.',
|
|
6
|
+
approval_required: 'A request is waiting for your approval.',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function notificationEnvelope(notification, badge, now) {
|
|
10
|
+
if (!notification || typeof notification.id !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(notification.id)) return null;
|
|
11
|
+
if (typeof notification.kind !== 'string' || !Object.hasOwn(TEXT, notification.kind)) return null;
|
|
12
|
+
if (!Number.isSafeInteger(notification.expiresAt) || notification.expiresAt <= now) return null;
|
|
13
|
+
if (!Number.isSafeInteger(badge) || badge < 0 || badge > 100_000) return null;
|
|
14
|
+
// The notification is only a hint. Destinations/content are loaded from the
|
|
15
|
+
// current authenticated server after the app opens, never trusted from push.
|
|
16
|
+
const envelope = {
|
|
17
|
+
notification: { title: 'ABot', body: TEXT[notification.kind] },
|
|
18
|
+
data: { notificationId: notification.id },
|
|
19
|
+
android: {
|
|
20
|
+
priority: 'HIGH',
|
|
21
|
+
ttl: `${Math.max(0, Math.floor(Math.min(notification.expiresAt - now, 7 * 86_400_000) / 1000))}s`,
|
|
22
|
+
notification: { channel_id: 'abot_updates', tag: notification.id,
|
|
23
|
+
notification_count: badge, default_sound: true },
|
|
24
|
+
},
|
|
25
|
+
apns: {
|
|
26
|
+
headers: { 'apns-push-type': 'alert', 'apns-priority': '10',
|
|
27
|
+
'apns-expiration': String(Math.floor(Math.min(notification.expiresAt, now + 7 * 86_400_000) / 1000)) },
|
|
28
|
+
payload: { aps: { badge, sound: 'default' } },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const preview = validatedNotificationPreview(notification.preview, notification, now);
|
|
32
|
+
if (preview) {
|
|
33
|
+
delete envelope.notification;
|
|
34
|
+
delete envelope.android.notification;
|
|
35
|
+
envelope.data = { notificationId: notification.id, abotPreview: JSON.stringify(preview),
|
|
36
|
+
abotKind: 'completed', abotExpiresAt: String(notification.expiresAt), abotBadge: String(badge) };
|
|
37
|
+
envelope.apns.payload.aps.alert = { title: 'ABot', body: TEXT.completed };
|
|
38
|
+
envelope.apns.payload.aps['mutable-content'] = 1;
|
|
39
|
+
}
|
|
40
|
+
return Buffer.byteLength(JSON.stringify(envelope), 'utf8') <= 4096 ? envelope : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isFcmDeviceToken(token) {
|
|
44
|
+
return typeof token === 'string' && /^[A-Za-z0-9_:.-]{16,4096}$/.test(token);
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createFcmNotificationDelivery } from './notification-delivery-fcm.mjs';
|
|
2
|
+
import { createGatewayNotificationDelivery } from './notification-delivery-gateway.mjs';
|
|
3
|
+
|
|
4
|
+
function hasOnlyDeliveryKeys(config, keys) {
|
|
5
|
+
return Object.keys(config).every((key) => keys.includes(key));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isCanonicalGatewayOrigin(value) {
|
|
9
|
+
if (typeof value !== 'string' || value.length > 2048) return false;
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
return url.protocol === 'https:' && url.origin === value;
|
|
13
|
+
} catch { return false; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Pure config validation: credentials are opened only when constructing a sender. */
|
|
17
|
+
export function validateNotificationDeliveryConfiguration(config = { type: 'disabled' }) {
|
|
18
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
19
|
+
throw new Error('Invalid notification delivery configuration');
|
|
20
|
+
}
|
|
21
|
+
if (config.type === 'disabled' && hasOnlyDeliveryKeys(config, ['type'])) {
|
|
22
|
+
return { type: 'disabled' };
|
|
23
|
+
}
|
|
24
|
+
if (config.type === 'fcm' && hasOnlyDeliveryKeys(config, ['type', 'serviceAccountFile'])) {
|
|
25
|
+
const path = config.serviceAccountFile;
|
|
26
|
+
if (typeof path === 'string' && path.trim() && path.length <= 4096 && !/[\x00-\x1f\x7f]/.test(path)) {
|
|
27
|
+
return { type: 'fcm', serviceAccountFile: path };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (config.type === 'gateway' && hasOnlyDeliveryKeys(config, ['type', 'gatewayOrigin']) &&
|
|
31
|
+
isCanonicalGatewayOrigin(config.gatewayOrigin)) {
|
|
32
|
+
return { type: 'gateway', gatewayOrigin: config.gatewayOrigin };
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Invalid notification delivery configuration');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createNotificationDelivery(config, dependencies = {}) {
|
|
38
|
+
const validated = validateNotificationDeliveryConfiguration(config);
|
|
39
|
+
if (validated.type === 'fcm') return createFcmNotificationDelivery(validated, dependencies);
|
|
40
|
+
if (validated.type === 'gateway') return createGatewayNotificationDelivery(validated, dependencies);
|
|
41
|
+
return Object.freeze({ mode: 'disabled', configured: false,
|
|
42
|
+
send: async () => ({ status: 'retry' }) });
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { readPrivateConfiguration } from '../config/config.mjs';
|
|
3
|
+
import { createFcmProvider } from './notification-delivery-fcm.mjs';
|
|
4
|
+
import { createNotificationGateway } from './notification-gateway.mjs';
|
|
5
|
+
|
|
6
|
+
/** The publisher runs this separate sender with its own identity and state. */
|
|
7
|
+
export function loadNotificationGatewayConfiguration(path) {
|
|
8
|
+
if (typeof path !== 'string' || !path) throw new Error('Provide a private gateway configuration');
|
|
9
|
+
const config = readPrivateConfiguration(path);
|
|
10
|
+
if (!config || Array.isArray(config) || typeof config !== 'object' ||
|
|
11
|
+
Object.keys(config).some(key => !['publicOrigin', 'port', 'stateDirectory', 'serviceAccountFile'].includes(key))) {
|
|
12
|
+
throw new Error('Invalid push gateway configuration');
|
|
13
|
+
}
|
|
14
|
+
let origin;
|
|
15
|
+
try { origin = new URL(config.publicOrigin); } catch { /* Validated below. */ }
|
|
16
|
+
if (!origin || origin.protocol !== 'https:' || origin.origin !== config.publicOrigin) {
|
|
17
|
+
throw new Error('Gateway requires a canonical HTTPS origin');
|
|
18
|
+
}
|
|
19
|
+
const port = config.port ?? 8790;
|
|
20
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Invalid gateway listener port');
|
|
21
|
+
const directory = dirname(resolve(path));
|
|
22
|
+
const privatePath = (value) => {
|
|
23
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value)) {
|
|
24
|
+
throw new Error('Gateway requires private state and credential paths');
|
|
25
|
+
}
|
|
26
|
+
return resolve(directory, value);
|
|
27
|
+
};
|
|
28
|
+
return { publicOrigin: config.publicOrigin, port,
|
|
29
|
+
stateDirectory: privatePath(config.stateDirectory),
|
|
30
|
+
serviceAccountFile: privatePath(config.serviceAccountFile) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function startConfiguredNotificationGateway(env, dependencies = {}) {
|
|
34
|
+
const path = env.ABOT_SOCKET_CONFIG || env.SOCKET_V2_CONFIG;
|
|
35
|
+
const config = loadNotificationGatewayConfiguration(path);
|
|
36
|
+
const provider = (dependencies.createProvider ?? createFcmProvider)({ serviceAccountFile: config.serviceAccountFile });
|
|
37
|
+
const gateway = (dependencies.createGateway ?? createNotificationGateway)({
|
|
38
|
+
stateDirectory: config.stateDirectory, publicOrigin: config.publicOrigin,
|
|
39
|
+
sendProviderEnvelope: (token, envelope, signal) => provider.sendEnvelope(token, envelope, signal),
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
await gateway.listen(config.port);
|
|
43
|
+
return gateway;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await gateway.close();
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { assertSameDatabaseFile, prepareAuthenticationDatabase } from '../auth/auth-storage-path.mjs';
|
|
4
|
+
|
|
5
|
+
const APPLICATION_ID = 0x41424757;
|
|
6
|
+
const SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
7
|
+
const secret = () => randomBytes(32).toString('base64url');
|
|
8
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
const failure = (code, status = 400) => Object.assign(new Error(code), { code, status });
|
|
10
|
+
|
|
11
|
+
function matchesCredential(value, expected) {
|
|
12
|
+
if (typeof value !== 'string' || !SECRET.test(value) || !expected) return false;
|
|
13
|
+
return timingSafeEqual(Buffer.from(digest(value), 'hex'), Buffer.from(expected, 'hex'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Isolated gateway state. Never point this at a Socket authentication directory. */
|
|
17
|
+
export class NotificationGatewayStore {
|
|
18
|
+
constructor({ stateDirectory, now = Date.now }) {
|
|
19
|
+
this.now = now;
|
|
20
|
+
const prepared = prepareAuthenticationDatabase(stateDirectory);
|
|
21
|
+
this.db = new DatabaseSync(prepared.path);
|
|
22
|
+
try {
|
|
23
|
+
assertSameDatabaseFile(stateDirectory, prepared);
|
|
24
|
+
this.db.exec('PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON; PRAGMA temp_store=MEMORY; PRAGMA secure_delete=ON;');
|
|
25
|
+
if (this.db.prepare('PRAGMA journal_mode').get().journal_mode !== 'delete') throw new Error();
|
|
26
|
+
this.transaction(() => this.initialize());
|
|
27
|
+
this.db.exec('PRAGMA synchronous=FULL;');
|
|
28
|
+
assertSameDatabaseFile(stateDirectory, prepared);
|
|
29
|
+
} catch {
|
|
30
|
+
this.db.close();
|
|
31
|
+
throw new Error('Gateway requires its own private state directory with a recognized database');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
initialize() {
|
|
36
|
+
const id = this.db.prepare('PRAGMA application_id').get().application_id;
|
|
37
|
+
const version = this.db.prepare('PRAGMA user_version').get().user_version;
|
|
38
|
+
if (id === APPLICATION_ID && version === 1) return;
|
|
39
|
+
const objects = this.db.prepare("SELECT name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").all();
|
|
40
|
+
if (id || version || objects.length) throw new Error();
|
|
41
|
+
this.db.exec(`
|
|
42
|
+
CREATE TABLE gateway_challenges (
|
|
43
|
+
id TEXT PRIMARY KEY, token TEXT NOT NULL, token_hash TEXT NOT NULL,
|
|
44
|
+
proof_hash TEXT NOT NULL, expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL
|
|
45
|
+
) STRICT;
|
|
46
|
+
CREATE TABLE gateway_token_attempts (token_hash TEXT PRIMARY KEY, attempted_at INTEGER NOT NULL) STRICT;
|
|
47
|
+
CREATE TABLE gateway_endpoints (
|
|
48
|
+
id TEXT PRIMARY KEY, token TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE,
|
|
49
|
+
send_hash TEXT NOT NULL, manage_hash TEXT NOT NULL, expires_at INTEGER NOT NULL
|
|
50
|
+
) STRICT;
|
|
51
|
+
CREATE TABLE gateway_deliveries (
|
|
52
|
+
endpoint_id TEXT NOT NULL REFERENCES gateway_endpoints(id) ON DELETE CASCADE,
|
|
53
|
+
notification_id TEXT NOT NULL, done INTEGER NOT NULL, lease_until INTEGER NOT NULL,
|
|
54
|
+
expires_at INTEGER NOT NULL, PRIMARY KEY(endpoint_id, notification_id)
|
|
55
|
+
) STRICT;
|
|
56
|
+
PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=1;
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
transaction(operation) {
|
|
61
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
62
|
+
try { const result = operation(); this.db.exec('COMMIT'); return result; }
|
|
63
|
+
catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
prune() {
|
|
67
|
+
const now = this.now();
|
|
68
|
+
this.db.prepare('DELETE FROM gateway_challenges WHERE expires_at<=?').run(now);
|
|
69
|
+
this.db.prepare('DELETE FROM gateway_token_attempts WHERE attempted_at<=?').run(now - 120_000);
|
|
70
|
+
this.db.prepare('DELETE FROM gateway_endpoints WHERE expires_at<=?').run(now);
|
|
71
|
+
this.db.prepare('DELETE FROM gateway_deliveries WHERE expires_at<=?').run(now);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
createChallenge(token) {
|
|
75
|
+
return this.transaction(() => {
|
|
76
|
+
this.prune();
|
|
77
|
+
const tokenHash = digest(token);
|
|
78
|
+
if (this.db.prepare('SELECT 1 FROM gateway_token_attempts WHERE token_hash=?').get(tokenHash)) {
|
|
79
|
+
throw failure('rate_limited', 429);
|
|
80
|
+
}
|
|
81
|
+
const count = this.db.prepare('SELECT count(*) AS total FROM gateway_challenges').get().total;
|
|
82
|
+
if (count >= 128) throw failure('capacity', 503);
|
|
83
|
+
const challengeId = secret(); const proof = secret(); const expiresAt = this.now() + 120_000;
|
|
84
|
+
this.db.prepare('INSERT INTO gateway_token_attempts VALUES (?, ?)').run(tokenHash, this.now());
|
|
85
|
+
this.db.prepare('INSERT INTO gateway_challenges VALUES (?, ?, ?, ?, ?, 0)')
|
|
86
|
+
.run(challengeId, token, tokenHash, digest(proof), expiresAt);
|
|
87
|
+
return { challengeId, proof, expiresAt };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
removeChallenge(id) { this.db.prepare('DELETE FROM gateway_challenges WHERE id=?').run(id); }
|
|
92
|
+
|
|
93
|
+
confirm(challengeId, proof) {
|
|
94
|
+
return this.transaction(() => {
|
|
95
|
+
this.prune();
|
|
96
|
+
const challenge = this.db.prepare('SELECT * FROM gateway_challenges WHERE id=?').get(challengeId);
|
|
97
|
+
if (!challenge) return null;
|
|
98
|
+
if (!matchesCredential(proof, challenge.proof_hash)) {
|
|
99
|
+
if (challenge.attempts >= 4) this.removeChallenge(challengeId);
|
|
100
|
+
else this.db.prepare('UPDATE gateway_challenges SET attempts=attempts+1 WHERE id=?').run(challengeId);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const existing = this.db.prepare('SELECT id FROM gateway_endpoints WHERE token_hash=?').get(challenge.token_hash);
|
|
104
|
+
const count = this.db.prepare('SELECT count(*) AS total FROM gateway_endpoints').get().total;
|
|
105
|
+
if (!existing && count >= 1024) throw failure('capacity', 503);
|
|
106
|
+
const endpointId = secret(); const sendToken = secret(); const manageToken = secret();
|
|
107
|
+
const expiresAt = this.now() + 30 * 86_400_000;
|
|
108
|
+
// Only a successfully proven replacement rotates an existing target grant.
|
|
109
|
+
if (existing) this.db.prepare('DELETE FROM gateway_endpoints WHERE id=?').run(existing.id);
|
|
110
|
+
this.db.prepare('INSERT INTO gateway_endpoints VALUES (?, ?, ?, ?, ?, ?)')
|
|
111
|
+
.run(endpointId, challenge.token, challenge.token_hash, digest(sendToken), digest(manageToken), expiresAt);
|
|
112
|
+
this.removeChallenge(challengeId);
|
|
113
|
+
return { endpointId, sendToken, manageToken, expiresAt };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
authorize(endpointId, credential, management = false) {
|
|
118
|
+
const endpoint = this.db.prepare('SELECT * FROM gateway_endpoints WHERE id=? AND expires_at>?')
|
|
119
|
+
.get(endpointId, this.now());
|
|
120
|
+
if (!endpoint || !matchesCredential(credential, management ? endpoint.manage_hash : endpoint.send_hash)) return null;
|
|
121
|
+
return { id: endpoint.id, token: endpoint.token, expiresAt: endpoint.expires_at };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
revoke(endpointId, manageToken) {
|
|
125
|
+
if (!this.authorize(endpointId, manageToken, true)) return false;
|
|
126
|
+
this.db.prepare('DELETE FROM gateway_endpoints WHERE id=?').run(endpointId);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
removeInvalidEndpoint(endpointId) { this.db.prepare('DELETE FROM gateway_endpoints WHERE id=?').run(endpointId); }
|
|
131
|
+
|
|
132
|
+
claimDelivery(endpointId, notificationId, expiresAt) {
|
|
133
|
+
return this.transaction(() => {
|
|
134
|
+
this.prune();
|
|
135
|
+
const prior = this.db.prepare('SELECT * FROM gateway_deliveries WHERE endpoint_id=? AND notification_id=?')
|
|
136
|
+
.get(endpointId, notificationId);
|
|
137
|
+
if (prior?.done) return 'done';
|
|
138
|
+
if (prior?.lease_until > this.now()) return 'busy';
|
|
139
|
+
const count = this.db.prepare('SELECT count(*) AS total FROM gateway_deliveries').get().total;
|
|
140
|
+
if (!prior && count >= 20_000) throw failure('capacity', 503);
|
|
141
|
+
this.db.prepare(`INSERT INTO gateway_deliveries VALUES (?, ?, 0, ?, ?)
|
|
142
|
+
ON CONFLICT(endpoint_id,notification_id) DO UPDATE SET lease_until=excluded.lease_until`)
|
|
143
|
+
.run(endpointId, notificationId, this.now() + 15_000, expiresAt);
|
|
144
|
+
return 'send';
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
finishDelivery(endpointId, notificationId, delivered) {
|
|
149
|
+
if (delivered) {
|
|
150
|
+
this.db.prepare('UPDATE gateway_deliveries SET done=1,lease_until=0 WHERE endpoint_id=? AND notification_id=?')
|
|
151
|
+
.run(endpointId, notificationId);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.db.prepare('DELETE FROM gateway_deliveries WHERE endpoint_id=? AND notification_id=? AND done=0')
|
|
155
|
+
.run(endpointId, notificationId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
close() { this.db.close(); }
|
|
159
|
+
}
|
|
@@ -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
|
+
}
|