diff --git a/src/app/api/feed/route.ts b/src/app/api/feed/route.ts new file mode 100644 index 0000000..8b81e80 --- /dev/null +++ b/src/app/api/feed/route.ts @@ -0,0 +1,21 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { clampLimit, getHomeFeedPage } from "@/lib/feed"; + +// GET /api/feed?cursor=&limit= +// Same-origin proxy for the Minnect home feed. Returns { items, nextCursor }. +// 401 { error: "sign_in" } when the session is missing or dead. + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const cursor = req.nextUrl.searchParams.get("cursor") || null; + const limit = clampLimit(req.nextUrl.searchParams.get("limit")); + + const result = await getHomeFeedPage(sessionId, cursor, limit); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ items: result.items, nextCursor: result.nextCursor }); +} diff --git a/src/lib/feed.ts b/src/lib/feed.ts new file mode 100644 index 0000000..a09ba0e --- /dev/null +++ b/src/lib/feed.ts @@ -0,0 +1,90 @@ +import { FEED_BASE_URL } from "./env"; +import { ensureFreshSession, refreshSession } from "./session-refresh"; + +// Home-feed proxy logic. Reads one page of the person's home feed from Minnect +// with their server-held feed token. Tokens never leave the server and are +// never logged. Card-field extraction is a later task; items pass through raw. + +export const DEFAULT_LIMIT = 20; +export const MAX_LIMIT = 50; + +export type FeedPageResult = + | { ok: true; items: unknown[]; nextCursor: string | null } + | { ok: false; status: 401 | 502; error: "sign_in" | "feed_unavailable" }; + +interface FeedOptions { + fetchImpl?: typeof fetch; + now?: number; +} + +/** Clamp a requested page size to 1..50, defaulting to 20. */ +export function clampLimit(raw: string | number | null | undefined): number { + const n = typeof raw === "number" ? raw : Number.parseInt(raw ?? "", 10); + if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT; + return Math.min(Math.floor(n), MAX_LIMIT); +} + +function buildUrl(cursor: string | null, limit: number): string { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set("cursor", cursor); + return `${FEED_BASE_URL}/api/v1/feeds/home/items?${params.toString()}`; +} + +async function callFeed( + token: string, + cursor: string | null, + limit: number, + doFetch: typeof fetch, +): Promise { + return doFetch(buildUrl(cursor, limit), { + method: "GET", + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + }); +} + +export async function getHomeFeedPage( + sessionId: string | undefined, + cursor: string | null, + limit: number, + options: FeedOptions = {}, +): Promise { + const signIn = { ok: false, status: 401, error: "sign_in" } as const; + const doFetch = options.fetchImpl ?? fetch; + + let session = await ensureFreshSession(sessionId, options); + if (!session || !sessionId) return signIn; + + let res: Response; + try { + res = await callFeed(session.feedAccessToken, cursor, limit, doFetch); + if (res.status === 401) { + // One refresh, then one retry. A second 401 means sign in again. + session = await refreshSession(sessionId, options); + if (!session) return signIn; + res = await callFeed(session.feedAccessToken, cursor, limit, doFetch); + if (res.status === 401) return signIn; + } + } catch { + return { ok: false, status: 502, error: "feed_unavailable" }; + } + if (res.status !== 200) { + return { ok: false, status: 502, error: "feed_unavailable" }; + } + + let body: unknown; + try { + body = await res.json(); + } catch { + return { ok: false, status: 502, error: "feed_unavailable" }; + } + const root = body && typeof body === "object" ? (body as Record) : {}; + const meta = + root.metadata && typeof root.metadata === "object" + ? (root.metadata as Record) + : {}; + return { + ok: true, + items: Array.isArray(root.data) ? root.data : [], + nextCursor: typeof meta.nextCursor === "string" && meta.nextCursor ? meta.nextCursor : null, + }; +} diff --git a/tests/feed-route.test.ts b/tests/feed-route.test.ts new file mode 100644 index 0000000..66dd5f5 --- /dev/null +++ b/tests/feed-route.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "@/app/api/feed/route"; +import { createSession } from "@/lib/session-store"; + +describe("GET /api/feed", () => { + it("no cookie -> 401 sign_in", async () => { + const res = await GET(new NextRequest("http://localhost/api/feed")); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "sign_in" }); + }); + + it("signed in: page then next page with cursor; limit capped at 50", async () => { + const sid = createSession({ + profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r", + feedAccessToken: "T", feedAccessTokenExpiresAt: null, + person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null }, createdAt: Date.now(), + }); + const urls: string[] = []; + const orig = globalThis.fetch; + globalThis.fetch = vi.fn(async (url: string) => { + urls.push(String(url)); + const next = urls.length === 1 ? "C1" : null; + return new Response(JSON.stringify({ data: [{ n: urls.length }], metadata: { nextCursor: next } }), { status: 200 }); + }) as unknown as typeof fetch; + try { + const h = { cookie: `mfb_session=${sid}` }; + const r1 = await GET(new NextRequest("http://localhost/api/feed", { headers: h })); + expect(await r1.json()).toEqual({ items: [{ n: 1 }], nextCursor: "C1" }); + const r2 = await GET(new NextRequest("http://localhost/api/feed?cursor=C1&limit=500", { headers: h })); + expect(await r2.json()).toEqual({ items: [{ n: 2 }], nextCursor: null }); + expect(new URL(urls[0]).searchParams.has("cursor")).toBe(false); + const u2 = new URL(urls[1]); + expect(u2.searchParams.get("cursor")).toBe("C1"); + expect(u2.searchParams.get("limit")).toBe("50"); + } finally { + globalThis.fetch = orig; + } + }); +}); diff --git a/tests/feed.test.ts b/tests/feed.test.ts new file mode 100644 index 0000000..4b47b3a --- /dev/null +++ b/tests/feed.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clampLimit, getHomeFeedPage } from "@/lib/feed"; +import { createSession } from "@/lib/session-store"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +let sid: string; +beforeEach(() => { + sid = createSession({ + profileAccessToken: "p", + profileAccessTokenExpiresAt: null, + profileRefreshToken: "r", + feedAccessToken: "FEEDTOK", + feedAccessTokenExpiresAt: null, + person: { userId: "u", name: "Ann", email: "a@b.co", pictureUrl: null }, + createdAt: Date.now(), + }); +}); + +describe("getHomeFeedPage", () => { + it("no session is sign_in and nothing is fetched", async () => { + const f = vi.fn(); + expect(await getHomeFeedPage(undefined, null, 20, { fetchImpl: f as unknown as typeof fetch })) + .toEqual({ ok: false, status: 401, error: "sign_in" }); + expect(f).not.toHaveBeenCalled(); + }); + + it("first page: items + nextCursor, no cursor upstream, bearer token, home path", async () => { + const f = vi.fn(async () => json({ data: [{ id: 1 }], metadata: { nextCursor: "C1" } })); + const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch }); + expect(r).toEqual({ ok: true, items: [{ id: 1 }], nextCursor: "C1" }); + const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit]; + const u = new URL(url); + expect(u.pathname).toBe("/api/v1/feeds/home/items"); + expect(u.searchParams.get("limit")).toBe("20"); + expect(u.searchParams.has("cursor")).toBe(false); + expect((init.headers as Record).Authorization).toBe("Bearer FEEDTOK"); + }); + + it("forwards the cursor on a later page; null nextCursor at the end", async () => { + const f = vi.fn(async () => json({ data: [], metadata: { nextCursor: null } })); + const r = await getHomeFeedPage(sid, "X", 20, { fetchImpl: f as unknown as typeof fetch }); + expect(r).toEqual({ ok: true, items: [], nextCursor: null }); + const u = new URL((f.mock.calls[0] as unknown as [string])[0]); + expect(u.searchParams.get("cursor")).toBe("X"); + }); + + it("feed 401 twice is sign_in", async () => { + const f = vi.fn(async (url: string) => + String(url).includes("/feeds/home/items") ? json({}, 401) : json({}, 500), + ); + const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch }); + expect(r).toEqual({ ok: false, status: 401, error: "sign_in" }); + }); + + it("upstream 500 is feed_unavailable", async () => { + const f = vi.fn(async () => json({}, 500)); + const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch }); + expect(r).toEqual({ ok: false, status: 502, error: "feed_unavailable" }); + }); +}); + +describe("clampLimit", () => { + it("defaults to 20, caps at 50", () => { + expect(clampLimit(null)).toBe(20); + expect(clampLimit("abc")).toBe(20); + expect(clampLimit("999")).toBe(50); + expect(clampLimit("10")).toBe(10); + expect(clampLimit("0")).toBe(20); + }); +});