8.9 KiB
Low-level design
Phase 3 output. Exact interfaces for every module in ROADMAP.md, written before implementation. Error handling follows 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
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
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
declare module 'express-serve-static-core' {
interface Request { integration?: IntegrationRow }
}
export const requireApiKey: RequestHandler;
Reads Authorization, expects Bearer <key>. 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
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
export const recordDelivery = (input: {
provider: ProviderSlug;
externalId: string;
integrationId?: number;
}): Promise<number | null>;
export const markProcessing = (id: number): Promise<void>;
export const markDone = (id: number): Promise<void>;
export const markFailed = (id: number, error: string): Promise<void>;
export const resetStaleProcessing = (olderThanMs: number): Promise<number>;
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
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
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<ProviderSlug, ProviderConfig>;
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
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 <provider>_<externalId> 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
export type Job = () => Promise<void>;
export const enqueue = (name: string, job: Job): void;
export const size = (): number;
export const drain = (): Promise<void>;
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
export const requestJson = <T>(url: string, init?: RequestInit & {
timeoutMs?: number;
maxAttempts?: number;
}): Promise<T>;
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
router.post('/:provider', rawBody, handler);
Handler order is fixed and each step has a distinct exit:
- Unknown slug —
404. - Body is not JSON —
400. - Challenge request — provider-defined status and body.
- No signing secret configured server-side —
500, since that is our misconfiguration and not the caller's fault. - Signature invalid —
401. - Provider registered but no normalizer yet —
501. The transport is infrastructure and works; the payload mapping arrives with that provider's integration. - Payload fails normalization —
400. recordDeliveryreturns null —200 { duplicate: true }, no work enqueued.- 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.