Mirror tested canonical source 439a74ca52125c2bb1654363c0fcff16b47a8297

This commit is contained in:
Admin 2026-10-06 08:24:21 +00:00
parent 0e88737e9c
commit 06c012c941
24 changed files with 2771 additions and 2 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.git
.env*
test-results

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env*
test-results/

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm test && npm run build
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY server ./server
USER node
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s CMD wget -qO- http://127.0.0.1:8080/health || exit 1
CMD ["node","server/index.mjs"]

View File

@ -1,3 +1,25 @@
# dnd-character-deploy # Arrowstack Character
Deployment mirror of the private Arrowstack Character PWA. Keycloak credentials are injected at runtime. Mobile-first Vue 3 / TypeScript character-sheet PWA. Pinia manages state, Dexie stores characters in IndexedDB, Zod validates saves and JSON imports. A small Express gateway protects the application with a confidential Keycloak OIDC client using authorization code flow, PKCE, state and nonce verification.
## v0.1
Create/edit multiple characters, abilities, skill/expertise and save proficiency, HP/temp HP, armour class, speed, manually entered attacks, spellbook/preparation, generic resources, rest preview, inventory, notes, d20 rolls, account-separated local saves, JSON import/export. No copyrighted spell or rule compendium is bundled. No server character storage or cross-device sync yet.
Short rest resets declared short-rest resources. Long rest also resets long-rest resources, restores HP and clears temporary HP. Hit dice and class-specific rest rules are manual. These are configurable sheet operations, not a complete edition-specific rules engine.
## Authentication and offline behavior
Use existing Arrowstack accounts in the dev realm. Any authenticated realm user can use the app; no control-plane role is required. Keycloak tokens and the confidential client secret stay server-side. The encrypted 12-hour session uses a Secure, HttpOnly, SameSite=Lax, host-only cookie. Protected HTML requires a session; unauthenticated /api/session returns 401. Static JS/CSS and PWA metadata are public, but contain no character data or secrets.
After successful sign-in, the worker caches the app shell and immutable build assets for offline use. The browser retains only the last subject/name/expiry, not OIDC tokens. Local offline access expires with the session after at most 12 hours. Revocation cannot be checked while offline; reconnecting rechecks the gateway session. Character storage is namespaced by Keycloak subject, not encrypted at rest. Treat the device as trusted. Sign-out removes offline identity and cached shell while preserving per-account character backups. Sign-out ends the app session; the Arrowstack SSO session may still exist.
Clearing site data removes saves. Export JSON backups before changing devices or clearing browser storage.
## Build and deploy
`npm ci`, `npm test`, `npm run build`. Docker executes tests and type-check/build, then runs as an unprivileged node user on 8080. No Gitea Actions builder is required.
Runtime configuration: APP_ORIGIN=https://dnd.arrowstack.dev, KEYCLOAK_ISSUER, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET. Provision confidential client `dnd-character` with standard authorization code flow only, exact redirect `https://dnd.arrowstack.dev/auth/callback`, PKCE S256. Use Arrowstack's secret handoff to inject credentials directly into Coolify. /health exposes status/version/revision, never secrets. Canonical source is private in Admin/dnd-character-pwa. For now Coolify builds the public Admin/dnd-character-deploy mirror main branch over HTTPS; runtime secrets are never committed. See deployment.md for resource IDs and release steps. Redeploy a prior mirror commit through Coolify to roll back; runtime has no database migrations.
Local production smoke test needs the same HTTPS origin and Keycloak configuration. Do not add an authentication bypass to production. Vite development uses its proxy for /auth and /api.

27
deployment.md Normal file
View File

@ -0,0 +1,27 @@
# Arrowstack Character deployment
Production: https://dnd.arrowstack.dev
Canonical source: private Admin/dnd-character-pwa.
Temporary deployment mirror: public Admin/dnd-character-deploy, main branch.
Coolify application: vfzhl6jql5vrzevm6yk1pvav (dnd-character).
Project: 9ax6f4a6yn1h6gi7jgiihjhs; production environment; server: t6bzdsemktk6j3fxuzjlzkjg.
Build: repository Dockerfile, HTTPS mirror clone, port 8080.
Health: http://127.0.0.1:8080/health. Use IPv4 explicitly: BusyBox wget with localhost failed against the IPv4 listener.
Keycloak: confidential dnd-character client, dev realm, exact callback https://dnd.arrowstack.dev/auth/callback. Runtime issuer/client credentials are handed directly to Coolify. Never commit credentials or character data.
## Release steps
1. Change the canonical private source; run npm ci, npm test and npm run build.
2. Copy the tested source and lockfile into the deployment mirror. Exclude node_modules, dist, .env files, test output and credentials. Retain private canonical provenance in commit messages.
3. Set the runtime SOURCE_COMMIT to the mirror revision being deployed.
4. Check active deployment jobs, deploy the application once, inspect the terminal build/runtime status, and verify /health and the Keycloak redirect on the live origin.
5. Roll back by pinning a prior known-good mirror commit and deploying it. There are no server character database migrations.
The mirror is a temporary fallback authorized by the user. The private repository deploy key workflow failed at git.arrowstack.dev port 22 with Permission denied (publickey); investigate the Gitea SSH/deploy-key listener before removing the mirror. Do not make canonical source public.
Earlier inline-source test applications cbnkwtdve2fzumnxnz0jqupb and cdr9ivftpqqzdakmfuq0r5yz are inactive and have no domain. They are not production targets.
## Verification boundaries
Seven focused tests cover HP/temp HP, proficiency/expertise, declared rest resets, JSON validation, IndexedDB account separation/persistence, encrypted session integrity/expiry and fail-closed missing secrets. Production TypeScript checks and Vite build also run in the Docker image build. Authenticated browser creation/reload and installed iPhone offline behavior require a signed-in device smoke test; build and health alone do not prove those interactions.

