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/map-host.ts
import { identity, outbox, resource, storage, upload } from '@napplet/sdk';
import type { NostrEvent, UploadResult } from '@napplet/sdk';
import type { BlackSiteMap } from './model';
import { validateMap } from './model';
import {
    mapEventTemplate,
    parseMapEvent,
    mapResourceUrls,
    readMapBytes,
    MAX_MAP_BYTES,
} from './vendor/map-event.mjs';
export type MapMetadata = Extract<
    ReturnType<typeof parseMapEvent>,
    {
        ok: true;
    }
>['map'];
export interface MapRecord {
    event: NostrEvent;
    metadata: MapMetadata;
    relayHints: string[];
}
export interface DiscoverOptions {
    author?: string;
    authors?: string[];
    relays?: string[];
    until?: number;
    limit?: number;
}
export interface PublishOptions {
    base?: MapRecord;
    remixOf?: NostrEvent;
    remixRelay?: string;
    content?: string;
    hashtags?: string[];
    image?: string;
    onStatus?: (message: string) => void;
}
const pendingUploads = new Map<string, UploadResult>();
export const getViewer = () => identity.getPublicKey();
export interface Draft {
    map: BlackSiteMap;
    base?: MapRecord;
    remixOf?: NostrEvent;
    remixRelay?: string;
    content?: string;
    hashtags?: string[];
    image?: string;
    savedAt?: number;
}
let identityEpoch = 0;
export const watchViewer = (callback: (pubkey: string) => void) =>
    identity.onChanged((pubkey) => {
        identityEpoch++;
        callback(pubkey);
    });
