diff --git a/easel/web/.gitignore b/easel/web/.gitignore new file mode 100644 index 0000000000..dd78d933f3 --- /dev/null +++ b/easel/web/.gitignore @@ -0,0 +1,2 @@ +/test-results/ +/node_modules/ diff --git a/easel/web/README.md b/easel/web/README.md new file mode 100644 index 0000000000..a734614bda --- /dev/null +++ b/easel/web/README.md @@ -0,0 +1,61 @@ +# Aesel in a browser + +The `/try/` client uses `phone/session.mjs` and the shared AC engine. The first +release makes JavaScript pieces using hosted AC braincells, publishes edits to +the signed-in handle and embeds the public piece. It does not run a local CLI. + +```sh +npm ci --prefix easel/web +node easel/web/build.mjs +node easel/web/serve.mjs +# http://localhost:8771/try/ +``` + +The build writes only to `system/public/aesel/try/` (or a supplied output +directory). It bundles explicit browser shims and guides; it never serves the +repository or exposes a development token endpoint. Its only build dependency +is pinned esbuild. Build outputs are ignored by git. + +Auth0 uses the existing AC SPA client, `LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt`, +at `hi.aesthetic.computer`, with the existing vendored Auth0 SPA SDK and PKCE. +Append these settings without replacing other AC URLs: + +| Setting | Production | Local | +| --- | --- | --- | +| Allowed Callback URLs | `https://aesel.app/try/` | `http://localhost:8771/try/` | +| Allowed Logout URLs | `https://aesel.app/try/` | `http://localhost:8771/try/` | +| Allowed Web Origins | `https://aesel.app` | `http://localhost:8771` | +| Allowed Origins (CORS) | `https://aesel.app` | `http://localhost:8771` | + +Auth0 owns token storage and refresh. Engine credentials stay in memory. Drafts, +conversation context and the last-opened piece are stored separately per Auth0 +subject in this browser's localStorage. They survive sign-out but are not synced +between computers. Download source before clearing browser data. Model output +is rendered as text; preview code runs on the separate AC origin in a sandboxed +iframe. The gate discloses public publishing and existing staff access. + +After committing and pushing to main, run `bash easel/web/deploy.sh`. It builds +from that pushed commit on lith, then atomically switches the `/try/` symlink +to the new release. It does not deploy other working-tree changes or restart +the monolith. The existing aesel.app Caddy site serves the result. Prior releases +remain in `/opt/ac/.aesel-web-releases/`; `try/build.json` identifies the revision. +A regular monolith deployment does not rebuild this client; run this script +again when its shared engine dependencies change. + +Validation: + +```sh +node --test easel/web/storage.test.mjs easel/test/phone-session.test.mjs easel/test/phone-credits.test.mjs +node easel/web/browser.test.mjs +``` + +The browser test needs the local server and installed Chrome. It uses the real +bundle and agent loop with mocked authentication, inference and uploads. It +checks saved conversations, two tool rounds, publication and preview wiring, +account isolation, escaping, logout and mobile layout. It does not prove live +Auth0, live inference or rendering on older browsers. Browser build targets: +Chrome 90, Firefox 91 and Safari 15.6; actual oldest-device support still needs +device testing, including the AC runtime inside the preview. + +Only pieces are supported initially. Native preview capture, local files, other +media and vendor CLI subscriptions remain desktop capabilities. diff --git a/easel/web/app.css b/easel/web/app.css new file mode 100644 index 0000000000..452ea32db0 --- /dev/null +++ b/easel/web/app.css @@ -0,0 +1,2 @@ +@font-face{font-family:Unifont;src:url('/pixel.woff') format('woff');font-display:swap} +:root{color-scheme:light;--paper:#f4eddb;--ink:#39274c;--purple:#63417d;--line:#d8c9bc;--pink:#f4c0d7}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:18px/1.5 Unifont,ui-monospace,monospace}button,textarea,select{font:inherit;color:inherit}button,a,select{-webkit-tap-highlight-color:transparent}button,select{border:1px solid var(--line);border-radius:7px;background:#fff9ee;padding:8px 13px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}a{color:var(--purple)}button:hover:not(:disabled){background:var(--pink)}button.primary{background:var(--purple);color:white;border-color:var(--purple)}button.primary:hover:not(:disabled){background:#4d2e65}:focus-visible{outline:3px solid #b35b97;outline-offset:3px}[hidden]{display:none!important}header{display:flex;justify-content:space-between;align-items:center;gap:18px;padding:18px 26px;border-bottom:1px solid var(--line)}.brand{font-size:30px;text-decoration:none;line-height:1.2}.brand span{display:block;font-size:14px}.account{display:flex;align-items:center;gap:14px;font-size:15px}#gate{max-width:650px;margin:7vh auto;padding:24px;text-align:center}#gate img{object-fit:contain;image-rendering:pixelated}h1{font-size:42px;line-height:1.15;margin:20px 0}#gate-status{min-height:27px}#gate-status.error,.bad{color:#942748}.disclosure{font-size:15px;max-width:460px;margin:30px auto}.pieces{display:flex;align-items:center;gap:12px;padding:14px 26px}.pieces select{max-width:320px;min-width:120px}#status{margin-left:auto;font-size:15px}.workbench{display:grid;grid-template-columns:minmax(280px,1fr) minmax(300px,1fr);height:calc(100vh - 165px);min-height:420px;gap:24px;padding:0 26px 20px}.notebook{display:flex;flex-direction:column;min-height:0;border:1px solid var(--line);border-radius:10px;background:#fffaf0;overflow:hidden}#log{list-style:none;margin:0;padding:22px;overflow:auto;flex:1}#log li{margin:0 0 22px;white-space:pre-wrap;overflow-wrap:anywhere}#log li.you{border-left:3px solid #b35b97;padding-left:14px}#log li.note{font-size:15px;color:#705d77}#composer{border-top:1px solid var(--line);padding:14px}textarea{display:block;width:100%;resize:vertical;min-height:76px;max-height:180px;border:0;background:transparent;outline-offset:2px}.compose-actions{display:flex;justify-content:space-between;align-items:center;margin-top:10px}.compose-actions span{font-size:14px;color:#705d77}.preview-panel{display:flex;flex-direction:column;min-height:0}.preview-title{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:42px;margin-bottom:12px}.preview-title button{margin-left:auto;font-size:15px}.stage{position:relative;flex:1;min-height:260px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#e8ddcd;display:flex;align-items:center;justify-content:center}.stage iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:white}#publish-note{font-size:14px;margin:10px 0 0}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){header{padding:14px 16px}.account{gap:8px;flex-wrap:wrap;justify-content:flex-end}#credits{display:none}.pieces{padding:12px 16px;flex-wrap:wrap}.pieces label{display:none}.pieces select{flex:1;min-width:0;max-width:none}#status{width:100%;text-align:right}.workbench{display:flex;flex-direction:column;height:auto;min-height:0;padding:0 16px 16px;gap:16px}.notebook{height:52vh;min-height:310px}.preview-panel{min-height:330px}.stage{height:320px;flex:auto}h1{font-size:34px}#gate{margin:3vh auto}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} diff --git a/easel/web/app.mjs b/easel/web/app.mjs new file mode 100644 index 0000000000..bdc303b30c --- /dev/null +++ b/easel/web/app.mjs @@ -0,0 +1,148 @@ +import '../phone/shim/globals.js'; +import { createSession } from '../phone/session.mjs'; +import { accountStorage } from './storage.mjs'; + +// A few shared engine helpers postdate the oldest browser build we target. +if (!Array.prototype.at) Object.defineProperty(Array.prototype, 'at', {value(index) { return this[index < 0 ? this.length + index : index]; }}); +if (!Array.prototype.findLast) Object.defineProperty(Array.prototype, 'findLast', {value(fn) { for(let i=this.length-1;i>=0;i--) if(fn(this[i],i,this)) return this[i]; }}); +if (!AbortSignal.timeout) AbortSignal.timeout = ms => { const c=new AbortController(); setTimeout(()=>c.abort(),ms); return c.signal; }; +if (!AbortSignal.prototype.throwIfAborted) AbortSignal.prototype.throwIfAborted = function() { if(this.aborted) throw new DOMException('Stopped','AbortError'); }; + +const $ = id => document.getElementById(id); +const callback = `${location.origin}/try/`; +let auth, session, streaming, subject = '', source = '', submitting = false; + +function gate(message, error = false) { + $('gate').hidden = false; + $('workspace').hidden = true; + $('gate-status').textContent = message; + $('gate-status').className = error ? 'error' : ''; +} +function line(kind, text) { + const node = document.createElement('li'); + node.className = kind; + node.textContent = text; + $('log').append(node); + return node; +} +function follow(fn) { + const log = $('log'), near = log.scrollHeight - log.scrollTop - log.clientHeight < 100; + fn(); + if (near) log.scrollTop = log.scrollHeight; +} +function busy(value) { + $('send').disabled = value; + $('stop').hidden = !value; + $('new').disabled = value; + $('history').disabled = value; + $('logout').disabled = value; +} +function event(e) { + if(e.type === 'you' || e.type === 'note' || e.type === 'bad') follow(()=>line(e.type,e.text)); + if(e.type === 'busy') busy(e.busy); + if(e.type === 'status') $('status').textContent = e.text; + if(e.type === 'signedIn') { $('handle').textContent = e.handle ? `@${e.handle}` : ''; $('logout').hidden = false; } + if(e.type === 'credits') $('credits').textContent = Number.isFinite(e.total) ? `${e.total.toLocaleString()} braincells` : ''; + if(e.type === 'piece' || e.type === 'source') { source = e.source || ''; $('download').disabled = !source; } + if(e.type === 'thread') { + $('log').replaceChildren(); streaming = null; + $('preview').hidden = true; $('preview').removeAttribute('src'); $('empty').hidden = false; $('piece-link').hidden = true; + for(const item of e.events || []) event(item); + } + if(e.type === 'preview') { + const url = new URL(e.url); + if(url.origin !== 'https://aesthetic.computer') return; + $('preview').src = url.href; $('preview').hidden = false; $('empty').hidden = true; + $('piece-link').href = url.href; $('piece-link').hidden = false; + } + if(e.type === 'history') { + const selected = session?.state.id; + $('history').replaceChildren(...e.items.map(item => { const o=document.createElement('option');o.value=item.id;o.textContent=item.title;o.selected=item.id===selected;return o; })); + } + if(e.type === 'bridge') { + const p=e.params || {}; + if(e.method === 'turn/started') streaming = null; + if(e.method === 'turn/progress') $('status').textContent = p.phase || 'Working…'; + if(e.method === 'item/agentMessage/delta') follow(()=>{ if(!streaming) streaming=line('answer',''); streaming.append(document.createTextNode(p.delta || '')); }); + if(e.method === 'item/completed' && p.item?.type === 'agentMessage') { + if(!streaming) follow(()=>line('answer',p.item.text || '')); + streaming=null; + } + if(e.method === 'turn/completed') { + streaming=null; + if(p.turn?.error?.message) line('bad',p.turn.error.message); + $('status').textContent=p.turn?.error ? 'Could not finish' : p.turn?.status === 'interrupted' ? 'Stopped' : 'Ready'; + } + } +} + +async function enter() { + const user=await auth.getUser(); + if(!user?.sub) throw new Error('Sign in again to open your pieces.'); + subject=user.sub; + const token=await auth.getTokenSilently(); + session=createSession({storage:accountStorage(localStorage,user.sub),emit:event}); + await session.adoptToken(token); + if(!session.state.handle) { + gate('Claim an @handle at aesthetic.computer/handle, then return here.'); + const link=document.createElement('a');link.href='https://aesthetic.computer/handle';link.textContent='Choose your handle'; + $('gate-status').append(document.createElement('br'),link); + return; + } + await session.begin(); + await session.open(); + $('gate').hidden=true;$('workspace').hidden=false; + $('input').focus({preventScroll:true}); +} + +async function boot() { + if(!window.auth0 || !crypto.subtle) throw new Error('This browser could not load secure sign-in. Try a current Firefox or Chrome.'); + auth=new window.auth0.Auth0Client({ + domain:'hi.aesthetic.computer',clientId:'LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt', + cacheLocation:'localstorage',useRefreshTokens:true,useRefreshTokensFallback:true, + httpTimeoutInSeconds:30,authorizationParams:{redirect_uri:callback}, + }); + const query=new URLSearchParams(location.search); + if(query.has('state') && (query.has('code') || query.has('error'))) { + try { await auth.handleRedirectCallback(); } + finally { history.replaceState({},'',callback); } + } else await auth.checkSession(); + $('login').disabled=false; + if(await auth.isAuthenticated()) await enter(); + else gate(''); +} +$('login').onclick=()=>{ + $('login').disabled=true; + auth.loginWithRedirect({authorizationParams:{redirect_uri:callback}}).catch(error=>{gate(error.message,true);$('login').disabled=false;}); +}; +$('logout').onclick=async()=>{ + if(session?.state.busy || session?.state.publishing) { line('note','Wait for this turn and upload to finish before signing out.');return; } + session?.signOut(); + await auth.logout({logoutParams:{returnTo:callback}}); +}; +$('composer').onsubmit=async e=>{ + e.preventDefault(); + const text=$('input').value.trim(); + if(!text || submitting || session?.state.busy) return; + submitting=true;busy(true); + try { + // Renew via the SDK before each turn instead of persisting engine tokens. + const token=await auth.getTokenSilently(); + if((await auth.getUser())?.sub !== subject) throw new Error('Your signed-in account changed. Reload to open its pieces.'); + session.state.token=token; + $('input').value=''; + await session.ask(text); + if(session.state.publishing) await session.state.publishing; + } catch(error) {line('bad',error.message);} + finally {submitting=false;busy(false);} +}; +$('input').onkeydown=e=>{if(e.key==='Enter' && !e.shiftKey && !e.isComposing && matchMedia('(hover: hover)').matches){e.preventDefault();$('send').click();}}; +$('stop').onclick=()=>session?.stop(); +$('new').onclick=async()=>{try{await session.newPiece();$('input').focus();}catch(error){line('bad',error.message);}}; +$('history').onchange=async()=>{try{await session.resumeSession($('history').value);}catch(error){line('bad',error.message);}}; +$('download').onclick=()=>{ + const url=URL.createObjectURL(new Blob([source],{type:'text/javascript'})); + const a=document.createElement('a');a.href=url;a.download=`${session.state.slug}.mjs`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000); +}; +window.addEventListener('pagehide',()=>session?.saveCurrent()); +void boot().catch(error=>{gate(error.message,true);$('login').disabled=!auth;}); diff --git a/easel/web/browser.test.mjs b/easel/web/browser.test.mjs new file mode 100644 index 0000000000..f7817019ac --- /dev/null +++ b/easel/web/browser.test.mjs @@ -0,0 +1,84 @@ +// Focused integration: real browser bundle/agent loop, mocked account and network. +// Nothing is sent to inference, storage or publishing services. +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +const browser=await chromium.launch({channel:'chrome',headless:true}); +const context=await browser.newContext(); +const page=await context.newPage(); +const errors=[];page.on('pageerror',error=>errors.push(error.message)); +let rounds=0,uploaded='',requests=[]; +const source='export function paint({ wipe, ink, circle }) { wipe("purple"); ink("pink").circle(100, 100, 40).draw(); }\n'; +const json=(route,data)=>route.fulfill({contentType:'application/json',body:JSON.stringify(data)}); +await context.route('**/*',async route=>{ + const url=new URL(route.request().url()); + if(url.pathname==='/try/auth0.js') return route.fulfill({contentType:'text/javascript',body:`window.auth0={Auth0Client:class { + async checkSession(){} async isAuthenticated(){return !localStorage.getItem('signedOut');} + async getUser(){return {sub:localStorage.getItem('testAccount') || 'auth0|alice'};} + async getTokenSilently(){return 'test-only-token';} + async logout(){localStorage.setItem('signedOut','yes');location.reload();} + async loginWithRedirect(){localStorage.removeItem('signedOut');location.reload();} + }};`}); + if(url.hostname==='localhost') return route.continue(); + requests.push({url:url.href,method:route.request().method()}); + if(url.pathname==='/userinfo')return json(route,{sub:'auth0|alice'}); + if(url.pathname==='/handle')return json(route,{handle:'alice'}); + if(url.pathname==='/api/easel-credits')return json(route,{remaining:500,purchased:0}); + if(url.pathname==='/api/easel-inference'){ + rounds++; + const body=route.request().postDataJSON(); + assert(body.system && body.tools.some(t=>t.name==='write_piece')); + const events=rounds===1 ? [ + {type:'content_block_start',index:0,content_block:{type:'tool_use',id:'write-1',name:'write_piece'}}, + {type:'content_block_delta',index:0,delta:{type:'input_json_delta',partial_json:JSON.stringify({source})}}, + {type:'content_block_stop',index:0}, + {type:'message_delta',delta:{stop_reason:'tool_use'}}, + ] : [ + {type:'content_block_delta',index:0,delta:{type:'text_delta',text:'A pink circle on purple. '}}, + {type:'message_delta',delta:{stop_reason:'end_turn'}}, + ]; + return route.fulfill({contentType:'text/event-stream',body:events.map(e=>'data: '+JSON.stringify(e)+'\n\n').join('')}); + } + if(url.pathname.includes('/presigned-upload-url/'))return json(route,{uploadURL:'https://upload.test/piece'}); + if(url.hostname==='upload.test'){uploaded=route.request().postData();return route.fulfill({body:''});} + if(url.pathname.endsWith('.mjs'))return route.fulfill({body:uploaded}); + if(url.pathname.startsWith('/@alice/'))return route.fulfill({contentType:'text/html',body:'

