42 lines
5.6 KiB
JavaScript

import {Catalogue,installCatalogue} from './catalogue.mjs';
import express from 'express';
import {Campaigns,installCampaigns} from './campaigns.mjs';
import {productionStorage} from './storage.mjs';
import {CharacterOAuth} from './oauth.mjs';
import {installCharacters} from './characters.mjs';
import {installMcp} from './mcp.mjs';
import * as oidc from 'openid-client';
import {sessionCodec} from './auth.mjs';
import {fileURLToPath} from 'node:url';
const origin=process.env.APP_ORIGIN,issuer=process.env.KEYCLOAK_ISSUER,clientId=process.env.KEYCLOAK_CLIENT_ID,secret=process.env.KEYCLOAK_CLIENT_SECRET;
if(!origin||!issuer||!clientId)throw new Error('APP_ORIGIN and Keycloak configuration are required');
if(!origin.startsWith('https://')||!issuer.startsWith('https://'))throw new Error('HTTPS is required');
const storage=await productionStorage();
const campaigns=new Campaigns(storage);await campaigns.init();
const catalogue=new Catalogue(storage);await catalogue.init();
const codec=sessionCodec(secret,clientId);const app=express();app.disable('x-powered-by');app.set('trust proxy',1);app.use(express.json({limit:'2mb'}));app.use(express.urlencoded({extended:false,limit:'2kb'}));
let config;async function provider(){return config??=await oidc.discovery(new URL(issuer),clientId,secret)}
const cookie=(res,name,value,seconds)=>res.append('Set-Cookie',`${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${seconds}`);
const readCookie=(req,name)=>{const raw=(req.headers.cookie||'').split(';').map(x=>x.trim()).find(x=>x.startsWith(`${name}=`));return raw?raw.slice(name.length+1):''};
app.use((req,res,next)=>{res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'same-origin','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",'Cache-Control':'no-store'});next()});
app.get('/health',(_,res)=>res.json({status:'ok',version:'0.4.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'}));
app.get('/auth/login',async(req,res,next)=>{try{const pkce=oidc.randomPKCECodeVerifier(),state=oidc.randomState(),nonce=oidc.randomNonce();cookie(res,'__Host-character-flow',await codec.seal({purpose:'character-flow',pkce,state,nonce},600),600);const url=oidc.buildAuthorizationUrl(await provider(),{redirect_uri:`${origin}/auth/callback`,scope:'openid profile',state,nonce,code_challenge:await oidc.calculatePKCECodeChallenge(pkce),code_challenge_method:'S256'});res.redirect(url.href)}catch(e){next(e)}});
app.get('/auth/callback',async(req,res,next)=>{try{const flow=await codec.open(readCookie(req,'__Host-character-flow'));cookie(res,'__Host-character-flow','',0);if(!flow||typeof flow.pkce!=='string'||typeof flow.state!=='string'||typeof flow.nonce!=='string')return res.status(400).send('Sign-in expired. Start again at /auth/login.');const url=new URL(req.originalUrl,origin);const tokens=await oidc.authorizationCodeGrant(await provider(),url,{pkceCodeVerifier:flow.pkce,expectedState:flow.state,expectedNonce:flow.nonce});const claims=tokens.claims();if(!claims?.sub)throw new Error('Missing identity');cookie(res,'__Host-character-session',await codec.seal({purpose:'character-session',sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);const back=await codec.open(readCookie(req,'__Host-character-mcp-return'));cookie(res,'__Host-character-mcp-return','',0);res.redirect(back?.purpose==='mcp-return'?`/mcp/consent?ticket=${encodeURIComponent(back.ticket)}`:'/')}catch(e){next(e)}});
app.use(async(req,res,next)=>{const session=await codec.open(readCookie(req,'__Host-character-session'));req.identity=session?.sub&&typeof session.name==='string'&&(!session.purpose||session.purpose==='character-session')?session:null;next()});
app.get('/api/session',(req,res)=>req.identity?res.json({sub:req.identity.sub,name:req.identity.name,expiresAt:req.identity.exp*1000}):res.status(401).json({error:'Sign in required'}));
app.post('/auth/logout',(req,res)=>{if(req.get('origin')!==origin)return res.sendStatus(403);cookie(res,'__Host-character-session','',0);cookie(res,'__Host-character-flow','',0);res.redirect('/auth/signed-out')});
app.get('/auth/signed-out',(_,res)=>res.type('html').send('<!doctype html><meta name="viewport" content="width=device-width"><title>Signed out</title><h1>Signed out of Character</h1><p>Your character saves remain in your account and on this device.</p><a href="/auth/login">Sign in with Arrowstack</a>'));
const oauth=new CharacterOAuth({storage,codec,origin});
const mcpAuth=oauth.install(app,{cookie,readCookie});
installCharacters(app,{storage,origin});
installCampaigns(app,{campaigns,origin});
installCatalogue(app,{catalogue});
installMcp(app,{storage,campaigns,catalogue,auth:mcpAuth,origin});
const dist=fileURLToPath(new URL('../dist/',import.meta.url));
app.use((req,res,next)=>{if(['/sw.js','/manifest.webmanifest','/icon-192.png','/icon-512.png'].includes(req.path)||req.path.startsWith('/assets/'))return next();if(!req.identity)return res.redirect('/auth/login');next()});
app.use(express.static(dist,{index:false,setHeaders:res=>res.set('Cache-Control','private, no-cache')}));
app.get('/',(_,res)=>res.sendFile(`${dist}/index.html`));
app.use((req,res)=>res.sendStatus(404));
app.use((error,req,res,next)=>{console.error('Request failed:',error.code||error.name);res.status(503).send('Sign-in could not be completed. Please try /auth/login again.');});
app.listen(Number(process.env.PORT||8080),'0.0.0.0',()=>console.log('Character PWA listening on 8080'));