Building an Issuing Authority (RTH / PTA token issuer)

Audience: Operators who run their own WBSP platform and want their own user, catalogue and billing management — i.e. you do not want wbsp.ai deciding what may be deployed on your cluster. This document is both a specification (what your service MUST emit for wbsp-api to accept it) and a build guide (how to stand one up and test it end to end).

Related: Capability-Grant Authorization (the operator switches on wbsp-api) · Platform Operator Guide · Architecture · wire contract: specs/078-permission-to-run/contracts/token-claims.md

Table of Contents

  1. Why an issuing authority exists
  2. Concepts
  3. The flow
  4. Normative requirements
  5. Token reference
  6. The minting endpoint (CLI-facing)
  7. The control plane (issuer → platform)
  8. Build guide
  9. Conformance checklist
  10. Current limitations

1. Why an issuing authority exists

wbsp-platform gives you the means to host applications: build, route, provision databases, scale, tear down. It deliberately does not decide which applications may run, who pays for them, or how big they may get. Feature 078 ("Permission to Run") moves that decision out of the platform and into a separate, replaceable service: the issuing authority.

The issuing authority is a website or server that you own. It answers one question —

"May this variant be hosted, on whose account, and within what ceilings?"

— and expresses the answer as a signed capability token. wbsp-api verifies the signature offline against a public key you gave it, and hosts within the limits the token carries. It never calls back to you to ask, and it holds no copy of your user database, catalogue, or billing rules.

On the public platform that role is played by the wbsp.ai website. Nothing in wbsp-api prefers it. Point the trust anchor at your own key and your service becomes the sole authority for your cluster; wbsp.ai has no say over what runs there.

One nuance, settled 2026-08-11 (see notes/wbsp-subscribers-portal-prerequisites.md): "authority" above means the hosting authority — the RTH and control tokens, grounded in your own subscriber and billing relationships. The access licence (PTA) for software acquired from the wbsp.ai catalogue always comes from wbsp.ai. A fully self-contained platform running only its own catalogue plays both roles itself — but they are distinct roles with distinct keys once the per-role trust anchors land.

What this replaces — and what it does not. Grants are an authorization layer, not an authentication layer. wbsp-api still authenticates every deploy caller with its existing identity check (a HAP OIDC token or a CI token) and still enforces tenant scoping. You therefore need both: an identity provider for who is calling, and an issuing authority for what they are permitted to run. See HOWTO-register-auth.md for the identity half.


2. Concepts

TermMeaning
RTH — Request to HostThe core grant. Names the variant, who will pay, and tiered resource ceilings. One RTH authorizes one hosted application.
PTA — Permission to AccessA signed licence to run a variant, issued by the catalogue/software authority (wbsp.ai for its catalogue). Minted on every grant since website feature 095, public included, and it doubles as the registry pull credential (wbsp:<PTA> basic auth at the gateway, revocation re-checked per pull). Admission requires it only when the RTH says public: false; image access on the deploy-from-image path requires it always.
Control tokenA token your issuer signs to call the platform's control plane (usage, inventory, shutdown, revoke). Same key today (the per-role anchor split is accepted, scheduled work), same audience, no grant claims.
WWP — who-will-payAn opaque payer identifier in your own billing namespace (account id, tenant id, contract party). The platform stores and reports it; it never interprets it.
VariantThe catalogue identity of the thing being deployed. On the public platform this is a wbsp.ai catalogue variant id; on your platform it is whatever string your catalogue uses — the platform treats it as an opaque label that must match between PTA and RTH.
Audience (aud)The cluster a grant is valid for. wbsp-api rejects any grant whose aud is not exactly its configured WBSP_GRANT_AUDIENCE. This is what stops a grant minted for one cluster being replayed against another.
Trust anchorThe set of Ed25519 public keys wbsp-api trusts, plus that audience. Configured by env var — no JWKS fetch, no network dependency on your issuer.
observe / enforceThe per-cluster switch. observe (default) verifies and logs but never blocks or caps; enforce makes refusals, ceilings and teardown real. See capability-grants.md.