Preview fixture

'}); + return json(route,{}); +}); +try { + await page.goto('http://localhost:8771/try/'); + await page.locator('#workspace').waitFor({state:'visible'}); + await page.locator('#input').fill('Make a pink circle.'); + await page.locator('#send').click(); + await page.locator('#log .answer').waitFor(); + await page.locator('#preview').waitFor({state:'visible'}); + await page.waitForFunction(()=>!document.getElementById('send').disabled); + assert.equal(uploaded,source);assert.equal(rounds,2); + assert.equal(await page.locator('#log img').count(),0); + const firstId=await page.locator('#history').inputValue(); + await page.reload(); + await page.locator('#workspace').waitFor({state:'visible'}); + assert.equal(await page.locator('#history').inputValue(),firstId); + assert.match(await page.locator('#log').innerText(),/Make a pink circle/); + await page.locator('#new').click(); + await page.waitForFunction(id=>document.getElementById('history').value!==id,firstId); + assert.equal(await page.locator('#log li').count(),0); + await page.locator('#history').selectOption(firstId); + await page.locator('#log .answer').waitFor(); + await mkdir(new URL('./test-results/',import.meta.url),{recursive:true}); + await page.screenshot({path:new URL('./test-results/desktop.png',import.meta.url).pathname}); + await page.setViewportSize({width:390,height:844}); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); + await page.screenshot({path:new URL('./test-results/mobile.png',import.meta.url).pathname,fullPage:true}); + const storage=await page.evaluate(()=>JSON.stringify(localStorage)); + assert(!storage.includes('test-only-token')); + await page.locator('#logout').click(); + await page.locator('#gate').waitFor({state:'visible'}); + await page.evaluate(()=>{localStorage.setItem('testAccount','auth0|bob');localStorage.removeItem('signedOut');}); + await page.reload(); + await page.locator('#workspace').waitFor({state:'visible'}); + assert(!((await page.locator('#log').innerText()).includes('pink circle'))); + assert.equal(await page.locator('#history option').count(),1); + assert.deepEqual(errors,[]); + console.log('PASS: generation, upload, preview, reload, thread switching, text safety, account isolation, sign-out and mobile layout. Network mocked.'); +}finally {await browser.close();} diff --git a/easel/web/build.mjs b/easel/web/build.mjs new file mode 100644 index 0000000000..9d0ef56b6f --- /dev/null +++ b/easel/web/build.mjs @@ -0,0 +1,29 @@ +import { build } from 'esbuild'; +import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..'); +const out=resolve(root,process.argv[2] || 'system/public/aesel/try'); +await mkdir(out,{recursive:true}); +const shims={'node:events':'events','node:fs':'fs','node:path':'path','node:url':'url','node:crypto':'crypto','node:os':'absent','node:http':'absent','node:child_process':'absent','node:readline':'absent'}; +const guides={}; +for(const name of ['pieces.md','screen.md','hand.md','kidlisp.md','api.json']) guides[`/easel/context/${name}`]=await readFile(resolve(root,'easel/context',name),'utf8'); +const result=await build({ + entryPoints:[resolve(root,'easel/web/app.mjs')],outfile:resolve(out,'app.js'),bundle:true,format:'iife', + platform:'browser',target:['chrome90','firefox91','safari15.6'],minify:true,metafile:true, + define:{'import.meta.url':JSON.stringify('https://aesel.app/easel/src/browser.mjs')}, + banner:{js:`globalThis.__aeselGuides=${JSON.stringify(guides)};`}, + plugins:[{name:'phone-shims',setup(b){ + b.onResolve({filter:/^node:/},args=>{ + const name=shims[args.path];if(!name) throw new Error(`Unmapped browser dependency: ${args.path}`); + return {path:resolve(root,`easel/phone/shim/${name}.mjs`)}; + }); + b.onResolve({filter:/^\/easel\//},args=>({path:resolve(root,args.path.slice(1))})); + b.onResolve({filter:/^\.\/(revisions|preview-frame|jev-advisor)\.mjs$/},args=>({path:resolve(root,'easel/phone/shim',args.path)})); + }}], +}); +for(const name of ['index.html','app.css']) await copyFile(resolve(root,'easel/web',name),resolve(out,name)); +await copyFile(resolve(root,'system/public/aesthetic.computer/dep/auth0-spa-js.production.js'),resolve(out,'auth0.js')); +await writeFile(resolve(out,'build.json'),JSON.stringify({revision:process.env.AESEL_WEB_REVISION || null,inputs:Object.keys(result.metafile.inputs)},null,2)+'\n'); +console.log(`Aesel web → ${out}`); diff --git a/easel/web/deploy.sh b/easel/web/deploy.sh new file mode 100644 index 0000000000..ec0563250d --- /dev/null +++ b/easel/web/deploy.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Deploy only the browser client, built on lith from a pushed main commit. +set -euo pipefail +cd "$(dirname "$0")/../.." +revision=$(git rev-parse "${1:-HEAD}^{commit}") +host=${LITH_HOST:-lith.aesthetic.computer} +key=${LITH_SSH_KEY:-aesthetic-computer-vault/home/.ssh/id_rsa} +git fetch origin main --quiet +git merge-base --is-ancestor "$revision" origin/main || { + echo 'Push this commit to main before deploying.' >&2; exit 1; +} +ssh -i "$key" -o BatchMode=yes -o ConnectTimeout=10 "root@$host" bash -s -- "$revision" <<'REMOTE' +set -euo pipefail +revision=$1 +cd /opt/ac +git fetch origin main --quiet +git merge-base --is-ancestor "$revision" origin/main +release_root=/opt/ac/.aesel-web-releases +release="$release_root/$revision" +link=/opt/ac/system/public/aesel/try +if [ -e "$link" ] && [ ! -L "$link" ]; then + echo "$link already exists as a real directory; refusing to replace it." >&2 + exit 1 +fi +mkdir -p "$release_root" +work=$(mktemp -d "$release_root/.build.XXXXXX") +trap 'rm -rf "$work"' EXIT +if [ ! -f "$release/build.json" ]; then + mkdir "$work/source" + git archive "$revision" easel/web easel/phone easel/src easel/context \ + system/public/aesthetic.computer/dep/auth0-spa-js.production.js \ + | tar -x -C "$work/source" + cd "$work/source" + npm ci --prefix easel/web --include=dev --no-audit --no-fund + AESEL_WEB_REVISION="$revision" node easel/web/build.mjs "$work/output" + mv "$work/output" "$release" +fi +# GNU mv replaces the previous symlink atomically; retain earlier releases. +ln -s "$release" "$work/try" +mv -Tf "$work/try" "$link" +echo "Aesel web deployed: $revision → https://aesel.app/try/" +REMOTE diff --git a/easel/web/index.html b/easel/web/index.html new file mode 100644 index 0000000000..5b8bc99b31 --- /dev/null +++ b/easel/web/index.html @@ -0,0 +1,35 @@ + + + + + + + Aesel — make a piece + + + + + + +
Aeselby Aesthetic Computer
+
+ +

