SOURCE / PINNED RELEASE
Made of little things.
Powder Tool V600Billion
- Release
- 142767edcab8…
- Author-recorded commit
- 6d92971effd0…
- License
- LICENSE
- Author’s source reference
- nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy
Archive hash verified: ed7d6a8ea7083197…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* A small host for testing the napplet in a real browser without the whole Hangar.
*
* It frames the napplet as a napplet host does (sandbox "allow-scripts",
* srcdoc, an opaque origin, no special headers) and answers the postMessage
* protocol for the four domains the napplet uses: storage (a Map that
* outlives napplet reloads), identity, outbox (a memory relay behind the
* NAP-OUTBOX messages, events signed in Node with a test key) and link. Two
* players:
*
* hangar the real Kehto prelude (@kehto/shell, the version the Hangar uses),
* as nappelin.com apps/hangar/src/host.ts injects it;
* soy napplet.soy's player exactly: its document (the player CSP, then
* @napplet/shim 0.30.0 with its domains and its shell bootstrap,
* then the napplet), its storage limits (1 MiB and 256 keys in
* all; a message over 360 000 characters is dropped unanswered)
* and its publishing rule: outbox.publish (and relay.publish) sign only its own
* soy.app-data/1 records (announced at the handshake with an
* `appData` hint), so every event the game makes is refused with
* the error that host gives.
*
* The game's code path is therefore the production one; only the host is
* small. Used by scripts/smoke.mjs, e2e-online.mjs and make-fixtures.mjs, on
* the built dist/index.html. The page exposes `window.__host`.
*/
import http from 'node:http';
import { existsSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { injectNappletNamespacePrelude } from '@kehto/shell';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import { chromium } from 'playwright';
export const BUILT = fileURLToPath(new URL('../dist/index.html', import.meta.url));
/** Screenshots and console logs of the browser checks (gitignored). */
export const QA_DIR = fileURLToPath(new URL('../qa/', import.meta.url));
/** Deterministic test keys; never used anywhere else. */
export const KEYS = {
alice: Uint8Array.from({ length: 32 }, (_, i) => i + 1),
bob: Uint8Array.from({ length: 32 }, (_, i) => 64 - i),
};
export const PUBKEYS = Object.fromEntries(Object.entries(KEYS).map(([name, key]) => [name, getPublicKey(key)]));
/*
* napplet.soy's player, from zeSchlausKwab/napplet-soy at e755368
* (packages/runtime/src/index.ts, prelude.ts, capabilities.ts, storage.ts).
*/
const SOY_CSP = "default-src 'none'; script-src 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'unsafe-inline'; img-src data: blob:; font-src data: blob:; media-src data: blob:; connect-src 'none'; worker-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
const SOY_DOMAINS = ['shell', 'identity', 'storage', 'theme', 'resource', 'relay', 'outbox', 'common', 'link', 'fs', 'upload', 'lists', 'config', 'media', 'cvm', 'webrtc'];
const SOY_SHELL_PRELUDE = `
(() => {
let environment;
const listeners = new Set();
let resolveReady;
const ready = new Promise(resolve => { resolveReady = resolve; });
window.addEventListener('message', event => {
if (event.source !== window.parent || event.data?.type !== 'shell.init' || environment) return;
environment = Object.freeze(event.data);
resolveReady(environment);
for (const listener of listeners) { try { listener(environment); } catch {} }
listeners.clear();
});
window.napplet.shell = Object.freeze({
ready: () => ready,
supports: domain => !!environment?.capabilities?.domains?.includes(domain),
get services() { return environment?.services || []; },
onReady: handler => {
if (environment) queueMicrotask(() => handler(environment));
else listeners.add(handler);
return { close: () => listeners.delete(handler) };
},
});
window.parent.postMessage({type: 'shell.ready'}, '*');
})();`;
const SOY_CONFIG = `(() => {
const api = window.napplet.config;
const declared = null;
window.napplet.config = Object.freeze({
...api,
registerSchema: (schema, version) => api.registerSchema(structuredClone(schema), version),
get schema() { return structuredClone(api.schema ?? declared); },
onSchemaError: callback => {
const unsubscribe = api.onSchemaError(callback);
unsubscribe.close = unsubscribe;
return unsubscribe;
},
});
})();`;
/**
* The document napplet.soy's player puts into the frame (verifiedDocument),
* plus, like their startup check, a record of every policy violation
* (`window.__violations`).
*/
export function soyDocument(html) {
const shim = readFileSync(createRequire(import.meta.url).resolve('@napplet/shim/prelude.global'), 'utf8');
const install = `globalThis.NappletShimPrelude.install(${JSON.stringify({ domains: SOY_DOMAINS.filter((domain) => domain !== 'shell') })});`;
const probe = "window.__violations=[];document.addEventListener('securitypolicyviolation',(e)=>window.__violations.push(e.violatedDirective+' '+e.blockedURI));";
const bootstrap = `${probe}\n${shim}\n${install}\n${SOY_CONFIG}\n${SOY_SHELL_PRELUDE}`;
return `<!doctype html><meta http-equiv="Content-Security-Policy" content="${SOY_CSP}"><meta name="referrer" content="no-referrer"><script>${bootstrap.replace(/<\/script/gi, '<\\/script')}</script>${html}`;
}
const HOST_SCRIPT = String.raw`
const DOMAINS = __DOMAINS__;
const SRCDOC = __SRCDOC__;
const LIMITS = __LIMITS__;
const state = window.__host = {
storage: new Map(), events: [], links: [], published: [], log: [],
pubkey: __PUBKEY__, profile: __PROFILE__, subs: new Map(), frame: null,
};
const matches = (event, filter) => {
if (filter.ids && !filter.ids.includes(event.id)) return false;
if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
if (typeof filter.since === 'number' && event.created_at < filter.since) return false;
if (typeof filter.until === 'number' && event.created_at > filter.until) return false;
for (const [key, values] of Object.entries(filter)) {
if (!key.startsWith('#') || !Array.isArray(values)) continue;
if (!event.tags.some((tag) => tag[0] === key.slice(1) && values.includes(tag[1]))) return false;
}
return true;
};
const matchesAny = (event, filters) => filters.some((filter) => matches(event, filter));
function send(message) { state.frame?.contentWindow?.postMessage(message, '*'); }
function store(event) {
if (state.events.some((known) => known.id === event.id)) return;
state.events.push(event);
for (const [subId, sub] of state.subs) if (matchesAny(event, sub.filters)) send({ type: sub.domain + '.event', subId, result: { event } });
}
/** Sign and keep an event for a napplet: the Hangar signs anything, napplet.soy only its own app-data records. */
async function signFor(template) {
if (!state.pubkey) return { ok: false, error: 'No account is signed in.' };
if (LIMITS && !(template?.kind === 30078 && template.tags?.some((tag) => tag[0] === 'L' && tag[1] === 'soy.app-data/1'))) {
return { ok: false, error: 'app-data-invalid-request: only documented kind-30078 records and configured relay destinations can be published.' };
}
try {
const event = await window.__sign(state.pubkey, template);
state.published.push(event);
store(event);
return { ok: true, event, eventId: event.id };
} catch (error) { return { ok: false, error: String(error?.message ?? error) }; }
}
state.inject = (event) => store(event);
state.setIdentity = (pubkey, profile = null) => {
state.pubkey = pubkey; state.profile = profile;
send({ type: 'identity.changed', pubkey });
};
state.reload = () => {
state.subs.clear();
state.frame?.remove();
const frame = document.createElement('iframe');
frame.sandbox.add('allow-scripts');
frame.className = 'napplet-frame';
frame.srcdoc = SRCDOC;
document.body.append(frame);
state.frame = frame;
};
window.addEventListener('message', async (incoming) => {
if (!state.frame || incoming.source !== state.frame.contentWindow) return;
const m = incoming.data;
if (!m || typeof m.type !== 'string') return;
state.log.push(m.type);
if (LIMITS && JSON.stringify(m).length > LIMITS.message) { state.dropped = (state.dropped ?? 0) + 1; return; }
const reply = (fields) => send({ type: m.type + '.result', id: m.id, ...fields });
switch (m.type) {
case 'shell.ready': send({ type: 'shell.init', capabilities: { domains: DOMAINS, ...(LIMITS ? { appData: LIMITS.appData } : {}) }, services: [] }); break;
case 'storage.get': reply({ ok: true, value: state.storage.has(m.key) ? state.storage.get(m.key) : null }); break;
case 'storage.set': {
const others = [...state.storage].filter(([key]) => key !== m.key);
const used = others.reduce((n, [key, value]) => n + key.length + value.length, 0);
if (typeof m.value !== 'string' || m.value.length > 8 * 1024 * 1024) reply({ ok: false, error: 'too large' });
else if (LIMITS && ((!state.storage.has(m.key) && state.storage.size >= LIMITS.keys) || used + m.key.length + m.value.length > LIMITS.bytes)) reply({ ok: false, error: 'Storage quota exceeded' });
else { state.storage.set(m.key, m.value); reply({ ok: true }); }
break;
}
case 'storage.remove': state.storage.delete(m.key); reply({ ok: true }); break;
case 'storage.keys': reply({ ok: true, keys: [...state.storage.keys()] }); break;
case 'identity.getPublicKey': reply({ pubkey: state.pubkey }); break;
case 'identity.getProfile': reply({ profile: state.profile }); break;
case 'link.open': state.links.push(m.url); reply({ status: 'opened' }); break;
case 'outbox.query': reply({ events: state.events.filter((event) => matchesAny(event, m.filters)).map((event) => ({ event })) }); break;
case 'outbox.subscribe':
state.subs.set(m.subId, { domain: 'outbox', filters: m.filters });
for (const event of state.events) if (matchesAny(event, m.filters)) send({ type: 'outbox.event', subId: m.subId, result: { event } });
break;
case 'outbox.close': state.subs.delete(m.subId); break;
case 'outbox.publish': reply(await signFor(m.event)); break;
case 'relay.subscribe':
state.subs.set(m.subId, { domain: 'relay', filters: m.filters });
for (const event of state.events) if (matchesAny(event, m.filters)) send({ type: 'relay.event', subId: m.subId, result: { event } });
send({ type: 'relay.eose', subId: m.subId });
break;
case 'relay.close': state.subs.delete(m.subId); break;
case 'relay.publish': reply(await signFor(m.event)); break;
default: break;
}
});
state.reload();
`;
/**
* @param {{ html?: string, player?: 'hangar'|'soy', domains?: string[], identity?: 'alice'|'bob'|null, profile?: object|null, viewport?: { width: number, height: number } }} options
*/
export async function startHarness({
html,
player = 'hangar',
domains = player === 'soy' ? SOY_DOMAINS : ['storage', 'identity', 'outbox', 'link'],
identity = 'alice',
profile = { name: 'alice' },
viewport = { width: 1000, height: 650 },
} = {}) {
if (html === undefined) {
if (!existsSync(BUILT)) throw new Error('no dist/index.html yet: npm run wasm && npm run build');
html = readFileSync(BUILT, 'utf8');
}
const srcdoc = player === 'soy' ? soyDocument(html) : injectNappletNamespacePrelude(html, { domains });
// napplet.soy's host-policy hint at the handshake (appDataPolicy in its app-data-session.ts).
const appData = { profile: 'soy.app-data/1', scope: '0'.repeat(64), maxContentBytes: 16384, relays: ['wss://relay.napplet.soy'], defaultRelays: ['wss://relay.napplet.soy'] };
const limits = player === 'soy' ? { message: 360000, keys: 256, bytes: 1024 * 1024, appData } : null;
const script = HOST_SCRIPT
.replace('__DOMAINS__', () => JSON.stringify(domains))
.replace('__LIMITS__', () => JSON.stringify(limits))
.replace('__SRCDOC__', () => JSON.stringify(srcdoc).replace(/<\/script/gi, '<\\/script'))
.replace('__PUBKEY__', () => JSON.stringify(identity ? PUBKEYS[identity] : ''))
.replace('__PROFILE__', () => JSON.stringify(identity ? profile : null));
const page = '<!doctype html><meta charset="utf-8"><title>harness</title>'
+ '<style>html,body{margin:0;height:100%;background:#000}iframe{border:0;width:100%;height:100%;display:block}</style>'
+ `<body><script>${script}</script></body>`;
const server = http.createServer((request, response) => {
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
response.end(page);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport });
const tab = await context.newPage();
const errors = [];
tab.on('pageerror', (error) => errors.push(error.message));
const consoleLines = [];
tab.on('console', (message) => consoleLines.push(message.text()));
await tab.exposeFunction('__sign', (pubkey, template) => {
const key = Object.entries(PUBKEYS).find(([, value]) => value === pubkey)?.[0];
if (!key) throw new Error('unknown test identity');
return finalizeEvent({
kind: template.kind, content: template.content ?? '', tags: template.tags ?? [],
created_at: template.created_at ?? Math.floor(Date.now() / 1000),
}, KEYS[key]);
});
await tab.goto(`http://127.0.0.1:${server.address().port}/`);
// Both test players are existing accounts: their profiles are on the relay.
for (const [name, key] of Object.entries(KEYS)) {
const event = finalizeEvent({ kind: 0, created_at: 1_700_000_000, tags: [], content: JSON.stringify({ name }) }, key);
await tab.evaluate((profile) => window.__host.inject(profile), event);
}
const frame = () => tab.frames().find((item) => item !== tab.mainFrame());
return {
tab, errors, consoleLines, frame,
host: (fn, arg) => tab.evaluate(fn, arg),
async waitForGame(timeout = 30000) {
await tab.waitForFunction(() => {
const inner = window.__host.frame?.contentDocument; // not readable: opaque origin
return inner === null || inner === undefined;
}, null, { timeout: 1000 }).catch(() => {});
const started = Date.now();
while (Date.now() - started < timeout) {
const ready = await frame()?.evaluate(() => document.getElementById('canvas')?.classList.contains('ready')).catch(() => false);
if (ready) return Date.now() - started;
await tab.waitForTimeout(200);
}
throw new Error(`the game did not become presentable within ${timeout} ms`);
},
async close() {
await browser.close();
server.close();
},
};
}
/** Sign an event as a test identity, for injecting other players' events. */
export function signAs(name, template) {
return finalizeEvent({ created_at: Math.floor(Date.now() / 1000), content: '', tags: [], ...template }, KEYS[name]);
}
