Skip to content

Commit d28be8c

Browse files
authored
Merge pull request #9 from AdaInTheLab/scms/vesper-admin
FIX [OPS] Stabilize lab-api behind Cloudflare Tunnel (localhost ingress)
2 parents 843987b + f775e59 commit d28be8c

14 files changed

Lines changed: 440 additions & 199 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ SESSION_SECRET=your_session_secret_here
1515

1616
# Optional: Restrict GitHub username
1717
ALLOWED_GITHUB_USERNAME=your_username_here
18+
ADMIN_DEV_BYPASS=true
19+

jest.config.cjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ module.exports = {
1010
},
1111

1212
moduleNameMapper: {
13+
// keep your existing "strip relative .js" helper
1314
"^(\\.{1,2}/.*)\\.js$": "$1",
15+
16+
// ✅ map alias imports that end in .js to the TS source file
17+
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
18+
19+
// ✅ map alias imports without extension (or other)
20+
"^@/(.*)$": "<rootDir>/src/$1",
1421
},
1522
};

src/auth.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function configurePassport() {
4646
passport.use(
4747
new GitHubStrategy(
4848
{ clientID, clientSecret, callbackURL },
49-
(_accessToken: any, _refreshToken: any, profile: any, done: (arg0: null, arg1: boolean) => any) => {
49+
(_accessToken: any, _refreshToken: any, profile: any, done: any) => {
5050
const allowed = process.env.ALLOWED_GITHUB_USERNAME;
5151
if (allowed && profile?.username !== allowed) return done(null, false);
5252
return done(null, profile);
@@ -58,10 +58,73 @@ export function configurePassport() {
5858
passport.deserializeUser((user: any, done) => done(null, user));
5959
}
6060

61-
export const ensureAuthenticated = (req: Request, res: Response, next: NextFunction) => {
61+
export const ensureAuthenticated = (
62+
req: Request,
63+
res: Response,
64+
next: NextFunction
65+
) => {
6266
if (req.isAuthenticated?.()) return next();
6367
return res.status(401).json({ error: "Unauthorized" });
6468
};
6569

70+
export function getGithubLogin(user: any): string | null {
71+
const raw = String(user?.username ?? user?.login ?? "").trim();
72+
return raw ? raw.toLowerCase() : null;
73+
}
74+
75+
function parseAllowlist(envValue?: string): string[] {
76+
return String(envValue ?? "")
77+
.split(",")
78+
.map((s) => s.trim().toLowerCase())
79+
.filter(Boolean);
80+
}
81+
82+
// ✅ Single source of truth for admin allowlist
83+
function buildAdminAllowlist(): Set<string> {
84+
return new Set<string>([
85+
...parseAllowlist(process.env.ADMIN_GITHUB_USERS),
86+
...parseAllowlist(process.env.ADMIN_GITHUB_LOGINS),
87+
...parseAllowlist(process.env.ALLOWED_GITHUB_USERNAME),
88+
]);
89+
}
90+
91+
/**
92+
* Admin gate (API-side). Use this on /admin/* routes.
93+
*
94+
* Semantics:
95+
* - 401: not authenticated (no session)
96+
* - 403: authenticated but not authorized OR allowlist not configured (prod)
97+
*/
98+
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
99+
// ✅ DEV BYPASS (explicit opt-in, never works in production)
100+
if (
101+
process.env.NODE_ENV !== "production" &&
102+
process.env.ADMIN_DEV_BYPASS === "true"
103+
) {
104+
return next();
105+
}
106+
107+
const allow = buildAdminAllowlist();
108+
109+
// Fail-closed when misconfigured (production only).
110+
if (allow.size === 0 && process.env.NODE_ENV === "production") {
111+
return res.status(403).json({ error: "Admin not configured" });
112+
}
113+
114+
if (!req.isAuthenticated?.()) {
115+
return res.status(401).json({ error: "Unauthorized" });
116+
}
117+
118+
const login = getGithubLogin((req as any).user);
119+
if (!login) {
120+
return res.status(401).json({ error: "Unauthorized" });
121+
}
122+
123+
if (allow.size > 0 && !allow.has(login)) {
124+
return res.status(403).json({ error: "Forbidden" });
125+
}
126+
127+
return next();
128+
};
129+
66130
export default passport;
67-
1

src/lib/helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// lib/helpers.ts
2+
3+
export function normalizeLocale(input: unknown) {
4+
const raw = String(input ?? "en").trim().toLowerCase();
5+
if (!raw) return "en";
6+
7+
// Handle common variants: en-US, en_US, en-us -> en
8+
const two = raw.split(/[-_]/)[0];
9+
if (two === "en" || two === "ko") return two;
10+
11+
// Fallback: keep first two letters if present
12+
if (two.length >= 2) return two.slice(0, 2);
13+
14+
return "en";
15+
}
File renamed without changes.

src/mappers/labNotesMapper.ts

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,55 @@ function deriveStatus(published: string): "published" | "draft" {
44
return published ? "published" : "draft";
55
}
66

7-
function deriveType(note: LabNoteRecord): "labnote" | "paper" | "memo" {
8-
// If you already store note.type, prefer it:
9-
if (note.type) return note.type;
7+
function normalizeStatus(s?: "published" | "draft" | "archived" | null | undefined, published?: string): "published" | "draft" | "archived" {
8+
const v = (s ?? "").toLowerCase();
9+
if (v === "published" || v === "draft" || v === "archived") return v;
10+
return deriveStatus(published ?? "");
11+
}
12+
type LabNoteType = "labnote" | "paper" | "memo" | "lore" | "weather";
13+
const ALLOWED_NOTE_TYPES: ReadonlySet<LabNoteType> = new Set([
14+
"labnote",
15+
"paper",
16+
"memo",
17+
"lore",
18+
"weather",
19+
]);
20+
21+
function deriveType(note: LabNoteRecord): LabNoteType {
22+
// Prefer stored note.type if present
23+
const raw = (note.type ?? "").toLowerCase() as LabNoteType;
24+
25+
if (raw && ALLOWED_NOTE_TYPES.has(raw)) return raw;
1026

1127
// Optional: derive from category if it has meaning
1228
if (note.category === "paper") return "paper";
1329
if (note.category === "memo") return "memo";
30+
if (note.category === "lore") return "lore";
31+
if (note.category === "weather") return "weather";
1432

1533
return "labnote";
1634
}
17-
18-
export function mapToLabNoteView(note: LabNoteRecord, tags: string[]): LabNoteView {
35+
export function mapToLabNoteView(note: LabNoteRecord, tags: string[]): {
36+
id: string;
37+
slug: string;
38+
title: string;
39+
subtitle: string | undefined;
40+
summary: string;
41+
contentHtml: string;
42+
published: string;
43+
status: "published" | "draft" | "archived";
44+
type: "labnote" | "paper" | "memo" | "lore" | "weather";
45+
dept: string | undefined;
46+
locale: string;
47+
author: { kind: "human" | "ai" | "hybrid" } | undefined;
48+
department_id: string;
49+
shadow_density: number;
50+
safer_landing: boolean;
51+
tags: string[];
52+
readingTime: number;
53+
created_at: string | undefined;
54+
updated_at: string | undefined
55+
} {
1956
const published = note.published_at ?? "";
2057

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

32-
status: note.status ?? deriveStatus(published),
69+
status: normalizeStatus(note.status, published),
3370
type: deriveType(note),
3471

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

58-
export function mapToLabNotePreview(note: LabNoteRecord, tags: string[]): LabNoteView {
95+
export function mapToLabNotePreview(note: LabNoteRecord, tags: string[]): {
96+
id: string;
97+
slug: string;
98+
title: string;
99+
subtitle: string | undefined;
100+
summary: string;
101+
contentHtml: string;
102+
published: string;
103+
status: "published" | "draft" | "archived";
104+
type: "labnote" | "paper" | "memo" | "lore" | "weather";
105+
dept: string | undefined;
106+
locale: string;
107+
author: { kind: "human" | "ai" | "hybrid" } | undefined;
108+
department_id: string;
109+
shadow_density: number;
110+
safer_landing: boolean;
111+
tags: string[];
112+
readingTime: number;
113+
created_at: string | undefined;
114+
updated_at: string | undefined
115+
} {
59116
const published = note.published_at ?? "";
60117

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

72-
status: note.status ?? deriveStatus(published),
129+
status: normalizeStatus(note.status, published),
73130
type: deriveType(note),
74131

75132
dept: note.dept ?? undefined,

src/middleware/requireAdmin.ts

Lines changed: 46 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,66 @@
11
import type { Request, Response, NextFunction } from "express";
2+
import { getGithubLogin } from "auth.js"
23

34
function getAuthToken(req: Request): string | null {
45
const h = req.header("authorization");
56
if (!h) return null;
6-
7-
// Accept: "Bearer xxx" or "token xxx"
87
const m = /^(Bearer|token)\s+(.+)$/i.exec(h.trim());
98
return m?.[2] ?? null;
109
}
1110

11+
function parseAllowlist(envValue?: string): string[] {
12+
return String(envValue ?? "")
13+
.split(",")
14+
.map(s => s.trim().toLowerCase())
15+
.filter(Boolean);
16+
}
17+
18+
function getSessionLogin(req: Request): string | null {
19+
// passport-github2 typically gives profile.username
20+
const u: any = (req as any).user;
21+
const login = (u?.username ?? u?.login ?? "").toString().trim();
22+
return login ? login.toLowerCase() : null;
23+
}
24+
1225
// tiny cache: token -> { login, expiresAt }
1326
const tokenCache = new Map<string, { login: string; expiresAt: number }>();
14-
const CACHE_MS = 60_000; // 1 minute (adjust as desired)
15-
16-
export async function requireAdmin(req: Request, res: Response, next: NextFunction) {
17-
const token = getAuthToken(req);
18-
if (!token) {
19-
return res.status(401).json({ error: "unauthorized", message: "Missing Authorization token" });
20-
}
27+
const CACHE_MS = 60_000;
2128

22-
// Cache hit?
23-
const cached = tokenCache.get(token);
24-
if (cached && cached.expiresAt > Date.now()) {
25-
(req as any).adminLogin = cached.login;
29+
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
30+
// ✅ DEV BYPASS (explicit opt-in, never in production)
31+
if (
32+
process.env.NODE_ENV !== "production" &&
33+
process.env.ADMIN_DEV_BYPASS === "true"
34+
) {
2635
return next();
2736
}
37+
// 1) Not logged in? -> 401
38+
if (!req.isAuthenticated?.()) {
39+
return res.status(401).json({ error: "Unauthorized" });
40+
}
2841

29-
try {
30-
const ghRes = await fetch("https://api.github.com/user", {
31-
headers: {
32-
Authorization: `Bearer ${token}`,
33-
Accept: "application/vnd.github+json",
34-
"User-Agent": "human-pattern-lab-api",
35-
},
36-
});
37-
38-
// Helpful differentiation for debugging
39-
if (ghRes.status === 401) {
40-
return res.status(401).json({ error: "unauthorized", message: "Invalid GitHub token" });
41-
}
42-
if (ghRes.status === 403) {
43-
// could be rate limiting or token restriction
44-
const remaining = ghRes.headers.get("x-ratelimit-remaining");
45-
const msg =
46-
remaining === "0"
47-
? "GitHub rate limit exceeded (try again later)"
48-
: "Forbidden by GitHub (token may lack scopes or be restricted)";
49-
return res.status(403).json({ error: "forbidden", message: msg });
50-
}
51-
if (!ghRes.ok) {
52-
return res.status(502).json({ error: "bad_gateway", message: "GitHub auth check failed" });
53-
}
54-
55-
const user = (await ghRes.json()) as { login?: string };
56-
const login = user.login?.trim();
42+
const login = getGithubLogin((req as any).user);
43+
if (!login) {
44+
return res.status(401).json({ error: "Unauthorized" });
45+
}
5746

58-
if (!login) {
59-
return res.status(401).json({ error: "unauthorized", message: "GitHub token user unknown" });
60-
}
47+
// 2) Now check allowlist
48+
const allow = new Set<string>([
49+
...parseAllowlist(process.env.ADMIN_GITHUB_USERS),
50+
...parseAllowlist(process.env.ADMIN_GITHUB_LOGINS),
51+
...parseAllowlist(process.env.ALLOWED_GITHUB_USERNAME),
52+
]);
6153

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

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

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

75-
return next();
76-
} catch {
77-
return res.status(500).json({ error: "server_error", message: "Auth check failed" });
78-
}
79-
}

0 commit comments

Comments
 (0)