530f81416a
Replaces the Redis-over-Tailscale handoff with a far simpler HTTP surface on the existing public API — no Redis client, Tailscale, or firewall changes needed on the dev side. - GET /ai/stream — SSE feed of every ingested message, reads the internal Redis stream; resumes from Last-Event-ID on reconnect (no missed messages); heartbeats keep the connection warm through proxies - POST /ai/drafts — writeback: creates a ContentDraft (PENDING_REVIEW) that shows up in the admin /admin/drafts review UI - AiStreamGuard — static bearer token auth (AI_STREAM_TOKEN), accepts header or ?token= query for browser EventSource - Remove Tailscale sidecar + public Redis port; Redis stays internal-only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
44 lines
1.6 KiB
TypeScript
44 lines
1.6 KiB
TypeScript
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { timingSafeEqual } from 'crypto';
|
|
|
|
/**
|
|
* Guard for the AI-dev stream + writeback endpoints.
|
|
*
|
|
* Authenticates with a single static bearer token (AI_STREAM_TOKEN), accepted
|
|
* either as `Authorization: Bearer <token>` (POST / header-capable clients) or
|
|
* as a `?token=<token>` query param (browser EventSource cannot set headers).
|
|
*
|
|
* This is a separate auth realm from the admin/member JWT — the AI dev never
|
|
* gets a login, just one long-lived token you rotate by changing the env var.
|
|
*/
|
|
@Injectable()
|
|
export class AiStreamGuard implements CanActivate {
|
|
constructor(private readonly config: ConfigService) {}
|
|
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const expected = this.config.get<string>('AI_STREAM_TOKEN');
|
|
if (!expected) {
|
|
throw new UnauthorizedException('AI stream is not configured');
|
|
}
|
|
|
|
const req = context.switchToHttp().getRequest();
|
|
const header: string | undefined = req.headers?.authorization;
|
|
const fromHeader = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
|
|
const fromQuery: string | undefined = req.query?.token;
|
|
const provided = fromHeader ?? fromQuery;
|
|
|
|
if (!provided || !safeEqual(provided, expected)) {
|
|
throw new UnauthorizedException('Invalid AI stream token');
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function safeEqual(a: string, b: string): boolean {
|
|
const ab = Buffer.from(a);
|
|
const bb = Buffer.from(b);
|
|
if (ab.length !== bb.length) return false;
|
|
return timingSafeEqual(ab, bb);
|
|
}
|