diff --git a/AGENTS.md b/AGENTS.md index 9e5022b..064b8cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,10 @@ leave documentation describing planned behavior as if it already exists. - `src/lib/shape.ts` owns DID validation, SHA-256 hashing, trait mapping, catalogue names, palettes, and path construction. It contains no Svelte or browser UI state. +- `src/lib/identity.ts` validates friendly handles and resolves them to canonical DIDs without + changing the generator's DID-only input contract. +- `src/lib/export.ts` serializes portable specimen SVGs and provenance metadata. Export changes + must preserve well-formed XML and keep subject DIDs escaped as untrusted text. - `src/lib/shape.test.ts` protects deterministic output, validation, and trait bounds. - `src/lib/protocol.ts` is the canonical TypeScript source for the production hostname, generator version, and every application NSID used by runtime code. @@ -107,8 +111,10 @@ Rules: ## AT Protocol and authentication boundaries -The checked-in prototype does not yet authenticate or write repository records. Until that work -lands, the study tray is explicitly browser-local and must not imply that it follows the user. +The checked-in prototype resolves handles through Microcosm Slingshot but does not yet authenticate +or write repository records. Until that work lands, the study tray is explicitly browser-local and +must not imply that it follows the user. Copy must disclose that handle resolution is a network +request while direct DID generation remains local. When implementing the PDS-backed milestone: diff --git a/README.md b/README.md index 318bd34..7d6284e 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,20 @@ uploaded, or centrally assigned. ## Current state -The first public prototype includes: +The public prototype includes: - deterministic, accessible SVG specimens generated locally; +- DID input and privacy-disclosed handle-to-DID resolution; +- standalone SVG export with subject, fingerprint, catalogue, and generator metadata; - a specimen label and morphological traits; - a curated public cabinet of example identities; - a browser-local study tray; - static output suitable for deployment at `hasharium.croft.click`; - initial AT Protocol lexicons under the required `click.croft.hasharium.*` namespace. -OAuth, handle resolution, PDS record writes, public collection loading, intersections, and -exhibitions are deliberately not presented as working yet. The local study tray is a preview of -the eventual signed collection experience. +OAuth, PDS record writes, public collection loading, intersections, and exhibitions are +deliberately not presented as working yet. The local study tray is a preview of the eventual signed +collection experience. ## Development @@ -49,6 +51,8 @@ The static production output is written to `build/`. ```text src/lib/shape.ts SHA-256-to-SVG renderer and morphology +src/lib/identity.ts DID input and bounded handle resolution +src/lib/export.ts standalone SVG and provenance metadata export src/lib/protocol.ts canonical host, NSIDs, and protocol constants src/lib/components/Specimen.svelte accessible SVG presentation src/routes/+page.svelte observation, cabinet, and study-tray interaction diff --git a/src/lib/export.test.ts b/src/lib/export.test.ts new file mode 100644 index 0000000..70606c6 --- /dev/null +++ b/src/lib/export.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { exportSpecimenSvg, specimenExportFilename } from "./export"; +import { GENERATOR_VERSION, SOURCE_URL } from "./protocol"; +import { generateSpecimen } from "./shape"; + +function decodeXmlText(value: string): string { + return value + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&"); +} + +describe("specimen SVG export", () => { + it("embeds portable provenance metadata and complete geometry", async () => { + const specimen = await generateSpecimen("did:plc:ewvi7nxzyoun6zhxrhs64oiz"); + const svg = exportSpecimenSvg(specimen); + const metadataText = svg.match( + /(.+)<\/metadata>/, + )?.[1]; + + expect(svg.startsWith('')).toBe(true); + expect(svg.match(/ { + const specimen = await generateSpecimen( + "did:example:subject?left=1&right=2", + ); + const svg = exportSpecimenSvg(specimen); + expect(svg).toContain("left=1&right=2"); + expect(svg).not.toContain("left=1&right=2"); + }); +}); diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..c6e9bf3 --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,89 @@ +import { GENERATOR_VERSION, SOURCE_URL } from "./protocol"; +import type { Specimen } from "./shape"; + +export interface SpecimenExportMetadata { + format: "hasharium-specimen-v1"; + subject: string; + fingerprint: string; + catalogueNumber: string; + name: string; + generatorVersion: typeof GENERATOR_VERSION; + source: typeof SOURCE_URL; +} + +function escapeXmlText(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function escapeXmlAttribute(value: string): string { + return escapeXmlText(value) + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function specimenExportMetadata( + specimen: Specimen, +): SpecimenExportMetadata { + return { + format: "hasharium-specimen-v1", + subject: specimen.did, + fingerprint: specimen.fingerprint, + catalogueNumber: specimen.catalogueNumber, + name: specimen.name, + generatorVersion: GENERATOR_VERSION, + source: SOURCE_URL, + }; +} + +export function exportSpecimenSvg(specimen: Specimen): string { + const gradientId = `wash-${specimen.fingerprint.slice(0, 10)}`; + const titleId = `${gradientId}-title`; + const descriptionId = `${gradientId}-description`; + const metadata = escapeXmlText( + JSON.stringify(specimenExportMetadata(specimen)), + ); + const paths = specimen.paths + .map((path, index) => { + const fill = + index === 0 ? `url(#${gradientId})` : specimen.palette[(index + 1) % 3]; + const fillOpacity = index === 0 ? 0.96 : 0.72 + index * 0.06; + const stroke = + index === specimen.paths.length - 1 ? "#f4eddb" : specimen.palette[2]; + const strokeOpacity = index === specimen.paths.length - 1 ? 0.8 : 0.38; + const filter = index === 0 ? ` filter="url(#${gradientId}-texture)"` : ""; + return ` `; + }) + .join("\n"); + + return ` + + ${escapeXmlText(specimen.name)} + A deterministic ${specimen.symmetry}-fold ${specimen.material} specimen generated from ${escapeXmlText(specimen.did)}. + ${metadata} + + + + + + + + + + + + + +${paths} + + + + +`; +} + +export function specimenExportFilename(specimen: Specimen): string { + return `hasharium-${specimen.catalogueNumber.toLowerCase()}.svg`; +} diff --git a/src/lib/identity.test.ts b/src/lib/identity.test.ts new file mode 100644 index 0000000..59e49fe --- /dev/null +++ b/src/lib/identity.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { + IdentityResolutionError, + isHandle, + normalizeHandle, + resolveIdentity, +} from "./identity"; + +describe("handle validation", () => { + it("accepts domain handles with an optional at sign", () => { + expect(isHandle("alice.example")).toBe(true); + expect(isHandle(" @Alice.Example ")).toBe(true); + expect(normalizeHandle(" @Alice.Example ")).toBe("alice.example"); + }); + + it("rejects incomplete or malformed handles", () => { + expect(isHandle("alice")).toBe(false); + expect(isHandle("-alice.example")).toBe(false); + expect(isHandle("alice..example")).toBe(false); + expect(isHandle(`${"a".repeat(64)}.example`)).toBe(false); + }); +}); + +describe("identity resolution", () => { + it("keeps a valid DID local", async () => { + const fetcher = vi.fn(); + await expect(resolveIdentity(" did:plc:abc123 ", fetcher)).resolves.toEqual( + { + did: "did:plc:abc123", + }, + ); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("resolves a handle to its canonical DID", async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ did: "did:plc:abc123", handle: "alice.example" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + + await expect(resolveIdentity("@Alice.Example", fetcher)).resolves.toEqual({ + did: "did:plc:abc123", + handle: "alice.example", + }); + const requestUrl = new URL(String(fetcher.mock.calls[0][0])); + expect(requestUrl.origin).toBe("https://slingshot.microcosm.blue"); + expect(requestUrl.searchParams.get("identifier")).toBe("alice.example"); + }); + + it("rejects malformed resolver responses", async () => { + const fetcher = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ did: "not-a-did" }), { status: 200 }), + ); + + await expect( + resolveIdentity("alice.example", fetcher), + ).rejects.toBeInstanceOf(IdentityResolutionError); + }); + + it("turns resolver failures into a useful identity error", async () => { + const fetcher = vi + .fn() + .mockRejectedValue(new TypeError("offline")); + await expect(resolveIdentity("alice.example", fetcher)).rejects.toThrow( + "could not be resolved right now", + ); + }); +}); diff --git a/src/lib/identity.ts b/src/lib/identity.ts new file mode 100644 index 0000000..f9ccb60 --- /dev/null +++ b/src/lib/identity.ts @@ -0,0 +1,114 @@ +import { isDid } from "./shape"; + +const RESOLVER_ENDPOINT = + "https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc"; +const MAX_RESPONSE_BYTES = 32_768; + +export interface ResolvedIdentity { + did: string; + handle?: string; +} + +export class IdentityResolutionError extends Error { + constructor(message: string) { + super(message); + this.name = "IdentityResolutionError"; + } +} + +export function normalizeHandle(value: string): string { + const trimmed = value.trim(); + return (trimmed.startsWith("@") ? trimmed.slice(1) : trimmed).toLowerCase(); +} + +export function isHandle(value: string): boolean { + const handle = normalizeHandle(value); + if (handle.length < 3 || handle.length > 253 || !handle.includes(".")) { + return false; + } + + const labels = handle.split("."); + return labels.every( + (label) => + label.length > 0 && + label.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export async function resolveIdentity( + value: string, + fetcher: typeof fetch = fetch, +): Promise { + const identifier = value.trim(); + if (isDid(identifier)) return { did: identifier }; + if (!isHandle(identifier)) { + throw new IdentityResolutionError( + "Enter a complete DID or handle, such as did:plc:… or alice.example.", + ); + } + + const handle = normalizeHandle(identifier); + const url = new URL(RESOLVER_ENDPOINT); + url.searchParams.set("identifier", handle); + + let response: Response; + try { + response = await fetcher(url, { + headers: { accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(8_000), + }); + } catch { + throw new IdentityResolutionError( + "That handle could not be resolved right now. Check it and try again.", + ); + } + + if (!response.ok) { + throw new IdentityResolutionError( + response.status === 404 + ? "No decentralised identity was found for that handle." + : "That handle could not be resolved right now. Check it and try again.", + ); + } + + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + throw new IdentityResolutionError( + "The identity resolver returned an invalid response.", + ); + } + + const body = await response.text(); + if (new TextEncoder().encode(body).byteLength > MAX_RESPONSE_BYTES) { + throw new IdentityResolutionError( + "The identity resolver returned an invalid response.", + ); + } + + let payload: unknown; + try { + payload = JSON.parse(body); + } catch { + throw new IdentityResolutionError( + "The identity resolver returned an invalid response.", + ); + } + + if ( + !isRecord(payload) || + typeof payload.did !== "string" || + !isDid(payload.did) + ) { + throw new IdentityResolutionError( + "The identity resolver returned an invalid response.", + ); + } + + return { did: payload.did, handle }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 298656e..383ee63 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,8 @@