What shall we make?

+

Describe a piece. Play it as it takes shape.

+ +

Loading…

+

Pieces publish to your public @handle as you work. Conversations and revisions are shared with authorized AC staff. Privacy

+ +
+
+ +
+
+
    +
    AC braincells
    +
    +

    Your piece appears here.

    Edits publish to your public @handle. Drafts stay in this browser.

    +
    +
    + + diff --git a/easel/web/package-lock.json b/easel/web/package-lock.json new file mode 100644 index 0000000000..ea6e40290d --- /dev/null +++ b/easel/web/package-lock.json @@ -0,0 +1,499 @@ +{ + "name": "aesel-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aesel-web", + "version": "0.1.0", + "devDependencies": { + "esbuild": "0.28.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + } + } +} diff --git a/easel/web/package.json b/easel/web/package.json new file mode 100644 index 0000000000..1471d04ef6 --- /dev/null +++ b/easel/web/package.json @@ -0,0 +1,15 @@ +{ + "name": "aesel-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs", + "start": "node serve.mjs", + "test": "node --test storage.test.mjs", + "test:browser": "node browser.test.mjs" + }, + "devDependencies": { + "esbuild": "0.28.1" + } +} diff --git a/easel/web/serve.mjs b/easel/web/serve.mjs new file mode 100644 index 0000000000..0270c033f5 --- /dev/null +++ b/easel/web/serve.mjs @@ -0,0 +1,17 @@ +// Serve only the public Aesel site. No tokens, repository files or dev endpoints. +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { resolve, extname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const root=fileURLToPath(new URL('../../system/public/aesel/',import.meta.url)); +const types={'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.woff':'font/woff'}; +createServer(async(req,res)=>{ + try { + let path=decodeURIComponent(new URL(req.url,'http://localhost').pathname); + if(path.endsWith('/')) path+='index.html'; + const file=resolve(root,'.'+path); + if(!file.startsWith(resolve(root)+sep)) {res.writeHead(403).end();return;} + const data=await readFile(file); + res.writeHead(200,{'Content-Type':types[extname(file)] || 'application/octet-stream','Cache-Control':'no-store'}).end(data); + }catch {res.writeHead(404).end('Not found');} +}).listen(Number(process.env.PORT || 8771),'127.0.0.1',()=>console.log('Aesel web → http://localhost:8771/try/')); diff --git a/easel/web/storage.mjs b/easel/web/storage.mjs new file mode 100644 index 0000000000..4012e2a743 --- /dev/null +++ b/easel/web/storage.mjs @@ -0,0 +1,21 @@ +// Keep drafts per Auth0 identity. The engine's account record stays in memory; +// credential persistence and renewal belong to the Auth0 SDK alone. +export function accountStorage(backing, subject) { + if (!subject) throw new Error("An account is required to open saved pieces."); + const prefix = `aesel.web.v1:${encodeURIComponent(subject)}:`; + let account = null; + return { + get(key) { + if (key !== "session") return backing.getItem(prefix + key); + return account || backing.getItem(prefix + 'position'); + }, + set(key, value) { + if (key === "session") { + account = value; + const { threadID } = JSON.parse(value); + if (threadID) backing.setItem(prefix + 'position', JSON.stringify({threadID})); + } + else backing.setItem(prefix + key, value); + }, + }; +} diff --git a/easel/web/storage.test.mjs b/easel/web/storage.test.mjs new file mode 100644 index 0000000000..ac944a70cf --- /dev/null +++ b/easel/web/storage.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { accountStorage } from './storage.mjs'; + +test('drafts and last-opened piece survive reload, isolated by account without credentials',()=>{ + const values=new Map(); + const backing={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; + const a=accountStorage(backing,'auth0|alice'); + a.set('session',JSON.stringify({token:'private-token',handle:'alice',threadID:'piece-1'})); + a.set('threads','saved draft'); + const reload=accountStorage(backing,'auth0|alice'); + assert.equal(reload.get('threads'),'saved draft'); + assert.deepEqual(JSON.parse(reload.get('session')),{threadID:'piece-1'}); + assert.equal(accountStorage(backing,'auth0|bob').get('threads'),null); + assert.equal(accountStorage(backing,'auth0|bob').get('session'),null); + assert(!JSON.stringify([...values]).includes('private-token')); + assert.throws(()=>accountStorage(backing,''),/account/); +}); diff --git a/system/public/aesel/.gitignore b/system/public/aesel/.gitignore new file mode 100644 index 0000000000..f09d1f2d0a --- /dev/null +++ b/system/public/aesel/.gitignore @@ -0,0 +1 @@ +/try