Files
tower/apps/api/src/modules/ai-stream/ai-stream.guard.ts
T
maaz519 530f81416a 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>
2026-06-20 13:06:17 +05:30

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);
}