kdlb9zs9n: Scaffold Next.js app and create the server session from a Minnect login

This commit is contained in:
2026-10-19 13:19:20 -06:00
parent 20c0d5da6c
commit 6e4cc3494b
16 changed files with 7658 additions and 0 deletions
+22
View File
@@ -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: "/",
};
}
+17
View File
@@ -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",
);
+188
View File
@@ -0,0 +1,188 @@
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;
}
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)
);
}
/**
* 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 =
typeof expiresIn === "number" ? Date.now() + expiresIn * 1000 : null;
// 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 feedRes = await doFetch(
`${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`,
{
method: "POST",
headers: jsonHeaders(profileAccessToken),
body: JSON.stringify({}),
},
);
if (feedRes.status !== 200) {
throw new LoginError(502, FEED_ERROR);
}
const feedToken = readFeedToken(await parseJson(feedRes));
if (!feedToken) {
throw new LoginError(502, FEED_ERROR);
}
return {
person: { userId, name, email: personEmail, pictureUrl },
tokens: {
profileAccessToken,
profileAccessTokenExpiresAt,
profileRefreshToken,
feedAccessToken: feedToken,
},
};
}
+48
View File
@@ -0,0 +1,48 @@
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;
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;
}
export function deleteSession(id: string): void {
store.delete(id);
}