jeswr

solid-type-index

Use when discovering or registering Solid data by RDF class, reading publicTypeIndex or privateTypeIndex links, creating missing Type Index documents, or implementing TypeRegistration wrappers and bounded pod search.

jeswr 0 Updated 22h ago
GitHub

Install

npx skillscat add jeswr/solid-sdk/solid-type-index

Install via the SkillsCat registry.

SKILL.md

Quick reference

Use the Solid Type Index
The Type Index advertises where data of a given RDF class lives. It is a discovery hint, not an authorization grant.

Read

  1. Fetch the WebID profile for solid:publicTypeIndex; follow its pim:preferencesFile link and read solid:privateTypeIndex from the Preferences Document (with the WebID as subject).
  2. Fetch the index and find solid:TypeRegistration subjects whose solid:forClass matches the desired class.
  3. Read either solid:instance for one resource or solid:instanceContainer for a collection.
  4. Fetch the discovered location and apply its real access controls.

Register

  • Create a TypeRegistration with exactly one of solid:instance or solid:instanceContainer.
  • Use typed DatasetWrapper/TermWrapper classes and RDF/JS terms; do not concatenate Turtle or hand-add quads.
  • Serialize the full dataset with n3.Writer and use conditional PUT with explicit Turtle content type.
  • On 412, re-fetch, reapply the registration, and retry according to the caller's bounded conflict policy.

Bootstrap missing indexes

Do not assume the server seeded them. When an index is genuinely absent, create a solid:TypeIndex plus solid:ListedDocument (public) or solid:UnlistedDocument (private). Link the public index from the WebID Profile Document and the private index from the Preferences Document, discovering or creating the latter via pim:preferencesFile. Use conditional writes. Never overwrite an index or preferences document that was merely unreadable.
Keep the profile publicly readable and protect the private index itself through the effective access-control system.

Search and security

  • Validate every profile-linked index and registered location against the user's advertised storage roots before attaching credentials.
  • Use parsed origin plus path-segment containment, not raw string prefix.
  • Bound client-side pod search by source count, result count, and wall-clock time, including the discovery phase.
  • De-duplicate results by resource URL and label capped results as incomplete.
  • Scheme-filter every RDF-derived URL before rendering it as a link or image.
    When interoperating with a deployed application, verify the actual predicates its shipping code reads and writes; a vocabulary document alone may not reflect de-facto data.

Agent persona

When this work is delegated to a sub-agent, spawn the solid-data-modeler persona from
`.claude/agents/solid-data-modeler.md` — it routes through this skill.
Orchestration: .claude/agents/solid-app-orchestration.md.

Solid Type Index

The Type Index is the convention by which a pod owner advertises where in the
pod a given RDF class is stored
, so independent apps can discover each other's
data instead of hard-coding paths. Two indexes per profile: a public one
(discoverable types) and a private one (sensitive types).

