From cdeaabf67292cbc2c416da46c34dde8c26394735 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 29 Jun 2026 00:17:26 -0800 Subject: [PATCH] crm: Linked Open Data endpoint (data.aesthetic.computer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the CIDOC CRM / Linked Art broadcast layer — the read-side sibling of the Bluesky mirror. Exposes opted-in AC works as dereferenceable Linked Art (JSON-LD profile of CIDOC CRM) for cultural-heritage researchers. - linked-art.mjs: pure serializers (person/painting/piece/mood) + license registry - crm.mjs: data.aesthetic.computer endpoint — path routing, Stage-0 consent gate (@handles.linkedData), content negotiation, landing, VoID, SPARQL stub - lith server host-rewrite + @data Caddy block - linked-art.test.mjs: 6 passing fixtures - crm/SCORE.md design dossier --- crm/SCORE.md | 320 +++++++++++++++++++++++++++++++ lith/Caddyfile | 9 + lith/DNS.md | 3 +- lith/server.mjs | 9 + system/backend/linked-art.mjs | 188 ++++++++++++++++++ system/netlify/functions/crm.mjs | 264 +++++++++++++++++++++++++ system/tests/linked-art.test.mjs | 91 +++++++++ 7 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 crm/SCORE.md create mode 100644 system/backend/linked-art.mjs create mode 100644 system/netlify/functions/crm.mjs create mode 100644 system/tests/linked-art.test.mjs diff --git a/crm/SCORE.md b/crm/SCORE.md new file mode 100644 index 000000000..7b74c7039 --- /dev/null +++ b/crm/SCORE.md @@ -0,0 +1,320 @@ +# CRM — Linked Open Data for Aesthetic Computer + +**Status:** Stage 1 implemented (serializer + endpoint + landing, on `main`) · +**Owner:** @jeffrey · **Drafted:** 2026-06-28 + +**Stage 1 ships in these files:** +- `system/backend/linked-art.mjs` — pure Linked Art serializers (person/painting/piece/mood) + license registry. +- `system/netlify/functions/crm.mjs` — endpoint: path routing, Stage-0 consent gate, content negotiation, landing, VoID, 501 SPARQL stub. +- `system/tests/linked-art.test.mjs` — `node --test system/tests/linked-art.test.mjs` (6 passing). +- `lith/server.mjs` — host rewrite `data.aesthetic.computer/* → /api/crm/*`. +- `lith/Caddyfile` — `@data` host block → lith. + +**To go live:** (1) create the Cloudflare `data` record in the `aesthetic.computer` +zone → lith origin `209.38.133.33` (add to `lith/DNS.md`); (2) deploy lith +(`fish lith/deploy.fish`); (3) opt a handle in: +`db['@handles'].updateOne({handle:'sat'}, {$set:{linkedData:{enabled:true, license:'CC-BY-4.0', optInAt:new Date()}}})`. +Until a handle opts in, every entity correctly 404s. In dev, `?preview=CC-BY-4.0` +bypasses the gate to validate serialization without touching live data. + +Expose AC users' work as **dereferenceable linked data** so cultural-heritage +researchers (Getty, museums, the American Art Collaborative) can discover, +cite, and federate over it — e.g. a Getty researcher querying *"all digital +paintings produced by @sat in 2026"* against AC's endpoint joined with their +own collection. + +This is the **research/archival sibling of the Bluesky mirror**. Bluesky +broadcasts moods to a social firehose; this broadcasts *all* opted-in work to +the semantic web. Same architecture (opt-in allowlist, dual-channel, stable +external identity), different audience and ontology. + +--- + +## 1. Precedent in the repo — the Bluesky pattern + +`system/backend/bluesky-mirror.mjs` is the template we are deliberately copying: + +- **Opt-in allowlist** — only handles in `secrets.bluesky.mirrorHandles` broadcast. +- **Dual-channel** — a mood lives in Mongo *and* gets a stable external + identity (`atproto.rkey`, `bluesky.uri`) plus a public permalink + (`https://aesthetic.computer/moods~{handle}~{rkey}` — `bluesky-mirror.mjs:80`). +- **Pull-back** — engagement is re-fetched from a public API + (`bluesky-engagement.mjs`). + +CRM keeps all three properties. The "external identity" becomes an RDF URI; +the "broadcast target" becomes a SPARQL store + JSON-LD content negotiation. + +--- + +## 2. Strategic decision: target **Linked Art**, a profile of CIDOC CRM + +CIDOC CRM (ISO 21127) is the *ontology*. But Getty and peer institutions do +not ingest raw CRM RDF in practice — they consume **[Linked Art](https://linked.art)**, +a JSON-LD profile of CIDOC CRM with fixed property names (`produced_by`, +`carried_out_by`, `classified_as`) and Getty AAT vocabulary terms. + +> **Decision:** emit **Linked Art JSON-LD** as the primary representation, with +> the **CIDOC CRM** classes underneath. A SPARQL endpoint exposes the same +> triples. "Through CIDOC CRM" is satisfied by Linked Art; raw-CRM purists can +> still query the SPARQL store. + +Why this matters: Linked Art is what their pipelines actually parse, and it +gives us an off-the-shelf `@context`, validators, and example consumers. + +--- + +## 3. URI strategy — a dedicated identity domain: `data.aesthetic.computer` + +**Decision:** canonical linked-data identifiers live on **`data.aesthetic.computer`**, +mirroring Getty's own `data.getty.edu` and Europeana's `data.europeana.eu`. A +dedicated domain is the cultural-heritage convention precisely because the +identifier must resolve *forever* while the public app (`aesthetic.computer`) +keeps changing. Each identity URI carries a `subject_of`/`equivalent` link back +to the human-facing page on the apex, so the two never drift. + +| Entity | Canonical identity URI | Links to (human page) | Source of truth | +|---|---|---|---| +| Person | `https://data.aesthetic.computer/@sat` | `aesthetic.computer/@sat` | `@handles`, `users` | +| Painting | `https://data.aesthetic.computer/painting/{code}` | `aesthetic.computer/painting/{code}` | `paintings` (image at `/media/@sat/painting/{slug}.png`) | +| Piece (KidLisp/.mjs) | `https://data.aesthetic.computer/piece/{code}` | `aesthetic.computer/{$code}` | `pieces` / `kidlisp` | +| Mood | `https://data.aesthetic.computer/mood/{handle}/{rkey}` | `aesthetic.computer/moods~{handle}~{rkey}` | `moods` (`atproto.rkey`) | + +Dereferencing rule on `data.aesthetic.computer/{path}`: default (or +`Accept: application/ld+json`, `?format=jsonld`) → **Linked Art JSON-LD**; +`Accept: text/html` → a small human-readable view that also ``s +the apex page. `data.aesthetic.computer/sparql` and `/.well-known/void` complete +the hub. + +> The JSON-LD examples in §4 still show `aesthetic.computer/...` ids for +> readability — read them as `data.aesthetic.computer/...` per this table. + +--- + +## 4. Entity mapping — CIDOC CRM classes + Linked Art shape + +All four entities attribute back to the **Person** node, which is the hub. + +### 4.1 Person — `@sat` → `E21 Person` + +```json +{ + "@context": "https://linked.art/ns/v1/linked-art.json", + "id": "https://aesthetic.computer/@sat", + "type": "Person", + "_label": "@sat", + "identified_by": [ + { "type": "Name", "content": "@sat", + "classified_as": [{ "id": "http://vocab.getty.edu/aat/300404670", "_label": "primary name" }] } + ], + "subject_of": [ + { "type": "LinguisticObject", "_label": "AC profile", + "digitally_carried_by": [{ "type": "DigitalObject", + "access_point": [{ "id": "https://aesthetic.computer/@sat" }] }] } + ] +} +``` +- Latest mood can ride along as a `subject_of` LinguisticObject. +- **No email, Auth0 sub, or `verifications` data is ever emitted.** Only the + public handle and color identity. + +### 4.2 Painting → `E22 Human-Made Object` / `D1 Digital Object` + +Production event `E12 Production` carries the authorship + time-span. + +```json +{ + "@context": "https://linked.art/ns/v1/linked-art.json", + "id": "https://aesthetic.computer/painting/Abc123", + "type": "DigitalObject", + "_label": "painting Abc123 by @sat", + "classified_as": [ + { "id": "http://vocab.getty.edu/aat/300033973", "_label": "paintings (visual works)" }, + { "id": "http://vocab.getty.edu/aat/300312038", "_label": "digital images" } + ], + "produced_by": { + "type": "Production", + "carried_out_by": [{ "id": "https://aesthetic.computer/@sat", "type": "Person" }], + "timespan": { "type": "TimeSpan", "begin_of_the_begin": "2026-03-15T00:00:00Z" }, + "took_place_at": [{ "id": "https://aesthetic.computer", "type": "Place", "_label": "Aesthetic Computer" }] + }, + "subject_to": [{ "type": "Right", + "classified_as": [{ "id": "https://creativecommons.org/licenses/by/4.0/", "_label": "CC BY 4.0" }] }], + "digitally_shown_by": [{ "type": "DigitalObject", "format": "image/png", + "access_point": [{ "id": "https://aesthetic.computer/media/@sat/painting/Abc123.png" }] }] +} +``` +Source fields: `paintings.code`, `.slug`, `.when`, `.user` (→ resolved to handle). + +### 4.3 Piece (KidLisp / .mjs) → `E73 Information Object` + `E65 Creation` + +Generative-art provenance — a novel, valuable contribution to the CRM graph. +The *source code itself* is a `LinguisticObject`; the running program is the +`DigitalObject`. Hash gives content-addressed identity. + +```json +{ + "@context": "https://linked.art/ns/v1/linked-art.json", + "id": "https://aesthetic.computer/$cow", + "type": "DigitalObject", + "_label": "KidLisp piece $cow by @sat", + "classified_as": [ + { "id": "http://vocab.getty.edu/aat/300265727", "_label": "software" }, + { "id": "http://vocab.getty.edu/aat/300047090", "_label": "generative art" } + ], + "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" } + }, + "identified_by": [{ "type": "Identifier", "content": "", + "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 2188df7a5..57bef937f 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 17b613f37..4808bdfbc 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 b4d288eb9..4855f4463 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 000000000..9e192ad40 --- /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 000000000..839b43fbe --- /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 000000000..e1729bd1e --- /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, []); +}); -- 2.51.2