A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Resolve did:plc through Eurosky's EU mirror before plc.directory

The read renderer resolved every did:plc author's PDS endpoint from
plc.directory, the canonical directory run by Bluesky PBC in the US.
That was the last centralised, US-operated dependency on an otherwise
self-hostable read path (handle resolution already prefers the handle's
own .well-known; did:web resolves from the domain). For an instance
built around European data sovereignty, every reader request leaking to
plc.directory undercut the premise.

Resolve did:plc through an ordered host list -- Eurosky's EU-hosted
plc-mirror (https://plc.eurosky.network) first, plc.directory as
fallback -- trying the next host on any non-ok response or thrown
request. The mirror speaks the same GET /{did} API and DID-doc shape,
so this is a host-ordering change, not a new protocol path. did:web is
unchanged. SSRF guarantees hold: both hosts are public so each request
passes safeFetch, and the serviceEndpoint is still validated through
assertSafeUrl.

Mirror-first accepts one trade-off: a mirror lagging the canonical log
could briefly return a stale endpoint right after a writer migrates
their PDS (the article 404s until the mirror syncs). The canonical
fallback covers misses and outages. Rationale and the deferred
handle-resolver follow-up are recorded in
docs/decisions/0022-eurosky-plc-primary.md.

+233 -15
+64
docs/decisions/0022-eurosky-plc-primary.md
··· 1 + # 0022 — Resolve did:plc via the Eurosky mirror first, plc.directory as fallback 2 + 3 + ## Context 4 + 5 + The read-through renderer turns a writer's DID into their PDS `serviceEndpoint` in 6 + `src/lib/media/pds.ts` (`resolvePdsUrl`). For `did:plc` this means fetching the DID 7 + document from a PLC directory. Until now that was hard-coded to `https://plc.directory`, 8 + the canonical directory operated by Bluesky PBC in the US. 9 + 10 + That is the one remaining centralised, US-operated dependency on the otherwise 11 + self-hostable read path (handle resolution already prefers the handle's own 12 + `/.well-known/atproto-did`; `did:web` resolves from the domain itself). For an instance 13 + positioning itself around European data sovereignty, every `did:plc` reader request 14 + leaving for `plc.directory` undercuts that. 15 + 16 + Eurosky (Modal Foundation, NL non-profit) runs a public `plc-mirror` on EU infrastructure 17 + at `https://plc.eurosky.network`. Verified 2026-06-19: same `GET /{did}` API and DID-doc 18 + shape (`#atproto_pds` / `AtprotoPersonalDataServer` → `serviceEndpoint`) as the canonical 19 + directory; sampled DIDs (incl. `eurosky.social` and Bluesky-era DIDs) all return `200`. 20 + 21 + ## Options 22 + 23 + 1. **Replace** `plc.directory` with the mirror. — Maximal sovereignty, but a mirror can 24 + lag or go down, and lag means serving a *stale* `serviceEndpoint` right after a writer 25 + migrates their PDS (article 404s until the mirror catches up). Single point of failure. 26 + 2. **Mirror first, canonical fallback.** — Sovereignty by default; canonical (source of 27 + truth) catches misses/lag/outages. Accepts a brief, self-healing staleness window only 28 + in the rare case the mirror answers but is behind. 29 + 3. **Canonical first, mirror fallback.** — Never stale, but barely uses EU infra. 30 + 4. **Configurable single host (env), default canonical.** — Simple, but no resilience and 31 + off-by-default for sovereignty. 32 + 33 + ## Decision 34 + 35 + **Option 2.** `PLC_HOSTS = ['https://plc.eurosky.network', 'https://plc.directory']`, tried 36 + in order; any failure falls through to the next host. A "failure" is broader than a bad 37 + HTTP response: the doc is parsed and the PDS endpoint extracted *inside* the same try, so a 38 + mirror that returns `200` with a malformed doc (no `#atproto_pds` service, or a 39 + `serviceEndpoint` that fails `assertSafeUrl`) also falls back to canonical rather than 40 + failing the read. `did:web` is unchanged (resolves from the domain). Both hosts are public, 41 + so each request still passes the `safeFetch` SSRF guard; the DID-doc `serviceEndpoint` is 42 + still validated through `assertSafeUrl` before use, regardless of which host produced it. 43 + 44 + ## Why 45 + 46 + - Keeps DID resolution on European infrastructure by default — the point of this instance. 47 + - Canonical fallback means a mirror miss/outage/malformed response degrades gracefully 48 + instead of breaking reads. Staleness is only *partially* self-correcting: the fallback 49 + fires when a stale endpoint is unreachable/non-ok, but mirror-first cannot detect a 50 + stale-**but-still-live** endpoint — if the mirror returns `200` with an old-yet-resolvable 51 + `serviceEndpoint` (e.g. just after a PDS migration, while the old host still answers), that 52 + endpoint is used and canonical is never consulted. Accepted for a renderer: worst case is 53 + briefly serving a writer's article from their previous PDS until the mirror syncs. If this 54 + ever matters, the fix is to consult canonical for freshness, not a deeper fallback. 55 + - Drop-in: the mirror speaks the same API, so this is a host-ordering change, not a new 56 + protocol path. No change to the SSRF guarantees (Decision 0016 / `safe-fetch.ts`). 57 + 58 + ## Consequences / follow-ups 59 + 60 + - Resolution order is hard-coded. If other self-hosters want canonical-first (or a 61 + non-Eurosky mirror), promote `PLC_HOSTS` to an env-configured list. Deferred until needed. 62 + - The handle→DID fallback in `src/lib/reader/identity.ts` still uses `https://bsky.social` 63 + as its XRPC resolver (only when a handle lacks `/.well-known/atproto-did`). Same 64 + sovereignty shape, lower traffic; left as a separate, lesser follow-up.
+117
src/lib/media/pds.test.ts
··· 1 + import { afterEach, describe, expect, it, vi } from 'vitest'; 2 + import { resolvePdsUrl } from './pds'; 3 + 4 + /** 5 + * `resolvePdsUrl` turns a writer's DID into their PDS `serviceEndpoint`. For `did:plc` it 6 + * consults the PLC directory; we resolve through Eurosky's European mirror first (data 7 + * sovereignty) and fall back to the canonical Bluesky-run `plc.directory` so a mirror 8 + * miss/lag/outage never breaks resolution. `safeFetch` calls global `fetch` after its 9 + * public-host guard, so stubbing `fetch` (both PLC hosts are public) drives these tests. 10 + * See docs/decisions/0022-eurosky-plc-primary.md. 11 + */ 12 + const DID = 'did:plc:abc123'; 13 + const ENC = encodeURIComponent( DID ); 14 + const EUROSKY = `https://plc.eurosky.network/${ ENC }`; 15 + const CANONICAL = `https://plc.directory/${ ENC }`; 16 + 17 + function didDoc( endpoint: string ) { 18 + return { 19 + ok: true, 20 + status: 200, 21 + json: async () => ( { 22 + service: [ 23 + { 24 + id: '#atproto_pds', 25 + type: 'AtprotoPersonalDataServer', 26 + serviceEndpoint: endpoint, 27 + }, 28 + ], 29 + } ), 30 + } as unknown as Response; 31 + } 32 + 33 + const notFound = () => 34 + ( { ok: false, status: 404, json: async () => ( {} ) } as unknown as Response ); 35 + 36 + describe( 'resolvePdsUrl — did:plc resolution order', () => { 37 + afterEach( () => vi.unstubAllGlobals() ); 38 + 39 + it( 'resolves via the Eurosky mirror first, without touching the canonical directory', async () => { 40 + const fetchSpy = vi.fn( async ( url: URL | string ) => { 41 + if ( String( url ) === EUROSKY ) { 42 + return didDoc( 'https://pds.example.com' ); 43 + } 44 + throw new Error( `unexpected fetch: ${ url }` ); 45 + } ); 46 + vi.stubGlobal( 'fetch', fetchSpy ); 47 + 48 + expect( await resolvePdsUrl( DID ) ).toBe( 'https://pds.example.com' ); 49 + const hosts = fetchSpy.mock.calls.map( ( c ) => String( c[ 0 ] ) ); 50 + expect( hosts ).toContain( EUROSKY ); 51 + expect( hosts ).not.toContain( CANONICAL ); 52 + } ); 53 + 54 + it( 'falls back to plc.directory when the mirror returns a non-ok response', async () => { 55 + const fetchSpy = vi.fn( async ( url: URL | string ) => { 56 + if ( String( url ) === EUROSKY ) { 57 + return notFound(); 58 + } 59 + if ( String( url ) === CANONICAL ) { 60 + return didDoc( 'https://canonical-pds.example.com' ); 61 + } 62 + throw new Error( `unexpected fetch: ${ url }` ); 63 + } ); 64 + vi.stubGlobal( 'fetch', fetchSpy ); 65 + 66 + expect( await resolvePdsUrl( DID ) ).toBe( 'https://canonical-pds.example.com' ); 67 + } ); 68 + 69 + it( 'falls back to plc.directory when the mirror request throws', async () => { 70 + const fetchSpy = vi.fn( async ( url: URL | string ) => { 71 + if ( String( url ) === EUROSKY ) { 72 + throw new Error( 'network down' ); 73 + } 74 + if ( String( url ) === CANONICAL ) { 75 + return didDoc( 'https://canonical-pds.example.com' ); 76 + } 77 + throw new Error( `unexpected fetch: ${ url }` ); 78 + } ); 79 + vi.stubGlobal( 'fetch', fetchSpy ); 80 + 81 + expect( await resolvePdsUrl( DID ) ).toBe( 'https://canonical-pds.example.com' ); 82 + } ); 83 + 84 + it( 'throws after trying every PLC host when none can resolve the DID', async () => { 85 + const fetchSpy = vi.fn( async () => notFound() ); 86 + vi.stubGlobal( 'fetch', fetchSpy ); 87 + 88 + await expect( resolvePdsUrl( DID ) ).rejects.toThrow( 89 + /Failed to resolve DID document/ 90 + ); 91 + expect( fetchSpy ).toHaveBeenCalledTimes( 2 ); 92 + } ); 93 + 94 + it( 'throws on an unsupported DID method without any network request', async () => { 95 + const fetchSpy = vi.fn(); 96 + vi.stubGlobal( 'fetch', fetchSpy ); 97 + 98 + await expect( resolvePdsUrl( 'did:foo:bar' ) ).rejects.toThrow( 99 + /Unsupported DID method/ 100 + ); 101 + expect( fetchSpy ).not.toHaveBeenCalled(); 102 + } ); 103 + 104 + it( 'resolves did:web from the domain itself, never via a PLC host', async () => { 105 + const fetchSpy = vi.fn( async ( url: URL | string ) => { 106 + if ( String( url ) === 'https://example.com/.well-known/did.json' ) { 107 + return didDoc( 'https://pds.example.com' ); 108 + } 109 + throw new Error( `unexpected fetch: ${ url }` ); 110 + } ); 111 + vi.stubGlobal( 'fetch', fetchSpy ); 112 + 113 + expect( await resolvePdsUrl( 'did:web:example.com' ) ).toBe( 'https://pds.example.com' ); 114 + const hosts = fetchSpy.mock.calls.map( ( c ) => String( c[ 0 ] ) ); 115 + expect( hosts.some( ( h ) => h.includes( 'plc.' ) ) ).toBe( false ); 116 + } ); 117 + } );
+52 -15
src/lib/media/pds.ts
··· 1 1 import { safeFetch, assertSafeUrl } from '../net/safe-fetch'; 2 2 3 3 /** 4 - * Resolve a writer's PDS endpoint from their DID document. Handles `did:plc` (via 5 - * plc.directory) and `did:web`. The `did:web` host and the returned `serviceEndpoint` 6 - * are untrusted, so both go through `safeFetch`/`assertSafeUrl` to prevent SSRF. 4 + * PLC directory hosts for `did:plc` resolution, tried in order. Eurosky's European mirror 5 + * is primary (data sovereignty — keep DID lookups on EU infrastructure); the canonical 6 + * Bluesky-run `plc.directory` is the fallback so a mirror miss, lag, or outage never breaks 7 + * resolution. Both are public hosts, so each request still passes the `safeFetch` SSRF guard. 8 + * See docs/decisions/0022-eurosky-plc-primary.md. 7 9 */ 8 - export async function resolvePdsUrl( did: string ): Promise< string > { 9 - let docUrl: string; 10 - if ( did.startsWith( 'did:plc:' ) ) { 11 - docUrl = `https://plc.directory/${ encodeURIComponent( did ) }`; 12 - } else if ( did.startsWith( 'did:web:' ) ) { 13 - const host = did.slice( 'did:web:'.length ).replace( /:/g, '/' ); 14 - docUrl = `https://${ host }/.well-known/did.json`; 15 - } else { 16 - throw new Error( `Unsupported DID method: ${ did }` ); 17 - } 10 + const PLC_HOSTS = [ 'https://plc.eurosky.network', 'https://plc.directory' ]; 11 + 12 + interface DidDocument { 13 + service?: Array< { id: string; type: string; serviceEndpoint: string } >; 14 + } 18 15 16 + /** Fetch and parse a DID document. Throws on a non-ok response. */ 17 + async function fetchDidDoc( docUrl: string ): Promise< DidDocument > { 19 18 const res = await safeFetch( docUrl ); 20 19 if ( ! res.ok ) { 21 20 throw new Error( `Failed to resolve DID document (${ res.status })` ); 22 21 } 23 - const doc: { service?: Array< { id: string; type: string; serviceEndpoint: string } > } = 24 - await res.json(); 22 + return ( await res.json() ) as DidDocument; 23 + } 24 + 25 + /** Pull the atproto PDS `serviceEndpoint` out of a DID document. */ 26 + function extractPdsEndpoint( doc: DidDocument ): string { 25 27 const pds = ( doc.service ?? [] ).find( 26 28 ( service ) => 27 29 service.id === '#atproto_pds' || ··· 33 35 // The endpoint comes from the DID doc — validate before anyone fetches it. 34 36 return assertSafeUrl( pds.serviceEndpoint ).toString().replace( /\/$/, '' ); 35 37 } 38 + 39 + /** Resolve a `did:plc` via the PLC hosts in order, falling back on any failure. */ 40 + async function resolveViaPlcHosts( did: string ): Promise< string > { 41 + const path = encodeURIComponent( did ); 42 + let lastError: unknown; 43 + for ( const host of PLC_HOSTS ) { 44 + try { 45 + return extractPdsEndpoint( await fetchDidDoc( `${ host }/${ path }` ) ); 46 + } catch ( error ) { 47 + lastError = error; 48 + } 49 + } 50 + throw lastError instanceof Error 51 + ? lastError 52 + : new Error( `Failed to resolve ${ did } from any PLC directory` ); 53 + } 54 + 55 + /** 56 + * Resolve a writer's PDS endpoint from their DID document. Handles `did:plc` (via the PLC 57 + * directory hosts, Eurosky mirror first) and `did:web` (from the domain's own `did.json`). 58 + * The `did:web` host and the returned `serviceEndpoint` are untrusted, so both go through 59 + * `safeFetch`/`assertSafeUrl` to prevent SSRF. 60 + */ 61 + export async function resolvePdsUrl( did: string ): Promise< string > { 62 + if ( did.startsWith( 'did:plc:' ) ) { 63 + return resolveViaPlcHosts( did ); 64 + } 65 + if ( did.startsWith( 'did:web:' ) ) { 66 + const host = did.slice( 'did:web:'.length ).replace( /:/g, '/' ); 67 + return extractPdsEndpoint( 68 + await fetchDidDoc( `https://${ host }/.well-known/did.json` ) 69 + ); 70 + } 71 + throw new Error( `Unsupported DID method: ${ did }` ); 72 + }