feat: AI dev stream over HTTPS (SSE read + draft writeback)
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>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user