diff --git a/.env.example b/.env.example index 2fc4e1a..7d3104c 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,5 @@ SESSION_SECRET=your_session_secret_here # Optional: Restrict GitHub username ALLOWED_GITHUB_USERNAME=your_username_here +ADMIN_DEV_BYPASS=true + diff --git a/jest.config.cjs b/jest.config.cjs index d99699e..e0d10f8 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -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$": "/src/$1.ts", + + // ✅ map alias imports without extension (or other) + "^@/(.*)$": "/src/$1", }, }; diff --git a/src/auth.ts b/src/auth.ts index 09e9936..4d9c18a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -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); @@ -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 { + return new Set([ + ...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 \ No newline at end of file diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts new file mode 100644 index 0000000..fc9ac7a --- /dev/null +++ b/src/lib/helpers.ts @@ -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"; +} \ No newline at end of file diff --git a/lib/labNotes.ts b/src/lib/labNotes.ts similarity index 100% rename from lib/labNotes.ts rename to src/lib/labNotes.ts diff --git a/src/mappers/labNotesMapper.ts b/src/mappers/labNotesMapper.ts index 7b150af..de51e81 100644 --- a/src/mappers/labNotesMapper.ts +++ b/src/mappers/labNotesMapper.ts @@ -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 = 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 { @@ -29,7 +66,7 @@ export function mapToLabNoteView(note: LabNoteRecord, tags: string[]): LabNoteVi contentHtml: note.content_html?.trim() || "

Content pending migration.

