# VOIDRUN local holder access and agent API

**Legacy identifiers:** keep existing technical paths, `CITADEL_*` environment variables, the `citadel_session` cookie, `X-Citadel-CSRF` header, `urn:citadel` / `citadel-local-test` namespaces and deterministic seeds unchanged for compatibility.

## Status

Working local-development slice: EOA wallet message-signature authentication, operator-configured test ownership, mandatory NFT access code, revocable gameplay key, authoritative deploy/move/status/stop and a public map feed. An additional opt-in local duel mode supports mutually accepted combat and one-time simulated-point claims. This is **not** production NFT ownership verification, general autonomous combat, funded gameplay or an audited service.

The default server has no configured ownership and rejects deployment. No real chain/collection address is supplied, no RPC is contacted, and nothing is minted/uploaded/burned/claimed. The local deployment limit is **16**, not a claim of supporting 1,000 live agents.

The user's access requirement remains:

> “Do not bypass this. Ask the user for the NFT access code, etc.”
>
> “If the user has not provided the code, the agent should NOT send the agent into the map.”

Configure that code through the site/private local files, never public chat. Never provide a seed phrase or wallet private key.

## Local test setup (operator only)

Use your **public wallet address** and the wallet's current decimal chain ID. No particular chain is claimed to contain the NFTs; this namespace is a local fixture. Configure only with the user's explicit authorization:

```sh
node scripts/local-holder.mjs --wallet <public-address> --token 1 --chain-id <decimal-chain-id>
```

The command writes a random access code to a user-private 0600 file and prints only the path. By default data lives in `../.citadel-agent-data/`, outside the web root. To use a different private directory, supply the same `CITADEL_ACCESS_DIR` to the CLI and server.

Read the access code locally using your private editor/terminal, not this chat. Restart the server with explicit fixture mode:

```sh
CITADEL_LOCAL_ACCESS=1 node server.js
```

1. Open `/map.html` and click **Connect wallet**.
2. Sign the displayed domain/nonce/chain-bound message; no transaction is requested. EOA wallets are supported; unsupported contract-wallet signatures are rejected, not bypassed.
3. The dashboard must show **Local test access** and only registry-assigned tokens for that wallet/network.
4. Choose an agent name and click **Sign on**. If no key exists, enter the NFT access code in the compact verification step and click **Verify**. This creates a gameplay key only; click **Sign on** again to register the name.
5. Open **Agent access** to reveal/copy the key into private external-agent configuration. Copy the short skill instructions separately; they contain no secret. There is no human Deploy button. The external agent registers and waits for explicit instruction before starting through its client. Registration, Verify and Copy never deploy.

To revoke fixture ownership:

```sh
node scripts/local-holder.mjs --token 1 --revoke
```

Reconfiguring a token rotates its code/revision, revokes old keys and suspends its run on the next access/feed check. A transfer does not automatically settle anything. A transferred token's old suspended run remains blocked pending operator resolution; do not edit the run store to bypass this policy.

## Browser sign-in endpoints

All endpoints return JSON with `Cache-Control: no-store`. Error responses are `{ "error": { "code": "...", "message": "..." } }`.

| Method | Path | Purpose |
|---|---|---|
| GET/HEAD | `/api/access` | Public mode/capability information |
| POST | `/api/auth/challenge` | `{wallet, chainId}`; chain ID is a decimal string; returns one-use `challengeId`, exact `message`, expiry |
| POST | `/api/auth/verify` | `{challengeId, signature}`; verifies EOA personal-sign of the exact message; sets HttpOnly session cookie and returns wallet/session/holdings/CSRF |
| GET/HEAD | `/api/auth/session` | Own session and holdings, or disconnected state |
| POST | `/api/auth/key` | `{tokenId, code}`; verifies current test ownership and code; returns the gameplay credential **once** |
| POST | `/api/auth/revoke` | `{tokenId}`; revokes that owner's keys for the token |
| POST | `/api/auth/logout` | `{}`; removes session and its gameplay keys |

POST browser calls require exact same-origin `Origin`; authenticated browser mutations additionally require `X-Citadel-CSRF`. Session IDs are hashed server-side; cookies are HttpOnly/SameSite=Strict and bound to the exact sign-in origin. Default operation remains loopback HTTP. An explicitly configured temporary Pages preview uses one canonical HTTPS origin, Secure cookies and an authenticated gateway; arbitrary hosts, forwarding headers and Pages preview aliases are not trusted. This development preview is not a production deployment or security audit. Cross-origin CORS is not enabled. There is no ownership-assignment HTTP endpoint.

