@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,48 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { createPrivateKey } from 'node:crypto';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MAX_CREDENTIAL_BYTES = 32 * 1024;
|
|
6
|
+
|
|
7
|
+
function isPrivateCredentialFile(stat) {
|
|
8
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_CREDENTIAL_BYTES) return false;
|
|
9
|
+
if ((stat.mode & 0o7777) !== 0o600) return false;
|
|
10
|
+
return typeof process.getuid === 'function' && stat.uid === process.getuid();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseServiceAccount(json) {
|
|
14
|
+
if (!json || json.type !== 'service_account') throw new Error();
|
|
15
|
+
if (typeof json.project_id !== 'string' || !/^[a-z][a-z0-9-]{4,61}[a-z0-9]$/.test(json.project_id)) throw new Error();
|
|
16
|
+
if (typeof json.client_email !== 'string' || json.client_email.length > 254 ||
|
|
17
|
+
!/^[A-Za-z0-9._-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com$/.test(json.client_email)) throw new Error();
|
|
18
|
+
if (typeof json.private_key !== 'string' || json.private_key.length > 16 * 1024) throw new Error();
|
|
19
|
+
const key = createPrivateKey(json.private_key);
|
|
20
|
+
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails?.modulusLength < 2048) throw new Error();
|
|
21
|
+
return { projectId: json.project_id, clientEmail: json.client_email, key };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Never use token_uri or any URL from a credential file as a network destination. */
|
|
25
|
+
export function loadFcmServiceAccount(path) {
|
|
26
|
+
let descriptor;
|
|
27
|
+
try {
|
|
28
|
+
if (!isAbsolute(path)) throw new Error();
|
|
29
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
30
|
+
const before = fstatSync(descriptor);
|
|
31
|
+
if (!isPrivateCredentialFile(before)) throw new Error();
|
|
32
|
+
const buffer = Buffer.alloc(MAX_CREDENTIAL_BYTES + 1);
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
while (bytes < buffer.length) {
|
|
35
|
+
const count = readSync(descriptor, buffer, bytes, buffer.length - bytes, null);
|
|
36
|
+
if (count === 0) break;
|
|
37
|
+
bytes += count;
|
|
38
|
+
}
|
|
39
|
+
if (bytes > MAX_CREDENTIAL_BYTES) throw new Error();
|
|
40
|
+
const after = fstatSync(descriptor);
|
|
41
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || bytes !== before.size) throw new Error();
|
|
42
|
+
return parseServiceAccount(JSON.parse(buffer.toString('utf8', 0, bytes)));
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error('FCM credentials require a valid private service-account file owned by this user with mode 0600');
|
|
45
|
+
} finally {
|
|
46
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { sign } from 'node:crypto';
|
|
2
|
+
import { loadFcmServiceAccount } from './notification-delivery-credentials.mjs';
|
|
3
|
+
import { postNotificationRequest } from './notification-delivery-http.mjs';
|
|
4
|
+
import { isFcmDeviceToken, notificationEnvelope } from './notification-delivery-payload.mjs';
|
|
5
|
+
|
|
6
|
+
const OAUTH_URL = 'https://oauth2.googleapis.com/token';
|
|
7
|
+
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
|
|
8
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
9
|
+
|
|
10
|
+
function createAssertion(account, now) {
|
|
11
|
+
const iat = Math.floor(now / 1000);
|
|
12
|
+
const body = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({
|
|
13
|
+
iss: account.clientEmail, scope: FCM_SCOPE, aud: OAUTH_URL, iat, exp: iat + 3600,
|
|
14
|
+
})}`;
|
|
15
|
+
return `${body}.${sign('RSA-SHA256', Buffer.from(body), account.key).toString('base64url')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hasInvalidDeviceResponse(response) {
|
|
19
|
+
if (response.status !== 404) return false;
|
|
20
|
+
const details = response.json?.error?.details;
|
|
21
|
+
if (!Array.isArray(details)) return false;
|
|
22
|
+
return details.some((detail) => detail?.['@type'] === 'type.googleapis.com/google.firebase.fcm.v1.FcmError' &&
|
|
23
|
+
detail.errorCode === 'UNREGISTERED');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasExpiredEnvelope(envelope, now) {
|
|
27
|
+
const expiry = Number(envelope?.apns?.headers?.['apns-expiration']);
|
|
28
|
+
return Number.isSafeInteger(expiry) && expiry > 0 && expiry <= Math.floor(now / 1000);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function envelopeForDispatch(envelope, now) {
|
|
32
|
+
if (!envelope.android) return envelope;
|
|
33
|
+
const expiry = Number(envelope.apns?.headers?.['apns-expiration']);
|
|
34
|
+
if (!Number.isSafeInteger(expiry) || expiry <= 0) return envelope;
|
|
35
|
+
return { ...envelope, android: { ...envelope.android,
|
|
36
|
+
ttl: `${Math.max(0, expiry - Math.floor(now / 1000))}s` } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Trusted gateway/socket code supplies a bounded, projected envelope. */
|
|
40
|
+
export function createFcmProvider(config, dependencies = {}) {
|
|
41
|
+
const account = loadFcmServiceAccount(config.serviceAccountFile);
|
|
42
|
+
const now = dependencies.now ?? Date.now;
|
|
43
|
+
const endpoint = `https://fcm.googleapis.com/v1/projects/${account.projectId}/messages:send`;
|
|
44
|
+
let cachedToken;
|
|
45
|
+
let tokenExpiresAt = 0;
|
|
46
|
+
|
|
47
|
+
async function authorization(signal) {
|
|
48
|
+
if (cachedToken && tokenExpiresAt > now() + 60_000) return cachedToken;
|
|
49
|
+
const response = await postNotificationRequest(OAUTH_URL, {
|
|
50
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
51
|
+
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
52
|
+
assertion: createAssertion(account, now()) }).toString(),
|
|
53
|
+
}, signal, dependencies);
|
|
54
|
+
const token = response.json?.access_token;
|
|
55
|
+
const duration = response.json?.expires_in;
|
|
56
|
+
if (!response.ok || typeof token !== 'string' || !/^[\x21-\x7e]{1,8192}$/.test(token) ||
|
|
57
|
+
!Number.isSafeInteger(duration) || duration < 1 || duration > 3600) throw new Error();
|
|
58
|
+
cachedToken = token;
|
|
59
|
+
tokenExpiresAt = now() + duration * 1000;
|
|
60
|
+
return token;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
async sendEnvelope(deviceToken, envelope, signal) {
|
|
65
|
+
if (!isFcmDeviceToken(deviceToken)) return { status: 'invalid' };
|
|
66
|
+
try {
|
|
67
|
+
if (!envelope || typeof envelope !== 'object' || Buffer.byteLength(JSON.stringify(envelope), 'utf8') > 4096) {
|
|
68
|
+
return { status: 'discarded' };
|
|
69
|
+
}
|
|
70
|
+
if (hasExpiredEnvelope(envelope, now())) return { status: 'discarded' };
|
|
71
|
+
const token = await authorization(signal);
|
|
72
|
+
if (signal?.aborted) return { status: 'retry' };
|
|
73
|
+
if (hasExpiredEnvelope(envelope, now())) return { status: 'discarded' };
|
|
74
|
+
const response = await postNotificationRequest(endpoint, {
|
|
75
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ message: { ...envelopeForDispatch(envelope, now()), token: deviceToken } }),
|
|
77
|
+
}, signal, dependencies);
|
|
78
|
+
if (response.status === 401) { cachedToken = undefined; tokenExpiresAt = 0; }
|
|
79
|
+
if (hasInvalidDeviceResponse(response)) return { status: 'invalid' };
|
|
80
|
+
if (response.ok && typeof response.json?.name === 'string' && response.json.name.startsWith('projects/')) {
|
|
81
|
+
return { status: 'delivered' };
|
|
82
|
+
}
|
|
83
|
+
return { status: 'retry' };
|
|
84
|
+
} catch { return { status: 'retry' }; }
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createFcmNotificationDelivery(config, dependencies = {}) {
|
|
90
|
+
const provider = createFcmProvider(config, dependencies);
|
|
91
|
+
const now = dependencies.now ?? Date.now;
|
|
92
|
+
return Object.freeze({ mode: 'fcm', configured: true,
|
|
93
|
+
async send({ registration, notification, badge }, signal) {
|
|
94
|
+
if (registration?.provider !== 'fcm') return { status: 'invalid' };
|
|
95
|
+
const envelope = notificationEnvelope(notification, badge, now());
|
|
96
|
+
if (!envelope) return { status: 'discarded' };
|
|
97
|
+
return provider.sendEnvelope(registration.token, envelope, signal);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { postNotificationRequest } from './notification-delivery-http.mjs';
|
|
2
|
+
import { notificationEnvelope } from './notification-delivery-payload.mjs';
|
|
3
|
+
import { validatedNotificationPreview } from './notification-preview-crypto.mjs';
|
|
4
|
+
|
|
5
|
+
function hasGatewayRegistration(registration) {
|
|
6
|
+
if (registration?.provider !== 'gateway') return false;
|
|
7
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(registration.endpointId ?? '')) return false;
|
|
8
|
+
return /^[A-Za-z0-9_-]{32,512}$/.test(registration.sendToken ?? '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function hasInvalidGatewayRecipient(response) {
|
|
12
|
+
if (response.status === 401 && response.json?.error === 'unauthorized') return true;
|
|
13
|
+
return response.status === 410 && response.json?.error === 'endpoint_unavailable';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createGatewayNotificationDelivery(config, dependencies = {}) {
|
|
17
|
+
const now = dependencies.now ?? Date.now;
|
|
18
|
+
const endpoint = `${config.gatewayOrigin}/v1/deliver`;
|
|
19
|
+
return Object.freeze({ mode: 'gateway', configured: true,
|
|
20
|
+
async send({ registration, notification, badge }, signal) {
|
|
21
|
+
if (!hasGatewayRegistration(registration)) return { status: 'invalid' };
|
|
22
|
+
if (!notificationEnvelope(notification, badge, now())) return { status: 'discarded' };
|
|
23
|
+
const preview = validatedNotificationPreview(notification.preview, notification, now());
|
|
24
|
+
try {
|
|
25
|
+
const response = await postNotificationRequest(endpoint, {
|
|
26
|
+
headers: { Authorization: `Bearer ${registration.sendToken}`, 'Content-Type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ endpointId: registration.endpointId, notificationId: notification.id,
|
|
28
|
+
kind: notification.kind, expiresAt: notification.expiresAt, badge, ...(preview ? { preview } : {}) }),
|
|
29
|
+
}, signal, dependencies);
|
|
30
|
+
if (hasInvalidGatewayRecipient(response)) return { status: 'invalid' };
|
|
31
|
+
if (response.ok && response.json?.ok === true) return { status: 'delivered' };
|
|
32
|
+
return { status: 'retry' };
|
|
33
|
+
} catch { return { status: 'retry' }; }
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const MAX_RESPONSE_BYTES = 16 * 1024;
|
|
2
|
+
|
|
3
|
+
function abortFailure() { return new Error('Notification delivery interrupted'); }
|
|
4
|
+
|
|
5
|
+
function waitForResponse(promise, signal) {
|
|
6
|
+
if (signal.aborted) return Promise.reject(abortFailure());
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const abort = () => reject(abortFailure());
|
|
9
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
10
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function readBoundedJson(response, signal) {
|
|
15
|
+
const declared = Number(response.headers.get('content-length'));
|
|
16
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) throw new Error();
|
|
17
|
+
if (!response.body) return null;
|
|
18
|
+
const reader = response.body.getReader();
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let size = 0;
|
|
21
|
+
try {
|
|
22
|
+
while (true) {
|
|
23
|
+
const { value, done } = await waitForResponse(reader.read(), signal);
|
|
24
|
+
if (done) break;
|
|
25
|
+
size += value.byteLength;
|
|
26
|
+
if (size > MAX_RESPONSE_BYTES) throw new Error();
|
|
27
|
+
chunks.push(Buffer.from(value));
|
|
28
|
+
}
|
|
29
|
+
if (!size) return null;
|
|
30
|
+
return JSON.parse(Buffer.concat(chunks, size).toString('utf8'));
|
|
31
|
+
} finally {
|
|
32
|
+
void reader.cancel().catch(() => {});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Destination is constructed by an adapter, never from a mobile registration. */
|
|
37
|
+
export async function postNotificationRequest(url, init, signal,
|
|
38
|
+
{ fetchImpl = globalThis.fetch, timeoutMs = 8_000 } = {}) {
|
|
39
|
+
const timeout = new AbortController();
|
|
40
|
+
const combined = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
|
|
41
|
+
const deadline = setTimeout(() => timeout.abort(), Math.min(Math.max(timeoutMs, 1), 10_000));
|
|
42
|
+
try {
|
|
43
|
+
if (combined.aborted) throw abortFailure();
|
|
44
|
+
const response = await waitForResponse(fetchImpl(url, { ...init,
|
|
45
|
+
method: 'POST', redirect: 'error', signal: combined }), combined);
|
|
46
|
+
if (response.status >= 300 && response.status < 400) throw new Error();
|
|
47
|
+
const json = await readBoundedJson(response, combined);
|
|
48
|
+
return { status: response.status, ok: response.ok, json };
|
|
49
|
+
} catch {
|
|
50
|
+
timeout.abort();
|
|
51
|
+
throw new Error('Notification provider unavailable');
|
|
52
|
+
} finally { clearTimeout(deadline); }
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|