3. The flow

   ┌──────────────┐   1. login (OIDC)     ┌─────────────────────┐
   │ your users   │──────────────────────▶│ your identity prov. │  (HAP, or any OIDC IdP)
   └──────┬───────┘                       └─────────────────────┘
          │ 2. "deploy variant X"
          ▼
   ┌────────────────────────────────────┐
   │  YOUR ISSUING AUTHORITY            │   holds the PRIVATE Ed25519 signing key
   │  • is this user allowed variant X? │
   │  • who pays (wwp)?                 │
   │  • what plan → what ceilings?      │
   └──────┬─────────────────────────────┘
          │ 3. mints RTH; attaches the catalogue authority's PTA — EdDSA, aud=<cluster>
          ▼
   ┌────────────────────────────────────┐
   │  deploy call → wbsp-api            │   POST /api/v1/apps/{tenant}/{name}/deploy
   │  body: { yaml, destination,        │   Authorization: Bearer <identity token>
   │          image_tag, rth, pta }     │
   └──────┬─────────────────────────────┘
          │ 4. verify offline against WBSP_GRANT_VERIFY_KEYS (public keys only)
          │    → record the grant, apply resources.*.hard as k8s limits (enforce)
          ▼
   ┌────────────────────────────────────┐
   │  application hosted                │
   └──────┬─────────────────────────────┘
          │ 5. later: your issuer polls usage / revokes
          ▼
   DELETE /api/v1/grants/{jti}   (control token)  → reconcile loop tears the app down

The private key never leaves your issuer. The platform holds only public keys, so a compromised platform cannot mint permission to run anything.


4. Normative requirements

MUST / SHOULD / MAY as in RFC 2119. "The issuer" is your service.

4.1 Keys and signing

#Requirement
IA-1The issuer MUST sign with Ed25519 and emit a compact JWS with header alg: "EdDSA", typ: "JWT". Any other algorithm is rejected (wbsp-api allows EdDSA only — none and RS/HS/ES families are refused before signature checking).
IA-2The issuer MUST keep the private signing key in a secret store and MUST NOT place it on the platform, in a repo, in an image, or in an app's environment.
IA-3The issuer MUST publish the corresponding public key to the operator as kid:base64(key). Accepted encodings: raw 32-byte Ed25519 key, or PKIX/SPKI DER; base64 standard or URL-safe, padded or unpadded.
IA-4The issuer SHOULD set a kid header naming the signing key. Note: wbsp-api currently uses kid only as a label — it tries every configured key and accepts the first that verifies. Rotation therefore works by listing old and new keys simultaneously in the trust anchor; it does not depend on kid matching.
IA-5To rotate: generate a new keypair, ask the operator to add it to WBSP_GRANT_VERIFY_KEYS alongside the old one, start signing with the new key, then have the operator drop the old entry. Existing hosted apps are unaffected — verification happens at deploy time, not continuously.

4.2 Claims

#Requirement
IA-6Every token (RTH, PTA, control) MUST carry aud equal to the target cluster's WBSP_GRANT_AUDIENCE, exactly. A mismatch is rejected with reason aud_mismatch.
IA-7Every token MUST carry a unique jti. The RTH's jti is the grant id — it is the handle used to revoke, and the platform's replay guard. A jti MUST NOT be reused.
IA-8Tokens MUST NOT carry exp. Validity is the durable hosting record plus push-revoke, not expiry. An expiring RTH would silently orphan a running application.
IA-9iat SHOULD be set to issue time. If nbf is present it is honoured with a ≤60 s clock-skew tolerance (reason not_yet_valid otherwise). Keep the issuer's clock NTP-synced.
IA-10An RTH MUST carry variant, public, wwp and resources in addition to aud/jti. contract is optional and is stored as a reference only.
IA-11When public is false, the issuer MUST also mint a PTA for the same variant and the same aud, and the deploy caller MUST present both. Missing PTA ⇒ missing_pta_for_private; differing variant ⇒ pta_variant_mismatch.
IA-12When public is true, admission needs no PTA and ignores any presented one. Image access is different: on the deploy-from-image path the PTA is the registry pull credential and is required regardless of public (see §10).
IA-13wwp MUST be meaningful in the issuer's billing namespace and SHOULD be stable for the life of the hosting relationship — it is what the platform reports back in usage/inventory, and what you will reconcile against.

