feat(api): Fastify control plane (auth, servers, WS build stream, OAuth 2.1 AS, JWKS)
This commit is contained in:
30
apps/api/src/lib/crypto.ts
Normal file
30
apps/api/src/lib/crypto.ts
Normal 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
73
apps/api/src/lib/jwks.ts
Normal 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
22
apps/api/src/lib/queue.ts
Normal 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
25
apps/api/src/lib/redis.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user