", published, - status: note.status ?? deriveStatus(published), + status: normalizeStatus(note.status, published), type: deriveType(note), dept: note.dept ?? undefined, @@ -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 { @@ -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, diff --git a/src/middleware/requireAdmin.ts b/src/middleware/requireAdmin.ts index 95ff098..efe2051 100644 --- a/src/middleware/requireAdmin.ts +++ b/src/middleware/requireAdmin.ts @@ -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(); -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([ + ...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" }); - } -} diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index 3df10ad..86d55c5 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -2,39 +2,30 @@ import type { Request, Response } from "express"; import type Database from "better-sqlite3"; import { randomUUID } from "node:crypto"; -import passport, { ensureAuthenticated, isGithubOAuthEnabled } from "../auth.js"; +import passport, { requireAdmin, isGithubOAuthEnabled } from "../auth.js"; +import { normalizeLocale } from "@/lib/helpers.js"; -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"; -} export function registerAdminRoutes(app: any, db: Database.Database) { // If you're behind a proxy/SSL terminator, this must be set (and should not be conditional). const UI_BASE_URL = process.env.UI_BASE_URL ?? "http://localhost:8001"; - app.get("/admin/notes", ensureAuthenticated, (_req: Request, res: Response) => { + app.get("/admin/notes", requireAdmin, (_req: Request, res: Response) => { try { const rows = db.prepare(` - SELECT - id, slug, title, locale, - category, excerpt, content_html, - department_id, shadow_density, coherence_score, - safer_landing, read_time_minutes, published_at, - created_at, updated_at - FROM v_lab_notes - ORDER BY published_at DESC - `).all(); + SELECT + id, slug, title, locale, + type, status, dept, + category, excerpt, summary, + content_html, + department_id, shadow_density, coherence_score, + safer_landing, read_time_minutes, published_at, + created_at, updated_at + FROM v_lab_notes + ORDER BY published_at DESC, updated_at DESC + + `).all(); res.json(rows); } catch (e: any) { @@ -45,41 +36,61 @@ export function registerAdminRoutes(app: any, db: Database.Database) { // --------------------------------------------------------------------------- // Admin: upsert Lab Note (protected) // --------------------------------------------------------------------------- - app.post("/admin/notes", ensureAuthenticated, (req: Request, res: Response) => { - const { - id, - title, - slug, - locale, - category, - excerpt, - content_html, - department_id, - shadow_density, - coherence_score, - safer_landing, - read_time_minutes, - published_at, - } = req.body ?? {}; - - if (!title) return res.status(400).json({ error: "title is required" }); - if (!slug) return res.status(400).json({ error: "slug is required" }); - - const noteId = id ?? randomUUID(); - const noteLocale = normalizeLocale(locale); - - const stmt = db.prepare(` + app.post("/admin/notes", requireAdmin, (req: Request, res: Response) => { + try { + const { + id, + title, + slug, + locale, + category, + excerpt, + content_html, + department_id, + shadow_density, + coherence_score, + safer_landing, + read_time_minutes, + published_at, + type, + status, + dept, + summary, + // tags, // keep for later + } = req.body ?? {}; + + if (!title) return res.status(400).json({ error: "title is required" }); + if (!slug) return res.status(400).json({ error: "slug is required" }); + + const noteId = id ?? randomUUID(); + const noteLocale = normalizeLocale(locale); + + const noteType = String(type ?? "labnote"); + const noteStatus = String(status ?? (published_at ? "published" : "draft")); + + const normalizedPublishedAt = + noteStatus === "published" + ? (published_at || new Date().toISOString().slice(0, 10)) + : (published_at || null); + + const stmt = db.prepare(` INSERT INTO lab_notes ( id, title, slug, locale, - category, excerpt, content_html, + type, status, dept, + category, excerpt, summary, content_html, department_id, shadow_density, coherence_score, - safer_landing, read_time_minutes, published_at + safer_landing, read_time_minutes, published_at, + updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) ON CONFLICT(slug, locale) DO UPDATE SET title=excluded.title, + type=excluded.type, + status=excluded.status, + dept=excluded.dept, category=excluded.category, excerpt=excluded.excerpt, + summary=excluded.summary, content_html=excluded.content_html, department_id=excluded.department_id, shadow_density=excluded.shadow_density, @@ -87,30 +98,40 @@ export function registerAdminRoutes(app: any, db: Database.Database) { safer_landing=excluded.safer_landing, read_time_minutes=excluded.read_time_minutes, published_at=excluded.published_at, - updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') + updated_at=excluded.updated_at `); - try { stmt.run( noteId, title, slug, noteLocale, + + noteType, + noteStatus, + dept ?? null, + category || "Uncategorized", excerpt || "", + summary || "", + content_html || null, + department_id || "SCMS", shadow_density ?? 0, coherence_score ?? 1.0, safer_landing ? 1 : 0, read_time_minutes ?? 5, - published_at || new Date().toISOString().slice(0, 10) // YYYY-MM-DD + + normalizedPublishedAt ); return res.status(201).json({ id: noteId, slug, locale: noteLocale, + type: noteType, + status: noteStatus, message: "Note saved with energetic metadata", }); } catch (e: any) { @@ -121,6 +142,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) { } }); + // --------------------------------------------------------------------------- // Auth helpers (always available) // --------------------------------------------------------------------------- @@ -177,7 +199,4 @@ export function registerAdminRoutes(app: any, db: Database.Database) { res.json({ enabled: isGithubOAuthEnabled() }); }); } - app.get("/auth/github", (_req: any, res: { status: (arg0: number) => { (): any; new(): any; json: { (arg0: { enabled: boolean; }): any; new(): any; }; }; }) => res.status(501).json({ enabled: false })); - app.get("/auth/github/callback", (_req: any, res: { redirect: (arg0: string) => any; }) => res.redirect(`${UI_BASE_URL}/login`)); - } diff --git a/src/routes/labNotesRoutes.ts b/src/routes/labNotesRoutes.ts index d50ee82..a5e2edd 100644 --- a/src/routes/labNotesRoutes.ts +++ b/src/routes/labNotesRoutes.ts @@ -4,6 +4,7 @@ import type Database from "better-sqlite3"; import type { LabNoteRecord, TagResult } from "../types/labNotes.js"; import type { UpsertBody } from "../types/UpsertBody.js" import { mapToLabNotePreview, mapToLabNoteView } from "../mappers/labNotesMapper.js"; +import { normalizeLocale } from "../lib/helpers.js" // OPTIONAL: markdown -> html (pick one implementation) // If you already have a markdown renderer elsewhere in the API, use that instead. @@ -13,7 +14,21 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { // Public: Lab Notes list (preview) app.get("/lab-notes", (req: Request, res: Response) => { try { - const locale = String(req.query.locale ?? "en").toLowerCase(); + const baseLocale = (input: unknown) => { + const raw = String(input ?? "en").trim().toLowerCase(); + if (!raw) return "en"; + const two = raw.split(/[-_]/)[0]; + return two === "all" ? "all" : (two || "en"); + }; + + const locale = normalizeLocale(req.query.locale); + + const orderBy = ` + ORDER BY + CASE WHEN published_at IS NULL OR published_at = '' THEN 1 ELSE 0 END, + published_at DESC, + updated_at DESC + `; const sqlAll = ` SELECT @@ -22,7 +37,7 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { published_at, created_at, updated_at FROM v_lab_notes WHERE status != 'archived' - ORDER BY published_at DESC + ${orderBy} `; const sqlByLocale = ` @@ -33,7 +48,7 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { FROM v_lab_notes WHERE locale = ? AND status != 'archived' - ORDER BY published_at DESC + ${orderBy} `; const notes = (locale === "all" @@ -57,8 +72,6 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { } }); - - // Public: single Lab Note (detail) app.get("/lab-notes/:slug", (req: Request, res: Response) => { const { slug } = req.params; @@ -93,16 +106,35 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { const slug = body.slug.trim(); const title = body.title.trim(); - const markdown = body.markdown; + const markdown = String(body.markdown); const html = marked.parse(markdown) as string; + // ✅ locale must match your schema + UI behavior + const locale = normalizeLocale((body as any).locale); + + // tags + metadata const tags = body.tags ?? []; - const department_id = body.department_id ?? "SCMS"; // choose your default + const department_id = body.department_id ?? "SCMS"; + const dept = (body as any).dept ?? null; + const shadow_density = body.shadow_density ?? 0; const safer_landing = body.safer_landing ? 1 : 0; - const read_time_minutes = body.read_time_minutes ?? null; - const published_at = body.published_at ?? null; - const category = body.category ?? (body as any).type ?? null; // map your frontmatter 'type' if you want + const read_time_minutes = body.read_time_minutes ?? 5; + + // type/status (support your taxonomy) + const type = ((body as any).type ?? "labnote"); + const status = + ((body as any).status ?? ((body.published_at ?? "").trim() ? "published" : "draft")); + + // published date only when published + const published_at = + status === "published" + ? ((body.published_at ?? "").trim() || new Date().toISOString().slice(0, 10)) + : null; + + // summary/excerpt + const summary = (body as any).summary ?? null; + const excerpt = body.excerpt ?? markdown @@ -111,76 +143,98 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { .trim() .slice(0, 220); - // Check for existing note + // category: keep if you still use it, otherwise null + const category = body.category ?? null; + + // ✅ check existing by (slug, locale) const existing = db - .prepare("SELECT id FROM lab_notes WHERE slug = ?") - .get(slug) as { id: string } | undefined; + .prepare("SELECT id FROM lab_notes WHERE slug = ? AND locale = ?") + .get(slug, locale) as { id: string } | undefined; - const now = new Date().toISOString(); const noteId = existing?.id ?? crypto.randomUUID(); - // Transaction: upsert note + tags const tx = db.transaction(() => { if (existing) { db.prepare(` - UPDATE lab_notes - SET - title = ?, - excerpt = ?, - content_html = ?, - department_id = ?, - shadow_density = ?, - safer_landing = ?, - read_time_minutes = ?, - published_at = COALESCE(?, published_at), - category = ?, - updated_at = ? - WHERE slug = ? - `).run( + UPDATE lab_notes + SET + title = ?, + excerpt = ?, + summary = ?, + content_html = ?, + department_id = ?, + dept = ?, + type = ?, + status = ?, + shadow_density = ?, + safer_landing = ?, + read_time_minutes = ?, + published_at = ?, + category = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE slug = ? AND locale = ? + `).run( title, excerpt, - markdown, - html, + summary, + html, // ✅ store HTML in content_html department_id, + dept, + type, + status, shadow_density, safer_landing, read_time_minutes, published_at, category, - now, - slug + slug, + locale ); db.prepare("DELETE FROM lab_note_tags WHERE note_id = ?").run(noteId); } else { db.prepare(` - INSERT INTO lab_notes ( - id, slug, title, excerpt, content_html, - department_id, shadow_density, safer_landing, - read_time_minutes, published_at, category, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( + INSERT INTO lab_notes ( + id, group_id, slug, locale, + type, status, title, + category, excerpt, summary, content_html, + department_id, dept, shadow_density, safer_landing, + read_time_minutes, published_at, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, + strftime('%Y-%m-%dT%H:%M:%fZ','now'), + strftime('%Y-%m-%dT%H:%M:%fZ','now') + ) + `).run( noteId, + noteId, // group_id defaulting to noteId keeps v2 happy slug, + locale, + type, + status, title, + category, excerpt, - markdown, - html, + summary, + html, // ✅ store HTML department_id, + dept, shadow_density, safer_landing, read_time_minutes, - published_at, - category, - now, - now + published_at ); } const insertTag = db.prepare( - "INSERT INTO lab_note_tags (note_id, tag) VALUES (?, ?)" + "INSERT OR IGNORE INTO lab_note_tags (note_id, tag) VALUES (?, ?)" ); + for (const t of tags) { const tag = String(t).trim(); if (tag) insertTag.run(noteId, tag); @@ -189,10 +243,17 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { try { tx(); - return res.json({ ok: true, slug, action: existing ? "updated" : "created" }); + return res.json({ + ok: true, + slug, + locale, + id: noteId, + action: existing ? "updated" : "created", + }); } catch (e: any) { return res.status(500).json({ ok: false, error: e?.message ?? "upsert failed" }); } }); + } diff --git a/src/types/UpsertBody.ts b/src/types/UpsertBody.ts index d4eb46f..e143f3d 100644 --- a/src/types/UpsertBody.ts +++ b/src/types/UpsertBody.ts @@ -1,13 +1,27 @@ export type UpsertBody = { + // identity slug: string; title: string; - excerpt?: string; - markdown: string; // raw md from skulk + locale?: string; // e.g. "en", "en-US", "ko" + + // content + markdown: string; // raw md input + excerpt?: string; // optional override + summary?: string; // longer abstract (UI-facing) + + // taxonomy + type?: "labnote" | "paper" | "memo" | "lore"; + status?: "draft" | "published"; + category?: string; // legacy / optional + dept?: string; // human-facing dept label + + // metadata tags?: string[]; department_id?: string; shadow_density?: number; safer_landing?: boolean; read_time_minutes?: number; - published_at?: string; - category?: string; -}; \ No newline at end of file + + // publishing + published_at?: string; // YYYY-MM-DD (only meaningful if published) +}; diff --git a/src/types/labNotes.ts b/src/types/labNotes.ts index 97c1fdb..4f0da2f 100644 --- a/src/types/labNotes.ts +++ b/src/types/labNotes.ts @@ -1,35 +1,43 @@ // src/types/labNotes.ts +export type LabNoteType = "labnote" | "paper" | "memo" | "lore" | "weather"; +export type LabNoteStatus = "published" | "draft" | "archived"; + +export const ALLOWED_NOTE_TYPES: ReadonlySet = new Set([ + "labnote", + "paper", + "memo", + "lore", + "weather", +]); + export interface LabNoteRecord { id: string; title: string; slug: string; - category?: string; // reserved; not yet part of public contract + category?: string; excerpt?: string; department_id?: string; shadow_density?: number; coherence_score?: number; - safer_landing?: number; // 0/1 in sqlite + safer_landing?: number; read_time_minutes?: number; published_at?: string; - // content content_html?: string | null; - // timestamps created_at?: string; updated_at?: string; - // 🆕 optional future fields (safe to add now) subtitle?: string | null; - type?: "labnote" | "paper" | "memo" | null; - status?: "published" | "draft" | "archived" | null; + type?: LabNoteType | null; + status?: LabNoteStatus | null; - dept?: string | null; // human readable label if you store it + dept?: string | null; locale?: string | null; author_kind?: "human" | "ai" | "hybrid" | null; @@ -37,12 +45,10 @@ export interface LabNoteRecord { author_id?: string | null; } - export interface TagResult { tag: string; } -// What the frontend expects (your canonical UI contract) export interface LabNoteView { id: string; slug: string; @@ -54,9 +60,8 @@ export interface LabNoteView { contentHtml: string; published: string; - // 🆕 (recommended) - status?: "published" | "draft" | "archived"; - type?: "labnote" | "paper" | "memo"; + status?: LabNoteStatus; + type?: LabNoteType; dept?: string; locale?: string; @@ -72,8 +77,6 @@ export interface LabNoteView { tags: string[]; readingTime: number; - // optional but nice if you want them later created_at?: string; updated_at?: string; } - diff --git a/tests/admin.auth.test.ts b/tests/admin.auth.test.ts index 5635078..0955884 100644 --- a/tests/admin.auth.test.ts +++ b/tests/admin.auth.test.ts @@ -1,15 +1,16 @@ import request from "supertest"; -import app from "../src/index.js"; import { createTestApp, api } from "./helpers/createTestApp.js"; +// Make admin middleware "configured" for tests +process.env.ADMIN_GITHUB_USERS = "ada"; + describe("Admin auth", () => { - it("POST /api/admin/notes rejects unauthenticated", async () => { + it("POST /admin/notes rejects unauthenticated", async () => { const { app } = createTestApp(); const res = await request(app) .post(api("/admin/notes")) .send({ title: "Nope", slug: "nope", excerpt: "nope" }); expect(res.status).toBe(401); - }); }); diff --git a/tests/helpers/createTestApp.js b/tests/helpers/createTestApp.js index 34a7d86..ab82cd4 100644 --- a/tests/helpers/createTestApp.js +++ b/tests/helpers/createTestApp.js @@ -12,6 +12,12 @@ export function api(path) { } export function createTestApp() { + // ✅ Make admin auth "configured" in tests so unauthenticated requests return 401 + // (otherwise requireAdmin returns 403: "Admin not configured") + if (!(process.env.ADMIN_GITHUB_USERS ?? "").trim()) { + process.env.ADMIN_GITHUB_USERS = "ada"; + } + const app = express(); app.use(express.json()); @@ -20,7 +26,9 @@ export function createTestApp() { registerHealthRoutes(app, db); registerLabNotesRoutes(app, db); registerAdminRoutes(app, db); + bootstrapDb(db); seedMarkerNote(db); + return { app, db }; } diff --git a/tsconfig.json b/tsconfig.json index 51d841c..226eb26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,11 @@ "outDir": "./dist", "rootDir": "./src", "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "baseUrl": "./src", + "paths": { + "@/*": ["*"] + } }, "include": ["src/**/*"], "exclude": ["cli/**/*"]