4.3 Resource ceilings

#Requirement
IA-14resources MUST be present and MUST use the tier ladder {initial, warning, soft, hard} for each dimension: instances, memory, cpu, db_size, cost.
IA-15memory, cpu, db_size MUST be Kubernetes quantity strings ("512Mi", "500m", "5Gi"). instances MUST be an integer count. cost is a number in your own rate card's units.
IA-16hard is the enforced ceiling. In enforce mode wbsp-api applies memory.hard, cpu.hard and db_size.hard as container resources plus a namespace ResourceQuota/LimitRange, and instances.hard as the replica cap. The issuer MUST NOT set a hard below what the application needs to start.
IA-17warning and soft drive threshold state reported back through the control plane; they are advisory in Phase 1. initial is the starting allocation. The issuer SHOULD keep initial ≤ warning ≤ soft ≤ hard.
IA-18cost tiers are recorded but not enforced in Phase 1 (no metering yet). Populate them anyway so the record is complete when Phase 2 metering lands.
IA-19An RTH whose hard values are all empty results in no caps applied — a silently unlimited app. Issuers that care about ceilings MUST set at least one.

4.4 Lifecycle

#Requirement
IA-20To change limits on a running app, the issuer MUST mint a new RTH with a fresh jti and have it re-presented on a redeploy. The new grant supersedes the prior one for that app. Re-presenting the same RTH is a replay (409, reason replayed_jti, in enforce mode).
IA-21To withdraw permission (cancellation, non-payment, abuse, expiry of your own contract), the issuer MUST call DELETE /api/v1/grants/{jti} with a control token. In enforce mode the reconcile loop tears the application down within roughly a minute; the call is idempotent.
IA-22The issuer SHOULD retain its own mapping of jti → (user, variant, deployment). The platform reports jti, variant and wwp, but only the issuer knows which of its customers a grant belongs to.
IA-23The issuer MUST NOT log, persist in plaintext, or return in an error body any minted token value. The platform likewise never logs token values.
IA-24The issuer SHOULD serve minting only over TLS and only to authenticated callers (§6).

5. Token reference

Verified offline by wbsp-api; the authoritative wire contract is contracts/token-claims.md.

RTH — Request to Host

{
  "aud": "wbsp-api",
  "jti": "rth_01J8ZQ2M4T7C9V5X",
  "variant": "variant_7f3a2c19",
  "public": true,
  "wwp": "acct_44921",
  "contract": "ctr_2026_0087",
  "resources": {
    "instances": { "initial": 1,       "warning": 2,       "soft": 3,       "hard": 3 },
    "memory":    { "initial": "256Mi", "warning": "384Mi", "soft": "512Mi", "hard": "512Mi" },
    "cpu":       { "initial": "100m",  "warning": "300m",  "soft": "500m",  "hard": "500m" },
    "db_size":   { "initial": "1Gi",   "warning": "4Gi",   "soft": "5Gi",   "hard": "5Gi" },
    "cost":      { "initial": 0,       "warning": 40,      "soft": 80,      "hard": 100 }
  },
  "iat": 1769400000
}

PTA — Permission to Access (private variants only)

{
  "aud": "wbsp-api",
  "jti": "pta_01J8ZQ2M4T7C9V5Y",
  "variant": "variant_7f3a2c19",
  "iat": 1769400000
}

Control token

{ "aud": "wbsp-api", "jti": "ctl_01J8ZQ2M4T7C9V5Z", "iat": 1769400000 }

How wbsp-api verifies (order of checks)

  1. Algorithm must be EdDSA; anything else fails immediately.
  2. Signature must verify against some key in WBSP_GRANT_VERIFY_KEYS. If none matches ⇒ no_matching_key.
  3. aud must equal the configured audience ⇒ else aud_mismatch.
  4. nbf/iat honoured with 60 s leeway ⇒ else not_yet_valid.
  5. If public: false, the PTA is verified the same way and its variant must equal the RTH's.
  6. The RTH jti must not already have a live hosting record ⇒ else replayed_jti.

