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.
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 ?? []);
}
