Attempt 2 of a management ui.
This commit is contained in:
54 files changed
+8804
-267
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Signed-cookie session for the manager app — adapted from (not imported
|
||||
* from) the main NextChair app's src/lib/booking-draft/sign.ts and
|
||||
* src/lib/security/secrets-match.ts. Deliberately copied rather than
|
||||
* shared: this app must never import anything from the main app's src/ —
|
||||
* a previous attempt at this exact tool was built as routes inside the
|
||||
* main app and repeatedly broke from transitively importing Prisma/
|
||||
* Auth.js/business data into a mode that has no business database at
|
||||
* all — see ../../README.md's "Why it's a separate app" for the full
|
||||
* history. Keeping this app's module graph fully separate is what makes
|
||||
* that failure structurally impossible to repeat here.
|
||||
*
|
||||
* HMAC-signed (tamper-evident), not encrypted — the payload is just an
|
||||
* email and an expiry, nothing sensitive to disclose to the browser
|
||||
* holding its own cookie. Session length: 7 days.
|
||||
*/
|
||||
|
||||
const CONTEXT = "nextchair-manager-session-v1";
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface ManagerSession {
|
||||
email: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = process.env.MANAGER_SESSION_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("MANAGER_SESSION_SECRET must be set.");
|
||||
}
|
||||
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
|
||||
return `${encodedPayload}.${mac}`;
|
||||
}
|
||||
|
||||
function verifyToken(token: string): string | null {
|
||||
const separatorIndex = token.indexOf(".");
|
||||
if (separatorIndex === -1) return null;
|
||||
|
||||
const encodedPayload = token.slice(0, separatorIndex);
|
||||
const mac = token.slice(separatorIndex + 1);
|
||||
if (!encodedPayload || !mac) return null;
|
||||
|
||||
let payload: string;
|
||||
try {
|
||||
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const expectedBuffer = Buffer.from(expectedMac);
|
||||
if (macBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createSessionToken(email: string): string {
|
||||
const payload: ManagerSession = { email, exp: Date.now() + SESSION_TTL_MS };
|
||||
return sign(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** Returns the session if the token is validly signed and not expired, otherwise null. Never throws on malformed input. */
|
||||
export function verifySessionToken(token: string | undefined): ManagerSession | null {
|
||||
if (!token) return null;
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
let session: ManagerSession;
|
||||
try {
|
||||
session = JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof session.email !== "string" || typeof session.exp !== "number") return null;
|
||||
if (Date.now() > session.exp) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same signal/reasoning as the main app's src/lib/security/cookie-
|
||||
* security.ts's isSecureDeployment() — NODE_ENV=production doesn't mean
|
||||
* TLS is actually in front of this app, so the cookie's Secure attribute
|
||||
* is derived from the deployer-declared public URL instead.
|
||||
*/
|
||||
export function isSecureDeployment(): boolean {
|
||||
return (process.env.MANAGER_PUBLIC_URL ?? "").startsWith("https://");
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE_NAME = "manager_session";
|
||||
Reference in new issue
Block a user