Back to Powder Tool V600Billion
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.

source/test/server.test.mjs
/**
 * 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);
});