@altimist/did-publisher
v0.6.0
Published
Mounts altimist-id's Resolver API on altimist-web's resolver-surface paths (DID document, revocations, team issuer keys). Phase 2a of F-010.
Readme
@altimist/did-publisher
Proxy altimist-id's Resolver API to the public did:web URLs (<handle>.altimist.com/.well-known/did.json, altimist.com/users/<handle>/did.json, altimist.com/.well-known/revocations.json, altimist.com/.well-known/team-issuers/<team>.json). Two consumption shapes:
routeResolverRequest()— single fetch handler dispatcher for Cloudflare Workers / Bun.serve / Deno.serve. The recommended path (ADR-012 Option W).didJsonHandler/revocationsHandler/teamIssuersHandler— Next.js App Router route-handler factories. v0.1 API; retained for any consumer that wants to mount per-route handlers under file-based routing.
Decision rationale in ADR-011 (original placement on altimist-web, never deployed) and ADR-012 (Cloudflare Worker placement, current direction).
Install
npm install @altimist/did-publisherUsage — Cloudflare Workers (recommended)
// src/index.ts
import { routeResolverRequest } from '@altimist/did-publisher';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const response = await routeResolverRequest(request, {
origin: env.ALTIMIST_ID_ORIGIN, // e.g. "https://altimist.id"
apex: env.ALTIMIST_ID_APEX, // "altimist.com" / "staging.altimist.com"
payProxySecret: env.FINTERNET_PAY_PROXY_SECRET, // optional (F-024); omit to fall back to platform IP
trustCloudflareHeaders: true, // ONLY when this genuinely IS a CF Worker behind Cloudflare (see below)
});
return response ?? new Response('Not Found', { status: 404 });
},
};routeResolverRequest() returns:
- A
Responsefor the resolver-surface URLs (see the route table below). nullfor everything else — the caller decides what to do (404, fall through to another origin, etc).
Usage — Next.js App Router (v0.1 API)
In a Next.js project that owns *.altimist.com, mount three route handlers:
// app/.well-known/did.json/route.ts
import { didJsonHandler } from '@altimist/did-publisher';
export const GET = didJsonHandler({
origin: process.env.ALTIMIST_ID_URL!, // e.g. "https://altimist.id"
apex: process.env.RESOLVER_APEX || 'altimist.com',
});// app/.well-known/revocations.json/route.ts
import { revocationsHandler } from '@altimist/did-publisher';
export const GET = revocationsHandler({
origin: process.env.ALTIMIST_ID_URL!,
});// app/.well-known/team-issuers/[team]/route.ts
import { teamIssuersHandler } from '@altimist/did-publisher';
export const GET = teamIssuersHandler({
origin: process.env.ALTIMIST_ID_URL!,
});What each route does
| Path | Behaviour | Cache | CORS |
|---|---|---|---|
| <handle>.<apex>/.well-known/did.json | Proxies ${origin}/api/resolver/did/<handle> | altimist-id's, forwarded (measured 2026-09-07: public, max-age=0, s-maxage=60, must-revalidate) | altimist-id's, forwarded |
| <apex>/users/<handle>/did.json | Proxies ${origin}/api/resolver/did/<handle>?form=path (F-011 path-form DID hosting) | altimist-id's, forwarded | altimist-id's, forwarded |
| <apex>/.well-known/revocations.json | Proxies ${origin}/api/resolver/revocations | altimist-id's, forwarded (measured 2026-09-07: public, max-age=0, s-maxage=30, must-revalidate) | altimist-id's, forwarded |
| <apex>/.well-known/team-issuers/<team>.json | Proxies ${origin}/api/resolver/team-issuers/<team> | altimist-id's, forwarded (measured 2026-09-07: public, max-age=0, s-maxage=300, must-revalidate) | altimist-id's, forwarded |
| <handle>.<apex>/finternet-pay/v1/descriptors (POST) | Proxies ${origin}/api/pay/<handle>/descriptors — the altimist-id F-024 signed payment-descriptor endpoint. Body forwarded verbatim; x-finternet-pay-proxy + CF-Connecting-IP forwarded only when both payProxySecret and trustCloudflareHeaders are set (and the request carries CF-Connecting-IP). Non-POST → 405 locally (never proxied) | no-store (never cached — money path, at any status, including guards/405) | none — see below |
| <apex>/users/<handle>/finternet-pay/v1/descriptors (POST) | Path-form of the above (mirrors F-011 path-form DID hosting) | no-store | none |
trustCloudflareHeaders— read before setting.CF-Connecting-IPis only authentic when Cloudflare's own edge terminates the connection: CF strips or overwrites any client-supplied value before the Worker sees it. SettrustCloudflareHeaders: trueonly in a deployment that is provably a genuine Cloudflare Worker sitting directly behind Cloudflare's network (likealtimist-com-router). Never set it in a Bun/Deno (or any) self-hosted deployment reachable directly from the public internet — there an attacker can forgeCF-Connecting-IP, and with a matchingpayProxySecretthe proxy would forward that forged IP as genuine, which altimist-id would then trust. Left unset (the default) the header pair is never forwarded and altimist-id safely falls back to its platform-observed IP.
<apex> is configured per environment (altimist.com for production, staging.altimist.com for staging). Handle URLs require a single-label subdomain (patrick.altimist.com); apex paths require the host to be exactly the apex (returns 404 otherwise).
Cache policy is altimist-id's, and is forwarded rather than restated
This package sets no cache policy of its own (v0.5.0). Every resolver
response carries Cache-Control verbatim from altimist-id, whatever that is at
the time; the "currently" values in the table above are altimist-id's, quoted
for orientation, not a contract this package enforces. The one exception is the
FinternetPay money path, which is unconditionally no-store at any status.
It was not always so, and the reason is worth keeping. Up to v0.4.0 this
package held its own copies of the three cache strings. altimist-id then
removed stale-while-revalidate=86400 from did.json — cache purge is a
confirmed no-op in production, so swr let the CDN serve a revoked device
key to a third-party verifier for up to 24 hours — shipped it, and verified
it on its own route. The header a verifier actually received never changed,
because DID_CACHE here overrode it. A second copy of a policy is drift by
construction; forwarding removes the class rather than the instance.
This did not by itself close the revocation-staleness hole, and altimist-id
has since closed it. When v0.5.0 shipped, altimist-id still sent
stale-while-revalidate=86400 on revocations.json, which does not deliver
the 60s upper bound on revocation propagation F-010's non-functional AC
states when the origin is degraded — and because the old constant here held
the same string, forwarding changed nothing on that route. It became a
one-line change in altimist-id needing no publish here, which was the point,
and that change has landed: measured 2026-09-07, none of the three read
routes sends stale-while-revalidate at all.
Two consequences worth knowing:
- A
Cache-Controlis forwarded at any status, not only200. altimist-id answers an unknown handle with an explicitno-store, and dropping that is worse than passing it on. - Responses this package generates itself carry no cache header at all: the
no-handle and wrong-host guards, and the synthesised
503below.
CORS policy is altimist-id's too, and is forwarded the same way
This package sets no CORS policy of its own (v0.6.0). Every proxied
resolver response carries upstream's Access-Control-* headers verbatim —
today access-control-allow-origin: *, access-control-allow-methods:
GET, HEAD, OPTIONS and access-control-max-age: 86400.
This is the second half of the same row-51 defect, and it was live in production until v0.6.0. Measured 2026-09-07:
https://altimist.id/api/resolver/did/rich 200, access-control-allow-origin: *
https://rich.altimist.com/.well-known/did.json 200, no ACAO, x-alt-cache: missx-alt-cache: miss proves the response was built fresh, so it was neither a
stale cache nor deploy lag. The response builder rebuilt the Response from
scratch with a two-header allowlist — Content-Type plus Cache-Control —
and dropped every other upstream header silently. altimist-id had been
sending the CORS headers all along and they never reached a caller, so no
browser on any other origin could resolve an Altimist DID: a cross-origin
fetch without an Access-Control-Allow-Origin is unreadable, and did:web
resolution is inherently cross-origin.
The rules mirror the cache-policy ones exactly:
- Collected by prefix (
access-control-), not by a list of header names. A named list is what caused the bug; a second one would only move it — altimist-id addingAccess-Control-Expose-Headerswould arrive as another silent drop. - Forwarded unvalidated, including a policy this package would consider wrong. Filtering or correcting upstream's CORS decisions would be a second opinion about them, which is the class row 51 is about.
- Nothing is invented. In particular there is no
Access-Control-Allow-Credentials: it is illegal per the Fetch standard alongside the*altimist-id sends, and a browser rejects such a response rather than relaxing. - Forwarded at any status, so a browser can read altimist-id's own 404.
- Responses this package generates itself carry no CORS header, for the
same reason they carry no cache header — there is no upstream response to
forward one from. So a cross-origin caller asking a host that carries no
handle at all (the apex, a multi-label host) sees a CORS failure rather
than a readable
404. - Nothing but the
access-control-family rides along. altimist-id is a Next.js app and sends aVarynaming four RSC-internal headers, plusX-Powered-By; neither means anything on a did:web document. - The money path gets no CORS headers at any status. altimist-id sends it
none, it is a
POSTneeding a preflight this package does not answer, and granting a cross-origin read of a payment descriptor is altimist-id's decision to make, not a proxy's to infer.
Known gap, in altimist-id rather than here. Measured 2026-09-07, altimist-id sends no
Access-Control-*on its resolver 404s (/api/resolver/did/<unknown>answers404withCache-Controland nothing else). Forwarding faithfully means a cross-origin browser resolving an unknown handle still sees a CORS failure instead of a readable{"error":"unknown handle"}. Closing that is a change to altimist-id's resolver routes; this package will forward it the day it ships, with no publish needed here.
Failure modes
- Upstream 4xx (404 unknown handle, etc.): pass through with the same status and upstream's
Cache-ControlandAccess-Control-*if it sent them. - Upstream 3xx: returns
503. Redirects are never followed — "upstream" means the configuredoriginand nothing else, because since v0.5.0 upstream also chooses the cache policy. (redirect: "manual"plus a status check, notredirect: "error", which workerd refuses.) - Upstream 5xx or timeout: returns
503so consumer apps fail-closed (DID resolution unavailable). No cache header and no CORS header — a fail-closed response this package invented carries no policy it invented. - A body that never fully arrives: returns
503. A partial read is a transport failure, never a200 {}.
Cloudflare cache purge
altimist-id is responsible for purging the edge cache on every mutation (device enrolment, revocation, etc.). This package is read-only — see altimist-id's src/lib/cache-purge.ts for the purge side.
License
UNLICENSED — internal Altimist.
