Infrastructure build to support third-party app integrations
This commit is contained in:
113
backend/config/normalizers.ts
Normal file
113
backend/config/normalizers.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { NormalizedDelivery, NoteProvenance } from '../types/integration.js';
|
||||
import type { NoteInput } from '../types/domain.js';
|
||||
|
||||
export class NormalizationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NormalizationError';
|
||||
}
|
||||
}
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new NormalizationError('Payload is not an object');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
|
||||
/** notes.id is VARCHAR(64), so composite keys have to be trimmed to fit. */
|
||||
const noteId = (provider: string, externalId: string): string =>
|
||||
`${provider}_${externalId}`.slice(0, 64);
|
||||
|
||||
const provenance = (
|
||||
meta: Omit<NoteProvenance, 'receivedAt'>
|
||||
): Record<string, unknown> => ({
|
||||
...meta,
|
||||
receivedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const isNoteInput = (value: unknown): value is NoteInput => {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const note = value as Record<string, unknown>;
|
||||
|
||||
if (typeof note.id !== 'string' || note.id.length === 0) return false;
|
||||
if (typeof note.text !== 'string' || note.text.length === 0) return false;
|
||||
if (typeof note.author !== 'string' || note.author.length === 0) return false;
|
||||
if (note.x !== undefined && typeof note.x !== 'number') return false;
|
||||
if (note.y !== undefined && typeof note.y !== 'number') return false;
|
||||
if (note.color !== undefined && typeof note.color !== 'string') return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isNoteInputArray = (value: unknown): value is NoteInput[] =>
|
||||
Array.isArray(value) && value.every(isNoteInput);
|
||||
|
||||
/**
|
||||
* Direct push: the caller already speaks our note shape, so normalization is
|
||||
* a validation step rather than a translation.
|
||||
*/
|
||||
export const normalizeRest = (payload: unknown): NormalizedDelivery => {
|
||||
const body = asRecord(payload);
|
||||
|
||||
if (!isNoteInputArray(body.notes)) {
|
||||
throw new NormalizationError('Expected a "notes" array of note objects');
|
||||
}
|
||||
|
||||
const externalId = asString(body.batchId) ?? randomBatchId();
|
||||
|
||||
return {
|
||||
externalId,
|
||||
notes: body.notes.map((note) => ({
|
||||
...note,
|
||||
sourceMeta: provenance({ provider: 'rest', externalId }),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const randomBatchId = (): string =>
|
||||
`batch_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
/**
|
||||
* Linear comment webhook. Kept minimal on purpose: this proves the webhook
|
||||
* pipeline end to end. Label filtering and issue-thread context belong to the
|
||||
* Linear integration proper, not to this infrastructure.
|
||||
*/
|
||||
export const normalizeLinear = (payload: unknown): NormalizedDelivery => {
|
||||
const body = asRecord(payload);
|
||||
const data = asRecord(body.data);
|
||||
|
||||
const externalId = asString(data.id);
|
||||
if (!externalId) {
|
||||
throw new NormalizationError('Linear payload is missing data.id');
|
||||
}
|
||||
|
||||
const text = asString(data.body);
|
||||
if (!text) {
|
||||
return { externalId, notes: [] };
|
||||
}
|
||||
|
||||
const user = typeof data.user === 'object' && data.user !== null
|
||||
? (data.user as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
externalId,
|
||||
notes: [
|
||||
{
|
||||
id: noteId('linear', externalId),
|
||||
text,
|
||||
author: asString(user.name) ?? 'unknown',
|
||||
sourceMeta: provenance({
|
||||
provider: 'linear',
|
||||
externalId,
|
||||
permalink: asString(data.url),
|
||||
authorHandle: asString(user.name),
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
95
backend/config/providers.ts
Normal file
95
backend/config/providers.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
NormalizedDelivery,
|
||||
ProviderSlug,
|
||||
SignatureScheme,
|
||||
} from '../types/integration.js';
|
||||
import { normalizeLinear, normalizeRest } from './normalizers.js';
|
||||
|
||||
export type ChallengeResponse = { status: number; body: unknown };
|
||||
|
||||
export type ProviderConfig = {
|
||||
slug: ProviderSlug;
|
||||
signatureScheme: SignatureScheme;
|
||||
/** Header carrying the signature. Empty only when the scheme is 'none'. */
|
||||
signatureHeader: string;
|
||||
timestampHeader?: string;
|
||||
/** Environment variable holding the shared secret when the install has none. */
|
||||
secretEnvVar?: string;
|
||||
/** Returns a response when the request is a handshake rather than an event. */
|
||||
challenge?: (body: unknown) => ChallengeResponse | null;
|
||||
/**
|
||||
* Absent until that provider's integration is built. The transport above is
|
||||
* infrastructure; the payload mapping belongs to the integration itself.
|
||||
*/
|
||||
normalize?: (payload: unknown) => NormalizedDelivery;
|
||||
oauth?: {
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
scopes: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const slackChallenge = (body: unknown): ChallengeResponse | null => {
|
||||
if (typeof body !== 'object' || body === null) return null;
|
||||
const candidate = body as { type?: unknown; challenge?: unknown };
|
||||
if (candidate.type !== 'url_verification') return null;
|
||||
if (typeof candidate.challenge !== 'string') return null;
|
||||
return { status: 200, body: { challenge: candidate.challenge } };
|
||||
};
|
||||
|
||||
export const providers: Record<ProviderSlug, ProviderConfig> = {
|
||||
rest: {
|
||||
slug: 'rest',
|
||||
signatureScheme: 'none',
|
||||
signatureHeader: '',
|
||||
normalize: normalizeRest,
|
||||
},
|
||||
linear: {
|
||||
slug: 'linear',
|
||||
signatureScheme: 'linear-sha256',
|
||||
signatureHeader: 'linear-signature',
|
||||
secretEnvVar: 'LINEAR_SIGNING_SECRET',
|
||||
normalize: normalizeLinear,
|
||||
oauth: {
|
||||
authorizeUrl: 'https://linear.app/oauth/authorize',
|
||||
tokenUrl: 'https://api.linear.app/oauth/token',
|
||||
scopes: ['read'],
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
slug: 'slack',
|
||||
signatureScheme: 'slack-v0',
|
||||
signatureHeader: 'x-slack-signature',
|
||||
timestampHeader: 'x-slack-request-timestamp',
|
||||
secretEnvVar: 'SLACK_SIGNING_SECRET',
|
||||
challenge: slackChallenge,
|
||||
oauth: {
|
||||
authorizeUrl: 'https://slack.com/oauth/v2/authorize',
|
||||
tokenUrl: 'https://slack.com/api/oauth.v2.access',
|
||||
scopes: ['channels:history', 'reactions:read', 'users:read'],
|
||||
},
|
||||
},
|
||||
github: {
|
||||
slug: 'github',
|
||||
signatureScheme: 'github-sha256',
|
||||
signatureHeader: 'x-hub-signature-256',
|
||||
secretEnvVar: 'GITHUB_WEBHOOK_SECRET',
|
||||
oauth: {
|
||||
authorizeUrl: 'https://github.com/login/oauth/authorize',
|
||||
tokenUrl: 'https://github.com/login/oauth/access_token',
|
||||
scopes: ['repo', 'read:discussion'],
|
||||
},
|
||||
},
|
||||
jira: {
|
||||
slug: 'jira',
|
||||
signatureScheme: 'none',
|
||||
signatureHeader: '',
|
||||
oauth: {
|
||||
authorizeUrl: 'https://auth.atlassian.com/authorize',
|
||||
tokenUrl: 'https://auth.atlassian.com/oauth/token',
|
||||
scopes: ['read:jira-work', 'offline_access'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getProvider = (slug: ProviderSlug): ProviderConfig => providers[slug];
|
||||
Reference in New Issue
Block a user