This skill resolves the deferral in AGENTS.md §Writing data ("the type index
is the discovery mechanism for other apps' data; no typed wrapper ships for it
yet… out of scope"). No @solid/object wrapper ships for it, so you implement
read+write through your own TermWrapper subclasses — never inline quads, never
string-concatenated Turtle. Follow the house stack in AGENTS.md: auth via
@solid/reactive-authentication (plain fetch), I/O via @jeswr/fetch-rdf,
serialisation via n3.

Stay in scope. Use the Type Index for interop — locating/exposing data
other apps share. For data only your own app touches, derive paths from the
pod root (AGENTS.md §Writing data); you do not need a registration.

Vocabulary

The solid: namespace is http://www.w3.org/ns/solid/terms#
(type-indexes spec). Predicate/class
IRIs (verified against the spec):

Term IRI suffix on solid: Role
solid:publicTypeIndex publicTypeIndex profile → public index doc
solid:privateTypeIndex privateTypeIndex Preferences Document (WebID subject) → private index doc
solid:TypeIndex TypeIndex rdf:type of an index document
solid:ListedDocument ListedDocument extra rdf:type on a public index
solid:UnlistedDocument UnlistedDocument extra rdf:type on a private index
solid:TypeRegistration TypeRegistration rdf:type of one registration entry
solid:forClass forClass registration → the RDF class it indexes (IRI)
solid:instance instance registration → a single resource (IRI)
solid:instanceContainer instanceContainer registration → a container to list (IRI)

rdf:type is http://www.w3.org/1999/02/22-rdf-syntax-ns#type. A registration
uses solid:instance (one resource) or solid:instanceContainer (a
container of many) — not both.

Read path: discover an index, then a class

  1. Fetch the WebID profile (AGENTS.md §Reading data); read solid:publicTypeIndex
    and pim:preferencesFile off the WebID subject. Fetch the linked Preferences Document
    with auth and read solid:privateTypeIndex off the same WebID subject there.
  2. Fetch the index document; find solid:TypeRegistration subjects whose
    solid:forClass matches the class you want; read solid:instance /
    solid:instanceContainer.
  3. Discovery is a hint, not a grant. A registration tells you where data
    might be — you must still GET the resource to learn your actual access
    (forum).

Reading the registry can also be done with a SPARQL query that pairs
solid:forClass with solid:instance/solid:instanceContainer
(forum);
the wrapper approach below is the house default.

Convention, not enforcement — always fallback-and-create

The server does not maintain the index. It is convention-only: every app
must cooperate, and an app cannot assume another app registered correctly
(forum).

The Community Solid Server does not seed type-index files. Tim Berners-Lee:
apps/providers must create them, in "the same place they are now"
(forum).
NSS auto-seeds; CSS does not. So never assume the indexes exist — read,
and if absent, create-and-link.

When creating a preferences/profile file to host the private index, do not
clobber an existing one you merely failed to read (e.g. on a 403). Treat
unreadable as "may exist", not "absent" (TBL, same thread).

Create-and-link recipe (when an index is absent)

  1. Read the profile and, for a private index, discover pim:preferencesFile
    (http://www.w3.org/ns/pim/space#preferencesFile) on the WebID subject. Validate that
    URL against the user's advertised storage roots before an authenticated fetch.
    If no preferences link exists, choose <podRoot>settings/prefs.ttl and read it first.
    Reuse an existing document; only a confirmed 404 permits creating one with
    If-None-Match: *. A 401, 403, network error, or parse error is not absence.
  2. Choose a location near the profile — convention: <podRoot>settings/publicTypeIndex.ttl
    and <podRoot>settings/privateTypeIndex.ttl (the spec's examples use
    /settings/).
  3. PUT the confirmed-missing index document with If-None-Match: *, typed
    solid:TypeIndex and solid:ListedDocument (public) / solid:UnlistedDocument
    (private). Only bootstrap with Write and Control access to the root storage.
  4. For a public index, add solid:publicTypeIndex on the WebID subject in the
    WebID Profile Document. For a private index, add solid:privateTypeIndex
    on the WebID subject in the Preferences Document. Persist the full modified
    dataset with If-Match using that document's ETag; create genuinely absent documents
    with If-None-Match: *. On 412, re-fetch and reconcile without dropping other data;
    stop if a safe conditional update is unavailable.
  5. If the preferences link was missing, conditionally update the profile to add
    <webId> pim:preferencesFile <preferencesUrl> only after the preferences document
    has been persisted. Keep the WebID card world-readable; restrict preferences and the
    private index to the owner through the effective access-control system.

The bootstrap specification
requires the public pointer in the profile and the private pointer in preferences. For example,
the profile at https://pod.example/profile/card contains:

@prefix solid: <http://www.w3.org/ns/solid/terms#> .
@prefix pim: <http://www.w3.org/ns/pim/space#> .
<#me> solid:publicTypeIndex </settings/publicTypeIndex.ttl> ;
    pim:preferencesFile </settings/prefs.ttl> .

The Preferences Document at https://pod.example/settings/prefs.ttl contains:

@prefix solid: <http://www.w3.org/ns/solid/terms#> .
<https://pod.example/profile/card#me> solid:privateTypeIndex </settings/privateTypeIndex.ttl> .

Spec-exact shapes (type-indexes):

# public index document
<> a solid:TypeIndex, solid:ListedDocument .
<#registration-ab09fd> a solid:TypeRegistration ;
    solid:forClass vcard:AddressBook ;
    solid:instance </public/contacts/myPublicAddressBook.ttl> .

# private index document
<> a solid:TypeIndex, solid:UnlistedDocument .
<#registration-ab09fd> a solid:TypeRegistration ;
    solid:forClass vcard:AddressBook ;
    solid:instanceContainer </private/myBookmarks/> .

Worked TermWrapper implementation

Two wrappers: TypeIndexDataset (the whole index document — a DatasetWrapper,
because registrations are sibling subjects in the document, not objects
reachable from the document's own subject) and TypeRegistration (one entry —
a TermWrapper). All forClass/instance/instanceContainer values are
IRIs, so use the NamedNode* mappers; rdf:type is a set, so use SetFrom.
Compile-verified (tsc --strict) against the published @rdfjs/wrapper
0.34.0 + @jeswr/fetch-rdf 0.1.0.

import {
  TermWrapper, DatasetWrapper,
  OptionalFrom, OptionalAs,
  SetFrom,
  NamedNodeAs, NamedNodeFrom,
} from "@rdfjs/wrapper";

const SOLID = "http://www.w3.org/ns/solid/terms#";
const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";

/** One `solid:TypeRegistration` entry. */
class TypeRegistration extends TermWrapper {
  /** The RDF class this entry indexes (an IRI). */
  get forClass(): string | undefined {
    return OptionalFrom.subjectPredicate(this, SOLID + "forClass", NamedNodeAs.string);
  }
  set forClass(v: string | undefined) {
    OptionalAs.object(this, SOLID + "forClass", v, NamedNodeFrom.string);
  }

  /** A single resource holding instances of `forClass`. */
  get instance(): string | undefined {
    return OptionalFrom.subjectPredicate(this, SOLID + "instance", NamedNodeAs.string);
  }
  set instance(v: string | undefined) {
    OptionalAs.object(this, SOLID + "instance", v, NamedNodeFrom.string);
  }

  /** A container listing instances of `forClass`. */
  get instanceContainer(): string | undefined {
    return OptionalFrom.subjectPredicate(this, SOLID + "instanceContainer", NamedNodeAs.string);
  }
  set instanceContainer(v: string | undefined) {
    OptionalAs.object(this, SOLID + "instanceContainer", v, NamedNodeFrom.string);
  }

  /** Stamp the entry as a TypeRegistration (call once when minting). */
  markRegistration(): void {
    this.types.add(SOLID + "TypeRegistration");
  }

  /** Live set of `rdf:type` IRIs. */
  get types(): Set<string> {
    return SetFrom.subjectPredicate(this, RDF + "type", NamedNodeAs.string, NamedNodeFrom.string);
  }
}

/** A type-index document, wrapped whole. */
class TypeIndexDataset extends DatasetWrapper {
  /** Every `solid:TypeRegistration` subject in the document. */
  get registrations(): Iterable<TypeRegistration> {
    return this.instancesOf(SOLID + "TypeRegistration", TypeRegistration);
  }

  /** Find the location(s) registered for a class IRI. */
  locate(classIri: string): { instance?: string; container?: string }[] {
    const out: { instance?: string; container?: string }[] = [];
    for (const reg of this.registrations) {
      if (reg.forClass === classIri) {
        out.push({ instance: reg.instance, container: reg.instanceContainer });
      }
    }
    return out;
  }

  /** Add a registration; serialise + conditional PUT afterwards to persist. */
  register(
    indexUrl: string,            // the type-index document URL
    fragment: string,            // e.g. "#registration-notes"
    classIri: string,
    location: { instance?: string; container?: string },
  ): TypeRegistration {
    // `this` IS a DatasetCore (DatasetWrapper implements it), so the new
    // wrapper writes into the same underlying dataset.
    const reg = new TypeRegistration(indexUrl + fragment, this, this.factory);
    reg.markRegistration();
    reg.forClass = classIri;
    if (location.instance) reg.instance = location.instance;
    if (location.container) reg.instanceContainer = location.container;
    return reg;
  }
}

// usage
import { fetchRdf } from "@jeswr/fetch-rdf";
import { DataFactory } from "n3";

const { dataset, etag } = await fetchRdf(indexUrl);
const index = new TypeIndexDataset(dataset, DataFactory);
const where = index.locate("https://schema.org/TextDigitalDocument");
index.register(indexUrl, "#registration-notes", "https://schema.org/TextDigitalDocument", {
  container: new URL("notes/", podRoot).toString(),
});

Then mint + persist exactly as AGENTS.md §Writing data prescribes: mutate the
in-memory dataset through these wrappers, serialise the whole dataset with
n3.Writer, and conditional-PUT with if-match and an explicit
content-type: text/turtle. Handle 412 by re-fetching and re-applying.

Notes / caveats

  • SetFrom.subjectPredicate(this, iri, readMapper, writeMapper) returns a
    live Set — adding/removing mutates the dataset (used by
    markRegistration). instancesOf(classIri, Ctor) is a protected helper on
    DatasetWrapper — callable only from a subclass, which is why
    TypeIndexDataset exists rather than scanning quads by hand.
  • Use one DataFactory (n3's) throughout, as elsewhere in the stack.
  • Public index = types discoverable by others (access may still be restricted);
    private index = owner-only, behind WAC
    (forum).
  • A registration may carry an solid:instance and be one of several entries
    for a class; treat locate() as returning a list.
  • SolidOS interop: the de-facto on-the-wire term is what the SHIPPING pane
    reads/writes — verify against the pane source, not the spec/ns-doc.
    Type-Index
    discovery only tells you where another app's data is; to actually read/write it
    you must match the predicates that app uses, and a shipping SolidOS pane can
    differ from its own ns-doc. Concretely: the SolidOS schedule pane
    (`solid-panes/src/schedule/schedulePane.ts`)
    reads and writes sched:availabilty (misspelled, no second i), whereas
    the W3C ns-doc (http://www.w3.org/ns/pim/schedule#) defines sched:availibility
    (a different misspelling). To interoperate you must write the term the live
    pane reads
    (sched:availabilty) and read tolerantly (accept both spellings).
    General rule: for SolidOS interop, the de-facto term shipping in the pane wins over
    the spec — grep the pane source for the predicate before you hard-code it.
    (Verified 2026-06 against the live solid-panes main; learned building the Pod
    Manager /schedule interop, jeswr/solid-pod-manager src/lib/schedule.ts.)

Type-Index-driven client-side pod search (no server FTS)

(An app that searches the user's whole pod across categories — a global
search box — without a server full-text index. Browser/client.)

A Solid pod has no built-in full-text search, so a cross-category "search my
pod" is a client-side scan seeded by the Type Index: it tells you which
containers hold what
, so you scan those instead of crawling blindly. Four
rules, learned building the Pod Manager global pod search
(jeswr/solid-pod-manager #97):

  1. Enumerate, scan, de-dupe. Build the source set from the user's
    Type-Index-registered solid:instance/solid:instanceContainer locations
    (the read path above) plus your own app's first-party typed stores, then
    scan each client-side and match in memory. De-dupe results by resource URL
    — the same resource can be reached via a type registration and a
    first-party path; let the typed (registered) entry win so a result is shown
    once with its richer metadata.

  2. OWN-POD ONLY — SSRF-validate every discovered URL against the user's own
    storages before fetching.
    The profile-linked solid:publicTypeIndex /
    solid:privateTypeIndex / pim:preferencesFile URLs (and the
    solid:instance* locations inside an index) are values read from a
    document
    — an attacker who can write a triple in the profile or index can
    point them at a foreign origin. Before attaching the user's DPoP token,
    confirm each URL falls under one of the user's own pim:storage roots
    (the isUnderStorage segment-boundary check in solid-fetch-rdf); drop —
    never fetch — any off-storage URL
    (attaching your DPoP token to a foreign
    origin leaks it). Re-validate the registered instance/instanceContainer
    locations too, not just the index URL — both come off attacker-writable
    documents. (This is the own-pod-discovery mirror of solid-fetch-rdf's
    cross-pod-claim / external-index provenance rules; same primitive.)

  3. Bound the scan so a large pod cannot hang the UI. Cap on three axes
    together — max sources scanned, max results collected, and a wall-
    clock time budget — and check the budget before each source and
    before the type-index network discovery step (discovery is itself N
    fetches). Stop early when any cap trips and surface "showing first N" /
    "results may be incomplete" so a capped scan never looks like an empty pod.

  4. Render no raw RDF value as a link. Result titles/URLs are pod data; a
    javascript:/data: IRI in an <a href>/<img src> is an injection vector
    — scheme-filter to http(s): before rendering (same rule as solid-fetch-rdf
    §"Consuming an external Hydra / TPF search index safely").

Keep the URL-validation and scheme checks in pure predicates (no fetch) so
the search filter is exhaustively unit-testable without a live pod — the same
testability discipline as the cross-pod provenance predicate in solid-fetch-rdf.

Cross-references

  • AGENTS.md §Writing data — read-modify-write, ETags, explicit Content-Type,
    trailing-slash rule (containers end in /). This skill closes that
    section's type-index deferral.
  • Package-local `solid-fetch-rdf` skill — isUnderStorage own-storage containment check, the
    cross-pod-claim provenance tiers, and the external-index SSRF/scheme rules the
    pod-search section above reuses.
  • solid-rdf skill — Turtle/JSON-LD parse + serialise, the "never hand-build
    triples" rule.
  • solid-server-matrix (proposed) — type-index seeding differs by server
    (NSS auto-seeds; CSS does not), which is the whole reason fallback-and-create
    is mandatory.

Could not verify

  • Embedded-entity declaration in the registry (forum thread
    t/how-should-embedded-entities-be-declared-in-the-type-index-registry/3023)
    — out of scope here; not fetched.
  • Exact @rdfjs/wrapper member names — resolved: the worked example above
    was compiled (tsc --strict) against the published @rdfjs/wrapper 0.34.0
    .d.ts (incl. instancesOf(klass: string, ctor) on DatasetWrapper,
    NamedNodeAs.string / NamedNodeFrom.string, 4-arg
    SetFrom.subjectPredicate) on 2026-06-05.