Infrastructure build to support third-party app integrations
This commit is contained in:
77
backend/lib/crypto.ts
Normal file
77
backend/lib/crypto.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_BYTES = 12;
|
||||
const KEY_BYTES = 32;
|
||||
|
||||
const loadKey = (): Buffer => {
|
||||
const raw = process.env.TOKEN_ENCRYPTION_KEY;
|
||||
if (!raw) {
|
||||
throw new Error('TOKEN_ENCRYPTION_KEY is not set');
|
||||
}
|
||||
|
||||
const key = Buffer.from(raw, 'base64');
|
||||
if (key.length !== KEY_BYTES) {
|
||||
throw new Error(
|
||||
`TOKEN_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes, got ${key.length}`
|
||||
);
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
/** Serialized as base64(iv):base64(authTag):base64(ciphertext). */
|
||||
export const encryptSecret = (plaintext: string): string => {
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv(ALGORITHM, loadKey(), iv);
|
||||
|
||||
const payload = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
|
||||
return [
|
||||
iv.toString('base64'),
|
||||
cipher.getAuthTag().toString('base64'),
|
||||
payload.toString('base64'),
|
||||
].join(':');
|
||||
};
|
||||
|
||||
export const decryptSecret = (ciphertext: string): string => {
|
||||
const parts = ciphertext.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Ciphertext is not in the expected iv:tag:payload form');
|
||||
}
|
||||
|
||||
const [iv, tag, payload] = parts;
|
||||
const decipher = createDecipheriv(
|
||||
ALGORITHM,
|
||||
loadKey(),
|
||||
Buffer.from(iv, 'base64')
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(payload, 'base64')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
};
|
||||
|
||||
export const hashApiKey = (key: string): string =>
|
||||
createHash('sha256').update(key, 'utf8').digest('hex');
|
||||
|
||||
/**
|
||||
* Constant-time string comparison. Both sides are hashed first so that
|
||||
* unequal lengths cannot short-circuit the comparison or throw.
|
||||
*/
|
||||
export const safeEquals = (a: string, b: string): boolean => {
|
||||
const digestA = createHash('sha256').update(a, 'utf8').digest();
|
||||
const digestB = createHash('sha256').update(b, 'utf8').digest();
|
||||
return timingSafeEqual(digestA, digestB);
|
||||
};
|
||||
234
backend/lib/httpClient.ts
Normal file
234
backend/lib/httpClient.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const DEFAULT_BASE_DELAY_MS = 250;
|
||||
const DEFAULT_RETRY_AFTER_CAP_MS = 60_000;
|
||||
|
||||
export type HttpFailureKind = 'status' | 'timeout' | 'network' | 'aborted' | 'invalid-body';
|
||||
|
||||
export type RequestJsonInit = RequestInit & {
|
||||
timeoutMs?: number;
|
||||
maxAttempts?: number;
|
||||
/** First-retry backoff, doubled per attempt. Exposed so tests need not wait on real backoff. */
|
||||
baseDelayMs?: number;
|
||||
/** Upper bound on a honored `Retry-After`, so a hostile header cannot park the process. */
|
||||
retryAfterCapMs?: number;
|
||||
};
|
||||
|
||||
export class HttpRequestError extends Error {
|
||||
readonly url: string;
|
||||
readonly status: number | undefined;
|
||||
readonly attempts: number;
|
||||
readonly kind: HttpFailureKind;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
kind: HttpFailureKind,
|
||||
attempts: number,
|
||||
status?: number,
|
||||
detail?: string
|
||||
) {
|
||||
const statusPart = status === undefined ? '' : ` status ${status}`;
|
||||
const detailPart = detail === undefined ? '' : `: ${detail}`;
|
||||
super(
|
||||
`HTTP request to ${url} failed after ${attempts} attempt(s) (${kind}${statusPart})${detailPart}`
|
||||
);
|
||||
this.name = 'HttpRequestError';
|
||||
this.url = url;
|
||||
this.status = status;
|
||||
this.attempts = attempts;
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
export const isHttpRequestError = (err: unknown): err is HttpRequestError =>
|
||||
err instanceof HttpRequestError;
|
||||
|
||||
/**
|
||||
* Returns the delay a `Retry-After` header asks for, clamped to `capMs`, or null when the
|
||||
* header is absent or unintelligible. Supports both delay-seconds and HTTP-date forms.
|
||||
*/
|
||||
export const parseRetryAfter = (
|
||||
headerValue: string | null,
|
||||
capMs: number = DEFAULT_RETRY_AFTER_CAP_MS
|
||||
): number | null => {
|
||||
if (headerValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = headerValue.trim();
|
||||
if (raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(raw)) {
|
||||
return Math.min(Number(raw) * 1000, capMs);
|
||||
}
|
||||
|
||||
const deadline = Date.parse(raw);
|
||||
if (Number.isNaN(deadline)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(deadline - Date.now(), 0), capMs);
|
||||
};
|
||||
|
||||
const backoffDelay = (attempt: number, baseDelayMs: number, capMs: number): number => {
|
||||
const ceiling = Math.min(baseDelayMs * 2 ** (attempt - 1), capMs);
|
||||
// Half fixed, half jittered, so concurrent callers do not resynchronize on the same tick.
|
||||
return Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
|
||||
};
|
||||
|
||||
/** Settles early when `signal` fires; the caller re-checks the signal before retrying. */
|
||||
const sleep = (ms: number, signal: AbortSignal | undefined): Promise<void> =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (signal?.aborted === true) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
|
||||
const isRetryableStatus = (status: number): boolean => status === 429 || status >= 500;
|
||||
|
||||
/** Frees the socket for reuse; a body we never read would otherwise stay pending. */
|
||||
const discardBody = async (response: Response): Promise<void> => {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// A already-consumed or errored body is irrelevant to the retry decision.
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = async <T>(response: Response, url: string, attempts: number): Promise<T> => {
|
||||
// 204/205 are defined as bodiless, so absence of JSON is the correct outcome, not a failure.
|
||||
if (response.status === 204 || response.status === 205) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = await response.text();
|
||||
} catch (err) {
|
||||
throw new HttpRequestError(
|
||||
url,
|
||||
'invalid-body',
|
||||
attempts,
|
||||
response.status,
|
||||
err instanceof Error ? err.message : 'could not read response body'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new HttpRequestError(
|
||||
url,
|
||||
'invalid-body',
|
||||
attempts,
|
||||
response.status,
|
||||
'response body was not valid JSON'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs a JSON request, retrying only failures a retry can plausibly fix: 429, 5xx, timeouts
|
||||
* and transport errors. Any other non-2xx fails on the first attempt, since re-sending a 400 or
|
||||
* 401 only spends rate limit against an answer that will not change.
|
||||
*
|
||||
* Resolves `undefined` for bodiless 204/205 responses; any other 2xx whose body is not valid
|
||||
* JSON rejects rather than resolving `undefined` silently.
|
||||
*/
|
||||
export const requestJson = async <T>(url: string, init?: RequestJsonInit): Promise<T> => {
|
||||
const {
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
||||
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
||||
retryAfterCapMs = DEFAULT_RETRY_AFTER_CAP_MS,
|
||||
signal,
|
||||
...requestInit
|
||||
} = init ?? {};
|
||||
|
||||
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
||||
throw new TypeError('requestJson(init.maxAttempts) requires a positive integer');
|
||||
}
|
||||
|
||||
const callerSignal = signal ?? undefined;
|
||||
// Read through a call so narrowing never freezes this at its first observed value.
|
||||
const callerAborted = (): boolean => callerSignal !== undefined && callerSignal.aborted;
|
||||
|
||||
let lastFailure: HttpRequestError | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
if (callerAborted()) {
|
||||
throw new HttpRequestError(url, 'aborted', attempt - 1, undefined, 'caller aborted');
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const attemptSignal =
|
||||
callerSignal === undefined
|
||||
? timeoutSignal
|
||||
: AbortSignal.any([callerSignal, timeoutSignal]);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { ...requestInit, signal: attemptSignal });
|
||||
} catch (err) {
|
||||
if (callerAborted()) {
|
||||
throw new HttpRequestError(url, 'aborted', attempt, undefined, 'caller aborted');
|
||||
}
|
||||
|
||||
const kind: HttpFailureKind = timeoutSignal.aborted ? 'timeout' : 'network';
|
||||
const detail = timeoutSignal.aborted
|
||||
? `attempt exceeded ${timeoutMs}ms`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'transport error';
|
||||
lastFailure = new HttpRequestError(url, kind, attempt, undefined, detail);
|
||||
|
||||
if (attempt === maxAttempts) {
|
||||
throw lastFailure;
|
||||
}
|
||||
|
||||
await sleep(backoffDelay(attempt, baseDelayMs, retryAfterCapMs), callerSignal);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return await readJson<T>(response, url, attempt);
|
||||
}
|
||||
|
||||
await discardBody(response);
|
||||
|
||||
if (!isRetryableStatus(response.status)) {
|
||||
throw new HttpRequestError(url, 'status', attempt, response.status);
|
||||
}
|
||||
|
||||
lastFailure = new HttpRequestError(url, 'status', attempt, response.status);
|
||||
if (attempt === maxAttempts) {
|
||||
throw lastFailure;
|
||||
}
|
||||
|
||||
const retryAfter = parseRetryAfter(response.headers.get('retry-after'), retryAfterCapMs);
|
||||
await sleep(
|
||||
retryAfter ?? backoffDelay(attempt, baseDelayMs, retryAfterCapMs),
|
||||
callerSignal
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable while maxAttempts >= 1; the loop either returns or throws.
|
||||
throw lastFailure ?? new HttpRequestError(url, 'network', maxAttempts);
|
||||
};
|
||||
91
backend/lib/queue.ts
Normal file
91
backend/lib/queue.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type Job = () => Promise<void>;
|
||||
|
||||
type QueueEntry = { name: string; job: Job };
|
||||
|
||||
type QueueSettings = { maxAttempts: number; baseDelayMs: number };
|
||||
|
||||
const settings: QueueSettings = { maxAttempts: 3, baseDelayMs: 100 };
|
||||
|
||||
const pending: QueueEntry[] = [];
|
||||
const idleWaiters: Array<() => void> = [];
|
||||
let active = false;
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
// Delay for attempt n is drawn from [base * 2^(n-1), base * 2^n), so successive
|
||||
// waits always grow while jitter keeps retries from synchronizing across jobs.
|
||||
const backoffDelay = (attempt: number): number => {
|
||||
const window = settings.baseDelayMs * 2 ** (attempt - 1);
|
||||
return window + Math.random() * window;
|
||||
};
|
||||
|
||||
const runEntry = async (entry: QueueEntry): Promise<void> => {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
await entry.job();
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt >= settings.maxAttempts) {
|
||||
console.error(
|
||||
`[queue] job "${entry.name}" abandoned after ${attempt} attempt(s)`,
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
await sleep(backoffDelay(attempt));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runLoop = async (): Promise<void> => {
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = pending.shift();
|
||||
if (!entry) return;
|
||||
await runEntry(entry);
|
||||
}
|
||||
} finally {
|
||||
active = false;
|
||||
for (const resolve of idleWaiters.splice(0)) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
export const enqueue = (name: string, job: Job): void => {
|
||||
pending.push({ name, job });
|
||||
if (active) return;
|
||||
|
||||
active = true;
|
||||
// Deferred to a microtask so enqueue() returns to its caller — typically a
|
||||
// request handler that has already responded — before any job body runs.
|
||||
void Promise.resolve()
|
||||
.then(runLoop)
|
||||
.catch((err: unknown) => {
|
||||
active = false;
|
||||
console.error('[queue] queue loop stopped unexpectedly', err);
|
||||
});
|
||||
};
|
||||
|
||||
export const size = (): number => pending.length;
|
||||
|
||||
export const drain = (): Promise<void> => {
|
||||
if (!active && pending.length === 0) return Promise.resolve();
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
idleWaiters.push(resolve);
|
||||
});
|
||||
};
|
||||
|
||||
export const configureQueue = (opts: {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
}): void => {
|
||||
if (opts.maxAttempts !== undefined) {
|
||||
settings.maxAttempts = Math.max(1, Math.floor(opts.maxAttempts));
|
||||
}
|
||||
if (opts.baseDelayMs !== undefined) {
|
||||
settings.baseDelayMs = Math.max(0, opts.baseDelayMs);
|
||||
}
|
||||
};
|
||||
81
backend/lib/signatures.ts
Normal file
81
backend/lib/signatures.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { getProvider } from '../config/providers.js';
|
||||
import type { ProviderSlug, SignatureResult } from '../types/integration.js';
|
||||
|
||||
const DEFAULT_TOLERANCE_SECONDS = 300;
|
||||
|
||||
const OK: SignatureResult = { ok: true };
|
||||
|
||||
const fail = (reason: Exclude<SignatureResult, { ok: true }>['reason']): SignatureResult => ({
|
||||
ok: false,
|
||||
reason,
|
||||
});
|
||||
|
||||
const header = (headers: IncomingHttpHeaders, name: string): string | undefined => {
|
||||
const value = headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
};
|
||||
|
||||
const hmacHex = (secret: string, payload: string | Buffer): string =>
|
||||
createHmac('sha256', secret).update(payload).digest('hex');
|
||||
|
||||
const constantTimeEquals = (a: string, b: string): boolean => {
|
||||
const bufA = Buffer.from(a, 'utf8');
|
||||
const bufB = Buffer.from(b, 'utf8');
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
};
|
||||
|
||||
const withinTolerance = (timestamp: string, toleranceSeconds: number): boolean => {
|
||||
const sent = Number(timestamp);
|
||||
if (!Number.isFinite(sent)) return false;
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
return Math.abs(nowSeconds - sent) <= toleranceSeconds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Never throws. A malformed header from an anonymous caller must be an
|
||||
* ordinary negative result, not an exception reachable from the edge.
|
||||
*/
|
||||
export const verifySignature = (input: {
|
||||
provider: ProviderSlug;
|
||||
rawBody: Buffer;
|
||||
headers: IncomingHttpHeaders;
|
||||
secret: string;
|
||||
toleranceSeconds?: number;
|
||||
}): SignatureResult => {
|
||||
const config = getProvider(input.provider);
|
||||
const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
||||
|
||||
if (config.signatureScheme === 'none') return OK;
|
||||
|
||||
const presented = header(input.headers, config.signatureHeader);
|
||||
if (!presented) return fail('missing');
|
||||
|
||||
if (config.signatureScheme === 'slack-v0') {
|
||||
const timestamp = config.timestampHeader
|
||||
? header(input.headers, config.timestampHeader)
|
||||
: undefined;
|
||||
if (!timestamp) return fail('missing');
|
||||
if (!withinTolerance(timestamp, tolerance)) return fail('stale');
|
||||
|
||||
if (!presented.startsWith('v0=')) return fail('malformed');
|
||||
|
||||
const base = `v0:${timestamp}:${input.rawBody.toString('utf8')}`;
|
||||
const expected = `v0=${hmacHex(input.secret, base)}`;
|
||||
return constantTimeEquals(presented, expected) ? OK : fail('mismatch');
|
||||
}
|
||||
|
||||
if (config.signatureScheme === 'github-sha256') {
|
||||
if (!presented.startsWith('sha256=')) return fail('malformed');
|
||||
const expected = `sha256=${hmacHex(input.secret, input.rawBody)}`;
|
||||
return constantTimeEquals(presented, expected) ? OK : fail('mismatch');
|
||||
}
|
||||
|
||||
// linear-sha256: bare lowercase hex digest of the raw body.
|
||||
if (!/^[0-9a-f]{64}$/.test(presented)) return fail('malformed');
|
||||
const expected = hmacHex(input.secret, input.rawBody);
|
||||
return constantTimeEquals(presented, expected) ? OK : fail('mismatch');
|
||||
};
|
||||
@@ -1,21 +1,20 @@
|
||||
import { Transform } from 'node:stream';
|
||||
import { Transform, type TransformCallback } from 'node:stream';
|
||||
|
||||
/**
|
||||
* Backpressure on readable side is limits how many batches are in flight.
|
||||
*
|
||||
* @param {number} size - maximum items per emitted batch
|
||||
* @returns {Transform}
|
||||
* @param size - maximum items per emitted batch
|
||||
*/
|
||||
export const batch = (size) => {
|
||||
export const batch = <T>(size: number): Transform => {
|
||||
if (!Number.isInteger(size) || size < 1) {
|
||||
throw new TypeError('batch(size) requires a positive integer size');
|
||||
}
|
||||
|
||||
let pending = [];
|
||||
let pending: T[] = [];
|
||||
|
||||
return new Transform({
|
||||
objectMode: true,
|
||||
transform(item, _encoding, callback) {
|
||||
transform(item: T, _encoding: BufferEncoding, callback: TransformCallback) {
|
||||
pending.push(item);
|
||||
|
||||
if (pending.length < size) {
|
||||
@@ -27,7 +26,7 @@ export const batch = (size) => {
|
||||
pending = [];
|
||||
callback(null, full);
|
||||
},
|
||||
flush(callback) {
|
||||
flush(callback: TransformCallback) {
|
||||
if (pending.length === 0) {
|
||||
callback();
|
||||
return;
|
||||
@@ -40,20 +39,17 @@ export const batch = (size) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {Transform}
|
||||
*/
|
||||
export const jsonArray = () => {
|
||||
export const jsonArray = (): Transform => {
|
||||
let wroteFirst = false;
|
||||
|
||||
return new Transform({
|
||||
writableObjectMode: true,
|
||||
transform(item, _encoding, callback) {
|
||||
let serialized;
|
||||
transform(item: unknown, _encoding: BufferEncoding, callback: TransformCallback) {
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(item);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
callback(err as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +57,7 @@ export const jsonArray = () => {
|
||||
wroteFirst = true;
|
||||
callback(null, prefix + serialized);
|
||||
},
|
||||
flush(callback) {
|
||||
flush(callback: TransformCallback) {
|
||||
callback(null, wroteFirst ? ']' : '[]');
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user