",
+ "classified_as": [{ "id": "http://vocab.getty.edu/aat/300435704", "_label": "checksum" }] }],
+ "subject_to": [{ "type": "Right",
+ "classified_as": [{ "id": "https://creativecommons.org/publicdomain/zero/1.0/", "_label": "CC0" }] }],
+ "carries": [{ "type": "LinguisticObject", "content": "(wipe blue) (ink red) ...",
+ "classified_as": [{ "id": "http://vocab.getty.edu/aat/300028676", "_label": "source code" }] }]
+}
+```
+Source fields: `pieces.code`, `.source`, `.hash`, `.when`, `.user`, `.trustLevel`
+(only `trusted` pieces broadcast — natural spam gate).
+
+### 4.4 Mood → `E33 Linguistic Object` + `E65 Creation`
+
+Easiest map; already has `atproto.rkey` permalink and a Bluesky cross-ref we
+can surface as `equivalent`/`subject_of`.
+
+```json
+{
+ "@context": "https://linked.art/ns/v1/linked-art.json",
+ "id": "https://aesthetic.computer/moods~sat~3k...",
+ "type": "LinguisticObject",
+ "_label": "mood by @sat",
+ "classified_as": [{ "id": "http://vocab.getty.edu/aat/300026032", "_label": "statements" }],
+ "content": "feeling generative today",
+ "language": [{ "id": "http://vocab.getty.edu/aat/300388277", "_label": "English" }],
+ "created_by": {
+ "type": "Creation",
+ "carried_out_by": [{ "id": "https://aesthetic.computer/@sat", "type": "Person" }],
+ "timespan": { "type": "TimeSpan", "begin_of_the_begin": "2026-03-15T00:00:00Z" }
+ },
+ "subject_of": [{ "type": "DigitalObject",
+ "access_point": [{ "id": "at://did:plc:.../app.bsky.feed.post/..." }] }]
+}
+```
+Source fields: `moods.mood`, `.when`, `.user`, `.atproto.rkey`, `.bluesky.uri`.
+Skip `deleted:true`.
+
+---
+
+## 5. Consent & rights — **the gate (Stage 0, non-negotiable)**
+
+Getty pipelines *reject objects with no rights statement*, and broadcasting a
+user's full corpus to the permanent semantic web demands explicit, revocable
+consent. Mirror the Bluesky allowlist exactly:
+
+- Add `@handles.linkedData = { enabled: bool, license: "CC-BY-4.0" | "CC0-1.0" | ... , optInAt: Date }`
+ (or a `secrets.crm.handles` allowlist for a curated v1).
+- **Nothing without a license.** No flag → entity 404s for `ld+json` and is
+ absent from SPARQL.
+- Revocation removes triples from the store and flips `ld+json` to 410 Gone.
+- Privacy floor: never emit email, Auth0 `sub`, IP/boot telemetry, chat, or
+ `verifications`. Only public handle + the four content types above.
+
+A `prompt`-driven command (`linkdata on cc-by` / `linkdata off`) is the natural
+user-facing toggle, parallel to how moods are posted.
+
+---
+
+## 6. The `data.aesthetic.computer` subdomain — wired like `at.aesthetic.computer`
+
+The AT subdomain is the exact template (three layers):
+
+1. **Landing page** — `at/landing-page.html` is a human-facing "here's what we
+ offer" page (mission + top users + media feed). LOD gets the sibling
+ `crm/landing-page.html` → served as static `root` for `data.aesthetic.computer/`:
+ what linked open data is, the license, a live SPARQL box, and an example
+ Getty-style federated query.
+2. **Backend service reverse-proxied behind it** — the knot does
+ `reverse_proxy localhost:5555` (`at/knot/infra/Caddyfile`). LOD does the same
+ for Oxigraph: `data.aesthetic.computer/sparql → reverse_proxy localhost:7878`,
+ and `/painting/* /piece/* /mood/* /@*` → `reverse_proxy localhost:8888`
+ (the `crm.mjs` serializer on lith).
+3. **Cloudflare DNS record + a `lith/Caddyfile` host block.** New `data` record
+ in the `aesthetic.computer` zone (add to `lith/DNS.md`), and a block mirroring
+ the existing `@give`/`@papers` pattern:
+
+```caddyfile
+@data host data.aesthetic.computer
+handle @data {
+ handle /sparql* { reverse_proxy localhost:7878 } # Oxigraph
+ handle /.well-known/void { reverse_proxy localhost:8888 }
+ handle /painting/* { reverse_proxy localhost:8888 } # crm.mjs serializer
+ handle /piece/* { reverse_proxy localhost:8888 }
+ handle /mood/* { reverse_proxy localhost:8888 }
+ handle /@* { reverse_proxy localhost:8888 }
+ handle { # static landing
+ root * /opt/ac/system/public/data.aesthetic.computer
+ file_server
+ }
+}
+```
+
+Stage 1 only needs layers 1 + 3 + the `localhost:8888` routes (no Oxigraph);
+Stage 2 adds the `localhost:7878` proxy. Same staging as below.
+
+## 7. Architecture — staged rollout
+
+### Stage 1 — Subdomain + content negotiation → Linked Art (cheapest, no triple store)
+- `data` DNS record + `@data` Caddy block (§6) + static `crm/landing-page.html`.
+- New function `system/netlify/functions/crm.mjs` (routes `/painting/{code}`,
+ `/piece/{code}`, `/mood/{handle}/{rkey}`, `/@{handle}`) + a serializer lib
+ `system/backend/linked-art.mjs` (`paintingToLinkedArt`, `pieceToLinkedArt`,
+ `moodToLinkedArt`, `personToLinkedArt`).
+- Reuses current Mongo indexes (`paintings.code`, `pieces.code`,
+ `moods.atproto.rkey`). **Pure serializer over data we already have. No triple
+ store, no new process — just lith.**
+- Validate output against linked.art tooling + a JSON-LD playground.
+
+### Stage 2 — SPARQL endpoint
+- Stand up **[Oxigraph](https://github.com/oxigraph/oxigraph)** (Rust, single
+ binary — runs on **lith** at `localhost:7878`; the `@data` block already
+ proxies `/sparql` to it).
+- ETL worker feeds it from the **existing `_firehose` change stream** (30-day
+ TTL already in Mongo): on insert/update of an opted-in painting/piece/mood,
+ map → triples → `INSERT`/`DELETE` in the store. This is the literal analog of
+ the outbound Bluesky worker.
+- Publish a VoID dataset description at `data.aesthetic.computer/.well-known/void`.
+
+### Stage 3 — Discovery & images
+- Sitemap of entity URIs; `robots`/`llms.txt` note pointing crawlers to the LOD.
+- **IIIF Image API** over the painting CDN — Getty's image tooling is
+ IIIF-native; this is high-leverage for paintings specifically.
+- Optional: OAI-PMH feed for harvesters; federated-query examples in docs.
+
+---
+
+## 8. Decisions
+
+**Resolved:**
+- **Domain** → `data.aesthetic.computer` (mirrors `data.getty.edu`). ✓
+- **Store** → Oxigraph on lith (`localhost:7878`). ✓
+
+**Still open:**
+1. **v1 allowlist vs. self-serve** — curated `secrets.crm.handles` to start
+ (like Bluesky's `mirrorHandles`), open the `linkdata` command later.
+2. **License menu** — which CC licenses to offer; default per entity type
+ (paintings CC BY, pieces CC0?).
+3. **AAT term review** — confirm the chosen Getty AAT ids with a CRM-literate
+ reviewer before going public.
+
+---
+
+## 9. Build order
+
+1. ✅ **Stage 0 + serializer lib** — `linkedData` consent gate (in `crm.mjs`) +
+ `linked-art.mjs` with all four mappers + `node --test` fixtures (6 passing).
+2. ✅ **Stage 1** — `@data` Caddy block + lith host rewrite + `crm.mjs` serving
+ Linked Art + inline landing + VoID. *Pending:* Cloudflare `data` record +
+ deploy + external validation against linked.art tooling.
+3. ⬜ **Stage 2** — Oxigraph on lith (`localhost:7878`) + `_firehose` ETL +
+ `/sparql` + flip the Caddy `/sparql*` handle to `:7878`.
+4. ⬜ **Stage 3** — IIIF + sitemap + docs with a worked Getty-style federated query.
diff --git a/lith/Caddyfile b/lith/Caddyfile
index 2188df7a54..57bef937fc 100644
--- a/lith/Caddyfile
+++ b/lith/Caddyfile
@@ -165,6 +165,15 @@
}
}
+ # --- data.aesthetic.computer (Linked Open Data / CIDOC CRM) ---
+ # lith rewrites every path here to the `crm` function, which dispatches by
+ # path (landing, /@handle, /painting, /piece, /mood, /.well-known/void).
+ # Stage 2 adds: handle /sparql* { reverse_proxy localhost:7878 } # Oxigraph
+ @data host data.aesthetic.computer
+ handle @data {
+ reverse_proxy localhost:8888
+ }
+
# --- news.aesthetic.computer ---
@news host news.aesthetic.computer
handle @news {
diff --git a/lith/DNS.md b/lith/DNS.md
index 17b613f379..4808bdfbc7 100644
--- a/lith/DNS.md
+++ b/lith/DNS.md
@@ -7,7 +7,8 @@ The primary frontend origin is `209.38.133.33`.
## Frontend Records On lith
-- `aesthetic.computer` zone: `aesthetic.computer`, `api`, `bills`, `feed`, `give`, `keeps`, `l5`, `news`, `p5`, `pals`, `papers`, `processing`, `sitemap`, `www`
+- `aesthetic.computer` zone: `aesthetic.computer`, `api`, `bills`, `data`, `feed`, `give`, `keeps`, `l5`, `news`, `p5`, `pals`, `papers`, `processing`, `sitemap`, `www`
+ - `data` (A → 209.38.133.33, proxied) added 2026-06-29 — Linked Open Data / CIDOC CRM endpoint (`crm.mjs`); needs lith deploy to serve.
- `false.work` zone: `builds.false.work`
- `jas.life` zone: `jas.life`
- `justanothersystem.org` zone: `justanothersystem.org`, `www`
diff --git a/lith/server.mjs b/lith/server.mjs
index b4d288eb92..4855f44635 100644
--- a/lith/server.mjs
+++ b/lith/server.mjs
@@ -179,6 +179,15 @@ app.use((req, _res, next) => {
}
}
+ // data.aesthetic.computer → crm function (Linked Open Data / CIDOC CRM).
+ // Every path under the subdomain routes to the `crm` handler, which dispatches
+ // by path internally (landing, /@handle, /painting, /piece, /mood, /sparql).
+ if (host === "data.aesthetic.computer" || host === "data.prompt.ac") {
+ if (!req.path.startsWith("/api/crm")) {
+ req.url = "/api/crm" + (req.url === "/" ? "" : req.url);
+ }
+ }
+
next();
});
diff --git a/system/backend/linked-art.mjs b/system/backend/linked-art.mjs
new file mode 100644
index 0000000000..9e192ad40e
--- /dev/null
+++ b/system/backend/linked-art.mjs
@@ -0,0 +1,188 @@
+// linked-art.mjs
+// Serialize Aesthetic Computer entities to Linked Art — the JSON-LD profile of
+// CIDOC CRM that Getty and peer cultural-heritage institutions actually ingest.
+// This is the read-side sibling of bluesky-mirror.mjs: instead of broadcasting a
+// mood to a social firehose, we expose opted-in works as linked open data.
+//
+// These functions are PURE: they take already-resolved plain fields and return a
+// JSON-LD object. All MongoDB lookups + the consent/rights gate live in the
+// crm.mjs function. Keeping them pure makes them unit-testable against fixtures.
+//
+// Canonical identity URIs live on data.aesthetic.computer; each carries a link
+// back to the human-facing page on the apex. See crm/SCORE.md.
+// 2026.06.29
+
+export const LA_CONTEXT = "https://linked.art/ns/v1/linked-art.json";
+export const DATA_BASE = "https://data.aesthetic.computer";
+export const WEB_BASE = "https://aesthetic.computer";
+
+// Getty AAT vocabulary terms. NOTE: these ids are pending a review by a
+// CRM-literate reviewer before going public (crm/SCORE.md §8, open decision 3).
+const AAT = {
+ primaryName: ["300404670", "primary name"],
+ paintings: ["300033973", "paintings (visual works)"],
+ digitalImages: ["300312038", "digital images"],
+ software: ["300265727", "software"],
+ computerArt: ["300047090", "computer art"],
+ sourceCode: ["300028676", "source code"],
+ statements: ["300026032", "statements (document genres)"],
+ english: ["300388277", "English"],
+ checksum: ["300435704", "checksum"],
+};
+
+// Build a Getty AAT classification node.
+function aat(key) {
+ const [id, label] = AAT[key];
+ return { id: `http://vocab.getty.edu/aat/${id}`, type: "Type", _label: label };
+}
+
+// Supported licenses → Creative Commons URIs. A work cannot be broadcast
+// without one (Getty pipelines reject objects lacking a rights statement).
+export const LICENSES = {
+ "CC0-1.0": ["https://creativecommons.org/publicdomain/zero/1.0/", "CC0 1.0 Universal"],
+ "CC-BY-4.0": ["https://creativecommons.org/licenses/by/4.0/", "CC BY 4.0"],
+ "CC-BY-SA-4.0": ["https://creativecommons.org/licenses/by-sa/4.0/", "CC BY-SA 4.0"],
+ "CC-BY-NC-4.0": ["https://creativecommons.org/licenses/by-nc/4.0/", "CC BY-NC 4.0"],
+};
+
+export function isLicense(key) {
+ return Object.prototype.hasOwnProperty.call(LICENSES, key);
+}
+
+// E30 Right → a `subject_to` rights statement.
+function rights(license) {
+ const entry = LICENSES[license];
+ if (!entry) return [];
+ const [id, label] = entry;
+ return [{ type: "Right", _label: label, classified_as: [{ id, type: "Type", _label: label }] }];
+}
+
+// E52 Time-Span from a Date or ISO string.
+function timespan(when) {
+ if (!when) return undefined;
+ const iso = when instanceof Date ? when.toISOString() : new Date(when).toISOString();
+ return { type: "TimeSpan", begin_of_the_begin: iso, end_of_the_end: iso };
+}
+
+// The author as an E21 Person reference (the hub every work points back to).
+function personRef(handle) {
+ const clean = handle.replace(/^@/, "");
+ return { id: `${DATA_BASE}/@${clean}`, type: "Person", _label: `@${clean}` };
+}
+
+// E12 Production / E65 Creation event carrying authorship + time.
+function madeBy(type, handle, when, place = true) {
+ const ev = { type, carried_out_by: [personRef(handle)] };
+ const ts = timespan(when);
+ if (ts) ev.timespan = ts;
+ if (place) {
+ ev.took_place_at = [{ id: WEB_BASE, type: "Place", _label: "Aesthetic Computer" }];
+ }
+ return ev;
+}
+
+// A `subject_of` / seeAlso link back to the human-facing apex page.
+function seeAlso(webUrl, label) {
+ return {
+ type: "LinguisticObject",
+ _label: label,
+ digitally_carried_by: [
+ { type: "DigitalObject", access_point: [{ id: webUrl, type: "DigitalObject" }] },
+ ],
+ };
+}
+
+// ⸻ Person — @sat → E21 Person ⸻
+export function personToLinkedArt({ handle, latestMood } = {}) {
+ const clean = handle.replace(/^@/, "");
+ const doc = {
+ "@context": LA_CONTEXT,
+ id: `${DATA_BASE}/@${clean}`,
+ type: "Person",
+ _label: `@${clean}`,
+ identified_by: [
+ { type: "Name", content: `@${clean}`, classified_as: [aat("primaryName")] },
+ ],
+ subject_of: [seeAlso(`${WEB_BASE}/@${clean}`, "Aesthetic Computer profile")],
+ };
+ if (latestMood?.mood) {
+ doc.subject_of.push({
+ type: "LinguisticObject",
+ _label: "latest mood",
+ classified_as: [aat("statements")],
+ content: latestMood.mood,
+ });
+ }
+ return doc;
+}
+
+// ⸻ Painting → E22 Human-Made Object / D1 Digital Object ⸻
+export function paintingToLinkedArt({ code, handle, when, imageUrl, license } = {}) {
+ const clean = handle.replace(/^@/, "");
+ return {
+ "@context": LA_CONTEXT,
+ id: `${DATA_BASE}/painting/${code}`,
+ type: "DigitalObject",
+ _label: `painting ${code} by @${clean}`,
+ classified_as: [aat("paintings"), aat("digitalImages")],
+ produced_by: madeBy("Production", handle, when),
+ subject_to: rights(license),
+ subject_of: [seeAlso(`${WEB_BASE}/painting/${code}`, "view on Aesthetic Computer")],
+ digitally_shown_by: imageUrl
+ ? [
+ {
+ type: "DigitalObject",
+ format: "image/png",
+ access_point: [{ id: imageUrl, type: "DigitalObject" }],
+ },
+ ]
+ : undefined,
+ };
+}
+
+// ⸻ Piece (KidLisp / .mjs) → E73 Information Object + E65 Creation ⸻
+export function pieceToLinkedArt({ code, handle, when, source, hash, license } = {}) {
+ const clean = handle.replace(/^@/, "");
+ const doc = {
+ "@context": LA_CONTEXT,
+ id: `${DATA_BASE}/piece/${code}`,
+ type: "DigitalObject",
+ _label: `piece $${code} by @${clean}`,
+ classified_as: [aat("software"), aat("computerArt")],
+ created_by: madeBy("Creation", handle, when),
+ subject_to: rights(license),
+ subject_of: [seeAlso(`${WEB_BASE}/${code}`, "run on Aesthetic Computer")],
+ };
+ if (hash) {
+ doc.identified_by = [
+ { type: "Identifier", content: hash, classified_as: [aat("checksum")] },
+ ];
+ }
+ if (source) {
+ doc.carries = [
+ { type: "LinguisticObject", content: source, classified_as: [aat("sourceCode")] },
+ ];
+ }
+ return doc;
+}
+
+// ⸻ Mood → E33 Linguistic Object + E65 Creation ⸻
+export function moodToLinkedArt({ handle, rkey, mood, when, blueskyUri, license } = {}) {
+ const clean = handle.replace(/^@/, "");
+ const doc = {
+ "@context": LA_CONTEXT,
+ id: `${DATA_BASE}/mood/${clean}/${rkey}`,
+ type: "LinguisticObject",
+ _label: `mood by @${clean}`,
+ classified_as: [aat("statements")],
+ content: mood,
+ language: [aat("english")],
+ created_by: madeBy("Creation", handle, when, false),
+ subject_to: rights(license),
+ subject_of: [seeAlso(`${WEB_BASE}/moods~${clean}~${rkey}`, "view on Aesthetic Computer")],
+ };
+ if (blueskyUri) {
+ doc.equivalent = [{ id: blueskyUri, type: "LinguisticObject", _label: "Bluesky post" }];
+ }
+ return doc;
+}
diff --git a/system/netlify/functions/crm.mjs b/system/netlify/functions/crm.mjs
new file mode 100644
index 0000000000..839b43fbec
--- /dev/null
+++ b/system/netlify/functions/crm.mjs
@@ -0,0 +1,264 @@
+// 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,
+ isLicense,
+ personToLinkedArt,
+ paintingToLinkedArt,
+ pieceToLinkedArt,
+ moodToLinkedArt,
+ DATA_BASE,
+ WEB_BASE,
+} from "../../backend/linked-art.mjs";
+
+const dev = process.env.CONTEXT === "dev" || process.env.NETLIFY_DEV === "true";
+const JSONLD = "application/ld+json";
+
+// 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);
+}
+
+// Read the consent + license for a handle (without the leading @).
+async function consentFor(database, handle) {
+ if (!handle) return null;
+ const doc = await database.db
+ .collection("@handles")
+ .findOne({ handle: handle.replace(/^@/, "") });
+ const ld = doc?.linkedData;
+ if (!ld?.enabled || !isLicense(ld.license)) return null;
+ return { handle: doc.handle, license: ld.license };
+}
+
+// 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 });
+}
+
+// A minimal human view that points at both the JSON-LD and the apex page.
+function htmlView(doc, webUrl) {
+ const body = `${doc._label}
+
+${doc._label}
+Linked Art (CIDOC CRM) identity on data.aesthetic.computer.
+
+${JSON.stringify(doc, null, 2).replace(/`;
+ 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);
+
+ // Landing page.
+ if (segs.length === 0) return landing();
+
+ // 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();
+
+ // Person: /@handle
+ if (segs[0].startsWith("@")) {
+ const handle = segs[0].slice(1);
+ const consent = previewLicense(event) ? { handle, license: previewLicense(event) } : await consentFor(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}`);
+ }
+
+ // Painting: /painting/{code}
+ if (segs[0] === "painting" && segs[1]) {
+ const p = await database.db.collection("paintings").findOne({ code: segs[1] });
+ if (!p || p.nuked) return respond(404, { message: "Not found" });
+ const handle = await handleFor(p.user);
+ const consent = previewLicense(event) ? { handle, license: previewLicense(event) } : await consentFor(database, handle);
+ if (!consent || !handle) return respond(404, { message: "Not found" });
+
+ const slug = String(p.slug || "").split("/").pop();
+ const imageUrl = `${WEB_BASE}/media/@${handle}/painting/${slug}.png`;
+ const doc = paintingToLinkedArt({ code: p.code, handle, when: p.when, imageUrl, license: consent.license });
+ return finish(event, doc, `${WEB_BASE}/painting/${p.code}`);
+ }
+
+ // 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 consentFor(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}`);
+ }
+
+ // 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 consentFor(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}`);
+ }
+
+ 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;
+}
+
+function finish(event, doc, webUrl) {
+ return wantsHtml(event) ? htmlView(doc, webUrl) : jsonld(doc);
+}
+
+function voidDoc() {
+ const licenses = 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":
+ "Opted-in paintings, pieces, moods, and people from Aesthetic Computer, " +
+ "expressed as Linked Art (a JSON-LD profile of CIDOC CRM).",
+ "dcterms:license": licenses,
+ "void:uriSpace": `${DATA_BASE}/`,
+ "void:rootResource": DATA_BASE,
+ });
+}
+
+function landing() {
+ const body = `
+
+data · Aesthetic Computer
+
+
+data.aesthetic.computer
+Aesthetic Computer publishes its community's opted-in works as
+Linked Art — the JSON-LD profile of
+CIDOC CRM used across the cultural-heritage
+world. This makes paintings, pieces, moods, and people here discoverable and
+citable by researchers (e.g. at the Getty) as linked open data.
+
+Entity identifiers
+
+${DATA_BASE}/@{handle} — a person (E21)
+${DATA_BASE}/painting/{code} — a digital painting (E22)
+${DATA_BASE}/piece/{code} — a KidLisp / JavaScript piece (E73)
+${DATA_BASE}/mood/{handle}/{rkey} — a mood (E33)
+
+Request Accept: application/ld+json (or append
+?format=jsonld) for the Linked Art representation; a browser gets a
+human-readable view that links back to the work on aesthetic.computer.
+
+Consent & rights
+Only works whose author has opted in — with an explicit Creative Commons
+license — are published here. Everything else returns 404.
+
+Coming soon
+
+${DATA_BASE}/sparql — SPARQL query endpoint (Stage 2)
+- /.well-known/void — dataset description
+
`;
+ return respond(200, body, { "Content-Type": "text/html; charset=utf-8" });
+}
diff --git a/system/tests/linked-art.test.mjs b/system/tests/linked-art.test.mjs
new file mode 100644
index 0000000000..e1729bd1e6
--- /dev/null
+++ b/system/tests/linked-art.test.mjs
@@ -0,0 +1,91 @@
+// linked-art.test.mjs — validate the Linked Art serializers (crm/SCORE.md Stage 1).
+// Run: node --test system/tests/linked-art.test.mjs
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ personToLinkedArt,
+ paintingToLinkedArt,
+ pieceToLinkedArt,
+ moodToLinkedArt,
+ LICENSES,
+ isLicense,
+ LA_CONTEXT,
+ DATA_BASE,
+ WEB_BASE,
+} from "../backend/linked-art.mjs";
+
+test("license registry", () => {
+ assert.ok(isLicense("CC-BY-4.0"));
+ assert.ok(isLicense("CC0-1.0"));
+ assert.ok(!isLicense("All Rights Reserved"));
+ assert.equal(LICENSES["CC-BY-4.0"][0], "https://creativecommons.org/licenses/by/4.0/");
+});
+
+test("person → E21 Person with seeAlso back to apex", () => {
+ const doc = personToLinkedArt({ handle: "@sat", latestMood: { mood: "generative" } });
+ assert.equal(doc["@context"], LA_CONTEXT);
+ assert.equal(doc.id, `${DATA_BASE}/@sat`);
+ assert.equal(doc.type, "Person");
+ assert.equal(doc.identified_by[0].content, "@sat");
+ const link = doc.subject_of.find((s) => s._label === "Aesthetic Computer profile");
+ assert.equal(link.digitally_carried_by[0].access_point[0].id, `${WEB_BASE}/@sat`);
+ assert.ok(doc.subject_of.some((s) => s.content === "generative"));
+});
+
+test("painting → DigitalObject with Production, author, rights, image", () => {
+ const when = new Date("2026-03-15T12:00:00Z");
+ const doc = paintingToLinkedArt({
+ code: "Abc123",
+ handle: "sat",
+ when,
+ imageUrl: `${WEB_BASE}/media/@sat/painting/2026.3.15.png`,
+ license: "CC-BY-4.0",
+ });
+ assert.equal(doc.id, `${DATA_BASE}/painting/Abc123`);
+ assert.equal(doc.type, "DigitalObject");
+ assert.equal(doc.produced_by.type, "Production");
+ assert.equal(doc.produced_by.carried_out_by[0].id, `${DATA_BASE}/@sat`);
+ assert.equal(doc.produced_by.timespan.begin_of_the_begin, when.toISOString());
+ assert.equal(doc.subject_to[0].classified_as[0].id, "https://creativecommons.org/licenses/by/4.0/");
+ assert.equal(doc.digitally_shown_by[0].format, "image/png");
+ // Every Getty AAT classification must resolve to a real AAT URI.
+ for (const c of doc.classified_as) assert.match(c.id, /vocab\.getty\.edu\/aat\/\d+$/);
+});
+
+test("piece → E73 with source LinguisticObject + checksum identifier", () => {
+ const doc = pieceToLinkedArt({
+ code: "cow",
+ handle: "sat",
+ when: new Date("2026-03-15T12:00:00Z"),
+ source: "(wipe blue)",
+ hash: "deadbeef",
+ license: "CC0-1.0",
+ });
+ assert.equal(doc.id, `${DATA_BASE}/piece/cow`);
+ assert.equal(doc.created_by.type, "Creation");
+ assert.equal(doc.identified_by[0].content, "deadbeef");
+ assert.equal(doc.carries[0].content, "(wipe blue)");
+ assert.equal(doc.subject_to[0].classified_as[0].id, "https://creativecommons.org/publicdomain/zero/1.0/");
+ assert.equal(doc.subject_of[0].digitally_carried_by[0].access_point[0].id, `${WEB_BASE}/cow`);
+});
+
+test("mood → E33 LinguisticObject, Bluesky cross-ref, apex permalink", () => {
+ const doc = moodToLinkedArt({
+ handle: "sat",
+ rkey: "3kabc",
+ mood: "feeling generative",
+ when: new Date("2026-03-15T12:00:00Z"),
+ blueskyUri: "at://did:plc:xyz/app.bsky.feed.post/3kabc",
+ license: "CC-BY-4.0",
+ });
+ assert.equal(doc.id, `${DATA_BASE}/mood/sat/3kabc`);
+ assert.equal(doc.type, "LinguisticObject");
+ assert.equal(doc.content, "feeling generative");
+ assert.equal(doc.equivalent[0].id, "at://did:plc:xyz/app.bsky.feed.post/3kabc");
+ assert.equal(doc.subject_of[0].digitally_carried_by[0].access_point[0].id, `${WEB_BASE}/moods~sat~3kabc`);
+});
+
+test("no license → no rights statement (gate is upstream, serializer stays honest)", () => {
+ const doc = paintingToLinkedArt({ code: "x", handle: "sat", when: new Date(), license: undefined });
+ assert.deepEqual(doc.subject_to, []);
+});