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),ETag, andCache-Control; 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. HonorsIf-None-Match(emitsVary: If-None-Match). PUT on container paths is rejected with 405. Page-root responses (/: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,ETag, andCache-Controlare forwarded, 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>.- Auth is optional: if both
AuthorizationandDPoPheaders are present,verifyDpopTokenagainstWRITE_WEBIDSsetsWAC-Allowtouser="read write", public="read"for an authenticated allowlisted WebID, elseuser="read", public="read". - 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.
- Auth is optional: if both
- Draft PUT
PUT /:page*/history/draft/:doc— Solid-OIDC-authenticated againstWRITE_WEBIDS.- 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 againstWRITE_WEBIDS.- 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. - Changelog month PATCH
PATCH /:page*/history/changelog/YYYY/MM— Solid-OIDC-authenticated againstWRITE_WEBIDS. 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 againstWRITE_WEBIDS. 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, Date, Digest, Signature, If-None-Match, If-Match; exposesETag, Cache-Control, WAC-Allow, Allow, Accept-Put, Accept-Patch; echoesOrigin(falls back to*);Vary: Origin.
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 WRITE_WEBIDS, GITHUB_REPO, GITHUB_TOKEN, GITHUB_REF for netlify dev.
| Variable | Required | Description |
|---|---|---|
WRITE_WEBIDS |
Yes | Comma-separated list of WebIDs allowed to write (PUT). Empty list → no PUTs allowed. |
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. |
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),ETag, andCache-Controlfrom the upstream response. - 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 container path is derived from the URL pathname (not context.params, because Netlify's greedy :page* swallows trailing slashes into the previous segment), and an empty pathname / lists the repo root.
-
Load
GITHUB_REPO/GITHUB_TOKEN/GITHUB_REFfrom env. -
Detect container requests via
pathname === '/' || pathname.endsWith('/'). The container path is the pathname stripped of leading and trailing slashes (root/→ empty path, whichlistDirectoryFromGitHubtranslates to the repo-root Contents URL). -
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
/:page*/(not/, 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).
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-8. AddVary: If-None-Matchwhen the caller sent anIf-None-Match. 5xx is surfaced asGitHubFetchError→ 502; 4xx surfaces asGitHubApiError→ 502. -
Directory-index for HTML clients — before any of the above listing work, the router checks the
Acceptheader. If the best match in the HTML family (text/html,application/xhtml+xml) outscores the best match in the Turtle family (text/turtle,text/n3) by q-value (or by order on tie), the router first attemptsfetchFileFromGitHubfor<page>/index.html(orindex.htmlforGET /) against the samerefthe container would have used. On 200/304 it returns that file with the upstreamContent-Type,ETag, andCache-Controlforwarded andVary: If-None-Matchadded when the caller sentIf-None-Match; the URL stays/:page*/(no redirect). On any other status (404 included), the router falls through to the Turtle listing path above. A missingAccept,*/*, or anAcceptthat mentions neither family all default to the existing Turtle behavior (back-compat).
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.
- Load env; resolve
path; reject 400 on unsafe path. - If the request carries both
AuthorizationandDPoPheaders, runverifyDpopTokenagainstWRITE_WEBIDS. Anonymous reads (no headers, or one header missing) are not rejected. - 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-Allow:user="read write", public="read"for an authenticated allowlisted WebID;user="read", public="read"for an unauthenticated/anonymous reader. 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.
- Load
WRITE_WEBIDS; verify the DPoP token viaverifyDpopToken(Authorization+DPoPbound toPUT+req.url), allow-listed againstWRITE_WEBIDS; reject 401 if headers missing, 403 if the WebID isn't allowlisted. - 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:
- Load
WRITE_WEBIDS; verify the DPoP token viaverifyDpopToken(Authorization+DPoPbound toPATCH+req.url), allow-listed againstWRITE_WEBIDS. - 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. - 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' }>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. 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.
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.
- Load
WRITE_WEBIDS; verify DPoP token bound toPOST+req.url; reject 401/403. - 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. - If the client payload is non-empty, read the current month shard from
GITHUB_REF, substitute_:b1→<#current>, append the payload, andcommitFileOnBranchthe result to${page}-draft. If the payload is empty, skip the file write entirely — the squash-merge below still creates the commit. 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).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.
GETs read GITHUB_REF only (no draft fallback) and 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>.ttlonGITHUB_REF. 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>/.
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.
204 with CORS allow-list as documented above. 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 (writeWebIds, 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 Patch (M3-insert subset) parser/applier
├── 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:
- OIDC issuer: any issuer can sign DPoP tokens.
- Only tokens whose
payload.webidis inWRITE_WEBIDSare accepted on PUT, PATCH, or POST. - Draft GET: same allowlist gates the
WAC-Allowupgrade — anonymous readers (noAuthorization/DPoPheaders) and non-allowlisted WebIDs both getuser="read", public="read"(public read is always permitted); only an authenticated allowlisted WebID elevates touser="read write", public="read".
- Only tokens whose
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.