app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
master
Colby Knox
0.21.2: render view is read-only, slotSize on own change only, real render errors (Ray, #57 / PR #1); test:render; bran…
e02c729
28d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
#!/usr/bin/env node
// Brand-guide tests: node test/brand.mjs
//
// The guide resolver is a network contract, so every case here runs against a
// STUB registry rather than staging. The stub is the contract from the
// handoff: GET /api/owners/:owner/brand answers the resolved guide, POST
// /api/pages/:owner/brand/files takes the confirm push. That lets each status
// (confirmed, starter, missing, invalid, private, unreachable) be exercised
// deterministically, and lets the confirm push body be inspected byte for
// byte without writing to any real owner's guide.
import http from 'http';
import { spawn, execFile } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(DIR, '..');
let pass = 0;
const fails = [];
function ok(name, cond, detail) {
if (cond) { pass++; console.log(' ok ' + name); }
else { fails.push(name + (detail ? ': ' + detail : '')); console.log(' FAIL ' + name + (detail ? ': ' + detail : '')); }
}
const eq = (name, got, want) => ok(name, got === want, 'got ' + JSON.stringify(got) + ', want ' + JSON.stringify(want));
// ---- the stub registry ----
const GUIDES = {
goodco: {
status: 'confirmed', version: 3, sha: 'a'.repeat(40), can_edit: true,
owner_kind: 'org', visibility: 'public',
page: 'goodco/brand', url: 'http://stub/goodco/brand',
editable_by: { rule: 'org-members', owner_kind: 'org', who: ['goodco'], text: 'Any member of goodco can edit goodco/brand.' },
brand: {
brand_manifest: '1.0', owner: 'goodco', name: 'GoodCo', status: 'confirmed',
confirmed_by: 'someone', confirmed_at: '2026-01-01T00:00:00Z',
colors: { primary: '#ff0000', background: '#101010', text: '#ffffff' },
typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },
logos: { mark: 'assets/mark.svg' },
},
assets: { mark: 'http://stub/goodco/brand/files/assets/mark.svg', fonts: [] },
hints: [], warnings: [], errors: [],
},
starterco: {
status: 'starter', version: 1, sha: 'b'.repeat(40), can_edit: true,
owner_kind: 'user', visibility: 'public',
page: 'starterco/brand', url: 'http://stub/starterco/brand',
editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['starterco'], text: 'Only starterco can edit starterco/brand.' },
brand: {
brand_manifest: '1.0', owner: 'starterco', name: 'Starterco', status: 'starter',
colors: { primary: '#00ff00', background: '#181818', text: '#ffffff' },
typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },
logos: { mark: 'assets/mark.png' },
},
assets: { mark: 'http://stub/starterco/brand/files/assets/mark.png', fonts: [] },
hints: [{ level: 'warning', code: 'BRAND_GUIDE_STARTER', message: 'starterco/brand is a starter guide nobody has approved yet.' }],
warnings: [], errors: [],
},
// can_edit false: a thread looking at someone else's guide
lockedco: {
status: 'starter', version: 1, sha: 'c'.repeat(40), can_edit: false,
owner_kind: 'user', visibility: 'public',
page: 'lockedco/brand', url: 'http://stub/lockedco/brand',
editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['lockedco'], text: 'Only lockedco can edit lockedco/brand.' },
brand: {
brand_manifest: '1.0', owner: 'lockedco', name: 'Lockedco', status: 'starter',
colors: { primary: '#0000ff', background: '#181818', text: '#ffffff' },
typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },
logos: { mark: 'assets/mark.png' },
},
assets: { mark: 'http://stub/lockedco/brand/files/assets/mark.png', fonts: [] },
hints: [], warnings: [], errors: [],
},
invalidco: {
status: 'invalid', version: 1, sha: 'd'.repeat(40), can_edit: true,
owner_kind: 'user', visibility: 'public',
page: 'invalidco/brand', url: 'http://stub/invalidco/brand',
editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['invalidco'], text: 'Only invalidco can edit invalidco/brand.' },
brand: null, assets: null,
errors: [{ field: 'colors.primary', message: 'not a hex color' }],
warnings: [], hints: [],
},
privateco: {
status: 'private', version: null, sha: null, can_edit: false,
owner_kind: 'user', visibility: 'private',
page: 'privateco/brand', url: null,
editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['privateco'], text: 'Only privateco can edit privateco/brand.' },
brand: null, assets: null, errors: [], warnings: [], hints: [],
},
};
const pushes = [];
let brandRouteBroken = false;
// starter route (0.21.0): who gets a draft, who is refused
const DRAFTED = new Set();
const STARTER_REFUSED = new Set(['nobodyelse']);
// the registry says exists, the resolver keeps saying missing: two identities
const STARTER_EXISTS_UNREADABLE = new Set(['ghostco']);
const starterCalls = [];
const stub = http.createServer((req, res) => {
const u = new URL(req.url, 'http://stub');
const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
let m = u.pathname.match(/^\/api\/owners\/([^/]+)\/brand$/);
if (m && req.method === 'GET') {
if (brandRouteBroken) return send(500, { error: 'stub is down' });
const o = decodeURIComponent(m[1]);
const g = GUIDES[o];
if (!g && DRAFTED.has(o)) {
// what the registry drafts: a starter the owner can edit
return send(200, { status: 'starter', version: 1, sha: 'f'.repeat(40), can_edit: true, owner_kind: 'user', visibility: 'public',
page: o + '/brand', url: 'http://stub/' + o + '/brand', editable_by: { rule: 'owner-only', owner_kind: 'user', who: [o], text: 'Only ' + o + ' can edit ' + o + '/brand.' },
brand: { brand_manifest: '1.0', owner: o, name: o, status: 'starter', colors: { primary: '#394dd0', background: '#0c0d18', text: '#ffffff' },
typography: { display: { family: 'Inter' }, body: { family: 'Inter' } }, logos: { mark: 'assets/mark.svg' } },
assets: { mark: 'http://stub/' + o + '/brand/files/assets/mark.svg', fonts: [] }, hints: [], warnings: [], errors: [] });
}
if (!g) return send(404, { error: 'no brand guide', hints: [{ level: 'warning', code: 'BRAND_GUIDE_STARTER', message: 'no guide yet' }] });
return send(200, g);
}
m = u.pathname.match(/^\/api\/owners\/([^/]+)\/brand\/starter$/);
if (m && req.method === 'POST') {
const o = decodeURIComponent(m[1]);
starterCalls.push(o);
if (STARTER_REFUSED.has(o)) return send(403, { error: 'Only ' + o + ' can edit ' + o + '/brand.', code: 'BRAND_EDITORS', hint: 'ask ' + o });
if (STARTER_EXISTS_UNREADABLE.has(o)) return send(200, { ok: true, owner: o, status: 'exists', drafting: false });
if (GUIDES[o] || DRAFTED.has(o)) return send(200, { ok: true, owner: o, status: 'exists', drafting: false });
// 202 now; the draft "lands" on the next resolver read
setTimeout(() => DRAFTED.add(o), 300);
return send(202, { ok: true, owner: o, status: 'drafting', drafting: true });
}
m = u.pathname.match(/^\/api\/pages\/([^/]+)\/brand\/files$/);
if (m && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let parsed = null;
try { parsed = JSON.parse(body); } catch {}
const o = decodeURIComponent(m[1]);
pushes.push({ owner: o, body: parsed });
try {
const bj = (parsed.files || []).find((f) => f.path === 'brand.json');
const b = JSON.parse(bj.encoding === 'base64' ? Buffer.from(bj.content, 'base64').toString('utf8') : bj.content);
if (b.status === 'confirmed' && GUIDES[o]) { GUIDES[o].status = 'confirmed'; GUIDES[o].brand = b; GUIDES[o].sha = 'e'.repeat(40); }
} catch {}
send(200, { ok: true, sha: 'e'.repeat(40) });
});
return;
}
// the human at the gate, as the registry names them
if (u.pathname === '/auth/me') return send(200, { user: { username: 'starterco' } });
// the page rows the studio pulls for titles: answer empty so nothing hangs
if (u.pathname.startsWith('/api/pages')) return send(404, { error: 'not found' });
send(404, { error: 'not found' });
});
// ---- harness ----
const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port)));
async function main() {
const stubPort = await listen(stub);
const REG = 'http://127.0.0.1:' + stubPort;
const srv = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--ai-thread', 'brand test', '--ttl', '3'], {
cwd: ROOT,
env: { ...process.env, ADOM_WIKI_REGISTRY: REG, HERO_STUDIO_PORT: 'auto', HERO_RENDERER: 'local' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let port = 0;
let log = '';
srv.stdout.on('data', (d) => { log += d; const m = /hero-studio on (\d+)/.exec(String(d)); if (m && !port) port = +m[1]; });
srv.stderr.on('data', (d) => { log += d; const m = /hero-studio on (\d+)/.exec(String(d)); if (m && !port) port = +m[1]; });
for (let i = 0; i < 100 && !port; i++) await new Promise((r) => setTimeout(r, 100));
if (!port) { console.error('server never printed its port. log:\n' + log); srv.kill('SIGTERM'); process.exit(1); }
const BASE = 'http://127.0.0.1:' + port + '/';
// the server answers only once it is actually listening
for (let i = 0; i < 100; i++) {
try { await fetch(BASE + 'version'); break; } catch { await new Promise((r) => setTimeout(r, 100)); }
}
const get = async (p) => (await fetch(BASE + p)).json();
const post = async (p, body) => (await fetch(BASE + p, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aiThread: 'brand test', ...(body || {}) }),
})).json();
const pose = async (ref) => { await post('state', { mode: 'app', page: ref, comp: { id: null } }); return (await get('brand?fresh=1')).guide; };
try {
// 1. every status the contract names resolves to itself, and a guide is
// only usable when the server says so.
console.log('the resolver, per status:');
let g = await pose('goodco/brand');
eq('confirmed resolves', g.status, 'confirmed');
eq(' carries the sha', g.sha, 'a'.repeat(40));
eq(' carries the version', g.version, 3);
ok(' carries the brand body', !!(g.brand && g.brand.colors.primary === '#ff0000'));
g = await pose('starterco/brand');
eq('starter resolves', g.status, 'starter');
ok(' relays the server hints', (g.hints || []).some((h) => h.code === 'BRAND_GUIDE_STARTER'));
ok(' relays editable_by.text verbatim', (g.editable_by || {}).text === 'Only starterco can edit starterco/brand.');
// (nobodyelse: the registry refuses to draft for this identity, so the
// 404 stays missing; nobodyco, drafted on pose, is covered in 4b)
g = await pose('nobodyelse/brand');
eq('a 404 is missing, not an error', g.status, 'missing');
ok(' and never borrows Adom\'s brand body', !g.brand);
g = await pose('invalidco/brand');
eq('invalid resolves', g.status, 'invalid');
ok(' and names the bad fields', (g.errors || []).some((e) => e.field === 'colors.primary'));
ok(' and applies nothing', !g.brand);
g = await pose('privateco/brand');
eq('private resolves', g.status, 'private');
ok(' and applies nothing', !g.brand);
// 2. an unhappy registry must say so, NOT silently become "use Adom's
// look": a wrong brand published under someone else's name is worse than
// a hero that holds.
brandRouteBroken = true;
g = await pose('goodco/brand');
eq('a 500 from the registry is unavailable', g.status, 'unavailable');
eq(' and is marked unreachable', g.reachable, false);
ok(' and applies nothing', !g.brand);
brandRouteBroken = false;
// 3. confirm is a human act, and only for a guide this identity may edit.
console.log('confirm:');
await pose('lockedco/brand');
let r = await post('brand/confirm', { fromGate: true, human: 'someone' });
ok('can_edit:false cannot confirm', r.ok === false, JSON.stringify(r).slice(0, 160));
ok(' and the refusal names who may', /Only lockedco can edit/.test(JSON.stringify(r)), JSON.stringify(r).slice(0, 160));
eq(' and nothing was pushed', pushes.length, 0);
r = await post('brand/confirm', {});
ok('an unattested confirm is refused', r.ok === false, JSON.stringify(r).slice(0, 160));
eq(' and nothing was pushed', pushes.length, 0);
// 4. the confirm push body is brand.json v1, with the human recorded.
await pose('starterco/brand');
r = await post('brand/confirm', { fromGate: true, human: 'starterco' });
ok('a confirm on an editable starter guide pushes', r.ok === true, JSON.stringify(r).slice(0, 200));
eq(' pushed once', pushes.length, 1);
const push = pushes[0] || { body: {} };
eq(' to the right owner', push.owner, 'starterco');
const files = (push.body && push.body.files) || [];
const bj = files.find((f) => f.path === 'brand.json');
ok(' the push writes brand.json', !!bj, JSON.stringify(files.map((f) => f.path)));
ok(' with a commit message', !!(push.body && push.body.message));
let written = null;
try { written = JSON.parse(bj.encoding === 'base64' ? Buffer.from(bj.content, 'base64').toString('utf8') : bj.content); } catch {}
ok(' the written brand.json parses', !!written);
if (written) {
eq(' brand_manifest is 1.0', written.brand_manifest, '1.0');
eq(' owner matches the page owner', written.owner, 'starterco');
eq(' status becomes confirmed', written.status, 'confirmed');
eq(' confirmed_by names the human', written.confirmed_by, 'starterco');
ok(' confirmed_at is an ISO instant', typeof written.confirmed_at === 'string' && !isNaN(Date.parse(written.confirmed_at)), String(written.confirmed_at));
// v1 required keys survive the confirm: a confirm must not drop fields
ok(' colors survive the confirm', !!(written.colors && written.colors.primary === '#00ff00'));
ok(' typography survives the confirm', !!(written.typography && written.typography.display.family === 'Inter'));
ok(' logos survive the confirm', !!(written.logos && written.logos.mark));
}
// 4b. 0.21.0: a missing guide is drafted on pose, once, so the Brand step
// is never empty; a refused draft is carried on the guide with the reason.
console.log('starter auto-draft:');
g = await pose('nobodyco/brand');
eq('a missing guide is drafted and resolves as starter', g.status, 'starter');
eq(' the starter route was called once for that owner', starterCalls.filter((o) => o === 'nobodyco').length, 1);
ok(' the outcome rides on the guide', g.starter && g.starter.result === 'drafting', JSON.stringify(g.starter));
g = await pose('nobodyelse/brand');
eq('a refused draft leaves the guide missing', g.status, 'missing');
eq(' and was tried exactly once for that owner across both poses', starterCalls.filter((o) => o === 'nobodyelse').length, 1);
ok(' and names the refusal', g.starter && g.starter.result === 'refused' && g.starter.code === 'BRAND_EDITORS', JSON.stringify(g.starter));
r = await post('generate', { fromGate: true });
ok(' Generate stays locked on it', r.ok === false && /no brand guide/.test(r.error || ''), JSON.stringify(r).slice(0, 200));
g = await pose('ghostco/brand');
eq('exists on the registry but unreadable here stays missing', g.status, 'missing');
ok(' and is named as an identity problem, not a draft in progress', g.starter && g.starter.result === 'exists_unreadable' && /cannot read/.test(g.starter.error || ''), JSON.stringify(g.starter));
r = await post('generate', { fromGate: true });
ok(' Generate stays locked and says so', r.ok === false && /cannot read it/.test(JSON.stringify(r)), JSON.stringify(r).slice(0, 220));
// 4c. 0.21.0: Generate on a starter the human may edit confirms it in the
// same click; the stamp is what the registry holds AFTER the push.
console.log('confirm inside Generate:');
GUIDES.starterco.status = 'starter'; GUIDES.starterco.brand.status = 'starter';
const before = pushes.length;
await pose('starterco/brand');
let gen21 = await post('generate', { fromGate: true, renderer: 'local' });
ok('Generate on an editable starter renders', gen21.ok === true, JSON.stringify(gen21).slice(0, 300));
eq(' and pushed the confirm', pushes.length, before + 1);
let lastPush = pushes[pushes.length - 1];
let wb = null; try { const bj = lastPush.body.files.find((f) => f.path === 'brand.json'); wb = JSON.parse(bj.content); } catch {}
ok(' brand.json went out confirmed, by the human', wb && wb.status === 'confirmed' && wb.confirmed_by === 'starterco' && !!wb.confirmed_at, JSON.stringify(wb).slice(0, 200));
if (gen21.ok && gen21.file) {
const meta = await readPngComment(path.join(ROOT, 'generated', gen21.file));
eq(' the stamp says confirmed (post-push), not starter', meta && meta.brand_status, 'confirmed');
eq(' branding is the owner', meta && meta.branding, 'starterco');
eq(' and the stamp carries this package version so the wiki judges it', meta && meta.version, JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
try { fs.unlinkSync(path.join(ROOT, 'generated', gen21.file)); } catch {}
}
await pose('lockedco/brand');
const lockedBefore = pushes.length;
gen21 = await post('generate', { fromGate: true });
ok('Generate on a starter this identity cannot edit is refused', gen21.ok === false && /locked/.test(gen21.error || ''), JSON.stringify(gen21).slice(0, 200));
ok(' with the resolver\'s own editor rule', /Only lockedco can edit/.test(JSON.stringify(gen21)));
eq(' and nothing was pushed', pushes.length, lockedBefore);
// 5. provenance names the brand the hero was drawn with, under exactly
// the four keys the handoff fixes: branding, brand_sha, brand_version,
// brand_status. A consumer reading a published hero can tell which guide
// (and which revision of it) produced those pixels.
console.log('provenance:');
await pose('goodco/brand');
const gen = await post('generate', { fromGate: true });
ok('a gate generate renders', gen.ok === true, JSON.stringify(gen).slice(0, 300));
if (gen.ok && gen.file) {
const png = path.join(ROOT, 'generated', gen.file);
const meta = await readPngComment(png);
ok(' the PNG carries a provenance comment', !!meta, String(meta).slice(0, 120));
if (meta) {
eq(' branding names the page owner', meta.branding, 'goodco');
eq(' brand_sha is the resolved sha', meta.brand_sha, 'a'.repeat(40));
eq(' brand_version is the resolved version', meta.brand_version, 3);
eq(' brand_status is the resolved status', meta.brand_status, 'confirmed');
ok(' the four keys sit at the top level of the meta', ['branding', 'brand_sha', 'brand_version', 'brand_status'].every((k) => k in meta));
}
try { fs.unlinkSync(png); } catch {}
}
} finally {
try { await post('shutdown', {}); } catch {}
setTimeout(() => srv.kill('SIGKILL'), 3000);
srv.kill('SIGTERM');
stub.close();
}
console.log('');
if (fails.length) {
console.log(pass + ' passed, ' + fails.length + ' FAILED:');
for (const f of fails) console.log(' - ' + f);
process.exit(1);
}
console.log(pass + ' passed');
process.exit(0);
}
/// The provenance comment out of a PNG's tEXt chunk, parsed.
function readPngComment(file) {
return new Promise((resolve) => {
const py = 'import sys, json\n'
+ 'from PIL import Image\n'
+ 'im = Image.open(sys.argv[1])\n'
+ 'print(im.info.get("comment") or im.text.get("comment") or "")\n';
execFile('python3', ['-c', py, file], (err, stdout) => {
if (err) return resolve(null);
try { resolve(JSON.parse(String(stdout).trim())); } catch { resolve(null); }
});
});
}
main().catch((e) => { console.error(e); process.exit(1); });