SOURCE / PINNED RELEASE
Made of little things.
Black Site Map Editor
- Release
- 32a57733b7e9…
- Author-recorded commit
- 3f54890f542f…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor
Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Black Site custom maps on Nostr (../map-event.md): an addressable kind 35858 event that
// points at the map JSON stored on Blossom. Dependency-free, so a map editor can copy it.
//
// mapEventTemplate(map, upload) -> unsigned event template for publishing
// parseMapEvent(event) -> { ok: true, map: {...} } | { ok: false, errors: [...] }
// mapResourceUrls(parsed) -> URLs to try with NAP-RESOURCE, best first
// readMapBytes(bytes, parsed) -> { ok: true, map } | { ok: false, errors } (hash, size, JSON, schema)
//
// A remix passes the signed parent map event as `extra.remixOf`; see ../remix-lineage.md.
import { parseLineage, remixTags } from './lineage.mjs';
import { MAP_VERSION, validateMap } from './validate-map.mjs';
export const MAP_EVENT_KIND = 35858;
export const MAP_MIME = 'application/json';
/** Map JSON larger than this is rejected before parsing. */
export const MAX_MAP_BYTES = 256 * 1024;
const HEX64 = /^[0-9a-f]{64}$/;
/**
* Builds the unsigned event for a validated map after its JSON was uploaded to Blossom.
* `upload` is the NAP-UPLOAD result (or the same fields): { sha256, size, url?, fallbackUrls? }.
* `extra` may add { content, image, hashtags, client }, and { remixOf, remixRelay } for a remix:
* the signed kind 35858 event it was made from and a relay hint where that event was found.
*/
export function mapEventTemplate(map, upload, extra = {}) {
const errors = validateMap(map);
if (errors.length) throw new Error(`map is invalid:\n${errors.join('\n')}`);
if (!HEX64.test(upload.sha256 || '')) throw new Error('upload.sha256 must be 64 lowercase hex chars');
if (!Number.isInteger(upload.size) || upload.size <= 0 || upload.size > MAX_MAP_BYTES) throw new Error(`upload.size must be 1-${MAX_MAP_BYTES} bytes`);
if (extra.remixOf && extra.remixOf.kind !== MAP_EVENT_KIND) throw new Error(`a map can only remix a kind ${MAP_EVENT_KIND} map`);
const servers = [...new Set([upload.url, ...(upload.fallbackUrls || [])].filter(Boolean).map(blossomServer).filter(Boolean))];
const tags = [
['d', map.id],
['title', map.name],
...(map.desc ? [['summary', map.desc]] : []),
['version', String(MAP_VERSION)],
['x', upload.sha256],
['m', MAP_MIME],
['size', String(upload.size)],
...servers.map((s) => ['server', s]),
...(extra.image ? [['image', extra.image]] : []),
...(extra.hashtags || []).map((t) => ['t', String(t).toLowerCase()]),
...(extra.remixOf ? remixTags(extra.remixOf, { relay: extra.remixRelay }) : []),
...(extra.client ? [['client', extra.client]] : []),
['alt', `Black Site map: ${map.name}`],
];
return { kind: MAP_EVENT_KIND, content: extra.content || '', tags, created_at: Math.floor(Date.now() / 1000) };
}
/** Reads and checks the tags of a kind 35858 event. Does not fetch anything. */
export function parseMapEvent(event) {
const errors = [];
const err = (msg) => errors.push(msg);
if (!event || event.kind !== MAP_EVENT_KIND) return { ok: false, errors: [`kind must be ${MAP_EVENT_KIND}`] };
if (!HEX64.test(event.pubkey || '')) err('pubkey must be 64 lowercase hex chars');
const tags = Array.isArray(event.tags) ? event.tags.filter((t) => Array.isArray(t) && typeof t[0] === 'string') : [];
const one = (name) => tags.find((t) => t[0] === name)?.[1];
const all = (name) => tags.filter((t) => t[0] === name && typeof t[1] === 'string').map((t) => t[1]);
const id = one('d');
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) err('d tag must be a valid map id');
const version = Number(one('version'));
if (!Number.isInteger(version) || version < 1) err('version tag must be a positive integer');
else if (version > MAP_VERSION) err(`map version ${version} is newer than this game supports (${MAP_VERSION})`);
const sha256 = one('x');
if (!HEX64.test(sha256 || '')) err('x tag must be the 64-char lowercase hex SHA-256 of the map JSON');
if (one('m') !== undefined && one('m') !== MAP_MIME) err(`m tag must be "${MAP_MIME}"`);
const size = one('size') === undefined ? undefined : Number(one('size'));
if (size !== undefined && (!Number.isInteger(size) || size <= 0 || size > MAX_MAP_BYTES)) err(`size tag must be 1-${MAX_MAP_BYTES} bytes`);
const title = one('title');
if (typeof title !== 'string' || !title.length) err('title tag is required');
const lineage = parseLineage(event);
if (!lineage.ok) errors.push(...lineage.errors);
if (errors.length) return { ok: false, errors };
return {
ok: true,
map: {
address: `${MAP_EVENT_KIND}:${event.pubkey}:${id}`,
pubkey: event.pubkey,
id,
version,
sha256,
size,
title,
summary: one('summary') || '',
image: one('image') || null,
hashtags: all('t'),
servers: all('server').map(blossomServer).filter(Boolean),
content: typeof event.content === 'string' ? event.content : '',
lineage: lineage.lineage,
createdAt: event.created_at,
},
};
}
/**
* URLs for `resource.bytes`, best first: the hash-verified `blossom:` URI (the shell finds and
* checks the blob), then `https://<server>/<sha256>.json` for each `server` tag and for any
* extra Blossom servers passed in (e.g. the author's kind 10063 list).
*/
export function mapResourceUrls(parsed, extraServers = []) {
const servers = [...new Set([...parsed.servers, ...extraServers.map(blossomServer).filter(Boolean)])];
return [`blossom:${parsed.sha256}`, ...servers.map((s) => `${s}/${parsed.sha256}.json`)];
}
/**
* Checks downloaded map bytes against the event and the map format. Pass `sha256Hex` (an async
* bytes -> hex function) to verify the hash yourself, which is required for `https:` downloads.
*/
export async function readMapBytes(bytes, parsed, sha256Hex) {
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
if (buf.byteLength > MAX_MAP_BYTES) return { ok: false, errors: [`map JSON is larger than ${MAX_MAP_BYTES} bytes`] };
if (parsed.size !== undefined && buf.byteLength !== parsed.size) return { ok: false, errors: [`map JSON is ${buf.byteLength} bytes, the event says ${parsed.size}`] };
if (sha256Hex && (await sha256Hex(buf)) !== parsed.sha256) return { ok: false, errors: ['map JSON does not match the x tag hash'] };
let map;
try {
map = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buf));
} catch {
return { ok: false, errors: ['map is not valid UTF-8 JSON'] };
}
const errors = validateMap(map);
if (!errors.length && map.id !== parsed.id) errors.push(`map id "${map.id}" does not match the d tag "${parsed.id}"`);
return errors.length ? { ok: false, errors } : { ok: true, map };
}
/** Normalizes a Blossom server or blob URL to its https origin, or null. */
function blossomServer(url) {
try {
const u = new URL(url);
return u.protocol === 'https:' && !u.username && !u.password ? u.origin : null;
} catch {
return null;
}
}