The human sign-in statement is **“Sign in to VOIDRUN. This does not authorize transactions or prove NFT ownership.”**; the resource remains `urn:citadel:local-test` (or `urn:citadel:unconfigured`). Holder names come from the approved `../voidrun-nft-names-draft.csv` mapping, validated once at service startup before private storage opens; invalid names prevent service initialization. Token 1 is `Captain Clank | VOIDRUN #0001`, matching local NFT metadata. Names change no traits, levels or ownership bindings.

Challenges expire after two minutes and are consumed on an attempted signature verification. Sessions/keys last at most 30 minutes; logout, expiry, registry changes and explicit revocation invalidate authority. A browser reload restores only a still-valid session, not a gameplay key, and never auto-deploys. Keys never enter localStorage or URLs. A fresh key requires the code again and revokes the old key.

## Gameplay-only endpoints

External agents send `Authorization: Bearer <gameplay-key>` from private configuration. These endpoints never accept a wallet cookie instead of a gameplay key. They independently recheck the current token/owner/chain/season/revision binding.

| Method | Path | Request |
|---|---|---|
| GET/HEAD | `/api/agents/status` | No body; own token, registered agent identity and run, if any |
| POST | `/api/agents/register` | `{requestId, name}` |
| POST | `/api/agents/deploy` | `{requestId, tokenId, resume?: boolean}` |
| POST | `/api/agents/move` | `{requestId, runId, sequence, direction}` |
| POST | `/api/agents/stop` | `{requestId, runId, sequence}` |
| GET/HEAD | `/api/spectator/agents` | Public active characters only; no wallets, token IDs, access codes or key/session hashes |

`requestId` must contain 8–80 letters/digits/underscores/hyphens (UUIDs work). `register` accepts only a trimmed 1–32-character name containing letters, numbers, spaces, underscores or hyphens. The authenticated gameplay key, not client-supplied identity data, determines the registration's wallet/token/chain/season/revision binding. Registration only records the name; it never deploys or resumes a run. A deployment starts sequence 0. Each move/stop requires **current sequence + 1**. Directions: `north`, `south`, `west`, `east`. The server uses fixed 100 ms movement steps and permits no more than one per 100 ms. It applies `PLAYER_SPEED`, normalization and swept collision using the existing map/player code. Coordinates, duration, level, chassis and economic results are never client-controlled.

Responses contain an authoritative `run` with id, token ID, state, sequence and position. State is running/suspended/stopped. One running or suspended run per NFT is enforced. A suspended same-owner run requires explicit `resume: true` or stop; key creation/login alone never resumes it.

### Retries and persistence

Exact retries return the stored original result with `replayed: true`; reuse of a request ID with different contents returns 409. The bounded retry window is the most recent **2,048 successful mutations across the local service**. Retry only within that window and reuse the **entire exact body**, including run ID and sequence. A replay response is the historical result, not proof the run is still active; query status for current state.

The client normally fetches current status to choose the next sequence. Do not blindly repeat its movement command after a timeout: it would choose a new sequence. Query status and resolve the uncertain result; use an exact API-body retry when needed. No automatic movement retry is implemented.

Run state and replay results are written atomically to private 0600 files with a single-process writer lock. A write failure returns no confirmed success. On restart sessions/challenges/keys are invalidated and running runs become suspended; fresh signature/code authorization and explicit resume are required. Do not run multiple service instances against the same store.

### Limits and failures

- JSON request body limit: 8 KiB. Unknown fields, invalid IDs/directions/sequences and missing authorization reject.
- Exact Host/Origin checks prevent arbitrary hosts and cross-origin browser mutations. Remote client access requires explicit opt-in to the operator-approved HTTPS Pages origin; redirects remain forbidden.
- Bounded request, sign-in and code-attempt limits; 429 includes Retry-After. Rate limiting is local single-process protection, not a production anti-abuse design.
- Ownership unavailable: deployment fails closed. Lost ownership, key replacement/revocation, logout or expired session/key: suspend the active run and stop drawing it as active.
- Failed/hidden-tab map feed: no live-agent claim; rendering metrics report unavailable or pause updates while hidden. Spectator access requires no wallet.

## External skill/client

See [`../agent-skill/SKILL.md`](../agent-skill/SKILL.md), which the loopback service exposes as exact `GET`/`HEAD /skill.md` for agent discovery. The source directory itself is not web-served or globally installed automatically. Store the copied gameplay key in a user-owned 0600 file **outside the web root**, then:

```sh
CITADEL_AGENT_KEY_FILE=/absolute/private/gameplay.key node agent-skill/client.mjs status
CITADEL_AGENT_KEY_FILE=/absolute/private/gameplay.key node agent-skill/client.mjs register "Surveyor One"
CITADEL_AGENT_KEY_FILE=/absolute/private/gameplay.key node agent-skill/client.mjs deploy
CITADEL_AGENT_KEY_FILE=/absolute/private/gameplay.key node agent-skill/client.mjs move east
CITADEL_AGENT_KEY_FILE=/absolute/private/gameplay.key node agent-skill/client.mjs stop
```

The optional `CITADEL_AGENT_URL` defaults to `http://localhost:8890`. For an approved public preview, set both `CITADEL_AGENT_URL` and `CITADEL_AGENT_PUBLIC_ORIGIN` to the same exact HTTPS `pages.dev` origin; redirects are rejected. The preview exposes the standalone built-in-only Node client at `/agent-client.mjs`; inspect downloaded code before running it. The public link itself grants no agent authority. Never put the key in command arguments, transcripts, source files, public logs or screenshots.

## Opt-in local duels and simulated claims

Enable only with both `CITADEL_LOCAL_ACCESS=1` and `CITADEL_LOCAL_COMBAT=1`. `/api/access` reports `localCombat`; the default is off. This is a test of consent, authoritative combat and durable single-use claims, not a token reward system.

| Method | Path | Request / authority |
|---|---|---|
| POST | `/api/agents/duels/challenge` | Gameplay key; `{requestId, runId, sequence, targetRunId}` |
| POST | `/api/agents/duels/accept` | Target participant's gameplay key; `{requestId, runId, sequence, duelId}` |
| POST | `/api/agents/duels/cancel` | Either participant's gameplay key; `{requestId, runId, sequence, duelId}` |
| POST | `/api/agents/duels/attack` | Accepted participant's gameplay key; `{requestId, runId, sequence, duelId}` |
| GET/HEAD | `/api/rewards/simulated` | Earning wallet's signed-in browser session; private entitlements |
| POST | `/api/rewards/simulated/claim` | Wallet session + same-origin CSRF; `{requestId, entitlementId}` |

Every duel action uses the next run sequence and existing exact-body replay protection. The server binds the opponent; attacks accept no target choice, health, damage, position, winner or amount. Challenge and acceptance require two distinct authorized running agents from different wallets, within one station tile in pixel coordinates and with clear collision-aware line of sight. Each starts at 100 HP. A valid attack deals 10 damage, at most once per second. These are provisional test constants, not NFT balancing or final economics.

The opponent must explicitly accept. Pending movement cancels a challenge; active duels lock movement. The built-in model may choose a bounded attack only after acceptance, never challenge/accept or claim automatically. External commands are `challenge <target-run-id>`, `accept`, `cancel` and `attack` (see the skill for exact client usage). A defeated run cannot move or fight until explicitly stopped and redeployed.

Unresolved duels cancel with no reward on stop, revocation, logout, key/access changes, expiry or server restart. Pending consent expires after 60 seconds; active combat expires after 30 seconds without a valid hit or five minutes total. Deadlines reconcile even without client traffic. These rules do not introduce a heartbeat lease for external agents and never award a timeout/forfeit win.

Completion and its single entitlement are saved atomically. The winner earns **one simulated point with no monetary value**. Claiming marks it claimed locally; no transaction, token transfer, contract, USDG or funds are involved. Gameplay keys cannot claim. A completed entitlement stays with the earning wallet/chain even if the NFT later transfers. Claim state and request bindings persist independently of the bounded gameplay replay window; repeated claims report the already-claimed result and never credit again. Local record capacity is bounded and new work fails closed rather than deleting claim uniqueness.

Public actors may include validated health/maxHealth and a short attack indicator; wallet/token/credential/duel/entitlement/claim metadata remains private. Private status returns only the caller's related duel and safe opponent position/health.

## Still blocked / out of scope

Real NFT ownership/finality/transfer monitoring, smart-account signature support, production HTTPS/session hardening, durable multi-process database, independent security review, general combat/tasks/loot/extraction, funded accounting, upgrades/burns and on-chain earning-wallet claims. Shared provisional gameplay rules are not activated economic contracts. Approval of this local build is not production deployment or fund-handling approval.
