Merge Feed browser: Fixes into develop
Remove the sign-out feature entirely (sdyl405w4) and stop video posts freezing: prefer hls.js, degrade to the thumbnail with a note (qhkr2z22o).
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
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).
|
||||
// POST signs in, GET says who the person is. There is no sign-out: the session ends
|
||||
// when the server restarts or the cookie is cleared, and Minnect's revoke endpoint
|
||||
// is never called (the refresh token is shared across the person's devices).
|
||||
// The reusable token-refresh logic lives in session-refresh.ts for the feed
|
||||
// route (a later task) to call before it uses a token.
|
||||
|
||||
@@ -91,21 +92,3 @@ export async function GET(req: NextRequest) {
|
||||
feedUserId: session.person.feedUserId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
+1
-13
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadSession, signOut, type Person } from "@/lib/auth-client";
|
||||
import { loadSession, type Person } from "@/lib/auth-client";
|
||||
import { SignInForm } from "./SignInForm";
|
||||
import { Logo } from "./Logo";
|
||||
import { Feed } from "./Feed";
|
||||
@@ -10,7 +10,6 @@ type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Perso
|
||||
|
||||
export function App() {
|
||||
const [state, setState] = useState<State>({ kind: "loading" });
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -22,14 +21,6 @@ export function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSignOut() {
|
||||
if (signingOut) return;
|
||||
setSigningOut(true);
|
||||
await signOut();
|
||||
setSigningOut(false);
|
||||
setState({ kind: "out" });
|
||||
}
|
||||
|
||||
if (state.kind === "loading") {
|
||||
return (
|
||||
<main className="center">
|
||||
@@ -54,9 +45,6 @@ export function App() {
|
||||
<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 feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
|
||||
</main>
|
||||
|
||||
@@ -9,9 +9,12 @@ export interface VideoPlayerProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const UNPLAYABLE = "Video can't be played here.";
|
||||
|
||||
/**
|
||||
* Real <video controls>. Native HLS where the browser has it, otherwise hls.js,
|
||||
* loaded only when a video card mounts. Never autoplays.
|
||||
* Real <video controls>. hls.js (loaded only when a video card mounts) is
|
||||
* preferred; native HLS is used only where hls.js is unsupported. Never
|
||||
* autoplays. Any failure falls back to the thumbnail with a short note.
|
||||
*/
|
||||
export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
@@ -22,21 +25,14 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
||||
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 mode = choosePlayback(hlsUrl, {
|
||||
nativeHls: supportsNativeHls(video),
|
||||
hlsJsSupported: Hls.isSupported(),
|
||||
});
|
||||
if (mode === "hlsjs") {
|
||||
const hls = new Hls();
|
||||
hls.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (data.fatal) setFailed(true);
|
||||
@@ -44,6 +40,11 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(video);
|
||||
destroy = () => hls.destroy();
|
||||
} else if (mode === "native") {
|
||||
video.src = hlsUrl;
|
||||
} else {
|
||||
setFailed(true);
|
||||
}
|
||||
})
|
||||
.catch(() => setFailed(true));
|
||||
return () => {
|
||||
@@ -52,9 +53,16 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
||||
};
|
||||
}, [hlsUrl]);
|
||||
|
||||
if (failed && poster) {
|
||||
if (failed) {
|
||||
return (
|
||||
<div>
|
||||
{poster ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={poster} alt="Video thumbnail" loading="lazy" />;
|
||||
<img src={poster} alt="Video thumbnail" loading="lazy" />
|
||||
) : null}
|
||||
<p role="status">{UNPLAYABLE}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<video
|
||||
@@ -64,6 +72,7 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
||||
preload="none"
|
||||
poster={poster ?? undefined}
|
||||
aria-label={label}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,19 +31,6 @@ export async function loadSession(f: Fetch = fetch): Promise<Person | 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. */
|
||||
|
||||
@@ -196,27 +196,6 @@ export async function refreshProfileTokens(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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").
|
||||
}
|
||||
}
|
||||
|
||||
/** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */
|
||||
async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise<string | null> {
|
||||
try {
|
||||
|
||||
@@ -4,12 +4,11 @@ import {
|
||||
updateSession,
|
||||
type SessionData,
|
||||
} from "./session-store";
|
||||
import { exchangeFeedToken, refreshProfileTokens, revokeToken } from "./minnect";
|
||||
import { exchangeFeedToken, refreshProfileTokens } 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.
|
||||
// `refreshSession` once after a feed call returns 401. 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;
|
||||
@@ -85,20 +84,3 @@ export async function ensureFreshSession(
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,15 @@ export interface PlaybackEnv {
|
||||
hlsJsSupported: boolean;
|
||||
}
|
||||
|
||||
/** No hlsUrl: thumbnail only. Native HLS wins; hls.js is the fallback. */
|
||||
/**
|
||||
* No hlsUrl: thumbnail only. hls.js (Media Source Extensions) is preferred
|
||||
* because Chromium reports partial native HLS support it cannot honour; native
|
||||
* HLS is only chosen where hls.js is unsupported (Safari on iOS).
|
||||
*/
|
||||
export function choosePlayback(hlsUrl: string | null | undefined, env: PlaybackEnv): Playback {
|
||||
if (!hlsUrl) return "none";
|
||||
if (env.nativeHls) return "native";
|
||||
return env.hlsJsSupported ? "hlsjs" : "none";
|
||||
if (env.hlsJsSupported) return "hlsjs";
|
||||
return env.nativeHls ? "native" : "none";
|
||||
}
|
||||
|
||||
export function supportsNativeHls(video: Pick<HTMLVideoElement, "canPlayType">): boolean {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
endSession,
|
||||
ensureFreshSession,
|
||||
refreshSession,
|
||||
} from "@/lib/session-refresh";
|
||||
@@ -113,27 +112,3 @@ describe("refreshSession failure", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-79
@@ -1,26 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { DELETE, GET, POST } from "@/app/api/session/route";
|
||||
import { 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.
|
||||
// non-200 or a weird body at any step.
|
||||
function makeFetchStub(
|
||||
overrides: Partial<Record<"login" | "me" | "feed" | "revoke", Response>> = {},
|
||||
overrides: Partial<Record<"login" | "me" | "feed", 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 ??
|
||||
@@ -193,70 +184,3 @@ describe("GET /api/session", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,13 @@ describe("choosePlayback", () => {
|
||||
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");
|
||||
it("prefers hls.js whenever it is supported, even if native HLS is reported", () => {
|
||||
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: true })).toBe("hlsjs");
|
||||
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: true })).toBe("hlsjs");
|
||||
});
|
||||
it("uses native HLS only when hls.js is unsupported", () => {
|
||||
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: false })).toBe("native");
|
||||
});
|
||||
it("falls back to none when neither is available", () => {
|
||||
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: false })).toBe("none");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user