export async function getPendingUpload(viewer: string, map: BlackSiteMap) {
    return pendingUploads.get(
        `${viewer}:${await sha256Hex(new TextEncoder().encode(JSON.stringify(map)))}`,
    );
}
export async function sha256Hex(bytes: Uint8Array): Promise<string> {
    const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes));
    return Array.from(new Uint8Array(digest), (n) => n.toString(16).padStart(2, '0')).join('');
}
function record(event: NostrEvent, relayHints: string[] = []): MapRecord {
    const parsed = parseMapEvent(event);
    if (!parsed.ok) throw new Error(parsed.errors.join('\n'));
    return { event, metadata: parsed.map, relayHints };
}
export async function discoverMaps(options: DiscoverOptions = {}) {
    const limit = Math.min(100, Math.max(1, options.limit ?? 50));
    const result = await outbox.query(
        [
            {
                kinds: [35858],
                ...(options.author
                    ? { authors: [options.author] }
                    : options.authors
                      ? { authors: options.authors }
                      : {}),
                ...(options.until !== undefined ? { until: options.until } : {}),
                limit,
            },
        ],
        {
            ...(options.author
                ? { authors: [options.author] }
                : options.authors
                  ? { authors: options.authors }
                  : {}),
            ...(options.relays ? { relays: options.relays } : {}),
            limit,
            timeoutMs: 8000,
        },
    );
    const newest = new Map<string, (typeof result.events)[number]>();
    for (const item of result.events) {
        const d = item.event.tags.find((tag) => tag[0] === 'd')?.[1];
        const key = `${item.event.pubkey}:${d}`;
        const prior = newest.get(key);
        if (
            !prior ||
            item.event.created_at > prior.event.created_at ||
            (item.event.created_at === prior.event.created_at && item.event.id < prior.event.id)
        )
            newest.set(key, item);
    }
    const maps: MapRecord[] = [];
    let invalid = 0;
    for (const item of newest.values()) {
        try {
            maps.push(record(item.event, item.sidecar?.relayHints ?? []));
        } catch {
            invalid++;
        }
    }
    maps.sort(
        (a, b) => b.event.created_at - a.event.created_at || a.event.id.localeCompare(b.event.id),
    );
    const nextUntil =
        result.events.length >= limit
            ? Math.min(...result.events.map((item) => item.event.created_at))
            : undefined;
    return {
        maps,
        invalid,
        incomplete: Boolean(result.incomplete),
        error: result.error,
        nextUntil,
        paginationBlocked:
            nextUntil !== undefined &&
            (nextUntil === options.until ||
                result.events.every((item) => item.event.created_at === nextUntil)),
    };
}
export async function loadMap(source: MapRecord): Promise<BlackSiteMap> {
    const servers = await outbox
        .query([{ kinds: [10063], authors: [source.event.pubkey], limit: 1 }], {
            authors: [source.event.pubkey],
            timeoutMs: 5000,
        })
        .catch(() => ({ events: [] }));
    const serverEvent = [...servers.events].sort(
        (a, b) => b.event.created_at - a.event.created_at,
    )[0]?.event;
    const extraServers =
        serverEvent?.tags.filter((tag) => tag[0] === 'server').map((tag) => tag[1]) ?? [];
    let lastError: unknown = new Error('Map bytes are unavailable.');
    for (const url of mapResourceUrls(source.metadata, extraServers)) {
        try {
            const blob = await resource.bytes(
                url.replace(/^blossom:([0-9a-f]{64})$/, 'blossom:sha256:$1'),
            );
            if (blob.size > MAX_MAP_BYTES) throw new Error('Map exceeds the 256 KiB limit.');
            const checked = await readMapBytes(
                new Uint8Array(await blob.arrayBuffer()),
                source.metadata,
                sha256Hex,
            );
            if (!checked.ok) throw new Error(checked.errors.join('\n'));
            return checked.map;
        } catch (error) {
            lastError = error;
            const code = (
                error as {
                    code?: string;
                }
            ).code;
            if (
                !['not-found', 'network-error', 'timeout', 'unsupported-scheme'].includes(
                    code ?? '',
                )
            )
                throw error;
        }
    }
    throw lastError;
}
async function requireViewer(expected?: string) {
    const viewer = await getViewer();
    if (!viewer) throw new Error('Sign in with your Nostr identity in the host to publish maps.');
    if (expected && viewer !== expected)
        throw new Error('Your identity changed. Save this draft and retry with its owner.');
    return viewer;
}
async function checkBase(base: MapRecord, viewer: string) {
    if (base.event.pubkey !== viewer)
        throw new Error(
            'Only the author can update or delete a map. Use Remix to create your own copy.',
        );
    const lookup = await outbox.query(
        [{ kinds: [35858], authors: [viewer], '#d': [base.metadata.id], limit: 1 }],
        { authors: [viewer], relays: base.relayHints, timeoutMs: 8000 },
    );
    if (lookup.incomplete || lookup.error)
        throw new Error(
            'Current revision could not be confirmed. Retry before overwriting this map.',
        );
    const latest = [...lookup.events].sort(
        (a, b) => b.event.created_at - a.event.created_at || a.event.id.localeCompare(b.event.id),
    )[0]?.event;
    if (!latest || latest.id !== base.event.id)
        throw new Error(
            'This map changed since you opened it. Reload it or remix your draft before publishing.',
        );
}
async function completeUpload(
    initial: UploadResult,
    onStatus?: (message: string) => void,
    remember?: (result: UploadResult) => void,
): Promise<UploadResult> {
    let current = initial;
    const deadline = Date.now() + 180000;
    while (current.status === 'uploading' || current.status === 'pending') {
        if (Date.now() > deadline)
            throw new Error('Upload is still pending. Keep this draft and retry.');
        onStatus?.('Waiting for sharing approval…');
        await new Promise((resolve) => setTimeout(resolve, 500));
        current = await upload.status(current.uploadId);
        remember?.(current);
    }
    // status snapshots need not repeat the initial request's `ok` field.
    if (current.ok === false || current.status !== 'complete')
        throw new Error(current.error ?? `Upload ${current.status}.`);
    return current;
}
export async function publishMap(
    map: BlackSiteMap,
    options: PublishOptions = {},
): Promise<MapRecord> {
    options.onStatus?.('Checking your map and account…');
    const errors = validateMap(map);
    if (errors.length) throw new Error(errors.join('\n'));
    const viewer = await requireViewer();
    const epoch = identityEpoch;
    if (options.base) {
        if (map.id !== options.base.metadata.id)
            throw new Error(
                'An update must keep the published map ID. Remix to create another map.',
            );
        options.onStatus?.('Checking the published revision…');
        await checkBase(options.base, viewer);
    } else {
        options.onStatus?.('Preparing your map address…');
        // Identity is (kind, author, d), never the d-tag alone. This read can
        // detect an existing own map, but cannot prove absence across relays.
        // A failed/incomplete read must not become a prerequisite for publishing.
        let existing;
        try {
            existing = await outbox.query(
                [{ kinds: [35858], authors: [viewer], '#d': [map.id], limit: 1 }],
                { authors: [viewer], timeoutMs: 8000 },
            );
        } catch {
            // OUTBOX publication has its own acknowledgement and error handling.
        }
        if (
            existing?.events.some(
                ({ event }) =>
                    event.kind === 35858 &&
                    event.pubkey === viewer &&
                    event.tags.find((tag) => tag[0] === 'd')?.[1] === map.id,
            )
        )
            throw new Error(
                'You already published a map at this address under your account. Open it from My maps to update it, or create a remix.',
            );
    }
    const bytes = new TextEncoder().encode(JSON.stringify(map));
    if (bytes.length > MAX_MAP_BYTES) throw new Error('Map exceeds the 256 KiB limit.');
    const hash = await sha256Hex(bytes),
        cacheKey = `${viewer}:${hash}`;
    let uploaded = pendingUploads.get(cacheKey);
    if (!uploaded) {
        await requireViewer(viewer);
        if (epoch !== identityEpoch)
            throw new Error(
                'Identity changed while preparing the upload. Keep the draft and retry.',
            );
        options.onStatus?.('Preparing your map…');
        const support = await upload.info();
        if (!support.rails.some((rail) => rail.rail === 'blossom' && rail.enabled))
            throw new Error(
                'This host has no enabled Blossom upload rail. Keep your draft and use a compatible host.',
            );
        if (support.maxBytes !== undefined && bytes.length > support.maxBytes)
            throw new Error(
                `This host limits uploads to ${support.maxBytes} bytes. Your map draft is retained.`,
            );
        if (
            support.mimeTypes?.length &&
            !support.mimeTypes.includes('application/json') &&
            !support.mimeTypes.includes('application/*') &&
            !support.mimeTypes.includes('*/*')
        )
            throw new Error(
                'This host does not permit application/json uploads. Your map draft is retained.',
            );
        await requireViewer(viewer);
        if (epoch !== identityEpoch)
            throw new Error('Identity changed during upload preflight. Keep your draft and retry.');
        options.onStatus?.('Uploading your map…');
        uploaded = await upload.upload({
            rail: 'blossom',
            data: new Blob([bytes], { type: 'application/json' }),
            mimeType: 'application/json',
            filename: `${map.id}.json`,
            noTransform: true,
        });
        pendingUploads.set(cacheKey, uploaded);
    }
    try {
        uploaded = await completeUpload(uploaded, options.onStatus, (status) =>
            pendingUploads.set(cacheKey, status),
        );
        if (uploaded.sha256 !== hash || uploaded.size !== bytes.length) {
            pendingUploads.delete(cacheKey);
            throw new Error('Uploaded bytes differ from the exact map JSON. Publication stopped.');
        }
        pendingUploads.set(cacheKey, uploaded);
    } catch (error) {
        if (['failed', 'cancelled'].includes(pendingUploads.get(cacheKey)?.status ?? ''))
            pendingUploads.delete(cacheKey);
        throw error;
    }
    await requireViewer(viewer);
    if (epoch !== identityEpoch)
        throw new Error('Identity changed while publishing. Keep this draft and retry.');
    if (options.base) await checkBase(options.base, viewer);
    const template = mapEventTemplate(
        map,
        { ...uploaded, sha256: hash, size: bytes.length },
        options,
    );
    if (options.base && !options.remixOf) {
        const lineageNames = new Set(['A', 'E', 'K', 'P', 'a', 'e', 'k', 'p']);
        template.tags.push(
            ...options.base.event.tags
                .filter((tag) => lineageNames.has(tag[0]))
                .map((tag) => [...tag]),
        );
    }
    if (options.base)
        template.created_at = Math.max(template.created_at, options.base.event.created_at + 1);
    options.onStatus?.('Sharing your map…');
    await requireViewer(viewer);
    if (epoch !== identityEpoch)
        throw new Error('Identity changed while publishing. Keep this draft and retry.');
    let result;
    try {
        result = await outbox.publish(template);
    } catch (error) {
        throw Object.assign(error instanceof Error ? error : new Error(String(error)), {
            upload: uploaded,
            template,
        });
    }
    await requireViewer(viewer);
    if (epoch !== identityEpoch)
        throw new Error(
            'Identity changed during publication. Reload your maps to confirm the result.',
        );
    if (!result.ok || !result.event)
        throw Object.assign(
            new Error(
                `${result.error ?? 'The host could not publish kind 35858 maps.'} Your draft and verified Blossom upload are retained for Retry.`,
            ),
            { upload: uploaded, template },
        );
    if (result.event.pubkey !== viewer)
        throw new Error('The host returned a publication from another identity.');
    pendingUploads.delete(cacheKey);
    return record(
        result.event,
        Object.keys(result.relays ?? {}).filter((url) => result.relays?.[url]),
    );
}
export async function deleteMap(source: MapRecord): Promise<void> {
    const epoch = identityEpoch;
    const viewer = await requireViewer(source.event.pubkey);
    await checkBase(source, viewer);
    await requireViewer(viewer);
    if (epoch !== identityEpoch)
        throw new Error('Identity changed while preparing deletion. Retry with the map owner.');
    const result = await outbox.publish({
        kind: 5,
        content: 'Removed by the map author.',
        tags: [
            ['a', source.metadata.address],
            ['e', source.event.id],
            ['k', '35858'],
        ],
        created_at: Math.max(Math.floor(Date.now() / 1000), source.event.created_at + 1),
    });
    if (!result.ok) throw new Error(result.error ?? 'The host does not permit map deletion.');
}
const draftKey = (viewer: string) => `black-site-editor:draft:${viewer || 'guest'}`;
export async function saveDraft(viewer: string, draft: Draft) {
    await storage.setItem(draftKey(viewer), JSON.stringify(draft));
}
export async function loadDraft(viewer: string): Promise<Draft | null> {
    const value = await storage.getItem(draftKey(viewer));
    if (!value) return null;
    let draft: Draft;
    try {
        draft = JSON.parse(value);
    } catch {
        throw new Error('Stored draft is malformed JSON.');
    }
    if (!draft || typeof draft !== 'object' || validateMap(draft.map).length)
        throw new Error('Stored draft has invalid map data.');
    if (draft.base) {
        const base = draft.base;
        if (
            !base.event ||
            !Array.isArray(base.event.tags) ||
            base.event.pubkey !== viewer ||
            !/^[0-9a-f]{64}$/.test(base.event.id) ||
            !Array.isArray(base.relayHints) ||
            !base.relayHints.every((url) => typeof url === 'string')
        )
            throw new Error('Stored draft has an invalid published base or owner.');
        const checked = record(base.event, base.relayHints);
        if (checked.metadata.id !== draft.map.id)
            throw new Error('Stored draft ID differs from its published base.');
        draft.base = checked;
    }
    if (draft.remixOf && (!draft.remixOf.tags || !parseMapEvent(draft.remixOf).ok))
        throw new Error('Stored draft has invalid remix attribution.');
    if (
        (draft.content !== undefined && typeof draft.content !== 'string') ||
        (draft.image !== undefined && typeof draft.image !== 'string') ||
        (draft.remixRelay !== undefined && typeof draft.remixRelay !== 'string') ||
        (draft.hashtags !== undefined &&
            (!Array.isArray(draft.hashtags) ||
                !draft.hashtags.every((tag) => typeof tag === 'string'))) ||
        (draft.savedAt !== undefined && (!Number.isFinite(draft.savedAt) || draft.savedAt < 0))
    )
        throw new Error('Stored draft has invalid metadata.');
    return draft;
}
export async function clearDraft(viewer: string) {
    await storage.removeItem(draftKey(viewer));
}
const tutorialKey = 'black-site-editor:tutorial-seen:v1';
export async function loadTutorialSeen(): Promise<boolean> {
    return Boolean(await storage.getItem(tutorialKey));
}
export async function saveTutorialSeen() {
    await storage.setItem(tutorialKey, '1');
}

