# NodeHub License Pass

License Pass lets a visitor request one lease code from an operator's own UNO inventory on NodeHub. The visitor redeems that code in the official Unetwork app. This file is the stable contract. It does not contain a dispenser token. The operator copies a filled prompt from NodeHub → License Pass → Copy for your AI agent, and that prompt supplies `NODEHUB_API_URL` and `NODEHUB_LICENSE_PASS_TOKEN`.

Issuing works only after the operator chooses Go live in NodeHub. Until the pass is live, the request below returns `LICENSE_PASS_WIDGET_CLOSED` (403). A deployment can also refuse new issuance with `LICENSE_PASS_GENERATION_DISABLED` or `LICENSE_PASS_WRITES_FROZEN` (503) while provider write gates are off.

Machine-readable index: https://manage.unetworknodes.io/llms.txt

## Keep the token server-side

Store the dispenser token as a secret. Never commit it, never prefix it for a browser bundle (`NEXT_PUBLIC_`, `VITE_`, or a WordPress shortcode attribute), and never log it.

```
NODEHUB_API_URL=https://manage.unetworknodes.io
NODEHUB_LICENSE_PASS_TOKEN=$NODEHUB_LICENSE_PASS_TOKEN
```

The recommended integration is a route on the operator's own server. That server calls NodeHub. The visitor's browser calls the operator's server. The HTML embed and the hosted page put the token where a visitor can read it. Use those only when that tradeoff is acceptable.

## Option 1. Server route (recommended)

`POST https://manage.unetworknodes.io/api/public/license-pass/request`

Headers:

- `content-type: application/json` (required; anything else is `JSON_CONTENT_TYPE_REQUIRED`, 415)
- `accept: application/json`
- No query string. A query string is `LICENSE_PASS_REQUEST_INVALID` (400).
- Do not send cookies. `access-control-allow-origin` on this route is `*`.

JSON body:

```json
{
  "token": "$NODEHUB_LICENSE_PASS_TOKEN",
  "requestKey": "<16 to 128 characters matching [A-Za-z0-9_.:-]>",
  "email": "<visitor email>"
}
```

- `token` identifies the dispenser. `widgetId` is optional and is not required when `token` is present.
- `requestKey` is required (`LICENSE_PASS_REQUEST_KEY_REQUIRED`, 400 when it is missing or the wrong shape). It is an idempotency key. On a wallet-inventory dispenser (Create my dispenser) the key is the pair of `requestKey` and the normalized email: the same pair returns the same lease code and does not issue a second license. A different email is a different request. Use 32 random hex characters for a new visitor.
- `email` is required. Trim and lowercase it before sending. It must match `^[^\s@]+@[^\s@]+\.[^\s@]+$` and be at most 320 characters. A missing or invalid email is `LICENSE_PASS_EMAIL_INVALID` (400).
- Allowed site origins are optional. An empty list accepts a request that has no `Origin` header, which is the normal server-to-server call. If the operator later saves an allow-list, send `Origin` set to exactly one of those `https` origins. A mismatch is `LICENSE_PASS_DOMAIN_NOT_ALLOWED` (403).
- Callers are limited to 2 requests in 10 seconds and 6 requests in a minute. The dispenser accepts at most 500 issuance attempts in a minute. Over the limit is `LICENSE_PASS_RATE_LIMITED` (429). When a response is lost, `LICENSE_PASS_BUSY` (503), or `LICENSE_PASS_GENERATION_UNCONFIRMED` (502), retry the same `requestKey` and email. Do not mint a new key for that visitor.

Success is HTTP 200:

```json
{
  "version": 1,
  "route": "/api/public/license-pass/request",
  "state": "ok",
  "data": {
    "leaseCode": "string",
    "issuedAt": "ISO-8601",
    "leaseEndsAt": "ISO-8601 or null",
    "unoSharePercentage": "integer or null",
    "uloSharePercentage": "integer or null",
    "minimumUptimePercentage": "integer or null"
  }
}
```

Show `leaseCode` to the visitor and tell them to redeem it in the Unetwork app. Do not log the code.

Errors are `{ "version": 1, "error": { "code": "LICENSE_PASS_...", "message": "..." } }`. Codes you should handle:

| Code | HTTP | Meaning |
| --- | --- | --- |
| `LICENSE_PASS_WIDGET_CLOSED` | 403 | The pass is paused. The operator must choose Go live. |
| `LICENSE_PASS_GENERATION_DISABLED` | 503 | NodeHub is not issuing. |
| `LICENSE_PASS_WRITES_FROZEN` | 503 | Issuing is paused for this deployment. |
| `LICENSE_PASS_TOKEN_INVALID` | 401 | The token does not match this dispenser. |
| `LICENSE_PASS_EMAIL_INVALID` | 400 | Email missing or not a single plain address. |
| `LICENSE_PASS_POOL_EMPTY` | 409 | No license is available right now. |
| `LICENSE_PASS_BUSY` | 503 | Published inventory is not ready. Retry the same request key. This is not an unconfirmed create. |
| `LICENSE_PASS_GENERATION_UNCONFIRMED` | 502 | Retry the same requestKey and email. Do not start a second issuance. |
| `LICENSE_PASS_RATE_LIMITED` | 429 | Back off, then retry the same key. |

`POST https://manage.unetworknodes.io/api/public/license-pass/replay` with the same JSON body never issues a license. It returns `{ "version": 1, "route": "/api/public/license-pass/replay", "state": "ok", "data": { "issuedAt": "ISO-8601" } }` only when that request already settled. Use it to confirm a code you already received.

Do not call `/api/license-pass/*`. Those routes belong to the signed-in operator console.

## Option 2. HTML embed

Paste this where the dispenser should appear. It is a working drop-in for the wallet-inventory dispenser created by Create my dispenser. The frame asks the visitor for an email, then requests one lease code with `token`, `requestKey`, and `email`. The same visitor and the same normalized email keep one `requestKey`. A lost response, `LICENSE_PASS_BUSY` (503), or `LICENSE_PASS_GENERATION_UNCONFIRMED` (502) retries that same key. A different email is a different request.

The token is visible in the page source (`data-token`). Anyone who can view the page source can read it. Prefer option 1, the server route, when the token must stay on the server.

```html
<div data-nodehub-license-pass data-token="$NODEHUB_LICENSE_PASS_TOKEN" data-theme="dark"></div>
<script src="https://manage.unetworknodes.io/widget.js" async></script>
```

The loader is `https://manage.unetworknodes.io/widget.js`. The frame is `https://manage.unetworknodes.io/api/public/license-pass/embed`. The frame collects the email. The token is posted into the frame. It is not placed in the frame URL.

## Option 3. Hosted request page (no code)

https://manage.unetworknodes.io/api/public/license-pass/hosted/$NODEHUB_LICENSE_PASS_TOKEN

That page collects an email and calls the same request endpoint. The token is in the URL. Anyone with the link can request a license while the pass is live. Do not commit the URL.

## Framework hints

- Next.js or Vercel: set both variables in the project environment for Production and Preview. Read them only in a Route Handler or Server Action. Do not use `NEXT_PUBLIC_`. The handler accepts the visitor email, creates one `requestKey`, and POSTs to NodeHub.
- Plain HTML: a static file cannot keep the token secret. Link to the hosted request page, or add a small server for option 1.
- WordPress: store the token in `wp-config.php` as an environment value or in the host's secret store, not in the theme and not in a shortcode. A plugin or REST callback performs the POST.
- Any other host: the browser calls your server, and your server calls NodeHub with `fetch`. No NodeHub SDK is required.

## How to test

1. In NodeHub the pass must say Live. Go live is a separate operator action. A paused pass returns `LICENSE_PASS_WIDGET_CLOSED`.
2. From the server, POST one request with a fresh `requestKey` and a test email you control.
3. Expect HTTP 200 and a `leaseCode` only when issuing is enabled. `503` with `LICENSE_PASS_GENERATION_DISABLED` or `LICENSE_PASS_WRITES_FROZEN` means NodeHub is not issuing. Stop. Do not loop, and do not rotate the key for that visitor.
4. Repeat the same POST with the same email. The same `leaseCode` must come back.
5. Confirm you did not log the token or the lease code.

## Do not

- Do not rotate the token, create another dispenser, or change lease terms from the site.
- Do not extend, revoke, or otherwise mutate a lease.
- Do not hardcode an operator name, a wallet address, or another tenant's token.

The filled prompt from the operator console replaces `$NODEHUB_LICENSE_PASS_TOKEN` with the live dispenser token and replaces the hosted URL and embed with that same token.