Rejection reasons (stable strings)

ReasonMeaning
no_grantNo token presented.
malformedNot a parseable JWT, or an unexpected claim shape.
no_matching_keySignature verified against none of the trusted keys (covers both a wrong key and a bad signature).
aud_mismatchaud is not this cluster's audience.
not_yet_validnbf/iat in the future beyond the 60 s leeway.
missing_pta_for_privateRTH says public: false and no PTA was presented.
pta_<reason>The PTA itself failed for <reason> (e.g. pta_aud_mismatch).
pta_variant_mismatchPTA and RTH name different variants.
replayed_jtiA live hosting record already exists for this grant id.

In observe mode each of these is logged as grant.observe would_reject reason=<x> and the deploy proceeds unchanged. In enforce mode they become 403 (grant_rejected) or 409 (grant_replayed).


6. The minting endpoint (CLI-facing)

If your users deploy with the wbsp CLI, your issuer should expose a minting endpoint the CLI can call after login. This is the shape agreed with the wbsp.ai website team, and adopting it keeps you compatible with the CLI wiring when it lands (see §10 — the CLI does not call it yet).

Endpoint: POST /api/v1/grants (path is yours to choose).

Auth: Authorization: Bearer <login session token> — reuse your existing OIDC session validation. Do not invent a second auth path.

Request:

{ "variant": "<catalogue variant id>", "audience": "wbsp-api" }

audience is the value the target cluster requires as aud. Either accept it from the caller (deriving it from the chosen destination) or map destination → audience server-side; stamp it verbatim into the tokens.

Response — granted:

{ "rth": "<EdDSA JWT>", "pta": "<EdDSA JWT or null>" }

Response — denied (403):

{ "error": "not_permitted", "message": "…" }

The authorization rule is yours. This is the point of running your own issuer. A minimal starting rule (the one wbsp.ai is starting with): public variants are always granted with public: true and no PTA; private variants are denied outright until the owner/collaborator check is wired. A fuller rule consults your user model, plan tier, quota, and billing state, and picks the resources ladder from the customer's plan.


7. The control plane (issuer → platform)

Your issuer is the only party that knows when permission should end. These endpoints let it act. All require Authorization: Bearer <control token> signed by a trusted key. They return 503 (not_configured) if grants are not configured on that cluster, and 401 if the control token itself fails to verify — control-token authentication is always required, in both modes.

Method & pathPurpose
GET /api/v1/tenants/{tenant}/appsInventory of hosted apps for a tenant.
GET /api/v1/tenants/{tenant}/usageSame, framed as usage/limits/threshold state.
GET /api/v1/tenants/{tenant}/apps/{name}/usageOne app.
POST /api/v1/grants/verifyVerify-only: does this platform accept this token, and as which role? Writes nothing. See §7.1.
POST /api/v1/tenants/{tenant}/apps/{name}/enforceBody {"action":"shutdown"} — stop an app. {"action":"resume"} — bring it back (§7.2). throttle returns 501.
DELETE /api/v1/grants/{jti}Revoke a grant; the reconcile loop tears the app down. Idempotent; 404 if no record.

Read endpoints are always live. Enforcement actions obey the mode: in observe shutdown and throttle return 200 with {"would": "<action>", "applied": false} and change nothing. resume is the exception — it restores service rather than taking it away, so it executes in both modes.

7.1 Verifying your keys without deploying anything

POST /api/v1/grants/verify
Authorization: Bearer <control token>
{ "token": "<candidate JWT>" }

200 { "verified": true,  "role": "rth", "reason": "" }
200 { "verified": false, "role": null,  "reason": "no_matching_key" }

This is how an issuing authority proves each of its keys against a platform: the control token proves your control key by authenticating (401 vs 200), and the body proves whichever other key you send. The endpoint writes no record, mints nothing, deploys nothing, and behaves identically on every conforming platform.

