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.
/**
* The save server end to end in Node: two players (alice, bob) with their own
* keys and stores share one relay, and talk to it through the same fetch
* handler the game calls. Checks the event formats, what each endpoint
* returns in the shape upstream's client parses, the rules (only authors edit
* and delete, nobody votes on their own save, big saves stay local), and that
* malformed events from strangers do not count.
*/
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { finalizeEvent, getPublicKey, verifyEvent } from 'nostr-tools/pure';
import { createHandler, readForm } from '../src/server/index.js';
import { createStore, parseQuery } from '../src/server/store.js';
import { MAX_SHARED_BYTES, SAVE_KIND } from '../src/server/events.js';
import { stableId, usernameFor } from '../src/server/names.js';
import { thumbnailPng } from '../src/save/thumbnail.js';
import { readPng } from './lib/png.mjs';
const KEYS = { alice: Uint8Array.from({ length: 32 }, (_, i) => i + 1), bob: Uint8Array.from({ length: 32 }, (_, i) => 64 - i) };
const PUB = Object.fromEntries(Object.entries(KEYS).map(([name, key]) => [name, getPublicKey(key)]));
const SAVE = new Uint8Array(readFileSync(new URL('./fixtures/stamp-small.cps', import.meta.url)));
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;
for (const [key, values] of Object.entries(filter)) {
if (key.startsWith('#') && !event.tags.some((tag) => tag[0] === key.slice(1) && values.includes(tag[1]))) return false;
}
return true;
};
function world() {
const events = [];
let clock = 1_800_000_000;
const watchers = new Set();
const relay = {
events,
add(event) {
if (events.some((known) => known.id === event.id)) return;
events.push(event);
for (const watcher of watchers) if (watcher.filters.some((filter) => matches(event, filter))) watcher.onEvent(event);
},
collect: async (filters) => events.filter((event) => filters.some((filter) => matches(event, filter))),
};
const player = (name, profile = { name }, { watch = true } = {}) => {
// Every player is an existing Nostr account with a profile on the relay.
if (!events.some((event) => event.kind === 0 && event.pubkey === PUB[name])) {
relay.add(finalizeEvent({ kind: 0, created_at: 1_700_000_000, tags: [], content: JSON.stringify(profile) }, KEYS[name]));
}
const nostr = {
collect: relay.collect,
...(watch ? { watch: (filters, onEvent) => { const watcher = { filters, onEvent }; watchers.add(watcher); return () => watchers.delete(watcher); } } : {}),
publish: async (template) => {
const event = finalizeEvent({ ...template, created_at: template.created_at }, KEYS[name]);
relay.add(event);
return event;
},
};
const storage = new Map();
const store = createStore({
nostr,
storage: {
get: async (key) => storage.get(key) ?? null, set: async (key, value) => { storage.set(key, value); },
remove: async (key) => { storage.delete(key); }, keys: async () => [...storage.keys()],
},
session: { pubkey: PUB[name], profile, username: usernameFor(PUB[name], profile), userId: stableId(PUB[name]) },
now: () => ++clock,
thumbnail: async (bytes) => (await thumbnailPng(bytes)).png,
});
const fetch = createHandler(store, { motd: 'hello' });
const call = async (path, { method = 'GET', form } = {}) => {
let body;
if (form) { body = new FormData(); for (const [key, value] of Object.entries(form)) body.append(key, value); }
const host = path.startsWith('static:') ? 'static.powdertoy.co.uk' : 'powdertoy.co.uk';
const response = await fetch(`https://${host}${path.replace(/^static:/, '')}`, { method, body });
return response;
};
const getJson = async (path, options) => (await call(path, options)).json();
const getText = async (path, options) => (await call(path, options)).text();
const upload = (form) => getText('/Save.api', { method: 'POST', form: { Description: '', Publish: 'Public', Key: 'x', ...form } });
return { name, store, storage, call, getJson, getText, upload, username: usernameFor(PUB[name], profile) };
};
return { relay, player, tick: () => ++clock };
}
const file = (bytes) => new Blob([bytes]);
test('publishing a save: the event, the listing, the details, the file and its picture', async () => {
const { relay, player } = world();
const alice = player('alice');
const answer = await alice.upload({ Name: 'Tower', Description: 'A metal tower', Data: file(SAVE) });
assert.match(answer, /^OK \d+$/);
const id = Number(answer.slice(3));
const [event] = relay.events.filter((item) => item.kind === SAVE_KIND);
assert.ok(verifyEvent(event));
assert.equal(event.kind, SAVE_KIND);
assert.equal(event.pubkey, PUB.alice);
const tag = (name) => event.tags.find((t) => t[0] === name)?.[1];
assert.match(tag('d'), /^powder-toy\/save\/[0-9a-f]{16}$/);
assert.ok(event.tags.some((t) => t[0] === 't' && t[1] === 'powder-toy'));
assert.equal(tag('title'), 'Tower');
assert.equal(tag('summary'), 'A metal tower');
assert.equal(tag('size'), String(SAVE.length));
assert.equal(tag('alt'), 'Powder Toy save: Tower');
assert.match(tag('x'), /^[0-9a-f]{64}$/);
assert.deepEqual(new Uint8Array(Buffer.from(event.content, 'base64')), SAVE);
assert.equal(id, stableId(`${SAVE_KIND}:${PUB.alice}:${tag('d')}`));
const bob = player('bob');
const list = await bob.getJson('/Browse.json?Start=0&Count=20');
assert.equal(list.Saves.length, 1);
assert.deepEqual(Object.keys(list.Saves[0]).sort(), ['Comments', 'Created', 'ID', 'Name', 'Published', 'Score', 'ScoreDown', 'ScoreUp', 'ShortName', 'Updated', 'Username', 'Version']);
assert.equal(list.Saves[0].ID, id);
assert.equal(list.Saves[0].Username, alice.username);
assert.equal(list.Saves[0].Published, true);
const view = await bob.getJson(`/Browse/View.json?ID=${id}`);
assert.equal(view.Name, 'Tower');
assert.equal(view.Description, 'A metal tower');
assert.equal(view.Username, alice.username);
assert.deepEqual([view.ScoreUp, view.ScoreDown, view.ScoreMine, view.Comments], [0, 0, 0, 0]);
const data = new Uint8Array(await (await bob.call(`static:/${id}.cps`)).arrayBuffer());
assert.deepEqual(data, SAVE);
const picture = await bob.call(`static:/${id}_small.png`);
assert.equal(picture.headers.get('content-type'), 'image/png');
assert.equal(readPng(Buffer.from(await picture.arrayBuffer())).width, 100);
assert.equal((await bob.call(`static:/${id}_1790000000_small.png`)).status, 200, 'dated thumbnail URLs work too');
});
test('saving again under the same name replaces the save and keeps its creation date and tags', async () => {
const { relay, player } = world();
const alice = player('alice');
const first = await alice.upload({ Name: 'Bridge', Data: file(SAVE) });
const id = Number(first.slice(3));
await alice.getJson(`/Browse/EditTag.json?Op=add&ID=${id}&Tag=bridge&Key=x`);
const created = (await alice.getJson(`/Browse/View.json?ID=${id}`)).DateCreated;
const second = await alice.upload({ Name: 'bridge ', Description: 'v2', Data: file(SAVE) });
assert.equal(Number(second.slice(3)), id, 'same author and name keeps the ID');
const fresh = player('bob');
const list = await fresh.getJson('/Browse.json?Start=0&Count=20');
assert.equal(list.Saves.length, 1, 'older versions do not show up');
const view = await fresh.getJson(`/Browse/View.json?ID=${id}`);
assert.equal(view.Description, 'v2');
assert.equal(view.DateCreated, created);
assert.deepEqual(view.Tags, ['bridge']);
assert.equal(relay.events.filter((event) => event.kind === SAVE_KIND).length, 3);
});
test('big saves are refused for sharing but can be kept unpublished', async () => {
const { relay, player } = world();
const alice = player('alice');
const big = new Uint8Array(MAX_SHARED_BYTES + 1).fill(7);
const refused = await alice.upload({ Name: 'Huge', Data: file(big) });
assert.match(refused, /at most 43 KB/);
const shared = () => relay.events.filter((item) => item.kind !== 0).length;
assert.equal(shared(), 0);
const kept = await alice.upload({ Name: 'Huge', Data: file(big), Publish: 'Private' });
assert.match(kept, /^OK \d+$/);
assert.equal(shared(), 0, 'unpublished saves never reach a relay');
});
test('unpublished saves stay local, show under the author\'s own saves, and can be published later', async () => {
const { relay, player } = world();
const alice = player('alice');
const answer = await alice.upload({ Name: 'Secret', Data: file(SAVE), Publish: 'Private' });
const id = Number(answer.slice(3));
const shared = () => relay.events.filter((item) => item.kind !== 0).length;
assert.equal(shared(), 0);
assert.ok([...alice.storage.keys()].some((key) => key.startsWith('priv:')));
assert.equal((await alice.getJson('/Browse.json?Start=0&Count=20')).Saves.length, 0, 'not in the public listing');
const own = await alice.getJson(`/Browse.json?Start=0&Count=20&Search_Query=${encodeURIComponent(`user:${alice.username}`)}`);
assert.equal(own.Count, 1);
assert.equal(own.Saves[0].Published, false);
assert.equal((await alice.getJson(`/Browse/View.json?ID=${id}`)).Published, false);
assert.deepEqual(new Uint8Array(await (await alice.call(`static:/${id}.cps`)).arrayBuffer()), SAVE);
const bob = player('bob');
assert.equal((await bob.call(`/Browse/View.json?ID=${id}`)).status, 404, 'other players cannot see it');
const published = await alice.getJson(`/Browse/View.json?ID=${id}&Key=x`, { method: 'POST', form: { ActionPublish: 'bagels' } });
assert.equal(published.Status, 1);
assert.equal(shared(), 1);
assert.equal([...alice.storage.keys()].some((key) => key.startsWith('priv:')), false, 'the local copy is gone once published');
assert.equal((await bob.getJson('/Browse.json?Start=0&Count=20')).Saves.length, 1);
});
test('votes: others vote and take it back, authors cannot vote on their own saves', async () => {
const { relay, player } = world();
const alice = player('alice');
const id = Number((await alice.upload({ Name: 'Volcano', Data: file(SAVE) })).slice(3));
assert.match(await alice.getText('/Vote.api', { method: 'POST', form: { ID: String(id), Action: 'Up', Key: 'x' } }), /own save/);
const bob = player('bob');
await bob.getJson('/Browse.json?Start=0&Count=20');
assert.equal(await bob.getText('/Vote.api', { method: 'POST', form: { ID: String(id), Action: 'Up', Key: 'x' } }), 'OK');
const vote = relay.events.find((event) => event.kind === 7);
assert.equal(vote.content, '+');
assert.deepEqual(vote.tags.map((tag) => tag[0]).sort(), ['a', 'e', 'k', 'p']);
assert.equal((await bob.getJson(`/Browse/View.json?ID=${id}`)).ScoreMine, 1);
const carol = player('alice'); // a fresh store sees the vote from the relay
const listed = await carol.getJson('/Browse.json?Start=0&Count=20');
assert.deepEqual([listed.Saves[0].ScoreUp, listed.Saves[0].Score], [1, 1]);
assert.equal(await bob.getText('/Vote.api', { method: 'POST', form: { ID: String(id), Action: 'Reset', Key: 'x' } }), 'OK');
const reset = relay.events.find((event) => event.kind === 5);
assert.deepEqual(reset.tags, [['e', vote.id], ['k', '7']]);
const again = player('alice');
assert.equal((await again.getJson('/Browse.json?Start=0&Count=20')).Saves[0].ScoreUp, 0, 'a taken-back vote no longer counts');
assert.equal(await bob.getText('/Vote.api', { method: 'POST', form: { ID: String(id), Action: 'Down', Key: 'x' } }), 'OK');
assert.equal((await bob.getJson(`/Browse/View.json?ID=${id}`)).ScoreMine, -1);
assert.equal((await player('alice').getJson('/Browse.json?Start=0&Count=20')).Saves[0].ScoreDown, 1);
});
test('comments are NIP-22 replies to the save and show with the commenter\'s name', async () => {
const { relay, player } = world();
const alice = player('alice');
const id = Number((await alice.upload({ Name: 'Clock', Data: file(SAVE) })).slice(3));
const bob = player('bob');
await bob.getJson('/Browse.json?Start=0&Count=20');
const posted = await bob.getJson(`/Browse/Comments.json?ID=${id}`, { method: 'POST', form: { Comment: 'Nice gears', Key: 'x' } });
assert.equal(posted.Status, 1);
const comment = relay.events.find((event) => event.kind === 1111);
const tags = Object.fromEntries(comment.tags.map((tag) => [tag[0], tag[1]]));
assert.equal(tags.A, tags.a);
assert.equal(tags.K, String(SAVE_KIND));
assert.equal(tags.P, PUB.alice);
const list = await player('alice').getJson(`/Browse/Comments.json?ID=${id}&Start=0&Count=20`);
assert.equal(list.length, 1);
assert.equal(list[0].Username, bob.username);
assert.equal(list[0].Text, 'Nice gears');
assert.equal((await alice.getJson(`/Browse/View.json?ID=${id}`)).Comments, 1);
const empty = await bob.getJson(`/Browse/Comments.json?ID=${id}`, { method: 'POST', form: { Comment: ' ', Key: 'x' } });
assert.equal(empty.Status, 0);
});
test('tags, deletion and unpublishing are for the author only', async () => {
const { relay, player } = world();
const alice = player('alice');
const id = Number((await alice.upload({ Name: 'Maze', Data: file(SAVE) })).slice(3));
const tagged = await alice.getJson(`/Browse/EditTag.json?Op=add&ID=${id}&Tag=Puzzle&Key=x`);
assert.deepEqual([tagged.Status, tagged.Tags], [1, ['puzzle']]);
assert.equal((await alice.getJson(`/Browse/EditTag.json?Op=add&ID=${id}&Tag=no%20spaces&Key=x`)).Status, 0);
const bob = player('bob');
await bob.getJson('/Browse.json?Start=0&Count=20');
assert.deepEqual((await bob.getJson('/Browse/Tags.json?Start=0&Count=50')).Tags, [{ Tag: 'puzzle', Count: 1 }]);
const refused = await bob.getJson(`/Browse/EditTag.json?Op=add&ID=${id}&Tag=mine&Key=x`);
assert.deepEqual([refused.Status, refused.Error], [0, 'Only the author can do that.']);
assert.equal((await bob.getJson(`/Browse/Delete.json?ID=${id}&Mode=Delete&Key=x`)).Status, 0);
assert.equal((await alice.getJson(`/Browse/Delete.json?ID=${id}&Mode=Unpublish&Key=x`)).Status, 1);
const deletion = relay.events.find((event) => event.kind === 5);
assert.deepEqual(deletion.tags.map((tag) => tag[0]).sort(), ['a', 'e', 'k', 't']);
assert.equal((await player('bob').getJson('/Browse.json?Start=0&Count=20')).Saves.length, 0, 'gone for everyone');
const own = await alice.getJson(`/Browse.json?Start=0&Count=20&Search_Query=${encodeURIComponent(`user:${alice.username}`)}`);
assert.deepEqual([own.Count, own.Saves[0].Published], [1, false], 'the author keeps an unpublished copy');
});
test('favourites, search words, sort by date, after: and the front page', async () => {
const { player } = world();
const alice = player('alice');
const ids = [];
for (const name of ['Alpha rocket', 'Beta reactor', 'Gamma rocket']) ids.push(Number((await alice.upload({ Name: name, Data: file(SAVE) })).slice(3)));
const bob = player('bob');
await bob.getJson('/Browse.json?Start=0&Count=20');
await bob.getText('/Vote.api', { method: 'POST', form: { ID: String(ids[1]), Action: 'Up', Key: 'x' } });
const top = await player('bob').getJson('/Browse.json?Start=0&Count=20');
assert.equal(top.Saves[0].Name, 'Beta reactor', 'the default view sorts by score');
const byDate = await bob.getJson(`/Browse.json?Start=0&Count=20&Search_Query=${encodeURIComponent('sort:date')}`);
assert.deepEqual(byDate.Saves.map((save) => save.Name), ['Gamma rocket', 'Beta reactor', 'Alpha rocket']);
const rockets = await bob.getJson('/Browse.json?Start=0&Count=20&Search_Query=rocket');
assert.deepEqual(rockets.Saves.map((save) => save.Name).sort(), ['Alpha rocket', 'Gamma rocket']);
const byUser = await bob.getJson(`/Browse.json?Start=0&Count=20&Search_Query=${encodeURIComponent(`user:${alice.username}`)}`);
assert.equal(byUser.Count, 3);
assert.equal((await bob.getJson('/Browse.json?Start=0&Count=20&Search_Query=user:nobody_1234')).Count, 0);
assert.equal((await bob.getJson('/Browse.json?Start=0&Count=20&Search_Query=after:2999-01-01')).Count, 0);
assert.equal((await bob.getJson(`/Browse/Favourite.json?ID=${ids[2]}&Key=x`)).Status, 1);
const favs = await bob.getJson('/Browse.json?Start=0&Count=20&Category=Favourites');
assert.deepEqual(favs.Saves.map((save) => save.ID), [ids[2]]);
assert.equal((await bob.getJson(`/Browse/View.json?ID=${ids[2]}`)).Favourite, true);
await bob.getJson(`/Browse/Favourite.json?ID=${ids[2]}&Mode=Remove&Key=x`);
assert.equal((await bob.getJson('/Browse.json?Start=0&Count=20&Category=Favourites')).Count, 0);
// The game counts one extra page on the default view; reporting 20 fewer shows every save once.
const pageOne = await bob.getJson('/Browse.json?Start=0&Count=20');
assert.deepEqual([pageOne.Count, pageOne.Saves.length], [0, 3]);
assert.equal((await bob.getJson('/Browse.json?Start=20&Count=20')).Saves.length, 0);
assert.equal(rockets.Count, 2, 'searches report their real count');
// The save ID shown after an upload finds the save when typed into the search.
const byId = await bob.getJson(`/Browse.json?Start=0&Count=20&Search_Query=${ids[1]}`);
assert.deepEqual(byId.Saves.map((save) => save.Name), ['Beta reactor']);
assert.equal((await bob.getJson(`/Browse.json?Start=0&Count=20&Search_Query=id:${ids[0]}`)).Saves[0].Name, 'Alpha rocket');
});
test('startup, login, user profiles and what the game cannot do here', async () => {
const { player } = world();
const alice = player('alice', { name: 'alice', about: 'I build towers', website: 'https://alice.example' });
await alice.upload({ Name: 'One', Data: file(SAVE) });
const startup = await alice.getJson('/Startup.json');
assert.deepEqual([startup.Session, startup.MessageOfTheDay], [true, 'hello']);
const login = await alice.getJson('/Login.json', { method: 'POST', form: { name: 'x', pass: 'y' } });
assert.deepEqual([login.Status, login.Username, login.UserID > 0], [1, alice.username, true]);
const user = await alice.getJson(`/User.json?Name=${alice.username}`);
assert.deepEqual([user.User.Username, user.User.Biography, user.User.Website, user.User.Saves.Count], [alice.username, 'I build towers', 'https://alice.example', 1]);
assert.equal((await alice.call('/User.json?Name=unknown_0000')).status, 404);
assert.equal((await alice.getJson('/Browse/Report.json?ID=1', { method: 'POST', form: { Reason: 'spam' } })).Status, 0);
assert.equal((await alice.getJson('/Profile.json', { method: 'POST', form: { Location: 'x' } })).Status, 0);
assert.equal((await alice.call('/Nope.json')).status, 404);
assert.equal((await alice.call('static:/12.png')).status, 404);
});
test('without a Nappelin account the game can browse but not write, and login explains why', async () => {
const { player } = world();
const alice = player('alice');
const id = Number((await alice.upload({ Name: 'Open', Data: file(SAVE) })).slice(3));
const events = [];
const guest = createStore({
nostr: { collect: async () => events, publish: async () => { throw new Error('no key'); } },
storage: null, session: null,
});
const fetch = createHandler(guest, { login: async () => null });
const login = await (await fetch('https://powdertoy.co.uk/Login.json', { method: 'POST', body: new FormData() })).json();
assert.equal(login.Status, 0);
assert.match(login.Error, /Sign in to Nappelin/);
const vote = await (await fetch('https://powdertoy.co.uk/Vote.api', { method: 'POST', body: (() => { const f = new FormData(); f.append('ID', String(id)); f.append('Action', 'Up'); return f; })() })).text();
assert.notEqual(vote, 'OK');
});
test('malformed events and strangers\' deletions do not count', async () => {
const { relay, player } = world();
const alice = player('alice');
const id = Number((await alice.upload({ Name: 'Real', Data: file(SAVE) })).slice(3));
const real = relay.events.find((item) => item.kind === SAVE_KIND);
const d = real.tags.find((tag) => tag[0] === 'd')[1];
const sign = (template, who = 'bob') => relay.add(finalizeEvent({ created_at: 1_900_000_000, content: '', tags: [], ...template }, KEYS[who]));
sign({ kind: SAVE_KIND, content: '!!!not base64!!!', tags: [['d', 'powder-toy/save/aaaaaaaaaaaaaaaa'], ['t', 'powder-toy'], ['title', 'Bad content']] });
sign({ kind: SAVE_KIND, content: 'AAAA', tags: [['d', 'powder-toy/save/bbbbbbbbbbbbbbbb'], ['t', 'powder-toy']] }); // no title
sign({ kind: SAVE_KIND, content: 'AAAA', tags: [['d', 'someone-else/cccc'], ['t', 'powder-toy'], ['title', 'Wrong d']] });
sign({ kind: SAVE_KIND, content: 'A'.repeat(200000), tags: [['d', 'powder-toy/save/dddddddddddddddd'], ['t', 'powder-toy'], ['title', 'Too big']] });
// bob tries to delete alice's save
sign({ kind: 5, tags: [['a', `${SAVE_KIND}:${PUB.alice}:${d}`], ['e', real.id], ['k', String(SAVE_KIND)], ['t', 'powder-toy']] });
// a vote for something that is not a save
sign({ kind: 7, content: '+', tags: [['a', `1:${PUB.alice}:x`], ['k', '1']] });
const list = await player('bob').getJson('/Browse.json?Start=0&Count=20');
assert.deepEqual(list.Saves.map((save) => [save.ID, save.Name, save.ScoreUp]), [[id, 'Real', 0]]);
});
test('request bodies: FormData with a file, and raw bytes from shared memory', async () => {
const form = new FormData();
form.append('Name', 'x');
form.append('Data', new Blob([new Uint8Array([1, 2, 3])]), 'save.bin');
const parsed = await readForm(form);
assert.equal(parsed.get('Name'), 'x');
assert.deepEqual([...parsed.get('Data')], [1, 2, 3]);
const shared = new SharedArrayBuffer(16);
const bytes = new TextEncoder().encode('a=1&b=two');
new Uint8Array(shared).set(bytes, 4);
const fromShared = await readForm(new DataView(shared, 4, bytes.length));
assert.deepEqual([fromShared.get('a'), fromShared.get('b')], ['1', 'two']);
assert.deepEqual(parseQuery('rocket user:bob_1234 sort:date after:2026-01-02 42 id:7'), {
words: ['rocket'], user: 'bob_1234', sortDate: true, after: Date.UTC(2026, 0, 2) / 1000, ids: [42, 7],
});
const handler = createHandler(createStore({ nostr: { collect: async () => [], publish: async () => ({}) }, storage: null, session: null }));
await assert.rejects(handler('https://starcatcher.us/scripts/main.lua'), TypeError);
});
