Back to Black Site Map Editor
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.

src/vendor/map-event.mjs
// 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;
  }
}