It requires the control token deliberately — an unauthenticated verifier would be a free oracle for testing stolen or forged tokens. reason is always one of the stable strings in §5; role is null on every refusal.

A token is verified as the role its claims say it is (an wwp/resources payload is an RTH, a bare variant is a PTA, neither is a control token) against that role's anchor. So a hosting grant signed by the wrong authority is a refusal, not a re-attribution — which is exactly the property the per-role anchors below exist to give you.

7.2 Resume

{"action": "resume"} brings back an application that was stopped. On this platform stop is teardown, not pause (compute and routes go; the registry entry and data sources stay), so resume is a redeploy from the platform's retained record — its retained image, retained pull credential, and stored configuration. Consequences worth designing for:

  • It takes deploy time, not unpause time: image pull, pod start, route creation.
  • The response predicts no resulting state{"app", "action":"resume", "applied":true} and nothing more. What the application then is comes from reading its state, never from this acknowledgement.
  • Add "destination": "<name>" when the application has several installations; without it, several is refused as destination_ambiguous rather than guessed.
  • Refusals name their cause: no_retained_image (never deployed from a published image), installation_not_found, access_denied, config_incomplete, grant_revoked (enforce mode: resume will not revive an app whose grant was revoked), deployer_unavailable (§7.3).

7.3 Platforms without a deployer (appliances)

A platform may run the verifier and control plane with no deployment machinery at all — the appliance case. There, grants are verified and revocations are recorded but not executed:

  • DELETE /api/v1/grants/{jti} answers {"applied": true, "executed": false}. The record is the durable fact; teardown happens if and when that platform supplies a remover. (executed is absent on deployer-backed platforms, where recording is the teardown mechanism.)
  • shutdown and resume answer 503 deployer_unavailable.

Present this to operators as recorded-not-torn-down. It is honest observe-grade behaviour, not enforcement.

Example inventory item:

{ "tenant": "acme", "app": "store", "grant_jti": "rth_01J8Z…",
  "variant": "variant_7f3a2c19", "wwp": "acct_44921",
  "limits": { "instances": {"hard": 3}, "memory": {"hard": "512Mi"} },
  "usage": "unavailable", "estimated_cost": "unavailable",
  "threshold_state": "ok", "mode": "enforce", "revoked": false }

usage and estimated_cost are the string "unavailable" in Phase 1 — metering is Phase 2. Your issuer should tolerate that value rather than assume a number.

Teardown timing: revocation marks the record; a reconcile loop running every minute (enforce mode only) removes the application and then deletes the record. A failed teardown is retried on the next tick, so revocation is at-least-once, not instantaneous.


8. Build guide

Step 1 — generate a signing keypair

# Private key — goes in YOUR secret store, never on the platform.
openssl genpkey -algorithm ed25519 -out grant-signing-key.pem

# Public key for the operator — PKIX/DER, base64 (accepted as-is):
openssl pkey -in grant-signing-key.pem -pubout -outform DER | base64

# …or the raw 32-byte form (also accepted; it is the tail of the DER):
openssl pkey -in grant-signing-key.pem -pubout -outform DER | tail -c 32 | base64

Give the operator the public key and a key id, e.g. myco-issuer-1.

Step 2 — wire the trust anchor into wbsp-api

On the wbsp-api deployment (its wbsp.yaml env: block, or the pod env):

env:
  WBSP_GRANT_VERIFY_KEYS: myco-issuer-1:<base64-public-key>   # comma-separate to add more
  WBSP_GRANT_AUDIENCE: cluster:<operator-chosen id>           # what your `aud` must equal
  WBSP_GRANT_MODE: observe                                    # ALWAYS start here

Redeploy wbsp-api. Boot logs should show capability-grant verification ready with the mode, the audience, and where each role's keys came from. Full env reference and the observe → enforce cutover procedure: capability-grants.md.

WBSP_GRANT_MODE=enforce with a role that can verify nothing is refused at boot — the platform will not run an open enforce gate for any role.

Per-role anchors. The single variable above verifies every role, which means whoever holds that key can sign any of them. Where the roles have different authorities — the typical case, with permission-to-access coming from the catalogue website and hosting grants from the platform's own subscriber portal — give each role its own anchor:

env:
  WBSP_PTA_VERIFY_KEYS:     website-1:<base64-public-key>    # who may authorize image access
  WBSP_RTH_VERIFY_KEYS:     hg-1:<base64-public-key>         # who may ask this platform to host
  WBSP_CONTROL_VERIFY_KEYS: ctl-1:<base64-public-key>        # who may drive the control plane

Each falls back to WBSP_GRANT_VERIFY_KEYS while unset, so a platform that sets none of them behaves exactly as before. A variable that is set replaces the legacy key set for its role rather than adding to it — otherwise the split would not actually withdraw the legacy signer's authority, which is the entire point. Prove the result with the verify endpoint (§7.1).

Audience, per platform. WBSP_GRANT_AUDIENCE should be one value per cluster, by convention cluster:<operator-chosen id>, so a token minted for one platform is not silently valid on every other. Roll it out under observe, where a mismatch is a log line rather than an outage: widen the issuer's allowlist, start stamping the new value, then require it here.

Precondition for enforce. A platform does not flip to enforce before it has both the per-role anchors and its own audience value. Until then, enforcement would be resting on a shared key and a shared audience — a gate that looks closed and is not.

Step 3 — mint a token

TypeScript/Node with jose (the shape the wbsp.ai website uses):

import { SignJWT, importPKCS8 } from 'jose'
import { randomUUID } from 'node:crypto'

const key = await importPKCS8(process.env.GRANT_SIGNING_KEY_PEM!, 'EdDSA')

export async function mintRTH(opts: {
  variant: string; audience: string; wwp: string; isPublic: boolean; plan: Plan
}) {
  return new SignJWT({
    variant: opts.variant,
    public: opts.isPublic,
    wwp: opts.wwp,
    resources: resourcesForPlan(opts.plan),   // your plan → tier ladder
  })
    .setProtectedHeader({ alg: 'EdDSA', typ: 'JWT', kid: 'myco-issuer-1' })
    .setJti(`rth_${randomUUID()}`)
    .setAudience(opts.audience)
    .setIssuedAt()
    // deliberately NO .setExpirationTime() — see IA-8
    .sign(key)
}

Python with PyJWT (pip install "pyjwt[crypto]"):

import jwt, time, uuid
from cryptography.hazmat.primitives.serialization import load_pem_private_key

key = load_pem_private_key(open("grant-signing-key.pem", "rb").read(), password=None)

rth = jwt.encode(
    {
        "aud": "wbsp-api",
        "jti": f"rth_{uuid.uuid4()}",
        "variant": "variant_7f3a2c19",
        "public": True,
        "wwp": "acct_44921",
        "resources": {
            "instances": {"initial": 1, "warning": 2, "soft": 3, "hard": 3},
            "memory": {"initial": "256Mi", "warning": "384Mi", "soft": "512Mi", "hard": "512Mi"},
            "cpu": {"initial": "100m", "warning": "300m", "soft": "500m", "hard": "500m"},
            "db_size": {"initial": "1Gi", "warning": "4Gi", "soft": "5Gi", "hard": "5Gi"},
            "cost": {"initial": 0, "warning": 40, "soft": 80, "hard": 100},
        },
        "iat": int(time.time()),
    },
    key, algorithm="EdDSA", headers={"kid": "myco-issuer-1"},
)

Step 4 — test against a cluster in observe mode

Put the token in the deploy body. The identity token is still required — grants sit beside identity, not instead of it.

curl -sS -X POST "https://api.example.com/api/v1/apps/$TENANT/$APP/deploy" \
  -H "Authorization: Bearer $IDENTITY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{\"yaml\": $(jq -Rs . < wbsp.yaml), \"destination\": \"aws\",
       \"image_tag\": \"$TAG\", \"rth\": \"$RTH\"}"

Then read the verdict from the platform logs (namespace is wbsp-<tenant>-<app> for the wbsp-api app itself — typically wbsp-wbsp-api):