1
index.html Normal file
View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#162720"><meta name="apple-mobile-web-app-capable" content="yes"><link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/icon-192.png"><title>Character · Arrowstack</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>

2533
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

1
package.json Normal file
View File

@ -0,0 +1 @@
{"name":"dnd-character-pwa","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc --noEmit && vite build","start":"node server/index.mjs","test":"node --import tsx --test tests/*.test.ts"},"dependencies":{"vue":"^3.5.22","pinia":"^3.0.3","dexie":"^4.2.0","zod":"^4.1.11","express":"^5.1.0","openid-client":"^6.8.1","jose":"^6.1.0"},"devDependencies":{"vite":"^7.1.9","@vitejs/plugin-vue":"^6.0.1","typescript":"^5.9.3","vue-tsc":"^3.1.0","tsx":"^4.20.6","fake-indexeddb":"^6.2.4"}}

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1 @@
{"id":"/","name":"Arrowstack Character","short_name":"Character","start_url":"/","scope":"/","display":"standalone","background_color":"#f4f0e8","theme_color":"#162720","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}

4
public/sw.js Normal file
View File

@ -0,0 +1,4 @@
const CACHE='character-shell-v1';
self.addEventListener('activate',e=>e.waitUntil((async()=>{for(const key of await caches.keys())if(key.startsWith('character-shell-')&&key!==CACHE)await caches.delete(key);await self.clients.claim()})()));
self.addEventListener('message',e=>{if(e.data==='CLEAR_AUTH')e.waitUntil(caches.delete(CACHE));if(e.data==='CACHE_SHELL')e.waitUntil((async()=>{const r=await fetch('/',{credentials:'same-origin',redirect:'manual'});if(r.ok&&r.headers.get('content-type')?.includes('text/html')){const html=await r.clone().text(),cache=await caches.open(CACHE);await cache.put('/',r);for(const match of html.matchAll(/(?:src|href)="(\/assets\/[^" ]+)"/g)){const response=await fetch(match[1]);if(response.ok)await cache.put(match[1],response)}}})())});
self.addEventListener('fetch',e=>{const url=new URL(e.request.url);if(url.origin!==self.location.origin||e.request.method!=='GET'||url.pathname.startsWith('/api/')||url.pathname.startsWith('/auth/'))return;if(e.request.mode==='navigate'&&url.pathname==='/'){e.respondWith((async()=>{try{const r=await fetch(e.request);if(r.ok&&!r.redirected&&r.headers.get('content-type')?.includes('text/html'))await(await caches.open(CACHE)).put('/',r.clone());return r}catch{return await caches.match('/')||new Response('Connect to the internet to sign in.',{status:503})}})());return}if(url.pathname.startsWith('/assets/'))e.respondWith((async()=>{const hit=await caches.match(e.request);if(hit)return hit;const r=await fetch(e.request);if(r.ok)await(await caches.open(CACHE)).put(e.request,r.clone());return r})())});

3
server/auth.mjs Normal file
View File

@ -0,0 +1,3 @@
import {createHash} from 'node:crypto';
import {EncryptJWT,jwtDecrypt} from 'jose';
export function sessionCodec(secret,clientId){if(!secret||secret.length<16)throw new Error('A confidential Keycloak client secret is required');const key=createHash('sha256').update(`character-session-v1:${clientId}:${secret}`).digest();return {seal:async(payload,seconds=43200)=>new EncryptJWT(payload).setProtectedHeader({alg:'dir',enc:'A256GCM'}).setIssuedAt().setExpirationTime(`${seconds}s`).encrypt(key),open:async(token)=>{try{return (await jwtDecrypt(token,key,{keyManagementAlgorithms:['dir'],contentEncryptionAlgorithms:['A256GCM']})).payload}catch{return null}}}}

26
server/index.mjs Normal file
View File

@ -0,0 +1,26 @@
import express from 'express';
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 codec=sessionCodec(secret,clientId);const app=express();app.disable('x-powered-by');app.set('trust proxy',1);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.1.0',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({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)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({sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);res.redirect('/')}catch(e){next(e)}});
app.use(async(req,res,next)=>{req.identity=await codec.open(readCookie(req,'__Host-character-session'));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 on this device.</p><a href="/auth/login">Sign in with Arrowstack</a>'));
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'));

70
src/App.vue Normal file
View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import {computed,onMounted,ref,watch} from 'vue';
import {useCharacters} from './store';
import {abilities,modifier,proficiency,signed,skills,skillBonus,changeHP,newCharacter,characterSchema,restPreview,applyRest,type Character} from './model';
const store=useCharacters(),activeId=ref(''),tab=ref('Sheet'),drawer=ref(''),error=ref(''),message=ref(''),user=ref<{sub:string,name:string,expiresAt:number}|null>(null),loading=ref(true),offline=ref(!navigator.onLine);
const c=computed(()=>store.all.find(x=>x.id===activeId.value));
const draft=ref<Character|null>(null),hpAmount=ref(1),restType=ref<'short-rest'|'long-rest'>('short-rest'),rollResult=ref(''),selectedAction=ref<Character['actions'][number]|null>(null);
const restChanges=computed(()=>c.value?restPreview(c.value,restType.value):[]);
const actionDraft=ref({name:'',bonus:0,damage:'',notes:''}),resourceDraft=ref({name:'',current:1,max:1,reset:'long-rest' as 'short-rest'|'long-rest'|'manual'}),spellDraft=ref({name:'',level:1,prepared:true,notes:''});
watch(c,()=>{if(c.value&&user.value)store.persist(c.value)},{deep:true});
async function authenticate(){try{const response=await fetch('/api/session',{cache:'no-store'});if(!response.ok){localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');throw new Error('Please sign in with Arrowstack to open your characters.')}const identity=await response.json();if(store.owner&&store.owner!==identity.sub){await store.flush();location.reload();return}user.value=identity;error.value='';localStorage.setItem('character-identity',JSON.stringify(user.value))}catch(e){if(e instanceof TypeError){try{const cached=JSON.parse(localStorage.getItem('character-identity')||'null');if(cached?.sub&&cached.expiresAt>Date.now()){user.value=cached;offline.value=true}else throw new Error()}catch{error.value='Connect to the internet to sign in with Arrowstack.'}}else error.value=(e as Error).message}}
onMounted(async()=>{await authenticate();if(user.value){try{await store.open(user.value.sub);activeId.value=store.all[0]?.id||'';if('serviceWorker'in navigator){await navigator.serviceWorker.register('/sw.js');const registration=await navigator.serviceWorker.ready;registration.active?.postMessage('CACHE_SHELL')}}catch{error.value='Could not open local character saves. Please retry.'}}loading.value=false;window.addEventListener('offline',()=>offline.value=true);window.addEventListener('online',async()=>{offline.value=false;await authenticate()});setInterval(()=>{if(user.value&&user.value.expiresAt<=Date.now()){user.value=null;drawer.value='';error.value='Your session expired. Connect and sign in again.'}},15000)});
function create(){draft.value=newCharacter();drawer.value='Character'}
function edit(){if(c.value){draft.value=structuredClone(JSON.parse(JSON.stringify(c.value)));drawer.value='Character'}}
function saveDraft(){try{const valid=characterSchema.parse(draft.value);const index=store.all.findIndex(x=>x.id===valid.id);if(index>=0)store.all[index]=valid;else store.all.push(valid);activeId.value=valid.id;store.persist(valid);drawer.value='';error.value=''}catch{message.value='Check your values: level 1–20, abilities 1–30, HP within maximum.'}}
function hp(amount:number){if(c.value)changeHP(c.value,amount)}
function addAction(){if(c.value&&actionDraft.value.name.trim()){c.value.actions.push({...actionDraft.value,id:crypto.randomUUID()});actionDraft.value={name:'',bonus:0,damage:'',notes:''};drawer.value=''}}
function addResource(){if(c.value&&resourceDraft.value.name.trim()&&resourceDraft.value.max>=0&&resourceDraft.value.current>=0&&resourceDraft.value.current<=resourceDraft.value.max){c.value.resources.push({...resourceDraft.value,id:crypto.randomUUID()});resourceDraft.value={name:'',current:1,max:1,reset:'long-rest'};drawer.value=''}else message.value='Remaining uses must be between zero and maximum.'}
function addSpell(){if(c.value&&spellDraft.value.name.trim()){c.value.spells.push({...spellDraft.value,id:crypto.randomUUID()});spellDraft.value={name:'',level:1,prepared:true,notes:''};drawer.value=''}}
function rest(type:'short-rest'|'long-rest'){restType.value=type;drawer.value='Rest'}
function confirmRest(){if(c.value)applyRest(c.value,restType.value);drawer.value='';message.value='Rest applied. Your sheet is saved.'}
function die(sides:number){const range=4294967296-(4294967296%sides);let n:number;do{n=crypto.getRandomValues(new Uint32Array(1))[0]}while(n>=range);return n%sides+1}
function roll(bonus=0,mode=0){const a=die(20),b=mode?die(20):a,v=mode===1?Math.max(a,b):mode===-1?Math.min(a,b):a;rollResult.value=`${v+bonus} · d20 ${v} ${signed(bonus)}${mode?` (${a}, ${b})`:''}`}
function exportCharacter(){if(!c.value)return;const blob=new Blob([JSON.stringify(c.value,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=`${c.value.name.replace(/[^a-z0-9]/gi,'-')}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
async function importCharacter(event:Event){const input=event.target as HTMLInputElement;try{const file=input.files?.[0];if(!file)return;if(file.size>2_000_000)throw new Error();const valid=characterSchema.parse(JSON.parse(await file.text()));valid.id=crypto.randomUUID();store.all.push(valid);activeId.value=valid.id;store.persist(valid);message.value='Character imported as a new copy.'}catch{message.value='Could not import. Choose a valid character JSON backup under 2 MB.'}input.value=''}
async function logout(){await store.flush();localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');for(const key of await caches.keys())if(key.startsWith('character-shell-'))await caches.delete(key);user.value=null;const form=document.createElement('form');form.method='POST';form.action='/auth/logout';document.body.appendChild(form);form.submit()}
async function remove(){if(c.value&&confirm(`Delete ${c.value.name} from this device? Export a backup first.`)){await store.remove(c.value.id);activeId.value=store.all[0]?.id||''}}
function openAction(action:Character['actions'][number]){selectedAction.value=action;rollResult.value='';drawer.value='Roll'}
</script>
<template>
<div v-if="loading" class="gate"><span class="sigil">✦</span><h1>Opening your adventure…</h1></div>
<div v-else-if="!user||error" class="gate"><span class="sigil">✦</span><p class="eyebrow">ARROWSTACK · CHARACTER</p><h1>Your next adventure<br>starts here.</h1><p>{{error||'Sign in to open your character sheet.'}}</p><a class="button primary" href="/auth/login">Sign in with Arrowstack →</a></div>
<div v-else class="app-shell">
<header class="topbar"><a class="brand" href="/">✦ <span>CHARACTER<span class="brand-sub">BY ARROWSTACK</span></span></a><span class="status">{{offline?'Offline session':store.saved?'Saved on device':'Saving…'}}</span><button class="quiet" @click="drawer='Account'" aria-label="Account and settings">{{user.name.split(' ')[0]}}</button></header>
<main>
<div class="character-switch"><select v-if="store.all.length" v-model="activeId" aria-label="Choose character"><option v-for="character in store.all" :value="character.id">{{character.name}}</option></select><span v-else>Your character collection</span><button class="quiet" @click="create">+ New character</button></div>
<p v-if="store.saveError" class="notice danger" role="alert">{{store.saveError}}</p><p v-if="message" class="notice" role="status">{{message}} <button class="quiet" @click="message=''">Dismiss</button></p>
<section v-if="!c" class="empty"><span class="sigil">✦</span><p class="eyebrow">A BLANK PAGE. A NEW STORY.</p><h1>Who will you become?</h1><p>Create a character and keep everything you need<br>for game night in your pocket.</p><button class="primary" @click="create">Create your first character →</button><label class="button quiet">Import a character<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label></section>
<template v-else>
<section class="hero"><div class="monogram">{{c.name.slice(0,1).toUpperCase()}}<span>✦</span></div><div><p class="eyebrow">LEVEL {{c.level}} · {{c.className||'ADVENTURER'}}</p><h1>{{c.name}}</h1><p>{{[c.species,c.background].filter(Boolean).join(' · ')||'Your story is waiting to be written.'}}</p></div><button class="edit" @click="edit">Edit character ↗</button></section>
<template v-if="tab==='Sheet'">
<div class="combat-grid"><button class="hp-card" @click="drawer='Hit points'"><div class="card-label">HIT POINTS <span>+ / −</span></div><div class="hp-value">{{c.hp.current}}<small>/ {{c.hp.max}}</small></div><div class="hp-track"><i :style="{width:`${100*c.hp.current/c.hp.max}%`}"></i></div><div class="hp-footer"><span>{{c.hp.temp}} temporary</span><span>Tap to damage or heal</span></div></button><div class="stat-card"><span>ARMOUR CLASS</span><strong>{{c.ac}}</strong><small>AC</small></div><div class="stat-card"><span>INITIATIVE</span><strong>{{signed(modifier(c.abilities.dex))}}</strong><button class="quiet" @click="roll(modifier(c.abilities.dex));drawer='Dice'">Roll d20 ↗</button></div><div class="stat-card"><span>SPEED</span><strong>{{c.speed}}</strong><small>feet</small></div></div>
<div class="ability-row"><button v-for="a in abilities" @click="roll(modifier(c.abilities[a]));drawer='Dice'"><span>{{a.toUpperCase()}}</span><strong>{{signed(modifier(c.abilities[a]))}}</strong><small>{{c.abilities[a]}}</small></button></div>
<div class="two-column"><section class="panel"><div class="section-title"><h2>Ready for your turn</h2><button class="quiet" @click="tab='Actions'">All actions ↗</button></div><div v-if="!c.actions.length" class="hint">Add your weapons and actions to keep them close at hand.<button class="quiet" @click="drawer='Add action'">+ Add action</button></div><button class="action-row" v-for="action in c.actions.slice(0,4)" @click="openAction(action)"><span class="action-symbol">↗</span><span><b>{{action.name}}</b><small>{{action.damage||'Custom action'}}</small></span><strong>{{signed(action.bonus)}}</strong></button></section><section class="panel"><div class="section-title"><h2>Resources</h2><button class="quiet" @click="drawer='Add resource'">+ Add</button></div><p class="hint" v-if="!c.resources.length">Spell slots, class features and item charges — tracked your way.</p><div class="resource" v-for="r in c.resources"><div><b>{{r.name}}</b><small>{{r.current}} / {{r.max}} · {{r.reset.replace('-',' ')}}</small></div><div class="counter"><button @click="r.current=Math.max(0,r.current-1)" :disabled="r.current===0" :aria-label="`Spend ${r.name}`">−</button><strong>{{r.current}}</strong><button @click="r.current=Math.min(r.max,r.current+1)" :disabled="r.current===r.max" :aria-label="`Restore ${r.name}`">+</button></div></div></section></div>
<section class="panel"><div class="section-title"><h2>Skills & saving throws</h2><span class="chip">Proficiency {{signed(proficiency(c.level))}}</span></div><div class="save-row"><button v-for="a in abilities" @click="roll(modifier(c.abilities[a])+(c.saves.includes(a)?proficiency(c.level):0));drawer='Dice'"><span>{{c.saves.includes(a)?'●':'○'}} {{a.toUpperCase()}}</span><b>{{signed(modifier(c.abilities[a])+(c.saves.includes(a)?proficiency(c.level):0))}}</b></button></div><div class="skill-grid"><button v-for="(a,name) in skills" @click="roll(skillBonus(c,String(name)));drawer='Dice'"><span>{{c.skills[name]==='expert'?'◆':c.skills[name]==='proficient'?'●':'○'}} {{name}}</span><b>{{signed(skillBonus(c,String(name)))}}</b></button></div></section>
<div class="rest-bar"><span>Take a moment to recover.</span><button @click="rest('short-rest')">Short rest</button><button class="primary" @click="rest('long-rest')">Long rest ☾</button></div>
</template>
<section class="panel" v-if="tab==='Actions'"><div class="section-title"><h2>Actions</h2><button @click="drawer='Add action'">+ Add action</button></div><p class="hint" v-if="!c.actions.length">Add attacks, features and custom actions with your own modifiers.</p><div class="list-entry" v-for="action in c.actions"><button class="action-row" @click="openAction(action)"><span><b>{{action.name}}</b><small>{{action.damage}} · {{action.notes}}</small></span><strong>{{signed(action.bonus)}}</strong></button><button class="quiet" @click="c.actions=c.actions.filter(a=>a.id!==action.id)" :aria-label="`Remove ${action.name}`">Remove</button></div></section>
<section class="panel" v-if="tab==='Spells'"><div class="section-title"><h2>Spellbook</h2><button @click="drawer='Add spell'">+ Add spell</button></div><p class="hint">Record your own spells. Track spell slots with resources on the Sheet tab.</p><div class="spell-row" v-for="spell in [...c.spells].sort((a,b)=>a.level-b.level)"><label><input type="checkbox" v-model="spell.prepared"><span><b>{{spell.name}}</b><small>{{spell.level?'Level '+spell.level:'Cantrip'}} · {{spell.prepared?'Prepared':'Not prepared'}}</small><p>{{spell.notes}}</p></span></label><button class="quiet" @click="c.spells=c.spells.filter(s=>s.id!==spell.id)">Remove</button></div></section>
<section class="panel" v-if="tab==='Gear'"><div class="section-title"><h2>Gear & inventory</h2><span class="chip">Saved automatically</span></div><p class="hint">Equipment, currency, attunement and anything you picked up along the way.</p><textarea v-model="c.inventory" rows="14" placeholder="Longbow, 20 arrows…" maxlength="12000"></textarea></section>
<template v-if="tab==='More'"><section class="panel"><div class="section-title"><h2>Your story</h2><span class="chip">Saved automatically</span></div><textarea v-model="c.notes" rows="8" placeholder="Backstory, allies, session notes…" maxlength="12000"></textarea></section><section class="panel"><h2>Character & account</h2><p class="hint">Signed in as {{user.name}}. Characters are saved on this device, separately for each Arrowstack account. Export backups to move them to another device.</p><div class="settings-actions"><button @click="edit">Edit character</button><button @click="exportCharacter">Export JSON backup</button><label class="button">Import JSON<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label><button @click="create">New character</button><button @click="drawer='Manage resources'">Manage resources</button><button class="danger-text" @click="remove">Delete character</button><button @click="logout" :disabled="offline">Sign out</button></div><p class="hint">Offline play lasts until your 12-hour sign-in session expires. Reconnect to sign in again. Attack modifiers, spell details and resource reset rules are entered manually; automated class rules are a future feature.</p></section></template>
</template>
<footer>✦ ARROWSTACK CHARACTER <span>v0.1 · Made for game night</span></footer>
</main>
<nav class="bottom-nav" v-if="c"><button v-for="(name,index) in ['Sheet','Actions','Spells','Gear','More']" :class="{active:tab===name}" @click="tab=name"><span>{{['◈','↗','✦','▣','•••'][index]}}</span>{{name}}</button></nav>
<div v-if="drawer" class="overlay" @click.self="drawer=''"><section class="drawer" role="dialog" aria-modal="true" :aria-label="drawer"><div class="section-title"><h2>{{drawer==='Rest'?(restType==='long-rest'?'Long rest':'Short rest'):drawer}}</h2><button class="close" @click="drawer=''" aria-label="Close dialog">×</button></div>
<template v-if="drawer==='Account'"><p class="hint">Signed in as {{user.name}}. Characters are stored on this device, separately for each Arrowstack account.</p><button class="primary wide" @click="logout" :disabled="offline">Sign out of Character</button><p class="hint" v-if="offline">Reconnect to sign out.</p></template>
<form v-if="drawer==='Character'&&draft" @submit.prevent="saveDraft"><label>Name<input v-model="draft.name" required maxlength="120"></label><div class="form-grid"><label>Class<input v-model="draft.className" maxlength="120"></label><label>Level<input type="number" v-model.number="draft.level" min="1" max="20" required></label><label>Species<input v-model="draft.species" maxlength="120"></label><label>Background<input v-model="draft.background" maxlength="120"></label></div><h3>Ability scores</h3><div class="form-grid three"><label v-for="a in abilities">{{a.toUpperCase()}}<input type="number" v-model.number="draft.abilities[a]" min="1" max="30" required></label></div><div class="form-grid three"><label>Max HP<input type="number" v-model.number="draft.hp.max" min="1" max="9999" required></label><label>Current HP<input type="number" v-model.number="draft.hp.current" min="0" :max="draft.hp.max" required></label><label>Temp HP<input type="number" v-model.number="draft.hp.temp" min="0" max="9999" required></label><label>AC<input type="number" v-model.number="draft.ac" min="0" max="9999" required></label><label>Speed (ft)<input type="number" v-model.number="draft.speed" min="0" max="9999" required></label></div><h3>Saving throw proficiency</h3><div class="check-row"><label v-for="a in abilities"><input type="checkbox" :value="a" v-model="draft.saves">{{a.toUpperCase()}}</label></div><h3>Skill proficiency</h3><div class="form-grid"><label v-for="(a,name) in skills">{{name}}<select v-model="draft.skills[name]"><option value="none">None</option><option value="proficient">Proficient</option><option value="expert">Expertise</option></select></label></div><button class="primary wide">Save character</button></form>
<template v-if="drawer==='Hit points'&&c"><p class="big-number">{{c.hp.current}} <small>/ {{c.hp.max}}</small></p><div class="quick-hp"><button v-for="n in [-10,-5,-1,1,5,10]" @click="hp(n)">{{signed(n)}}</button></div><label>Amount<input type="number" v-model.number="hpAmount" min="1" max="9999"></label><div class="form-grid"><button class="danger-text" @click="hp(-Math.max(0,Math.trunc(hpAmount||0)))">Take damage</button><button class="primary" @click="hp(Math.max(0,Math.trunc(hpAmount||0)))">Heal</button></div><label>Temporary HP<input type="number" :value="c.hp.temp" @change="c.hp.temp=Math.max(0,Math.min(9999,Math.trunc(Number(($event.target as HTMLInputElement).value)||0)))" min="0" max="9999"></label><p class="hint">Damage uses temporary HP first. Healing cannot exceed your maximum.</p></template>
<form v-if="drawer==='Add action'" @submit.prevent="addAction"><label>Name<input v-model="actionDraft.name" required maxlength="120"></label><div class="form-grid"><label>Attack bonus<input type="number" v-model.number="actionDraft.bonus" min="-50" max="100" required></label><label>Damage<input v-model="actionDraft.damage" placeholder="1d8 + 4 piercing" maxlength="120"></label></div><label>Notes<textarea v-model="actionDraft.notes" maxlength="12000"></textarea></label><button class="primary wide">Add action</button></form>
<form v-if="drawer==='Add resource'" @submit.prevent="addResource"><label>Name<input v-model="resourceDraft.name" placeholder="Level 1 spell slots" required maxlength="120"></label><div class="form-grid"><label>Remaining<input type="number" v-model.number="resourceDraft.current" min="0" :max="resourceDraft.max" required></label><label>Maximum<input type="number" v-model.number="resourceDraft.max" min="0" max="9999" required></label></div><label>Recovery<select v-model="resourceDraft.reset"><option value="short-rest">Short or long rest</option><option value="long-rest">Long rest</option><option value="manual">Manual only</option></select></label><button class="primary wide">Add resource</button></form>
<template v-if="drawer==='Manage resources'&&c"><div class="resource" v-for="r in c.resources"><b>{{r.name}}</b><button class="quiet danger-text" @click="c.resources=c.resources.filter(x=>x.id!==r.id)">Remove</button></div><button @click="drawer='Add resource'">+ Add resource</button></template>
<form v-if="drawer==='Add spell'" @submit.prevent="addSpell"><label>Name<input v-model="spellDraft.name" required maxlength="120"></label><label>Level (0 = cantrip)<input type="number" v-model.number="spellDraft.level" min="0" max="9" required></label><label>Spell notes<textarea v-model="spellDraft.notes" rows="6" maxlength="12000" placeholder="Casting time, range, components, duration, effects…"></textarea></label><button class="primary wide">Add spell</button></form>
<template v-if="drawer==='Rest'&&c"><p class="hint">Review before applying your rest.</p><div class="preview-row" v-if="restType==='long-rest'"><span>Hit points</span><b>{{c.hp.current}} → {{c.hp.max}}</b></div><div class="preview-row" v-if="restType==='long-rest'"><span>Temporary HP</span><b>{{c.hp.temp}} → 0</b></div><div class="preview-row" v-for="r in restChanges"><span>{{r.name}}</span><b>{{r.current}} → {{r.max}}</b></div><p class="hint">Manual resources, notes and gear remain as entered. Hit dice and class-specific rest rules are not automated yet.</p><button class="primary wide" @click="confirmRest">Confirm rest</button></template>
<template v-if="drawer==='Roll'&&selectedAction"><h3>{{selectedAction.name}}</h3><p class="hint">Attack {{signed(selectedAction.bonus)}} · {{selectedAction.damage}}</p><p>{{selectedAction.notes}}</p><div class="roll-buttons"><button @click="roll(selectedAction.bonus,-1)">Disadvantage</button><button class="primary" @click="roll(selectedAction.bonus)">Roll d20</button><button @click="roll(selectedAction.bonus,1)">Advantage</button></div><p class="roll-result" role="status">{{rollResult}}</p></template>
<template v-if="drawer==='Dice'"><p class="roll-result" role="status">{{rollResult}}</p><p class="hint">Includes the modifier shown on your sheet.</p></template>
</section></div>
</div>
</template>

8
src/db.ts Normal file
View File

@ -0,0 +1,8 @@
import Dexie, {type Table} from 'dexie';
import {characterSchema,type Character} from './model';
type Row={key:string,owner:string,character:Character};
export class CharacterDB extends Dexie {characters!:Table<Row,string>;constructor(name='arrowstack-character-v1'){super(name);this.version(1).stores({characters:'key,owner'})}}
export const db=new CharacterDB();
export async function loadCharacters(owner:string,database=db){return (await database.characters.where('owner').equals(owner).toArray()).map(r=>characterSchema.parse(r.character))}
export async function saveCharacter(owner:string,c:Character,database=db){const character=characterSchema.parse(c);await database.characters.put({key:`${owner}:${c.id}`,owner,character})}
export async function deleteCharacter(owner:string,id:string,database=db){await database.characters.delete(`${owner}:${id}`)}

2
src/main.ts Normal file
View File

@ -0,0 +1,2 @@
import {createApp} from 'vue';import {createPinia} from 'pinia';import App from './App.vue';import './style.css';
createApp(App).use(createPinia()).mount('#app');

20
src/model.ts Normal file
View File

@ -0,0 +1,20 @@
import { z } from 'zod';
export const abilities=['str','dex','con','int','wis','cha'] as const;
export type Ability=typeof abilities[number];
const score=z.number().int().min(1).max(30);
const counter=z.number().int().min(0).max(9999);
const text=z.string().max(12000);
export const resourceSchema=z.object({id:z.string(),name:text,current:counter,max:counter,reset:z.enum(['short-rest','long-rest','manual'])}).refine(r=>r.current<=r.max,'Current cannot exceed maximum');
export const actionSchema=z.object({id:z.string(),name:text,bonus:z.number().int().min(-50).max(100),damage:text,notes:text});
export const spellSchema=z.object({id:z.string(),name:text,level:z.number().int().min(0).max(9),prepared:z.boolean(),notes:text});
export const characterSchema=z.object({schemaVersion:z.literal(1),id:z.string(),name:z.string().min(1).max(120),className:text,level:z.number().int().min(1).max(20),species:text,background:text,abilities:z.object({str:score,dex:score,con:score,int:score,wis:score,cha:score}),skills:z.record(z.string(),z.enum(['none','proficient','expert'])),saves:z.array(z.enum(abilities)),hp:z.object({current:counter,max:z.number().int().min(1).max(9999),temp:counter}).refine(h=>h.current<=h.max),ac:counter,speed:counter,actions:z.array(actionSchema).max(200),resources:z.array(resourceSchema).max(200),spells:z.array(spellSchema).max(500),inventory:text,notes:text});
export type Character=z.infer<typeof characterSchema>;
export const skills:Record<string,Ability>={'Acrobatics':'dex','Animal Handling':'wis','Arcana':'int','Athletics':'str','Deception':'cha','History':'int','Insight':'wis','Intimidation':'cha','Investigation':'int','Medicine':'wis','Nature':'int','Perception':'wis','Performance':'cha','Persuasion':'cha','Religion':'int','Sleight of Hand':'dex','Stealth':'dex','Survival':'wis'};
export function newCharacter():Character{return {schemaVersion:1,id:crypto.randomUUID(),name:'New adventurer',className:'',level:1,species:'',background:'',abilities:{str:10,dex:10,con:10,int:10,wis:10,cha:10},skills:{},saves:[],hp:{current:10,max:10,temp:0},ac:10,speed:30,actions:[],resources:[],spells:[],inventory:'',notes:''}}
export const modifier=(n:number)=>Math.floor((n-10)/2);
export const proficiency=(level:number)=>2+Math.floor((level-1)/4);
export const signed=(n:number)=>n>=0?`+${n}`:`${n}`;
export function skillBonus(c:Character,name:string){return modifier(c.abilities[skills[name]])+proficiency(c.level)*(c.skills[name]==='expert'?2:c.skills[name]==='proficient'?1:0)}
export function changeHP(c:Character,amount:number){if(amount<0){const absorbed=Math.min(c.hp.temp,-amount);c.hp.temp-=absorbed;c.hp.current=Math.max(0,c.hp.current+amount+absorbed)}else c.hp.current=Math.min(c.hp.max,c.hp.current+amount)}
export function restPreview(c:Character,type:'short-rest'|'long-rest'){return c.resources.filter(r=>r.reset===type||(type==='long-rest'&&r.reset==='short-rest')).filter(r=>r.current!==r.max)}
export function applyRest(c:Character,type:'short-rest'|'long-rest'){for(const r of restPreview(c,type))r.current=r.max;if(type==='long-rest'){c.hp.current=c.hp.max;c.hp.temp=0}}

5
src/store.ts Normal file
View File

@ -0,0 +1,5 @@
import {defineStore} from 'pinia';
import {ref} from 'vue';
import {loadCharacters,saveCharacter,deleteCharacter} from './db';
import type {Character} from './model';
export const useCharacters=defineStore('characters',()=>{const all=ref<Character[]>([]),owner=ref(''),saveError=ref(''),saved=ref(true);let queue=Promise.resolve();async function open(sub:string){owner.value=sub;all.value=await loadCharacters(sub)}function persist(c:Character){saved.value=false;const snapshot=JSON.parse(JSON.stringify(c));const sub=owner.value;queue=queue.then(()=>saveCharacter(sub,snapshot)).then(()=>{saved.value=true;saveError.value=''}).catch(()=>{saveError.value='Could not save on this device. Export a backup before closing.'})}async function remove(id:string){await queue;await deleteCharacter(owner.value,id);all.value=all.value.filter(c=>c.id!==id)}async function flush(){await queue}return {all,owner,saved,saveError,open,persist,remove,flush}});

2
src/style.css Normal file

File diff suppressed because one or more lines are too long

5
tests/auth.test.ts Normal file
View File

@ -0,0 +1,5 @@
import {test} from 'node:test';import assert from 'node:assert/strict';
// @ts-ignore server module is plain JavaScript
import {sessionCodec} from '../server/auth.mjs';
test('encrypted sessions reject tampering, expiry and other client keys',async()=>{const codec=sessionCodec('a-confidential-client-secret','character'),token=await codec.seal({sub:'alice'});assert.equal((await codec.open(token)).sub,'alice');assert.equal(await codec.open(token.slice(0,-4)+'aaaa'),null);assert.equal(await sessionCodec('another-confidential-secret','character').open(token),null);assert.equal(await codec.open(await codec.seal({sub:'alice'},-1)),null);assert.equal(await codec.open(''),null)});
test('missing secrets fail closed',()=>{assert.throws(()=>sessionCodec('','character'))});

9
tests/character.test.ts Normal file
View File

@ -0,0 +1,9 @@
import 'fake-indexeddb/auto';
import {test} from 'node:test';import assert from 'node:assert/strict';
import {newCharacter,changeHP,modifier,proficiency,skillBonus,applyRest,restPreview,characterSchema} from '../src/model.ts';
import {CharacterDB,saveCharacter,loadCharacters,deleteCharacter} from '../src/db.ts';
test('damage consumes temporary HP, clamps zero, healing clamps max',()=>{const c=newCharacter();c.hp={current:20,max:30,temp:5};changeHP(c,-3);assert.deepEqual(c.hp,{current:20,max:30,temp:2});changeHP(c,-7);assert.deepEqual(c.hp,{current:15,max:30,temp:0});changeHP(c,-100);assert.equal(c.hp.current,0);changeHP(c,100);assert.equal(c.hp.current,30)});
test('ability and proficiency calculations include expertise',()=>{assert.equal(modifier(9),-1);assert.equal(proficiency(1),2);assert.equal(proficiency(5),3);assert.equal(proficiency(20),6);const c=newCharacter();c.level=5;c.abilities.dex=18;c.skills.Stealth='expert';assert.equal(skillBonus(c,'Stealth'),10)});
test('short and long rest reset only declared resources',()=>{const c=newCharacter();c.resources=[{id:'s',name:'Second Wind',current:0,max:1,reset:'short-rest'},{id:'l',name:'Slots',current:0,max:3,reset:'long-rest'},{id:'m',name:'Charges',current:0,max:2,reset:'manual'}];c.hp.current=3;c.hp.temp=4;assert.equal(restPreview(c,'short-rest').length,1);applyRest(c,'short-rest');assert.equal(c.hp.current,3);assert.deepEqual(c.resources.map(r=>r.current),[1,0,0]);applyRest(c,'long-rest');assert.deepEqual(c.resources.map(r=>r.current),[1,3,0]);assert.equal(c.hp.current,10);assert.equal(c.hp.temp,0)});
test('imports reject invalid characters and counters',()=>{const c=newCharacter();c.level=21;assert.equal(characterSchema.safeParse(c).success,false);c.level=1;c.resources=[{id:'x',name:'x',current:2,max:1,reset:'manual'}];assert.equal(characterSchema.safeParse(c).success,false)});
test('IndexedDB persists and separates characters by account',async()=>{const database=new CharacterDB('test-'+crypto.randomUUID()),c=newCharacter();c.name='First account';await saveCharacter('alice',c,database);const other=structuredClone(c);other.name='Second account';await saveCharacter('bob',other,database);assert.equal((await loadCharacters('alice',database))[0].name,'First account');assert.equal((await loadCharacters('bob',database))[0].name,'Second account');await deleteCharacter('alice',c.id,database);assert.equal((await loadCharacters('alice',database)).length,0);assert.equal((await loadCharacters('bob',database)).length,1);await database.delete()});

1
tsconfig.json Normal file
View File

@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","strict":true,"lib":["ES2022","DOM","DOM.Iterable"],"skipLibCheck":true,"types":["vite/client"],"allowImportingTsExtensions":true,"noEmit":true},"include":["src/**/*.ts","src/**/*.vue","vite.config.ts"]}

3
vite.config.ts Normal file
View File

@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({plugins:[vue()],server:{proxy:{'/api':'http://localhost:8080','/auth':'http://localhost:8080'}}});