Improving Win95 styling
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Deploy CA Lab Studio
|
# Deploy CA Lab Studio
|
||||||
|
|
||||||
Deploy `glitch_voxel_automata_lab`, not `glitch_voxel_automata`.
|
Deploy this CA Lab Studio app into `/opt/glitch_automata_lab` on the server.
|
||||||
|
|
||||||
This app is the CA Lab Studio service:
|
This app is the CA Lab Studio service:
|
||||||
|
|
||||||
@@ -8,6 +8,42 @@ This app is the CA Lab Studio service:
|
|||||||
- React admin app at `/admin`
|
- React admin app at `/admin`
|
||||||
- React viewer/recorder routes at `/view/*`
|
- React viewer/recorder routes at `/view/*`
|
||||||
- Postgres database via `DATABASE_URL`
|
- Postgres database via `DATABASE_URL`
|
||||||
|
- Cross-subdomain lab access via the shared gnommoweb `auth_token` JWT cookie
|
||||||
|
|
||||||
|
## Lab Access
|
||||||
|
|
||||||
|
In production, the lab expects gnommoweb to be the identity issuer:
|
||||||
|
|
||||||
|
```env
|
||||||
|
JWT_SECRET=... # same value as gnommoweb
|
||||||
|
LAB_AUTH_ENABLED=true
|
||||||
|
LAB_KEYCARD_REQUIRED=true
|
||||||
|
GNOMMOWEB_URL=https://glitch.university
|
||||||
|
LAB_KEYCARD_MERIT_SLUG=lab-keycard
|
||||||
|
LAB_USER_PROFILE_URL=https://glitch.university/api/user/profile
|
||||||
|
LAB_SIGN_IN_URL=https://glitch.university/auth/google?returnTo={returnTo}
|
||||||
|
```
|
||||||
|
|
||||||
|
The gnommoweb side should set `auth_token` with `Domain=.glitch.university`
|
||||||
|
and expose `LAB_USER_PROFILE_URL`. Access is granted immediately if the verified
|
||||||
|
JWT contains:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "merit_slug": "lab-keycard" }
|
||||||
|
```
|
||||||
|
|
||||||
|
If the JWT does not contain that claim, the lab calls the user profile endpoint
|
||||||
|
with the same `auth_token` cookie and expects the profile to include:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"merits": [
|
||||||
|
{ "slug": "lab-keycard" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Local development keeps auth disabled unless `LAB_AUTH_ENABLED=true` is set.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const SKIN_STORAGE_KEY = 'ca-studio-skin'
|
|||||||
const GALLERY_TAB_STORAGE_KEY = 'ca-studio-gallery-tab'
|
const GALLERY_TAB_STORAGE_KEY = 'ca-studio-gallery-tab'
|
||||||
const ENGINE_DIMENSION_STORAGE_KEY = 'ca-studio-engine-dimension'
|
const ENGINE_DIMENSION_STORAGE_KEY = 'ca-studio-engine-dimension'
|
||||||
const ICG_DIMENSION_STORAGE_KEY = 'ca-studio-icg-dimension'
|
const ICG_DIMENSION_STORAGE_KEY = 'ca-studio-icg-dimension'
|
||||||
|
const DEFAULT_SKIN = 'windows-95'
|
||||||
|
|
||||||
const SKINS = [
|
const SKINS = [
|
||||||
{ id: 'retro-crt', label: 'Retro CRT' },
|
{ id: 'retro-crt', label: 'Retro CRT' },
|
||||||
@@ -571,7 +572,7 @@ function App() {
|
|||||||
const [route, setRoute] = React.useState(routeFromLocation)
|
const [route, setRoute] = React.useState(routeFromLocation)
|
||||||
const [skin, setSkin] = React.useState<StudioSkin>(() => {
|
const [skin, setSkin] = React.useState<StudioSkin>(() => {
|
||||||
const stored = window.localStorage.getItem(SKIN_STORAGE_KEY)
|
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(() => {
|
React.useEffect(() => {
|
||||||
@@ -614,8 +615,16 @@ function AppFooter({
|
|||||||
skin: StudioSkin
|
skin: StudioSkin
|
||||||
onSkinChange: (skin: StudioSkin) => void
|
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 (
|
return (
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
|
<div className="footer-start" aria-hidden="true">Start</div>
|
||||||
<p>CA Lab is developed by Glitch University.</p>
|
<p>CA Lab is developed by Glitch University.</p>
|
||||||
<label className="skin-picker">
|
<label className="skin-picker">
|
||||||
<span>Skin</span>
|
<span>Skin</span>
|
||||||
@@ -627,10 +636,25 @@ function AppFooter({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="footer-clock" aria-label={`Current time ${clock}`}>{clock}</div>
|
||||||
</footer>
|
</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 }) {
|
function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
||||||
const [activeTab, setActiveTab] = React.useState<GalleryTab>(initialTab)
|
const [activeTab, setActiveTab] = React.useState<GalleryTab>(initialTab)
|
||||||
const [engineDimension, setEngineDimension] = React.useState<EngineDimension>(() => {
|
const [engineDimension, setEngineDimension] = React.useState<EngineDimension>(() => {
|
||||||
@@ -960,6 +984,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="status">{status}</div>
|
<div className="status">{status}</div>
|
||||||
|
<Win95WindowControls />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav aria-label="Gallery sections" className="gallery-tabs">
|
<nav aria-label="Gallery sections" className="gallery-tabs">
|
||||||
@@ -999,7 +1024,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
|||||||
{decks.map((deck) => (
|
{decks.map((deck) => (
|
||||||
<article
|
<article
|
||||||
aria-label={`Open ${deck.title}`}
|
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}
|
key={deck.id}
|
||||||
role="link"
|
role="link"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -1012,6 +1037,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span className="gallery-card-icon" aria-hidden="true" />
|
||||||
<span>{deck.slug}</span>
|
<span>{deck.slug}</span>
|
||||||
<strong>{deck.title}</strong>
|
<strong>{deck.title}</strong>
|
||||||
<small>{getDeckStudio(deck).presetTreeId ? 'tree selected' : 'choose a node tree in editor'}</small>
|
<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>
|
<span>{tree.slug}</span>
|
||||||
<strong>{tree.name}</strong>
|
<strong>{tree.name}</strong>
|
||||||
<small>
|
<small>
|
||||||
@@ -1133,7 +1160,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
|||||||
<div className="gallery-list">
|
<div className="gallery-list">
|
||||||
{visibleEngines.map((engine) => (
|
{visibleEngines.map((engine) => (
|
||||||
<article
|
<article
|
||||||
className="deck-card engine-gallery-card panel"
|
className="deck-card engine-gallery-card engine-entity-card panel"
|
||||||
key={engine.id}
|
key={engine.id}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -1142,6 +1169,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditingEngine(engine)}
|
onClick={() => setEditingEngine(engine)}
|
||||||
>
|
>
|
||||||
|
<span className="gallery-card-icon" aria-hidden="true" />
|
||||||
<span>{engine.engine_kind}</span>
|
<span>{engine.engine_kind}</span>
|
||||||
<strong>{engine.name}</strong>
|
<strong>{engine.name}</strong>
|
||||||
<small>{formatSupportedClasses(engine.supported_classes)}</small>
|
<small>{formatSupportedClasses(engine.supported_classes)}</small>
|
||||||
@@ -1222,7 +1250,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
|||||||
{visibleIcgs.map((generator) => (
|
{visibleIcgs.map((generator) => (
|
||||||
<article
|
<article
|
||||||
aria-label={`Edit ${generator.name}`}
|
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}
|
key={generator.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -1235,6 +1263,7 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span className="gallery-card-icon" aria-hidden="true" />
|
||||||
<span>{generator.generator_kind}</span>
|
<span>{generator.generator_kind}</span>
|
||||||
<strong>{generator.name}</strong>
|
<strong>{generator.name}</strong>
|
||||||
<small>{formatSupportedClasses(generator.supported_classes)}</small>
|
<small>{formatSupportedClasses(generator.supported_classes)}</small>
|
||||||
@@ -1248,6 +1277,11 @@ function DeckGallery({ initialTab }: { initialTab: GalleryTab }) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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 ? (
|
{creationOpen ? (
|
||||||
<GalleryCreationModal
|
<GalleryCreationModal
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
@@ -1352,7 +1386,10 @@ function GalleryCreationModal({
|
|||||||
<p className="eyebrow">Create</p>
|
<p className="eyebrow">Create</p>
|
||||||
<h2>New {labels[activeTab]}</h2>
|
<h2>New {labels[activeTab]}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose}>Close</button>
|
<div className="window-action-cluster">
|
||||||
|
<Win95WindowControls />
|
||||||
|
<button type="button" onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="gallery-create-body">
|
<div className="gallery-create-body">
|
||||||
{activeTab === 'decks' ? (
|
{activeTab === 'decks' ? (
|
||||||
@@ -1583,7 +1620,10 @@ function EngineEditModal({
|
|||||||
<p className="eyebrow">CA Engine</p>
|
<p className="eyebrow">CA Engine</p>
|
||||||
<h2>Edit {engine.name}</h2>
|
<h2>Edit {engine.name}</h2>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
<div className="gallery-create-body">
|
<div className="gallery-create-body">
|
||||||
<EngineConfigFields config={config} onChange={setConfig} />
|
<EngineConfigFields config={config} onChange={setConfig} />
|
||||||
@@ -1827,7 +1867,10 @@ function IcgEditModal({
|
|||||||
<p className="eyebrow">Initial Condition Generator</p>
|
<p className="eyebrow">Initial Condition Generator</p>
|
||||||
<h2>Edit {generator.name}</h2>
|
<h2>Edit {generator.name}</h2>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
<div className="gallery-create-body">
|
<div className="gallery-create-body">
|
||||||
<IcgConfigFields config={config} onChange={setConfig} />
|
<IcgConfigFields config={config} onChange={setConfig} />
|
||||||
@@ -2596,6 +2639,7 @@ function LibraryEditor({
|
|||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<div className="status">{status}</div>
|
<div className="status">{status}</div>
|
||||||
|
<Win95WindowControls />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -2617,6 +2661,7 @@ function LibraryEditor({
|
|||||||
<button aria-label="Close preset drawer" className="icon-button drawer-close" type="button" onClick={() => setTreeDrawerOpen(false)}>
|
<button aria-label="Close preset drawer" className="icon-button drawer-close" type="button" onClick={() => setTreeDrawerOpen(false)}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
<Win95WindowControls />
|
||||||
</div>
|
</div>
|
||||||
<div className="drawer-scroll">
|
<div className="drawer-scroll">
|
||||||
<div className="root-selector">
|
<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 aria-label="Close inspector" className="icon-button drawer-close" type="button" onClick={() => setInspectorOpen(false)}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
<Win95WindowControls />
|
||||||
</div>
|
</div>
|
||||||
<div className="inspector-tabs" role="tablist" aria-label="Preset inspector sections">
|
<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>
|
<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>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<div className="status">{status}</div>
|
<div className="status">{status}</div>
|
||||||
|
<Win95WindowControls />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -3794,6 +3841,7 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
|
|||||||
<h2>{activeScene ? slideTitle : sceneName}</h2>
|
<h2>{activeScene ? slideTitle : sceneName}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
|
<Win95WindowControls />
|
||||||
<button
|
<button
|
||||||
aria-label="Previous slide"
|
aria-label="Previous slide"
|
||||||
className="deck-nav-button"
|
className="deck-nav-button"
|
||||||
|
|||||||
@@ -360,15 +360,52 @@ label {
|
|||||||
color: var(--text-muted);
|
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 {
|
:root[data-skin="windows-95"] .app-footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 4px 8px;
|
padding: 4px 6px;
|
||||||
border-top: 2px solid #ffffff;
|
border-top: 2px solid #ffffff;
|
||||||
background: #c0c0c0;
|
background: #c0c0c0;
|
||||||
color: #000000;
|
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 {
|
.app-footer p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--admin-font-mono);
|
font-family: var(--admin-font-mono);
|
||||||
@@ -397,6 +434,23 @@ label {
|
|||||||
color: #000000;
|
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 {
|
.toggle-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -556,6 +610,34 @@ h2 {
|
|||||||
width: 100%;
|
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 {
|
:root[data-skin="windows-95"] .gallery-tabs {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -590,6 +672,97 @@ h2 {
|
|||||||
transform: none;
|
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"] .deck-card strong,
|
||||||
:root[data-skin="windows-95"] h1,
|
:root[data-skin="windows-95"] h1,
|
||||||
:root[data-skin="windows-95"] h2,
|
:root[data-skin="windows-95"] h2,
|
||||||
@@ -2234,6 +2407,53 @@ h2 {
|
|||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.property-modal-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
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) {
|
@media (max-width: 560px) {
|
||||||
.gallery-tabs {
|
.gallery-tabs {
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
|
|||||||
Vendored
+8
-1
@@ -1,6 +1,8 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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';
|
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 __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const adminPublicPath = resolve(__dirname, '../public/admin');
|
const adminPublicPath = resolve(__dirname, '../public/admin');
|
||||||
@@ -42,9 +44,14 @@ function caClassFromQuery(query) {
|
|||||||
}
|
}
|
||||||
export function createCaStudioApi(db) {
|
export function createCaStudioApi(db) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
const labAccess = requireLabAccess();
|
||||||
|
app.use(cookieParser());
|
||||||
app.use(express.json({ limit: '1mb' }));
|
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.use('/admin', express.static(adminPublicPath));
|
||||||
app.get('/', (_request, response) => {
|
app.get('/', labAccess, (_request, response) => {
|
||||||
response.redirect('/admin');
|
response.redirect('/admin');
|
||||||
});
|
});
|
||||||
app.get(/^\/(?:admin|view)(?:\/.*)?$/, (_request, response) => {
|
app.get(/^\/(?:admin|view)(?:\/.*)?$/, (_request, response) => {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+236
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Generated
+183
@@ -8,7 +8,9 @@
|
|||||||
"name": "@glitch-components/voxel-automata-lab-backend",
|
"name": "@glitch-components/voxel-automata-lab-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
@@ -16,7 +18,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electric-sql/pglite": "^0.5.3",
|
"@electric-sql/pglite": "^0.5.3",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.10.2",
|
"@types/node": "^24.10.2",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
@@ -1290,6 +1294,16 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/cookiejar": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||||
@@ -1343,6 +1357,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/methods": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
@@ -1350,6 +1375,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.2",
|
"version": "24.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"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": "^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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1865,6 +1903,25 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
@@ -1961,6 +2018,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -2508,6 +2574,61 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -2782,6 +2903,48 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -3325,6 +3488,26 @@
|
|||||||
"node": ">= 18"
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
@@ -24,7 +26,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electric-sql/pglite": "^0.5.3",
|
"@electric-sql/pglite": "^0.5.3",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.10.2",
|
"@types/node": "^24.10.2",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
|
|||||||
+184
-184
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
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>CA Studio Admin</title>
|
<title>CA Studio Admin</title>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-BXPDm7zD.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-Bo_SPfbl.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-DN6CRbyT.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-C7KqEE0-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import cookieParser from 'cookie-parser'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { Queryable } from './db.js'
|
import type { Queryable } from './db.js'
|
||||||
|
import { requireLabAccess } from './labAuth.js'
|
||||||
import {
|
import {
|
||||||
createCaEngine,
|
createCaEngine,
|
||||||
createDeck,
|
createDeck,
|
||||||
@@ -84,10 +86,15 @@ function caClassFromQuery(query: express.Request['query']) {
|
|||||||
|
|
||||||
export function createCaStudioApi(db: Queryable) {
|
export function createCaStudioApi(db: Queryable) {
|
||||||
const app = express()
|
const app = express()
|
||||||
|
const labAccess = requireLabAccess()
|
||||||
|
app.use(cookieParser())
|
||||||
app.use(express.json({ limit: '1mb' }))
|
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.use('/admin', express.static(adminPublicPath))
|
||||||
|
|
||||||
app.get('/', (_request, response) => {
|
app.get('/', labAccess, (_request, response) => {
|
||||||
response.redirect('/admin')
|
response.redirect('/admin')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
|
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
|
||||||
REMOTE_DIR="${DEPLOY_DIR:-/opt/voxel-automata-lab}"
|
REMOTE_DIR="${DEPLOY_DIR:-/opt/glitch_automata_lab}"
|
||||||
COMPOSE="docker compose -f ${REMOTE_DIR}/docker-compose.prod.yml --env-file ${REMOTE_DIR}/.env.prod"
|
COMPOSE="docker compose -f ${REMOTE_DIR}/docker-compose.prod.yml --env-file ${REMOTE_DIR}/.env.prod"
|
||||||
|
|
||||||
# Refuse to run on the production server itself.
|
# Refuse to run on the production server itself.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
# Required env vars (load via .env.prod on the server):
|
# Required env vars (load via .env.prod on the server):
|
||||||
# POSTGRES_USER, POSTGRES_PASSWORD — credentials for gnommo-db (shared)
|
# POSTGRES_USER, POSTGRES_PASSWORD — credentials for gnommo-db (shared)
|
||||||
# LAB_DB (optional) — database name within gnommo-db (default: ca_studio)
|
# LAB_DB (optional) — database name within gnommo-db (default: ca_studio)
|
||||||
|
# JWT_SECRET — same secret used by gnommoweb auth JWTs
|
||||||
|
# GNOMMOWEB_URL — e.g. https://glitch.university
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -43,6 +45,13 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3100"
|
PORT: "3100"
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${LAB_DB:-ca_studio}
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${LAB_DB:-ca_studio}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
LAB_AUTH_ENABLED: ${LAB_AUTH_ENABLED:-true}
|
||||||
|
LAB_KEYCARD_REQUIRED: ${LAB_KEYCARD_REQUIRED:-true}
|
||||||
|
GNOMMOWEB_URL: ${GNOMMOWEB_URL:-https://glitch.university}
|
||||||
|
LAB_KEYCARD_MERIT_SLUG: ${LAB_KEYCARD_MERIT_SLUG:-lab-keycard}
|
||||||
|
LAB_USER_PROFILE_URL: ${LAB_USER_PROFILE_URL:-https://glitch.university/api/user/profile}
|
||||||
|
LAB_SIGN_IN_URL: ${LAB_SIGN_IN_URL:-https://glitch.university/auth/google?returnTo={returnTo}}
|
||||||
# Run pending migrations before starting the server.
|
# Run pending migrations before starting the server.
|
||||||
# The lab's migrate script is idempotent (CREATE TABLE IF NOT EXISTS).
|
# The lab's migrate script is idempotent (CREATE TABLE IF NOT EXISTS).
|
||||||
command: sh -c "node scripts/migrate.mjs && node dist/server.js"
|
command: sh -c "node scripts/migrate.mjs && node dist/server.js"
|
||||||
|
|||||||
Reference in New Issue
Block a user