kubectl -n wbsp-wbsp-api logs deploy/wbsp-wbsp-api --tail=200 | grep grant\.
# grant.observe would_allow                       ← the token is correct
# grant.observe would_reject reason=aud_mismatch  ← fix and retry

Iterate here until every real deploy logs would_allow. Nothing you do at this stage can break a deploy: observe mode cannot refuse, cap, or tear anything down.

Step 5 — exercise the control plane

CTL=$(your-mint-control-token)   # aud + jti only, same key

curl -sS -H "Authorization: Bearer $CTL" \
  "https://api.example.com/api/v1/tenants/$TENANT/apps" | jq .

curl -sS -X DELETE -H "Authorization: Bearer $CTL" \
  "https://api.example.com/api/v1/grants/$JTI" | jq .
# observe: {"grant_jti":"…","would":"revoke","applied":false}
# enforce: {"grant_jti":"…","applied":true}     → app removed within ~1 minute

Step 6 — flip to enforce

Only after the observe logs are clean for real traffic: set WBSP_GRANT_MODE=enforce, redeploy wbsp-api. From then on, invalid grants are refused, resources.*.hard becomes real Kubernetes limits, and revoked grants cause teardown. Reverting to observe is a single env change and a redeploy — no application redeploys required.


9. Conformance checklist

A minimum viable issuer:

  • Ed25519 keypair generated; private key in a secret store; public key + kid handed to the operator.
  • RTH minting with aud, unique jti, variant, public, wwp, resources, iat, no exp.
  • Resource ladder derived from your plans, with hard values an app can actually start within.
  • Private variants: PTA minted for the same variant, or the request denied.
  • jti → customer/deployment mapping stored on your side.
  • Revocation path: control-token minting + DELETE /api/v1/grants/{jti} wired to your cancellation / non-payment / abuse flows.
  • Limit changes mint a new RTH (fresh jti), never re-present an old one.
  • No token value is ever logged or returned in an error.
  • Verified end to end against a cluster in observe mode before enforce is switched on.

Additionally, for a production platform:

  • Key rotation procedure documented and rehearsed (two keys in the anchor during overlap).
  • Inventory reconciliation: periodically compare GET /api/v1/tenants/{tenant}/apps against your own records to catch drift (grants you revoked that are still hosted, or vice versa).
  • Clock sync (NTP) on issuer hosts.
  • Tolerates "unavailable" for usage / estimated_cost.

10. Current limitations

Phase 1 of feature 078 — know these before designing around it.

  • The wbsp CLI does not fetch or attach grants yet. Grants reach wbsp-api only when the caller of the deploy API places rth/pta in the request body. A portal that calls wbsp-api directly works today; CLI wiring is a platform follow-up. Until it lands, clusters must remain in observe mode for CLI users, or CLI deploys will be refused once enforce is on.
  • Anchors are per ROLE, not per issuer; no federation. Since the per-role split (§8 step 2) a key can be trusted for permission-to-access without being trusted for hosting grants or the control plane. Within a role, though, every configured key is still equally trusted for every tenant, and any valid control token may read or revoke any grant on that cluster. Issuer → tenant scoping and per-issuer revocation remain deferred. Do not hand a control-token signing key to a party you would not trust with the whole cluster.
  • kid is not used to select a key — the verifier tries all configured keys (see IA-4).
  • No cost enforcement or metering. cost tiers and usage/estimated_cost are recorded or reported as "unavailable"; nothing is measured or billed by the platform (Phase 2).
  • throttle is not implemented — it returns 501. resume now works (§7.2), as a redeploy from the platform's retained record; it is not an unpause and takes deploy time.
  • A resumed application's state is not reported here. The resume acknowledgement says the redeploy began, never what the application became; a running-state read is a separate capability.
  • Revocation is eventual, on a one-minute reconcile tick, and enforce-mode only.
  • The PTA is also the pull credential (correction 2026-08-11 — this bullet previously said the opposite). At the wbsp.ai registry gateway a PTA is presented as wbsp:<PTA> basic auth and revocation is re-checked on every pull; the deploy-from-image path requires it for public and private variants alike. Identity-based image access rules are described in variant-deploy-repositories.md.

See also