@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,41 @@
|
|
|
1
|
+
import { validateRuntimeGrants } from '../auth/runtime-credentials.mjs';
|
|
2
|
+
import { validateNotificationDeliveryConfiguration } from '../notifications/notification-delivery.mjs';
|
|
3
|
+
|
|
4
|
+
function isCanonicalHttpsOrigin(value) {
|
|
5
|
+
if (typeof value !== 'string') return false;
|
|
6
|
+
try {
|
|
7
|
+
const origin = new URL(value);
|
|
8
|
+
return origin.protocol === 'https:' && origin.origin === value;
|
|
9
|
+
} catch { return false; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Pure preflight: no listener, credentials or state are created here. */
|
|
13
|
+
export function validateConfiguration(config) {
|
|
14
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
15
|
+
throw new Error('Invalid configuration');
|
|
16
|
+
}
|
|
17
|
+
if (!isCanonicalHttpsOrigin(config.publicOrigin)) {
|
|
18
|
+
throw new Error('Configure a canonical HTTPS publicOrigin');
|
|
19
|
+
}
|
|
20
|
+
const port = config.port ?? 8788;
|
|
21
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
22
|
+
throw new Error('Invalid listener port');
|
|
23
|
+
}
|
|
24
|
+
if (typeof config.stateDirectory !== 'string' || !config.stateDirectory.trim() ||
|
|
25
|
+
config.stateDirectory.length > 4096 || /[\x00-\x1f\x7f]/.test(config.stateDirectory)) {
|
|
26
|
+
throw new Error('Configure a private stateDirectory');
|
|
27
|
+
}
|
|
28
|
+
if (Object.hasOwn(config, 'mail') || Object.hasOwn(config, 'allowedEmail')) {
|
|
29
|
+
throw new Error('Email configuration is unsupported; create a pairing installation');
|
|
30
|
+
}
|
|
31
|
+
const notifications = config.notifications ?? {};
|
|
32
|
+
if (!notifications || typeof notifications !== 'object' || Array.isArray(notifications) ||
|
|
33
|
+
Object.keys(notifications).some(key => key !== 'delivery')) {
|
|
34
|
+
throw new Error('Invalid notification configuration');
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
publicOrigin: config.publicOrigin, port, stateDirectory: config.stateDirectory,
|
|
38
|
+
runtimeGrants: validateRuntimeGrants(config.runtimeGrants),
|
|
39
|
+
notifications: { delivery: validateNotificationDeliveryConfiguration(notifications.delivery) },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { openSync, closeSync, fstatSync, readSync, constants } from 'node:fs';
|
|
2
|
+
import { validateConfiguration } from './config-validation.mjs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MAX_CONFIG_BYTES = 32_768;
|
|
6
|
+
|
|
7
|
+
function selectEnvironmentValue(env, primary, legacy) {
|
|
8
|
+
const preferred = env[primary];
|
|
9
|
+
const fallback = env[legacy];
|
|
10
|
+
if (preferred && fallback && preferred !== fallback) {
|
|
11
|
+
throw new Error(`Conflicting ${primary} and ${legacy} settings`);
|
|
12
|
+
}
|
|
13
|
+
const value = preferred || fallback;
|
|
14
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
15
|
+
throw new Error(`Invalid ${primary} setting`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPrivateConfigurationFile(stat) {
|
|
21
|
+
if (!stat.isFile()) return false;
|
|
22
|
+
if (stat.size > MAX_CONFIG_BYTES) return false;
|
|
23
|
+
return (stat.mode & 0o077) === 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readPrivateConfiguration(path) {
|
|
27
|
+
let descriptor;
|
|
28
|
+
try {
|
|
29
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
30
|
+
if (!isPrivateConfigurationFile(fstatSync(descriptor))) throw new Error();
|
|
31
|
+
const buffer = Buffer.alloc(MAX_CONFIG_BYTES + 1);
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
while (bytes < buffer.length) {
|
|
34
|
+
const count = readSync(descriptor, buffer, bytes, buffer.length - bytes, null);
|
|
35
|
+
if (count === 0) break;
|
|
36
|
+
bytes += count;
|
|
37
|
+
}
|
|
38
|
+
if (bytes > MAX_CONFIG_BYTES) throw new Error();
|
|
39
|
+
return JSON.parse(buffer.toString('utf8', 0, bytes));
|
|
40
|
+
} catch {
|
|
41
|
+
// Filesystem and JSON errors may contain paths or configuration values.
|
|
42
|
+
throw new Error('Configuration must be a small private file with mode 0600 and valid JSON');
|
|
43
|
+
} finally {
|
|
44
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadConfiguration(env = process.env) {
|
|
49
|
+
const path = selectEnvironmentValue(env, 'ABOT_SOCKET_CONFIG', 'SOCKET_V2_CONFIG');
|
|
50
|
+
if (!path) throw new Error('Set ABOT_SOCKET_CONFIG to a private JSON configuration file');
|
|
51
|
+
const config = validateConfiguration(readPrivateConfiguration(path));
|
|
52
|
+
const delivery = config.notifications.delivery;
|
|
53
|
+
if (delivery.type === 'fcm') {
|
|
54
|
+
delivery.serviceAccountFile = resolve(dirname(resolve(path)), delivery.serviceAccountFile);
|
|
55
|
+
}
|
|
56
|
+
return { ...config, stateDirectory: resolve(dirname(resolve(path)), config.stateDirectory) };
|
|
57
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { loadConfiguration } from '../config/config.mjs';
|
|
6
|
+
import { writeDeploymentPlan } from './deployment-plan.mjs';
|
|
7
|
+
|
|
8
|
+
const flags = new Map([['--output', 'output'], ['--proxy', 'proxy'], ['--user', 'username'],
|
|
9
|
+
['--certificate', 'certificatePath'], ['--private-key', 'keyPath']]);
|
|
10
|
+
|
|
11
|
+
export function parseDeploymentArguments(args) {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
14
|
+
const name = flags.get(args[i]);
|
|
15
|
+
const value = args[i + 1];
|
|
16
|
+
if (!name || Object.hasOwn(result, name) || !value || value.startsWith('--')) {
|
|
17
|
+
throw new Error('Invalid deployment options');
|
|
18
|
+
}
|
|
19
|
+
result[name] = value;
|
|
20
|
+
}
|
|
21
|
+
if (!result.output || !result.proxy) throw new Error('Deployment requires output and proxy selection');
|
|
22
|
+
result.output = resolve(result.output);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function deploymentRuntimePaths() {
|
|
27
|
+
return { nodePath: realpathSync(process.execPath),
|
|
28
|
+
entryPath: fileURLToPath(new URL('../../bin/abot-socket.mjs', import.meta.url)) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function prepareDeployment(args, { env, out, platform = process.platform, username = userInfo().username }) {
|
|
32
|
+
const options = parseDeploymentArguments(args);
|
|
33
|
+
if (options.username && options.username !== username) {
|
|
34
|
+
throw new Error('Generate deployment files as the installation owner');
|
|
35
|
+
}
|
|
36
|
+
const config = loadConfiguration(env);
|
|
37
|
+
const output = writeDeploymentPlan({ ...options, ...deploymentRuntimePaths(), config,
|
|
38
|
+
configPath: resolve(env.ABOT_SOCKET_CONFIG || env.SOCKET_V2_CONFIG), platform, username });
|
|
39
|
+
out(`Deployment files prepared in ${JSON.stringify(options.output)}. Review the instructions before applying them.`);
|
|
40
|
+
out('No service, certificate, proxy configuration or package was installed.');
|
|
41
|
+
return output;
|
|
42
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { validateConfiguration } from '../config/config-validation.mjs';
|
|
3
|
+
|
|
4
|
+
function requireLinuxPlatform(platform) {
|
|
5
|
+
if (platform !== 'linux') throw new Error('Deployment plans target Linux with systemd only');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function requireServiceUsername(username) {
|
|
9
|
+
if (typeof username !== 'string' || !/^[a-z_][a-z0-9_-]{0,31}$/.test(username)) {
|
|
10
|
+
throw new Error('Choose an unprivileged Linux service username');
|
|
11
|
+
}
|
|
12
|
+
if (username === 'root') throw new Error('The relay service must not run as root');
|
|
13
|
+
return username;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requireDeploymentPath(value, label) {
|
|
17
|
+
// Spaces and percent signs are handled by each target syntax. Other shell,
|
|
18
|
+
// proxy-variable and control characters are deliberately unsupported.
|
|
19
|
+
if (typeof value !== 'string' || value.length > 4096 ||
|
|
20
|
+
!/^\/[A-Za-z0-9_./ %+-]+$/.test(value) || value === '/' ||
|
|
21
|
+
posix.normalize(value) !== value || value.endsWith('/')) {
|
|
22
|
+
throw new Error(`${label} requires an absolute normalized path containing letters, numbers, spaces, . _ - + or %`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function requireProxyHost(origin) {
|
|
28
|
+
const labels = origin.hostname.split('.');
|
|
29
|
+
if (origin.hostname.length > 253 || labels.length < 2 ||
|
|
30
|
+
labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
|
|
31
|
+
throw new Error('HTTPS deployment requires a canonical DNS hostname');
|
|
32
|
+
}
|
|
33
|
+
if (labels.every((label) => /^\d+$/.test(label))) {
|
|
34
|
+
throw new Error('HTTPS deployment requires a DNS hostname, not an IP address');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requireHttpsListenerPort(origin, relayPort) {
|
|
39
|
+
const port = origin.port ? Number(origin.port) : 443;
|
|
40
|
+
if (port < 1 || port > 65535) throw new Error('HTTPS requires a valid listener port');
|
|
41
|
+
if (port === relayPort) throw new Error('HTTPS and the private relay require different listener ports');
|
|
42
|
+
return port;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function requirePersistentServicePath(path) {
|
|
46
|
+
const temporaryRoots = ['/tmp', '/var/tmp', '/private/tmp', '/private/var/tmp'];
|
|
47
|
+
if (temporaryRoots.some((root) => path === root || path.startsWith(`${root}/`))) {
|
|
48
|
+
throw new Error('The system service requires persistent paths outside temporary storage');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateDeploymentInput(options) {
|
|
53
|
+
requireLinuxPlatform(options.platform ?? 'linux');
|
|
54
|
+
const config = validateConfiguration(options.config);
|
|
55
|
+
const origin = new URL(config.publicOrigin);
|
|
56
|
+
requireProxyHost(origin);
|
|
57
|
+
const proxy = options.proxy ?? 'nginx';
|
|
58
|
+
if (!['nginx', 'apache', 'none'].includes(proxy)) throw new Error('Choose nginx, apache or none for the HTTPS plan');
|
|
59
|
+
const configPath = requireDeploymentPath(options.configPath, 'Configuration');
|
|
60
|
+
const statePath = requireDeploymentPath(posix.resolve(posix.dirname(configPath), config.stateDirectory), 'State directory');
|
|
61
|
+
const nodePath = requireDeploymentPath(options.nodePath, 'Node executable');
|
|
62
|
+
const entryPath = requireDeploymentPath(options.entryPath, 'Installed command');
|
|
63
|
+
for (const path of [configPath, statePath, nodePath, entryPath]) requirePersistentServicePath(path);
|
|
64
|
+
if (entryPath.includes('/_npx/')) throw new Error('Install the package persistently before preparing a service');
|
|
65
|
+
return {
|
|
66
|
+
config, configPath, statePath, nodePath, entryPath, proxy,
|
|
67
|
+
username: requireServiceUsername(options.username),
|
|
68
|
+
certificatePath: proxy === 'none' ? undefined : requireDeploymentPath(options.certificatePath, 'TLS certificate'),
|
|
69
|
+
keyPath: proxy === 'none' ? undefined : requireDeploymentPath(options.keyPath, 'TLS private key'),
|
|
70
|
+
hostname: origin.hostname, authority: origin.host,
|
|
71
|
+
httpsPort: requireHttpsListenerPort(origin, config.port),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const systemdQuoted = (value) => `"${value.replaceAll('%', '%%')}"`;
|
|
76
|
+
export const proxyQuoted = (value) => `"${value}"`;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { validateDeploymentInput } from './deployment-input.mjs';
|
|
4
|
+
import { deploymentCommands, renderApacheProxy, renderNginxProxy, renderSystemdService } from './deployment-templates.mjs';
|
|
5
|
+
|
|
6
|
+
export function renderDeploymentPlan(options) {
|
|
7
|
+
const input = validateDeploymentInput(options);
|
|
8
|
+
const notes = [
|
|
9
|
+
'This is a reviewable Linux deployment plan. No service, proxy, certificate or system setting has been changed.',
|
|
10
|
+
'Run the listed commands manually from the generated directory, one at a time; stop if any command fails.',
|
|
11
|
+
'Inspect existing destination files before copying; these sample commands are for a new installation, not an automatic update.',
|
|
12
|
+
`The existing unprivileged service user must be ${input.username} and must own the private configuration and state directory.`,
|
|
13
|
+
'The service user needs execute/read access to the installed Node and package paths. Do not use an npx cache path.',
|
|
14
|
+
'State must already exist with mode 0700, and its database/configuration files must remain 0600. Persistent writes are limited to that directory.',
|
|
15
|
+
'TLS certificate/key files, DNS and firewall access must be configured separately. This plan never requests or copies a private key.',
|
|
16
|
+
'Use a local direct HTTPS proxy. CDN or additional proxy chains require a separately reviewed trusted-client-IP policy.',
|
|
17
|
+
'Keep request bodies, pairing details and Authorization headers out of proxy logs; review inherited logging settings.',
|
|
18
|
+
'Regenerate the plan if the Node executable, package installation or private configuration location changes.',
|
|
19
|
+
'After service and HTTPS checks pass, run the pairing command for fresh credentials; these files contain no pairing code.',
|
|
20
|
+
];
|
|
21
|
+
if (input.proxy === 'apache') notes.push(
|
|
22
|
+
'The Apache command paths target Debian/Ubuntu. Other distributions require their local module/site and service commands.',
|
|
23
|
+
'Apache requires version 2.4.47+ and an existing Listen directive for the HTTPS port; do not add a duplicate listener.',
|
|
24
|
+
);
|
|
25
|
+
if (input.proxy === 'none') notes.push(
|
|
26
|
+
'No proxy configuration was generated. Your existing HTTPS proxy must satisfy the documented Host, trusted-IP and WebSocket forwarding contract.',
|
|
27
|
+
);
|
|
28
|
+
const commands = deploymentCommands(input.proxy);
|
|
29
|
+
const files = {
|
|
30
|
+
'abot-socket.service': renderSystemdService(input),
|
|
31
|
+
'DEPLOYMENT.txt': [...notes, '', 'Review and run individually:', ...commands, ''].join('\n'),
|
|
32
|
+
};
|
|
33
|
+
if (input.proxy === 'nginx') files['nginx.conf'] = renderNginxProxy(input);
|
|
34
|
+
if (input.proxy === 'apache') files['apache.conf'] = renderApacheProxy(input);
|
|
35
|
+
return { files, notes, commands };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only writes a new operator-selected directory. Never executes plan commands. */
|
|
39
|
+
export function writeDeploymentPlan({ output, ...options }, io = fs) {
|
|
40
|
+
if (typeof output !== 'string' || !output.trim()) throw new Error('Choose a new deployment output directory');
|
|
41
|
+
const plan = renderDeploymentPlan(options);
|
|
42
|
+
const directory = resolve(output);
|
|
43
|
+
io.mkdirSync(directory, { mode: 0o700 });
|
|
44
|
+
const created = [];
|
|
45
|
+
try {
|
|
46
|
+
for (const [name, content] of Object.entries(plan.files)) {
|
|
47
|
+
const path = join(directory, name);
|
|
48
|
+
const descriptor = io.openSync(path, 'wx', 0o600);
|
|
49
|
+
created.push(path);
|
|
50
|
+
try { io.writeFileSync(descriptor, content); } finally { io.closeSync(descriptor); }
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const failures = [];
|
|
54
|
+
for (const path of created.reverse()) {
|
|
55
|
+
try { io.unlinkSync(path); } catch (failure) { failures.push(failure); }
|
|
56
|
+
}
|
|
57
|
+
try { io.rmdirSync(directory); } catch (failure) { failures.push(failure); }
|
|
58
|
+
if (failures.length) throw new AggregateError([error, ...failures], 'Deployment write failed and cleanup was incomplete');
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return { directory, ...plan };
|
|
62
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { proxyQuoted, systemdQuoted } from './deployment-input.mjs';
|
|
3
|
+
|
|
4
|
+
export function renderSystemdService(input) {
|
|
5
|
+
return `[Unit]
|
|
6
|
+
Description=ABot Socket relay
|
|
7
|
+
After=network.target
|
|
8
|
+
|
|
9
|
+
[Service]
|
|
10
|
+
Type=simple
|
|
11
|
+
User=${input.username}
|
|
12
|
+
WorkingDirectory=${systemdQuoted(posix.dirname(input.configPath))}
|
|
13
|
+
ExecStart=${systemdQuoted(input.nodePath)} ${systemdQuoted(input.entryPath)} start --config ${systemdQuoted(input.configPath)}
|
|
14
|
+
Restart=on-failure
|
|
15
|
+
RestartSec=5s
|
|
16
|
+
UMask=0077
|
|
17
|
+
NoNewPrivileges=true
|
|
18
|
+
PrivateTmp=true
|
|
19
|
+
ProtectSystem=strict
|
|
20
|
+
ReadWritePaths=${systemdQuoted(input.statePath)}
|
|
21
|
+
StandardOutput=journal
|
|
22
|
+
StandardError=journal
|
|
23
|
+
|
|
24
|
+
[Install]
|
|
25
|
+
WantedBy=multi-user.target
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function renderNginxProxy(input) {
|
|
30
|
+
return `# Include inside nginx's http context. Existing TLS files are required.
|
|
31
|
+
# Review global real_ip/CDN configuration before using this direct-proxy plan.
|
|
32
|
+
server {
|
|
33
|
+
listen ${input.httpsPort} ssl;
|
|
34
|
+
server_name ${input.hostname};
|
|
35
|
+
ssl_certificate ${proxyQuoted(input.certificatePath)};
|
|
36
|
+
ssl_certificate_key ${proxyQuoted(input.keyPath)};
|
|
37
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
38
|
+
client_max_body_size 4k;
|
|
39
|
+
client_body_timeout 15s;
|
|
40
|
+
access_log off;
|
|
41
|
+
|
|
42
|
+
location / {
|
|
43
|
+
proxy_pass http://127.0.0.1:${input.config.port};
|
|
44
|
+
proxy_http_version 1.1;
|
|
45
|
+
# Preserve the real authority, including any explicit port. The relay
|
|
46
|
+
# rejects an unknown Host; never replace it with a configured constant.
|
|
47
|
+
proxy_set_header Host $http_host;
|
|
48
|
+
proxy_set_header Origin $http_origin;
|
|
49
|
+
proxy_set_header Authorization $http_authorization;
|
|
50
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
51
|
+
proxy_set_header X-Socket-Client-IP $remote_addr;
|
|
52
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
53
|
+
proxy_set_header Connection "upgrade";
|
|
54
|
+
proxy_connect_timeout 5s;
|
|
55
|
+
proxy_read_timeout 90s;
|
|
56
|
+
proxy_send_timeout 90s;
|
|
57
|
+
proxy_buffering off;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderApacheProxy(input) {
|
|
64
|
+
return `# Apache 2.4.47+; enable ssl, headers, proxy and proxy_http modules.
|
|
65
|
+
# Add a matching Listen directive only if this HTTPS port is not already active.
|
|
66
|
+
# This direct-proxy plan uses the actual connection peer, not forwarded headers.
|
|
67
|
+
<VirtualHost *:${input.httpsPort}>
|
|
68
|
+
ServerName ${input.authority}
|
|
69
|
+
SSLEngine on
|
|
70
|
+
SSLCertificateFile ${proxyQuoted(input.certificatePath)}
|
|
71
|
+
SSLCertificateKeyFile ${proxyQuoted(input.keyPath)}
|
|
72
|
+
SSLProtocol -all +TLSv1.2 +TLSv1.3
|
|
73
|
+
ProxyRequests Off
|
|
74
|
+
ProxyPreserveHost On
|
|
75
|
+
ProxyTimeout 90
|
|
76
|
+
LimitRequestBody 4096
|
|
77
|
+
|
|
78
|
+
RequestHeader unset X-Forwarded-Proto
|
|
79
|
+
RequestHeader set X-Forwarded-Proto "https"
|
|
80
|
+
RequestHeader unset X-Socket-Client-IP
|
|
81
|
+
RequestHeader set X-Socket-Client-IP "expr=%{CONN_REMOTE_ADDR}"
|
|
82
|
+
# Host, Authorization and Origin are forwarded unchanged. Unknown Host
|
|
83
|
+
# values reach the relay unchanged and are rejected by its authority check.
|
|
84
|
+
ProxyPass "/" "http://127.0.0.1:${input.config.port}/" upgrade=websocket connectiontimeout=5 timeout=90
|
|
85
|
+
ProxyPassReverse "/" "http://127.0.0.1:${input.config.port}/"
|
|
86
|
+
</VirtualHost>
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function deploymentCommands(proxy) {
|
|
91
|
+
const commands = [
|
|
92
|
+
'sudo install -m 0644 -- ./abot-socket.service /etc/systemd/system/abot-socket.service',
|
|
93
|
+
'sudo systemctl daemon-reload',
|
|
94
|
+
'sudo systemctl enable --now abot-socket.service',
|
|
95
|
+
];
|
|
96
|
+
if (proxy === 'none') return commands;
|
|
97
|
+
if (proxy === 'nginx') return [...commands,
|
|
98
|
+
'sudo install -m 0644 -- ./nginx.conf /etc/nginx/conf.d/abot-socket.conf',
|
|
99
|
+
'sudo nginx -t',
|
|
100
|
+
'sudo systemctl reload nginx',
|
|
101
|
+
];
|
|
102
|
+
return [...commands,
|
|
103
|
+
'sudo install -m 0644 -- ./apache.conf /etc/apache2/sites-available/abot-socket.conf',
|
|
104
|
+
'sudo a2enmod ssl headers proxy proxy_http',
|
|
105
|
+
'sudo a2ensite abot-socket.conf',
|
|
106
|
+
'sudo apachectl configtest',
|
|
107
|
+
'sudo systemctl reload apache2',
|
|
108
|
+
];
|
|
109
|
+
}
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadConfiguration } from './config/config.mjs';
|
|
4
|
+
import { createSocketServer } from './transport/server.mjs';
|
|
5
|
+
|
|
6
|
+
export async function startConfiguredServer(env = process.env) {
|
|
7
|
+
const config = loadConfiguration(env);
|
|
8
|
+
const app = createSocketServer(config);
|
|
9
|
+
try { await app.listen(config.port); }
|
|
10
|
+
catch (error) { await app.close(); throw error; }
|
|
11
|
+
return app;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
15
|
+
try {
|
|
16
|
+
const app = await startConfiguredServer();
|
|
17
|
+
console.log('ABot Socket listening on loopback behind the configured HTTPS reverse proxy.');
|
|
18
|
+
const stop = () => { void app.close(); };
|
|
19
|
+
process.once('SIGTERM', stop);
|
|
20
|
+
process.once('SIGINT', stop);
|
|
21
|
+
} catch {
|
|
22
|
+
// Configuration/provider errors can contain secrets; never print them here.
|
|
23
|
+
console.error('ABot Socket could not start. Check private configuration, state permissions and listener availability.');
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -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
|
+
}
|