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.8.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('Signed out

Signed out of Character

Your character saves remain in your account and on this device.

Sign in with Arrowstack')); // The static OAuth client is optional. Configure BOTH values via the secure // Keycloak-to-Coolify handoff; never commit or log the client secret. const mcpClientId=process.env.MCP_CHATGPT_CLIENT_ID; const mcpClientSecret=process.env.MCP_CHATGPT_CLIENT_SECRET; if(Boolean(mcpClientId)!==Boolean(mcpClientSecret))throw new Error('Incomplete ChatGPT MCP OAuth client configuration'); const predefinedClient=mcpClientId&&mcpClientSecret?{ client_id:mcpClientId,client_secret:mcpClientSecret, redirect_uris:['https://chatgpt.com/connector_platform_oauth_redirect'] }:undefined; const oauth=new CharacterOAuth({storage,codec,origin,predefinedClient}); 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'));