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's memory: public saves, votes, comments and profiles as
* they arrive from relays through the host, plus the player's private saves
* and favourites in napplet storage.
*
* Everything that goes out is an unsigned template handed to the host, which
* signs it with the player's key (`nostr.publish`). Everything that comes in
* is checked by src/server/events.js before it counts. Reads use
* `nostr.collect`, a one-shot query the host answers from its relays, and
* `nostr.watch` for saves that arrive while the game runs (src/napplet.js).
*/
import { base64ToBytes, bytesToBase64 } from '../base64.js';
import {
addressOf, commentTemplate, D_PREFIX, deletionTemplate, MAX_SHARED_BYTES, parseComment, parseDeletion,
parseSaveEvent, parseVote, SAVE_KIND, SAVE_TAG, saveTemplate, voteTemplate,
} from './events.js';
import { stableId, usernameFor } from './names.js';
export class ServerError extends Error {
constructor(message) {
super(message);
this.name = 'ServerError';
}
}
const PRIVATE_PREFIX = 'priv:';
const FAVOURITES_KEY = 'favourites';
const LIST_TTL = 15;
const VOTES_TTL = 20;
const COMMENTS_TTL = 15;
const chunks = (items, size) => {
const out = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
};
const encoder = new TextEncoder();
export async function sha256Hex(bytes) {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
export function parseQuery(text) {
const query = { words: [], user: null, sortDate: false, after: null, ids: [] };
for (const token of String(text ?? '').split(/\s+/).filter(Boolean)) {
const lower = token.toLowerCase();
// A save ID (shown after uploading, "click to copy") finds that save.
const id = /^(?:id:)?(\d{1,10})$/.exec(lower);
if (id) query.ids.push(Number(id[1]));
else if (lower === 'sort:date') query.sortDate = true;
else if (lower.startsWith('user:')) query.user = token.slice(5);
else if (lower.startsWith('after:')) {
const at = Date.parse(`${token.slice(6)}T00:00:00Z`);
if (!Number.isNaN(at)) query.after = Math.floor(at / 1000);
} else query.words.push(lower);
}
return query;
}
/**
* @param {{
* nostr: { collect(filters, options?): Promise<object[]>, publish(template): Promise<object>, watch?(filters, onEvent): () => void },
* storage: { get, set, remove, keys } | null,
* session: { pubkey: string, username: string } | null,
* now?: () => number,
* thumbnail?: (bytes: Uint8Array) => Promise<Uint8Array>,
* }} options
*/
export function createStore({ nostr, storage, session: initialSession = null, now = () => Math.floor(Date.now() / 1000), thumbnail }) {
let session = initialSession;
let sessionProblem = '';
const saves = new Map(); // address -> newest public save
const removedSaves = new Map(); // address -> time its author deleted it
const privateSaves = new Map(); // d -> record (the player's own, never published)
const ids = new Map(); // id -> { address } | { d } for private saves
const votes = new Map(); // address -> Map<pubkey, vote>
const voteEvents = new Map(); // vote id -> vote (kept to answer deletions)
const revokedVotes = new Set();
const comments = new Map(); // address -> { at, list }
const profiles = new Map(); // pubkey -> profile | null
const usernames = new Map(); // username -> pubkey
const thumbnails = new Map(); // event id -> png
let listedAt = 0;
let votesAt = 0;
let privateLoaded = false;
let favourites = null;
const adopt = (next) => {
session = next;
if (session) usernames.set(session.username, session.pubkey);
};
adopt(session);
const nameOf = (pubkey) => {
if (session && pubkey === session.pubkey) return session.username;
const name = usernameFor(pubkey, profiles.get(pubkey));
usernames.set(name, pubkey);
return name;
};
function keepSave(save) {
const known = saves.get(save.address);
const removedAt = removedSaves.get(save.address);
if (removedAt !== undefined && removedAt >= save.updated) return;
if (known && (known.updated > save.updated || (known.updated === save.updated && known.event.id >= save.event.id))) return;
saves.set(save.address, save);
ids.set(stableId(save.address), { address: save.address });
}
function keepDeletion(deletion) {
for (const address of deletion.addresses) {
if (!address.startsWith(`${SAVE_KIND}:${deletion.pubkey}:`)) continue; // only authors delete their own
const before = removedSaves.get(address) ?? 0;
removedSaves.set(address, Math.max(before, deletion.at));
const save = saves.get(address);
if (save && save.updated <= deletion.at) saves.delete(address);
}
for (const id of deletion.ids) {
const vote = voteEvents.get(id);
if (vote && vote.pubkey === deletion.pubkey) revokeVote(vote);
else if (!vote) revokedVotes.add(`${deletion.pubkey}:${id}`);
}
}
function revokeVote(vote) {
revokedVotes.add(`${vote.pubkey}:${vote.id}`);
const byVoter = votes.get(vote.address);
if (byVoter?.get(vote.pubkey)?.id === vote.id) byVoter.delete(vote.pubkey);
}
function keepVote(vote) {
voteEvents.set(vote.id, vote);
if (revokedVotes.has(`${vote.pubkey}:${vote.id}`)) return;
const byVoter = votes.get(vote.address) ?? new Map();
const current = byVoter.get(vote.pubkey);
if (!current || current.at < vote.at || (current.at === vote.at && current.id < vote.id)) byVoter.set(vote.pubkey, vote);
votes.set(vote.address, byVoter);
}
async function loadPrivate() {
if (privateLoaded || !storage || !session) { privateLoaded = true; return; }
privateLoaded = true;
for (const key of await storage.keys()) {
if (!key.startsWith(PRIVATE_PREFIX)) continue;
try {
const record = JSON.parse(await storage.get(key));
if (record?.owner !== session.pubkey || typeof record.d !== 'string') continue;
privateSaves.set(record.d, record);
ids.set(stableId(`local:${record.owner}:${record.d}`), { d: record.d });
} catch { /* an unreadable record is skipped */ }
}
}
async function loadFavourites() {
if (favourites) return favourites;
favourites = new Set();
if (!storage) return favourites;
try {
const list = JSON.parse(await storage.get(FAVOURITES_KEY) ?? '[]');
if (Array.isArray(list)) for (const id of list) if (Number.isInteger(id)) favourites.add(id);
} catch { /* start over */ }
return favourites;
}
let watching = false;
/** New saves and deletions arrive while the game runs, so a friend's save shows up at once. */
function startWatching() {
if (watching || typeof nostr.watch !== 'function') return;
watching = true;
nostr.watch([{ kinds: [SAVE_KIND], '#t': [SAVE_TAG] }, { kinds: [5], '#t': [SAVE_TAG] }], (event) => {
if (event?.kind === 5) { const deletion = parseDeletion(event); if (deletion) keepDeletion(deletion); return; }
const save = parseSaveEvent(event);
if (save) { keepSave(save); void loadProfiles([save.pubkey]); }
});
}
async function refresh(force = false) {
const due = force || now() - listedAt >= LIST_TTL;
await loadPrivate();
startWatching();
if (!due) return;
const [found, deletions] = await Promise.all([
nostr.collect([{ kinds: [SAVE_KIND], '#t': [SAVE_TAG], limit: 500 }]),
nostr.collect([{ kinds: [5], '#t': [SAVE_TAG], limit: 500 }]),
]);
for (const event of deletions) { const deletion = parseDeletion(event); if (deletion) keepDeletion(deletion); }
for (const event of found) { const save = parseSaveEvent(event); if (save) keepSave(save); }
listedAt = now();
await Promise.all([loadProfiles([...new Set([...saves.values()].map((save) => save.pubkey))]), loadVotes(force)]);
}
async function loadProfiles(pubkeys) {
const missing = pubkeys.filter((pubkey) => !profiles.has(pubkey) && !(session && pubkey === session.pubkey));
if (!missing.length) return;
for (const pubkey of missing) profiles.set(pubkey, null);
const events = (await Promise.all(chunks(missing, 100).map((authors) => nostr.collect([{ kinds: [0], authors }], { minMs: 600 })))).flat();
const newest = new Map();
for (const event of events) {
if (event?.kind !== 0 || typeof event.content !== 'string') continue;
if ((newest.get(event.pubkey)?.created_at ?? -1) >= event.created_at) continue;
newest.set(event.pubkey, event);
}
for (const [pubkey, event] of newest) {
try { profiles.set(pubkey, JSON.parse(event.content)); } catch { /* keep null */ }
}
for (const pubkey of pubkeys) nameOf(pubkey);
}
async function loadVotes(force = false) {
if (!force && now() - votesAt < VOTES_TTL) return;
const addresses = [...saves.keys()];
if (!addresses.length) { votesAt = now(); return; }
const found = (await Promise.all(chunks(addresses, 100).map((list) => nostr.collect([{ kinds: [7], '#a': list }], { minMs: 600 })))).flat();
const parsed = found.map(parseVote).filter(Boolean);
const voteIds = parsed.map((vote) => vote.id);
const deletions = voteIds.length
? (await Promise.all(chunks(voteIds, 100).map((list) => nostr.collect([{ kinds: [5], '#e': list }], { minMs: 600 })))).flat()
: [];
for (const vote of parsed) keepVote(vote);
for (const event of deletions) { const deletion = parseDeletion(event); if (deletion) keepDeletion(deletion); }
votesAt = now();
}
function scoreOf(address) {
let up = 0, down = 0;
for (const vote of votes.get(address)?.values() ?? []) {
if (vote.value > 0) up++; else down++;
}
return { up, down };
}
function publicInfo(save) {
const { up, down } = scoreOf(save.address);
return {
id: stableId(save.address), address: save.address, pubkey: save.pubkey, username: nameOf(save.pubkey),
name: save.name, description: save.description, created: save.created, updated: save.updated,
up, down, tags: save.tags, published: true, save,
};
}
function privateInfo(record) {
return {
id: stableId(`local:${record.owner}:${record.d}`), address: null, pubkey: record.owner, username: nameOf(record.owner),
name: record.name, description: record.description, created: record.created, updated: record.updated,
up: 0, down: 0, tags: record.tags ?? [], published: false, record,
};
}
function find(id) {
const entry = ids.get(Number(id));
if (entry?.address) {
const save = saves.get(entry.address);
return save ? publicInfo(save) : null;
}
if (entry?.d) {
const record = privateSaves.get(entry.d);
return record ? privateInfo(record) : null;
}
return null;
}
async function lookup(id) {
let info = find(id);
if (!info) {
await refresh(true);
info = find(id);
}
if (!info) throw new ServerError('This save is not on the relays (any more).');
return info;
}
function requireSession() {
if (sessionProblem) throw new ServerError(sessionProblem);
if (!session) throw new ServerError('Sign in to Nappelin first.');
return session;
}
function requireOwner(info) {
const me = requireSession();
if (info.pubkey !== me.pubkey) throw new ServerError('Only the author can do that.');
return me;
}
async function dTagFor(name) {
return D_PREFIX + (await sha256Hex(encoder.encode(name.trim().toLowerCase()))).slice(0, 16);
}
async function publishSave({ d, name, description, bytes, tags, publishedAt }) {
if (bytes.length > MAX_SHARED_BYTES) {
throw new ServerError(`This save is ${Math.ceil(bytes.length / 1024)} KB. Saves shared over Nostr can be at most `
+ `${Math.floor(MAX_SHARED_BYTES / 1024)} KB. Save it on your device instead, or uncheck Publish.`);
}
const template = saveTemplate({
d, name, description, data: bytesToBase64(bytes), size: bytes.length, tags, publishedAt,
sha256: await sha256Hex(bytes), now: now(),
});
const signed = await nostr.publish(template);
const save = parseSaveEvent(signed);
if (!save) throw new ServerError('The host returned a save the game cannot read back.');
removedSaves.delete(save.address);
keepSave(save);
return save;
}
async function storePrivate(record) {
if (!storage) throw new ServerError('This host keeps no storage, so unpublished saves cannot be kept.');
await storage.set(PRIVATE_PREFIX + record.d, JSON.stringify(record));
privateSaves.set(record.d, record);
ids.set(stableId(`local:${record.owner}:${record.d}`), { d: record.d });
}
async function dropPrivate(d) {
privateSaves.delete(d);
if (storage) await storage.remove(PRIVATE_PREFIX + d);
}
return {
get session() { return session; },
/** Sign-in from the game's login window, after the player signed in to Nappelin. */
setSession(next) {
sessionProblem = '';
privateLoaded = false;
adopt(next);
},
/** The account changed under a running game: writes stop until the game is reloaded. */
block(reason) {
sessionProblem = reason;
},
async list({ query = '', category = '', start = 0, count = 20 } = {}) {
await refresh();
const parsed = parseQuery(query);
let items = [...saves.values()].map(publicInfo);
if (session && (parsed.user === session.username || category === 'MyOwn')) {
items = items.concat([...privateSaves.values()].map(privateInfo));
}
if (category === 'Favourites') {
const favs = await loadFavourites();
items = items.filter((info) => favs.has(info.id));
}
if (parsed.user !== null) {
const pubkey = usernames.get(parsed.user);
items = pubkey ? items.filter((info) => info.pubkey === pubkey) : [];
}
if (parsed.ids.length) items = items.filter((info) => parsed.ids.includes(info.id));
if (parsed.after !== null) items = items.filter((info) => info.created >= parsed.after);
for (const word of parsed.words) {
items = items.filter((info) => info.name.toLowerCase().includes(word) || info.description.toLowerCase().includes(word)
|| info.username.toLowerCase().includes(word) || info.tags.includes(word));
}
items.sort(parsed.sortDate
? (a, b) => b.updated - a.updated
: (a, b) => (b.up - b.down) - (a.up - a.down) || b.updated - a.updated);
return { count: items.length, items: items.slice(start, start + count) };
},
async info(id) {
const info = await lookup(id);
const favs = await loadFavourites();
let mine = 0;
if (session && info.address) mine = votes.get(info.address)?.get(session.pubkey)?.value ?? 0;
let commentCount = 0;
if (info.address) commentCount = (await this.comments(id, 0, 1000)).length;
return { ...info, mine, favourite: favs.has(info.id), comments: commentCount };
},
async data(id) {
const info = await lookup(id);
return base64ToBytes(info.published ? info.save.event.content : info.record.data);
},
async thumbnail(id) {
const info = await lookup(id);
const key = info.published ? info.save.event.id : `local:${info.record.d}:${info.record.updated}`;
if (!thumbnails.has(key)) {
if (!thumbnail) throw new ServerError('No preview');
if (thumbnails.size > 200) thumbnails.delete(thumbnails.keys().next().value);
thumbnails.set(key, thumbnail(await this.data(id)));
}
return thumbnails.get(key);
},
async upload({ name, description = '', bytes, publish }) {
const me = requireSession();
const clean = String(name ?? '').trim().slice(0, 100);
if (!clean) throw new ServerError('Give the save a name.');
await refresh();
const d = await dTagFor(clean);
const address = addressOf(me.pubkey, d);
if (publish) {
const before = saves.get(address);
const save = await publishSave({
d, name: clean, description: String(description).slice(0, 2000), bytes,
tags: before?.tags ?? privateSaves.get(d)?.tags ?? [], publishedAt: before?.created ?? now(),
});
return stableId(save.address);
}
const at = now();
const earlier = privateSaves.get(d);
await storePrivate({
owner: me.pubkey, d, name: clean, description: String(description).slice(0, 2000),
data: bytesToBase64(bytes), created: earlier?.created ?? at, updated: at, tags: earlier?.tags ?? [],
});
return stableId(`local:${me.pubkey}:${d}`);
},
async vote(id, direction) {
const me = requireSession();
const info = await lookup(id);
if (!info.published) throw new ServerError('Unpublished saves cannot be voted on.');
if (info.pubkey === me.pubkey) throw new ServerError('You cannot vote on your own save.');
const mineNow = [...voteEvents.values()].filter((vote) => vote.pubkey === me.pubkey && vote.address === info.address
&& !revokedVotes.has(`${vote.pubkey}:${vote.id}`));
if (mineNow.length) {
await nostr.publish(deletionTemplate({ ids: mineNow.map((vote) => vote.id), kind: 7 }, now()));
for (const vote of mineNow) revokeVote(vote);
}
if (direction) {
const signed = await nostr.publish(voteTemplate(info.save, direction, now()));
const vote = parseVote(signed);
if (vote) keepVote(vote);
}
},
async comments(id, start = 0, count = 20) {
const info = await lookup(id);
if (!info.address) return [];
const cached = comments.get(info.address);
if (!cached || now() - cached.at >= COMMENTS_TTL) {
const found = await nostr.collect([{ kinds: [1111], '#A': [info.address] }], { minMs: 600 });
const list = new Map((cached?.list ?? []).map((comment) => [comment.id, comment]));
for (const event of found) {
const comment = parseComment(event);
if (comment && comment.address === info.address) list.set(comment.id, comment);
}
comments.set(info.address, { at: now(), list: [...list.values()] });
await loadProfiles([...new Set([...list.values()].map((comment) => comment.pubkey))]);
}
return comments.get(info.address).list
.slice().sort((a, b) => b.at - a.at || (a.id < b.id ? -1 : 1))
.slice(start, start + count)
.map((comment) => ({ ...comment, username: nameOf(comment.pubkey) }));
},
async addComment(id, text) {
requireSession();
const info = await lookup(id);
if (!info.published) throw new ServerError('Unpublished saves have no comments.');
const clean = String(text ?? '').trim().slice(0, 2000);
if (!clean) throw new ServerError('The comment is empty.');
const signed = await nostr.publish(commentTemplate(info.save, clean, now()));
const comment = parseComment(signed);
if (comment) {
const cached = comments.get(info.address) ?? { at: 0, list: [] };
cached.list = cached.list.filter((item) => item.id !== comment.id).concat(comment);
comments.set(info.address, cached);
}
},
async editTag(id, op, tag) {
const info = await lookup(id);
requireOwner(info);
const clean = String(tag ?? '').trim().toLowerCase();
if (!/^[a-z0-9]{1,16}$/.test(clean) || clean === SAVE_TAG) throw new ServerError('Tags are 1 to 16 lowercase letters or digits.');
const next = op === 'delete' ? info.tags.filter((item) => item !== clean) : [...new Set([...info.tags, clean])].slice(0, 20);
if (info.published) {
const saved = await publishSave({
d: info.save.d, name: info.name, description: info.description, bytes: base64ToBytes(info.save.event.content),
tags: next, publishedAt: info.created,
});
return saved.tags;
}
await storePrivate({ ...info.record, tags: next });
return next;
},
async remove(id, mode) {
const info = await lookup(id);
const me = requireOwner(info);
if (!info.published) {
if (mode === 'Unpublish') throw new ServerError('This save is not published.');
await dropPrivate(info.record.d);
return;
}
if (mode === 'Unpublish') {
await storePrivate({
owner: me.pubkey, d: info.save.d, name: info.name, description: info.description, data: info.save.event.content,
created: info.created, updated: now(), tags: info.tags,
});
}
await nostr.publish(deletionTemplate({ ids: [info.save.event.id], addresses: [info.address], kind: SAVE_KIND, saveTag: true }, now()));
removedSaves.set(info.address, now());
saves.delete(info.address);
},
async publishPrivate(id) {
const info = await lookup(id);
requireOwner(info);
if (info.published) return stableId(info.address);
const save = await publishSave({
d: info.record.d, name: info.name, description: info.description, bytes: base64ToBytes(info.record.data),
tags: info.tags, publishedAt: now(),
});
await dropPrivate(info.record.d);
return stableId(save.address);
},
async favourite(id, on) {
requireSession();
const favs = await loadFavourites();
if (on) favs.add(Number(id)); else favs.delete(Number(id));
if (storage) await storage.set(FAVOURITES_KEY, JSON.stringify([...favs]));
},
async user(name) {
await refresh();
const pubkey = usernames.get(name);
if (!pubkey) throw new ServerError('No player by that name has shared a save yet.');
const profile = session && pubkey === session.pubkey ? session.profile : profiles.get(pubkey);
const own = [...saves.values()].filter((save) => save.pubkey === pubkey).map(publicInfo);
const scores = own.map((info) => info.up - info.down);
return {
id: stableId(pubkey), username: name, pubkey,
biography: typeof profile?.about === 'string' ? profile.about.slice(0, 1000) : '',
website: typeof profile?.website === 'string' ? profile.website.slice(0, 200) : '',
location: typeof profile?.nip05 === 'string' ? profile.nip05.slice(0, 100) : '',
saves: own.length,
average: scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0,
highest: scores.length ? Math.max(...scores) : 0,
};
},
async tags({ start = 0, count = 50, query = '' } = {}) {
await refresh();
const counts = new Map();
for (const save of saves.values()) for (const tag of save.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
const prefix = String(query).toLowerCase();
return [...counts].filter(([tag]) => !prefix || tag.startsWith(prefix))
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
.slice(start, start + count);
},
};
}
