Interface Contract Matrix — entitlement claims, error codes, activation paths
Reading guide
This is the authoritative comparison of entitlement fields, errors, and activation paths across the SDK and hosted Worker at the recorded version.
When upgrading a client, compare issuer, activation route, claim names, and error mapping; wire version negotiation is not implemented.
Status:
VERIFIED— every cell quotes the implementation at the d1 commit (e24890a7+ d1 changes). This is the single contract reference for pairing@yetdapay/license-*SDKs with the hosted DevPay backend (be-cloudflareWorker) or a self-hosted@yetdapay/license-server.Wire contract version: v1 (implicit — no version field or negotiation exists; see
known-limitations.md§3.2).
1. Entitlement claims
Columns: Worker actual = what be-cloudflare emits/verifies (license-crypto/src/lib.rs, src/license_security.rs) · SDK default = @yetdapay/license-server defaults (sdk/license/server/src/server.mjs:29-35, entitlement.mjs) · SDK worker-preset = createWorkerConfig() (d1).
| claim | Worker actual | SDK default | SDK worker-preset |
|---|---|---|---|
iss | "devpay-lic" (required on verify; license-crypto/lib.rs:28,59) | "yp" emitted + required (server.mjs:30) | "devpay-lic" (WORKER_ISSUER) |
aud | absent — never emitted, never checked | absent unless audience set; then emitted + required | absent — preset leaves audience unset. Worker aud emission is DEFERRED (§5, known-limitations.md §3.8) |
lic_id | required string (verify fails closed without it) | required string | same |
product_id | required; re-checked vs live license row (401 on drift) | required; re-checked vs store (server.mjs:403) | same |
tenant_id | absent — DEFERRED (known-limitations.md §3.8) | always emitted (nullable); enforced only via expectedTenantId | absent tolerated (normalized to null) |
features | emitted from product namespace; missing → [], non-string items dropped | required array of strings (malformed → UNAUTHORIZED) | same |
jkt_bound | emitted + required; bound to proof jwk | required (falls back to cnf.jkt) | same |
cnf.jkt | emitted (same value as jkt_bound) | emitted + cross-checked vs jkt_bound when both present | same |
iat | emitted; not verified on the Worker (only exp is checked) | required numeric; future iat beyond 60 s skew rejected | same |
exp | iat + 120 (ENTITLEMENT_TTL_SEC); exp < now rejected | required; exp <= now rejected; default TTL 120 s | same |
Notes:
jkt=sha256(sha256(RFC 7638 thumbprint))— the DevPay-specific double-hash, not the RFC 9449 thumbprint (experiments/secure-browser-licensing/tools/sdk-worker-contract.test.mjs).- Worker feature-gates all PoP endpoints behind
LICENSE_SECURITY_ENABLED; absent →404(be-cloudflare/src/lib.rs:76-94,239). - Axum
be/has no PoP entitlement plane — it neither issues nor verifies these claims (legacy license records only, §3).
2. Error codes
Columns: Axum be/ (legacy license surface + generic err() codec) · Worker (license_security.rs) · SDK (@yetdapay/license-serverErrorCode→HTTP; the web client forwards {code} verbatim).
| failure | Axum be/ | Worker | SDK (@yetdapay/license-server) |
|---|---|---|---|
| malformed body / missing field | 400 BAD_JSON/BAD_MSGPACK/BAD_PROTOBUF (codec) · 400 VALIDATION (field checks) | 400 VALIDATION | 400 VALIDATION |
malformed public_jwk | n/a (no PoP plane) | 400 VALIDATION ("invalid public_jwk") | 400 INVALID_PUBLIC_JWK — distinct code |
| unknown license key (activation) | 200 body {code:"NOT_FOUND", valid:false} (verify); 404 NOT_FOUND (record routes) | 403 NOT_FOUND ("license not activatable") | 404 NOT_FOUND |
| product mismatch | 200 body PRODUCT_MISMATCH | 403 PRODUCT_MISMATCH | 403 PRODUCT_MISMATCH |
| revoked / suspended / expired | 200 body code (valid=false) | 403 same code † | 403 same code |
unknown license status | 200 body INVALID_STATUS | 403 INVALID_STATUS | 403 REVOKED (unknown status is not trusted) |
| device limit | 409 DEVICE_LIMIT (/licenses/:id/activations) | 403 DEVICE_LIMIT | 403 DEVICE_LIMIT |
| auth failures (token/proof/binding/replay/body-hash) | 401 generic | 401 UNAUTHORIZED — one opaque code for every check | 401 UNAUTHORIZED — same opaque policy |
| feature not in entitlement | n/a | 403 FORBIDDEN | 403 FORBIDDEN |
| invalid capability (unknown/mismatch) | n/a | 403 FORBIDDEN ("invalid capability") | 403 CAPABILITY_INVALID — distinct code |
| capability consumed/expired | n/a | 409 CAPABILITY_CONSUMED | 409 CAPABILITY_CONSUMED |
| body too large | n/a | 413 PAYLOAD_TOO_LARGE | 413 BODY_TOO_LARGE |
| storage/internal failure | 500 TX/500 INTERNAL | 500 INTERNAL | 500 INTERNAL |
| server misconfiguration | startup panic | 500 INTERNAL | 500 CONFIG |
| ownership/scope denial | 403 FORBIDDEN | 403 FORBIDDEN | 403 RESOURCE_FORBIDDEN |
| rate limit | 429 bare status (global RPM limiter; /auth/login adds 5-fail lock) | Worker platform / not modeled | adapter concern, not modeled |
Documented deltas (deliberate, not bugs):
VALIDATIONvsINVALID_PUBLIC_JWK: the Worker folds a bad device JWK intoVALIDATION; the SDK distinguishes it. Both are400.FORBIDDENvsCAPABILITY_INVALID: the Worker returnsFORBIDDENfor capability validation failures; the SDK usesCAPABILITY_INVALID. Both are403.403vs404NOT_FOUND: the Worker deliberately answers403 NOT_FOUNDfor an unknown license key (same class as other activation denials); the SDK uses404. Web clients see the{code}verbatim inLicenseError.codewithstatus= the actual HTTP status.PAYLOAD_TOO_LARGEvsBODY_TOO_LARGE: both413.- unknown
status→INVALID_STATUS(Worker/Axum) vsREVOKED(SDK): the SDK treats any unrecognized license status as revoked (fail closed). - † Worker
403"same code" applies to/activateonly:/activatereturns thelicense_gateverdict code verbatim (license_security.rs:412-419, e.g.403 REVOKED). On the execute/capability guard path (license_security.rs:259-279) any non-INVALID_STATUSgate verdict collapses to403 FORBIDDEN— the specific code survives only in thelicense_blockedsecurity event.
3. Activation paths
| surface | route(s) | model |
|---|---|---|
Axum be/ (legacy records) | POST /api/v1/licenses/verify (200 body verdict), POST /api/v1/licenses/:id/activations, DELETE /api/v1/licenses/:id/activations/:device | activations_json device records — no PoP, no entitlement |
Worker PoP (be-cloudflare) | POST /api/v1/licenses/activate → {entitlement, expires_in, features}; POST /api/v1/licenses/capability; POST /api/v1/licenses/execute; GET /api/v1/licenses/status | entitlement JWS + DPoP proof + license_bindings |
SDK default (@yetdapay/license-web) | POST /api/license/activate (client.js DEFAULT_ACTIVATE_PATH) | PoC/Nuxt-style self-hosted server |
| SDK worker-preset | POST /api/v1/licenses/activate (createWorkerConfig() / WORKER_ACTIVATE_PATH) | same wire format, Worker route |
Request body is identical on both paths: {key, product_id, public_jwk} → {entitlement, expires_in, features}. Only the route differs.
4. Version compatibility
License SDK + MCP packages are 0.1.0; @yetdapay/cli is 0.2.0 on wire contract v1; there is no version field in tokens, headers, or envelopes (known-limitations.md §3.2), so compatibility is "same contract generation" — not negotiated.
| component | package/binary | version | contract |
|---|---|---|---|
| Browser license SDK | @yetdapay/license-web | 0.1.0 | wire v1; Worker pairing via createWorkerConfig() |
| Server license SDK | @yetdapay/license-server | 0.1.0 | wire v1; Worker claim policy via createWorkerConfig() |
| Nuxt adapter | @yetdapay/license-nuxt | 0.1.0 | wire v1 (wraps license-web/license-server) |
| CLI | @yetdapay/cli (yp binary) | 0.1.0 | local protect tooling; no license wire calls yet |
| Commerce SDK | @yetdapay/sdk | 0.1.0 | donation/feedback REST (Axum-only endpoints today) |
| Local MCP | yetdapay-mcp 0.1.0 | 0.1.0 | JSON-RPC over stdio, MCP 2024-11-05, REST client of be/ |
| Remote MCP gateway | yetdapay-mcp-gateway 0.1.0 | 0.1.0 | same tools over streamable HTTP |
| Backends | Axum be/ · be-cloudflare Worker | repo SHA-versioned | REST /api/v1/**; PoP entitlement wire v1 (Worker only) |
5. Deferred follow-ups
audin Worker entitlements —DEFERRED. The Worker neither emits nor verifiesaud; adding it changes the token format (wire v2 candidate). Recorded inknown-limitations.md§3.8.tenant_idin Worker entitlements —DEFERRED. Same wire-format impact. The SDK already emits/normalizes it (nullable).- Error-code convergence (
INVALID_PUBLIC_JWK,CAPABILITY_INVALID,PAYLOAD_TOO_LARGE,INVALID_STATUS) — documented deltas above; any unification is a wire v2 decision, not a doc fix.