Promote to main: Sign-in and Read the home feed
Validated in dev by Cesar. Includes server session + sign-in + refresh/sign-out (kdlb9zs9n, tlv84yb7n, yarm6mesq) and the home feed proxy + cards + video (emrgilhzp, q12xo172i, vycxexra8).
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# Minnect host base URLs. Copy this file to .env.local (gitignored) to override.
|
||||||
|
# Sane defaults are built in, so .env.local is optional.
|
||||||
|
|
||||||
|
# Profiles host: sign-in, auth/me, refresh, revoke, feed-token exchange.
|
||||||
|
PROFILES_BASE_URL=https://profiles.minnect.com
|
||||||
|
|
||||||
|
# Feed host: home feed, posts, likes, comments (used by later tasks).
|
||||||
|
FEED_BASE_URL=https://feed.sharedservices.vt.com
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
@@ -6,3 +6,7 @@ out/
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# typescript / next
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7006
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-helper",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hls.js": "1.7.3",
|
||||||
|
"next": "15.5.28",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.10.7",
|
||||||
|
"@types/react": "19.0.7",
|
||||||
|
"@types/react-dom": "19.0.3",
|
||||||
|
"eslint": "8.57.1",
|
||||||
|
"eslint-config-next": "15.5.28",
|
||||||
|
"typescript": "5.7.3",
|
||||||
|
"vitest": "2.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=<nextCursor>&limit=<n>
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { loginToMinnect, LoginError } from "@/lib/minnect";
|
||||||
|
import { createSession, getSession } from "@/lib/session-store";
|
||||||
|
import { endSession } from "@/lib/session-refresh";
|
||||||
|
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie";
|
||||||
|
|
||||||
|
// This route holds the session on the server. The browser talks only to this
|
||||||
|
// app; Minnect tokens never leave the server (ADR-001).
|
||||||
|
//
|
||||||
|
// POST signs in, GET says who the person is, DELETE signs out (revoke + clear).
|
||||||
|
// The reusable token-refresh logic lives in session-refresh.ts for the feed
|
||||||
|
// route (a later task) to call before it uses a token.
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function badRequest(message: string) {
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/session
|
||||||
|
* Body: { email, password }
|
||||||
|
* On success: sets the opaque session cookie, returns { name, email, pictureUrl }.
|
||||||
|
* On failure: no session, no cookie, one generic message.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||||
|
const email = typeof record.email === "string" ? record.email : "";
|
||||||
|
const password = typeof record.password === "string" ? record.password : "";
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return badRequest("Email and password are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { person, tokens } = await loginToMinnect(email, password);
|
||||||
|
|
||||||
|
const sessionId = createSession({
|
||||||
|
profileAccessToken: tokens.profileAccessToken,
|
||||||
|
profileAccessTokenExpiresAt: tokens.profileAccessTokenExpiresAt,
|
||||||
|
profileRefreshToken: tokens.profileRefreshToken,
|
||||||
|
feedAccessToken: tokens.feedAccessToken,
|
||||||
|
feedAccessTokenExpiresAt: tokens.feedAccessTokenExpiresAt,
|
||||||
|
person,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = NextResponse.json({
|
||||||
|
name: person.name,
|
||||||
|
email: person.email,
|
||||||
|
pictureUrl: person.pictureUrl,
|
||||||
|
});
|
||||||
|
res.cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions());
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof LoginError) {
|
||||||
|
return NextResponse.json({ error: err.publicMessage }, { status: err.status });
|
||||||
|
}
|
||||||
|
// Never echo an unexpected error to the browser.
|
||||||
|
return NextResponse.json({ error: "Could not sign you in." }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/session
|
||||||
|
* Returns { name, email, pictureUrl } for the signed-in person, or 401
|
||||||
|
* { error: "sign_in" } when the session is missing or dead.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
if (!sessionId) {
|
||||||
|
return NextResponse.json({ error: "sign_in" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "sign_in" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
name: session.person.name,
|
||||||
|
email: session.person.email,
|
||||||
|
pictureUrl: session.person.pictureUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/session
|
||||||
|
* Sign out: revoke the refresh token at Minnect (best-effort), delete the
|
||||||
|
* server session, and clear the cookie. Always returns 200: sign-out succeeds
|
||||||
|
* even when revoke errors. After this, the session is gone, so every
|
||||||
|
* token-requiring route (and GET /api/session) returns 401 { error: "sign_in" }.
|
||||||
|
*/
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
|
||||||
|
await endSession(sessionId);
|
||||||
|
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
// Expire the cookie with the same attributes it was set with.
|
||||||
|
res.cookies.set(SESSION_COOKIE, "", { ...sessionCookieOptions(), maxAge: 0 });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
:root { --red: #E8202A; --ink: #111; --grey: #6b6b6b; --line: #d9d9d9; --bg: #f5f5f5; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: Roboto, system-ui, sans-serif; color: var(--ink); background: var(--bg); }
|
||||||
|
.center { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
|
||||||
|
.card { width: 100%; max-width: 400px; background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 32px 24px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.card h1 { font-size: 22px; margin: 12px 0 8px; }
|
||||||
|
label { font-size: 14px; font-weight: 500; margin-top: 8px; }
|
||||||
|
input { font: inherit; padding: 12px; border: 1px solid #999; border-radius: 8px; background: #fff; color: var(--ink); }
|
||||||
|
input:focus-visible, button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
|
button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; border: 0; border-radius: 8px; background: var(--red); color: #fff; cursor: pointer; }
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--red); border-radius: 8px; color: #a8141b; background: #fff3f3; font-size: 14px; }
|
||||||
|
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #fff; border-bottom: 1px solid var(--line); }
|
||||||
|
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
|
||||||
|
.shell header span { color: var(--grey); }
|
||||||
|
.shell header { position: sticky; top: 0; z-index: 5; }
|
||||||
|
.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: #fff; color: var(--ink); border: 1px solid var(--ink); }
|
||||||
|
.feed { max-width: 640px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.post { background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.post-head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.avatar { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--line); flex: none; }
|
||||||
|
.avatar-initials { display: grid; place-items: center; background: var(--ink); color: #fff; font-weight: 500; font-size: 14px; }
|
||||||
|
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
|
||||||
|
.post-who time { color: var(--grey); font-size: 13px; }
|
||||||
|
.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
|
||||||
|
.post-media img { display: block; width: 100%; height: auto; border-radius: 8px; }
|
||||||
|
.post-video { background: #000; border-radius: 8px; overflow: hidden; }
|
||||||
|
.post-video video { display: block; width: 100%; max-height: 520px; background: #000; }
|
||||||
|
.post-stats { display: flex; gap: 16px; color: var(--grey); font-size: 14px; }
|
||||||
|
.heart { color: var(--red); }
|
||||||
|
.feed-message { text-align: center; color: var(--grey); padding: 32px 16px; }
|
||||||
|
.feed-message button { margin-top: 8px; }
|
||||||
|
.feed-center { display: grid; place-items: center; padding: 16px; }
|
||||||
|
.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--red); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
||||||
|
.sentinel { height: 1px; }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Roboto } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const roboto = Roboto({ subsets: ["latin"], weight: ["400", "500", "700"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Minnect feed browser",
|
||||||
|
description: "Sign in with your Minnect account to read and write the home feed.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={roboto.className}>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { App } from "@/components/App";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return <App />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { loadSession, signOut, type Person } from "@/lib/auth-client";
|
||||||
|
import { SignInForm } from "./SignInForm";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import { Feed } from "./Feed";
|
||||||
|
|
||||||
|
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [state, setState] = useState<State>({ kind: "loading" });
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
loadSession().then((person) => {
|
||||||
|
if (live) setState(person ? { kind: "in", person } : { kind: "out" });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
if (signingOut) return;
|
||||||
|
setSigningOut(true);
|
||||||
|
await signOut();
|
||||||
|
setSigningOut(false);
|
||||||
|
setState({ kind: "out" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === "loading") {
|
||||||
|
return (
|
||||||
|
<main className="center">
|
||||||
|
<p aria-live="polite">Loading...</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.kind === "out") {
|
||||||
|
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<header>
|
||||||
|
<Logo size={32} />
|
||||||
|
<div>
|
||||||
|
<strong>{state.person.name}</strong>
|
||||||
|
<span>{state.person.email}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={handleSignOut} disabled={signingOut}>
|
||||||
|
{signingOut ? "Signing out..." : "Sign out"}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<Feed onSignedOut={() => setState({ kind: "out" })} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
||||||
|
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
|
||||||
|
import { FeedView, type FeedStatus } from "./FeedView";
|
||||||
|
|
||||||
|
const POLL_MS = 45_000;
|
||||||
|
|
||||||
|
export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
|
||||||
|
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||||
|
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
|
||||||
|
const [pending, setPending] = useState<CardPost[]>([]);
|
||||||
|
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||||
|
const busy = useRef(false);
|
||||||
|
const shown = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
shown.current = new Set(posts.map((p) => p.key));
|
||||||
|
}, [posts]);
|
||||||
|
|
||||||
|
const fail = useCallback(
|
||||||
|
(e: unknown) => {
|
||||||
|
if (e instanceof FeedLoadError && e.signedOut) onSignedOut();
|
||||||
|
},
|
||||||
|
[onSignedOut],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadFirst = useCallback(async () => {
|
||||||
|
if (busy.current) return;
|
||||||
|
busy.current = true;
|
||||||
|
setStatus("loading");
|
||||||
|
setLoadMoreFailed(false);
|
||||||
|
try {
|
||||||
|
const page = await fetchFeedPage(null);
|
||||||
|
setPosts(mergePosts(toCardPosts(page.items)));
|
||||||
|
setNextCursor(page.nextCursor);
|
||||||
|
setPending([]);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
setStatus("error");
|
||||||
|
} finally {
|
||||||
|
busy.current = false;
|
||||||
|
}
|
||||||
|
}, [fail]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (busy.current || !nextCursor) return;
|
||||||
|
busy.current = true;
|
||||||
|
setLoadingMore(true);
|
||||||
|
setLoadMoreFailed(false);
|
||||||
|
try {
|
||||||
|
const page = await fetchFeedPage(nextCursor);
|
||||||
|
setPosts((cur) => mergePosts(cur, toCardPosts(page.items)));
|
||||||
|
setNextCursor(page.nextCursor);
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
setLoadMoreFailed(true);
|
||||||
|
} finally {
|
||||||
|
busy.current = false;
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [nextCursor, fail]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFirst();
|
||||||
|
}, [loadFirst]);
|
||||||
|
|
||||||
|
// Infinite scroll. A fresh observer per change re-checks a sentinel that is
|
||||||
|
// still in view after a page lands.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinel.current;
|
||||||
|
if (!el || !nextCursor || status !== "ready" || loadMoreFailed) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: "400px" },
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
|
||||||
|
|
||||||
|
// Real-time: poll the first page while the tab is visible.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "ready") return;
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const page = await fetchFeedPage(null);
|
||||||
|
const fresh = toCardPosts(page.items).filter((p) => !shown.current.has(p.key));
|
||||||
|
if (fresh.length) setPending((cur) => mergePosts(cur, fresh));
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const start = () => {
|
||||||
|
if (timer === null) timer = setInterval(() => void poll(), POLL_MS);
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
if (timer !== null) clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
void poll();
|
||||||
|
start();
|
||||||
|
} else {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (document.visibilityState === "visible") start();
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
|
}, [status, fail]);
|
||||||
|
|
||||||
|
function showNew() {
|
||||||
|
setPosts((cur) => mergePosts(cur, pending));
|
||||||
|
setPending([]);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeedView
|
||||||
|
posts={posts}
|
||||||
|
status={status}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
loadMoreFailed={loadMoreFailed}
|
||||||
|
newCount={pending.length}
|
||||||
|
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
|
||||||
|
onShowNew={showNew}
|
||||||
|
sentinelRef={sentinel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Ref } from "react";
|
||||||
|
import type { CardPost } from "@/lib/feed-model";
|
||||||
|
import { PostCard } from "./PostCard";
|
||||||
|
|
||||||
|
export type FeedStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export interface FeedViewProps {
|
||||||
|
posts: CardPost[];
|
||||||
|
status: FeedStatus;
|
||||||
|
loadingMore: boolean;
|
||||||
|
loadMoreFailed: boolean;
|
||||||
|
newCount: number;
|
||||||
|
onRetry: () => void;
|
||||||
|
onShowNew: () => void;
|
||||||
|
sentinelRef?: Ref<HTMLDivElement>;
|
||||||
|
now?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return <span className="spinner" role="status" aria-label="Loading" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedView(p: FeedViewProps) {
|
||||||
|
if (p.status === "loading") {
|
||||||
|
return (
|
||||||
|
<section className="feed">
|
||||||
|
<div className="feed-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (p.status === "error") {
|
||||||
|
return (
|
||||||
|
<section className="feed">
|
||||||
|
<div className="feed-message" role="alert">
|
||||||
|
<p>The feed did not load.</p>
|
||||||
|
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="feed">
|
||||||
|
{p.newCount > 0 ? (
|
||||||
|
<button type="button" className="new-posts" onClick={p.onShowNew}>
|
||||||
|
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{p.posts.length === 0 ? (
|
||||||
|
<p className="feed-message">No posts yet.</p>
|
||||||
|
) : (
|
||||||
|
p.posts.map((post) => <PostCard key={post.key} post={post} now={p.now} />)
|
||||||
|
)}
|
||||||
|
{p.loadMoreFailed ? (
|
||||||
|
<div className="feed-message" role="alert">
|
||||||
|
<p>The feed did not load.</p>
|
||||||
|
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{p.loadingMore ? (
|
||||||
|
<div className="feed-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function Logo({ size = 44 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 48 48" role="img" aria-label="Minnect">
|
||||||
|
<polygon points="24,2 43,13 43,35 24,46 5,35 5,13" fill="#E8202A" />
|
||||||
|
<path d="M14 33V15l10 11 10-11v18" fill="none" stroke="#fff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||||
|
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
|
||||||
|
|
||||||
|
export function PostCard({ post, now }: { post: CardPost; now?: number }) {
|
||||||
|
return (
|
||||||
|
<article className="post" data-testid="post-card">
|
||||||
|
<header className="post-head">
|
||||||
|
{post.authorPictureUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img className="avatar" src={post.authorPictureUrl} alt="" width={40} height={40} />
|
||||||
|
) : (
|
||||||
|
<span className="avatar avatar-initials" aria-hidden="true">
|
||||||
|
{initials(post.authorName)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="post-who">
|
||||||
|
<strong>{post.authorName}</strong>
|
||||||
|
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{post.text ? <p className="post-text">{post.text}</p> : null}
|
||||||
|
{post.media.map((m, i) =>
|
||||||
|
m.kind === "video" && m.hlsUrl ? (
|
||||||
|
<div className="post-media post-video" key={i}>
|
||||||
|
<VideoPlayer hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
|
||||||
|
</div>
|
||||||
|
) : m.src ? (
|
||||||
|
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={m.src} alt={m.kind === "video" ? "Video thumbnail" : ""} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
<footer className="post-stats">
|
||||||
|
<span aria-label={`${post.likesCount} likes`}>
|
||||||
|
<span className="heart" aria-hidden="true">♥</span> {post.likesCount}
|
||||||
|
</span>
|
||||||
|
<span aria-label={`${post.commentsCount} comments`}>{post.commentsCount} comments</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { signIn, type Person } from "@/lib/auth-client";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
|
export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await signIn(email.trim(), password);
|
||||||
|
setBusy(false);
|
||||||
|
if (result.ok) {
|
||||||
|
setPassword("");
|
||||||
|
onSignedIn(result.person);
|
||||||
|
} else {
|
||||||
|
setPassword("");
|
||||||
|
setError(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="center">
|
||||||
|
<form className="card" onSubmit={submit} noValidate>
|
||||||
|
<Logo />
|
||||||
|
<h1>Sign in to Minnect</h1>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={busy || !email || !password}>
|
||||||
|
{busy ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
||||||
|
|
||||||
|
export interface VideoPlayerProps {
|
||||||
|
hlsUrl: string;
|
||||||
|
poster: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real <video controls>. Native HLS where the browser has it, otherwise hls.js,
|
||||||
|
* loaded only when a video card mounts. Never autoplays.
|
||||||
|
*/
|
||||||
|
export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
||||||
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = ref.current;
|
||||||
|
if (!video) return;
|
||||||
|
let destroy: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
const native = supportsNativeHls(video);
|
||||||
|
if (native) {
|
||||||
|
if (choosePlayback(hlsUrl, { nativeHls: true, hlsJsSupported: false }) === "native") {
|
||||||
|
video.src = hlsUrl;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
import("hls.js")
|
||||||
|
.then(({ default: Hls }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const mode = choosePlayback(hlsUrl, { nativeHls: false, hlsJsSupported: Hls.isSupported() });
|
||||||
|
if (mode !== "hlsjs") {
|
||||||
|
setFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hls = new Hls();
|
||||||
|
hls.on(Hls.Events.ERROR, (_e, data) => {
|
||||||
|
if (data.fatal) setFailed(true);
|
||||||
|
});
|
||||||
|
hls.loadSource(hlsUrl);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
destroy = () => hls.destroy();
|
||||||
|
})
|
||||||
|
.catch(() => setFailed(true));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
destroy?.();
|
||||||
|
};
|
||||||
|
}, [hlsUrl]);
|
||||||
|
|
||||||
|
if (failed && poster) {
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return <img src={poster} alt="Video thumbnail" loading="lazy" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={ref}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
poster={poster ?? undefined}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Browser-side calls to this app's own /api/session. No token, password or
|
||||||
|
// session id is stored or logged here; the session lives in an httpOnly cookie.
|
||||||
|
|
||||||
|
export type Person = { name: string; email: string; pictureUrl: string | null };
|
||||||
|
|
||||||
|
export const WRONG_CREDENTIALS = "Email or password is incorrect.";
|
||||||
|
export const GENERIC_FAILURE = "Could not sign you in. Try again.";
|
||||||
|
|
||||||
|
type Fetch = typeof fetch;
|
||||||
|
|
||||||
|
function toPerson(body: unknown): Person | null {
|
||||||
|
if (!body || typeof body !== "object") return null;
|
||||||
|
const r = body as Record<string, unknown>;
|
||||||
|
if (typeof r.email !== "string") return null;
|
||||||
|
return {
|
||||||
|
name: typeof r.name === "string" ? r.name : "",
|
||||||
|
email: r.email,
|
||||||
|
pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/session: the person, or null when signed out or unreachable. */
|
||||||
|
export async function loadSession(f: Fetch = fetch): Promise<Person | null> {
|
||||||
|
try {
|
||||||
|
const res = await f("/api/session", { credentials: "same-origin", cache: "no-store" });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return toPerson(await res.json());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/session: sign out. Resolves once the server has cleared the
|
||||||
|
* session and cookie. A failed request still returns the UI to signed-out,
|
||||||
|
* because the local session view is no longer trustworthy.
|
||||||
|
*/
|
||||||
|
export async function signOut(f: Fetch = fetch): Promise<void> {
|
||||||
|
try {
|
||||||
|
await f("/api/session", { method: "DELETE", credentials: "same-origin", cache: "no-store" });
|
||||||
|
} catch {
|
||||||
|
// Ignore: the caller returns to the sign-in screen regardless.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignInResult = { ok: true; person: Person } | { ok: false; message: string };
|
||||||
|
|
||||||
|
/** POST /api/session. A 401 is always the one wrong-credentials message. */
|
||||||
|
export async function signIn(email: string, password: string, f: Fetch = fetch): Promise<SignInResult> {
|
||||||
|
try {
|
||||||
|
const res = await f("/api/session", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) return { ok: false, message: WRONG_CREDENTIALS };
|
||||||
|
if (!res.ok) return { ok: false, message: GENERIC_FAILURE };
|
||||||
|
const person = toPerson(await res.json());
|
||||||
|
return person ? { ok: true, person } : { ok: false, message: GENERIC_FAILURE };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: GENERIC_FAILURE };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// The single browser-facing cookie. It holds only an opaque session id,
|
||||||
|
// never a Minnect token (ADR-001). httpOnly keeps it out of JavaScript.
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = "mfb_session";
|
||||||
|
|
||||||
|
export interface SessionCookieOptions {
|
||||||
|
httpOnly: true;
|
||||||
|
secure: boolean;
|
||||||
|
sameSite: "lax";
|
||||||
|
path: "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieOptions(): SessionCookieOptions {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
// Secure is required in production (ADR-001). It is relaxed in local
|
||||||
|
// development so the cookie works over plain http on localhost.
|
||||||
|
secure: process.env.NODE_ENV !== "development",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Minnect host base URLs. Defaults match the build contract (docs spec, "Hosts").
|
||||||
|
// Override per environment in .env.local (gitignored). Never put tokens here.
|
||||||
|
|
||||||
|
function baseUrl(value: string | undefined, fallback: string): string {
|
||||||
|
const v = (value ?? "").trim();
|
||||||
|
return (v || fallback).replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROFILES_BASE_URL = baseUrl(
|
||||||
|
process.env.PROFILES_BASE_URL,
|
||||||
|
"https://profiles.minnect.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FEED_BASE_URL = baseUrl(
|
||||||
|
process.env.FEED_BASE_URL,
|
||||||
|
"https://feed.sharedservices.vt.com",
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Browser-side reader for the same-origin GET /api/feed route.
|
||||||
|
|
||||||
|
export interface FeedPage {
|
||||||
|
items: unknown[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FeedLoadError extends Error {
|
||||||
|
constructor(public readonly signedOut: boolean) {
|
||||||
|
super(signedOut ? "sign_in" : "feed_unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFeedPage(
|
||||||
|
cursor: string | null,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<FeedPage> {
|
||||||
|
const qs = new URLSearchParams({ limit: "20" });
|
||||||
|
if (cursor) qs.set("cursor", cursor);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetchImpl(`/api/feed?${qs.toString()}`, { credentials: "same-origin" });
|
||||||
|
} catch {
|
||||||
|
throw new FeedLoadError(false);
|
||||||
|
}
|
||||||
|
if (res.status === 401) throw new FeedLoadError(true);
|
||||||
|
if (!res.ok) throw new FeedLoadError(false);
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { items?: unknown; nextCursor?: unknown };
|
||||||
|
return {
|
||||||
|
items: Array.isArray(body.items) ? body.items : [],
|
||||||
|
nextCursor: typeof body.nextCursor === "string" && body.nextCursor ? body.nextCursor : null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new FeedLoadError(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Pure feed model: pick renderable posts out of raw feed items, normalise the
|
||||||
|
// card fields, and keep one list sorted strictly by publishedAt, newest first.
|
||||||
|
|
||||||
|
export interface CardMedia {
|
||||||
|
kind: "image" | "video";
|
||||||
|
/** Image source (images) or poster (videos). May be null for a bare video. */
|
||||||
|
src: string | null;
|
||||||
|
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
|
||||||
|
hlsUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardPost {
|
||||||
|
key: string;
|
||||||
|
id: string | null;
|
||||||
|
authorName: string;
|
||||||
|
authorPictureUrl: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
time: number;
|
||||||
|
text: string;
|
||||||
|
likesCount: number;
|
||||||
|
commentsCount: number;
|
||||||
|
media: CardMedia[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isObj(v: unknown): v is Obj {
|
||||||
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
function str(v: unknown): string | null {
|
||||||
|
return typeof v === "string" && v.trim() ? v : null;
|
||||||
|
}
|
||||||
|
function num(v: unknown): number {
|
||||||
|
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "?";
|
||||||
|
const first = parts[0][0] ?? "";
|
||||||
|
const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? "") : "";
|
||||||
|
return (first + last).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorName(author: Obj): string {
|
||||||
|
const display = str(author.displayName);
|
||||||
|
if (display) return display.trim();
|
||||||
|
const full = [str(author.firstName), str(author.lastName)].filter(Boolean).join(" ").trim();
|
||||||
|
return full || "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The HLS URL only when it is https on stream.mux.com. */
|
||||||
|
export function muxHlsUrl(v: unknown): string | null {
|
||||||
|
const s = str(v);
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return u.protocol === "https:" && u.hostname === "stream.mux.com" ? s : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMedia(raw: unknown): CardMedia[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const items = raw
|
||||||
|
.filter(isObj)
|
||||||
|
.map((m, i) => ({ m, i, order: typeof m.order === "number" ? m.order : i }))
|
||||||
|
.sort((a, b) => a.order - b.order || a.i - b.i);
|
||||||
|
const out: CardMedia[] = [];
|
||||||
|
for (const { m } of items) {
|
||||||
|
if (m.mediaType === "video") {
|
||||||
|
const thumb = str(m.thumbnailUrl);
|
||||||
|
const hls = muxHlsUrl(m.hlsUrl);
|
||||||
|
if (thumb || hls) out.push({ kind: "video", src: thumb, hlsUrl: hls });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
||||||
|
if (src) out.push({ kind: "image", src });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card for one raw feed item, or null when it is not a post. */
|
||||||
|
export function toCardPost(item: unknown, index = 0): CardPost | null {
|
||||||
|
if (!isObj(item) || item.type !== "post" || !isObj(item.post)) return null;
|
||||||
|
const post = item.post;
|
||||||
|
const author = isObj(post.author) ? post.author : {};
|
||||||
|
const publishedAt = str(item.publishedAt);
|
||||||
|
const t = publishedAt ? Date.parse(publishedAt) : NaN;
|
||||||
|
const id =
|
||||||
|
typeof post.id === "string" || typeof post.id === "number" ? String(post.id) : null;
|
||||||
|
return {
|
||||||
|
key: id ?? `${publishedAt ?? "unknown"}#${index}`,
|
||||||
|
id,
|
||||||
|
authorName: authorName(author),
|
||||||
|
authorPictureUrl: str(author.profilePictureUrl),
|
||||||
|
publishedAt,
|
||||||
|
time: Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY,
|
||||||
|
text: typeof post.content === "string" ? post.content : "",
|
||||||
|
likesCount: num(item.likesCount ?? post.likesCount),
|
||||||
|
commentsCount: num(item.commentsCount ?? post.commentsCount),
|
||||||
|
media: toMedia(post.mediaItems),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCardPosts(items: unknown[]): CardPost[] {
|
||||||
|
const out: CardPost[] = [];
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
const p = toCardPost(item, i);
|
||||||
|
if (p) out.push(p);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest first by publishedAt. Ties keep their given order. */
|
||||||
|
export function sortByPublishedDesc(posts: CardPost[]): CardPost[] {
|
||||||
|
return posts
|
||||||
|
.map((p, i) => ({ p, i }))
|
||||||
|
.sort((a, b) => (a.p.time === b.p.time ? a.i - b.i : b.p.time > a.p.time ? 1 : -1))
|
||||||
|
.map((x) => x.p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge lists, drop duplicates (first wins), and sort newest first. */
|
||||||
|
export function mergePosts(...lists: CardPost[][]): CardPost[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const all: CardPost[] = [];
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const p of list) {
|
||||||
|
if (seen.has(p.key)) continue;
|
||||||
|
seen.add(p.key);
|
||||||
|
all.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortByPublishedDesc(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
|
||||||
|
const t = publishedAt ? Date.parse(publishedAt) : NaN;
|
||||||
|
if (!Number.isFinite(t)) return "";
|
||||||
|
const s = Math.max(0, Math.floor((now - t) / 1000));
|
||||||
|
if (s < 60) return "just now";
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 7) return `${d}d ago`;
|
||||||
|
return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
@@ -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<Response> {
|
||||||
|
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<FeedPageResult> {
|
||||||
|
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<string, unknown>) : {};
|
||||||
|
const meta =
|
||||||
|
root.metadata && typeof root.metadata === "object"
|
||||||
|
? (root.metadata as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
items: Array.isArray(root.data) ? root.data : [],
|
||||||
|
nextCursor: typeof meta.nextCursor === "string" && meta.nextCursor ? meta.nextCursor : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { PROFILES_BASE_URL } from "./env";
|
||||||
|
import type { SessionPerson } from "./session-store";
|
||||||
|
|
||||||
|
// Talks to the Minnect profiles host to turn an email + password into the
|
||||||
|
// tokens a session needs. Field-order extraction follows the build contract
|
||||||
|
// (docs spec, "Login"). Never logs or echoes the Minnect error body.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A login failure with a message that is safe to show the browser.
|
||||||
|
* The raw Minnect error is never attached, so an unknown email and a wrong
|
||||||
|
* password are indistinguishable to the client (spec requirement).
|
||||||
|
*/
|
||||||
|
export class LoginError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly publicMessage: string;
|
||||||
|
|
||||||
|
constructor(status: number, publicMessage: string) {
|
||||||
|
super(publicMessage);
|
||||||
|
this.name = "LoginError";
|
||||||
|
this.status = status;
|
||||||
|
this.publicMessage = publicMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC_SIGN_IN_ERROR = "Email or password is incorrect.";
|
||||||
|
const FEED_ERROR = "Could not open the feed.";
|
||||||
|
|
||||||
|
export interface LoginTokens {
|
||||||
|
profileAccessToken: string;
|
||||||
|
profileAccessTokenExpiresAt: number | null;
|
||||||
|
profileRefreshToken: string;
|
||||||
|
feedAccessToken: string;
|
||||||
|
feedAccessTokenExpiresAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Profile tokens from a fresh login or a refresh, with the access-token expiry resolved to an epoch. */
|
||||||
|
export interface ProfileTokens {
|
||||||
|
profileAccessToken: string;
|
||||||
|
profileAccessTokenExpiresAt: number | null;
|
||||||
|
profileRefreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The feed access token and its expiry (null when the body does not carry one). */
|
||||||
|
export interface FeedToken {
|
||||||
|
feedAccessToken: string;
|
||||||
|
feedAccessTokenExpiresAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
person: SessionPerson;
|
||||||
|
tokens: LoginTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginOptions {
|
||||||
|
/** Injectable fetch, for testing. Defaults to the global fetch. */
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonBody = Record<string, unknown> | null;
|
||||||
|
|
||||||
|
function jsonHeaders(token?: string): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJson(res: Response): Promise<JsonBody> {
|
||||||
|
try {
|
||||||
|
return (await res.json()) as JsonBody;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object"
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile access/refresh tokens, in the spec's documented field order:
|
||||||
|
// 1. data.accessToken / data.refreshToken
|
||||||
|
// 2. accessToken / refreshToken
|
||||||
|
// expiresIn (seconds) from data.expiresIn, then expiresIn.
|
||||||
|
function readProfileTokens(body: JsonBody): {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
} {
|
||||||
|
const root = asRecord(body);
|
||||||
|
const data = asRecord(root.data);
|
||||||
|
const accessToken = asString(data.accessToken) ?? asString(root.accessToken);
|
||||||
|
const refreshToken =
|
||||||
|
asString(data.refreshToken) ?? asString(root.refreshToken);
|
||||||
|
const rawExpires = data.expiresIn ?? root.expiresIn;
|
||||||
|
const expiresIn = typeof rawExpires === "number" ? rawExpires : undefined;
|
||||||
|
return { accessToken, refreshToken, expiresIn };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed access token, in the spec's documented field order:
|
||||||
|
// 1. accessToken 2. data.accessToken 3. token 4. data.token
|
||||||
|
function readFeedToken(body: JsonBody): string | undefined {
|
||||||
|
const root = asRecord(body);
|
||||||
|
const data = asRecord(root.data);
|
||||||
|
return (
|
||||||
|
asString(root.accessToken) ??
|
||||||
|
asString(data.accessToken) ??
|
||||||
|
asString(root.token) ??
|
||||||
|
asString(data.token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiresIn (seconds) -> absolute epoch ms, or null when it is not a number.
|
||||||
|
function expiresAtFrom(expiresIn: unknown, now: number): number | null {
|
||||||
|
return typeof expiresIn === "number" ? now + expiresIn * 1000 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange a profile access token for a feed token.
|
||||||
|
* `POST /api/v4/auth/external-services/feed` with body `{}`.
|
||||||
|
* Used by sign-in and by refresh. Throws LoginError(FEED_ERROR) on failure.
|
||||||
|
*/
|
||||||
|
export async function exchangeFeedToken(
|
||||||
|
profileAccessToken: string,
|
||||||
|
options: LoginOptions = {},
|
||||||
|
): Promise<FeedToken> {
|
||||||
|
const doFetch = options.fetchImpl ?? fetch;
|
||||||
|
const res = await doFetch(
|
||||||
|
`${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: jsonHeaders(profileAccessToken),
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new LoginError(502, FEED_ERROR);
|
||||||
|
}
|
||||||
|
const body = await parseJson(res);
|
||||||
|
const feedAccessToken = readFeedToken(body);
|
||||||
|
if (!feedAccessToken) {
|
||||||
|
throw new LoginError(502, FEED_ERROR);
|
||||||
|
}
|
||||||
|
// Feed-token expiry is read opportunistically: the spec does not require the
|
||||||
|
// body to carry it. When it is absent, the feed-call-returns-401 path (the
|
||||||
|
// feed route, a later task) is the backstop for a stale feed token.
|
||||||
|
const root = asRecord(body);
|
||||||
|
const rawExpires = asRecord(root.data).expiresIn ?? root.expiresIn;
|
||||||
|
return {
|
||||||
|
feedAccessToken,
|
||||||
|
feedAccessTokenExpiresAt: expiresAtFrom(rawExpires, Date.now()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange a profile refresh token for a new profile access + refresh token.
|
||||||
|
* `POST /api/v4/auth/refresh-token` with `{ refreshToken }`. The new tokens are
|
||||||
|
* read with the same field order as sign-in. No password is involved. Throws a
|
||||||
|
* plain Error on any failure; the caller treats that as "refresh failed".
|
||||||
|
*/
|
||||||
|
export async function refreshProfileTokens(
|
||||||
|
refreshToken: string,
|
||||||
|
options: LoginOptions = {},
|
||||||
|
): Promise<ProfileTokens> {
|
||||||
|
const doFetch = options.fetchImpl ?? fetch;
|
||||||
|
const res = await doFetch(
|
||||||
|
`${PROFILES_BASE_URL}/api/v4/auth/refresh-token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: jsonHeaders(),
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error("Profile token refresh failed.");
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
expiresIn,
|
||||||
|
} = readProfileTokens(await parseJson(res));
|
||||||
|
if (!accessToken || !newRefreshToken) {
|
||||||
|
throw new Error("Profile token refresh returned no tokens.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profileAccessToken: accessToken,
|
||||||
|
profileAccessTokenExpiresAt: expiresAtFrom(expiresIn, Date.now()),
|
||||||
|
profileRefreshToken: newRefreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a profile refresh token on sign-out.
|
||||||
|
* `POST /api/v4/auth/revoke-token` with `{ refreshToken }`. Best-effort: any
|
||||||
|
* error (network or non-200) is swallowed so that sign-out still succeeds.
|
||||||
|
*/
|
||||||
|
export async function revokeToken(
|
||||||
|
refreshToken: string,
|
||||||
|
options: LoginOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const doFetch = options.fetchImpl ?? fetch;
|
||||||
|
try {
|
||||||
|
await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/revoke-token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: jsonHeaders(),
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Sign-out still succeeds if revoke fails (spec, "Sign out").
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the three-call sign-in: login -> auth/me -> feed-token exchange.
|
||||||
|
* Returns the person to show and the tokens to store server-side.
|
||||||
|
* Throws LoginError on any failure; it never creates a session itself.
|
||||||
|
*/
|
||||||
|
export async function loginToMinnect(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
options: LoginOptions = {},
|
||||||
|
): Promise<LoginResult> {
|
||||||
|
const doFetch = options.fetchImpl ?? fetch;
|
||||||
|
|
||||||
|
// 1. Sign in.
|
||||||
|
const loginRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: jsonHeaders(),
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (loginRes.status !== 200) {
|
||||||
|
// Wrong password or unknown email both land here with one generic message.
|
||||||
|
throw new LoginError(401, GENERIC_SIGN_IN_ERROR);
|
||||||
|
}
|
||||||
|
const loginBody = await parseJson(loginRes);
|
||||||
|
const {
|
||||||
|
accessToken: profileAccessToken,
|
||||||
|
refreshToken: profileRefreshToken,
|
||||||
|
expiresIn,
|
||||||
|
} = readProfileTokens(loginBody);
|
||||||
|
if (!profileAccessToken || !profileRefreshToken) {
|
||||||
|
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
||||||
|
}
|
||||||
|
const profileAccessTokenExpiresAt = expiresAtFrom(expiresIn, Date.now());
|
||||||
|
|
||||||
|
// 2. Who the person is.
|
||||||
|
const meRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/me`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: jsonHeaders(profileAccessToken),
|
||||||
|
});
|
||||||
|
if (meRes.status !== 200) {
|
||||||
|
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
||||||
|
}
|
||||||
|
const meBody = await parseJson(meRes);
|
||||||
|
const user = asRecord(asRecord(meBody).data);
|
||||||
|
const userId = asString(user.userId);
|
||||||
|
const personEmail = asString(user.email);
|
||||||
|
if (!userId || !personEmail) {
|
||||||
|
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
||||||
|
}
|
||||||
|
const name =
|
||||||
|
[user.firstName, user.lastName]
|
||||||
|
.map((p) => (typeof p === "string" ? p.trim() : ""))
|
||||||
|
.filter((p) => p.length > 0)
|
||||||
|
.join(" ") || personEmail;
|
||||||
|
const pictureUrl = asString(user.profilePictureUrl) ?? null;
|
||||||
|
|
||||||
|
// 3. Feed token exchange.
|
||||||
|
const { feedAccessToken, feedAccessTokenExpiresAt } = await exchangeFeedToken(
|
||||||
|
profileAccessToken,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
person: { userId, name, email: personEmail, pictureUrl },
|
||||||
|
tokens: {
|
||||||
|
profileAccessToken,
|
||||||
|
profileAccessTokenExpiresAt,
|
||||||
|
profileRefreshToken,
|
||||||
|
feedAccessToken,
|
||||||
|
feedAccessTokenExpiresAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
deleteSession,
|
||||||
|
getSession,
|
||||||
|
updateSession,
|
||||||
|
type SessionData,
|
||||||
|
} from "./session-store";
|
||||||
|
import { exchangeFeedToken, refreshProfileTokens, revokeToken } from "./minnect";
|
||||||
|
|
||||||
|
// Reusable server-side session logic the route handlers share. The feed route
|
||||||
|
// (a later task) calls `ensureFreshSession` before using a token, and
|
||||||
|
// `refreshSession` once after a feed call returns 401. Sign-out uses
|
||||||
|
// `endSession`. Minnect tokens never leave the server; nothing here is logged.
|
||||||
|
|
||||||
|
/** Refresh when a token expires within this window (spec: two minutes). */
|
||||||
|
export const REFRESH_WINDOW_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
interface RefreshOptions {
|
||||||
|
/** Injectable fetch, for testing. Defaults to the global fetch. */
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
/** Injectable clock for the expiry decision, for testing. Defaults to Date.now(). */
|
||||||
|
now?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the expiry is known and falls within the refresh window. */
|
||||||
|
function expiresSoon(expiresAt: number | null, now: number): boolean {
|
||||||
|
return expiresAt !== null && expiresAt - now <= REFRESH_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the profile and feed tokens of a session and persist them, keeping the
|
||||||
|
* same session id (and the same person). Uses the stored profile refresh token,
|
||||||
|
* never a password. On any failure the session is deleted and `null` is
|
||||||
|
* returned, so the browser is sent back to the sign-in screen.
|
||||||
|
*/
|
||||||
|
export async function refreshSession(
|
||||||
|
sessionId: string,
|
||||||
|
options: RefreshOptions = {},
|
||||||
|
): Promise<SessionData | null> {
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await refreshProfileTokens(
|
||||||
|
session.profileRefreshToken,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
const feed = await exchangeFeedToken(profile.profileAccessToken, options);
|
||||||
|
|
||||||
|
const updated: SessionData = {
|
||||||
|
...session,
|
||||||
|
profileAccessToken: profile.profileAccessToken,
|
||||||
|
profileAccessTokenExpiresAt: profile.profileAccessTokenExpiresAt,
|
||||||
|
profileRefreshToken: profile.profileRefreshToken,
|
||||||
|
feedAccessToken: feed.feedAccessToken,
|
||||||
|
feedAccessTokenExpiresAt: feed.feedAccessTokenExpiresAt,
|
||||||
|
};
|
||||||
|
updateSession(sessionId, updated);
|
||||||
|
return updated;
|
||||||
|
} catch {
|
||||||
|
// Refresh failed: drop the session. The caller returns 401 { error: "sign_in" }.
|
||||||
|
deleteSession(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a session and, when its profile or feed access token is within the
|
||||||
|
* refresh window, refresh it first. Returns the usable session, or `null` when
|
||||||
|
* there is no session or the refresh failed. Call this before using a token.
|
||||||
|
*/
|
||||||
|
export async function ensureFreshSession(
|
||||||
|
sessionId: string | undefined,
|
||||||
|
options: RefreshOptions = {},
|
||||||
|
): Promise<SessionData | null> {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const now = options.now ?? Date.now();
|
||||||
|
if (
|
||||||
|
expiresSoon(session.profileAccessTokenExpiresAt, now) ||
|
||||||
|
expiresSoon(session.feedAccessTokenExpiresAt, now)
|
||||||
|
) {
|
||||||
|
return refreshSession(sessionId, options);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-out. Revoke the refresh token (best-effort) and delete the server
|
||||||
|
* session. Always resolves: a revoke error does not stop sign-out. The caller
|
||||||
|
* clears the cookie.
|
||||||
|
*/
|
||||||
|
export async function endSession(
|
||||||
|
sessionId: string | undefined,
|
||||||
|
options: Pick<RefreshOptions, "fetchImpl"> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
if (!sessionId) return;
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (session) {
|
||||||
|
await revokeToken(session.profileRefreshToken, options);
|
||||||
|
}
|
||||||
|
deleteSession(sessionId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
// Server-side session store. The browser never sees any of this; it only holds
|
||||||
|
// the opaque id (see cookie.ts). Keyed by a random 256-bit session id.
|
||||||
|
//
|
||||||
|
// NOTE: this is in-memory and per-process. It is fine for now but does not
|
||||||
|
// survive a restart and is not shared across instances. A durable store
|
||||||
|
// (e.g. Redis) is a later concern, tracked outside this task.
|
||||||
|
|
||||||
|
export interface SessionPerson {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
pictureUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
profileAccessToken: string;
|
||||||
|
/** Epoch milliseconds when the profile access token expires, or null if unknown. */
|
||||||
|
profileAccessTokenExpiresAt: number | null;
|
||||||
|
profileRefreshToken: string;
|
||||||
|
feedAccessToken: string;
|
||||||
|
/** Epoch milliseconds when the feed access token expires, or null if unknown. */
|
||||||
|
feedAccessTokenExpiresAt: number | null;
|
||||||
|
person: SessionPerson;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Survive Next.js dev HMR by stashing the map on globalThis.
|
||||||
|
const globalRef = globalThis as typeof globalThis & {
|
||||||
|
__mfbSessionStore?: Map<string, SessionData>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const store: Map<string, SessionData> =
|
||||||
|
globalRef.__mfbSessionStore ?? new Map<string, SessionData>();
|
||||||
|
globalRef.__mfbSessionStore = store;
|
||||||
|
|
||||||
|
export function createSession(data: SessionData): string {
|
||||||
|
const id = randomBytes(32).toString("hex");
|
||||||
|
store.set(id, data);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(id: string): SessionData | null {
|
||||||
|
return store.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the data for an existing session. Used after a token refresh to
|
||||||
|
* persist the new tokens. A no-op when the session is already gone, so a
|
||||||
|
* refresh cannot resurrect a signed-out session.
|
||||||
|
*/
|
||||||
|
export function updateSession(id: string, data: SessionData): void {
|
||||||
|
if (store.has(id)) store.set(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(id: string): void {
|
||||||
|
store.delete(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Chooses how a feed video plays. Pure, so it is unit-testable without a browser.
|
||||||
|
|
||||||
|
export type Playback = "none" | "native" | "hlsjs";
|
||||||
|
|
||||||
|
export interface PlaybackEnv {
|
||||||
|
/** True when the browser can play .m3u8 directly (Safari). */
|
||||||
|
nativeHls: boolean;
|
||||||
|
/** True when hls.js (Media Source Extensions) is supported. */
|
||||||
|
hlsJsSupported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No hlsUrl: thumbnail only. Native HLS wins; hls.js is the fallback. */
|
||||||
|
export function choosePlayback(hlsUrl: string | null | undefined, env: PlaybackEnv): Playback {
|
||||||
|
if (!hlsUrl) return "none";
|
||||||
|
if (env.nativeHls) return "native";
|
||||||
|
return env.hlsJsSupported ? "hlsjs" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsNativeHls(video: Pick<HTMLVideoElement, "canPlayType">): boolean {
|
||||||
|
return video.canPlayType("application/vnd.apple.mpegurl") !== "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { loadSession, signIn, WRONG_CREDENTIALS } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||||
|
|
||||||
|
describe("signIn", () => {
|
||||||
|
it("wrong password gives the one message", async () => {
|
||||||
|
const f = vi.fn(async () => json({ error: "Email or password is incorrect." }, 401));
|
||||||
|
expect(await signIn("a@b.co", "bad", f as unknown as typeof fetch)).toEqual({ ok: false, message: WRONG_CREDENTIALS });
|
||||||
|
});
|
||||||
|
it("success returns the person and posts JSON to /api/session", async () => {
|
||||||
|
const f = vi.fn(async () => json({ name: "Ann", email: "a@b.co", pictureUrl: null }));
|
||||||
|
const r = await signIn("a@b.co", "pw", f as unknown as typeof fetch);
|
||||||
|
expect(r).toEqual({ ok: true, person: { name: "Ann", email: "a@b.co", pictureUrl: null } });
|
||||||
|
expect(f.mock.calls[0]).toMatchObject(["/api/session", { method: "POST" }]);
|
||||||
|
});
|
||||||
|
it("network failure is not the wrong-password message", async () => {
|
||||||
|
const f = vi.fn(async () => { throw new Error("down"); });
|
||||||
|
const r = await signIn("a@b.co", "pw", f as unknown as typeof fetch);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.message).not.toBe(WRONG_CREDENTIALS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadSession", () => {
|
||||||
|
it("401 means signed out", async () => {
|
||||||
|
const f = vi.fn(async () => json({ error: "sign_in" }, 401));
|
||||||
|
expect(await loadSession(f as unknown as typeof fetch)).toBeNull();
|
||||||
|
});
|
||||||
|
it("200 returns the person", async () => {
|
||||||
|
const f = vi.fn(async () => json({ name: "Ann", email: "a@b.co", pictureUrl: "u" }));
|
||||||
|
expect((await loadSession(f as unknown as typeof fetch))?.email).toBe("a@b.co");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
||||||
|
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
||||||
|
import { mergePosts, relativeTime, toCardPosts } from "@/lib/feed-model";
|
||||||
|
|
||||||
|
const NOW = Date.parse("2026-10-19T12:00:00Z");
|
||||||
|
const post = (id: string, publishedAt: string, extra: Record<string, unknown> = {}) => ({
|
||||||
|
type: "post",
|
||||||
|
publishedAt,
|
||||||
|
likesCount: 3,
|
||||||
|
commentsCount: 2,
|
||||||
|
post: {
|
||||||
|
id,
|
||||||
|
content: `text ${id}\nsecond line`,
|
||||||
|
author: { id: "a", displayName: `Author ${id}`, profilePictureUrl: "https://img/a.jpg" },
|
||||||
|
mediaItems: [],
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(over: Partial<FeedViewProps>) {
|
||||||
|
const props: FeedViewProps = {
|
||||||
|
posts: [], status: "ready", loadingMore: false, loadMoreFailed: false,
|
||||||
|
newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, ...over,
|
||||||
|
};
|
||||||
|
return renderToStaticMarkup(createElement(FeedView, props));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("feed model", () => {
|
||||||
|
it("skips non-post items and posts without a post object", () => {
|
||||||
|
const items = [
|
||||||
|
{ type: "trending-conversations", conversations: [] },
|
||||||
|
{ type: "post", publishedAt: "2026-10-19T11:00:00Z" },
|
||||||
|
post("1", "2026-10-19T11:00:00Z"),
|
||||||
|
];
|
||||||
|
expect(toCardPosts(items).map((p) => p.id)).toEqual(["1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts strictly by publishedAt descending, interleaving own posts", () => {
|
||||||
|
// API returns own posts first; they must land in time order.
|
||||||
|
const items = [
|
||||||
|
post("own-old", "2026-10-19T08:00:00Z"),
|
||||||
|
post("own-new", "2026-10-19T11:30:00Z"),
|
||||||
|
post("other-mid", "2026-10-19T10:00:00Z"),
|
||||||
|
post("other-new", "2026-10-19T11:45:00Z"),
|
||||||
|
];
|
||||||
|
expect(mergePosts(toCardPosts(items)).map((p) => p.id)).toEqual([
|
||||||
|
"other-new", "own-new", "other-mid", "own-old",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes and re-sorts across pages", () => {
|
||||||
|
const a = toCardPosts([post("1", "2026-10-19T10:00:00Z")]);
|
||||||
|
const b = toCardPosts([post("1", "2026-10-19T10:00:00Z"), post("2", "2026-10-19T11:00:00Z")]);
|
||||||
|
expect(mergePosts(a, b).map((p) => p.id)).toEqual(["2", "1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to first+last name and picks the image url in order", () => {
|
||||||
|
const [p] = toCardPosts([
|
||||||
|
post("1", "2026-10-19T10:00:00Z", {
|
||||||
|
author: { firstName: "Ada", lastName: "Lovelace" },
|
||||||
|
mediaItems: [
|
||||||
|
{ order: 2, mediaType: "image", imageUrl: "https://i/2.jpg" },
|
||||||
|
{ order: 1, mediaType: "image", imageMediumUrl: "https://i/1m.jpg", imageUrl: "https://i/1.jpg" },
|
||||||
|
{ order: 3, mediaType: "image", imageMediumWebpUrl: "https://i/3.webp", imageUrl: "https://i/3.jpg" },
|
||||||
|
{ order: 4, mediaType: "video", thumbnailUrl: "https://i/v.jpg", hlsUrl: "https://stream.mux.com/x" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(p.authorName).toBe("Ada Lovelace");
|
||||||
|
expect(p.media.map((m) => m.src)).toEqual([
|
||||||
|
"https://i/1m.jpg", "https://i/2.jpg", "https://i/3.webp", "https://i/v.jpg",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats relative time", () => {
|
||||||
|
expect(relativeTime("2026-10-19T11:55:00Z", NOW)).toBe("5m ago");
|
||||||
|
expect(relativeTime("2026-10-19T09:00:00Z", NOW)).toBe("3h ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedView", () => {
|
||||||
|
it("renders cards with full multi-line text, counts and avatar", () => {
|
||||||
|
const long = "x".repeat(2000);
|
||||||
|
const posts = toCardPosts([
|
||||||
|
post("1", "2026-10-19T11:00:00Z", { content: `line one\nline two ${long}` }),
|
||||||
|
post("2", "2026-10-19T10:00:00Z", { author: { displayName: "No Pic" } }),
|
||||||
|
]);
|
||||||
|
const html = render({ posts });
|
||||||
|
expect((html.match(/data-testid="post-card"/g) ?? []).length).toBe(2);
|
||||||
|
expect(html).toContain("line one\nline two " + long);
|
||||||
|
expect(html).toContain('src="https://img/a.jpg"');
|
||||||
|
expect(html).toContain("NP"); // initials tile
|
||||||
|
expect(html).toContain("1h ago");
|
||||||
|
expect(html).toContain("3 likes");
|
||||||
|
expect(html).toContain("2 comments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render a non-post item", () => {
|
||||||
|
const posts = toCardPosts([{ type: "trending-conversations" }, post("1", "2026-10-19T11:00:00Z")]);
|
||||||
|
const html = render({ posts });
|
||||||
|
expect((html.match(/data-testid="post-card"/g) ?? []).length).toBe(1);
|
||||||
|
expect(html).not.toContain("trending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty state", () => {
|
||||||
|
expect(render({ posts: [] })).toContain("No posts yet.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error state with retry", () => {
|
||||||
|
const html = render({ status: "error" });
|
||||||
|
expect(html).toContain("The feed did not load.");
|
||||||
|
expect(html).toContain("Try again");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spinner while loading more and a new-posts control", () => {
|
||||||
|
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
|
||||||
|
expect(html).toContain('class="spinner"');
|
||||||
|
expect(html).toContain("2 new posts");
|
||||||
|
expect(html).not.toContain("Load more");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchFeedPage", () => {
|
||||||
|
it("returns items + nextCursor and sends the cursor", async () => {
|
||||||
|
const f = vi.fn(async () => new Response(JSON.stringify({ items: [1], nextCursor: "C" }), { status: 200 }));
|
||||||
|
const page = await fetchFeedPage("X", f as unknown as typeof fetch);
|
||||||
|
expect(page).toEqual({ items: [1], nextCursor: "C" });
|
||||||
|
expect(String((f.mock.calls[0] as unknown[])[0])).toContain("cursor=X");
|
||||||
|
});
|
||||||
|
it("401 -> signedOut, 500 -> not", async () => {
|
||||||
|
const mk = (s: number) => (async () => new Response("{}", { status: s })) as unknown as typeof fetch;
|
||||||
|
await expect(fetchFeedPage(null, mk(401))).rejects.toMatchObject({ signedOut: true });
|
||||||
|
await expect(fetchFeedPage(null, mk(500))).rejects.toBeInstanceOf(FeedLoadError);
|
||||||
|
await expect(fetchFeedPage(null, mk(500))).rejects.toMatchObject({ signedOut: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
endSession,
|
||||||
|
ensureFreshSession,
|
||||||
|
refreshSession,
|
||||||
|
} from "@/lib/session-refresh";
|
||||||
|
import { createSession, getSession, type SessionData } from "@/lib/session-store";
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seeds a session whose profile access token expires at `profileExpiresAt`
|
||||||
|
// (and feed at `feedExpiresAt`), with known "old" tokens.
|
||||||
|
function seedSession(
|
||||||
|
profileExpiresAt: number | null,
|
||||||
|
feedExpiresAt: number | null = null,
|
||||||
|
): string {
|
||||||
|
const data: SessionData = {
|
||||||
|
profileAccessToken: "old-access",
|
||||||
|
profileAccessTokenExpiresAt: profileExpiresAt,
|
||||||
|
profileRefreshToken: "old-refresh",
|
||||||
|
feedAccessToken: "old-feed",
|
||||||
|
feedAccessTokenExpiresAt: feedExpiresAt,
|
||||||
|
person: { userId: "u1", name: "Pat Rivera", email: "person@example.com", pictureUrl: null },
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
return createSession(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub for the refresh path: refresh-token -> new profile tokens, feed -> new feed token.
|
||||||
|
function refreshFetchStub(
|
||||||
|
overrides: Partial<Record<"refresh" | "feed", Response>> = {},
|
||||||
|
) {
|
||||||
|
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/v4/auth/refresh-token")) {
|
||||||
|
return (
|
||||||
|
overrides.refresh ??
|
||||||
|
json({ data: { accessToken: "new-access", refreshToken: "new-refresh", expiresIn: 3600 } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v4/auth/external-services/feed")) {
|
||||||
|
return overrides.feed ?? json({ accessToken: "new-feed" });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch to ${url}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ensureFreshSession", () => {
|
||||||
|
it("an access token expiring within 2 min triggers refresh; tokens change, no password", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60_000); // 1 min out: inside the 2-min window
|
||||||
|
const fetchImpl = refreshFetchStub();
|
||||||
|
|
||||||
|
const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(updated).not.toBeNull();
|
||||||
|
expect(updated!.profileAccessToken).toBe("new-access");
|
||||||
|
expect(updated!.profileRefreshToken).toBe("new-refresh");
|
||||||
|
expect(updated!.feedAccessToken).toBe("new-feed");
|
||||||
|
// Persisted under the same id.
|
||||||
|
expect(getSession(id)!.profileAccessToken).toBe("new-access");
|
||||||
|
// Refresh used the stored refresh token, never the login/password route.
|
||||||
|
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/login"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a feed token expiring within 2 min also triggers refresh", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 30_000);
|
||||||
|
const fetchImpl = refreshFetchStub();
|
||||||
|
|
||||||
|
const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(updated!.feedAccessToken).toBe("new-feed");
|
||||||
|
expect(updated!.profileAccessToken).toBe("new-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tokens comfortably in the future are not refreshed", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 60 * 60_000);
|
||||||
|
const fetchImpl = refreshFetchStub();
|
||||||
|
|
||||||
|
const same = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(same!.profileAccessToken).toBe("old-access");
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no cookie / unknown session is null", async () => {
|
||||||
|
expect(await ensureFreshSession(undefined)).toBeNull();
|
||||||
|
expect(await ensureFreshSession("does-not-exist")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refreshSession failure", () => {
|
||||||
|
it("a failed refresh deletes the session", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60_000);
|
||||||
|
const fetchImpl = refreshFetchStub({ refresh: json({ data: null }, 401) });
|
||||||
|
|
||||||
|
const result = await refreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed feed exchange during refresh also deletes the session", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60_000);
|
||||||
|
const fetchImpl = refreshFetchStub({ feed: json({ data: null }, 500) });
|
||||||
|
|
||||||
|
const result = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("endSession", () => {
|
||||||
|
it("revokes the refresh token and deletes the session", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60 * 60_000);
|
||||||
|
const fetchImpl = vi.fn(async (_input: RequestInfo | URL) => json({ data: { success: true } }));
|
||||||
|
|
||||||
|
await endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token"))).toBe(true);
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still deletes the session when revoke throws", async () => {
|
||||||
|
const id = seedSession(Date.now() + 60 * 60_000);
|
||||||
|
const fetchImpl = vi.fn(async (_input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
throw new Error("revoke down");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { DELETE, GET, POST } from "@/app/api/session/route";
|
||||||
|
import { SESSION_COOKIE } from "@/lib/cookie";
|
||||||
|
import { getSession } from "@/lib/session-store";
|
||||||
|
|
||||||
|
// A fetch stub that walks the Minnect calls. `overrides` lets a test force a
|
||||||
|
// non-200 or a weird body at any step. `revoke` covers the sign-out call.
|
||||||
|
function makeFetchStub(
|
||||||
|
overrides: Partial<Record<"login" | "me" | "feed" | "revoke", Response>> = {},
|
||||||
|
) {
|
||||||
|
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
|
||||||
|
if (url.endsWith("/api/v4/auth/revoke-token")) {
|
||||||
|
return (
|
||||||
|
overrides.revoke ??
|
||||||
|
new Response(JSON.stringify({ data: { success: true } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v4/auth/login")) {
|
||||||
|
return (
|
||||||
|
overrides.login ??
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: { accessToken: "profile-access", refreshToken: "profile-refresh", expiresIn: 3600 },
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v4/auth/me")) {
|
||||||
|
return (
|
||||||
|
overrides.me ??
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
userId: "user-123",
|
||||||
|
email: "person@example.com",
|
||||||
|
firstName: "Pat",
|
||||||
|
lastName: "Rivera",
|
||||||
|
profilePictureUrl: "https://cdn.example.com/pat.jpg",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v4/auth/external-services/feed")) {
|
||||||
|
return (
|
||||||
|
overrides.feed ??
|
||||||
|
new Response(JSON.stringify({ accessToken: "feed-access" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch to ${url}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function postRequest(body: unknown): NextRequest {
|
||||||
|
return new NextRequest("http://localhost/api/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/session", () => {
|
||||||
|
it("wrong password: 401, generic message, no cookie, no session", async () => {
|
||||||
|
const serverError = {
|
||||||
|
data: null,
|
||||||
|
errors: [{ message: "User not found in our records", code: "UnauthorizedException" }],
|
||||||
|
};
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
makeFetchStub({
|
||||||
|
login: new Response(JSON.stringify(serverError), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await POST(postRequest({ email: "person@example.com", password: "wrong" }));
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(json).toEqual({ error: "Email or password is incorrect." });
|
||||||
|
// The Minnect message must not leak through.
|
||||||
|
expect(JSON.stringify(json)).not.toContain("records");
|
||||||
|
// No cookie is set.
|
||||||
|
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing fields: 400, no cookie", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeFetchStub());
|
||||||
|
const res = await POST(postRequest({ email: "person@example.com" }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correct login: 200, person body, opaque session cookie, server session created", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeFetchStub());
|
||||||
|
|
||||||
|
const res = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(json).toEqual({
|
||||||
|
name: "Pat Rivera",
|
||||||
|
email: "person@example.com",
|
||||||
|
pictureUrl: "https://cdn.example.com/pat.jpg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookie = res.cookies.get(SESSION_COOKIE);
|
||||||
|
expect(cookie).toBeDefined();
|
||||||
|
expect(cookie?.httpOnly).toBe(true);
|
||||||
|
expect(cookie?.sameSite).toBe("lax");
|
||||||
|
// The cookie is an opaque id, not a Minnect token.
|
||||||
|
const id = cookie!.value;
|
||||||
|
expect(id).not.toContain("profile-access");
|
||||||
|
expect(id).not.toContain("feed-access");
|
||||||
|
|
||||||
|
// The tokens live server-side only.
|
||||||
|
const session = getSession(id);
|
||||||
|
expect(session?.profileAccessToken).toBe("profile-access");
|
||||||
|
expect(session?.profileRefreshToken).toBe("profile-refresh");
|
||||||
|
expect(session?.feedAccessToken).toBe("feed-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("feed-token exchange fails: 502, feed message, no cookie", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
makeFetchStub({
|
||||||
|
feed: new Response(JSON.stringify({ data: null }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||||
|
const json = await res.json();
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
expect(json).toEqual({ error: "Could not open the feed." });
|
||||||
|
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/session", () => {
|
||||||
|
it("no cookie: 401 sign_in", async () => {
|
||||||
|
const res = await GET(new NextRequest("http://localhost/api/session"));
|
||||||
|
const json = await res.json();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(json).toEqual({ error: "sign_in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown session id: 401 sign_in", async () => {
|
||||||
|
const res = await GET(
|
||||||
|
new NextRequest("http://localhost/api/session", {
|
||||||
|
headers: { cookie: `${SESSION_COOKIE}=does-not-exist` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(json).toEqual({ error: "sign_in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("valid session: returns the person's name and email", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeFetchStub());
|
||||||
|
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||||
|
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
|
||||||
|
|
||||||
|
const res = await GET(
|
||||||
|
new NextRequest("http://localhost/api/session", {
|
||||||
|
headers: { cookie: `${SESSION_COOKIE}=${id}` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(json.name).toBe("Pat Rivera");
|
||||||
|
expect(json.email).toBe("person@example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function requestWithCookie(method: string, id: string): NextRequest {
|
||||||
|
return new NextRequest("http://localhost/api/session", {
|
||||||
|
method,
|
||||||
|
headers: { cookie: `${SESSION_COOKIE}=${id}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DELETE /api/session", () => {
|
||||||
|
it("revokes, deletes the session, clears the cookie; next GET is 401", async () => {
|
||||||
|
const fetchStub = makeFetchStub();
|
||||||
|
vi.stubGlobal("fetch", fetchStub);
|
||||||
|
|
||||||
|
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||||
|
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
|
||||||
|
expect(getSession(id)).not.toBeNull();
|
||||||
|
|
||||||
|
const delRes = await DELETE(requestWithCookie("DELETE", id));
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
|
||||||
|
// The revoke-token call was made.
|
||||||
|
expect(
|
||||||
|
fetchStub.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token")),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Cookie is cleared (empty value, immediate expiry).
|
||||||
|
const cookie = delRes.cookies.get(SESSION_COOKIE);
|
||||||
|
expect(cookie?.value).toBe("");
|
||||||
|
expect(cookie?.maxAge).toBe(0);
|
||||||
|
|
||||||
|
// Session is gone.
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
|
||||||
|
// The next token-requiring call (GET) returns 401 sign_in.
|
||||||
|
const getRes = await GET(requestWithCookie("GET", id));
|
||||||
|
expect(getRes.status).toBe(401);
|
||||||
|
expect(await getRes.json()).toEqual({ error: "sign_in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still succeeds when revoke returns an error", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeFetchStub());
|
||||||
|
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||||
|
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
|
||||||
|
|
||||||
|
// Now make revoke fail.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
makeFetchStub({
|
||||||
|
revoke: new Response(JSON.stringify({ data: null }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const delRes = await DELETE(requestWithCookie("DELETE", id));
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe("");
|
||||||
|
expect(getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no cookie: still 200 and clears the cookie", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeFetchStub());
|
||||||
|
const delRes = await DELETE(new NextRequest("http://localhost/api/session", { method: "DELETE" }));
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { PostCard } from "@/components/PostCard";
|
||||||
|
import { toCardPosts } from "@/lib/feed-model";
|
||||||
|
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
||||||
|
|
||||||
|
const HLS = "https://stream.mux.com/abc.m3u8";
|
||||||
|
const item = (media: Record<string, unknown>) => ({
|
||||||
|
type: "post",
|
||||||
|
publishedAt: "2026-10-19T10:00:00Z",
|
||||||
|
post: { id: "1", content: "hi", author: { displayName: "Ann" }, mediaItems: [media] },
|
||||||
|
});
|
||||||
|
const html = (media: Record<string, unknown>) =>
|
||||||
|
renderToStaticMarkup(createElement(PostCard, { post: toCardPosts([item(media)])[0] }));
|
||||||
|
|
||||||
|
describe("choosePlayback", () => {
|
||||||
|
it("no hlsUrl means no player", () => {
|
||||||
|
expect(choosePlayback(null, { nativeHls: true, hlsJsSupported: true })).toBe("none");
|
||||||
|
expect(choosePlayback(undefined, { nativeHls: false, hlsJsSupported: true })).toBe("none");
|
||||||
|
});
|
||||||
|
it("uses native HLS where supported, hls.js otherwise", () => {
|
||||||
|
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: true })).toBe("native");
|
||||||
|
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: true })).toBe("hlsjs");
|
||||||
|
});
|
||||||
|
it("falls back to none when neither is available", () => {
|
||||||
|
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: false })).toBe("none");
|
||||||
|
});
|
||||||
|
it("detects native HLS from canPlayType", () => {
|
||||||
|
expect(supportsNativeHls({ canPlayType: () => "maybe" })).toBe(true);
|
||||||
|
expect(supportsNativeHls({ canPlayType: () => "" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("video media in the card", () => {
|
||||||
|
const video = { mediaType: "video", thumbnailUrl: "https://i/v.jpg" };
|
||||||
|
it("hlsUrl present renders an accessible controls player with the poster", () => {
|
||||||
|
const out = html({ ...video, hlsUrl: HLS });
|
||||||
|
expect(out).toContain("<video");
|
||||||
|
expect(out).toContain("controls");
|
||||||
|
expect(out).toContain('poster="https://i/v.jpg"');
|
||||||
|
expect(out).toContain('aria-label="Video from Ann"');
|
||||||
|
expect(out).not.toContain("autoplay");
|
||||||
|
});
|
||||||
|
it("hlsUrl absent shows the thumbnail only, no player", () => {
|
||||||
|
const out = html(video);
|
||||||
|
expect(out).not.toContain("<video");
|
||||||
|
expect(out).toContain('src="https://i/v.jpg"');
|
||||||
|
});
|
||||||
|
it("rejects an hlsUrl not on stream.mux.com", () => {
|
||||||
|
const out = html({ ...video, hlsUrl: "https://evil.example/x.m3u8" });
|
||||||
|
expect(out).not.toContain("<video");
|
||||||
|
expect(out).toContain('src="https://i/v.jpg"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
esbuild: { jsx: "automatic" },
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user