Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ SESSION_SECRET=your_session_secret_here

# Optional: Restrict GitHub username
ALLOWED_GITHUB_USERNAME=your_username_here
ADMIN_DEV_BYPASS=true

7 changes: 7 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ module.exports = {
},

moduleNameMapper: {
// keep your existing "strip relative .js" helper
"^(\\.{1,2}/.*)\\.js$": "$1",

// ✅ map alias imports that end in .js to the TS source file
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",

// ✅ map alias imports without extension (or other)
"^@/(.*)$": "<rootDir>/src/$1",
},
};
69 changes: 66 additions & 3 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function configurePassport() {
passport.use(
new GitHubStrategy(
{ clientID, clientSecret, callbackURL },
(_accessToken: any, _refreshToken: any, profile: any, done: (arg0: null, arg1: boolean) => any) => {
(_accessToken: any, _refreshToken: any, profile: any, done: any) => {
const allowed = process.env.ALLOWED_GITHUB_USERNAME;
if (allowed && profile?.username !== allowed) return done(null, false);
return done(null, profile);
Expand All @@ -58,10 +58,73 @@ export function configurePassport() {
passport.deserializeUser((user: any, done) => done(null, user));
}

export const ensureAuthenticated = (req: Request, res: Response, next: NextFunction) => {
export const ensureAuthenticated = (
req: Request,
res: Response,
next: NextFunction
) => {
if (req.isAuthenticated?.()) return next();
return res.status(401).json({ error: "Unauthorized" });
};

export function getGithubLogin(user: any): string | null {
const raw = String(user?.username ?? user?.login ?? "").trim();
return raw ? raw.toLowerCase() : null;
}

function parseAllowlist(envValue?: string): string[] {
return String(envValue ?? "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}

// ✅ Single source of truth for admin allowlist
function buildAdminAllowlist(): Set<string> {
return new Set<string>([
...parseAllowlist(process.env.ADMIN_GITHUB_USERS),
...parseAllowlist(process.env.ADMIN_GITHUB_LOGINS),
...parseAllowlist(process.env.ALLOWED_GITHUB_USERNAME),
]);
}

/**
* Admin gate (API-side). Use this on /admin/* routes.
*
* Semantics:
* - 401: not authenticated (no session)
* - 403: authenticated but not authorized OR allowlist not configured (prod)
*/
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
// ✅ DEV BYPASS (explicit opt-in, never works in production)
if (
process.env.NODE_ENV !== "production" &&
process.env.ADMIN_DEV_BYPASS === "true"
) {
return next();
}

const allow = buildAdminAllowlist();

// Fail-closed when misconfigured (production only).
if (allow.size === 0 && process.env.NODE_ENV === "production") {
return res.status(403).json({ error: "Admin not configured" });
}

if (!req.isAuthenticated?.()) {
return res.status(401).json({ error: "Unauthorized" });
}

const login = getGithubLogin((req as any).user);
if (!login) {
return res.status(401).json({ error: "Unauthorized" });
}

if (allow.size > 0 && !allow.has(login)) {
return res.status(403).json({ error: "Forbidden" });
}

return next();
};

export default passport;
1
15 changes: 15 additions & 0 deletions src/lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// lib/helpers.ts

export function normalizeLocale(input: unknown) {
const raw = String(input ?? "en").trim().toLowerCase();
if (!raw) return "en";

// Handle common variants: en-US, en_US, en-us -> en
const two = raw.split(/[-_]/)[0];
if (two === "en" || two === "ko") return two;

// Fallback: keep first two letters if present
if (two.length >= 2) return two.slice(0, 2);

return "en";
}
File renamed without changes.
73 changes: 65 additions & 8 deletions src/mappers/labNotesMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,55 @@ function deriveStatus(published: string): "published" | "draft" {
return published ? "published" : "draft";
}

function deriveType(note: LabNoteRecord): "labnote" | "paper" | "memo" {
// If you already store note.type, prefer it:
if (note.type) return note.type;
function normalizeStatus(s?: "published" | "draft" | "archived" | null | undefined, published?: string): "published" | "draft" | "archived" {
const v = (s ?? "").toLowerCase();
if (v === "published" || v === "draft" || v === "archived") return v;
return deriveStatus(published ?? "");
}
type LabNoteType = "labnote" | "paper" | "memo" | "lore" | "weather";
const ALLOWED_NOTE_TYPES: ReadonlySet<LabNoteType> = new Set([
"labnote",
"paper",
"memo",
"lore",
"weather",
]);

function deriveType(note: LabNoteRecord): LabNoteType {
// Prefer stored note.type if present
const raw = (note.type ?? "").toLowerCase() as LabNoteType;

if (raw && ALLOWED_NOTE_TYPES.has(raw)) return raw;

// Optional: derive from category if it has meaning
if (note.category === "paper") return "paper";
if (note.category === "memo") return "memo";
if (note.category === "lore") return "lore";
if (note.category === "weather") return "weather";

return "labnote";
}

export function mapToLabNoteView(note: LabNoteRecord, tags: string[]): LabNoteView {
export function mapToLabNoteView(note: LabNoteRecord, tags: string[]): {
id: string;
slug: string;
title: string;
subtitle: string | undefined;
summary: string;
contentHtml: string;
published: string;
status: "published" | "draft" | "archived";
type: "labnote" | "paper" | "memo" | "lore" | "weather";
dept: string | undefined;
locale: string;
author: { kind: "human" | "ai" | "hybrid" } | undefined;
department_id: string;
shadow_density: number;
safer_landing: boolean;
tags: string[];
readingTime: number;
created_at: string | undefined;
updated_at: string | undefined
} {
const published = note.published_at ?? "";

return {
Expand All @@ -29,7 +66,7 @@ export function mapToLabNoteView(note: LabNoteRecord, tags: string[]): LabNoteVi
contentHtml: note.content_html?.trim() || "<p>Content pending migration.</p>",
published,

status: note.status ?? deriveStatus(published),
status: normalizeStatus(note.status, published),
type: deriveType(note),

dept: note.dept ?? undefined,
Expand All @@ -55,7 +92,27 @@ export function mapToLabNoteView(note: LabNoteRecord, tags: string[]): LabNoteVi
};
}

export function mapToLabNotePreview(note: LabNoteRecord, tags: string[]): LabNoteView {
export function mapToLabNotePreview(note: LabNoteRecord, tags: string[]): {
id: string;
slug: string;
title: string;
subtitle: string | undefined;
summary: string;
contentHtml: string;
published: string;
status: "published" | "draft" | "archived";
type: "labnote" | "paper" | "memo" | "lore" | "weather";
dept: string | undefined;
locale: string;
author: { kind: "human" | "ai" | "hybrid" } | undefined;
department_id: string;
shadow_density: number;
safer_landing: boolean;
tags: string[];
readingTime: number;
created_at: string | undefined;
updated_at: string | undefined
} {
const published = note.published_at ?? "";

return {
Expand All @@ -69,7 +126,7 @@ export function mapToLabNotePreview(note: LabNoteRecord, tags: string[]): LabNot
contentHtml: "",
published,

status: note.status ?? deriveStatus(published),
status: normalizeStatus(note.status, published),
type: deriveType(note),

dept: note.dept ?? undefined,
Expand Down
105 changes: 46 additions & 59 deletions src/middleware/requireAdmin.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,66 @@
import type { Request, Response, NextFunction } from "express";
import { getGithubLogin } from "auth.js"

function getAuthToken(req: Request): string | null {
const h = req.header("authorization");
if (!h) return null;

// Accept: "Bearer xxx" or "token xxx"
const m = /^(Bearer|token)\s+(.+)$/i.exec(h.trim());
return m?.[2] ?? null;
}

function parseAllowlist(envValue?: string): string[] {
return String(envValue ?? "")
.split(",")
.map(s => s.trim().toLowerCase())
.filter(Boolean);
}

function getSessionLogin(req: Request): string | null {
// passport-github2 typically gives profile.username
const u: any = (req as any).user;
const login = (u?.username ?? u?.login ?? "").toString().trim();
return login ? login.toLowerCase() : null;
}

// tiny cache: token -> { login, expiresAt }
const tokenCache = new Map<string, { login: string; expiresAt: number }>();
const CACHE_MS = 60_000; // 1 minute (adjust as desired)

export async function requireAdmin(req: Request, res: Response, next: NextFunction) {
const token = getAuthToken(req);
if (!token) {
return res.status(401).json({ error: "unauthorized", message: "Missing Authorization token" });
}
const CACHE_MS = 60_000;

// Cache hit?
const cached = tokenCache.get(token);
if (cached && cached.expiresAt > Date.now()) {
(req as any).adminLogin = cached.login;
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
// ✅ DEV BYPASS (explicit opt-in, never in production)
if (
process.env.NODE_ENV !== "production" &&
process.env.ADMIN_DEV_BYPASS === "true"
) {
return next();
}
// 1) Not logged in? -> 401
if (!req.isAuthenticated?.()) {
return res.status(401).json({ error: "Unauthorized" });
}

try {
const ghRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"User-Agent": "human-pattern-lab-api",
},
});

// Helpful differentiation for debugging
if (ghRes.status === 401) {
return res.status(401).json({ error: "unauthorized", message: "Invalid GitHub token" });
}
if (ghRes.status === 403) {
// could be rate limiting or token restriction
const remaining = ghRes.headers.get("x-ratelimit-remaining");
const msg =
remaining === "0"
? "GitHub rate limit exceeded (try again later)"
: "Forbidden by GitHub (token may lack scopes or be restricted)";
return res.status(403).json({ error: "forbidden", message: msg });
}
if (!ghRes.ok) {
return res.status(502).json({ error: "bad_gateway", message: "GitHub auth check failed" });
}

const user = (await ghRes.json()) as { login?: string };
const login = user.login?.trim();
const login = getGithubLogin((req as any).user);
if (!login) {
return res.status(401).json({ error: "Unauthorized" });
}

if (!login) {
return res.status(401).json({ error: "unauthorized", message: "GitHub token user unknown" });
}
// 2) Now check allowlist
const allow = new Set<string>([
...parseAllowlist(process.env.ADMIN_GITHUB_USERS),
...parseAllowlist(process.env.ADMIN_GITHUB_LOGINS),
...parseAllowlist(process.env.ALLOWED_GITHUB_USERNAME),
]);

// Optional allowlist: ADMIN_GITHUB_LOGINS="AdaVale,OtherAdmin"
const allow = (process.env.ADMIN_GITHUB_LOGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// 3) If no allowlist is configured, fail closed (prod + everywhere if you prefer)
if (allow.size === 0) {
return res.status(403).json({ error: "Admin not configured" });
}

if (allow.length > 0 && !allow.includes(login)) {
return res.status(403).json({ error: "forbidden", message: "Not an admin" });
}
// 4) Logged in but not allowed -> 403
if (!allow.has(login)) {
return res.status(403).json({ error: "Forbidden" });
}

tokenCache.set(token, { login, expiresAt: Date.now() + CACHE_MS });
(req as any).adminLogin = login;
return next();
};

return next();
} catch {
return res.status(500).json({ error: "server_error", message: "Auth check failed" });
}
}
Loading