// crm.mjs — Linked Open Data endpoint for data.aesthetic.computer // Serves opted-in AC works as Linked Art (JSON-LD profile of CIDOC CRM) so // cultural-heritage researchers (Getty, museums, the American Art Collaborative) // can discover, cite, and federate over them. Read-side sibling of the Bluesky // mirror. Full design: crm/SCORE.md. // // Routing (host data.aesthetic.computer is rewritten to /api/crm/* by lith): // / → landing page (HTML) // /@{handle} → Person (E21) // /painting/{code} → DigitalObject (E22) // /piece/{code} → DigitalObject (E73) // /mood/{handle}/{rkey} → LinguisticObject (E33) // /.well-known/void → VoID dataset description // /sparql → 501 until Stage 2 (Oxigraph) // // Consent gate (Stage 0): a work is only exposed if its owning handle has // opted in — `@handles.linkedData = { enabled: true, license: "CC-BY-4.0" }`. // No opt-in (or no license) → 404, so we never leak the existence of private work. // 2026.06.29 import { connect } from "../../backend/database.mjs"; import { respond } from "../../backend/http.mjs"; import { handleFor } from "../../backend/authorization.mjs"; import { LICENSES, RIGHTS_STATEMENTS, DEFAULT_RIGHTS, isLicense, personToLinkedArt, paintingToLinkedArt, pieceToLinkedArt, moodToLinkedArt, paintingImageUrl, DATA_BASE, WEB_BASE, } from "../../backend/linked-art.mjs"; import { paintingManifest, imageInfo, pngDimensions } from "../../backend/iiif.mjs"; const dev = process.env.CONTEXT === "dev" || process.env.NETLIFY_DEV === "true"; const JSONLD = "application/ld+json"; // The AC wordmark — "Aesthetic.Computer" with a pink dot (HTML; body use only). const AC = `Aesthetic.Computer`; // Strip the function prefix to get the entity path segments. // "/api/crm/painting/Abc123" → ["painting", "Abc123"] function segmentsFrom(path) { let p = (path || "/").split("?")[0]; p = p.replace(/^\/+/, ""); if (p.startsWith("api/crm")) p = p.slice("api/crm".length); else if (p.startsWith("crm")) p = p.slice("crm".length); return p.split("/").map(decodeURIComponent).filter(Boolean); } // Resolve the rights a handle's works are published under. // • handle doesn't exist → null (nothing to show) // • explicit opt-out (enabled === false) → null (excluded by the artist) // • opted in with a CC license → that license (open reuse) // • any other public handle (default) → "InC" In Copyright: the record is // open and citable, but copyright + the right to sell stay with the artist. async function rightsFor(database, handle) { if (!handle) return null; const doc = await database.db .collection("@handles") .findOne({ handle: handle.replace(/^@/, "") }); if (!doc) return null; const ld = doc.linkedData; if (ld?.enabled === false) return null; const license = isLicense(ld?.license) ? ld.license : DEFAULT_RIGHTS; return { handle: doc.handle, license, colors: doc.colors || [] }; } // In dev, ?preview=CC-BY-4.0 bypasses the gate so we can validate serialization // without flipping live data. Never active in production. function previewLicense(event) { if (!dev) return null; const v = event.queryStringParameters?.preview; if (v && isLicense(v)) return v; if (v === "1" || v === "true") return "CC-BY-4.0"; return null; } // Prefer HTML only when the client clearly asks for it over JSON-LD. function wantsHtml(event) { if (event.queryStringParameters?.format === "jsonld") return false; if (event.queryStringParameters?.format === "html") return true; const accept = event.headers?.accept || event.headers?.Accept || ""; return accept.includes("text/html") && !accept.includes(JSONLD); } function jsonld(doc) { return respond(200, doc, { "Content-Type": JSONLD }); } // Render a handle as its AC sigil: each glyph of "@handle" tinted by the // per-character colors stored in @handles.colors. function sigil(handle, colors) { if (!handle) return ""; const clean = handle.replace(/^@/, ""); const glyphs = [...`@${clean}`] .map((ch, i) => { const c = colors?.[i]; const col = c ? `rgb(${c.r},${c.g},${c.b})` : "var(--accent)"; return `${esc(ch)}`; }) .join(""); return `${glyphs}`; } // A rich human view of a record — the handle sigil, the painting image, rights, // and links to the JSON-LD / IIIF / apex. Styled to match the landing (auto // light/dark). Machines still get JSON-LD via content negotiation. function htmlView(doc, webUrl, extras = {}) { const { kind, handle, colors, imageUrl, code } = extras; const rights = doc.subject_to?.[0]; const rightsHtml = rights ? `

