Solid-protocol-compatible read/write proxy backed by a GitHub repository. Public reads from ${GITHUB_REPO}@${GITHUB_REF}; writes go to per-page ${page}-draft branches via Solid-OIDC-authenticated PUT.
- Public GET
GET /:page*/:doc— unauthenticated. Proxies${GITHUB_REPO}@${GITHUB_REF}:${page}/${doc}via the GitHub Contentsapplication/vnd.github.rawmedia type. ForwardsContent-Type(inferred from the file extension viamime-types) andETag; edge-caches withNetlify-CDN-Cache-Control: public, durable, max-age=60, stale-while-revalidate=86400(see Caching and WAC-Allow); honorsIf-None-Match(returns 304) and emitsVary: If-None-Matchwhenever the client sent one.- Root-level files (URLs with no page prefix, e.g.
GET /index.ttl) are served as-is from the repo root:${GITHUB_REPO}@${GITHUB_REF}:<doc>. Forwards the upstreamContent-Typeon 2xx (preferring the extension-guessed MIME when GitHub's raw media typeapplication/vnd.github.rawis otherwise generic) and the upstreamContent-Typeon non-2xx (so 404s surface asapplication/json, nottext/turtle). The corresponding draft branch is the literal namedraft(no-draftsuffix because there's no page name to suffix).
- Root-level files (URLs with no page prefix, e.g.
- Public container GET
GET /,GET /:page*/— unauthenticated. Lists the GitHub directory at${GITHUB_REPO}@${GITHUB_REF}:${page}/via the GitHub Contentsapplication/vnd.github+jsonmedia type and returns a Turtleldp:Container, ldp:BasicContainerdocument withldp:containstriples pointing to each child (files typed asldp:Resource, subdirectories typed asldp:Container, ldp:BasicContainer). Content-Type istext/turtle; charset=utf-8. Edge-cached like the file route. HonorsIf-None-Match(emitsVary: If-None-Match). PUT on container paths is rejected with 405. Page-root responses (/and/:page*/) additionally carry three provenance/memento triples on the container subject (omitted if no commit affects the page — see Public container GET/:page*/):<http://mementoweb.org/ns#memento>,owl:sameAs, andprov:wasGeneratedBy. Directory-index behavior: if the request'sAcceptheader preferstext/html(orapplication/xhtml+xml) overtext/turtle/text/n3, the router first attempts to serve<page>/index.htmlfrom${GITHUB_REPO}@${GITHUB_REF}(orindex.htmlat the repo root forGET /); the upstream status,Content-Type, andETagare forwarded, the response is edge-cached like the other public reads, andVary: If-None-Matchis added when the client sentIf-None-Match. A404from the index lookup falls back to the Turtle container listing. MissingAccept,*/*, or anAcceptthat doesn't mention either HTML or Turtle all default to the existing Turtle behavior. - Draft GET
GET /:page*/history/draft/:doc,GET /:page*/history/draft/— file and container reads of${page}-draft. Same proxy / listing semantics as the public route; falls back toGITHUB_REFon a 404. The same Accept-header rule applies: when HTML is preferred over Turtle, the router first tries<page>/index.htmlon${page}-draftand serves it read-only (WAC-Allow: user="read", public="read"; noAllow/Accept-Put/Accept-Patch;Cache-Control: private, no-store); a404from the index lookup falls back to the existing draft Turtle listing. With no page prefix (GET /history/draft/<doc>andGET /history/draft/) the draft branch is the literal namedraftand the file path is just<doc>. A no-page-prefix subdirectory container (GET /history/draft/<sub>/) likewise lists<sub>on the literaldraftbranch (falling back toGITHUB_REFon a 404), keeping the root page's draft tree navigable — e.g.GET /history/draft/topics/lists thetopicsdirectory.- Auth is optional: if both
AuthorizationandDPoPheaders are present, the DPoP token is verified andWAC-Allowreports the requesting agent's modes underuser; an unauthenticated request reports the public modes under bothuserandpublic(public access implies user access, matching CommunitySolidServer). - Missing headers are not an error — anonymous reads are allowed; the auth check only elevates
WAC-Allow. - Not cached — every draft response carries
Cache-Control: private, no-storeandNetlify-CDN-Cache-Control: no-store, becauseWAC-Allowvaries per request and a shared cache would leak one user's write capability to another.Vary: Origin, Authorization, DPoPadvertises the request headers the cache key must include.
- Auth is optional: if both
- Draft PUT
PUT /:page*/history/draft/:doc— Solid-OIDC-authenticated; authorized by WAC (writeon the target, plusappend/writeon the containing container when creating it).- Creates the
${page}-draftbranch fromGITHUB_REFif missing, then commits the file. - Honors
If-Match(sha precondition → 412 on mismatch) andIf-None-Match: *(create-only → 412 if the path exists on the branch). If-MatchandIf-None-Match: *are mutually exclusive — sending both returns 400.- With no page prefix (
PUT /history/draft/<doc>) the draft branch is the literal namedraftand the file path is just<doc>.
- Creates the
- Draft PATCH
PATCH /:page*/history/draft/:doc— Solid-OIDC-authenticated; authorized by WAC (writeon the target, orappendfor insert-only edits).- Accepts
Content-Type: text/n3; handles a singlesolid:InsertDeletePatchwith non-emptysolid:insertsand/or groundsolid:deletes(no variables) and empty/absentsolid:where. All insert/delete triples must be ground (no blank nodes, no variables). - Flow: fetch existing from
${page}-draft(404 means "create from empty"), parse as Turtle, apply deletes-then-inserts, re-serialize astext/turtle; charset=utf-8, commit. - Honors
If-Match(sha precondition → 412 on mismatch). - Errors: other
Content-Type→ 415; non-.ttlpath → 422; validation failure (blank nodes / variables / presentwhere/ malformed body / multiple patches) → 422; delete triple not present in the document → 409; non-draft URL → 405. - With no page prefix (
PATCH /history/draft/<doc>) the draft branch is the literal namedraftand the file path is just<doc>.
- Accepts
- History — LDP-navigable view of past commits on
${GITHUB_REF}affecting<page>/*. Path:/:page*/history[/YYYY[/MM]]/<shortSha>[/<doc*>]. Bucket levels (year, month) list children within[REPO_START_YEAR, currentYear]; year and month are optional when fetching by<shortSha>. Years outside the range return 404, empty months return 200 with no children.- Cache: bucket levels
public, max-age=86400, stale-while-revalidate=259200(1 day fresh, 3 days SWR); commit-SHA levelspublic, max-age=31536000, immutable(the URL is the commit, the response cannot change).
- Cache: bucket levels
- Changelog root GET
GET /:page*/history/changelog/— unauthenticated. The ActivityPubas:OrderedCollectionroot; lists year sub-containers (301 redirect from the no-slash form). - Changelog year GET
GET /:page*/history/changelog/YYYY/— unauthenticated. Lists month sub-containers for that year (301 redirect from the no-slash form). - Changelog month GET
GET /:page*/history/changelog/YYYY/MM— unauthenticated. The synthesized activities for that month, inline as anas:OrderedCollectionPagewithas:prev/as:nextto sibling months. Reads the shard from${page}-draftfirst (so any pending month-PATCH edits on the draft branch are visible); falls back toGITHUB_REFon 404. The response body is canonical — identical for every reader regardless of authentication; onlyWAC-Allowvaries per request. - Changelog month PATCH
PATCH /:page*/history/changelog/YYYY/MM— Solid-OIDC-authenticated; authorized by WAC (write, orappendfor insert-only edits). Buffers an edit to the past-month shard on${page}-draft. Sametext/n3patch constraints as the draft PATCH. - Changelog POST
POST /:page*/history/changelog/— Solid-OIDC-authenticated; authorized by WAC (appendorwriteon the changelog container/:page*/history/changelog/). Publishes a new activity: blank-nodeprov:Activityin Turtle withrdfs:labelas the commit message (required). One main commit per POST; the draft branch is squash-merged in and deleted. - CORS
OPTIONS— 204 with allow-listPOST, PATCH, PUT, GET, OPTIONS; allows headersAuthorization, DPoP, Content-Type, Accept, Accept-Charset, Accept-Datetime, Cache-Control, Date, Digest, If-Match, If-None-Match, Link, Prefer, Range, Signature, Slug, Want-Digest(full Solid-protocol set);Access-Control-Allow-Credentials: trueso credentialed writes succeed; exposesETag, Cache-Control, WAC-Allow, Allow, Accept-Put, Accept-Patch, Accept-Post; echoesOrigin(falls back to*when no Origin header is sent);Vary: Origin. Draft and changelog GETs additionally advertiseVary: Authorization, DPoPso caches don't collapse theWAC-Allowvariants across authenticated/anonymous callers. - Access control (WAC) — public reads stay open;
write/append/controlpermissions follow Web Access Control, are managed via the<path>.aclresource, and every permission change is viewable in an audit log.
Path safety — every path goes through isPathSafe (no leading /, no empty/./../NUL segments); unsafe paths are rejected with 400. The empty path (root container /`) is the only exception.
Errors — GitHubFetchError (network/5xx) and GitHubApiError (4xx) carry the upstream status; 5xx is surfaced as 502, 4xx passes through, 404 passes through.
- Node.js 18+
- netlify-cli for local development (
npm install -g netlify-cli) - A GitHub fine-grained personal access token with
contents:writeon the target repository
npm installnpm run build:configAlso runs automatically as netlify.toml's command, pretest, and vitest globalSetup. Writes netlify/functions/router/repo-start-year.generated.mjs (gitignored). The function itself ships from netlify/functions/router/ without a TS compile step (Netlify bundles .mts directly).
Optional: copy .env.example to .env and fill in OWNER_WEBIDS, GITHUB_REPO, GITHUB_TOKEN, GITHUB_REF for netlify dev.
| Variable | Required | Description |
|---|---|---|
OWNER_WEBIDS |
Yes | Comma-separated WebIDs seeded with acl:Control, acl:Read, and acl:Write on the root scope at bootstrap. They also always retain acl:Control on every .acl resource (see Owner protection). |
GITHUB_REPO |
Yes | owner/repo form. |
GITHUB_TOKEN |
Yes | GitHub PAT with contents:write on GITHUB_REPO. |
GITHUB_REF |
No | Ref for public reads and the base for new draft branches (default HEAD, which resolves to the repo's default branch on each call). |
REPO_START_YEAR |
No | 4-digit year in [1900, 2100]. Used directly if set; otherwise npm run build:config resolves it from the GitHub repo (public API for public repos, authenticated with GITHUB_TOKEN for private repos), and writes 0 if neither works. |
ACL_STORE |
No | Netlify Blobs store holding the authoritative WAC policy document (default acl). Never written to GITHUB_REPO. |
ACL_AUDIT_STORE |
No | Netlify Blobs store holding the append-only, hash-chained ACL audit ledger (default acl-audit). |
Debugging: every request handled by the router function logs a single [router] METHOD /path entry line at the start (e.g. [router] PUT /foo/history/draft/bar). Auth failures additionally log [router] ${pathname} auth failed: <message> followed by an [auth] DENIED: <reason> line. When the underlying verifier rejects on iat clock skew, an [auth] Token iat timestamp is N seconds ahead/behind server time line is emitted as well. Grepping for [router] or [auth] is the fastest way to follow a single request through the function.
Unauthenticated proxy of ${GITHUB_REPO}@${GITHUB_REF}:${page}/${doc}.
- Load
GITHUB_REPO/GITHUB_TOKEN/GITHUB_REFfrom env. - Resolve
path = ${page}/${doc}fromcontext.params; reject 400 on unsafe path. fetchFileFromGitHubagainsthttps://api.github.com/repos/${repo}/contents/${path}?ref=${ref}withAccept: application/vnd.github.raw, forwarding the request'sIf-None-Match.- Read
Content-Type(preferring the inferred MIME from the file extension) andETagfrom the upstream response, and set the public edge-cache headers (Netlify-CDN-Cache-Control: public, durable, max-age=60, stale-while-revalidate=86400; see Caching and WAC-Allow). - Add
Vary: If-None-Matchwhenever the caller sent anIf-None-Match(so CDN caches don't collapse 200 and 304 responses). - Return the body with the upstream status; 304 short-circuits to an empty body. Upstream 404 passes through to the caller; 5xx is surfaced as
GitHubFetchError→ 502.
Unauthenticated listing of ${GITHUB_REPO}@${GITHUB_REF}:${page}/ as a Turtle ldp:BasicContainer document. The router loads GITHUB_REPO/GITHUB_TOKEN/GITHUB_REF, derives the container path from the URL pathname (not context.params, because Netlify's greedy :page* swallows trailing slashes into the previous segment; root / → empty path, which listDirectoryFromGitHub translates to the repo-root Contents URL), and rejects unsafe paths with 400.
-
Content negotiation. The router checks
Acceptfirst: if the HTML family (text/html,application/xhtml+xml) outscores the Turtle family (text/turtle,text/n3) by q-value (or by order on tie), it attemptsfetchFileFromGitHubfor<page>/index.html(orindex.htmlforGET /) against the sameref. On 200/304 it returns that file with the upstreamContent-TypeandETag, the public edge-cache headers, andVary: Accept, If-None-Match; the URL stays/:page*/(no redirect). On any other status (404 included), or whenAcceptis missing/*/*/mentions neither family, it falls through to the Turtle listing below. Because one URL serves two representations, both the HTML index and the Turtle listing carryVary: Accept, so the edge caches the variants separately and a Turtle request is never served a cached HTML body (or vice versa). -
listDirectoryFromGitHubagainsthttps://api.github.com/repos/${repo}/contents/${path}?ref=${ref}withAccept: application/vnd.github+json. -
If upstream 404, return 404. Otherwise, serialize the entries via
serializeContainer(see Repository layout for an example). The container is typedldp:Container, ldp:BasicContainer; files are typedldp:Resourceand subdirectories are typedldp:Container, ldp:BasicContainer. -
Page-root provenance/memento triples — for
/and/:page*/(not the draft view), the container subject additionally carries three server-managed triples pointing to its HEAD-mirrored SHA container:<http://mementoweb.org/ns#memento> <pageUrl>/history/<latestShortSha>/— custom "has-memento" predicate naming the per-commit container that mirrors this page's current state.<http://www.w3.org/2002/07/owl#sameAs> <pageUrl>/history/<latestShortSha>/— same-entity link to the same per-commit container.<http://www.w3.org/ns/prov#wasGeneratedBy> <pageUrl>/history/changelog/<YYYY>/<MM>#<latestShortSha>— the synthesizedprov:Activityin the changelog that produced this version (year/month derived from the latest commit's author date).
<pageUrl>is${origin}/${path}for a non-empty page and the bare${origin}for the repo root, so the root emitshttps://host/history/<sha>/(no doubled slash). The same empty-path handling applies to a root commit-folder container'smemento:original(see History routes).The triples are omitted entirely when
listCommitsForPath({ perPage: 1 })returns no commits for the page (defensive: the listing then matches today's LDP-only output byte-for-byte). One extra GitHub API call is made per/:page*/GET to fetch the latest commit. These triples mirror the inverse direction served by commit-folder containers (see History routes). -
Set
Content-Type: text/turtle; charset=utf-8and the public edge-cache headers (Netlify-CDN-Cache-Control: public, durable, max-age=60, stale-while-revalidate=86400; see Caching and WAC-Allow). AddVary: Accept(content negotiation) and, when the caller sent one,Vary: If-None-Match. 5xx is surfaced asGitHubFetchError→ 502; 4xx surfaces asGitHubApiError→ 502.
Same proxy / listing semantics as the public route, but reads ${page}-draft (the per-page branch) and falls back to GITHUB_REF on a 404. Auth is optional and only affects the WAC-Allow response header. The container path is derived from the URL pathname: /:page*/history/draft/ strips the /history/draft/ suffix and uses the remaining prefix, while a no-page-prefix subdirectory (/history/draft/<sub>/) uses <sub> as the path on the literal draft branch (container URI / for the root, /<sub>/ for the subdirectory so children serialize relative to the request URL).
- Load env; resolve
path; reject 400 on unsafe path. - If the request carries both
AuthorizationandDPoPheaders, verify the DPoP token and resolve the effective WAC rules for that WebID. Anonymous reads (no headers, or one header missing) are not rejected — reads are public, so auth only affects theWAC-Allowwrite capability advertised on this route. - Fetch from
${page}-draft(file viafetchFileFromGitHub; container vialistDirectoryFromGitHub), forwardingIf-None-Match. On a 404 (per-page branch missing or never edited), transparently re-fetch fromGITHUB_REF. The fallback'sETag,Cache-Control, andVary: If-None-Matchare forwarded unchanged — git blob SHAs are content-addressed, so the same content onmainand a freshly-created${page}-drafthas the same SHA, and aPUTwithIf-Match: "<etag>"against the draft branch will be accepted. If the fallback also 404s, the caller sees 404 withWAC-Allow. 5xx is not retried. - Build
WAC-Allowfrom the effective policy:publicis the modes granted tofoaf:Agent, anduseris the requesting agent's modes unioned with the public modes (public access implies user access). For an unauthenticated request the agent's modes are the public modes, souserequalspublic. Emitted on every response (200, 304, 404, fallback). - Advertise editing capability via Solid-spec-compliant headers (mirrors CommunitySolidServer's behavior):
Allow: GET, PUT, OPTIONS,Accept-Put: */*,Accept-Patch: text/n3. The advertised methods apply to all draft GET responses (200/304/404 and the fallback case) —Accept-Patch: text/n3is advertised even for non-RDF content-types so clients likerdflib.jsrecognize the resource as editable and route PATCH requests accordingly; the handler then enforces the.ttl-only constraint on the actual PATCH. - On 200, container bodies are serialized as Turtle
ldp:Container, ldp:BasicContainer(same serializer as the public route) withContent-Type: text/turtle; charset=utf-8. AddVary: If-None-Matchwhenever the caller sentIf-None-Match. - Return the body with the upstream status (304 short-circuits to an empty body).
- Read-only directory-index for HTML clients — same
Accept-header rule as the public route, but when<page>/index.htmlis found on${page}-draftit is served read-only:WAC-Allow: user="read", public="read"is hard-coded (regardless ofauthResult), and theAllow/Accept-Put/Accept-Patchheaders are omitted so clients don't see a write capability advertised.Cache-Control: private, no-storeandNetlify-CDN-Cache-Control: no-storeare still emitted because the underlying resource is on a working branch. A 404 on the index lookup falls through to the existing draft Turtle listing (with the normal draft headers).
Solid-OIDC-authenticated commit to ${page}-draft.
- Verify the DPoP token via
verifyDpopToken(Authorization+DPoPbound toPUT+req.url); reject 401 if headers are missing, 403 if the WebID lackswriteon the target (andappend/writeon the container when creating it) per WAC. - Resolve
path = ${page}/${doc}fromcontext.params; reject 400 on unsafe path. - Parse
If-Match(strip weak prefixW/and surrounding quotes — only the first comma-separated value is honored) and detectIf-None-Match: *; reject 400 if both are present (mutually exclusive). - Load
GITHUB_REPO/GITHUB_TOKEN/GITHUB_REF;branch = ${page}-draft. - If
If-None-Match: *, probegetFileBlobSha({ref: branch, path}); reject 412 if the path already exists on the branch (create-only). commitFileOnBranch(insrc/github.ts):getBranchRef({branch}); if the branch doesn't exist:- Resolve
baseRef: whenGITHUB_REF === 'HEAD', callgetDefaultBranchto look up the repo's default branch; otherwise useGITHUB_REFdirectly. getBranchRef({branch: baseRef})to fetch its sha; reject 404 if missing.createBranchFromShato create${page}-draftfrom that sha.
- Resolve
getFileBlobSha({ref: branch, path})for the sha precondition (overridden by the caller'sIf-Matchif present).commitFile→PUT /repos/${repo}/contents/${path}with base64 body,branch: ${page}-draft,message: Update ${path} via solid-github-netlify, andshaset when known.
- Return 200 with
{commit, url, branch, path, etag}and anETag: "<contentSha>"header. On failure:GitHubApiErrorwith status 409 or status 422 whose message mentionssha+match/invalid→ 412 "If-Match failed".- Any other
GitHubApiError→ pass through with its status. GitHubFetchError→ its status (typically 502).- Anything else → 502 with
error.message.
GitHub's Contents API is eventually consistent: two PUTs racing on the same path can both succeed without If-Match and silently drop one writer. The router guards writes in two ways, both backed by GitHub's sha precondition:
If-Match: "<sha>"— caller passes the sha it last saw. The router passes it straight through to GitHub; if the sha no longer matches (someone else committed first), GitHub returns 409 (or 422 with asha … match/invalidmessage), the router maps that to 412 "If-Match failed", and the caller is expected to GET, re-merge, and retry.If-None-Match: *— create-only semantics. The router probesgetFileBlobShaagainst the draft branch; if anything is there, it returns 412 "Resource already exists" without ever touching GitHub. If the probe returns nothing, the write proceeds with no sha precondition.
When neither precondition is sent, the router still probes the branch for a current sha and forwards it (best-effort overwrite-with-precondition); the write is then a no-op only if the caller GET'd in the same race window. This is a best-effort guard, not a global lock — for correctness, always send If-Match.
Solid-OIDC-authenticated N3 Patch pass-through to ${page}-draft. The router advertises Accept-Patch: text/n3 on every draft GET so Solid clients (rdflib.js, mashlib, etc.) recognize the resource as editable and route PATCH requests through Solid's solid:InsertDeletePatch flow.
Supported patch shape — the minimum ground-triples subset of Solid Protocol §5.3.1. Insert and delete triples are both supported when they contain only ground triples (NamedNode subjects/predicates; NamedNode or Literal objects).
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://example.org/>.
_:patch
solid:inserts {
ex:alice ex:knows ex:bob .
};
a solid:InsertDeletePatch .A delete example:
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://example.org/>.
_:patch
solid:deletes {
ex:alice ex:knows ex:bob .
};
a solid:InsertDeletePatch .Requirements enforced by the handler:
- Exactly one resource of type
solid:InsertDeletePatchin the patch document. - At least one of
solid:insertsorsolid:deletesmust be present and contain at least one triple. solid:wherepredicate present ⇒ the formula must be empty. Solved conditions / variable bindings are not supported.- All triples inside
solid:insertsandsolid:deletesmust be ground: subject and predicate areNamedNodes; object is aNamedNodeorLiteral. No blank nodes, no variables. - Every
solid:deletestriple must already be present in the target document; if any is missing → 409 (per Solid spec §5.3.1: "the dataset does not contain all of these triples"). Deletions are applied before insertions.
Flow:
- Verify the DPoP token via
verifyDpopToken(Authorization+DPoPbound toPATCH+req.url); reject 401/403 unless WAC grantswriteon the target (orappendfor insert-only edits). - Resolve
path = ${page}/${doc}; reject 400 on unsafe path; reject 405 if the route isn't a draft URL. - Validate that
docends in.ttl(case-insensitive). Anything else → 422 "PATCH is only supported on .ttl paths". Note: the GET handler still advertisesAccept-Patch: text/n3for non-.ttldraft URLs so capability discovery is consistent, but the PATCH handler enforces this server-side. - Validate
Content-Type: text/n3(parameters ignored). Anything else → 415. - Parse
If-Matchif present (strip weak prefixW/and surrounding quotes). - Fetch the existing file from
${page}-draftviafetchFileFromGitHub. A 404 means "create from empty"; any other upstream status passes through. - Hand
body+existingtoapplyInsertDeleteTurtlePatchinsrc/patch.ts(see below). Validation failures throwPatchValidationError→ 422 with the message; conflict (missing delete target) throwsPatchConflictError→ 409. - If
applyInsertDeleteTurtlePatchreportschanged: false— the inserts were all already present and/or a delete+insert cancelled out, so the resulting RDF graph equals the existing one — skip the write entirely. GitHub's Contents API would otherwise create an empty commit for byte-identical content.If-Matchis still honoured (current blob SHA mismatch → 412), then the router returns 200 with{commit: null, url: null, branch, path, etag, changed: false}andETag: "<blob sha>". - Otherwise commit the merged turtle to
${page}-draftviacommitFileOnBranchwith the sameIf-Matchhandling as PUT. On failure:GitHubApiErrorwith status 409 or 422 → 412; otherGitHubApiError→ its status;GitHubFetchError/ anything else → 502. - Return 200 with
{commit, url, branch, path, etag}andETag: "<contentSha>".
A single function:
applyInsertDeleteTurtlePatch({ body, existing }: {
body: Uint8Array // raw PATCH body, expected to be text/n3
existing: Uint8Array | null // null if the file does not exist yet
}): Promise<{ content: string; contentType: 'text/turtle; charset=utf-8'; changed: boolean }>Behavior: parses body as N3 (so formulae are preserved as BlankNode-rooted sub-graphs), locates the single solid:InsertDeletePatch resource, gathers the ground triples inside its solid:inserts and solid:deletes formulae, validates the constraints listed above, then parses existing (if present) as Turtle, removes every delete triple (after verifying each is present — otherwise PatchConflictError), adds the insert triples, and serializes the resulting graph back to Turtle. changed reports whether the resulting RDF graph differs from existing (graph-level, so Writer prefix/formatting churn alone is not a change). Throws PatchValidationError (status 422) on any validation failure; throws PatchConflictError (status 409) when a delete triple is not present in the document. The router maps each error class to its status code and skips the commit when changed is false.
The router implements the minimum ground-triples subset only. The following Solid-spec features are not supported and return 422 (unless noted otherwise):
solid:wherewith variable bindings (the BGP solver / variable-substitution machinery fromsolidproject/conformance-test-harnessis out of scope here)- Blank-node generation in
solid:insertsorsolid:deletes - Variables in
solid:insertsorsolid:deletes - Multi-patch documents (
solid:Patchresources other than the singlesolid:InsertDeletePatch) - Named-graph patches (
solid:from/solid:into) application/sparql-updatePATCH bodies (would require a SPARQL Update engine)- PATCH on paths that don't end in
.ttl - PATCH on containers
Clients that need full N3 Patch semantics per Solid Protocol §5.3.1 should target a Solid server like CommunitySolidServer instead.
Solid-OIDC-authenticated publish trigger for the changelog. Each POST produces exactly one main commit; pending edits (regular draft files and past-month changelog edits) ride along in the squash merge.
- Verify the DPoP token bound to
POST+req.url; reject 401/403 unless WAC grantsappendorwrite. - Validate Content-Type
text/turtle; reject 415 otherwise. - Validate that the request path is
/<page>/history/changelog/(trailing slash, root only); reject 405 otherwise. - Parse the body as Turtle. Locate the
as:Createactivity and itsas:objectblank node. - Require an
rdfs:labelliteral on the activity blank node. Missing → 422 and abort.rdfs:labelbecomes the commit message and is not stored in the shard file. - Collect the remaining triples on the activity blank node — these are the client payload that will land in
<page>/.changelog/<year>/<month>.ttl(the activity's identifier becomes<#current>in the file). - Look up the predecessor's short SHA via
listCommitsForPath({ perPage: 1 })— the server determinesprov:usedfrom the commit history; the client does not supply it. - Read the current month shard from
${page}-draft(so any pending month-PATCH edits on the draft branch are picked up). If the draft branch has no shard, fall back toGITHUB_REF(so a previously squash-merged shard is preserved). If the client payload is non-empty, substitute_:b1→<#current>, append the payload, andcommitFileOnBranchthe result to${page}-draft. If the payload is empty, skip the file write entirely. squashMergeBranchmerges${page}-draft→GITHUB_REFwith the activity'srdfs:labelas the commit message. The squash carries any other pending edits on the draft branch (regular file edits and past-month changelog edits). When the draft branch has no diff from base (e.g. empty payload + no other pending edits), GitHub returns 204 No Content;squashMergeBranchresolves to the current HEAD sha ofGITHUB_REFand returns that as{ commit, url, branch, etag, activity }so the publish is reported as a successful no-op.deleteBranchremoves${page}-draft(the branch is recreated on the next edit).- Return 200 with
{commit, url, branch, etag, activity}.
Solid-OIDC-authenticated buffer for past-month changelog edits. Edits the per-month shard file at <page>/.changelog/<year>/<month>.ttl on ${page}-draft (not main) — the edit sits in the draft branch until the next POST drains it via the squash merge.
The month URL has no extension; the on-disk file extension (.ttl) is authoritative and derived from the parsed month. .ttl and trailing-slash variants of the URL return 404. Same patch constraints as the draft PATCH: ground triples only, no solid:where, no blank nodes, no variables. The router validates the path is a month bucket (not root, not year) and the Content-Type is text/n3. As with the draft PATCH, a patch whose net effect leaves the RDF graph unchanged (changed: false) creates no commit and returns 200 with {changed: false} and the current ETag; If-Match is still enforced.
GETs synthesize an LDP + ActivityStreams view of the page's commit history. The shard file holds only the client payload — rdfs:label and the server-managed triples (rdf:type, prov:generated, prov:used, prov:endedAtTime) are all synthesized from commit metadata at read time.
The changelog month is content-type-negotiable: today only Turtle is emitted, but the URL has no extension so future serializers (JSON-LD, NTriples, HTML) slot in via Accept-header negotiation without changing IRIs.
- Load
GITHUB_REPO/GITHUB_TOKEN/GITHUB_REF. - Root
/<page>/history/changelog/— return the synthesizedas:OrderedCollectionwith year sub-containers.as:first/as:lastpoint to the first/last year pages; noas:items(items live in year pages). 301 redirect from the no-slash form. - Year
/<page>/history/changelog/YYYY/— return the synthesizedas:OrderedCollectionPagefor that year.as:partOfthe root;as:prev/as:nextto sibling years;as:itemslists month-page URIs (bare, no.ttl). Month children are typedldp:Resource(notldp:Container). 301 redirect from the no-slash form. - Month
/<page>/history/changelog/YYYY/MM— the canonical month resource URL. The shard file is fetched from<page>/.changelog/<year>/<month>.ttlon${page}-draftfirst; on 404, fall back toGITHUB_REF(withlogTag: "fallback"). This means any pending month-PATCH edits on the draft branch are visible immediately, before the next POST drains them. The response body is canonical — anonymous and authenticated callers receive byte-identical Turtle; onlyWAC-Allowvaries per request. Enumerate commits vialistCommitsForPath(filtered by date range) to get the SHAs, dates, and commit messages. For each commit in chronological order:-
Look up the activity's client payload in the shard (subject is
<#current>, which is resolved to<#<latestShortSha>>at read time — but other prior commits in the shard keep their<#<shortSha>>subject). Activities with no client payload simply have no triples in the file. -
Add synthesized server-managed triples. Each commit is a local fragment of the month resource (
<#abc1234>, not a fragment of the page URL). For example:<#abc1234> a prov:Activity ; prov:generated <page_url>/history/abc1234 ; prov:used <page_url>/history/def5678 ; prov:endedAtTime "2024-03-15T10:30:00Z"^^xsd:dateTime ; rdfs:label "Initial save" ; ex:custom "foo" .
prov:usedis omitted for the very first commit ever.rdfs:labelis the commit message (the file holds only the client payload —ex:custom "foo"in this example).prov:generated/prov:usedpoint at the per-commit history folder (<page_url>/history/<shortSha>) so the page-state relationships are addressable.
-
- Wrap with the LDP + AS envelope (
a as:OrderedCollectionPage,as:partOf, inlineas:items). The document is serialized withbaseIRI = <monthUrl>so commit fragments serialize as<#<shortSha>>. - The
.ttlURL form (/YYYY/MM.ttl) and the trailing-slash form (/YYYY/MM/) both return 404 — the month is a resource, not a container, and the on-disk file extension is authoritative. - Years/months outside
[REPO_START_YEAR, currentYear]return 404.
Drift policy: main commits are the canonical source for server-managed triples. Force-push to GITHUB_REF and history rewrites are not supported; if they happen, GET will reflect the new commit state.
The history tree under /:page*/history/ is an LDP-navigable view of ${GITHUB_REPO}@${GITHUB_REF}'s commit history affecting <page>/*. It is fully read-only and anonymous; mutations flow through the existing draft route.
| URL | Response | Backing API calls |
|---|---|---|
GET /:page/history/ |
LDP BasicContainer listing years [REPO_START_YEAR..currentYear], plus <changelog/> and <draft/> as siblings (both are always-listed virtual routes, not files) |
0 |
GET /:page/history/YYYY/ (in range) |
LDP BasicContainer of <MM>/ for months with commits |
1 (date-scoped listCommitsForPath) |
GET /:page/history/YYYY/ (out of range) |
404 | 0 |
GET /:page/history/YYYY/MM/ |
LDP BasicContainer of <shortSha>/ for commits in that month |
1 (date-scoped listCommitsForPath) |
GET /:page/history/YYYY/MM/<shortSha>/ |
LDP BasicContainer listing immediate children of <page>/ at that commit |
1 (listDirectoryFromGitHub, single-folder, no recursive subtree) |
GET /:page/history/<shortSha>/ |
same as above (year/month prefix optional) | 1 |
GET /:page/history/<shortSha>/<doc*> |
file content at that commit | 1 (fetchFileFromGitHub) |
GET /:page/history/YYYY/MM/<shortSha>/<doc*> |
same as above (year/month prefix ignored) | 1 |
Container IRIs carry a trailing slash by convention (matches the Solid / LDP convention; CommunitySolidServer does the same). The no-slash form is always redirected to the slash form before any backing call:
| Request | Response |
|---|---|
GET /:page/history (no slash) |
301 Location: /:page/history/ |
GET /:page/history/YYYY (no slash) |
301 Location: /:page/history/YYYY/ |
GET /:page/history/YYYY/MM (no slash) |
301 Location: /:page/history/YYYY/MM/ |
GET /:page/history/<shortSha> (no slash) |
301 Location: /:page/history/<shortSha>/ |
GET /:page/history/<shortSha>/<doc*> |
200 (no redirect — file, not container) |
GET /:page/history/changelog (no slash) |
301 Location: /:page/history/changelog/ |
GET /:page/history/changelog/YYYY (no slash) |
301 Location: /:page/history/changelog/YYYY/ |
GET /:page/history/changelog/YYYY/MM |
200 (no redirect — month is a resource, not a container) |
GET /:page/history/changelog/YYYY/MM/ |
404 (month rejects the trailing-slash form; the month is a resource) |
The redirect fires before any GitHub API call, so a 301 is cheap (no upstream cost on a non-canonical request).
Commit-folder containers (<shortSha>/) carry two extra triples in Turtle form for provenance:
<http://www.w3.org/ns/prov#wasGeneratedBy>— the correspondingprov:Activityin the changelog:<pageUrl>/history/changelog/YYYY/MM#<shortSha>. The year/month is derived from the commit's author date via an extraGET /repos/:owner/:repo/commits/:ref(GitHub resolves 7-char short SHAs natively). If the commit lookup fails, this triple is omitted.<http://mementoweb.org/ns#original>— the version-independent page root:<pageUrl>/. For a root commit folder (/history/<shortSha>/)<pageUrl>is the bare origin, so the object ishttps://host/(no doubled slash).
These are emitted unconditionally alongside ldp:contains, regardless of whether the container is empty.
Date-scoped listCommitsForPath calls cap at perPage=100 (the GitHub API's first page). Year/month listings for pages with >100 commits affecting them in a given window are silently truncated — only the first 100 commits are reflected in <MM>/ or <shortSha>/ children.
Content negotiation: Accept: text/turtle (or absent) → text/turtle; charset=utf-8; Accept: text/html → text/html; charset=utf-8. The HTML form renders a <ul> of <a href> children, suitable for browser navigation.
The commit SHA in the URL is the source of truth. Year and month segments in the URL are bucket metadata used for LDP navigation; they are not used to resolve the commit. Concretely:
/foo/history/abc1234/foo.txtand/foo/history/2024/03/abc1234/foo.txtboth fetch<page>/foo.txtat commitabc1234*. The wrong year/month prefix is ignored./foo/history/draft(no:doc) returns 404 — the draft route has its own path matcher and is unaffected by the history catch-all.- SHA validity: 7–40 lowercase hex characters. SHAless URLs (e.g.
/foo/history/2026/) are valid only as year/month container listings, not as file fetches.
Authorization follows WAC 1.0, evaluated on every write and control request. Public reads are unenforced, so the public content routes never consult the policy — only the draft and changelog routes, which advertise write capability, resolve it. The policy is not stored in the GitHub repository: it lives in a Netlify Blobs store (ACL_STORE, default acl) as a single authoritative JSON document. Only the wire-format WAC-Allow header keeps WAC/RDF semantics — the stored policy is plain JSON.
The policy store and the audit store are read with consistency: "strong", so an ACL change is enforced on the very next request (the Blobs default is eventual, which can serve a stale policy for up to 60 seconds).
A single blob, keyed policy, holds every scope and the deduplicated rulesets they resolve to. It is the source of truth (there is no separate authored form); recovery pairs the audit ledger with a periodic offsite export (see Audit ledger).
scopesis kept sorted; a request path resolves to the longest matching prefix.- Each scope names two rulesets:
self(grants on the scope resource itself — WACacl:accessTo) anddefault(grants inherited by descendants — WACacl:default). defaultis pre-flattened: it already contains every ancestor's defaults, so evaluation never walks the hierarchy.- A
subjectis one of{ "class": "public" },{ "class": "authenticated" },{ "type": "webid", "id": … },{ "group": … }, or{ "origin": … }. - Modes are
read,write,append, andcontrol;appendis a subclass ofwrite(awritegrant satisfies anappendrequirement). - Read modes are stored for completeness; enforcement currently covers
write/append/controlonly, because public reads are unenforced.
Rulesets are content-addressed and shared, so a policy covering thousands of resources but only a handful of ACLs stays O(#ACLs) in size — the index is keyed by scope, not by resource.
OWNER_WEBIDS is an out-of-band escape hatch that mirrors CommunitySolidServer's OwnerPermissionReader ("allows pod owners to always edit permissions on the data"). A WebID listed in OWNER_WEBIDS is always granted full modes (read, write, append, control) on every .acl resource, regardless of what the policy says, so an owner can never be locked out by a misconfigured ACL — they can always read and repair it. Like CSS, the override is scoped to ACL resources, not the data itself.
get(ACL_STORE, "policy", { consistency: "strong" }).- Longest-prefix match the request path against
scopes. - Use the matched scope's
selfwhen the request targets the scope itself, otherwise itsdefault. - Grant when some rule matches both the subject and the required mode.
That is one blob read plus a scan over a single (small) ruleset, independent of how many resources fall under the scope. It runs only for writes and control requests; public GETs skip it entirely.
PUT requires write on the target and, when creating it, append (or write) on the containing container. PATCH requires write, or append for edits that only insert, and likewise append/write on the container when creating the resource. POST requires append or write on the container. ACL resources require control.
The same resolution produces WAC-Allow for the routes that advertise it (draft and changelog): public lists the modes granted to foaf:Agent, and user lists the requesting agent's modes unioned with the public modes (public access implies user access, matching CommunitySolidServer).
Policy edits arrive on the ACL resource routes — PUT /<path>.acl (replace) and PATCH /<path>.acl (N3 Patch), both gated on control. Each is a read-modify-write on the policy blob, guarded by Netlify Blobs' compare-and-swap so racing editors cannot drop each other:
getthe document and itsetag.- Apply the change, then recompute the edited scope and every descendant scope's flattened
default(bounded by the number of scopes, not resources). setJSON(ACL_STORE, "policy", next, { onlyIfMatch: etag }).- On conflict (
modified: false), re-read, re-merge, and retry — the same optimistic-concurrency contract as the GitHubIf-Matchflow above. - Append an audit event.
Every edit appends an immutable event to ACL_AUDIT_STORE (default acl-audit) under a sortable key events/<isoTimestamp>#<ulid>, hash-chained so gaps or tampering are detectable:
{
"ts": "2026-10-08T12:00:00Z",
"actor": "https://alice.example/profile#me",
"scope": "/alice/private/",
"op": "grant",
"before": "rs_prev",
"after": "rs_priv_self",
"prevHash": "…",
"hash": "…"
}The ledger is the audit source of truth, not a backup: it shares the policy's provider, account, and region, captures only function-mediated edits, and Netlify Blobs has no versioning or restore — a store-wide loss or leaked credential takes both policy and ledger. Pair it with a periodic offsite export (a private GitHub repo fits well, since each backup is a commit with a human-readable diff) and treat that export as the disaster-recovery source. Restore by writing the most recent offsite snapshot back to the policy blob: restorePolicySnapshot (via scripts/wac-backup.sh restore, which drives the authenticated Netlify CLI) forwards onlyIfNew on an empty store or onlyIfMatch against the current etag, so a blind overwrite is refused and a rejected write raises SnapshotError. The pure helpers in src/wac/backup.ts (serializeSnapshot/parseSnapshot, plus exportPolicySnapshot for the policy blob) make export and restore work offline; the snapshot carries the full ruleset bodies, so it is self-contained. The audit ledger can then be checked with verifyChain(await listEvents(store)); because events record ruleset ids rather than ruleset bodies, later events cannot be replayed onto a restored snapshot. list({ prefix: "events/" }) enumerates history, and per-blob metadata (rev, updatedAt, updatedBy) renders an audit view without reading values.
An ACL resource is served under the WAC acl link relation and is gated by control:
| Resource | ACL resource URL |
|---|---|
Root container / |
/.acl |
Container /:page*/ |
/:page*/.acl |
Member /:page*/:doc |
/:page*/:doc.acl |
GETreturns the ACL resource astext/turtle(RDF), serialized from the stored JSON policy — the representation the spec requires. The representation distinguishes the scope'sselfgrants, emitted asacl:accessTo <scope resource>, from itsdefaultgrants, emitted asacl:default <container>; an authorization that carries both predicates is emitted once per predicate.PUTreplaces the scope's authored grants;PATCHapplies an N3 Patch (same ground-triples subset as the draft PATCH above). Both parseacl:accessToandacl:defaultindependently, so theselfanddefaultrulesets can diverge.GETon a path whose scope has no authored grants returns404(WAC: no representation, so the path inherits from its nearest ancestor). A container whose ACL has noacl:defaultgrants nothing to its descendants — there is no fall-through to a higher ancestor.GETresponses carryLink: <{acl URL}>; rel="acl"(per the Solid Protocol, auxiliary resources are advertised onGET/HEAD).
WAC-Allow is computed from the effective policy for the requesting agent and is emitted only on the routes that advertise write capability — the draft routes and the changelog root/month GETs. Because those responses are agent-dependent, they are private, no-store with Vary: Authorization, DPoP and are never shared-cached.
Public content reads are agent-independent and therefore edge-cacheable: the public file, container, and directory-index routes carry Netlify-CDN-Cache-Control: public, durable, max-age=60, stale-while-revalidate=86400, so a request inside the revalidation window is served stale while the edge refreshes in the background, and an edge miss is backed by Netlify's durable cache instead of a fresh GitHub round trip.
The invariant: a response is edge-cacheable if and only if it is agent-independent. Any response carrying WAC-Allow must be no-store; any cacheable response must not carry WAC-Allow and must not vary on Authorization/DPoP. A regression test enforces this.
204 with CORS allow-list as documented above (including Access-Control-Allow-Credentials: true). No auth, no upstream call.
npm run test:unit # Pure module tests (no HTTP, no GitHub)
npm run test:integration # Router handler tests with mocked dependencies
npm run test:e2e # Real `netlify dev` on port 9999 (boots in-process).
├── netlify/
│ └── functions/
│ └── router/
│ └── router.mts # GET/PUT/PATCH router for /:page*/:doc, /:page*/history/draft/:doc, and /:page*/history/:rest*
├── netlify.toml # Build config (runs derive-repo-start-year.mjs) + function routing
├── scripts/
│ └── derive-repo-start-year.mjs # Build-time step: writes REPO_START_YEAR to a generated .mjs
├── src/
│ ├── auth.ts # DPoP token verification
│ ├── changelog.ts # Changelog POST flow + GET synthesis helpers
│ ├── config.ts # Env loading (ownerWebIds, aclStore, aclAuditStore, githubRepo, githubToken, githubRef)
│ ├── github.ts # GitHub Contents API + refs helpers + commitFileOnBranch + listCommitsForPath + squashMergeBranch + deleteBranch
│ ├── history.ts # parseHistoryPath: pure URL shape -> discriminated union
│ ├── ldp.ts # LDP BasicContainer Turtle/HTML serializers
│ ├── patch.ts # Minimal N3 InsertDelete patch (ground triples) parser/applier
│ └── wac/ # WAC policy store, evaluator, and audit ledger
│ ├── policy.ts # Authoritative JSON policy document: resolve + read-modify-write (CAS)
│ ├── evaluate.ts # Longest-prefix scope resolution + subject/mode matching
│ ├── audit.ts # Append-only, hash-chained ACL ledger + periodic snapshots
│ └── blobs.ts # Netlify Blobs access (strong consistency, conditional writes)
├── tests/
│ ├── helpers/ # dev-server spawn (port 9999) + build-config setup
│ ├── unit/ # auth, build-config, changelog, config, github, history, ldp, patch, router
│ ├── integration/ # Router handler tests with mocked deps
│ └── e2e/ # Tests against `netlify dev`
└── LICENSE
- Netlify function (
netlify/functions/router/router.mts): the only externally reachable surface; stateless across invocations.- Route table (
config.path):/,/:page*/:doc,/:page*/,/:page*/history/draft/:doc,/:page*/history/draft/,/:page*/history/:rest*,/:page*/history/changelog/,/:page*/history/changelog/:year/,/:page*/history/changelog/:year/:month. - Methods (
config.method):POST, PATCH, PUT, GET, OPTIONSwithpreferStatic: true— matching assets in the staticpublic/are served first; everything else falls through to the function.
- Route table (
- GitHub: durable storage for file contents.
- Public reads:
${GITHUB_REPO}@${GITHUB_REF}:${page}/${doc}viaGET /repos/${repo}/contents/${path}?ref=${ref}withAccept: application/vnd.github.raw. - Public container listings:
${GITHUB_REPO}@${GITHUB_REF}:${page}/withAccept: application/vnd.github+json. - History routes:
GET /repos/${repo}/commits?sha=${branch}&path=${path}&since=...&until=...(enumerate commits) andGET /repos/${repo}/contents/${path}?ref=${shortSha}(fetch a file at that commit). - Draft reads and writes target
${GITHUB_REPO}@${page}-draft, which the function creates fromGITHUB_REFon first PUT/PATCH/POST per page. - Changelog shards are stored at
${GITHUB_REPO}@${GITHUB_REF}:<page>/.changelog/<year>/<month>.ttl. The first POST for a page creates${page}-draft, commits the new current-month shard, squash-merges it intoGITHUB_REF, and deletes the draft branch. - Squash-merge:
POST /repos/${repo}/mergeswith{ base, head, commit_message, squash: true }. Merge conflict (HTTP 409 from GitHub) is surfaced as 502.
- Public reads:
- Netlify Blobs: the WAC policy and audit stores. The
policyblob (ACL_STORE) is the authoritative, confidential access-control document;ACL_AUDIT_STOREis the append-only, hash-chained ledger. Blobs have no public URL — the function is the only gate — so ACLs never leave the confidential store. Policy reads useconsistency: "strong"; policy writes useonlyIfMatchcompare-and-swap. - OIDC issuer: any issuer can sign DPoP tokens.
- Every write and control request is authorized by WAC per the spec's method→mode mapping:
PUTneedswriteon the resource and, when creating it,append/writeon the container;PATCHneedswrite, orappendfor insert-only edits (plusappend/writeon the container when creating the resource);POSTneedsappendorwriteon the container. Public reads are unenforced. - Draft and changelog GETs compute
WAC-Allowfrom the effective policy; an unauthenticated request reports the public modes under bothuserandpublic(public access implies user access, matching CommunitySolidServer). OWNER_WEBIDSseeds the root policy withacl:Control,acl:Read, andacl:Writeat bootstrap.
- Every write and control request is authorized by WAC per the spec's method→mode mapping:
A typical repo backing this function looks like:
${GITHUB_REPO}/
├── main # GITHUB_REF (default branch)
│ ├── foo/bar.txt # served by GET /foo/bar
│ ├── foo/.changelog/ # changelog shards (per-month)
│ │ └── 2024/03.ttl # client-supplied triples for the 2024/03 bucket
│ └── alice/profile.ttl # served by GET /alice/profile.ttl
└── foo-draft # short-lived: created on edit, deleted after the next POST's squash
├── bar.txt # pending file edit
└── .changelog/2024/03.ttl # pending changelog edits (past-month PATCHes)
Each per-page ${page}-draft branch is created on first PUT/PATCH/POST and lives until the next POST's squash merge deletes it. Public reads and draft reads are isolated to their respective branches; the changelog POST is the only thing that promotes a draft to GITHUB_REF, and it does so via a single squash merge. Promoting other draft edits (regular file edits) to GITHUB_REF remains a separate GitHub-side PR/merge operation.
The WAC policy is deliberately absent from this tree: it lives only in Netlify Blobs, so ACLs are never committed to GITHUB_REPO and never served by a public route.
ACL_STORE (Netlify Blobs) # confidential; never in GITHUB_REPO
└── policy # single authoritative JSON policy document
ACL_AUDIT_STORE (Netlify Blobs)
└── events/<isoTimestamp>#<ulid> # append-only, hash-chained policy edits
<offsite backup> # periodic export (e.g. private GitHub repo)
└── policy/<rev>.json # full snapshots for disaster recovery
{ "schema": 1, "rev": 42, "updatedAt": "2026-10-08T12:00:00Z", "updatedBy": "https://alice.example/profile#me", "scopes": { "": { "self": "rs_root_self", "default": "rs_root_def" }, "/alice/": { "self": "rs_alice_self", "default": "rs_alice_def" }, "/alice/private/": { "self": "rs_priv_self", "default": "rs_priv_def" } }, "rulesets": { "rs_root_def": [ { "subject": { "class": "public" }, "modes": ["read"] } ], "rs_alice_def": [ { "subject": { "type": "webid", "id": "https://alice.example/profile#me" }, "modes": ["read", "write", "control"] } ], "rs_priv_def": [ { "subject": { "type": "webid", "id": "https://alice.example/profile#me" }, "modes": ["read"] } ] } }