feat(api): Fastify control plane (auth, servers, WS build stream, OAuth 2.1 AS, JWKS)

This commit is contained in:
Marco Sadjadi
2026-05-19 00:24:47 +02:00
parent 15697ba6dd
commit 9658e843df
13 changed files with 871 additions and 4 deletions

View File

@@ -0,0 +1,30 @@
import crypto from 'node:crypto';
import { config } from '../config.js';
const ALGO = 'aes-256-gcm';
function getKey(): Buffer {
const hex = config.SECRETS_ENCRYPTION_KEY;
const buf = Buffer.from(hex, 'hex');
if (buf.length !== 32) {
throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
}
return buf;
}
export function encryptSecret(plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGO, getKey(), iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
}
export function decryptSecret(payload: string): string {
const [ivB64, tagB64, encB64] = payload.split('.');
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_secret_payload');
const decipher = crypto.createDecipheriv(ALGO, getKey(), Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
const dec = Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]);
return dec.toString('utf8');
}

73
apps/api/src/lib/jwks.ts Normal file
View File

@@ -0,0 +1,73 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { exportJWK, importPKCS8, importSPKI, type JWK, type KeyLike, SignJWT } from 'jose';
import { config } from '../config.js';
interface KeyMaterial {
kid: string;
privatePem: string;
publicPem: string;
privateKey: KeyLike;
publicJwk: JWK;
}
let cached: KeyMaterial | null = null;
async function loadOrGenerate(): Promise<KeyMaterial> {
if (cached) return cached;
const dir = path.resolve(config.OAUTH_KEY_DIR);
fs.mkdirSync(dir, { recursive: true });
const privPath = path.join(dir, 'oauth-private.pem');
const pubPath = path.join(dir, 'oauth-public.pem');
const kidPath = path.join(dir, 'oauth.kid');
let privatePem: string;
let publicPem: string;
let kid: string;
if (fs.existsSync(privPath) && fs.existsSync(pubPath) && fs.existsSync(kidPath)) {
privatePem = fs.readFileSync(privPath, 'utf8');
publicPem = fs.readFileSync(pubPath, 'utf8');
kid = fs.readFileSync(kidPath, 'utf8').trim();
} else {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
publicPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
kid = crypto.randomBytes(8).toString('hex');
fs.writeFileSync(privPath, privatePem, { mode: 0o600 });
fs.writeFileSync(pubPath, publicPem, { mode: 0o644 });
fs.writeFileSync(kidPath, kid);
}
const privateKey = await importPKCS8(privatePem, 'RS256');
const publicKey = await importSPKI(publicPem, 'RS256');
const publicJwk: JWK = { ...(await exportJWK(publicKey)), kid, alg: 'RS256', use: 'sig' };
cached = { kid, privatePem, publicPem, privateKey, publicJwk };
return cached;
}
export async function getJWKS(): Promise<{ keys: JWK[] }> {
const k = await loadOrGenerate();
return { keys: [k.publicJwk] };
}
export async function signAccessToken(input: {
subject: string;
audience: string;
issuer: string;
scope?: string;
ttlSeconds?: number;
}): Promise<string> {
const k = await loadOrGenerate();
const ttl = input.ttlSeconds ?? 3600;
return await new SignJWT({ scope: input.scope ?? '' })
.setProtectedHeader({ alg: 'RS256', kid: k.kid, typ: 'JWT' })
.setIssuer(input.issuer)
.setSubject(input.subject)
.setAudience(input.audience)
.setIssuedAt()
.setExpirationTime(`${ttl}s`)
.sign(k.privateKey);
}

22
apps/api/src/lib/queue.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Queue } from 'bullmq';
import { getRedis } from './redis.js';
export interface BuildJobData {
buildId: string;
serverId: string;
orgId: string;
prompt: string;
version: number;
slug: string;
serverName: string;
secrets: Record<string, string>;
}
let queue: Queue<BuildJobData> | null = null;
export function getBuildQueue(): Queue<BuildJobData> {
if (!queue) {
queue = new Queue<BuildJobData>('build', { connection: getRedis() });
}
return queue;
}

25
apps/api/src/lib/redis.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Redis } from 'ioredis';
import { config } from '../config.js';
let pub: Redis | null = null;
let sub: Redis | null = null;
let main: Redis | null = null;
export function getRedis(): Redis {
if (!main) main = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
return main;
}
export function getPublisher(): Redis {
if (!pub) pub = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
return pub;
}
export function getSubscriber(): Redis {
if (!sub) sub = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
return sub;
}
export function buildChannel(buildId: string): string {
return `build:${buildId}`;
}