Rights: ${esc(rights._label)}

` : ""; const img = imageUrl ? `${esc(doc._label)}` : ""; const quote = doc.type === "LinguisticObject" && doc.content ? `
“${esc(doc.content)}”
` : ""; const iiif = kind === "painting" ? `
  • IIIF manifest · open in Mirador ↗
  • ` : ""; const body = ` ${esc(doc._label)} · data data ${handle ? `
    ${sigil(handle, colors)}
    ` : ""}

    ${esc(doc._label)}

    ${img} ${quote} ${rightsHtml} `; return respond(200, body, { "Content-Type": "text/html; charset=utf-8" }); } export async function handler(event) { if (event.httpMethod !== "GET") return respond(405, { message: "Method Not Allowed" }); const segs = segmentsFrom(event.path); // SPARQL is Stage 2. if (segs[0] === "sparql") { return respond(501, { message: "SPARQL endpoint not yet available (Stage 2)." }); } // VoID dataset description. if (segs[0] === ".well-known" && segs[1] === "void") return voidDoc(); let database; try { database = await connect(); // Landing page — lists the opted-in people and their records. if (segs.length === 0) return await landing(database); // Sitemap of every entity URI, for crawlers + harvesters. if (segs[0] === "sitemap.xml") return await sitemap(database); // Person: /@handle if (segs[0].startsWith("@")) { const handle = segs[0].slice(1); const consent = previewLicense(event) ? { handle, license: previewLicense(event) } : await rightsFor(database, handle); if (!consent) return respond(404, { message: "Not found" }); const latestMood = await database.db .collection("moods") .findOne({ user: await subForHandle(database, consent.handle), deleted: { $ne: true } }, { sort: { when: -1 } }); const doc = personToLinkedArt({ handle: consent.handle, latestMood }); return finish(event, doc, `${WEB_BASE}/@${consent.handle}`, { kind: "person", handle: consent.handle, colors: consent.colors, }); } // Painting: /painting/{code} if (segs[0] === "painting" && segs[1]) { const pt = await loadPainting(database, segs[1], event); if (!pt) return respond(404, { message: "Not found" }); const doc = paintingToLinkedArt({ code: pt.code, handle: pt.handle, when: pt.when, imageUrl: pt.imageUrl, license: pt.license }); return finish(event, doc, `${WEB_BASE}/painting/${pt.code}`, { kind: "painting", handle: pt.handle, colors: pt.colors, imageUrl: pt.imageUrl, code: pt.code, }); } // IIIF surfaces: /iiif/{code}/manifest | /info.json | /full/max/0/default.png if (segs[0] === "iiif" && segs[1]) { const pt = await loadPainting(database, segs[1], event); if (!pt) return respond(404, { message: "Not found" }); // The full-image request (IIIF Level 0) redirects to the CDN. if (segs[2] === "full") { return respond(302, "", { Location: pt.imageUrl, "Content-Type": "text/plain" }); } const dims = (await pngDimensions(pt.imageUrl)) || { width: 2048, height: 2048 }; if (segs[2] === "info.json") { return respond(200, imageInfo({ code: pt.code, ...dims }), { "Content-Type": JSONLD }); } // Default (…/manifest or bare /iiif/{code}) → Presentation Manifest. const manifest = paintingManifest({ code: pt.code, handle: pt.handle, when: pt.when, license: pt.license, ...dims, }); return respond(200, manifest, { "Content-Type": JSONLD }); } // Piece: /piece/{code} if (segs[0] === "piece" && segs[1]) { const piece = (await database.db.collection("pieces").findOne({ code: segs[1] })) || (await database.db.collection("kidlisp").findOne({ code: segs[1] })); if (!piece || piece.user == null) return respond(404, { message: "Not found" }); const handle = await handleFor(piece.user); const consent = previewLicense(event) ? { handle, license: previewLicense(event) } : await rightsFor(database, handle); if (!consent || !handle) return respond(404, { message: "Not found" }); const doc = pieceToLinkedArt({ code: piece.code, handle, when: piece.when, source: piece.source, hash: piece.hash, license: consent.license, }); return finish(event, doc, `${WEB_BASE}/${piece.code}`, { kind: "piece", handle, colors: consent.colors, code: piece.code, }); } // Mood: /mood/{handle}/{rkey} if (segs[0] === "mood" && segs[1] && segs[2]) { const handle = segs[1].replace(/^@/, ""); const rkey = segs[2]; const consent = previewLicense(event) ? { handle, license: previewLicense(event) } : await rightsFor(database, handle); if (!consent) return respond(404, { message: "Not found" }); const sub = await subForHandle(database, consent.handle); const mood = await database.db .collection("moods") .findOne({ user: sub, "atproto.rkey": rkey, deleted: { $ne: true } }); if (!mood) return respond(404, { message: "Not found" }); const doc = moodToLinkedArt({ handle: consent.handle, rkey, mood: mood.mood, when: mood.when, blueskyUri: mood.bluesky?.uri, license: consent.license, }); return finish(event, doc, `${WEB_BASE}/moods~${consent.handle}~${rkey}`, { kind: "mood", handle: consent.handle, colors: consent.colors, }); } return respond(404, { message: "Not found" }); } catch (error) { console.error("❌ crm error:", error); return respond(500, { message: error.message || String(error) }); } finally { await database?.disconnect(); } } // Resolve a handle (no @) back to its owning Auth0 sub. async function subForHandle(database, handle) { const doc = await database.db.collection("@handles").findOne({ handle }); return doc?._id; } // Load a painting + its owner's handle/rights/image URL, honoring the rights // gate. Returns null for missing, nuked, handleless, or opted-out. Shared by the // Linked Art record route and the IIIF surfaces. async function loadPainting(database, code, event) { const p = await database.db.collection("paintings").findOne({ code }); if (!p || p.nuked) return null; const handle = await handleFor(p.user); const consent = previewLicense(event) ? { handle, license: previewLicense(event) } : await rightsFor(database, handle); if (!consent || !handle) return null; return { code: p.code, handle, when: p.when, license: consent.license, colors: consent.colors, imageUrl: paintingImageUrl(handle, p.slug, p.user), }; } // XML sitemap of every published entity URI (persons, paintings, pieces, moods), // so crawlers and cultural-heritage harvesters can discover the whole dataset. async function sitemap(database) { const db = database.db; const handles = await db.collection("@handles").find({}).toArray(); const bySub = new Map(); const urls = []; for (const h of handles) { if (!h.handle || h.linkedData?.enabled === false) continue; bySub.set(h._id, h.handle); urls.push(`${DATA_BASE}/@${h.handle}`); } const push = (u) => urls.push(u); for await (const p of db.collection("paintings").find({ nuked: { $ne: true }, user: { $ne: null } }, { projection: { code: 1, user: 1 } })) { if (bySub.has(p.user)) push(`${DATA_BASE}/painting/${p.code}`); } for await (const pc of db.collection("pieces").find({ user: { $ne: null } }, { projection: { code: 1, user: 1 } })) { if (bySub.has(pc.user)) push(`${DATA_BASE}/piece/${pc.code}`); } for await (const m of db .collection("moods") .find({ deleted: { $ne: true }, "atproto.rkey": { $exists: true }, user: { $ne: null } }, { projection: { user: 1, atproto: 1 } })) { const handle = bySub.get(m.user); if (handle) push(`${DATA_BASE}/mood/${handle}/${m.atproto.rkey}`); } const body = `\n` + `\n` + urls.map((u) => ` ${u.replace(/&/g, "&")}`).join("\n") + `\n\n`; return respond(200, body, { "Content-Type": "application/xml; charset=utf-8" }); } function finish(event, doc, webUrl, extras = {}) { return wantsHtml(event) ? htmlView(doc, webUrl, extras) : jsonld(doc); } function voidDoc() { const rights = [ ...Object.values(RIGHTS_STATEMENTS).map(([id]) => id), ...Object.values(LICENSES).map(([id]) => id), ]; return jsonld({ "@context": { void: "http://rdfs.org/ns/void#", dcterms: "http://purl.org/dc/terms/" }, "@id": `${DATA_BASE}/.well-known/void`, "@type": "void:Dataset", "dcterms:title": "Aesthetic Computer — Linked Open Data", "dcterms:description": "Public paintings, pieces, moods, and people from Aesthetic Computer, " + "expressed as Linked Art (a JSON-LD profile of CIDOC CRM). Records are " + "open and citable; works are In Copyright unless an artist has chosen a " + "Creative Commons license.", "dcterms:rights": rights, "void:uriSpace": `${DATA_BASE}/`, "void:rootResource": DATA_BASE, "void:sparqlEndpoint": `${DATA_BASE}/sparql`, }); } function esc(s) { return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } // Landing page — the human front door. Builds a recent-records feed across ALL // public contributors (opt-outs excluded), each labeled with its rights, and // points researchers at the standards + tooling that can consume the data. // Modeled on at.aesthetic.computer's people+media feed. async function landing(database) { const db = database.db; // Recent painting activity across all attributed (non-anonymous) users. const recent = await db .collection("paintings") .find({ nuked: { $ne: true }, user: { $ne: null } }) .sort({ when: -1 }) .limit(300) .toArray(); // Distinct users in recency order, keeping up to 4 thumbnails each. const order = []; const bySub = new Map(); for (const pt of recent) { if (!bySub.has(pt.user)) { bySub.set(pt.user, []); order.push(pt.user); } const arr = bySub.get(pt.user); if (arr.length < 4) arr.push(pt); } // Resolve handles + rights in one query; drop handleless + opted-out users. const handleDocs = await db.collection("@handles").find({ _id: { $in: order } }).toArray(); const handleBySub = new Map(handleDocs.map((h) => [h._id, h])); const shownSubs = order .filter((sub) => { const h = handleBySub.get(sub); return h?.handle && h.linkedData?.enabled !== false; // exclude opt-outs }) .slice(0, 24); // Total painting counts + latest mood per shown user — one aggregation each. const [countDocs, moodDocs] = await Promise.all([ db.collection("paintings").aggregate([ { $match: { user: { $in: shownSubs }, nuked: { $ne: true } } }, { $group: { _id: "$user", n: { $sum: 1 } } }, ]).toArray(), db.collection("moods").aggregate([ { $match: { user: { $in: shownSubs }, deleted: { $ne: true } } }, { $sort: { when: -1 } }, { $group: { _id: "$user", mood: { $first: "$mood" }, rkey: { $first: "$atproto.rkey" } } }, ]).toArray(), ]); const countBySub = new Map(countDocs.map((c) => [c._id, c.n])); const moodBySub = new Map(moodDocs.map((m) => [m._id, m])); const people = shownSubs.map((sub) => { const h = handleBySub.get(sub); const license = isLicense(h.linkedData?.license) ? h.linkedData.license : DEFAULT_RIGHTS; return { handle: h.handle, license, paintings: bySub.get(sub) || [], mood: moodBySub.get(sub), paintingCount: countBySub.get(sub) || 0, }; }); // Big animated slideshow — every recent painting with a handled owner. const slides = []; for (const pt of recent) { const h = handleBySub.get(pt.user); if (!h?.handle || h.linkedData?.enabled === false) continue; const img = paintingImageUrl(h.handle, pt.slug, pt.user); if (!img) continue; slides.push( ``, ); if (slides.length >= 40) break; } const track = slides.join(""); // Compact people chips beneath the slideshow. const chips = people .map( (p) => `@${esc(p.handle)} ${p.paintingCount}`, ) .join(""); const totalPeople = await db .collection("@handles") .countDocuments({ handle: { $exists: true }, "linkedData.enabled": { $ne: false } }); const sample = slides.length ? recent.find((pt) => handleBySub.get(pt.user)?.handle) : null; const sampleUrl = sample ? `${DATA_BASE}/painting/${sample.code}` : `${DATA_BASE}/@jeffrey`; const body = ` data · Aesthetic Computer data

    data.aesthetic.computer

    ${AC} as linked open data

    Every public painting, piece, mood, and person on ${AC} is published here as Linked Art — the JSON-LD profile of CIDOC CRM — so cultural-heritage researchers (e.g. at the Getty) can discover, query, and cite it with standard tools.

    ${track}${track}

    ${totalPeople.toLocaleString()} people · all works discoverable. Hover to pause; click any painting for its record.

    Access points

    SPARQL
    /sparql
    query the whole CIDOC CRM graph
    Linked Art
    /painting/{code}
    JSON-LD via Accept: ld+json
    IIIF
    /iiif/{code}/manifest
    Presentation v3 + Level-0 image
    Sitemap
    /sitemap.xml
    every entity URI
    VoID
    /.well-known/void
    dataset description
    Bluesky
    at.aesthetic.computer
    same works over ATProto

    Example queries

    Every painting by @jeffrey:

    curl -s '${DATA_BASE}/sparql' --data-urlencode 'query=
     PREFIX crm: <http://www.cidoc-crm.org/cidoc-crm/>
     SELECT ?work WHERE {
       ?work crm:P108i_was_produced_by/crm:P14_carried_out_by
             <${DATA_BASE}/@jeffrey> }'

    Because classifications are shared Getty AAT URIs, our data federates with the Getty's own — e.g. enrich each type with the Getty's preferred label (run from a client that federates both endpoints):

    PREFIX crm: <http://www.cidoc-crm.org/cidoc-crm/>
    PREFIX gvp: <http://vocab.getty.edu/ontology#>
    PREFIX xl:  <http://www.w3.org/2008/05/skos-xl#>
    SELECT DISTINCT ?type ?gettyLabel WHERE {
      SERVICE <${DATA_BASE}/sparql> { ?work crm:P2_has_type ?type
        FILTER(STRSTARTS(STR(?type),"http://vocab.getty.edu/aat/")) }
      SERVICE <https://vocab.getty.edu/sparql> {
        ?type gvp:prefLabelGVP/xl:literalForm ?gettyLabel . } }

    Or fetch one record's JSON-LD directly: curl -H "Accept: application/ld+json" ${esc(sampleUrl)} — inspect it in the JSON-LD Playground.

    People

    ${chips || 'none yet'}

    Rights & opting out

    These records describe work that is already public on aesthetic.computer — authorship, date, type, and a link to the image — much like a museum catalog entry. By default each is marked In Copyright: the record is open and citable, but copyright and the right to sell stay entirely with the artist. Artists may opt in to an open Creative Commons license to allow reuse, or opt out to be excluded — just ask.

    `; return respond(200, body, { "Content-Type": "text/html; charset=utf-8" }); }