Improving Win95 styling

This commit is contained in:
2026-07-03 23:57:26 +02:00
parent 0d8d96d5e3
commit 7fae2bd64e
18 changed files with 1471 additions and 200 deletions
+55 -7
View File
@@ -33,6 +33,7 @@ const SKIN_STORAGE_KEY = 'ca-studio-skin'
const GALLERY_TAB_STORAGE_KEY = 'ca-studio-gallery-tab'
const ENGINE_DIMENSION_STORAGE_KEY = 'ca-studio-engine-dimension'
const ICG_DIMENSION_STORAGE_KEY = 'ca-studio-icg-dimension'
const DEFAULT_SKIN = 'windows-95'
const SKINS = [
{ id: 'retro-crt', label: 'Retro CRT' },
@@ -571,7 +572,7 @@ function App() {
const [route, setRoute] = React.useState(routeFromLocation)
const [skin, setSkin] = React.useState<StudioSkin>(() => {
const stored = window.localStorage.getItem(SKIN_STORAGE_KEY)
return SKINS.some((item) => item.id === stored) ? (stored as StudioSkin) : 'retro-crt'
return SKINS.some((item) => item.id === stored) ? (stored as StudioSkin) : DEFAULT_SKIN
})
React.useEffect(() => {
@@ -614,8 +615,16 @@ function AppFooter({
skin: StudioSkin
onSkinChange: (skin: StudioSkin) => void
}) {
const [clock, setClock] = React.useState(() => formatTaskbarClock(new Date()))
React.useEffect(() => {
const timer = window.setInterval(() => setClock(formatTaskbarClock(new Date())), 30_000)
return () => window.clearInterval(timer)
}, [])
return (
<footer className="app-footer">
<div className="footer-start" aria-hidden="true">Start</div>
<p>CA Lab is developed by Glitch University.</p>
<label className="skin-picker">
<span>Skin</span>
@@ -627,10 +636,25 @@ function AppFooter({
))}
</select>
</label>
<div className="footer-clock" aria-label={`Current time ${clock}`}>{clock}</div>
</footer>
)
}
function formatTaskbarClock(date: Date) {
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function Win95WindowControls() {
return (
<span className="win95-window-controls" aria-hidden="true">
<span>_</span>
<span></span>
<span>×</span>
</span>
)
}
function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
const [activeTab, setActiveTab] = React.useState<GalleryTab>(initialTab)
const [engineDimension, setEngineDimension] = React.useState<EngineDimension>(() => {
@@ -960,6 +984,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
</p>
</div>
<div className="status">{status}</div>
<Win95WindowControls />
</header>
<nav aria-label="Gallery sections" className="gallery-tabs">
@@ -999,7 +1024,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
{decks.map((deck) => (
<article
aria-label={`Open ${deck.title}`}
className="deck-card gallery-entity-card panel"
className="deck-card gallery-entity-card deck-entity-card panel"
key={deck.id}
role="link"
tabIndex={0}
@@ -1012,6 +1037,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
}
}}
>
<span className="gallery-card-icon" aria-hidden="true" />
<span>{deck.slug}</span>
<strong>{deck.title}</strong>
<small>{getDeckStudio(deck).presetTreeId ? 'tree selected' : 'choose a node tree in editor'}</small>
@@ -1070,6 +1096,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
}
}}
>
<span className="gallery-card-icon" aria-hidden="true" />
<span>{tree.slug}</span>
<strong>{tree.name}</strong>
<small>
@@ -1133,7 +1160,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
<div className="gallery-list">
{visibleEngines.map((engine) => (
<article
className="deck-card engine-gallery-card panel"
className="deck-card engine-gallery-card engine-entity-card panel"
key={engine.id}
>
<button
@@ -1142,6 +1169,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
type="button"
onClick={() => setEditingEngine(engine)}
>
<span className="gallery-card-icon" aria-hidden="true" />
<span>{engine.engine_kind}</span>
<strong>{engine.name}</strong>
<small>{formatSupportedClasses(engine.supported_classes)}</small>
@@ -1222,7 +1250,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
{visibleIcgs.map((generator) => (
<article
aria-label={`Edit ${generator.name}`}
className="deck-card gallery-entity-card panel"
className="deck-card gallery-entity-card icg-entity-card panel"
key={generator.id}
role="button"
tabIndex={0}
@@ -1235,6 +1263,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
}
}}
>
<span className="gallery-card-icon" aria-hidden="true" />
<span>{generator.generator_kind}</span>
<strong>{generator.name}</strong>
<small>{formatSupportedClasses(generator.supported_classes)}</small>
@@ -1248,6 +1277,11 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
</>
) : null}
</section>
<div className="win95-statusbar" aria-hidden="true">
<span>Objects: {activeTab === 'decks' ? decks.length : activeTab === 'cas' ? presetTrees.length : activeTab === 'engines' ? visibleEngines.length : visibleIcgs.length}</span>
<span>Status: {status}</span>
<span>CA Lab Studio</span>
</div>
{creationOpen ? (
<GalleryCreationModal
activeTab={activeTab}
@@ -1352,7 +1386,10 @@ function GalleryCreationModal({
<p className="eyebrow">Create</p>
<h2>New {labels[activeTab]}</h2>
</div>
<button type="button" onClick={onClose}>Close</button>
<div className="window-action-cluster">
<Win95WindowControls />
<button type="button" onClick={onClose}>Close</button>
</div>
</header>
<div className="gallery-create-body">
{activeTab === 'decks' ? (
@@ -1583,7 +1620,10 @@ function EngineEditModal({
<p className="eyebrow">CA Engine</p>
<h2>Edit {engine.name}</h2>
</div>
<button disabled={saving} type="button" onClick={onClose}>Close</button>
<div className="window-action-cluster">
<Win95WindowControls />
<button disabled={saving} type="button" onClick={onClose}>Close</button>
</div>
</header>
<div className="gallery-create-body">
<EngineConfigFields config={config} onChange={setConfig} />
@@ -1827,7 +1867,10 @@ function IcgEditModal({
<p className="eyebrow">Initial Condition Generator</p>
<h2>Edit {generator.name}</h2>
</div>
<button disabled={saving} type="button" onClick={onClose}>Close</button>
<div className="window-action-cluster">
<Win95WindowControls />
<button disabled={saving} type="button" onClick={onClose}>Close</button>
</div>
</header>
<div className="gallery-create-body">
<IcgConfigFields config={config} onChange={setConfig} />
@@ -2596,6 +2639,7 @@ function LibraryEditor({
</div>
<div className="topbar-actions">
<div className="status">{status}</div>
<Win95WindowControls />
</div>
</header>
@@ -2617,6 +2661,7 @@ function LibraryEditor({
<button aria-label="Close preset drawer" className="icon-button drawer-close" type="button" onClick={() => setTreeDrawerOpen(false)}>
×
</button>
<Win95WindowControls />
</div>
<div className="drawer-scroll">
<div className="root-selector">
@@ -2707,6 +2752,7 @@ function LibraryEditor({
<button aria-label="Close inspector" className="icon-button drawer-close" type="button" onClick={() => setInspectorOpen(false)}>
×
</button>
<Win95WindowControls />
</div>
<div className="inspector-tabs" role="tablist" aria-label="Preset inspector sections">
<button className={inspectorTab === 'ca' ? 'active' : ''} role="tab" type="button" onClick={() => setInspectorTab('ca')}>CA</button>
@@ -3739,6 +3785,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
</div>
<div className="topbar-actions">
<div className="status">{status}</div>
<Win95WindowControls />
</div>
</header>
@@ -3794,6 +3841,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
<h2>{activeScene ? slideTitle : sceneName}</h2>
</div>
<div className="button-row">
<Win95WindowControls />
<button
aria-label="Previous slide"
className="deck-nav-button"
+244 -1
View File
@@ -360,15 +360,52 @@ label {
color: var(--text-muted);
}
.footer-start,
.footer-clock,
.win95-window-controls,
.win95-statusbar,
.gallery-card-icon {
display: none;
}
:root[data-skin="windows-95"] .app-footer {
width: 100%;
margin-top: 0;
padding: 4px 8px;
padding: 4px 6px;
border-top: 2px solid #ffffff;
background: #c0c0c0;
color: #000000;
}
:root[data-skin="windows-95"] .footer-start,
:root[data-skin="windows-95"] .footer-clock {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 3px 10px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #c0c0c0;
color: #000000;
font-weight: 700;
line-height: 1;
}
:root[data-skin="windows-95"] .footer-start::before {
content: "▦";
margin-right: 6px;
color: #008000;
font-size: 0.95rem;
}
:root[data-skin="windows-95"] .footer-clock {
min-width: 76px;
border-color: #404040 #ffffff #ffffff #404040;
font-family: var(--admin-font-mono);
font-weight: 400;
}
.app-footer p {
margin: 0;
font-family: var(--admin-font-mono);
@@ -397,6 +434,23 @@ label {
color: #000000;
}
:root[data-skin="windows-95"] .app-footer p {
flex: 1;
min-height: 28px;
padding: 6px 10px;
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.window-action-cluster {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-field {
display: flex;
align-items: center;
@@ -556,6 +610,34 @@ h2 {
width: 100%;
}
:root[data-skin="windows-95"] .win95-window-controls {
display: inline-grid;
grid-auto-flow: column;
gap: 2px;
margin-left: auto;
}
:root[data-skin="windows-95"] .win95-window-controls span {
display: grid;
place-items: center;
width: 18px;
height: 17px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #c0c0c0;
color: #000000;
font-family: "MS Sans Serif", Tahoma, Arial, sans-serif;
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
}
:root[data-skin="windows-95"] .gallery-hero .win95-window-controls {
position: absolute;
top: 7px;
right: 7px;
}
:root[data-skin="windows-95"] .gallery-tabs {
gap: 0;
margin-top: 10px;
@@ -590,6 +672,97 @@ h2 {
transform: none;
}
:root[data-skin="windows-95"] .gallery-list {
gap: 8px;
}
:root[data-skin="windows-95"] .deck-card {
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
min-height: 78px;
gap: 4px 10px;
}
:root[data-skin="windows-95"] .engine-gallery-card {
display: block;
}
:root[data-skin="windows-95"] .engine-card-main {
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 4px 10px;
border: 0;
}
:root[data-skin="windows-95"] .gallery-card-icon {
display: grid;
grid-row: 1 / span 3;
place-items: center;
width: 30px;
height: 30px;
color: #000000;
font-size: 1.35rem;
line-height: 1;
}
:root[data-skin="windows-95"] .deck-entity-card .gallery-card-icon::before {
content: "▤";
color: #000080;
}
:root[data-skin="windows-95"] .library-card .gallery-card-icon::before {
content: "▰";
color: #d4a000;
text-shadow: 1px 0 #ffff80;
}
:root[data-skin="windows-95"] .engine-entity-card .gallery-card-icon::before {
content: "⚙";
color: #404040;
}
:root[data-skin="windows-95"] .icg-entity-card .gallery-card-icon::before {
content: "✣";
color: #008000;
}
:root[data-skin="windows-95"] .deck-card > span:not(.gallery-card-icon),
:root[data-skin="windows-95"] .deck-card > strong,
:root[data-skin="windows-95"] .deck-card > small,
:root[data-skin="windows-95"] .engine-card-main > span:not(.gallery-card-icon),
:root[data-skin="windows-95"] .engine-card-main > strong,
:root[data-skin="windows-95"] .engine-card-main > small {
grid-column: 2;
}
:root[data-skin="windows-95"] .gallery-card-controls {
grid-column: 3;
grid-row: 1 / span 3;
}
:root[data-skin="windows-95"] .win95-statusbar {
display: grid;
grid-template-columns: minmax(110px, auto) minmax(0, 1fr) auto;
gap: 4px;
padding: 4px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #c0c0c0;
color: #000000;
font-family: var(--admin-font-mono);
font-size: 0.76rem;
}
:root[data-skin="windows-95"] .win95-statusbar span {
min-width: 0;
padding: 3px 6px;
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:root[data-skin="windows-95"] .deck-card strong,
:root[data-skin="windows-95"] h1,
:root[data-skin="windows-95"] h2,
@@ -2234,6 +2407,53 @@ h2 {
border-bottom: 1px solid var(--border);
}
:root[data-skin="windows-95"] .modal-backdrop {
background:
repeating-linear-gradient(45deg, rgb(0 0 0 / 0.06) 0 2px, transparent 2px 4px),
rgb(0 0 0 / 0.36);
backdrop-filter: none;
}
:root[data-skin="windows-95"] .property-modal {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: #c0c0c0;
box-shadow: 4px 4px 0 rgb(0 0 0 / 0.42);
}
:root[data-skin="windows-95"] .property-modal-header {
margin: 2px;
padding: 5px 7px;
border: 0;
background: linear-gradient(90deg, #000080, #1084d0);
color: #ffffff;
}
:root[data-skin="windows-95"] .property-modal-header h2,
:root[data-skin="windows-95"] .property-modal-header .eyebrow {
color: #ffffff;
}
:root[data-skin="windows-95"] .gallery-create-body,
:root[data-skin="windows-95"] .property-modal-list,
:root[data-skin="windows-95"] .slide-metadata-body {
margin: 2px;
border: 2px solid;
border-color: #808080 #ffffff #ffffff #808080;
background: #c0c0c0;
}
:root[data-skin="windows-95"] .preset-modal-stage,
:root[data-skin="windows-95"] .property-row,
:root[data-skin="windows-95"] .slide-ca-asset,
:root[data-skin="windows-95"] .slide-metadata-grid span {
border: 2px solid;
border-color: #808080 #ffffff #ffffff #808080;
border-radius: 0;
background: #d0d0d0;
}
.property-modal-list {
display: grid;
gap: 8px;
@@ -2908,6 +3128,29 @@ h2 {
}
}
@media (max-width: 1180px) {
:root[data-skin="windows-95"] .app-footer {
align-items: center;
flex-flow: row wrap;
gap: 4px;
}
:root[data-skin="windows-95"] .app-footer p {
order: 3;
width: 100%;
flex-basis: 100%;
}
:root[data-skin="windows-95"] .skin-picker {
flex: 1;
min-width: 172px;
}
:root[data-skin="windows-95"] .footer-clock {
margin-left: auto;
}
}
@media (max-width: 560px) {
.gallery-tabs {
gap: 3px;
+8 -1
View File
@@ -1,6 +1,8 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { requireLabAccess } from './labAuth.js';
import { createCaEngine, createDeck, createInitialConditionGenerator, createPresetNode, createPresetTree, createScene, deleteDeck, deletePresetNode, deletePresetTree, deleteScene, getCaEngine, getDeck, getInitialConditionGenerator, getPresetNode, getScene, listCaEngines, listCaEnginePresetUsage, listDecks, listInitialConditionGenerators, listPresetNodeUsage, listPresetNodes, listPresetTrees, listScenes, resolveDeck, resolvePresetNode, resolveScene, updateCaEngine, updateDeck, updateInitialConditionGenerator, updatePresetNode, updateScene } from './caStudioRepository.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const adminPublicPath = resolve(__dirname, '../public/admin');
@@ -42,9 +44,14 @@ function caClassFromQuery(query) {
}
export function createCaStudioApi(db) {
const app = express();
const labAccess = requireLabAccess();
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
app.use('/admin', labAccess);
app.use('/view', labAccess);
app.use('/api/ca', labAccess);
app.use('/admin', express.static(adminPublicPath));
app.get('/', (_request, response) => {
app.get('/', labAccess, (_request, response) => {
response.redirect('/admin');
});
app.get(/^\/(?:admin|view)(?:\/.*)?$/, (_request, response) => {
+1 -1
View File
File diff suppressed because one or more lines are too long
+236
View File
@@ -0,0 +1,236 @@
import jwt from 'jsonwebtoken';
function envFlag(name, fallback) {
const value = process.env[name];
if (value === undefined)
return fallback;
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
}
function trimTrailingSlash(value) {
return value.replace(/\/+$/, '');
}
export function labAuthConfig() {
const gnommowebUrl = trimTrailingSlash(process.env.GNOMMOWEB_URL ?? 'https://glitch.university');
return {
enabled: envFlag('LAB_AUTH_ENABLED', process.env.NODE_ENV === 'production'),
gnommowebUrl,
jwtSecret: process.env.JWT_SECRET ?? '',
keycardMeritSlug: process.env.LAB_KEYCARD_MERIT_SLUG ?? 'lab-keycard',
keycardRequired: envFlag('LAB_KEYCARD_REQUIRED', true),
signInUrl: process.env.LAB_SIGN_IN_URL ?? `${gnommowebUrl}/auth/google`,
userProfileUrl: process.env.LAB_USER_PROFILE_URL ?? `${gnommowebUrl}/api/user/profile`
};
}
function wantsHtml(request) {
const accept = request.get('accept') ?? '';
return accept.includes('text/html') || accept.includes('*/*');
}
function currentUrl(request) {
const proto = request.get('x-forwarded-proto') ?? request.protocol;
const host = request.get('x-forwarded-host') ?? request.get('host') ?? 'localhost';
return `${proto}://${host}${request.originalUrl}`;
}
function loginUrl(request, config) {
const returnTo = currentUrl(request);
if (config.signInUrl.includes('{returnTo}')) {
return config.signInUrl.replace('{returnTo}', encodeURIComponent(returnTo));
}
const url = new URL(config.signInUrl);
url.searchParams.set('returnTo', returnTo);
return url.toString();
}
function lockedPage(access, config) {
const unlockUrl = access.unlockUrl ?? `${config.gnommowebUrl}/tech-tree`;
const reason = access.reason ?? 'Your Glitch University account does not have the CA Lab Keycard yet.';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CA Lab Locked</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #008080;
color: #000;
font-family: "MS Sans Serif", Tahoma, Arial, sans-serif;
}
main {
width: min(520px, calc(100vw - 32px));
border: 2px solid;
border-color: #fff #404040 #404040 #fff;
background: #c0c0c0;
box-shadow: 4px 4px 0 rgb(0 0 0 / 0.35);
}
header {
padding: 6px 8px;
background: linear-gradient(90deg, #000080, #1084d0);
color: #fff;
font-weight: 700;
}
section { padding: 18px; }
h1 { margin: 0 0 10px; font-size: 1.2rem; }
p { line-height: 1.45; }
a {
display: inline-flex;
align-items: center;
min-height: 34px;
margin-top: 10px;
padding: 0 14px;
border: 2px solid;
border-color: #fff #404040 #404040 #fff;
background: #c0c0c0;
color: #000;
text-decoration: none;
}
</style>
</head>
<body>
<main>
<header>CA Lab Computer</header>
<section>
<h1>Lab keycard required</h1>
<p>${escapeHtml(reason)}</p>
<p>Earn the Cellular Automata lab merit on glitch.university, then return here.</p>
<a href="${escapeHtml(unlockUrl)}">Go to Glitch University</a>
</section>
</main>
</body>
</html>`;
}
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function isMeritSlug(value, requiredSlug) {
return typeof value === 'string' && value.trim() === requiredSlug;
}
function collectionHasMeritSlug(value, requiredSlug) {
if (!Array.isArray(value))
return false;
return value.some((entry) => {
if (isMeritSlug(entry, requiredSlug))
return true;
if (!entry || typeof entry !== 'object')
return false;
const record = entry;
return isMeritSlug(record.slug, requiredSlug);
});
}
function payloadHasMeritSlug(payload, requiredSlug) {
if (!payload || typeof payload !== 'object')
return false;
const record = payload;
const user = record.user && typeof record.user === 'object'
? record.user
: undefined;
return (isMeritSlug(record.merit_slug, requiredSlug) ||
collectionHasMeritSlug(record.merit_slugs, requiredSlug) ||
collectionHasMeritSlug(record.merits, requiredSlug) ||
(user ? collectionHasMeritSlug(user.merits, requiredSlug) : false));
}
function jwtClaimsHaveKeycard(user, config) {
if (!user)
return false;
return isMeritSlug(user.merit_slug, config.keycardMeritSlug) ||
collectionHasMeritSlug(user.merit_slugs, config.keycardMeritSlug);
}
async function checkUserProfileForKeycard(token, config) {
const response = await fetch(config.userProfileUrl, {
headers: {
accept: 'application/json',
cookie: `auth_token=${encodeURIComponent(token)}`
}
});
if (!response.ok) {
return {
allowed: false,
reason: response.status === 401
? 'Sign in to glitch.university to access the CA lab.'
: `The CA Lab Keycard could not be verified. The user profile endpoint returned HTTP ${response.status}.`
};
}
const profile = await response.json();
if (payloadHasMeritSlug(profile, config.keycardMeritSlug)) {
return { allowed: true, reason: 'CA Lab Keycard found in gnommoweb user profile.' };
}
return {
allowed: false,
reason: 'Your Glitch University account does not have the CA Lab Keycard yet.'
};
}
export function requireLabAccess(config = labAuthConfig()) {
return async (request, response, next) => {
if (!config.enabled) {
next();
return;
}
if (!config.jwtSecret) {
response.status(500).json({ error: 'Lab authentication is enabled but JWT_SECRET is not configured' });
return;
}
const token = request.cookies?.auth_token;
if (!token) {
if (wantsHtml(request)) {
response.redirect(loginUrl(request, config));
return;
}
response.status(401).json({
error: 'Authentication required',
signInUrl: loginUrl(request, config)
});
return;
}
try {
request.labUser = jwt.verify(token, config.jwtSecret);
}
catch {
if (wantsHtml(request)) {
response.redirect(loginUrl(request, config));
return;
}
response.status(401).json({
error: 'Invalid or expired token',
signInUrl: loginUrl(request, config)
});
return;
}
if (!config.keycardRequired) {
next();
return;
}
if (jwtClaimsHaveKeycard(request.labUser, config)) {
request.labAccess = { allowed: true, reason: 'CA Lab Keycard found in JWT claims.' };
next();
return;
}
let access;
try {
access = await checkUserProfileForKeycard(token, config);
}
catch {
access = { allowed: false, reason: 'The CA Lab Keycard service is unavailable.' };
}
request.labAccess = access;
if (access.allowed === true) {
next();
return;
}
if (wantsHtml(request)) {
response.status(403).type('html').send(lockedPage(access, config));
return;
}
response.status(403).json({
error: 'CA Lab Keycard required',
reason: access.reason,
unlockUrl: access.unlockUrl ?? `${config.gnommowebUrl}/tech-tree`
});
};
}
//# sourceMappingURL=labAuth.js.map
+1
View File
File diff suppressed because one or more lines are too long
+183
View File
@@ -8,7 +8,9 @@
"name": "@glitch-components/voxel-automata-lab-backend",
"version": "0.1.0",
"dependencies": {
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.16.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
@@ -16,7 +18,9 @@
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.3",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.2",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.7",
@@ -1290,6 +1294,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -1343,6 +1357,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -1350,6 +1375,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
@@ -1735,6 +1767,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1865,6 +1903,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -1961,6 +2018,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2508,6 +2574,61 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2782,6 +2903,48 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3325,6 +3488,26 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+4
View File
@@ -16,7 +16,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.16.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
@@ -24,7 +26,9 @@
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.3",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.2",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.7",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CA Studio Admin</title>
<script type="module" crossorigin src="/admin/assets/index-BXPDm7zD.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-DN6CRbyT.css">
<script type="module" crossorigin src="/admin/assets/index-Bo_SPfbl.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-C7KqEE0-.css">
</head>
<body>
<div id="root"></div>
+8 -1
View File
@@ -1,7 +1,9 @@
import express from 'express'
import cookieParser from 'cookie-parser'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Queryable } from './db.js'
import { requireLabAccess } from './labAuth.js'
import {
createCaEngine,
createDeck,
@@ -84,10 +86,15 @@ function caClassFromQuery(query: express.Request['query']) {
export function createCaStudioApi(db: Queryable) {
const app = express()
const labAccess = requireLabAccess()
app.use(cookieParser())
app.use(express.json({ limit: '1mb' }))
app.use('/admin', labAccess)
app.use('/view', labAccess)
app.use('/api/ca', labAccess)
app.use('/admin', express.static(adminPublicPath))
app.get('/', (_request, response) => {
app.get('/', labAccess, (_request, response) => {
response.redirect('/admin')
})
+297
View File
@@ -0,0 +1,297 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express'
import jwt from 'jsonwebtoken'
interface LabJwtClaims extends jwt.JwtPayload {
email?: string
id?: number
isAdmin?: boolean
merit_slug?: unknown
merit_slugs?: unknown
name?: string
picture?: string | null
role?: string
sub?: string
}
interface LabAccessResponse {
allowed?: boolean
reason?: string
unlockUrl?: string
}
interface LabAuthConfig {
enabled: boolean
gnommowebUrl: string
jwtSecret: string
keycardMeritSlug: string
keycardRequired: boolean
signInUrl: string
userProfileUrl: string
}
declare global {
namespace Express {
interface Request {
labUser?: LabJwtClaims
labAccess?: LabAccessResponse
}
}
}
function envFlag(name: string, fallback: boolean) {
const value = process.env[name]
if (value === undefined) return fallback
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase())
}
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, '')
}
export function labAuthConfig(): LabAuthConfig {
const gnommowebUrl = trimTrailingSlash(process.env.GNOMMOWEB_URL ?? 'https://glitch.university')
return {
enabled: envFlag('LAB_AUTH_ENABLED', process.env.NODE_ENV === 'production'),
gnommowebUrl,
jwtSecret: process.env.JWT_SECRET ?? '',
keycardMeritSlug: process.env.LAB_KEYCARD_MERIT_SLUG ?? 'lab-keycard',
keycardRequired: envFlag('LAB_KEYCARD_REQUIRED', true),
signInUrl: process.env.LAB_SIGN_IN_URL ?? `${gnommowebUrl}/auth/google`,
userProfileUrl: process.env.LAB_USER_PROFILE_URL ?? `${gnommowebUrl}/api/user/profile`
}
}
function wantsHtml(request: Request) {
const accept = request.get('accept') ?? ''
return accept.includes('text/html') || accept.includes('*/*')
}
function currentUrl(request: Request) {
const proto = request.get('x-forwarded-proto') ?? request.protocol
const host = request.get('x-forwarded-host') ?? request.get('host') ?? 'localhost'
return `${proto}://${host}${request.originalUrl}`
}
function loginUrl(request: Request, config: LabAuthConfig) {
const returnTo = currentUrl(request)
if (config.signInUrl.includes('{returnTo}')) {
return config.signInUrl.replace('{returnTo}', encodeURIComponent(returnTo))
}
const url = new URL(config.signInUrl)
url.searchParams.set('returnTo', returnTo)
return url.toString()
}
function lockedPage(access: LabAccessResponse, config: LabAuthConfig) {
const unlockUrl = access.unlockUrl ?? `${config.gnommowebUrl}/tech-tree`
const reason = access.reason ?? 'Your Glitch University account does not have the CA Lab Keycard yet.'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CA Lab Locked</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #008080;
color: #000;
font-family: "MS Sans Serif", Tahoma, Arial, sans-serif;
}
main {
width: min(520px, calc(100vw - 32px));
border: 2px solid;
border-color: #fff #404040 #404040 #fff;
background: #c0c0c0;
box-shadow: 4px 4px 0 rgb(0 0 0 / 0.35);
}
header {
padding: 6px 8px;
background: linear-gradient(90deg, #000080, #1084d0);
color: #fff;
font-weight: 700;
}
section { padding: 18px; }
h1 { margin: 0 0 10px; font-size: 1.2rem; }
p { line-height: 1.45; }
a {
display: inline-flex;
align-items: center;
min-height: 34px;
margin-top: 10px;
padding: 0 14px;
border: 2px solid;
border-color: #fff #404040 #404040 #fff;
background: #c0c0c0;
color: #000;
text-decoration: none;
}
</style>
</head>
<body>
<main>
<header>CA Lab Computer</header>
<section>
<h1>Lab keycard required</h1>
<p>${escapeHtml(reason)}</p>
<p>Earn the Cellular Automata lab merit on glitch.university, then return here.</p>
<a href="${escapeHtml(unlockUrl)}">Go to Glitch University</a>
</section>
</main>
</body>
</html>`
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
}
function isMeritSlug(value: unknown, requiredSlug: string) {
return typeof value === 'string' && value.trim() === requiredSlug
}
function collectionHasMeritSlug(value: unknown, requiredSlug: string): boolean {
if (!Array.isArray(value)) return false
return value.some((entry) => {
if (isMeritSlug(entry, requiredSlug)) return true
if (!entry || typeof entry !== 'object') return false
const record = entry as Record<string, unknown>
return isMeritSlug(record.slug, requiredSlug)
})
}
function payloadHasMeritSlug(payload: unknown, requiredSlug: string) {
if (!payload || typeof payload !== 'object') return false
const record = payload as Record<string, unknown>
const user = record.user && typeof record.user === 'object'
? record.user as Record<string, unknown>
: undefined
return (
isMeritSlug(record.merit_slug, requiredSlug) ||
collectionHasMeritSlug(record.merit_slugs, requiredSlug) ||
collectionHasMeritSlug(record.merits, requiredSlug) ||
(user ? collectionHasMeritSlug(user.merits, requiredSlug) : false)
)
}
function jwtClaimsHaveKeycard(user: LabJwtClaims | undefined, config: LabAuthConfig) {
if (!user) return false
return isMeritSlug(user.merit_slug, config.keycardMeritSlug) ||
collectionHasMeritSlug(user.merit_slugs, config.keycardMeritSlug)
}
async function checkUserProfileForKeycard(token: string, config: LabAuthConfig): Promise<LabAccessResponse> {
const response = await fetch(config.userProfileUrl, {
headers: {
accept: 'application/json',
cookie: `auth_token=${encodeURIComponent(token)}`
}
})
if (!response.ok) {
return {
allowed: false,
reason: response.status === 401
? 'Sign in to glitch.university to access the CA lab.'
: `The CA Lab Keycard could not be verified. The user profile endpoint returned HTTP ${response.status}.`
}
}
const profile = await response.json()
if (payloadHasMeritSlug(profile, config.keycardMeritSlug)) {
return { allowed: true, reason: 'CA Lab Keycard found in gnommoweb user profile.' }
}
return {
allowed: false,
reason: 'Your Glitch University account does not have the CA Lab Keycard yet.'
}
}
export function requireLabAccess(config: LabAuthConfig = labAuthConfig()): RequestHandler {
return async (request: Request, response: Response, next: NextFunction) => {
if (!config.enabled) {
next()
return
}
if (!config.jwtSecret) {
response.status(500).json({ error: 'Lab authentication is enabled but JWT_SECRET is not configured' })
return
}
const token = request.cookies?.auth_token
if (!token) {
if (wantsHtml(request)) {
response.redirect(loginUrl(request, config))
return
}
response.status(401).json({
error: 'Authentication required',
signInUrl: loginUrl(request, config)
})
return
}
try {
request.labUser = jwt.verify(token, config.jwtSecret) as LabJwtClaims
} catch {
if (wantsHtml(request)) {
response.redirect(loginUrl(request, config))
return
}
response.status(401).json({
error: 'Invalid or expired token',
signInUrl: loginUrl(request, config)
})
return
}
if (!config.keycardRequired) {
next()
return
}
if (jwtClaimsHaveKeycard(request.labUser, config)) {
request.labAccess = { allowed: true, reason: 'CA Lab Keycard found in JWT claims.' }
next()
return
}
let access: LabAccessResponse
try {
access = await checkUserProfileForKeycard(token, config)
} catch {
access = { allowed: false, reason: 'The CA Lab Keycard service is unavailable.' }
}
request.labAccess = access
if (access.allowed === true) {
next()
return
}
if (wantsHtml(request)) {
response.status(403).type('html').send(lockedPage(access, config))
return
}
response.status(403).json({
error: 'CA Lab Keycard required',
reason: access.reason,
unlockUrl: access.unlockUrl ?? `${config.gnommowebUrl}/tech-tree`
})
}
}
+200
View File
@@ -0,0 +1,200 @@
import express from 'express'
import cookieParser from 'cookie-parser'
import jwt from 'jsonwebtoken'
import request from 'supertest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { requireLabAccess } from '../src/labAuth.js'
const secret = 'test-lab-secret'
function testApp(config: Parameters<typeof requireLabAccess>[0]) {
const app = express()
app.use(cookieParser())
app.use(requireLabAccess(config))
app.get('/admin', (_request, response) => response.send('ok'))
app.get('/api/ca/decks', (_request, response) => response.json([{ id: 'deck' }]))
return app
}
function authCookie(claims: object = {}) {
return `auth_token=${jwt.sign({ sub: '42', email: 'student@glitch.university', ...claims }, secret)}`
}
describe('lab auth middleware', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('allows requests when lab auth is disabled', async () => {
const app = testApp({
enabled: false,
gnommowebUrl: 'https://glitch.university',
jwtSecret: '',
keycardMeritSlug: 'lab-keycard',
keycardRequired: true,
signInUrl: 'https://glitch.university/auth/google',
userProfileUrl: 'https://glitch.university/api/user/profile'
})
await request(app).get('/admin').expect(200, 'ok')
})
it('redirects browser requests without a shared auth cookie', async () => {
const app = testApp({
enabled: true,
gnommowebUrl: 'https://glitch.university',
jwtSecret: secret,
keycardMeritSlug: 'lab-keycard',
keycardRequired: true,
signInUrl: 'https://glitch.university/auth/google?returnTo={returnTo}',
userProfileUrl: 'https://glitch.university/api/user/profile'
})
const response = await request(app)
.get('/admin')
.set('accept', 'text/html')
.set('host', 'lab.glitch.university')
.set('x-forwarded-proto', 'https')
.expect(302)
expect(response.headers.location).toContain('https://glitch.university/auth/google?returnTo=')
expect(decodeURIComponent(response.headers.location)).toContain('https://lab.glitch.university/admin')
})
it('rejects API requests with an invalid shared auth cookie', async () => {
const app = testApp({
enabled: true,
gnommowebUrl: 'https://glitch.university',
jwtSecret: secret,
keycardMeritSlug: 'lab-keycard',
keycardRequired: true,
signInUrl: 'https://glitch.university/auth/google',
userProfileUrl: 'https://glitch.university/api/user/profile'
})
await request(app)
.get('/api/ca/decks')
.set('cookie', 'auth_token=not-a-real-jwt')
.set('accept', 'application/json')
.expect(401)
.expect((response) => {
expect(response.body.error).toBe('Invalid or expired token')
})
})
it('allows valid JWTs when keycard checks are disabled', async () => {
const app = testApp({
enabled: true,
gnommowebUrl: 'https://glitch.university',
jwtSecret: secret,
keycardMeritSlug: 'lab-keycard',
keycardRequired: false,
signInUrl: 'https://glitch.university/auth/google',
userProfileUrl: 'https://glitch.university/api/user/profile'
})
await request(app).get('/api/ca/decks').set('cookie', authCookie()).expect(200)
})
it('allows valid JWTs that include a lab keycard claim', async () => {
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
const app = testApp({
enabled: true,
gnommowebUrl: 'https://glitch.university',
jwtSecret: secret,
keycardMeritSlug: 'lab-keycard',
keycardRequired: true,
signInUrl: 'https://glitch.university/auth/google',
userProfileUrl: 'https://glitch.university/api/user/profile'
})
await request(app)
.get('/api/ca/decks')
.set('cookie', authCookie({ merit_slug: 'lab-keycard' }))
.expect(200)
expect(fetchSpy).not.toHaveBeenCalled()
})
it('allows valid JWTs when the gnommoweb user profile includes the merit slug', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
merits: [{ slug: 'lab-keycard' }]
}), {
headers: { 'content-type': 'application/json' },
status: 200
})))
const app = testApp({
enabled: true,
gnommowebUrl: 'https://glitch.university',
jwtSecret: secret,
keycardMeritSlug: 'lab-keycard',
keycardRequired: true,
signInUrl: 'https://glitch.university/auth/google',
userProfileUrl: 'https://glitch.university/api/user/profile'
})
await request(app)
.get('/api/ca/decks')
.set('cookie', authCookie())
.set('accept', 'application/json')
.expect(200)
})
it('returns a locked response when the gnommoweb user profile lacks the merit slug', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
merits: [{ slug: 'other_merit' }]
}), {
headers: { 'content-type': 'application/json' },
status: 200
})))
const app = testApp({
enabled: true,
gnommowebUrl: 'https://glitch.university',
jwtSecret: secret,
keycardMeritSlug: 'lab-keycard',
keycardRequired: true,
signInUrl: 'https://glitch.university/auth/google',
userProfileUrl: 'https://glitch.university/api/user/profile'
})
await request(app)
.get('/admin')
.set('cookie', authCookie())
.set('accept', 'text/html')
.expect(403)
.expect((response) => {
expect(response.text).toContain('Lab keycard required')
expect(response.text).toContain('does not have the CA Lab Keycard yet')
})
})
it('reports a missing gnommoweb user profile endpoint clearly', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'Not found' }), {
headers: { 'content-type': 'application/json' },
status: 404
})))
const app = testApp({
enabled: true,
gnommowebUrl: 'https://glitch.university',
jwtSecret: secret,
keycardMeritSlug: 'lab-keycard',
keycardRequired: true,
signInUrl: 'https://glitch.university/auth/google',
userProfileUrl: 'https://glitch.university/api/user/profile'
})
await request(app)
.get('/api/ca/decks')
.set('cookie', authCookie())
.set('accept', 'application/json')
.expect(403)
.expect((response) => {
expect(response.body.reason).toContain('HTTP 404')
})
})
})