Infrastructure build to support third-party app integrations

This commit is contained in:
KS Jannette
2026-08-01 07:35:01 -04:00
parent b4666c5439
commit 15af3465e2
53 changed files with 5864 additions and 659 deletions

77
backend/lib/crypto.ts Normal file
View 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
View 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
View 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
View 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');
};

View File

@@ -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 ? ']' : '[]');
},
});