Language-agnostic contract for Drawbridge — the public-facing API gateway that fronts the Gatekeeper, the Lightning mediator, and the Herald name service. It enforces an L402 (“Lightning HTTP 402”) paywall and a subscription-credential alternative on every protected route, then proxies the request upstream.
The canonical implementation is services/drawbridge/server/.
Related specs. Drawbridge is a thin proxy. The behaviour of every protected endpoint is the same as the corresponding Gatekeeper / Lightning-mediator / Herald endpoint — read those specs for the downstream contract. This document covers only what Drawbridge adds on top: the L402 challenge/response, subscription auth, per-operation pricing, rate limiting, and the route prefix layout.
The Drawbridge sits on the public network edge of an Archon node. It has three jobs:
X-Subscription-DID header
proving they hold a valid subscription credential issued by this
deployment’s owner; no payment per request./api/v1/did, /dids, /ipfs/*, /block/*, /search, /query)
are forwarded to the local Gatekeeper. Lightning routes are
forwarded to the local Lightning mediator. Herald routes
(/.well-known/*, /names/*) are forwarded to the local Herald./ready, /version, /status, /invoice/:did,
the Herald well-known endpoints.It carries no key material. The macaroon root secret
(ARCHON_DRAWBRIDGE_MACAROON_SECRET) is the only sensitive material on
disk; it MUST be ≥ 32 characters and is used only to sign + verify the
service’s own macaroons (HMAC-SHA-256 internal to macaroons.js).
Binds to ${ARCHON_BIND_ADDRESS}:${ARCHON_DRAWBRIDGE_PORT} (default
0.0.0.0:4222).
| Method | Path | Notes |
|---|---|---|
GET |
/api/v1/ready |
Returns <bool> — proxies upstream Gatekeeper readiness. false on connect failure. |
GET |
/api/v1/version |
{ version, commit } (commit is GIT_COMMIT sliced to 7 chars; the sentinel string "unknown" is returned when GIT_COMMIT is unset). |
GET |
/api/v1/status |
{ service: "drawbridge", upstream: GatekeeperStatus, uptime: <seconds>, memoryUsage: <node memUsage> }. HTTP 502 if Gatekeeper is unreachable. |
POST |
/api/v1/l402/pay |
Final step of L402 flow. Body: { paymentHash }. Marks the matching macaroon as paid + returns the bearer credential. See §4.4. |
GET |
/invoice/:did |
Forwarded to lightning-mediator’s /invoice/:did. Returns { paymentRequest, paymentHash, ... }. Used by external zappers to pay any DID that has published a Lightning service. |
GET |
/.well-known/* |
Forwarded to Herald (e.g. lnurlp/<name>, webfinger, names). |
GET\|POST\|PUT\|DELETE |
/names/* |
Forwarded to Herald (/api/*). |
GET |
/metrics |
Prometheus exposition. |
| Method | Path | Notes |
|---|---|---|
GET |
/api/v1/l402/status |
Returns service-level L402 status: { enabled, lightning, pricing: [<operationKey>, ...] }. |
POST |
/api/v1/l402/revoke |
Body: { macaroonId: <macaroonId> }. Revokes the macaroon (subsequent verifications return invalid). |
GET |
/api/v1/l402/payments/:did |
List of PaymentRecord for the DID. |
All admin routes require the X-Archon-Admin-Key header. When the
server ARCHON_ADMIN_API_KEY is unconfigured (empty), admin routes
return HTTP 403 { "error": "Admin API key not configured" }. When
configured, a missing or mismatched client header returns HTTP 401.
Constant-time comparison is used.
The following all require auth (subscription header or valid L402
bearer). On 402 challenge, the response is HTTP 402 with a
WWW-Authenticate: L402 macaroon="<base64>", invoice="<bolt11>" header
and a JSON body with the same fields.
Each route is a 1:1 forward to the upstream Gatekeeper after auth passes; the response shape is identical to the Gatekeeper’s. See the Gatekeeper spec §2 for each route’s contract.
| Method | Path |
|---|---|
GET |
/api/v1/registries |
POST |
/api/v1/did |
POST |
/api/v1/did/generate |
GET |
/api/v1/did/:did (forwards versionTime/versionSequence/confirm/verify query params) |
POST |
/api/v1/dids |
POST |
/api/v1/dids/export |
POST/GET |
/api/v1/ipfs/json[/:cid] |
POST/GET |
/api/v1/ipfs/text[/:cid] |
POST/GET |
/api/v1/ipfs/data[/:cid] |
POST/GET |
/api/v1/ipfs/stream[/:cid] (true streaming both directions; no body buffering) |
GET |
/api/v1/block/:registry/latest |
GET |
/api/v1/block/:registry/:blockId |
GET |
/api/v1/search?q= |
POST |
/api/v1/query |
Plus a wildcard mount of all Lightning routes, forwarded to the Lightning mediator:
| Method | Path |
|---|---|
* |
/api/v1/lightning/** |
The path tail is appended onto the upstream URL untouched. See the Lightning mediator spec.
Global Express limits (json, urlencoded, text, raw for
application/octet-stream): 10 MB each. The IPFS stream POST
(/api/v1/ipfs/stream) explicitly bypasses the raw parser so the
upstream Kubo client can stream the request body directly.
cors() with permissive defaults (any origin, no
credentials)./.well-known/*, /names/*): cors({ origin:
true, credentials: true }) — required for browser flows that depend
on session cookies (Herald login, OAuth/OIDC).All error responses are application/json { "error": "<message>" }
unless a downstream proxy returned its own body verbatim. Standard
status codes:
401 — auth header malformed or admin key wrong402 — L402 challenge (with WWW-Authenticate header)403 — admin not configured / scope insufficient429 — rate-limited502 — upstream Gatekeeper / Lightning / Herald unreachableThe route label is the request path with dynamic segments collapsed:
/did/<did> -> /did/:did
/invoice/<did> -> /invoice/:did
/ipfs/json/<cid> -> /ipfs/json/:cid
/ipfs/text/<cid> -> /ipfs/text/:cid
/ipfs/data/<cid> -> /ipfs/data/:cid
/ipfs/stream/<cid> -> /ipfs/stream/:cid
/queue/<registry> -> /queue/:registry
/block/<registry>/latest -> /block/:registry/latest
/block/<registry>/<id> -> /block/:registry/:blockId
/payments/<did> -> /payments/:did
The label does NOT include the /api/v1 prefix.
Two parallel auth schemes; either one passes the request through. Both are evaluated in this order on every protected route:
Subscription auth (cheap, header-only). If the request carries a
X-Subscription-DID header containing a DID that resolves to an
asset the deployment owner has issued as a subscription credential,
the request is marked authenticated and proceeds.
L402 auth (paid). If subscription auth didn’t apply, the L402
middleware looks for an Authorization: L402 <macaroon>:<preimage>
header. If valid, proceeds. Otherwise, issues a 402 challenge.
If ARCHON_DRAWBRIDGE_L402_ENABLED=false, the auth middleware chain
is empty and all proxy routes are open. This is intentional for
private / single-tenant deployments.
The L402 middleware (mounted on the /api/v1 router) has a hard-coded
bypass list for paths that should never be paywalled. Paths are matched
against the request path with the /api/v1 prefix stripped:
/ready, /version, /status, /metrics/l402//did/ or /ipfs/All other routes are handled outside this router and so are not subject
to the L402 middleware at all (e.g. /invoice/:did, /.well-known/*,
/names/*).
Implementations MUST honor this list — clients depend on at least
/ready, /version, and the GET DID/IPFS read paths being free.
The L402 protocol is a Lightning-paid HTTP authentication scheme. The Drawbridge implements a slight variant compatible with macaroons.js:
When auth fails on a protected route, Drawbridge:
resolveDID, getDIDs) by
matching the route to the pricing config (see §5).ARCHON_DRAWBRIDGE_DEFAULT_PRICE_SATS (default 10).POST /api/v1/l402/invoice to create
a CLN invoice for that amount.did = <header X-DID or empty>scope = <operation key>expiry = <now + ARCHON_DRAWBRIDGE_INVOICE_EXPIRY seconds> (default 3600)payment_hash = <invoice payment hash>POST /api/v1/l402/pending
on the Lightning mediator (the mediator owns the key-value store
for invoice → macaroon lookup).WWW-Authenticate: L402 macaroon="<base64>", invoice="<bolt11>"{ macaroon, invoice } for clients that don’t parse
the header.Macaroons are built with macaroons.js
and serialized with the library’s default (binary, base64-wrapped).
Caveats are encoded as plain text strings of the form <key> = <value>:
did = did:cid:bagaaiera...
scope = resolveDID
expiry = 1726700000
max_uses = 100
payment_hash = abc123...
Implementations SHOULD use a macaroons.js-compatible library (macaroons.py, libmacaroons, macaroon-rs, etc.). A minimal custom implementation MUST:
rootSecret = ARCHON_DRAWBRIDGE_MACAROON_SECRET
as the keyed-MAC primitive.http://localhost:<port> (yes, even on
remote servers — the location is informational, not a routing hint).Clients pay the invoice, retrieve the preimage from their Lightning wallet, and resubmit:
Authorization: L402 <base64-macaroon>:<hex-preimage>
Drawbridge’s L402 middleware:
:.sha256(preimage) == payment_hash from the macaroon
caveats.rootSecret.did matches the request’s X-DID (or empty).scope matches the operation key for the requested route.expiry > now.currentUses < maxUses (incremented on every accepted request).payment_hash matches the now-verified preimage.finish event, if
the upstream status was < 500, increments currentUses in the
store.On any failure: HTTP 401 with a fresh 402 challenge.
POST /api/v1/l402/pay exists for a polling flow where the client wants
Drawbridge to confirm the payment server-side rather than presenting
the credential immediately:
{ paymentHash }. (Any preimage in the body is IGNORED.)GET /api/v1/l402/pending/:paymentHash)./api/v1/l402/check) and verifies it matches the payment hash.{ macaroonId, macaroon, paymentHash, method, amountSat, preimage }.Per-operation prices are loaded at startup. Only two individual operations have dedicated environment-variable overrides:
ARCHON_DRAWBRIDGE_PRICE_CREATE_DID — sats for createDID
(accepts >= 0).ARCHON_DRAWBRIDGE_PRICE_RESOLVE_DID — sats for resolveDID
(must be > 0).For any other operation, set the JSON override
ARCHON_DRAWBRIDGE_PRICING (see §8.2).
The full set of operation keys (used by the route-to-scope mapper):
| Key | Routes |
|---|---|
resolveDID |
GET /did/:did |
createDID |
POST /did |
generateDID |
POST /did/generate |
getDIDs |
POST /dids |
exportDIDs |
POST /dids/export |
importDIDs |
POST /dids/import |
removeDIDs |
POST /dids/remove |
exportBatch |
POST /batch/export |
importBatch |
POST /batch/import |
importBatchByCids |
POST /batch/import/cids |
getQueue |
GET /queue/:registry |
clearQueue |
POST /queue/:registry/clear |
processEvents |
POST /events/process |
listRegistries |
GET /registries |
searchDIDs |
GET /search |
queryDIDs |
POST /query |
getBlock |
GET /block/:registry/:blockId and /latest |
addBlock |
POST /block/:registry |
addJSON |
POST /ipfs/json |
getJSON |
GET /ipfs/json/:cid |
addText |
POST /ipfs/text |
getText |
GET /ipfs/text/:cid |
addData |
POST /ipfs/data |
getData |
GET /ipfs/data/:cid |
The IPFS streaming routes (/ipfs/stream, /ipfs/stream/:cid) have no
explicit scope entry and so fall through to the default price.
Any operation without an explicit price uses
ARCHON_DRAWBRIDGE_DEFAULT_PRICE_SATS. createDID accepts a value of
0 (free); resolveDID requires > 0. Other free operations must be
configured via the ARCHON_DRAWBRIDGE_PRICING JSON override or by
disabling L402 entirely.
Per-DID sliding window stored in Redis. Request ledger key:
drawbridge:ratelimit:<did> (a sorted set of per-request members
scored by timestamp, TTL = rateLimitWindow seconds). Each
authenticated request adds an entry; if more than rateLimitMax
entries fall in the last rateLimitWindow seconds, the request is
rejected with HTTP
429 { "error": "Rate limit exceeded", "resetAt": <unix> }.
Defaults: 100 requests / 60 seconds. Configured via:
ARCHON_DRAWBRIDGE_RATE_LIMIT_MAXARCHON_DRAWBRIDGE_RATE_LIMIT_WINDOWAnonymous (unauthenticated) requests are rate-limited per-IP. The DID
used for rate-limiting is read from the X-DID request header (the L402
middleware also uses this header for the macaroon’s did caveat).
Namespace: drawbridge:. All values are JSON strings unless noted.
| Key | Type | TTL | Contents |
|---|---|---|---|
drawbridge:macaroon:<id> |
STRING | none | MacaroonRecord |
drawbridge:payment:<id> |
STRING | none | PaymentRecord |
drawbridge:payments:did:<did> |
SORTED SET | none | Payment IDs for that DID, scored by createdAt |
drawbridge:ratelimit:<did> |
SORTED SET | windowSeconds |
Sliding-window members scored by request timestamp |
MacaroonRecord:
{
"id": "<hex>",
"did": "<DID or empty>",
"scope": ["resolveDID", ...],
"createdAt": <unix ms>,
"expiresAt": <unix ms>,
"maxUses": <int>,
"currentUses": <int>,
"paymentHash": "<hex>",
"revoked": <bool>
}
PaymentRecord — same shape as in the
Lightning mediator spec §5.
A new implementation MUST use this schema if the Redis instance is shared with the reference TypeScript service.
ARCHON_DRAWBRIDGE_MACAROON_SECRET is ≥ 32
chars — exit 1 on failure.waitUntilReady=true, retry-with-backoff).
Exit on persistent failure.| Variable | Default | Meaning |
|---|---|---|
ARCHON_DRAWBRIDGE_PORT |
4222 |
HTTP listen port. |
ARCHON_BIND_ADDRESS |
0.0.0.0 |
|
ARCHON_GATEKEEPER_URL |
http://localhost:4224 |
Upstream Gatekeeper. |
ARCHON_HERALD_URL |
http://localhost:4230 |
Upstream Herald (for /.well-known/* and /names/*). |
ARCHON_LIGHTNING_MEDIATOR_URL |
http://localhost:4235 |
Upstream for Lightning routes + L402 invoice/pending storage. |
ARCHON_REDIS_URL |
redis://localhost:6379 |
Macaroon/payment/rate-limit store. |
ARCHON_ADMIN_API_KEY |
empty | Required for L402 admin routes. Empty → admin routes 403. |
ARCHON_DRAWBRIDGE_L402_ENABLED |
false |
When false, all proxy routes open. |
ARCHON_DRAWBRIDGE_MACAROON_SECRET |
empty (required) | HMAC root secret. ≥ 32 chars. |
ARCHON_DRAWBRIDGE_DEFAULT_PRICE_SATS |
10 |
Per-request price for unmapped operations. |
ARCHON_DRAWBRIDGE_INVOICE_EXPIRY |
3600 |
Macaroon expiry, seconds. |
ARCHON_DRAWBRIDGE_RATE_LIMIT_MAX |
100 |
|
ARCHON_DRAWBRIDGE_RATE_LIMIT_WINDOW |
60 |
Seconds. |
ARCHON_DRAWBRIDGE_PRICE_CREATE_DID |
unset | Sats price for createDID (accepts >= 0). |
ARCHON_DRAWBRIDGE_PRICE_RESOLVE_DID |
unset | Sats price for resolveDID (must be > 0). |
ARCHON_DRAWBRIDGE_PRICING |
unset | JSON pricing override; { "operations": { "<scope>": { "amountSat": <n>, "description": "..." } } }. Merged on top of the per-operation env vars. |
GIT_COMMIT |
unknown |
Build commit. |
SIGTERM/SIGINT → close HTTP listener → disconnect Redis → exit 0.
| Metric | Type | Labels |
|---|---|---|
drawbridge_http_requests_total |
counter | method, route, status |
drawbridge_http_request_duration_seconds |
histogram (buckets: 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5) |
method, route |
drawbridge_l402_challenges_total |
counter | did_known ("true"/"false") |
drawbridge_l402_verifications_total |
counter | result ("success"/"failure") |
drawbridge_version_info |
gauge | version, commit |
Plus the standard Prometheus process metrics. Route normalization rules in §2.7.
pino at LOG_LEVEL (default info); HTTP via pino-http in
production or morgan('dev') otherwise. Errors logged as structured
{ err } objects with the upstream URL when a proxy call fails.
No log lines are contractual for downstream consumers.
ghcr.io/archetech/drawbridgeNo dedicated unit tests. Validation is end-to-end via:
A conformant third implementation MUST:
WWW-Authenticate: L402 macaroon="...", invoice="...", JSON body
with the same fields.sha256(preimage) == payment_hash constant-time.ARCHON_ADMIN_API_KEY is configured (so the upstream services accept
the proxy request).