# Low-level design Phase 3 output. Exact interfaces for every module in [ROADMAP.md](ROADMAP.md), written before implementation. Error handling follows [agents.md](agents.md) section 2: async route handlers wrap in `try/catch`; services throw typed errors and let routes translate them into status codes. ## Shared types — `types/integration.ts` ```ts export type ProviderSlug = 'slack' | 'jira' | 'linear' | 'github' | 'rest'; export type IntegrationRow = { id: number; provider: ProviderSlug; externalWorkspaceId: string | null; displayName: string | null; scopes: string[]; tokenExpiresAt: Date | null; }; export type SignatureResult = | { ok: true } | { ok: false; reason: 'missing' | 'malformed' | 'mismatch' | 'stale' }; export type NormalizedDelivery = { externalId: string; notes: NoteInput[]; }; export type DeliveryStatus = 'pending' | 'processing' | 'done' | 'failed'; ``` String-literal unions rather than enums, per agents.md section 4. ## `lib/crypto.ts` ```ts export const encryptSecret = (plaintext: string): string; export const decryptSecret = (ciphertext: string): string; export const hashApiKey = (key: string): string; export const safeEquals = (a: string, b: string): boolean; ``` AES-256-GCM, serialized `base64(iv):base64(tag):base64(payload)`. `encryptSecret` throws if `TOKEN_ENCRYPTION_KEY` is missing or not 32 bytes after base64 decode. `safeEquals` pads to equal length before `timingSafeEqual` so it cannot leak length through an early throw. Tests: round-trip fidelity, distinct ciphertext for identical plaintext (random IV), tamper detection on the auth tag, rejection of a short key, `safeEquals` true and false paths. ## `middleware/apiKey.ts` ```ts declare module 'express-serve-static-core' { interface Request { integration?: IntegrationRow } } export const requireApiKey: RequestHandler; ``` Reads `Authorization`, expects `Bearer `. On success attaches `req.integration` and calls `next()`. On any failure responds `401 { error: 'Unauthorized' }` — identical body for missing, malformed, and unknown keys. Tests: valid key passes and attaches, missing header 401s, wrong scheme 401s, unknown key 401s, database error surfaces as 500 via `next(err)`. ## `routes/ingest.routes.ts` ```ts type IngestBody = { notes: NoteInput[] }; const isIngestBody = (value: unknown): value is IngestBody; ``` `POST /` returns `201 { inserted: number; notes: Note[] }`. Rejects an empty array and a batch over 5000 with `400`. The predicate checks `id`, `text`, and `author` are non-empty strings and that optional `x`, `y`, `color` have the right primitive types when present. Tests: happy path inserts and echoes, missing `notes` key 400s, non-array 400s, element missing `text` 400s, empty array 400s, oversized batch 400s, unauthenticated 401s, DAO rejection 500s. ## `db/ingest_events.dao.ts` ```ts export const recordDelivery = (input: { provider: ProviderSlug; externalId: string; integrationId?: number; }): Promise; export const markProcessing = (id: number): Promise; export const markDone = (id: number): Promise; export const markFailed = (id: number, error: string): Promise; export const resetStaleProcessing = (olderThanMs: number): Promise; ``` `recordDelivery` resolves to `null` when the unique constraint fires, which is the caller's signal that this is a redelivery. `resetStaleProcessing` exists so a restart can recover rows the in-process queue was holding. Tests: first delivery returns an id, identical redelivery returns null, different providers with the same external id both insert, status transitions persist, stale reset only touches `processing` rows past the cutoff. ## `lib/signatures.ts` ```ts export const verifySignature = (input: { provider: ProviderSlug; rawBody: Buffer; headers: IncomingHttpHeaders; secret: string; toleranceSeconds?: number; }): SignatureResult; ``` Dispatches on the registry's `signatureScheme`. Never throws on bad input — a malformed header is a `{ ok: false }` result, because a thrown exception here would be an unhandled rejection path reachable by any anonymous caller. Tests, per scheme: correct signature passes, altered body fails with `mismatch`, absent header fails with `missing`, timestamp outside tolerance fails with `stale`, and a signature of the right length but wrong content fails without a timing difference. ## `config/providers.ts` ```ts export type ProviderConfig = { slug: ProviderSlug; signatureScheme: 'slack-v0' | 'github-sha256' | 'linear-sha256' | 'none'; signatureHeader: string; timestampHeader?: string; challenge?: (body: unknown) => { status: number; body: unknown } | null; normalize: (payload: unknown) => NormalizedDelivery; oauth?: { authorizeUrl: string; tokenUrl: string; scopes: string[]; }; }; export const providers: Record; export const isProviderSlug = (value: string): value is ProviderSlug; ``` The `rest` entry uses scheme `none` and no normalizer of consequence — it exists so the registry is the complete inventory of inbound sources. Tests: every slug resolves, `isProviderSlug` rejects unknown input, each entry declares a header when its scheme is not `none`. ## `services/normalize.service.ts` ```ts export const normalize = ( provider: ProviderSlug, payload: unknown ): NormalizedDelivery; export const hasNormalizer = (provider: ProviderSlug): boolean; ``` Delegates to the registry entry. Throws `NormalizationError` when the payload lacks the fields that provider guarantees, and also when no normalizer is registered at all. Provenance written to `source_meta` as `{ provider, externalId, permalink, authorHandle, receivedAt }`. Two normalizers ship with the infrastructure. `rest` is the generic bulk push and is a validation step rather than a translation. `linear` maps a comment webhook and exists so the webhook path is provable end to end; it is deliberately minimal and is not the Linear integration, which additionally needs OAuth install and label filtering. `slack`, `github`, and `jira` declare transport only. Note ids are composed as `_` and truncated to 64 characters, because `notes.id` is `VARCHAR(64)`. Tests: a representative payload per shipped provider yields the expected notes, an empty message body yields zero notes rather than a blank note, provenance fields are populated, a long external id still fits the column, and a provider without a normalizer throws. ## `lib/queue.ts` ```ts export type Job = () => Promise; export const enqueue = (name: string, job: Job): void; export const size = (): number; export const drain = (): Promise; export const configureQueue = (opts: { maxAttempts?: number; baseDelayMs?: number; }): void; ``` Concurrency 1, exponential backoff with jitter, terminal failure logged and surfaced through the caller's `markFailed`. `drain` resolves when the queue is empty and no job is in flight, which is what makes the integration tests deterministic instead of timer-dependent. Tests: jobs run in order, a failing job retries to the cap then stops, backoff delays grow, `drain` waits for in-flight work, `size` reflects pending count. ## `lib/httpClient.ts` ```ts export const requestJson = (url: string, init?: RequestInit & { timeoutMs?: number; maxAttempts?: number; }): Promise; ``` Retries 429 and 5xx, honors `Retry-After` when present, jittered exponential backoff otherwise, `AbortSignal.timeout` for the deadline. A 4xx other than 429 fails immediately — retrying a 401 just burns rate limit. Tests: 200 parses, 429 with `Retry-After` waits then succeeds, 500 retries to cap, 400 fails without retry, timeout aborts. ## `routes/webhooks.routes.ts` ```ts router.post('/:provider', rawBody, handler); ``` Handler order is fixed and each step has a distinct exit: 1. Unknown slug — `404`. 2. Body is not JSON — `400`. 3. Challenge request — provider-defined status and body. 4. No signing secret configured server-side — `500`, since that is our misconfiguration and not the caller's fault. 5. Signature invalid — `401`. 6. Provider registered but no normalizer yet — `501`. The transport is infrastructure and works; the payload mapping arrives with that provider's integration. 7. Payload fails normalization — `400`. 8. `recordDelivery` returns null — `200 { duplicate: true }`, no work enqueued. 9. Otherwise enqueue the insert and return `200 { accepted: true, notes: n }`. Normalization runs *before* the ack rather than inside the queue job. It is a pure function, so it costs nothing on the request path, and doing it here means a malformed payload gets a `400` the sender can act on instead of failing silently in a background job. Only the database write is deferred. Tests: each numbered branch, plus the ordering regression — sign a body with irregular internal whitespace and assert verification still succeeds, which can only happen if the exact bytes survived `express.raw` ahead of `express.json`.