disclosure-bureau/web/app/auth/callback/route.ts
guto 7d13f93393 ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow
Fase 3 onda 2 — entity synthesis at scale:
- scripts/synthesize/20_entity_summary.py: queries DB for entities with
  total_mentions ≥ threshold + top-K verbatim chunk snippets via
  entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual),
  writes narrative_summary EN+PT-BR + summary_status=synthesized.
  Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no
  snippets). Combined with anchor curation: 20 curated + 158 synthesized
  = 178 entities with real narrative (vs 0 a day ago).

Fase 4 — chat with typed artifacts + persistence:
- lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image,
  entity_card, evidence_card, hypothesis_card, case_card, navigation_offer)
  alongside the existing event types.
- lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6
  citation + crop_image artifacts per query. Provider collects them and
  returns in done.artifacts so the route can persist.
- api/sessions/[id]/messages: persist artifacts to messages.citations.
- components/chat-bubble.tsx: ArtifactCard renders inline cards (citation,
  crop_image, entity_card, navigation_offer) for streamed and persisted
  messages. activeId now persisted in localStorage so navigation between
  pages keeps the same conversation. New sessions are lazy (only when user
  has zero). loadMessages hydrates tools + artifacts from server. CRUD UI:
  rename (✎) + archive (🗑) buttons per session in the list.

Home search:
- doc-list-filters: input now fires hybrid_search (rerank=0 for speed)
  in parallel with the local title filter; chunk hits render above the doc
  grid with snippet + score + classification.
- api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s).

Auth flow:
- infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands
  in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification).
- kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE
  email links don't 404 at the gateway.
- web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type=
  (PKCE); redirect to the public site host before verifyOtp so the session
  cookie lands on the right domain.

Audit deliverables:
- .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code
  analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by
  sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 03:52:59 -03:00

86 lines
3.4 KiB
TypeScript

/**
* Auth callback. The link emailed by Supabase / GoTrue can arrive in one of
* two shapes depending on flow:
*
* 1. OAuth code exchange: /auth/callback?code=<>&next=/...
* 2. PKCE email confirm: /auth/callback?token=<>&type=signup|magiclink|recovery|invite&next=/...
* (also: token_hash variant on newer Supabase JS)
*
* We handle both, exchange for a session, and bounce to `next` (or /).
*/
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
type OtpType = "signup" | "magiclink" | "recovery" | "invite" | "email_change";
const VALID_OTP_TYPES: Set<OtpType> = new Set([
"signup", "magiclink", "recovery", "invite", "email_change",
]);
export async function GET(request: Request) {
const url = new URL(request.url);
const { searchParams, origin } = url;
// Always bounce to the public site origin, not whatever Host the
// request happened to come in on (e.g. api.disclosure.top via the Kong
// proxy fallback). NEXT_PUBLIC_SITE_URL is baked at build time.
const siteOrigin = process.env.NEXT_PUBLIC_SITE_URL || origin;
// CRITICAL: cookies are scoped to the request host. If the link in the
// email landed on the API host (api.disclosure.top, proxied via Kong),
// any session cookie we set here would be on api.* and the user would
// appear logged out when they bounce to disclosure.top. Redirect first
// so verifyOtp runs on the site domain.
//
// `url.host` returns the internal listen host inside the Next.js server
// (typically `localhost:3000`), NOT the public host the browser used.
// We must read the proxy-forwarded host header to know where the user
// actually is. Traefik sets X-Forwarded-Host; Kong preserves Host.
const forwardedHost = (request.headers.get("x-forwarded-host") ||
request.headers.get("host") || "").toLowerCase();
try {
const siteUrl = new URL(siteOrigin);
if (forwardedHost && forwardedHost !== siteUrl.host.toLowerCase()) {
const redirected = new URL("/auth/callback", siteOrigin);
searchParams.forEach((v, k) => redirected.searchParams.set(k, v));
return NextResponse.redirect(redirected.toString());
}
} catch {
/* siteOrigin malformed — fall through and try local verify */
}
const code = searchParams.get("code");
const tokenHash = searchParams.get("token_hash") || searchParams.get("token");
const typeRaw = searchParams.get("type");
const next = searchParams.get("next") ?? "/";
const supabase = await createClient();
// Path 1 — PKCE / OTP token (token + type)
if (tokenHash && typeRaw && VALID_OTP_TYPES.has(typeRaw as OtpType)) {
const { error } = await supabase.auth.verifyOtp({
type: typeRaw as OtpType,
token_hash: tokenHash,
});
if (error) {
return NextResponse.redirect(
`${siteOrigin}/auth/signin?error=${encodeURIComponent(error.message)}`,
);
}
return NextResponse.redirect(`${siteOrigin}${next}`);
}
// Path 2 — OAuth code exchange
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return NextResponse.redirect(
`${siteOrigin}/auth/signin?error=${encodeURIComponent(error.message)}`,
);
}
return NextResponse.redirect(`${siteOrigin}${next}`);
}
// Neither shape provided
return NextResponse.redirect(`${siteOrigin}/auth/signin?error=missing_token`);
}