/** Resolve one canonical map address; arbitrary inbound signed objects never bypass OUTBOX. */
export async function resolveMap(target: {
    author: string;
    id: string;
    relays?: string[];
}): Promise<MapRecord> {
    if (!/^[0-9a-f]{64}$/.test(target.author) || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(target.id))
        throw new Error('Invalid map address.');
    const response = await outbox.query(
        [{ kinds: [35858], authors: [target.author], '#d': [target.id], limit: 10 }],
        { authors: [target.author], relays: target.relays, timeoutMs: 8000 },
    );
    if (response.error || response.incomplete)
        throw new Error(
            'Could not confirm the requested map from its author. Retry after relay discovery completes.',
        );
    const candidates = response.events.filter(
        (item) =>
            item.event.kind === 35858 &&
            item.event.pubkey === target.author &&
            item.event.tags.some((tag) => tag[0] === 'd' && tag[1] === target.id),
    );
    candidates.sort(
        (a, b) => b.event.created_at - a.event.created_at || a.event.id.localeCompare(b.event.id),
    );
    if (!candidates.length)
        throw new Error(
            'The requested map was not found. It may have been deleted or its relays may be unavailable.',
        );
    return record(candidates[0].event, candidates[0].sidecar?.relayHints ?? target.relays ?? []);
}