update shared utils

This commit is contained in:
JCEEE
2026-08-16 10:12:43 +01:00
parent 15535a7b0e
commit e2b99c45b6
2 changed files with 85 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
// Minimal signed-token helper (HMAC-SHA256, JWT-ish but not JWT) shared by the
// Hono proxy (issues + verifies) and the SvelteKit frontend (verifies locally
// so hooks.server.ts doesn't need a round-trip to the proxy on every request).
//
// The proxy independently re-verifies the same token on every write, so the
// frontend's local verification is a performance optimization, not the
// security boundary — the boundary is the proxy.
import crypto from "node:crypto";
// Dev-only fallback secrets. Both sides must derive the same default when the
// real env var isn't set, so local dev works without extra setup. Production
// MUST set SESSION_TOKEN_SECRET / PLATFORM_TOKEN_SECRET to a strong value.
export const DEV_SESSION_TOKEN_SECRET = "famchamp-dev-session-secret-change-me";
export const DEV_PLATFORM_TOKEN_SECRET = "famchamp-dev-platform-secret-change-me";
export type TokenPayload = Record<string, unknown> & { exp: number };
function encode(json: string): string {
return Buffer.from(json).toString("base64url");
}
function sign(secret: string, encodedPayload: string): string {
return crypto.createHmac("sha256", secret).update(encodedPayload).digest("base64url");
}
/** Signs `payload` (plus an `exp` computed from `ttlMs`) into an opaque token string. */
export function issueToken<T extends Record<string, unknown>>(
secret: string,
payload: T,
ttlMs: number,
): string {
const encoded = encode(JSON.stringify({ ...payload, exp: Date.now() + ttlMs }));
return `${encoded}.${sign(secret, encoded)}`;
}
/** Verifies signature + expiry. Returns the decoded payload, or null if invalid/expired/tampered. */
export function verifyToken<T extends TokenPayload = TokenPayload>(
secret: string,
token: string | undefined | null,
): T | null {
if (!token) return null;
const [encoded, sig] = token.split(".");
if (!encoded || !sig) return null;
const expected = sign(secret, encoded);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
// Constant-time comparison — avoids leaking signature bytes via timing.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
try {
const payload = JSON.parse(Buffer.from(encoded, "base64url").toString()) as T;
if (typeof payload.exp !== "number" || payload.exp <= Date.now()) return null;
return payload;
} catch {
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
// Name → handle / slug helpers shared by frontend + proxy.
// URL-safe family slug (hyphenated, lowercase): "The Smiths" → "the-smiths".
export function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Whitespace-free lowercase handle used for a member's URL segment and as the
// per-family username suffix: "Jakey Boy" → "jakeyboy", "Joe Edhook" → "joeedhook".
export function handle(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
}
// A member's PB username is namespaced by the family slug so it stays globally
// unique (PB requires auth-identity usernames to be unique) even though the URL
// segment is only unique within a family: "miss-fits:jakeyboy".
export function famUsername(famSlug: string, handleName: string): string {
return `${slugify(famSlug)}:${handleName}`;
}
// Reverse of famUsername — the URL segment is the part after the last ":".
export function handleOf(fullUsername: string): string {
const i = fullUsername.lastIndexOf(':');
return i >= 0 ? fullUsername.slice(i + 1) : fullUsername;
}