diff --git a/.github/workflows/docs-quality.yml b/.github/workflows/docs-quality.yml index 6f893c7..5496301 100644 --- a/.github/workflows/docs-quality.yml +++ b/.github/workflows/docs-quality.yml @@ -42,9 +42,14 @@ jobs: with: node-version: 22 cache: npm + cache-dependency-path: | + package-lock.json + examples/universal-usernames-adapter/package-lock.json - name: Install dependencies run: npm ci + # Also installs and tests the universal usernames adapter template + # (check:adapter), which has its own lockfile. - name: Validate documentation run: npm run check diff --git a/.gitignore b/.gitignore index 7799ea4..068fc6d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ .mintlify/ dist/ coverage/ +.env +.env.* +!.env.example diff --git a/api-reference/introduction.mdx b/api-reference/introduction.mdx index 1da5977..32f1c59 100644 --- a/api-reference/introduction.mdx +++ b/api-reference/introduction.mdx @@ -52,6 +52,74 @@ x-auth-token: YOUR_API_KEY +## Universal usernames API + +These routes connect a `.eth` name to a [username endpoint](/developer-guide/universal-usernames/endpoint-reference) your brand hosts. They use the same base URLs as the Offchain Manager API, but they don't accept API keys. Only the current exact owner of the parent name can call them, and the wallet signs every request. + +### Authenticate as the owner + +Both kinds of credential go in the `x-auth-token` header as `siwe:.`, where the message is an [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) sign-in message, the signature is the wallet's hex signature string, and both are base64-encoded as UTF-8 text. The message `domain` is the API host, such as `offchain-manager.namespace.ninja`, or a Namespace App domain. + +- Reads, such as getting the connection or validating an endpoint, take an unexpired sign-in session signed by the owner. +- Each change takes its own operation signature: a fresh message with a nonce from [`GET /api/v1/auth/nonce`](/api-reference/offchain-manager/universal-usernames/get-nonce), the statement `Authorize for Universal Usernames on `, an expiry at most 10 minutes after it was issued, and exactly one resource, `urn:namespace:uun:::`. + +The actions are `save`, `activate`, `rotate-credential`, `pause`, `resume`, and `disconnect`. `chainId` is `1` on the production API and `11155111` on staging. A nonce expires 60 seconds after you request it and works once. + +```ts +const message = createSiweMessage({ + domain: 'app.namespace.ninja', + uri: 'https://app.namespace.ninja', + address: owner.address, + statement: `Authorize pause for Universal Usernames on ${parentName}`, + version: '1', + chainId: 1, + nonce, // from GET /api/v1/auth/nonce + issuedAt, + expirationTime: new Date(issuedAt.getTime() + 5 * 60 * 1000), + resources: [`urn:namespace:uun:${namehash(parentName)}:pause:${revision}`], +}); +const signature = await owner.signMessage({ message }); +const base64 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); +const token = `siwe:${base64(message)}.${base64(signature)}`; +``` + +The [Offchain Manager SDK](/developer-guide/sdks/offchain-manager/universal-usernames) builds and signs these messages for you. + + + + Read the status, revisions, and last validation for a parent name. + + + Dry-run your endpoint for one username without saving anything. + + + Store endpoint settings as a new revision and validate them. + + + Verify a live lookup and start serving usernames. + + + ## Mint Manager API diff --git a/api-reference/offchain-manager/universal-usernames/activate-connection.mdx b/api-reference/offchain-manager/universal-usernames/activate-connection.mdx new file mode 100644 index 0000000..c6973be --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/activate-connection.mdx @@ -0,0 +1,5 @@ +--- +title: "Activate a username connection" +description: "Verify a live lookup and start serving usernames from the endpoint." +openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/activate" +--- diff --git a/api-reference/offchain-manager/universal-usernames/disconnect.mdx b/api-reference/offchain-manager/universal-usernames/disconnect.mdx new file mode 100644 index 0000000..0194c3d --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/disconnect.mdx @@ -0,0 +1,5 @@ +--- +title: "Disconnect a parent name" +description: "End the connection and wipe the stored credential." +openapi: "/openapi/offchain-openapi.json DELETE /api/v1/universal-usernames/{parentName}" +--- diff --git a/api-reference/offchain-manager/universal-usernames/get-connection.mdx b/api-reference/offchain-manager/universal-usernames/get-connection.mdx new file mode 100644 index 0000000..0fcbf91 --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/get-connection.mdx @@ -0,0 +1,5 @@ +--- +title: "Get a username connection" +description: "Read the status, revisions and last validation for a parent name." +openapi: "/openapi/offchain-openapi.json GET /api/v1/universal-usernames/{parentName}" +--- diff --git a/api-reference/offchain-manager/universal-usernames/get-nonce.mdx b/api-reference/offchain-manager/universal-usernames/get-nonce.mdx new file mode 100644 index 0000000..1664a66 --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/get-nonce.mdx @@ -0,0 +1,5 @@ +--- +title: "Get a nonce" +description: "Get a single-use nonce for an operation signature." +openapi: "/openapi/offchain-openapi.json GET /api/v1/auth/nonce" +--- diff --git a/api-reference/offchain-manager/universal-usernames/inspect-parent.mdx b/api-reference/offchain-manager/universal-usernames/inspect-parent.mdx new file mode 100644 index 0000000..416997c --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/inspect-parent.mdx @@ -0,0 +1,5 @@ +--- +title: "Inspect a parent name" +description: "Check ownership, resolver, route and blockers before connecting." +openapi: "/openapi/offchain-openapi.json GET /api/v1/universal-usernames/{parentName}/inspection" +--- diff --git a/api-reference/offchain-manager/universal-usernames/list-events.mdx b/api-reference/offchain-manager/universal-usernames/list-events.mdx new file mode 100644 index 0000000..e1ed131 --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/list-events.mdx @@ -0,0 +1,5 @@ +--- +title: "List connection events" +description: "Read the audit log for a parent name." +openapi: "/openapi/offchain-openapi.json GET /api/v1/universal-usernames/{parentName}/events" +--- diff --git a/api-reference/offchain-manager/universal-usernames/pause-connection.mdx b/api-reference/offchain-manager/universal-usernames/pause-connection.mdx new file mode 100644 index 0000000..e746230 --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/pause-connection.mdx @@ -0,0 +1,5 @@ +--- +title: "Pause a username connection" +description: "Stop serving usernames without disconnecting." +openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/pause" +--- diff --git a/api-reference/offchain-manager/universal-usernames/preview-username.mdx b/api-reference/offchain-manager/universal-usernames/preview-username.mdx new file mode 100644 index 0000000..98da045 --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/preview-username.mdx @@ -0,0 +1,5 @@ +--- +title: "Preview a username" +description: "Fetch the records any username would resolve to." +openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/preview" +--- diff --git a/api-reference/offchain-manager/universal-usernames/resume-connection.mdx b/api-reference/offchain-manager/universal-usernames/resume-connection.mdx new file mode 100644 index 0000000..c445abc --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/resume-connection.mdx @@ -0,0 +1,5 @@ +--- +title: "Resume a username connection" +description: "Serve usernames again after a pause." +openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/resume" +--- diff --git a/api-reference/offchain-manager/universal-usernames/rotate-credential.mdx b/api-reference/offchain-manager/universal-usernames/rotate-credential.mdx new file mode 100644 index 0000000..e5cf557 --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/rotate-credential.mdx @@ -0,0 +1,5 @@ +--- +title: "Rotate the endpoint credential" +description: "Replace the bearer token Namespace sends to your endpoint." +openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/credential" +--- diff --git a/api-reference/offchain-manager/universal-usernames/save-connection.mdx b/api-reference/offchain-manager/universal-usernames/save-connection.mdx new file mode 100644 index 0000000..99ffd90 --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/save-connection.mdx @@ -0,0 +1,5 @@ +--- +title: "Save a username connection" +description: "Save endpoint settings as a new revision and validate them." +openapi: "/openapi/offchain-openapi.json PUT /api/v1/universal-usernames/{parentName}" +--- diff --git a/api-reference/offchain-manager/universal-usernames/validate-endpoint.mdx b/api-reference/offchain-manager/universal-usernames/validate-endpoint.mdx new file mode 100644 index 0000000..ebd9fce --- /dev/null +++ b/api-reference/offchain-manager/universal-usernames/validate-endpoint.mdx @@ -0,0 +1,5 @@ +--- +title: "Validate an endpoint" +description: "Dry-run an endpoint for one username without saving anything." +openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/validate" +--- diff --git a/changelog/offchain-sdk.mdx b/changelog/offchain-sdk.mdx index 4ae7cd6..d56cbbb 100644 --- a/changelog/offchain-sdk.mdx +++ b/changelog/offchain-sdk.mdx @@ -6,6 +6,12 @@ icon: "cloud" --- + + - Added: + - **Universal usernames**: `getUsernameConnection`, `validateUsernameEndpoint`, `saveUsernameConnection`, `activateUsernameConnection`, `previewUsername`, `rotateUsernameCredential`, `pauseUsernameConnection`, `resumeUsernameConnection`, `disconnectUsernameConnection`, and `getUsernameConnectionEvents` connect a `.eth` name to a brand-hosted username endpoint. They authenticate with the parent owner's wallet, and every change takes one signature instead of an API key. + - **Typed errors** for these methods: `NotParentOwnerError`, `ConnectionRevisionConflictError`, and `EndpointValidationError`. Existing methods keep their current error behaviour. + + - Added: - **New Blockchain Support**: Added support for two Substrate L1 chains: diff --git a/changelog/universal-usernames-sdk.mdx b/changelog/universal-usernames-sdk.mdx new file mode 100644 index 0000000..9b30037 --- /dev/null +++ b/changelog/universal-usernames-sdk.mdx @@ -0,0 +1,16 @@ +--- +title: "Universal Usernames SDK" +description: "Changelog for the Universal Usernames SDK." +sidebarTitle: "Universal Usernames SDK" +icon: "globe" +--- + + + - Added: + - **Profile schema v1**: `USERNAME_PROFILE_SCHEMA_V1`, `PROFILE_LIMITS`, and the `UsernameProfileV1` type for the JSON your username endpoint returns. + - **Validator**: `validateUsernameProfile` and `parseUsernameProfile` report every issue with a stable code, a kind (`envelope`, `record`, or `transport`), and a JSON Pointer path. Duplicate keys are caught on the raw body. + - **Record resolution**: `resolveText`, `resolveAddress` with the ENSIP-19 default EVM rules, and `resolveContenthash` for `ipfs://`, `ipns://`, `bzz://`, and `ar://`. + - **Endpoint helpers**: `createUsernameHandler` serves profiles as a Fetch API handler with an optional constant-time bearer check, and `defineUsernameProfile` accepts chain names as address keys. + - **Validator CLI**: `universal-usernames validate --username diff --git a/developer-guide/guide/connect-universal-usernames.mdx b/developer-guide/guide/connect-universal-usernames.mdx new file mode 100644 index 0000000..37dd9da --- /dev/null +++ b/developer-guide/guide/connect-universal-usernames.mdx @@ -0,0 +1,190 @@ +--- +title: "Connect universal usernames" +description: "Resolve every username in your system as an ENS name." +sidebarTitle: "Connect universal usernames" +icon: "globe" +--- + +import ProfileExample from '/snippets/universal-usernames/profile-v1-example.mdx'; + +Connect `brand.eth` to an HTTPS endpoint you run, and every username that endpoint knows resolves as `username.brand.eth` in ENS-aware wallets. You build the endpoint, connect it in the Namespace App, point `brand.eth` at Namespace's HybridResolverV2, and activate. Your system stays the source of truth for usernames and records. + +For the concepts first, read [Universal usernames](/overview/universal-usernames). + +## Prerequisites + +- A `.eth` name directly below `.eth`, such as `brand.eth`, that hasn't expired. +- The wallet that owns it. Only the exact owner can connect a name, and approved operators don't count: + - for a wrapped name, the NameWrapper owner; + - for an unwrapped name, the ENS registry owner, shown as the manager in the ENS app, which can differ from the registrant; + - for an ENSv2 name, the owner in the ENSv2 registry. +- A place to host an HTTPS endpoint with a public address. +- ETH for one or two transactions, unless `brand.eth` already uses HybridResolverV2. + + +Universal usernames is rolling out to selected brands first. If the Namespace App doesn't show a **Usernames** tab for your wallet, [talk to us](/user-guide/custom-solutions). + + +## What you sign + +| Step | What it takes | +| --- | --- | +| Save the endpoint | One wallet signature | +| Switch the resolver | One or two transactions, only if `brand.eth` isn't on HybridResolverV2 yet | +| Activate | One wallet signature | +| Replace the endpoint later | Two wallet signatures: save, then activate | +| Rotate the credential, pause, resume, or disconnect | One wallet signature each, no transaction | + +Each signature authorizes one change to one parent at one revision, uses a single-use nonce, and expires within 10 minutes, so it can't be replayed. + + + + +Open the [offchain dashboard](https://app.namespace.ninja/offchain) in the Namespace App, go to the **Usernames** tab, and connect the wallet that owns `brand.eth`. Pick the name from the list, or type it in. The App checks ownership live, so a name you transferred recently shows its current owner. + + + + + +The App reads `brand.eth` onchain and shows its registry version, whether it's wrapped, its resolver and route, the records on the parent that will be copied, the number of stored offchain subnames, and any onchain subnames. + +Some states block a connection: + +| Blocker | Why | What to do | +| --- | --- | --- | +| `NOT_ETH_2LD` | The name isn't directly below `.eth`, for example `brand.app.eth` or a DNS name | Use a second-level `.eth` name | +| `EXPIRED` | The name has expired | Renew it | +| `CANNOT_SET_RESOLVER` | The name is wrapped with the `CANNOT_SET_RESOLVER` fuse burned | Use another name; the resolver can't change | +| `L2_ROUTE` | `brand.eth` already uses HybridResolverV2 to serve subnames from an L2 | Switching the route would break those subnames, so it's blocked | + +If `brand.eth` has stored offchain subnames, the App asks you to acknowledge that they stop resolving while the parent is connected. Namespace records that acknowledgment in the connection's events, and blocks changes to stored subnames until you disconnect. + +Onchain subnames such as an existing onchain `happy.brand.eth` keep resolving onchain and hide that username from your endpoint. The App lists them so you can decide what to do with each. + + + + + +Your endpoint answers `GET {endpoint URL}/{username}` with a JSON profile, or `404` when the username doesn't exist: + + + +Pick a starting point: + +- Copy the [adapter template](/developer-guide/universal-usernames/adapter-template), a tested Hono endpoint, and replace its `lookupUser` stub. +- Use `createUsernameHandler` from the [Universal Usernames SDK](/developer-guide/sdks/universal-usernames) inside your existing service. +- Have your coding agent build it. Install the skill with `npx skills add thenamespace/skills -s universal-usernames`; it carries the contract, record formats, and adapters for Hono, Express, and Next.js. + +The [endpoint reference](/developer-guide/universal-usernames/endpoint-reference) has the full contract. Deploy the endpoint and check it before you connect it: + +```bash +npx @thenamespace/universal-usernames validate https://api.brand.example/ens/users --username happy +``` + + + + + +In the App, enter: + +- The endpoint URL, such as `https://api.brand.example/ens/users`. Namespace appends `/{username}`. +- The authentication: none, or a bearer token your endpoint expects. +- A sample username that exists in your system, such as `happy`. + +Select **Validate** for a dry run. Namespace calls your endpoint the same way lookups do, lists every issue with the JSON path it applies to, and shows a read-only preview of the records that passed. Nothing is stored yet. Fix any issues with the [troubleshooting codes](/developer-guide/universal-usernames/troubleshooting) and validate again. + +When the report is clean, select **Save** and sign the message in your wallet. The connection becomes `validated`. Namespace stores the token encrypted and never shows it again. + + + + + +Lookups reach your endpoint through Namespace's HybridResolverV2 and its offchain route, route `1`. If `brand.eth` already uses HybridResolverV2 on its default or offchain route, skip this step. + +| Network | HybridResolverV2 address | +| --- | --- | +| Ethereum mainnet | `0x0A67542cbD8d71CB760d1EeB5a4b4a77ea35Ff9b` | +| Sepolia | `0x976a3E43b2a6706F52D9555931c68eb62D134ff7` | + +HybridResolverV2 has no fallback to your current resolver. Records on `brand.eth` itself, such as its address, avatar, or contenthash, have to be copied to HybridResolverV2 before the switch, or `brand.eth` stops returning them. The App does this in order: + +1. On HybridResolverV2, set the offchain route for `brand.eth` and copy every record the current resolver holds that the App can encode, in one `multicall`. +2. Set the resolver of `brand.eth` to HybridResolverV2. + +| Parent | Transaction 1, on HybridResolverV2 | Transaction 2 | +| --- | --- | --- | +| Already on HybridResolverV2, default or offchain route | None | None | +| ENSv1, unwrapped | `multicall` with `setNameRoute(name, 1)` and the record copies | `setResolver` on the ENS registry | +| ENSv1, wrapped | Same as unwrapped | `setResolver` on the NameWrapper | +| ENSv2 | Same `multicall`, with `setNameRoute` first | `setResolver` on the `.eth` registry | + +With a wallet that supports batched calls (EIP-5792), the App sends both as one batch. After the switch it shows the old and new value of every copied record. If a record can't be copied, the App stops and asks before dropping it. Your old resolver isn't changed. + +Until you activate, the parent keeps serving any stored offchain subnames, and usernames from your endpoint resolve empty. + + + + + +Select **Activate** and sign. Namespace checks that: + +- your wallet still owns `brand.eth`; +- the resolver of `brand.eth` is HybridResolverV2; +- the route for `happy.brand.eth` is the offchain route; +- `happy.brand.eth` has no onchain owner; +- stored offchain subnames are acknowledged, if there are any. + +The connection moves to `verifying` and the parent starts serving from your endpoint. Namespace then resolves the sample username through the ENS Universal Resolver with CCIP-Read and compares the answer with the validated preview. A match makes the connection `active`. A mismatch puts it back to `validated` with the errors, and you can fix them and activate again. + +Check it yourself with any ENS client. viem follows CCIP-Read by default: + +```ts +import { createPublicClient, http } from 'viem'; +import { mainnet } from 'viem/chains'; +import { normalize } from 'viem/ens'; + +const client = createPublicClient({ chain: mainnet, transport: http() }); +const name = normalize('happy.brand.eth'); + +const address = await client.getEnsAddress({ name }); +const avatar = await client.getEnsText({ name, key: 'avatar' }); +console.log({ address, avatar }); +``` + + + + + +The **Usernames** tab shows the connection's health. From there you can: + +- Preview any username to see the records it resolves to right now. Records are edited in your own system; Namespace only shows them. +- Replace the endpoint by saving the new URL and activating it. The current endpoint keeps serving until the new one passes, and a failed replacement leaves it serving. +- Rotate the credential. This is one step: Namespace validates the new token and switches to it in the same call, with no activation afterwards. Accept both tokens on your endpoint until the rotation finishes. +- Pause the connection. While it's paused, lookups under `brand.eth` return an error with no signature. Resuming checks ownership and the sample username again. +- Disconnect. This is final: Namespace wipes the credential, keeps a redacted history, and stored offchain subnames resolve again. The resolver and route stay as they are, so switch the resolver back yourself if you want to. +- Read the events. Every save, activation, rotation, pause, suspension, and disconnect is logged with the signer and revision. + +If `brand.eth` changes owner or expires, Namespace suspends the connection within about a minute and wipes the credential. Lookups return an error from then on. The new owner has to connect from scratch. + + + + +## Behaviours to plan for + +These follow from how the deployed resolver and the gateway work. They aren't bugs, so design around them. + +- A username that has an onchain owner keeps resolving onchain. HybridResolverV2 answers names with an onchain owner from its own records, even empty ones, so your endpoint never sees them. +- There's no fallback resolver. Copy the parent's records before switching, which the App does for you. +- Usernames are one label. `team.happy.brand.eth` resolves empty and never reaches your endpoint. +- Proofs last at most 60 seconds, and Namespace serves a change within 60 seconds. Wallets and apps can cache answers for longer. +- Changing the connection takes a wallet signature, never a transaction. Only the first resolver switch is onchain. +- Everything that resolves is public, even when your endpoint requires a bearer token. + +## Troubleshooting + +[Troubleshooting universal usernames](/developer-guide/universal-usernames/troubleshooting) explains every validation issue code and connection error. For general problems, see [Troubleshooting](/developer-guide/troubleshooting). + +## Next steps + +- Automate setup with the [owner API](/api-reference/offchain-manager/universal-usernames/get-connection) or the [Offchain Manager SDK](/developer-guide/sdks/offchain-manager/universal-usernames). +- Read the [endpoint reference](/developer-guide/universal-usernames/endpoint-reference) and [records](/developer-guide/universal-usernames/records) in full. diff --git a/developer-guide/sdks/introduction.mdx b/developer-guide/sdks/introduction.mdx index 8b614cf..192fa2f 100644 --- a/developer-guide/sdks/introduction.mdx +++ b/developer-guide/sdks/introduction.mdx @@ -18,6 +18,9 @@ SDK packages include: Query indexed subnames and registry deployments from L2 chains. + + Validate and serve the profiles behind universal usernames. + Upload and delete ENS avatar and header images with SIWE authentication. diff --git a/developer-guide/sdks/offchain-manager/universal-usernames.mdx b/developer-guide/sdks/offchain-manager/universal-usernames.mdx new file mode 100644 index 0000000..15a9e20 --- /dev/null +++ b/developer-guide/sdks/offchain-manager/universal-usernames.mdx @@ -0,0 +1,134 @@ +--- +title: "Universal usernames" +description: "Connect a .eth name to your username endpoint from code." +sidebarTitle: "Universal usernames" +icon: "globe" +--- + +The Offchain Manager SDK can run the whole [universal usernames setup](/developer-guide/guide/connect-universal-usernames) from code: validate an endpoint, save it, activate it, and manage it afterwards. These methods are in `@thenamespace/offchain-manager` 1.2.0 and later. + +They don't use API keys. Only the wallet that owns the parent name can call them, so the client needs that wallet: + +- Reads use a sign-in session from that wallet. +- Every change asks the wallet for one signature that authorizes that change, on that parent, at the revision you send. No transaction is involved. + +## Install and initialize + +```bash +npm install @thenamespace/offchain-manager@1.2.0 +``` + +Pass the owner's wallet as `provider`. It accepts the same wallets as the [Avatar SDK](/developer-guide/sdks/avatar/installation): a viem `WalletClient`, an ethers `Signer`, or a `WalletProvider`. The sign-in messages use the API host of the selected network as their domain. + +```ts +import { createOffchainClient } from '@thenamespace/offchain-manager'; + +const client = createOffchainClient({ + mode: 'mainnet', + provider: walletClient, // the wallet that owns brand.eth +}); +``` + +Use `mode: 'sepolia'` with a Sepolia name while you test. + +## Connect a parent name + +```ts +const parentName = 'brand.eth'; +const endpoint = { + endpointUrl: 'https://api.brand.example/ens/users', + authMode: 'bearer' as const, + credential: process.env.ENDPOINT_TOKEN, +}; + +// 1. Dry run: nothing is stored. +const report = await client.validateUsernameEndpoint(parentName, { ...endpoint, username: 'happy' }); +if (!report.ok) { + for (const issue of report.issues) console.error(issue.code, issue.path, issue.message); + throw new Error('Fix the endpoint before saving it'); +} + +// 2. Save. One signature. getUsernameConnection returns null only when the parent +// never had a connection; after a disconnect it returns the old one, whose revision you send. +const current = await client.getUsernameConnection(parentName); +const saved = await client.saveUsernameConnection(parentName, { + ...endpoint, + sampleUsername: 'happy', + expectedRevision: current?.revision ?? 0, +}); + +// 3. Point brand.eth at HybridResolverV2 (see the guide), then activate. One signature. +const connection = await client.activateUsernameConnection(parentName, { + expectedRevision: saved.revision, +}); +console.log(connection.status); // 'active', or 'validated' with connection.activation.errors +``` + +Every change takes the connection's current `revision` as `expectedRevision`. If someone else changed the connection in between, the call fails with a conflict instead of overwriting their change. + +## Replace the endpoint or rotate the credential + +On an active connection the two changes work differently: + +- Replacing the endpoint is two calls. `saveUsernameConnection` stores the new endpoint as a candidate while the current one keeps serving, and `activateUsernameConnection` promotes it after a live check. If the check fails, the candidate is rejected and the current endpoint keeps serving. +- Rotating the credential is one call. `rotateUsernameCredential` validates the new token and promotes it before it returns. Calling `activateUsernameConnection` afterwards fails with `409 INVALID_STATE`. + +```ts +// Replace the endpoint: save, then activate. +const candidate = await client.saveUsernameConnection(parentName, { + endpointUrl: 'https://api.brand.example/v2/ens/users', + authMode: 'bearer', + keepCredential: true, + sampleUsername: 'happy', + expectedRevision: connection.revision, +}); +const replaced = await client.activateUsernameConnection(parentName, { + expectedRevision: candidate.revision, +}); + +// Rotate the credential: one call, already serving when it returns. +const newToken = process.env.NEW_ENDPOINT_TOKEN; +if (!newToken) throw new Error('Set NEW_ENDPOINT_TOKEN to the new bearer token'); +const rotated = await client.rotateUsernameCredential(parentName, { + credential: newToken, + expectedRevision: replaced.revision, +}); +console.log(rotated.status); // 'active' +``` + +## Methods + +| Method | What it does | Wallet | +| --- | --- | --- | +| `getUsernameConnection(parentName)` | Status, revisions, and the last validation. Never includes the credential. Returns `null` when the parent has no connection. | Session | +| `validateUsernameEndpoint(parentName, { endpointUrl, authMode, credential?, username })` | Dry run against your endpoint. Returns a validation report; stores nothing. | Session | +| `saveUsernameConnection(parentName, { endpointUrl, authMode, credential?, keepCredential?, sampleUsername, acknowledgeStoredSubnames?, expectedRevision })` | Saves a new revision and validates it. `validated` only when the report is clean. | One signature | +| `activateUsernameConnection(parentName, { expectedRevision })` | Checks the preconditions, verifies a live lookup, and starts serving. On an active connection, promotes the endpoint replacement you saved. | One signature | +| `previewUsername(parentName, username)` | The records any username resolves to now, as a validation report | Session | +| `rotateUsernameCredential(parentName, { credential, expectedRevision })` | Validates a new bearer token and switches to it in the same call. There's nothing to activate afterwards. | One signature | +| `pauseUsernameConnection(parentName, { expectedRevision })` | Lookups return an error until you resume | One signature | +| `resumeUsernameConnection(parentName, { expectedRevision })` | Rechecks ownership and the sample username, then serves again | One signature | +| `disconnectUsernameConnection(parentName, { expectedRevision })` | Ends the connection for good and wipes the credential | One signature | +| `getUsernameConnectionEvents(parentName)` | The audit log, newest first | Session | + +Each method calls one route of the [owner API](/api-reference/offchain-manager/universal-usernames/get-connection). The request fields and the `UsernameConnection` shape are documented there. + +## Types + +| Type | Description | +| --- | --- | +| `UsernameConnection` | The connection: `status`, `revision`, `activeRevision`, `latestRevision`, owner snapshot, and activation result. Revisions report `hasCredential`, never the credential. | +| `UsernameConnectionStatus` | `'draft' \| 'validated' \| 'verifying' \| 'active' \| 'paused' \| 'suspended' \| 'disconnected'` | +| `UsernameValidationReport` | The validation report from `@thenamespace/universal-usernames`: `ok`, `issues`, `preview`, and request details | + +## Errors + +The universal usernames methods throw typed errors instead of raw HTTP errors: + +| Error | When | +| --- | --- | +| `NotParentOwnerError` | The wallet isn't the current owner of the parent (`403 NOT_PARENT_OWNER`) | +| `ConnectionRevisionConflictError` | `expectedRevision` is stale (`409 REVISION_CONFLICT`). Reload the connection and retry. | +| `EndpointValidationError` | The request was rejected as invalid (`422 VALIDATION_FAILED`) | + +Other failures carry the API's `code` and `message`. `UPSTREAM_UNAVAILABLE` (`503`) means an Ethereum RPC or the Namespace gateway didn't answer during an ownership, inspection, or validation check; nothing changed, so retry the call. [Troubleshooting universal usernames](/developer-guide/universal-usernames/troubleshooting#connection-errors) explains each code. The existing subname methods keep their current error behaviour. diff --git a/developer-guide/sdks/universal-usernames.mdx b/developer-guide/sdks/universal-usernames.mdx new file mode 100644 index 0000000..729b07c --- /dev/null +++ b/developer-guide/sdks/universal-usernames.mdx @@ -0,0 +1,185 @@ +--- +title: "Universal Usernames SDK" +description: "Schema, validator, and endpoint helpers for universal usernames." +sidebarTitle: "Universal Usernames" +icon: "globe" +--- + +`@thenamespace/universal-usernames` holds everything about the profile your username endpoint returns: the schema v1 definition, the validator Namespace runs, helpers to build and serve profiles, and a CLI that checks a deployed endpoint. The Namespace gateway uses the same validator, so a profile that passes here passes in production. + +It ships as CommonJS with type definitions and has no wallet or HTTP client dependencies. The handler uses the standard Fetch API `Request` and `Response`. + +## Install + + + + +```bash +npm install @thenamespace/universal-usernames@0.1.0 +``` + + + + +```bash +yarn add @thenamespace/universal-usernames@0.1.0 +``` + + + + +```bash +pnpm add @thenamespace/universal-usernames@0.1.0 +``` + + + + +## Serve profiles + +`createUsernameHandler` turns a lookup function into a Fetch API handler, `(request: Request) => Promise`. It reads the username from the path, calls your lookup, and answers `200` with the profile or `404` when the lookup returns `null`. With `bearerToken` set, it compares the `Authorization` header in constant time and answers `401` when it doesn't match. + +| Option | Description | +| --- | --- | +| `bearerToken` | Token every request must send as `Authorization: Bearer `. Leave it out for a public endpoint. | +| `basePath` | The path your endpoint URL ends in, such as `/ens/users`. The username is the segment after it. | + + + +```ts Next.js: app/ens/users/[username]/route.ts +import { createUsernameHandler, defineUsernameProfile } from '@thenamespace/universal-usernames'; +import { findUserByUsername } from '@/lib/users'; + +export const GET = createUsernameHandler( + async (username) => { + const user = await findUserByUsername(username); + if (!user) return null; + + return defineUsernameProfile({ + username, + texts: { name: user.displayName }, + addresses: { eth: user.walletAddress }, + }); + }, + { basePath: '/ens/users', bearerToken: process.env.ENDPOINT_TOKEN }, +); +``` + +```ts Hono +import { Hono } from 'hono'; +import { createUsernameHandler, defineUsernameProfile } from '@thenamespace/universal-usernames'; +import { findUserByUsername } from './users.js'; + +const handleUsername = createUsernameHandler( + async (username) => { + const user = await findUserByUsername(username); + if (!user) return null; + + return defineUsernameProfile({ + username, + texts: { name: user.displayName }, + addresses: { eth: user.walletAddress }, + }); + }, + { basePath: '/ens/users', bearerToken: process.env.ENDPOINT_TOKEN }, +); + +const app = new Hono(); +app.get('/ens/users/:username', (c) => handleUsername(c.req.raw)); + +export default app; +``` + + + +Return the username you were given, not the display name you store. Don't catch errors from your user store and return `null`: let them throw so your framework answers `500`, and wallets see an error instead of an empty profile. + +## Build profiles + +`defineUsernameProfile` builds a `UsernameProfileV1`. Address keys can be decimal coin types or chain names, which it converts with `coinTypeForChain`: + +```ts +import { defineUsernameProfile } from '@thenamespace/universal-usernames'; + +const profile = defineUsernameProfile({ + username: 'happy', + texts: { name: 'Happy', 'com.github': 'happy' }, + addresses: { + eth: '0x1111111111111111111111111111111111111111', + base: '0x2222222222222222222222222222222222222222', + btc: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + }, +}); +// profile.addresses → { '60': '0x1111…', '2147492101': '0x2222…', '0': 'bc1q…' } +``` + +`coinTypeForChain(name)` maps a chain name to its ENS coin type, for example `'eth'` to `60`, `'btc'` to `0`, `'sol'` to `501`, `'base'` to `2147492101`, and `'evm'` to the default EVM coin type `2147483648`. It throws on names it doesn't know. + +## Validate from the command line + +The CLI makes the same request Namespace makes and prints a validation report: + +```bash +npx @thenamespace/universal-usernames validate https://api.brand.example/ens/users --username happy +npx @thenamespace/universal-usernames validate https://api.brand.example/ens/users --username happy --token "$ENDPOINT_TOKEN" +``` + +It refuses redirects and applies the same size and time limits as the gateway. It exits with `0` when the report has no issues and `1` otherwise, so you can run it in CI after a deploy. + +## Validate in code + +`validateUsernameProfile` checks a raw response body and returns every issue plus a preview of the records that passed: + +```ts +import { validateUsernameProfile } from '@thenamespace/universal-usernames'; + +const body = await (await fetch('https://api.brand.example/ens/users/happy')).text(); +const { issues, preview } = validateUsernameProfile(body, { expectedUsername: 'happy' }); + +for (const issue of issues) { + console.log(`${issue.code} (${issue.kind}) at ${issue.path || '/'}: ${issue.message}`); +} +``` + +It works on the raw text rather than parsed JSON, so it catches duplicate keys that `JSON.parse` hides. Each issue has a stable `code`, a `kind` (`envelope`, `record`, or `transport`), and a JSON Pointer `path`. [Troubleshooting](/developer-guide/universal-usernames/troubleshooting) explains every code. + +`parseUsernameProfile` does the same split for runtime use. It returns `envelopeErrors`, `recordErrors`, and the `profile`, which is `null` when the envelope is invalid. + +## Normalize usernames + +```ts +import { isNormalizedUsername, normalizeUsername } from '@thenamespace/universal-usernames'; + +normalizeUsername('Happy'); // 'happy' +isNormalizedUsername('Happy'); // false +normalizeUsername('team.happy'); // throws UsernameError: one label only +``` + +Both follow [ENSIP-15](https://docs.ens.domains/ensip/15). Use `normalizeUsername` when you store usernames, so your lookups match what Namespace sends. + +## Reference + +| Export | Description | +| --- | --- | +| `createUsernameHandler(lookup, options?)` | Fetch API handler for your endpoint | +| `defineUsernameProfile(input)` | Builds a profile; address keys can be chain names | +| `validateUsernameProfile(rawBody, { expectedUsername })` | Every issue plus a preview of valid records | +| `parseUsernameProfile(rawBody, { expectedUsername })` | Envelope errors, record errors, and the parsed profile | +| `resolveText(profile, key)` | One text record: `found`, `absent`, or `invalid` | +| `resolveAddress(profile, coinType)` | One address as `0x`-prefixed bytes, with the ENSIP-19 default EVM rules applied | +| `resolveContenthash(profile)` | The contenthash as ENSIP-7 bytes | +| `normalizeUsername(input)` | ENSIP-15 normalized single label, or throws `UsernameError` | +| `isNormalizedUsername(label)` | `true` when the label is one normalized label | +| `checkEndpointUrl(url, options?)` | Checks an endpoint URL against the URL rules | +| `buildProfileUrl(endpointUrl, label)` | The URL Namespace requests for a username | +| `coinTypeForChain(name)` | Chain name to ENS coin type | +| `isEvmCoinType(coinType)`, `isSupportedCoinType(coinType)` | Coin type checks | +| `USERNAME_PROFILE_SCHEMA_V1` | The JSON schema for profile v1 | +| `PROFILE_SCHEMA_VERSION` | `1` | +| `PROFILE_LIMITS` | Size and count limits | +| `DEFAULT_EVM_COIN_TYPE` | `2147483648` | +| `SUPPORTED_CONTENTHASH_PROTOCOLS` | `['ipfs', 'ipns', 'bzz', 'ar']` | + +Types: `UsernameProfileV1`, `ValidationIssue`, `ValidationIssueCode`, `EndpointValidationReport`, `ParsedUsernameProfile`, and `RecordResult`. + +The package also ships valid and invalid profile fixtures under `fixtures/`, with an index of the issue codes each invalid one should produce. Use them in your own tests. diff --git a/developer-guide/skills.mdx b/developer-guide/skills.mdx index 250d81e..4e1f34b 100644 --- a/developer-guide/skills.mdx +++ b/developer-guide/skills.mdx @@ -85,6 +85,21 @@ Once installed, your AI agent can: - Tell a name obstacle from a minter obstacle, so a gated wallet is not sent back to the name field - Cap the price with `maxValue` and handle `MintManagerError` codes +### Universal usernames skill + +The `universal-usernames` skill is for the coding agent that builds your username endpoint. It covers the endpoint contract, profile schema v1, record formats, the `404` versus error rules, bearer authentication, and adapters for Hono, Express, and Next.js. + +**Install in your project:** + +```bash +npx skills add thenamespace/skills -s universal-usernames +``` + +Once installed, your AI agent can: +- Build an endpoint that maps your users to schema v1 profiles +- Key addresses by ENS coin type and apply the default EVM address correctly +- Check the endpoint with the validator CLI and fix each issue code it reports + Skills work with any AI coding tool that supports agent context, including Cursor, Claude Code, and GitHub Copilot. diff --git a/developer-guide/universal-usernames/adapter-template.mdx b/developer-guide/universal-usernames/adapter-template.mdx new file mode 100644 index 0000000..6d4a4d3 --- /dev/null +++ b/developer-guide/universal-usernames/adapter-template.mdx @@ -0,0 +1,157 @@ +--- +title: "Adapter template" +description: "A tested Hono endpoint to start from." +sidebarTitle: "Adapter template" +icon: "file-code" +--- + +The adapter template is a small TypeScript endpoint built with [Hono](https://hono.dev). It answers `GET /{username}` with a schema v1 profile, returns `404` for unknown usernames, and can require a bearer token. It has no dependency on Namespace packages, so you can copy it into any repository. The docs repository runs its tests on every change. + + + examples/universal-usernames-adapter + + +## What's in it + +| File | What it does | +| --- | --- | +| `src/app.ts` | The Hono app: bearer check, `GET /:username`, `404` for unknown usernames and paths, `500` when the lookup throws | +| `src/bearer-token.ts` | Constant-time bearer token comparison | +| `src/users.ts` | `lookupUser`, a stub over an in-memory map. Replace it with your user store. | +| `src/profile.ts` | The `UsernameProfileV1` type | +| `src/server.ts` | Starts the Node.js server and reads `PORT` and `ENDPOINT_TOKEN` | +| `schema/profile-v1.schema.json` | The profile schema the tests validate against | +| `tests/app.test.ts` | Vitest tests for `200`, `404`, `401`, `500`, and unknown paths | + +## Run it + +You need Node.js 22.12 or later. + +```bash +git clone --depth 1 https://github.com/thenamespace/docs.git namespace-docs +cp -R namespace-docs/examples/universal-usernames-adapter my-usernames-endpoint +cd my-usernames-endpoint +npm install +npm run dev +``` + +Then request a sample user: + +```bash +curl -i http://localhost:8787/happy +``` + +Run the tests and the type check: + +```bash +npm test +npm run typecheck +``` + +## The handler + +`createApp` takes your lookup function and an optional token. Everything Namespace relies on lives here: the status codes, the JSON content type, and the order of checks, so a wrong token never reveals whether a username exists. + +```ts src/app.ts +import { Hono } from 'hono'; +import { isAuthorizedBearer } from './bearer-token.js'; +import type { UsernameProfileV1 } from './profile.js'; + +/** Returns the profile for a username, or null when the username doesn't exist. */ +export type LookupUser = (username: string) => Promise; + +export interface AppOptions { + lookupUser: LookupUser; + /** When set, every request must send `Authorization: Bearer `. */ + endpointToken?: string; +} + +/** + * Namespace calls GET {your endpoint URL}/{username}, where username is one + * ENSIP-15 normalized label. Answer 200 with the profile, or 404 when the + * username doesn't exist. Any other status is treated as an outage, never as + * "no such user". + */ +export function createApp({ lookupUser, endpointToken }: AppOptions): Hono { + const app = new Hono(); + + if (endpointToken) { + app.use('*', async (c, next) => { + if (!isAuthorizedBearer(c.req.header('Authorization'), endpointToken)) { + return c.json({ error: 'unauthorized' }, 401); + } + await next(); + }); + } + + app.get('/:username', async (c) => { + // Hono decodes the percent-encoded label, so "caf%C3%A9" arrives as "café". + const username = c.req.param('username'); + const profile = await lookupUser(username); + if (!profile) return c.json({ error: 'not_found' }, 404); + return c.json(profile); + }); + + app.notFound((c) => c.json({ error: 'not_found' }, 404)); + + // A failing user store must not look like a missing user, so it gets a 500. + app.onError((error, c) => { + console.error('Username lookup failed', error); + return c.json({ error: 'internal_error' }, 500); + }); + + return app; +} +``` + +The bearer check hashes both tokens before comparing them, so the comparison takes the same time whatever the token's length: + +```ts src/bearer-token.ts +import { createHash, timingSafeEqual } from 'node:crypto'; + +const BEARER = /^Bearer\s+(.+)$/i; + +/** Checks an Authorization header against the expected bearer token in constant time. */ +export function isAuthorizedBearer(header: string | undefined, expectedToken: string): boolean { + const presented = header?.match(BEARER)?.[1]; + if (!presented) return false; + + // Hashing both sides gives timingSafeEqual equal-length inputs, so the + // comparison time reveals neither the token nor its length. + return timingSafeEqual(sha256(presented), sha256(expectedToken)); +} + +function sha256(value: string): Buffer { + return createHash('sha256').update(value).digest(); +} +``` + +## Connect your user store + +Replace `lookupUser` in `src/users.ts`. It receives the decoded, normalized username and returns a profile or `null`: + +- Return `null` when the user doesn't exist. The app answers `404`, and wallets get an empty result. +- Throw when your store fails. The app answers `500`, and wallets get an error instead of a wrong "no such user". +- Set `username` to the username you received, not the display name you store. +- Key addresses by coin type and leave missing records out. See [Records](/developer-guide/universal-usernames/records). + +## Require a bearer token + +Set `ENDPOINT_TOKEN` in `.env` to a long random value, for example from `openssl rand -hex 32`. Every request then needs `Authorization: Bearer `, and anything else gets `401`. Enter the same token as the credential when you connect the endpoint. The records stay public through ENS either way; the token only keeps other callers off your endpoint. + +## Deploy and validate + +```bash +npm run build +npm start +``` + +Serve it over HTTPS on any Node.js host, then validate the public URL with the [validator CLI](/developer-guide/sdks/universal-usernames#validate-from-the-command-line): + +```bash +npx @thenamespace/universal-usernames validate https://usernames.brand.example --username happy --token "$ENDPOINT_TOKEN" +``` + +The CLI exits with `0` when the profile is valid and prints every issue otherwise. When it passes, [connect the endpoint](/developer-guide/guide/connect-universal-usernames) in the Namespace App. + +To serve the endpoint under a path, mount the app with `app.route('/ens/users', createApp(…))` and connect `https://api.brand.example/ens/users`. The template's README shows the full snippet. diff --git a/developer-guide/universal-usernames/endpoint-reference.mdx b/developer-guide/universal-usernames/endpoint-reference.mdx new file mode 100644 index 0000000..b1413a0 --- /dev/null +++ b/developer-guide/universal-usernames/endpoint-reference.mdx @@ -0,0 +1,137 @@ +--- +title: "Endpoint reference" +description: "The HTTP contract your username endpoint implements." +sidebarTitle: "Endpoint reference" +icon: "plug" +--- + +import ProfileSchema from '/snippets/universal-usernames/profile-v1-schema.mdx'; +import ProfileExample from '/snippets/universal-usernames/profile-v1-example.mdx'; + +When a wallet resolves a username under your connected `.eth` name, the Namespace gateway calls your endpoint for that username, checks the answer, and signs the record the wallet asked for. This page is the contract your endpoint implements: the request, the profile schema, status codes, limits, and caching. + +To start from working code, use the [adapter template](/developer-guide/universal-usernames/adapter-template). + +## Request + +```http +GET https://api.brand.example/ens/users/happy +Accept: application/json +User-Agent: Namespace-UniversalUsernames/1 +Authorization: Bearer YOUR_ENDPOINT_TOKEN +``` + +Namespace takes the endpoint URL you connected, drops any trailing slash, and appends `/` and the username encoded with `encodeURIComponent`. It sends `Authorization` only when you set up [bearer authentication](#bearer-authentication). + +### The username + +- It's always one label. Namespace calls your endpoint for `happy.brand.eth`, never for `team.happy.brand.eth`. Deeper names resolve empty without a request. +- It's already [ENSIP-15](https://docs.ens.domains/ensip/15) normalized, so you receive `happy`, never `Happy`. Labels that aren't normalized resolve empty without a request. +- It's percent-encoded. `café` arrives as `caf%C3%A9`, so decode the path segment before you look it up. + +Look users up by the normalized form of their username. If your system allows usernames that ENSIP-15 changes, such as ones with uppercase letters, store the normalized form next to them. A username that ENSIP-15 rejects can't resolve through ENS. + +### Endpoint URL rules + +- `https://` only. +- No credentials in the URL (`https://user:pass@…`), no query string, and no fragment. +- At most 2,048 characters. A path such as `/ens/users` is fine. +- It must reach a public address. Requests to private, loopback, link-local, and cloud metadata addresses are blocked, for IPv4 and IPv6. +- Namespace doesn't follow redirects, so connect the final URL. + +## Response + +Answer `200` with a JSON body and `Content-Type: application/json`. A parameter such as `; charset=utf-8` is fine. + + + +### Schema v1 + + + +The schema describes a fully valid profile. Namespace doesn't throw away a whole profile for every schema violation: a problem in the envelope fails the profile, and a problem in one record fails only lookups of that record. See [what a lookup returns](#what-a-lookup-returns). + +### Field rules + +- `version` is `1`. +- `username` equals the requested username exactly, after decoding. Any other value fails the whole profile. +- `texts` and `addresses` are required objects. Send `{}` when there's nothing to return. +- Leave out records you don't have. Never send `null`. +- `addresses` keys are decimal ENS coin types, such as `"60"` for Ethereum, not chain IDs. +- `contenthash` is optional and takes an `ipfs://`, `ipns://`, `bzz://`, or `ar://` URI. +- No other top-level fields, and no duplicate keys at any level. +- Text values are plain text. Apps must never render them as HTML, so don't put markup in them. + +[Records](/developer-guide/universal-usernames/records) lists the text keys, coin types, and contenthash formats. + +## Status codes + +| Status | Namespace reads it as | The wallet gets | +| --- | --- | --- | +| `200` | The profile | The requested record, or an empty value when the profile doesn't have it | +| `404` | The username doesn't exist | An empty value for every record | +| `401`, `403` | `UNAUTHORIZED` | An error | +| `429` | `RATE_LIMITED` | An error | +| Any `3xx` | `HTTP_REDIRECT`, never followed | An error | +| Any other status | `HTTP_ERROR_STATUS` | An error | + +Only `404` means "no such user". Return it for missing users and for nothing else. If your database is down, answer `500` so wallets see an error instead of an empty profile. + +## What a lookup returns + +| Situation | Result | +| --- | --- | +| The profile has the requested record | The signed record | +| The profile is valid but doesn't have that record | A signed empty value: the zero address for `addr(node)`, `0x` for `addr(node, coinType)`, `""` for a text record, empty bytes for the contenthash | +| Your endpoint returns `404` | A signed empty value | +| The requested record is malformed | An error for that lookup only. Other records still resolve. | +| The envelope is invalid: bad JSON, a duplicate key, the wrong version, an unknown top-level field, a `username` mismatch, or too many records | An error for every lookup on that username | +| Timeout, `401`, `403`, `429`, `5xx`, a redirect, the wrong content type, or a response that's too large | An error. Never treated as "no such user". | +| The connection is paused or suspended | An error with no signature | +| A deeper name such as `team.happy.brand.eth` | A signed empty value. Your endpoint isn't called. | +| A name that has an onchain owner, such as an onchain subname `happy.brand.eth` | HybridResolverV2 answers from its own onchain records, even empty ones. Your endpoint isn't called. | + +Once `brand.eth` is connected, Namespace never falls back to stored offchain subnames for it, even when your endpoint is down. + +## Limits + +| Limit | Value | +| --- | --- | +| Time per request | 5 seconds in total, including the connection | +| Response size | 256 KiB (262,144 bytes) after decompression | +| Text records | 128 | +| Address records | 128 | +| Text key | 1 to 256 characters | +| Text value | Up to 4,096 characters | +| Address value | 1 to 1,024 characters | +| Contenthash | Up to 4,096 characters | +| Requests to your endpoint | 10 per second per parent name, bursts of 20, at most 10 at a time. Lookups over the limit fail with an error. | + +## Caching and proof expiry + +- The gateway keeps each `200` and `404` answer for 60 seconds from when it fetched it. The window is fixed and doesn't slide when the answer is read again. +- Failures aren't cached, and nothing stale is served after an outage. +- Lookups that arrive together for the same username share one request, so a wallet reading five records of one profile costs your endpoint one call. +- A signed answer expires no later than the cached profile it came from, so a proof lives at most 60 seconds. When less than 10 seconds of the window remain, the gateway fetches a fresh profile before it signs. +- Saving a new endpoint or rotating the credential starts a new cache. + +A change in your system reaches Namespace within 60 seconds. Proofs already issued stay valid until they expire. Wallets, apps, and indexers can keep resolved values for longer than that, and Namespace has no control over their caches. + +## Records are public + +Anyone who resolves `happy.brand.eth` can read everything your endpoint returns for `happy`. That stays true when you require a bearer token: the token keeps other callers off your endpoint, but the records it serves are public through ENS. Return only what you would show on a public profile page. + +## Bearer authentication + +Bearer authentication is optional. When you set it up, Namespace sends `Authorization: Bearer ` on every request: + +- Compare the token in constant time and answer `401` when it doesn't match. +- Namespace stores the credential encrypted and never shows it again after you save it. +- When you rotate the credential, Namespace validates the new one before it stops sending the old one. Accept both tokens until the rotation finishes. + +## Related pages + +- [Connect universal usernames](/developer-guide/guide/connect-universal-usernames) +- [Records](/developer-guide/universal-usernames/records) +- [Troubleshooting](/developer-guide/universal-usernames/troubleshooting) +- [Universal Usernames SDK](/developer-guide/sdks/universal-usernames) diff --git a/developer-guide/universal-usernames/records.mdx b/developer-guide/universal-usernames/records.mdx new file mode 100644 index 0000000..9721bde --- /dev/null +++ b/developer-guide/universal-usernames/records.mdx @@ -0,0 +1,128 @@ +--- +title: "Records" +description: "Text keys, coin types, and contenthash formats for profiles." +sidebarTitle: "Records" +icon: "list-check" +--- + +A profile carries three kinds of ENS records: text records in `texts`, addresses in `addresses`, and an optional `contenthash`. This page covers the format of each and what Namespace rejects. The shape of the whole response is in the [endpoint reference](/developer-guide/universal-usernames/endpoint-reference#schema-v1). + +## Text records + +`texts` maps a key to a string value. Keys are open: use the standard keys wallets already read, plus any custom keys you need. + +| Key | Holds | +| --- | --- | +| `name` | Display name | +| `description` | Short bio | +| `avatar` | Avatar image: an `https://` or `ipfs://` URL, or an NFT reference as described in [ENSIP-12](https://docs.ens.domains/ensip/12) | +| `header` | Banner image, in the same formats as `avatar` | +| `url` | Website | +| `location` | Free-text location | +| `email` | Email address | +| `com.github` | GitHub username | +| `com.twitter` | X username | + +Prefix custom keys with a domain you control, such as `com.brand.role`, so they don't collide with keys other apps define. + +Values are plain text. Apps must never render them as HTML, and you shouldn't put markup in them. + +| Rule | Issue code when broken | +| --- | --- | +| A key is 1 to 256 characters | `TEXT_KEY_INVALID` | +| A value is a string | `TEXT_VALUE_INVALID` | +| A value is at most 4,096 characters | `TEXT_VALUE_TOO_LONG` | +| At most 128 keys | `TOO_MANY_TEXTS` (fails the whole profile) | + +## Address records + +`addresses` maps an ENS coin type to an address. Coin types come from [ENSIP-9](https://docs.ens.domains/ensip/9) for non-EVM chains and [ENSIP-11](https://docs.ens.domains/ensip/11) for EVM chains, where the coin type is `0x80000000 | chainId`. Ethereum mainnet keeps the older coin type `60`. + + +Coin types aren't chain IDs. Base is `2147492101`, not `8453`, and Polygon is `2147483785`, not `137`. Coin type `137` is Rootstock, so a Polygon address under `"137"` answers lookups for a different chain. + + +| Chain | Coin type | +| --- | --- | +| Bitcoin | `0` | +| Litecoin | `2` | +| Dogecoin | `3` | +| Ethereum | `60` | +| Polkadot | `354` | +| Solana | `501` | +| Vara | `913` | +| Default EVM address | `2147483648` | +| OP Mainnet | `2147483658` | +| BNB Smart Chain | `2147483704` | +| Gnosis | `2147483748` | +| Polygon | `2147483785` | +| zkSync Era | `2147483972` | +| Base | `2147492101` | +| Arbitrum One | `2147525809` | +| Celo | `2147525868` | +| Avalanche C-Chain | `2147526762` | +| Linea | `2147542792` | +| Scroll | `2148018000` | + +Namespace converts addresses to bytes with [`@ensdomains/address-encoder`](https://github.com/ensdomains/address-encoder) 2.0.1, which covers about 150 coins. Any coin it has a coder for works, not only the ones above, and so does every EVM chain: its coin type is `2147483648 + chainId`. + +### Address format + +Send addresses the way users see them: + +- EVM chains: `0x` followed by 40 hex characters, either checksummed or all lowercase. A mixed-case address with a wrong checksum is invalid. +- Bitcoin: a legacy or bech32 address, such as `bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq`. +- Solana: the base58 public key. + +| Rule | Issue code when broken | +| --- | --- | +| The key is a decimal integer from `0` to `4294967295`, with no leading zeros, sign, or hex | `COIN_TYPE_INVALID` | +| Namespace has a coder for the coin type | `COIN_TYPE_UNSUPPORTED` | +| The value is a valid address for that coin, 1 to 1,024 characters | `ADDRESS_INVALID` | +| At most 128 addresses | `TOO_MANY_ADDRESSES` (fails the whole profile) | + +### Default EVM address + +Coin type `2147483648` is the default EVM address from [ENSIP-19](https://docs.ens.domains/ensip/19). Use it when a user has the same address on every EVM chain. Namespace follows ENSIP-19 exactly: + +- An explicit chain record wins. If the profile has `2147492101`, Base lookups return that address. +- An EVM chain with no record of its own returns the default address. That includes Ethereum: ENSIP-19 defines `chainFromCoinType(60)` as chain `1`, so a profile without `60` answers Ethereum lookups with the default address. +- An invalid explicit record fails the lookup. It doesn't fall back to the default. +- Non-EVM coins, such as Bitcoin and Solana, never use the default. + +```json Default address with a Base override +{ + "version": 1, + "username": "happy", + "texts": {}, + "addresses": { + "2147483648": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222", + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + } +} +``` + +With this profile, Ethereum, OP Mainnet, and Arbitrum lookups return `0x1111…1111`, Base returns `0x2222…2222`, Bitcoin returns the bech32 address, and Solana returns an empty value. + +## Contenthash + +`contenthash` takes a URI and is encoded to [ENSIP-7](https://docs.ens.domains/ensip/7) bytes with the `content-hash` library. + +| Protocol | Example | +| --- | --- | +| IPFS | `ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA` or `ipfs://bafy…` | +| IPNS | `ipns://k51qzi5uqu5dlvj2baxnqndepeb86cbk3ng7n3i46uzyxzyqj2xjonzllnv0v8` | +| Swarm | `bzz://d1de9994b4d039f6548d191eb26786769f580809256b4685ef316805265ea162` | +| Arweave | `ar://ys32Pt8uC7TrVxHdOLByOspfPEq2LO63wREHQIM9SJQ` | + +Namespace never guesses the protocol. A bare CID without `ipfs://` or an `https://` URL is rejected with `CONTENTHASH_UNSUPPORTED`, and a known protocol with a malformed value is rejected with `CONTENTHASH_INVALID`. + +## Missing records + +When a profile doesn't have the record a wallet asks for, the wallet gets a signed empty value: the zero address for `addr(node)`, `0x` for `addr(node, coinType)`, `""` for a text record, and empty bytes for the contenthash. Leave missing records out of the response. `null` isn't an empty value; it's an invalid record. + +## Related pages + +- [Endpoint reference](/developer-guide/universal-usernames/endpoint-reference) +- [Troubleshooting](/developer-guide/universal-usernames/troubleshooting) diff --git a/developer-guide/universal-usernames/troubleshooting.mdx b/developer-guide/universal-usernames/troubleshooting.mdx new file mode 100644 index 0000000..37ab59b --- /dev/null +++ b/developer-guide/universal-usernames/troubleshooting.mdx @@ -0,0 +1,184 @@ +--- +title: "Troubleshooting universal usernames" +description: "Fix validation issues and connection errors." +sidebarTitle: "Troubleshooting" +icon: "screwdriver-wrench" +--- + +Validation reports, the validator CLI, and the Namespace App describe each problem with a stable code, a kind, and a `path` into your response, such as `/addresses/60`. Find the code below. + +Each issue has one of three kinds: + +- `envelope` issues fail the whole profile. Every lookup for that username returns an error. +- `record` issues fail one record. Lookups for that record return an error, and the other records still resolve. +- `transport` issues come from the request itself, before Namespace can read a profile. + +Validation reports every issue it finds, so fix them all before you save. + +## Envelope issues + +### `INVALID_JSON` + +The body isn't valid JSON. Check for trailing commas, single quotes, or an HTML error page served with a `200`. Serialize the profile with your platform's JSON encoder instead of building the string by hand. + +### `DUPLICATE_KEY` + +The same key appears twice in one object, at any depth, such as two `"60"` entries in `addresses`. Most JSON parsers keep the last one silently, which is why Namespace rejects it. Build the response from a map or dictionary so each key appears once. + +### `NOT_AN_OBJECT` + +The body is valid JSON but not an object, for example an array or a string. Return one profile object per request. + +### `UNSUPPORTED_VERSION` + +`version` is missing or isn't `1`. Send `"version": 1` as a number, not the string `"1"`. + +### `UNKNOWN_FIELD` + +The profile has a top-level field that schema v1 doesn't define, such as `name` or `avatar` at the root. Move text records into `texts` and remove anything else. Only `version`, `username`, `texts`, `addresses`, and `contenthash` are allowed. + +### `USERNAME_MISSING` + +`username` is missing or empty. Add it to every `200` response. + +### `USERNAME_MISMATCH` + +`username` doesn't equal the requested username exactly. Echo the decoded path segment you received instead of the display name your database stores, so a request for `café` answers `"username": "café"`. + +### `TEXTS_NOT_OBJECT` + +`texts` is missing or isn't an object. Send `"texts": {}` when there are no text records. + +### `ADDRESSES_NOT_OBJECT` + +`addresses` is missing or isn't an object, for example an array of `{ chain, value }` pairs. Send an object keyed by coin type, or `"addresses": {}` when there are none. + +### `TOO_MANY_TEXTS` + +`texts` has more than 128 keys. Return only the records wallets need. + +### `TOO_MANY_ADDRESSES` + +`addresses` has more than 128 entries. Use the [default EVM address](/developer-guide/universal-usernames/records#default-evm-address) instead of repeating one address for every EVM chain. + +## Record issues + +### `TEXT_KEY_INVALID` + +A text key is empty or longer than 256 characters. Shorten it or remove the record. + +### `TEXT_VALUE_INVALID` + +A text value isn't a string, for example `null`, a number, or an object. Convert it to a string, or leave the record out when there's no value. + +### `TEXT_VALUE_TOO_LONG` + +A text value is longer than 4,096 characters. Truncate it, or host long content elsewhere and link to it with `url`. + +### `COIN_TYPE_INVALID` + +An `addresses` key isn't a canonical decimal coin type between `0` and `4294967295`. Keys such as `"ETH"`, `"0x3c"`, `"060"`, or `"-1"` fail. Use the decimal number, such as `"60"`. [Records](/developer-guide/universal-usernames/records#address-records) lists common coin types. + +### `COIN_TYPE_UNSUPPORTED` + +The coin type is well formed, but Namespace has no address coder for it, so it can't encode the address. This usually means a chain ID was used instead of a coin type: Base is `2147492101`, not `8453`. Use the coin type, or remove coins Namespace can't encode. + +### `ADDRESS_INVALID` + +The value isn't a valid address for that coin type: an Ethereum address with a wrong checksum, a Bitcoin address under coin type `60`, or an empty string. Send checksummed or all-lowercase EVM addresses, and check each address matches its coin type. + +### `CONTENTHASH_INVALID` + +The contenthash uses a supported protocol, but the value after it is malformed, such as `ipfs://` followed by something that isn't a CID. Check the hash or CID. + +### `CONTENTHASH_UNSUPPORTED` + +The contenthash doesn't start with `ipfs://`, `ipns://`, `bzz://`, or `ar://`. A bare CID or an `https://` URL fails because Namespace never guesses the protocol. Add the protocol, or leave `contenthash` out. + +## Transport issues + +### `INVALID_URL` + +The endpoint URL breaks the [URL rules](/developer-guide/universal-usernames/endpoint-reference#endpoint-url-rules). Use an `https://` URL with no credentials, query string, or fragment, at most 2,048 characters long. + +### `DESTINATION_BLOCKED` + +The endpoint host resolves to an address Namespace doesn't call, such as a private, loopback, link-local, or cloud metadata address. Serve the endpoint from a public address. A local or VPN-only service can't be connected. + +### `HTTP_TIMEOUT` + +No complete response arrived within 5 seconds. Look up the user by an indexed key, avoid calls to slow services on the request path, and check for cold starts on serverless hosts. + +### `NETWORK_ERROR` + +Namespace couldn't connect: DNS failed, the connection was refused or reset, or TLS failed. Check the hostname, that the server is running, and that its certificate is valid and trusted. + +### `HTTP_REDIRECT` + +The endpoint answered with a `3xx` redirect. Namespace never follows redirects. Connect the final URL, including the right host, `https://`, and trailing path. + +### `UNAUTHORIZED` + +The endpoint answered `401` or `403`. Check that the bearer credential saved in Namespace matches the token your endpoint expects, and that your endpoint accepts `Authorization: Bearer `. If you rotated the token, accept both until the rotation finishes. + +### `RATE_LIMITED` + +The endpoint answered `429`. Allow at least 10 requests per second, with bursts of 20, from Namespace. + +### `HTTP_ERROR_STATUS` + +The endpoint answered a status other than `200`, `404`, `401`, `403`, `429`, or a redirect, usually a `5xx`. Check your server logs for the request. Only answer `404` for usernames that don't exist. + +### `USERNAME_NOT_FOUND` + +The endpoint answered `404` for the sample username during setup. At lookup time a `404` is a normal empty result, but validation needs a profile to check. Enter a username that exists in your system. + +### `CONTENT_TYPE_INVALID` + +The response `Content-Type` isn't `application/json`. Set it explicitly. Many frameworks default to `text/plain` or `text/html` when you write a string. + +### `RESPONSE_TOO_LARGE` + +The response is larger than 256 KiB after decompression. Remove large values, such as images embedded as data URIs, and link to them instead. + +## Connection errors + +The owner API returns `{ "code": "…", "message": "…" }` when a request fails. `PRECONDITION_FAILED` also lists each failure in `details`. + +| Code | Status | What to do | +| --- | --- | --- | +| `UNSUPPORTED_PARENT` | 400 | Use a `.eth` name directly below `.eth`, such as `brand.eth`. | +| `INVALID_SESSION` | 401 | Sign in again with the wallet that owns the parent name. API keys don't work here. | +| `INVALID_OPERATION_SIGNATURE` | 401 | Sign a new message for this change with a fresh nonce. Check the action, parent, expected revision, chain, and expiry. | +| `NOT_PARENT_OWNER` | 403 | Sign with the current owner of the parent: the NameWrapper owner for wrapped names, or the ENS registry owner (manager) for unwrapped ones. Approved operators can't connect a name. | +| `CONNECTION_NOT_FOUND` | 404 | The parent has never had a connection. Save one with `expectedRevision: 0`. | +| `REVISION_CONFLICT` | 409 | Someone changed the connection since you read it. Reload it and send the new `revision` as `expectedRevision`. | +| `INVALID_STATE` | 409 | The change isn't possible in the current status, for example resuming a connection that isn't paused, or activating after a credential rotation, which already promoted the new credential. Reload the connection and check `status`. | +| `STORED_SUBNAMES_NOT_ACKNOWLEDGED` | 409 | The parent has stored offchain subnames. They stop resolving while it's connected. Save again with `acknowledgeStoredSubnames: true` if that's what you want. | +| `ENDPOINT_BACKED_PARENT` | 409 | Stored offchain subnames can't be created, changed, or deleted while the parent is connected. Change usernames in your own system instead. | +| `PRECONDITION_FAILED` | 412 | Activation checks failed. Fix each entry in `details`, such as switching the resolver to HybridResolverV2 or removing an onchain owner from the sample name. | +| `VALIDATION_FAILED` | 422 | The request body is invalid. Check the fields against the [API reference](/api-reference/offchain-manager/universal-usernames/save-connection). | +| `RATE_LIMITED` | 429 | Wait a moment before validating or previewing again. | +| `UPSTREAM_UNAVAILABLE` | 503 | An Ethereum RPC or the Namespace gateway didn't answer while Namespace checked the owner, inspected the parent, or validated the endpoint. Nothing changed. Retry the same request after a short wait. | + +## Lookups that surprise people + +### A username resolves to old onchain records + +`happy.brand.eth` has an onchain owner, for example from an earlier onchain subname. HybridResolverV2 answers names with an onchain owner from its own records, even empty ones, so your endpoint is never called for that name. Setup reports this as an onchain conflict. Remove or transfer the onchain subname if you want your endpoint to answer. + +### `brand.eth` lost its own records after the resolver switch + +HybridResolverV2 has no fallback to your previous resolver, so records on the parent name itself have to be copied to it before the switch. The Namespace App copies them for you. If you switched the resolver yourself, set the parent's records again on HybridResolverV2. + +### `team.happy.brand.eth` resolves empty + +Each username is one label under the parent. Deeper names are out of scope, resolve empty, and never reach your endpoint. + +### A change in my system doesn't show up in a wallet + +Namespace serves the change within 60 seconds. Proofs already issued last at most 60 seconds, and wallets and apps can cache answers for longer. Reload the wallet, or check with a fresh client such as viem. + +### Every lookup fails after I paused the connection + +That's what pausing does: lookups return an error with no signature until you resume. They don't fall back to stored subnames. diff --git a/docs.json b/docs.json index b50817c..6a7bd39 100644 --- a/docs.json +++ b/docs.json @@ -103,10 +103,20 @@ "group": "How to guides", "pages": [ "developer-guide/guide/create-offchain-subnames", + "developer-guide/guide/connect-universal-usernames", "developer-guide/guide/mint-l1-l2-subnames", "developer-guide/guide/subpages" ] }, + { + "group": "Universal usernames", + "pages": [ + "developer-guide/universal-usernames/endpoint-reference", + "developer-guide/universal-usernames/records", + "developer-guide/universal-usernames/adapter-template", + "developer-guide/universal-usernames/troubleshooting" + ] + }, { "group": "Starter kits", "pages": [ @@ -140,7 +150,8 @@ "developer-guide/sdks/offchain-manager/chainname", "developer-guide/sdks/offchain-manager/set-default-evm-address", "developer-guide/sdks/offchain-manager/text-records", - "developer-guide/sdks/offchain-manager/data-records" + "developer-guide/sdks/offchain-manager/data-records", + "developer-guide/sdks/offchain-manager/universal-usernames" ] }, { @@ -167,6 +178,13 @@ "developer-guide/sdks/indexer-manager/fetch-l2-registries" ] }, + { + "group": "Universal Usernames", + "icon": "globe", + "pages": [ + "developer-guide/sdks/universal-usernames" + ] + }, { "group": "Avatar", "icon": "image", @@ -200,7 +218,25 @@ "api-reference/offchain-manager/create-or-update-a-subname", "api-reference/offchain-manager/get-subname-by-full-name", "api-reference/offchain-manager/query-subnames", - "api-reference/offchain-manager/delete-a-subname" + "api-reference/offchain-manager/delete-a-subname", + { + "group": "Universal usernames", + "icon": "globe", + "pages": [ + "api-reference/offchain-manager/universal-usernames/get-connection", + "api-reference/offchain-manager/universal-usernames/inspect-parent", + "api-reference/offchain-manager/universal-usernames/validate-endpoint", + "api-reference/offchain-manager/universal-usernames/save-connection", + "api-reference/offchain-manager/universal-usernames/activate-connection", + "api-reference/offchain-manager/universal-usernames/preview-username", + "api-reference/offchain-manager/universal-usernames/rotate-credential", + "api-reference/offchain-manager/universal-usernames/pause-connection", + "api-reference/offchain-manager/universal-usernames/resume-connection", + "api-reference/offchain-manager/universal-usernames/disconnect", + "api-reference/offchain-manager/universal-usernames/list-events", + "api-reference/offchain-manager/universal-usernames/get-nonce" + ] + } ] }, { @@ -243,6 +279,7 @@ "tab": "Changelog", "pages": [ "changelog/offchain-sdk", + "changelog/universal-usernames-sdk", "changelog/address-sdk", "changelog/indexer-sdk", "changelog/mint-sdk", diff --git a/examples/tsconfig.json b/examples/tsconfig.json index c779ff9..1b9c944 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -9,5 +9,6 @@ "esModuleInterop": true, "types": ["node"] }, - "include": ["./*.ts"] + "include": ["./*.ts"], + "exclude": ["universal-usernames-adapter"] } diff --git a/examples/universal-usernames-adapter/.env.example b/examples/universal-usernames-adapter/.env.example new file mode 100644 index 0000000..d693cb0 --- /dev/null +++ b/examples/universal-usernames-adapter/.env.example @@ -0,0 +1,6 @@ +# Port the endpoint listens on. +PORT=8787 + +# Optional. When set, every request must send `Authorization: Bearer `. +# Use a long random value and enter the same value as the bearer credential in the Namespace App. +ENDPOINT_TOKEN= diff --git a/examples/universal-usernames-adapter/.gitignore b/examples/universal-usernames-adapter/.gitignore new file mode 100644 index 0000000..c744b43 --- /dev/null +++ b/examples/universal-usernames-adapter/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example diff --git a/examples/universal-usernames-adapter/README.md b/examples/universal-usernames-adapter/README.md new file mode 100644 index 0000000..b8ee714 --- /dev/null +++ b/examples/universal-usernames-adapter/README.md @@ -0,0 +1,115 @@ +# Universal usernames adapter template + +A small endpoint that serves your users' profiles to Namespace, so each username in your system resolves as `username.brand.eth` through ENS. It uses [Hono](https://hono.dev) and TypeScript and runs on Node.js. + +Namespace calls `GET {your endpoint URL}/{username}`. The endpoint answers `200` with a profile JSON, or `404` when the username doesn't exist. The full contract is in the [endpoint reference](https://docs.namespace.ninja/developer-guide/universal-usernames/endpoint-reference). + +## Requirements + +- Node.js 22.12 or later +- npm + +## Run it locally + +```bash +npm install +npm run dev +``` + +The endpoint listens on `http://localhost:8787`. Try the two sample users and a missing one: + +```bash +curl -i http://localhost:8787/happy +curl -i http://localhost:8787/caf%C3%A9 +curl -i http://localhost:8787/nobody # 404 +``` + +`happy` returns: + +```json +{ + "version": 1, + "username": "happy", + "texts": { + "name": "Happy", + "com.brand.role": "member", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" +} +``` + +## Run the tests + +```bash +npm test +npm run typecheck +``` + +The tests check a `200` response against the profile schema in `schema/profile-v1.schema.json`, the `404` for a missing username and unknown paths, the `500` when your user store fails, and the bearer token check. + +## Connect your user store + +Replace `lookupUser` in `src/users.ts` with a query against your database or user service. Keep these rules: + +- Look users up by their [ENSIP-15](https://docs.ens.domains/ensip/15) normalized username. Namespace only sends normalized labels, for example `happy`, never `Happy`. +- Return `null` when the user doesn't exist. The app turns that into a `404`, and wallets see an empty result. +- Throw when your store fails. The app returns a `500`, and wallets see an error instead of a wrong "no such user". +- Echo the requested username in `username`. Any other value fails the whole profile. +- Key addresses by decimal ENS coin type, such as `60` for Ethereum and `0` for Bitcoin, not by chain ID. Leave missing records out; never send `null`. + +Everything the endpoint returns is public once it resolves through ENS, even with a bearer token. + +## Require a bearer token + +Copy `.env.example` to `.env` and set `ENDPOINT_TOKEN` to a long random value: + +```bash +cp .env.example .env +openssl rand -hex 32 +``` + +With `ENDPOINT_TOKEN` set, every request must send `Authorization: Bearer `, or the endpoint answers `401`. Enter the same value as the bearer credential when you connect the endpoint in the Namespace App. The token only keeps other callers off your endpoint. It doesn't make the records private. + +## Deploy + +```bash +npm run build +npm start +``` + +Run it behind HTTPS on any Node.js host. Namespace only calls `https://` URLs and doesn't follow redirects, so connect the final URL. The URL you connect is the base: with `https://usernames.brand.example`, Namespace requests `https://usernames.brand.example/happy`. + +To serve the endpoint under a path such as `/ens/users`, mount the app there in `src/server.ts` and serve `root` instead: + +```ts +import { Hono } from 'hono'; + +const root = new Hono(); +root.route('/ens/users', createApp({ lookupUser, endpointToken })); + +serve({ fetch: root.fetch, port }); +``` + +Then connect `https://api.brand.example/ens/users`. + +## Validate before you connect + +Check the deployed endpoint with the validator CLI from `@thenamespace/universal-usernames`. It makes the same request Namespace makes and prints every problem it finds, with the JSON path of each one: + +```bash +npx @thenamespace/universal-usernames validate https://usernames.brand.example --username happy +npx @thenamespace/universal-usernames validate https://usernames.brand.example --username happy --token "$ENDPOINT_TOKEN" +``` + +It exits with `0` when the profile is valid and `1` otherwise. The CLI is available once `@thenamespace/universal-usernames` 0.1.0 is published on npm. + +## Connect it to your .eth name + +In the [Namespace App](https://app.namespace.ninja/offchain), connect your `.eth` name to the endpoint URL with the wallet that owns the name. The [integration guide](https://docs.namespace.ninja/developer-guide/guide/connect-universal-usernames) walks through each step. diff --git a/examples/universal-usernames-adapter/package-lock.json b/examples/universal-usernames-adapter/package-lock.json new file mode 100644 index 0000000..c4770f7 --- /dev/null +++ b/examples/universal-usernames-adapter/package-lock.json @@ -0,0 +1,1743 @@ +{ + "name": "universal-usernames-adapter", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "universal-usernames-adapter", + "version": "1.0.0", + "dependencies": { + "@hono/node-server": "2.1.5", + "hono": "4.13.8" + }, + "devDependencies": { + "@types/node": "22.20.1", + "ajv": "8.20.0", + "tsx": "4.23.15", + "typescript": "6.0.3", + "vitest": "3.2.7" + }, + "engines": { + "node": ">=22.12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/examples/universal-usernames-adapter/package.json b/examples/universal-usernames-adapter/package.json new file mode 100644 index 0000000..d647d86 --- /dev/null +++ b/examples/universal-usernames-adapter/package.json @@ -0,0 +1,28 @@ +{ + "name": "universal-usernames-adapter", + "version": "1.0.0", + "private": true, + "description": "Template brand endpoint for Namespace universal usernames: serves one profile JSON per username.", + "type": "module", + "engines": { + "node": ">=22.12" + }, + "scripts": { + "dev": "tsx watch --env-file-if-exists=.env src/server.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node --env-file-if-exists=.env dist/server.js", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@hono/node-server": "2.1.5", + "hono": "4.13.8" + }, + "devDependencies": { + "@types/node": "22.20.1", + "ajv": "8.20.0", + "tsx": "4.23.15", + "typescript": "6.0.3", + "vitest": "3.2.7" + } +} diff --git a/examples/universal-usernames-adapter/schema/profile-v1.schema.json b/examples/universal-usernames-adapter/schema/profile-v1.schema.json new file mode 100644 index 0000000..f75237a --- /dev/null +++ b/examples/universal-usernames-adapter/schema/profile-v1.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Universal Usernames profile v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "username", "texts", "addresses"], + "properties": { + "version": { "const": 1 }, + "username": { "type": "string", "minLength": 1 }, + "texts": { + "type": "object", + "maxProperties": 128, + "propertyNames": { "minLength": 1, "maxLength": 256 }, + "additionalProperties": { "type": "string", "maxLength": 4096 } + }, + "addresses": { + "type": "object", + "maxProperties": 128, + "propertyNames": { "pattern": "^(0|[1-9][0-9]*)$" }, + "additionalProperties": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "contenthash": { "type": "string", "minLength": 1, "maxLength": 4096 } + } +} diff --git a/examples/universal-usernames-adapter/src/app.ts b/examples/universal-usernames-adapter/src/app.ts new file mode 100644 index 0000000..c1f1eaf --- /dev/null +++ b/examples/universal-usernames-adapter/src/app.ts @@ -0,0 +1,49 @@ +import { Hono } from 'hono'; +import { isAuthorizedBearer } from './bearer-token.js'; +import type { UsernameProfileV1 } from './profile.js'; + +/** Returns the profile for a username, or null when the username doesn't exist. */ +export type LookupUser = (username: string) => Promise; + +export interface AppOptions { + lookupUser: LookupUser; + /** When set, every request must send `Authorization: Bearer `. */ + endpointToken?: string; +} + +/** + * Namespace calls GET {your endpoint URL}/{username}, where username is one + * ENSIP-15 normalized label. Answer 200 with the profile, or 404 when the + * username doesn't exist. Any other status is treated as an outage, never as + * "no such user". + */ +export function createApp({ lookupUser, endpointToken }: AppOptions): Hono { + const app = new Hono(); + + if (endpointToken) { + app.use('*', async (c, next) => { + if (!isAuthorizedBearer(c.req.header('Authorization'), endpointToken)) { + return c.json({ error: 'unauthorized' }, 401); + } + await next(); + }); + } + + app.get('/:username', async (c) => { + // Hono decodes the percent-encoded label, so "caf%C3%A9" arrives as "café". + const username = c.req.param('username'); + const profile = await lookupUser(username); + if (!profile) return c.json({ error: 'not_found' }, 404); + return c.json(profile); + }); + + app.notFound((c) => c.json({ error: 'not_found' }, 404)); + + // A failing user store must not look like a missing user, so it gets a 500. + app.onError((error, c) => { + console.error('Username lookup failed', error); + return c.json({ error: 'internal_error' }, 500); + }); + + return app; +} diff --git a/examples/universal-usernames-adapter/src/bearer-token.ts b/examples/universal-usernames-adapter/src/bearer-token.ts new file mode 100644 index 0000000..6b5e3f3 --- /dev/null +++ b/examples/universal-usernames-adapter/src/bearer-token.ts @@ -0,0 +1,17 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +const BEARER = /^Bearer\s+(.+)$/i; + +/** Checks an Authorization header against the expected bearer token in constant time. */ +export function isAuthorizedBearer(header: string | undefined, expectedToken: string): boolean { + const presented = header?.match(BEARER)?.[1]; + if (!presented) return false; + + // Hashing both sides gives timingSafeEqual equal-length inputs, so the + // comparison time reveals neither the token nor its length. + return timingSafeEqual(sha256(presented), sha256(expectedToken)); +} + +function sha256(value: string): Buffer { + return createHash('sha256').update(value).digest(); +} diff --git a/examples/universal-usernames-adapter/src/profile.ts b/examples/universal-usernames-adapter/src/profile.ts new file mode 100644 index 0000000..e638e55 --- /dev/null +++ b/examples/universal-usernames-adapter/src/profile.ts @@ -0,0 +1,15 @@ +/** + * Universal Usernames profile, schema version 1. + * The JSON schema is in schema/profile-v1.schema.json. + */ +export interface UsernameProfileV1 { + version: 1; + /** The requested username, exactly as Namespace sent it. */ + username: string; + /** ENS text records. Leave missing records out; never send null. */ + texts: Record; + /** Addresses keyed by decimal ENS coin type, such as "60" for Ethereum. */ + addresses: Record; + /** ipfs://, ipns://, bzz:// or ar:// URI. Omit when there is none. */ + contenthash?: string; +} diff --git a/examples/universal-usernames-adapter/src/server.ts b/examples/universal-usernames-adapter/src/server.ts new file mode 100644 index 0000000..e89aee2 --- /dev/null +++ b/examples/universal-usernames-adapter/src/server.ts @@ -0,0 +1,16 @@ +import { serve } from '@hono/node-server'; +import { createApp } from './app.js'; +import { lookupUser } from './users.js'; + +const port = Number(process.env.PORT ?? 8787); +if (!Number.isInteger(port) || port <= 0) { + throw new Error(`PORT must be a positive integer, got "${process.env.PORT}"`); +} + +const endpointToken = process.env.ENDPOINT_TOKEN || undefined; +const app = createApp({ lookupUser, endpointToken }); + +serve({ fetch: app.fetch, port }, (info) => { + const auth = endpointToken ? 'bearer token required' : 'no bearer token'; + console.log(`Universal usernames endpoint on http://localhost:${info.port} (${auth})`); +}); diff --git a/examples/universal-usernames-adapter/src/users.ts b/examples/universal-usernames-adapter/src/users.ts new file mode 100644 index 0000000..2197c4d --- /dev/null +++ b/examples/universal-usernames-adapter/src/users.ts @@ -0,0 +1,74 @@ +import type { UsernameProfileV1 } from './profile.js'; + +/** + * Stand-in for your user store. Replace lookupUser with a query against your + * database or user service. Key users by their ENSIP-15 normalized username, + * which is the form Namespace sends. + */ +interface BrandUser { + displayName: string; + avatarUrl?: string; + github?: string; + role: string; + ethereumAddress?: string; + baseAddress?: string; + bitcoinAddress?: string; + website?: string; + /** IPFS site for the user's page, if they have one. */ + siteContenthash?: string; +} + +const users = new Map([ + [ + 'happy', + { + displayName: 'Happy', + avatarUrl: 'https://cdn.brand.example/happy.png', + github: 'happy', + role: 'member', + ethereumAddress: '0x1111111111111111111111111111111111111111', + baseAddress: '0x2222222222222222222222222222222222222222', + bitcoinAddress: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + siteContenthash: 'ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA', + }, + ], + [ + 'café', + { + displayName: 'Café', + role: 'member', + ethereumAddress: '0x3333333333333333333333333333333333333333', + website: 'https://cafe.brand.example', + }, + ], +]); + +// ENS coin types (ENSIP-9 and ENSIP-11), not chain IDs. +const COIN_TYPE = { + bitcoin: '0', + ethereum: '60', + base: '2147492101', +} as const; + +export async function lookupUser(username: string): Promise { + const user = users.get(username); + if (!user) return null; + return toProfile(username, user); +} + +function toProfile(username: string, user: BrandUser): UsernameProfileV1 { + const texts: Record = { name: user.displayName, 'com.brand.role': user.role }; + if (user.avatarUrl) texts.avatar = user.avatarUrl; + if (user.github) texts['com.github'] = user.github; + if (user.website) texts.url = user.website; + + const addresses: Record = {}; + if (user.ethereumAddress) addresses[COIN_TYPE.ethereum] = user.ethereumAddress; + if (user.baseAddress) addresses[COIN_TYPE.base] = user.baseAddress; + if (user.bitcoinAddress) addresses[COIN_TYPE.bitcoin] = user.bitcoinAddress; + + // Echo the requested username exactly; a different value fails the whole profile. + const profile: UsernameProfileV1 = { version: 1, username, texts, addresses }; + if (user.siteContenthash) profile.contenthash = user.siteContenthash; + return profile; +} diff --git a/examples/universal-usernames-adapter/tests/app.test.ts b/examples/universal-usernames-adapter/tests/app.test.ts new file mode 100644 index 0000000..e5b6636 --- /dev/null +++ b/examples/universal-usernames-adapter/tests/app.test.ts @@ -0,0 +1,112 @@ +import { readFileSync } from 'node:fs'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { describe, expect, it, vi } from 'vitest'; +import { createApp } from '../src/app.js'; +import { lookupUser } from '../src/users.js'; + +const schema = JSON.parse( + readFileSync(new URL('../schema/profile-v1.schema.json', import.meta.url), 'utf8'), +); +const validateProfile = new Ajv2020({ allErrors: true }).compile(schema); + +// The headers Namespace sends on every lookup. +const namespaceHeaders = { + Accept: 'application/json', + 'User-Agent': 'Namespace-UniversalUsernames/1', +}; + +describe('universal usernames endpoint', () => { + const app = createApp({ lookupUser }); + + it('returns a schema v1 profile for an existing username', async () => { + const response = await app.request('/happy', { headers: namespaceHeaders }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toMatch(/^application\/json(;|$)/); + const body = await response.json(); + expect(validateProfile(body), JSON.stringify(validateProfile.errors)).toBe(true); + expect(body).toMatchObject({ + version: 1, + username: 'happy', + addresses: { '60': '0x1111111111111111111111111111111111111111' }, + }); + }); + + it('decodes a percent-encoded username and echoes it unchanged', async () => { + const response = await app.request(`/${encodeURIComponent('café')}`, { + headers: namespaceHeaders, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(validateProfile(body)).toBe(true); + expect(body.username).toBe('café'); + expect(body).not.toHaveProperty('contenthash'); + }); + + it('returns 404 with JSON for a username that does not exist', async () => { + const response = await app.request('/nobody', { headers: namespaceHeaders }); + + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toMatch(/^application\/json/); + }); + + it.each(['/', '/happy/extra', '/happy/'])('returns 404 for the unknown path %s', async (path) => { + const response = await app.request(path, { headers: namespaceHeaders }); + + expect(response.status).toBe(404); + }); + + it('returns 500, not 404, when the user store fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const failing = createApp({ + lookupUser: async () => { + throw new Error('database unavailable'); + }, + }); + + const response = await failing.request('/happy', { headers: namespaceHeaders }); + + expect(response.status).toBe(500); + }); +}); + +describe('universal usernames endpoint with a bearer token', () => { + const endpointToken = 'test-token-0b6f2d9e8c4a'; + const app = createApp({ lookupUser, endpointToken }); + + it('returns the profile when the token matches', async () => { + const response = await app.request('/happy', { + headers: { ...namespaceHeaders, Authorization: `Bearer ${endpointToken}` }, + }); + + expect(response.status).toBe(200); + expect(validateProfile(await response.json())).toBe(true); + }); + + it('returns 401 when the Authorization header is missing', async () => { + const response = await app.request('/happy', { headers: namespaceHeaders }); + + expect(response.status).toBe(401); + }); + + it.each([ + ['a wrong token', 'Bearer wrong-token'], + ['a token prefix', `Bearer ${endpointToken.slice(0, -1)}`], + ['another scheme', `Basic ${endpointToken}`], + ])('returns 401 for %s', async (_case, authorization) => { + const response = await app.request('/happy', { + headers: { ...namespaceHeaders, Authorization: authorization }, + }); + + expect(response.status).toBe(401); + }); + + it('checks the token before revealing whether a username exists', async () => { + const response = await app.request('/nobody', { + headers: { ...namespaceHeaders, Authorization: 'Bearer wrong-token' }, + }); + + expect(response.status).toBe(401); + }); +}); diff --git a/examples/universal-usernames-adapter/tsconfig.build.json b/examples/universal-usernames-adapter/tsconfig.build.json new file mode 100644 index 0000000..507d409 --- /dev/null +++ b/examples/universal-usernames-adapter/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/examples/universal-usernames-adapter/tsconfig.json b/examples/universal-usernames-adapter/tsconfig.json new file mode 100644 index 0000000..50ad540 --- /dev/null +++ b/examples/universal-usernames-adapter/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "tests"] +} diff --git a/examples/universal-usernames-owner-api.ts b/examples/universal-usernames-owner-api.ts new file mode 100644 index 0000000..a75d976 --- /dev/null +++ b/examples/universal-usernames-owner-api.ts @@ -0,0 +1,95 @@ +import 'dotenv/config'; +import { namehash, normalize } from 'viem/ens'; +import { privateKeyToAccount } from 'viem/accounts'; +import { createSiweMessage } from 'viem/siwe'; + +// Pauses a universal usernames connection through the owner API. +// Every change is authorized by one SIWE message signed by the parent's owner. + +const API_URL = 'https://offchain-manager.namespace.ninja'; +const CHAIN_ID = 1; // 11155111 with https://staging.offchain-manager.namespace.ninja +const SIWE_DOMAIN = 'app.namespace.ninja'; + +const ownerKey = process.env.OWNER_PRIVATE_KEY; +if (!ownerKey?.startsWith('0x')) { + throw new Error('Set OWNER_PRIVATE_KEY to the 0x-prefixed key of the wallet that owns the parent.'); +} +const owner = privateKeyToAccount(ownerKey as `0x${string}`); + +type Action = 'save' | 'activate' | 'rotate-credential' | 'pause' | 'resume' | 'disconnect'; + +// Base64 of the UTF-8 text, so Unicode parent names encode correctly. +function base64(text: string): string { + return Buffer.from(text, 'utf8').toString('base64'); +} + +function toAuthToken(message: string, signature: string): string { + return `siwe:${base64(message)}.${base64(signature)}`; +} + +async function signOperation(parentName: string, action: Action, expectedRevision: number) { + const nonceResponse = await fetch(`${API_URL}/api/v1/auth/nonce`); + if (!nonceResponse.ok) throw new Error(`Nonce request failed: ${nonceResponse.status}`); + const nonce = await nonceResponse.text(); + + const issuedAt = new Date(); + const message = createSiweMessage({ + domain: SIWE_DOMAIN, + uri: `https://${SIWE_DOMAIN}`, + address: owner.address, + statement: `Authorize ${action} for Universal Usernames on ${parentName}`, + version: '1', + chainId: CHAIN_ID, + nonce, + issuedAt, + expirationTime: new Date(issuedAt.getTime() + 5 * 60 * 1000), + resources: [`urn:namespace:uun:${namehash(parentName)}:${action}:${expectedRevision}`], + }); + const signature = await owner.signMessage({ message }); + return toAuthToken(message, signature); +} + +async function signSession() { + const nonceResponse = await fetch(`${API_URL}/api/v1/auth/nonce`); + if (!nonceResponse.ok) throw new Error(`Nonce request failed: ${nonceResponse.status}`); + + const issuedAt = new Date(); + const message = createSiweMessage({ + domain: SIWE_DOMAIN, + uri: `https://${SIWE_DOMAIN}`, + address: owner.address, + version: '1', + chainId: CHAIN_ID, + nonce: await nonceResponse.text(), + issuedAt, + expirationTime: new Date(issuedAt.getTime() + 60 * 60 * 1000), + }); + return toAuthToken(message, await owner.signMessage({ message })); +} + +async function main() { + const parentName = normalize('brand.eth'); + const connectionUrl = `${API_URL}/api/v1/universal-usernames/${encodeURIComponent(parentName)}`; + + const current = await fetch(connectionUrl, { headers: { 'x-auth-token': await signSession() } }); + if (!current.ok) throw new Error(`Reading the connection failed: ${current.status} ${await current.text()}`); + const { revision } = (await current.json()) as { revision: number }; + + const paused = await fetch(`${connectionUrl}/pause`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-auth-token': await signOperation(parentName, 'pause', revision), + }, + body: JSON.stringify({ expectedRevision: revision }), + }); + if (!paused.ok) throw new Error(`Pause failed: ${paused.status} ${await paused.text()}`); + + const connection = (await paused.json()) as { status: string; revision: number }; + console.log(`${parentName} is ${connection.status} at revision ${connection.revision}`); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/openapi/offchain-openapi.json b/openapi/offchain-openapi.json index 3c74a44..9f87c4b 100644 --- a/openapi/offchain-openapi.json +++ b/openapi/offchain-openapi.json @@ -215,6 +215,2507 @@ "summary": "Query subnames", "tags": ["Subnames"] } + }, + "/api/v1/universal-usernames/{parentName}": { + "get": { + "description": "Returns the connection for a parent name: status, the active and latest revisions, the last validation report and the owner snapshot. Never returns the bearer credential. Signed in as the current exact owner of the parent.", + "operationId": "UniversalUsernamesController_getConnection", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Get a username connection", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-session": [] + } + ], + "responses": { + "200": { + "description": "The connection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnection" + }, + "examples": { + "example": { + "summary": "Connection", + "value": { + "parentName": "brand.eth", + "parentNode": "0x2b5c7025998f88550ef2ece8bf87935f542b3c1f2b0d6e6d0b2c9a3e4f5a6b7c", + "chain": "mainnet", + "status": "active", + "revision": 3, + "activeRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "latestRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "owner": { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "source": "v1-wrapped", + "checkedAt": "2026-10-30T12:00:00.000Z" + }, + "storedSubnamesAcknowledged": false, + "suspendedReason": null, + "activation": { + "verifiedAt": "2026-10-30T12:03:00.000Z", + "checkedRecords": [ + "addr(60)", + "text(name)", + "text(avatar)", + "contenthash" + ] + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "updatedAt": "2026-10-30T12:03:00.000Z" + } + } + } + } + } + }, + "400": { + "description": "`UNSUPPORTED_PARENT`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UNSUPPORTED_PARENT": { + "summary": "UNSUPPORTED_PARENT", + "value": { + "code": "UNSUPPORTED_PARENT", + "message": "brand.app.eth is not a .eth name directly below .eth" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_SESSION`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_SESSION": { + "summary": "INVALID_SESSION", + "value": { + "code": "INVALID_SESSION", + "message": "The SIWE session is missing, malformed or expired" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "404": { + "description": "`CONNECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "CONNECTION_NOT_FOUND": { + "summary": "CONNECTION_NOT_FOUND", + "value": { + "code": "CONNECTION_NOT_FOUND", + "message": "brand.eth has no username connection" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + }, + "put": { + "description": "Saves a new revision of the endpoint settings and runs validation against `sampleUsername`. The connection becomes `validated` only when the stored report has no issues; otherwise it stays `draft`. On an active connection the new revision is a candidate: the active revision keeps serving until you activate the candidate, and if activation fails the candidate is `rejected` and the active revision stays. Requires an operation signature for action `save`. Use `expectedRevision: 0` when the parent has never had a connection. After a disconnect, send the `revision` of the disconnected connection; the next save starts a new draft.", + "operationId": "UniversalUsernamesController_saveConnection", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Save a username connection", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-operation-signature": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveUsernameConnectionRequest" + }, + "examples": { + "example": { + "summary": "Example", + "value": { + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "credential": "YOUR_ENDPOINT_TOKEN", + "sampleUsername": "happy", + "expectedRevision": 0 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The connection with the new revision", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnection" + }, + "examples": { + "example": { + "summary": "Connection", + "value": { + "parentName": "brand.eth", + "parentNode": "0x2b5c7025998f88550ef2ece8bf87935f542b3c1f2b0d6e6d0b2c9a3e4f5a6b7c", + "chain": "mainnet", + "status": "validated", + "revision": 1, + "activeRevision": null, + "latestRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "validated", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "owner": { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "source": "v1-wrapped", + "checkedAt": "2026-10-30T12:00:00.000Z" + }, + "storedSubnamesAcknowledged": false, + "suspendedReason": null, + "activation": null, + "createdAt": "2026-10-30T11:58:00.000Z", + "updatedAt": "2026-10-30T12:03:00.000Z" + } + } + } + } + } + }, + "400": { + "description": "`UNSUPPORTED_PARENT`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UNSUPPORTED_PARENT": { + "summary": "UNSUPPORTED_PARENT", + "value": { + "code": "UNSUPPORTED_PARENT", + "message": "brand.app.eth is not a .eth name directly below .eth" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_OPERATION_SIGNATURE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_OPERATION_SIGNATURE": { + "summary": "INVALID_OPERATION_SIGNATURE", + "value": { + "code": "INVALID_OPERATION_SIGNATURE", + "message": "The operation signature does not authorize this change" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "409": { + "description": "`REVISION_CONFLICT`, `INVALID_STATE`, `STORED_SUBNAMES_NOT_ACKNOWLEDGED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "REVISION_CONFLICT": { + "summary": "REVISION_CONFLICT", + "value": { + "code": "REVISION_CONFLICT", + "message": "expectedRevision 3 does not match the current revision 4" + } + }, + "INVALID_STATE": { + "summary": "INVALID_STATE", + "value": { + "code": "INVALID_STATE", + "message": "A paused connection cannot be activated" + } + }, + "STORED_SUBNAMES_NOT_ACKNOWLEDGED": { + "summary": "STORED_SUBNAMES_NOT_ACKNOWLEDGED", + "value": { + "code": "STORED_SUBNAMES_NOT_ACKNOWLEDGED", + "message": "brand.eth has 12 stored offchain subnames; set acknowledgeStoredSubnames to continue" + } + } + } + } + } + }, + "422": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "code": "VALIDATION_FAILED", + "message": "The request body is invalid" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + }, + "delete": { + "description": "Disconnects the parent. This is final: the credential is wiped from every revision, redacted revisions and events are kept, and stored offchain subnames resolve again. The resolver and route stay as they are; changing them back is a separate onchain action. Requires an operation signature for action `disconnect`.", + "operationId": "UniversalUsernamesController_disconnect", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + }, + { + "name": "expectedRevision", + "required": true, + "in": "query", + "description": "The current `revision` of the connection.", + "schema": { + "type": "integer", + "minimum": 0, + "example": 4 + } + } + ], + "summary": "Disconnect a parent name", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-operation-signature": [] + } + ], + "responses": { + "200": { + "description": "The disconnected connection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnection" + }, + "examples": { + "example": { + "summary": "Connection", + "value": { + "parentName": "brand.eth", + "parentNode": "0x2b5c7025998f88550ef2ece8bf87935f542b3c1f2b0d6e6d0b2c9a3e4f5a6b7c", + "chain": "mainnet", + "status": "disconnected", + "revision": 5, + "activeRevision": null, + "latestRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "validated", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "owner": { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "source": "v1-wrapped", + "checkedAt": "2026-10-30T12:00:00.000Z" + }, + "storedSubnamesAcknowledged": false, + "suspendedReason": null, + "activation": null, + "createdAt": "2026-10-30T11:58:00.000Z", + "updatedAt": "2026-10-30T12:03:00.000Z" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_OPERATION_SIGNATURE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_OPERATION_SIGNATURE": { + "summary": "INVALID_OPERATION_SIGNATURE", + "value": { + "code": "INVALID_OPERATION_SIGNATURE", + "message": "The operation signature does not authorize this change" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "404": { + "description": "`CONNECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "CONNECTION_NOT_FOUND": { + "summary": "CONNECTION_NOT_FOUND", + "value": { + "code": "CONNECTION_NOT_FOUND", + "message": "brand.eth has no username connection" + } + } + } + } + } + }, + "409": { + "description": "`REVISION_CONFLICT`, `INVALID_STATE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "REVISION_CONFLICT": { + "summary": "REVISION_CONFLICT", + "value": { + "code": "REVISION_CONFLICT", + "message": "expectedRevision 3 does not match the current revision 4" + } + }, + "INVALID_STATE": { + "summary": "INVALID_STATE", + "value": { + "code": "INVALID_STATE", + "message": "A paused connection cannot be activated" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/universal-usernames/{parentName}/inspection": { + "get": { + "description": "Reads the parent onchain: owner and where ownership comes from, resolver, route, whether the resolver can be changed, stored offchain subnames, and anything that blocks a connection. Signed in as the current exact owner of the parent.", + "operationId": "UniversalUsernamesController_inspectParent", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Inspect a parent name", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-session": [] + } + ], + "responses": { + "200": { + "description": "The inspection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParentInspection" + }, + "examples": { + "example": { + "summary": "Ready to connect", + "value": { + "parentName": "brand.eth", + "parentNode": "0x2b5c7025998f88550ef2ece8bf87935f542b3c1f2b0d6e6d0b2c9a3e4f5a6b7c", + "supported": true, + "owner": { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "source": "v1-wrapped", + "expiresAt": "2027-06-01T00:00:00.000Z" + }, + "resolver": { + "address": "0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63", + "isHybridResolverV2": false + }, + "route": { + "id": null, + "isOffchain": false + }, + "canSetResolver": true, + "storedSubnames": { + "count": 0 + }, + "blockers": [] + } + } + } + } + } + }, + "400": { + "description": "`UNSUPPORTED_PARENT`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UNSUPPORTED_PARENT": { + "summary": "UNSUPPORTED_PARENT", + "value": { + "code": "UNSUPPORTED_PARENT", + "message": "brand.app.eth is not a .eth name directly below .eth" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_SESSION`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_SESSION": { + "summary": "INVALID_SESSION", + "value": { + "code": "INVALID_SESSION", + "message": "The SIWE session is missing, malformed or expired" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/universal-usernames/{parentName}/validate": { + "post": { + "description": "Dry run. Calls the endpoint for one username the same way lookups do and returns every transport, envelope and record issue. Nothing is stored. Returns `200` even when `ok` is `false`. Rate-limited per parent. Signed in as the current exact owner of the parent.", + "operationId": "UniversalUsernamesController_validateEndpoint", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Validate an endpoint", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-session": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateUsernameEndpointRequest" + }, + "examples": { + "example": { + "summary": "Example", + "value": { + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "credential": "YOUR_ENDPOINT_TOKEN", + "username": "happy" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The validation report", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EndpointValidationReport" + }, + "examples": { + "valid": { + "summary": "Valid profile", + "value": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + } + }, + "invalidRecord": { + "summary": "One invalid record", + "value": { + "ok": false, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 131, + "issues": [ + { + "code": "ADDRESS_INVALID", + "kind": "record", + "path": "/addresses/60", + "message": "Address for coin type 60 is not a valid Ethereum address" + } + ], + "preview": { + "texts": { + "name": "Happy" + }, + "addresses": {}, + "contenthash": null + } + } + } + } + } + } + }, + "400": { + "description": "`UNSUPPORTED_PARENT`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UNSUPPORTED_PARENT": { + "summary": "UNSUPPORTED_PARENT", + "value": { + "code": "UNSUPPORTED_PARENT", + "message": "brand.app.eth is not a .eth name directly below .eth" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_SESSION`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_SESSION": { + "summary": "INVALID_SESSION", + "value": { + "code": "INVALID_SESSION", + "message": "The SIWE session is missing, malformed or expired" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "422": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "code": "VALIDATION_FAILED", + "message": "The request body is invalid" + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMITED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "RATE_LIMITED": { + "summary": "RATE_LIMITED", + "value": { + "code": "RATE_LIMITED", + "message": "Too many validation requests for brand.eth; try again shortly" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/universal-usernames/{parentName}/activate": { + "post": { + "description": "Activates a `validated` connection, or promotes the candidate revision that `PUT` saved on an active connection (endpoint replacement). Credential rotation promotes itself, so there is nothing to activate after it. Namespace checks the preconditions, sets the status to `verifying` (the parent starts serving from the endpoint), then resolves the sample username through the ENS Universal Resolver with CCIP-Read. When the live answer matches the validated preview the connection becomes `active`; otherwise it returns to `validated` with `activation.errors`. Requires an operation signature for action `activate`.", + "operationId": "UniversalUsernamesController_activateConnection", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Activate a username connection", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-operation-signature": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpectedRevisionRequest" + }, + "examples": { + "example": { + "summary": "Example", + "value": { + "expectedRevision": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The connection, `active` or back to `validated` with `activation.errors`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnection" + }, + "examples": { + "example": { + "summary": "Connection", + "value": { + "parentName": "brand.eth", + "parentNode": "0x2b5c7025998f88550ef2ece8bf87935f542b3c1f2b0d6e6d0b2c9a3e4f5a6b7c", + "chain": "mainnet", + "status": "active", + "revision": 2, + "activeRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "latestRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "owner": { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "source": "v1-wrapped", + "checkedAt": "2026-10-30T12:00:00.000Z" + }, + "storedSubnamesAcknowledged": false, + "suspendedReason": null, + "activation": { + "verifiedAt": "2026-10-30T12:03:00.000Z", + "checkedRecords": [ + "addr(60)", + "text(name)", + "text(avatar)", + "contenthash" + ] + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "updatedAt": "2026-10-30T12:03:00.000Z" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_OPERATION_SIGNATURE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_OPERATION_SIGNATURE": { + "summary": "INVALID_OPERATION_SIGNATURE", + "value": { + "code": "INVALID_OPERATION_SIGNATURE", + "message": "The operation signature does not authorize this change" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "404": { + "description": "`CONNECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "CONNECTION_NOT_FOUND": { + "summary": "CONNECTION_NOT_FOUND", + "value": { + "code": "CONNECTION_NOT_FOUND", + "message": "brand.eth has no username connection" + } + } + } + } + } + }, + "409": { + "description": "`REVISION_CONFLICT`, `INVALID_STATE`, `STORED_SUBNAMES_NOT_ACKNOWLEDGED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "REVISION_CONFLICT": { + "summary": "REVISION_CONFLICT", + "value": { + "code": "REVISION_CONFLICT", + "message": "expectedRevision 3 does not match the current revision 4" + } + }, + "INVALID_STATE": { + "summary": "INVALID_STATE", + "value": { + "code": "INVALID_STATE", + "message": "A paused connection cannot be activated" + } + }, + "STORED_SUBNAMES_NOT_ACKNOWLEDGED": { + "summary": "STORED_SUBNAMES_NOT_ACKNOWLEDGED", + "value": { + "code": "STORED_SUBNAMES_NOT_ACKNOWLEDGED", + "message": "brand.eth has 12 stored offchain subnames; set acknowledgeStoredSubnames to continue" + } + } + } + } + } + }, + "412": { + "description": "`PRECONDITION_FAILED`: the resolver, route or sample child is not ready. `details` lists each failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "PRECONDITION_FAILED": { + "summary": "PRECONDITION_FAILED", + "value": { + "code": "PRECONDITION_FAILED", + "message": "brand.eth is not ready to activate", + "details": [ + { + "code": "RESOLVER_NOT_HYBRID_RESOLVER_V2", + "message": "brand.eth uses resolver 0x231b…8E63, not HybridResolverV2" + } + ] + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/universal-usernames/{parentName}/preview": { + "post": { + "description": "Fetches the records for any username through the serving revision, or the latest one when nothing is serving yet, and returns them as a validation report. Read-only. Signed in as the current exact owner of the parent.", + "operationId": "UniversalUsernamesController_previewUsername", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Preview a username", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-session": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewUsernameRequest" + }, + "examples": { + "example": { + "summary": "Example", + "value": { + "username": "happy" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The validation report for that username", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EndpointValidationReport" + }, + "examples": { + "example": { + "summary": "Preview", + "value": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + } + } + } + } + } + }, + "401": { + "description": "`INVALID_SESSION`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_SESSION": { + "summary": "INVALID_SESSION", + "value": { + "code": "INVALID_SESSION", + "message": "The SIWE session is missing, malformed or expired" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "404": { + "description": "`CONNECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "CONNECTION_NOT_FOUND": { + "summary": "CONNECTION_NOT_FOUND", + "value": { + "code": "CONNECTION_NOT_FOUND", + "message": "brand.eth has no username connection" + } + } + } + } + } + }, + "422": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "code": "VALIDATION_FAILED", + "message": "The request body is invalid" + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMITED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "RATE_LIMITED": { + "summary": "RATE_LIMITED", + "value": { + "code": "RATE_LIMITED", + "message": "Too many validation requests for brand.eth; try again shortly" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/universal-usernames/{parentName}/credential": { + "post": { + "description": "Replaces the bearer credential. On an active connection Namespace validates a new revision with the new credential and promotes it in the same call when it passes; if it fails, the active revision keeps serving with the old credential. Do not call activate afterwards: there is no candidate left, and it returns `409 INVALID_STATE`. The credential is never returned. Requires an operation signature for action `rotate-credential`.", + "operationId": "UniversalUsernamesController_rotateCredential", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Rotate the endpoint credential", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-operation-signature": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateUsernameCredentialRequest" + }, + "examples": { + "example": { + "summary": "Example", + "value": { + "credential": "YOUR_NEW_ENDPOINT_TOKEN", + "expectedRevision": 4 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The connection after rotation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnection" + }, + "examples": { + "example": { + "summary": "Connection", + "value": { + "parentName": "brand.eth", + "parentNode": "0x2b5c7025998f88550ef2ece8bf87935f542b3c1f2b0d6e6d0b2c9a3e4f5a6b7c", + "chain": "mainnet", + "status": "active", + "revision": 5, + "activeRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "latestRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "owner": { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "source": "v1-wrapped", + "checkedAt": "2026-10-30T12:00:00.000Z" + }, + "storedSubnamesAcknowledged": false, + "suspendedReason": null, + "activation": { + "verifiedAt": "2026-10-30T12:03:00.000Z", + "checkedRecords": [ + "addr(60)", + "text(name)", + "text(avatar)", + "contenthash" + ] + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "updatedAt": "2026-10-30T12:03:00.000Z" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_OPERATION_SIGNATURE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_OPERATION_SIGNATURE": { + "summary": "INVALID_OPERATION_SIGNATURE", + "value": { + "code": "INVALID_OPERATION_SIGNATURE", + "message": "The operation signature does not authorize this change" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "404": { + "description": "`CONNECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "CONNECTION_NOT_FOUND": { + "summary": "CONNECTION_NOT_FOUND", + "value": { + "code": "CONNECTION_NOT_FOUND", + "message": "brand.eth has no username connection" + } + } + } + } + } + }, + "409": { + "description": "`REVISION_CONFLICT`, `INVALID_STATE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "REVISION_CONFLICT": { + "summary": "REVISION_CONFLICT", + "value": { + "code": "REVISION_CONFLICT", + "message": "expectedRevision 3 does not match the current revision 4" + } + }, + "INVALID_STATE": { + "summary": "INVALID_STATE", + "value": { + "code": "INVALID_STATE", + "message": "A paused connection cannot be activated" + } + } + } + } + } + }, + "422": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "code": "VALIDATION_FAILED", + "message": "The request body is invalid" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/universal-usernames/{parentName}/pause": { + "post": { + "description": "Pauses an active connection. While paused, lookups for usernames under the parent return an error and no signature; they never fall back to stored subnames. Requires an operation signature for action `pause`. Namespace can also pause a connection as an abuse measure; that appears in the events as actor `namespace-admin`.", + "operationId": "UniversalUsernamesController_pauseConnection", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Pause a username connection", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-operation-signature": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpectedRevisionRequest" + }, + "examples": { + "example": { + "summary": "Example", + "value": { + "expectedRevision": 4 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The paused connection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnection" + }, + "examples": { + "example": { + "summary": "Connection", + "value": { + "parentName": "brand.eth", + "parentNode": "0x2b5c7025998f88550ef2ece8bf87935f542b3c1f2b0d6e6d0b2c9a3e4f5a6b7c", + "chain": "mainnet", + "status": "paused", + "revision": 5, + "activeRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "latestRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "owner": { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "source": "v1-wrapped", + "checkedAt": "2026-10-30T12:00:00.000Z" + }, + "storedSubnamesAcknowledged": false, + "suspendedReason": null, + "activation": { + "verifiedAt": "2026-10-30T12:03:00.000Z", + "checkedRecords": [ + "addr(60)", + "text(name)", + "text(avatar)", + "contenthash" + ] + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "updatedAt": "2026-10-30T12:03:00.000Z" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_OPERATION_SIGNATURE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_OPERATION_SIGNATURE": { + "summary": "INVALID_OPERATION_SIGNATURE", + "value": { + "code": "INVALID_OPERATION_SIGNATURE", + "message": "The operation signature does not authorize this change" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "404": { + "description": "`CONNECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "CONNECTION_NOT_FOUND": { + "summary": "CONNECTION_NOT_FOUND", + "value": { + "code": "CONNECTION_NOT_FOUND", + "message": "brand.eth has no username connection" + } + } + } + } + } + }, + "409": { + "description": "`REVISION_CONFLICT`, `INVALID_STATE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "REVISION_CONFLICT": { + "summary": "REVISION_CONFLICT", + "value": { + "code": "REVISION_CONFLICT", + "message": "expectedRevision 3 does not match the current revision 4" + } + }, + "INVALID_STATE": { + "summary": "INVALID_STATE", + "value": { + "code": "INVALID_STATE", + "message": "A paused connection cannot be activated" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/universal-usernames/{parentName}/resume": { + "post": { + "description": "Resumes a paused connection after checking the owner and the sample username again. Requires an operation signature for action `resume`.", + "operationId": "UniversalUsernamesController_resumeConnection", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "Resume a username connection", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-operation-signature": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpectedRevisionRequest" + }, + "examples": { + "example": { + "summary": "Example", + "value": { + "expectedRevision": 5 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The resumed connection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnection" + }, + "examples": { + "example": { + "summary": "Connection", + "value": { + "parentName": "brand.eth", + "parentNode": "0x2b5c7025998f88550ef2ece8bf87935f542b3c1f2b0d6e6d0b2c9a3e4f5a6b7c", + "chain": "mainnet", + "status": "active", + "revision": 6, + "activeRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "latestRevision": { + "revisionNo": 1, + "endpointUrl": "https://api.brand.example/ens/users", + "authMode": "bearer", + "hasCredential": true, + "credentialUpdatedAt": "2026-10-30T11:58:00.000Z", + "sampleUsername": "happy", + "state": "active", + "validation": { + "ok": true, + "checkedAt": "2026-10-30T12:00:00.000Z", + "username": "happy", + "requestUrl": "https://api.brand.example/ens/users/happy", + "httpStatus": 200, + "latencyMs": 142, + "issues": [], + "preview": { + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" + } + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "createdBy": "0x1234567890abcdef1234567890abcdef12345678" + }, + "owner": { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "source": "v1-wrapped", + "checkedAt": "2026-10-30T12:00:00.000Z" + }, + "storedSubnamesAcknowledged": false, + "suspendedReason": null, + "activation": { + "verifiedAt": "2026-10-30T12:03:00.000Z", + "checkedRecords": [ + "addr(60)", + "text(name)", + "text(avatar)", + "contenthash" + ] + }, + "createdAt": "2026-10-30T11:58:00.000Z", + "updatedAt": "2026-10-30T12:03:00.000Z" + } + } + } + } + } + }, + "401": { + "description": "`INVALID_OPERATION_SIGNATURE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_OPERATION_SIGNATURE": { + "summary": "INVALID_OPERATION_SIGNATURE", + "value": { + "code": "INVALID_OPERATION_SIGNATURE", + "message": "The operation signature does not authorize this change" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "404": { + "description": "`CONNECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "CONNECTION_NOT_FOUND": { + "summary": "CONNECTION_NOT_FOUND", + "value": { + "code": "CONNECTION_NOT_FOUND", + "message": "brand.eth has no username connection" + } + } + } + } + } + }, + "409": { + "description": "`REVISION_CONFLICT`, `INVALID_STATE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "REVISION_CONFLICT": { + "summary": "REVISION_CONFLICT", + "value": { + "code": "REVISION_CONFLICT", + "message": "expectedRevision 3 does not match the current revision 4" + } + }, + "INVALID_STATE": { + "summary": "INVALID_STATE", + "value": { + "code": "INVALID_STATE", + "message": "A paused connection cannot be activated" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/universal-usernames/{parentName}/events": { + "get": { + "description": "Returns the audit log for the parent: saves, validations, activations, credential rotations, pauses, suspensions and disconnects, with the actor and revision. Details are redacted and never contain secrets. Signed in as the current exact owner of the parent.", + "operationId": "UniversalUsernamesController_listEvents", + "parameters": [ + { + "name": "parentName", + "required": true, + "in": "path", + "description": "The normalized parent name, URL-encoded. A .eth name directly below .eth, such as brand.eth.", + "schema": { + "type": "string", + "example": "brand.eth" + } + } + ], + "summary": "List connection events", + "tags": [ + "Universal usernames" + ], + "security": [ + { + "uun-session": [] + } + ], + "responses": { + "200": { + "description": "Events, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UsernameConnectionEvent" + } + }, + "examples": { + "example": { + "summary": "Events", + "value": [ + { + "action": "activate", + "actor": "0x1234567890abcdef1234567890abcdef12345678", + "revisionNo": 1, + "createdAt": "2026-10-30T12:03:00.000Z", + "details": { + "checkedRecords": 4 + } + }, + { + "action": "save", + "actor": "0x1234567890abcdef1234567890abcdef12345678", + "revisionNo": 1, + "createdAt": "2026-10-30T11:58:00.000Z", + "details": { + "endpointHost": "api.brand.example", + "authMode": "bearer" + } + } + ] + } + } + } + } + }, + "401": { + "description": "`INVALID_SESSION`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "INVALID_SESSION": { + "summary": "INVALID_SESSION", + "value": { + "code": "INVALID_SESSION", + "message": "The SIWE session is missing, malformed or expired" + } + } + } + } + } + }, + "403": { + "description": "`NOT_PARENT_OWNER`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "NOT_PARENT_OWNER": { + "summary": "NOT_PARENT_OWNER", + "value": { + "code": "NOT_PARENT_OWNER", + "message": "The signer is not the current owner of brand.eth" + } + } + } + } + } + }, + "404": { + "description": "`CONNECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "CONNECTION_NOT_FOUND": { + "summary": "CONNECTION_NOT_FOUND", + "value": { + "code": "CONNECTION_NOT_FOUND", + "message": "brand.eth has no username connection" + } + } + } + } + } + }, + "503": { + "description": "`UPSTREAM_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsernameConnectionError" + }, + "examples": { + "UPSTREAM_UNAVAILABLE": { + "summary": "UPSTREAM_UNAVAILABLE", + "value": { + "code": "UPSTREAM_UNAVAILABLE", + "message": "The ENS RPC did not answer while checking the owner of brand.eth; try again" + } + } + } + } + } + } + } + } + }, + "/api/v1/auth/nonce": { + "get": { + "description": "Returns a single-use nonce for a SIWE message. Every Universal Usernames change needs a fresh nonce in its operation signature. The nonce expires after 60 seconds and is consumed when the change is accepted.", + "operationId": "AuthenticationController_nonce", + "parameters": [], + "responses": { + "200": { + "description": "The nonce as plain text", + "content": { + "text/plain": { + "schema": { + "type": "string", + "example": "k3Xz9QpLm2RtVb7Wc" + } + } + } + } + }, + "summary": "Get a nonce", + "tags": [ + "Universal usernames" + ] + } } }, "info": { @@ -228,7 +2729,10 @@ }, "termsOfService": "https://namespace.ninja/tos" }, - "tags": [{ "name": "Subnames", "description": "" }], + "tags": [ + { "name": "Subnames", "description": "" }, + { "name": "Universal usernames", "description": "Connect a .eth name to a username endpoint your brand hosts. Only the current exact owner of the parent can call these routes." } + ], "servers": [ { "url": "https://offchain-manager.namespace.ninja", @@ -245,6 +2749,18 @@ "type": "apiKey", "in": "header", "name": "x-auth-token" + }, + "uun-session": { + "type": "apiKey", + "in": "header", + "name": "x-auth-token", + "description": "Wallet session for reads: `siwe:.`, the format the Namespace App uses. The SIWE message must be unexpired and signed by the current exact owner of the parent. API keys are rejected." + }, + "uun-operation-signature": { + "type": "apiKey", + "in": "header", + "name": "x-auth-token", + "description": "One signature per change, in the same `siwe:.` format. The EIP-4361 message is signed by the current exact owner and has: a `domain` that is the API host or a Namespace App domain; statement `Authorize for Universal Usernames on `; the deployment chain ID (1 or 11155111); a nonce from `GET /api/v1/auth/nonce`; an `expirationTime` at most 600 seconds after `issuedAt`; and exactly one resource, `urn:namespace:uun:::`. Actions: `save`, `activate`, `rotate-credential`, `pause`, `resume`, `disconnect`." } }, "schemas": { @@ -440,6 +2956,720 @@ } }, "required": ["parentName"] + }, + "ValidationIssueCode": { + "type": "string", + "description": "Stable issue code. `envelope` codes fail the whole profile, `record` codes fail one record, `transport` codes come from the request itself.", + "enum": [ + "INVALID_JSON", + "DUPLICATE_KEY", + "NOT_AN_OBJECT", + "UNSUPPORTED_VERSION", + "UNKNOWN_FIELD", + "USERNAME_MISSING", + "USERNAME_MISMATCH", + "TEXTS_NOT_OBJECT", + "ADDRESSES_NOT_OBJECT", + "TOO_MANY_TEXTS", + "TOO_MANY_ADDRESSES", + "TEXT_KEY_INVALID", + "TEXT_VALUE_INVALID", + "TEXT_VALUE_TOO_LONG", + "COIN_TYPE_INVALID", + "COIN_TYPE_UNSUPPORTED", + "ADDRESS_INVALID", + "CONTENTHASH_INVALID", + "CONTENTHASH_UNSUPPORTED", + "INVALID_URL", + "DESTINATION_BLOCKED", + "HTTP_TIMEOUT", + "NETWORK_ERROR", + "HTTP_REDIRECT", + "UNAUTHORIZED", + "RATE_LIMITED", + "HTTP_ERROR_STATUS", + "USERNAME_NOT_FOUND", + "CONTENT_TYPE_INVALID", + "RESPONSE_TOO_LARGE" + ] + }, + "ValidationIssue": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/ValidationIssueCode" + }, + "kind": { + "type": "string", + "enum": [ + "envelope", + "record", + "transport" + ] + }, + "path": { + "type": "string", + "description": "RFC 6901 JSON Pointer into the response body. Empty for the root or for transport issues.", + "example": "/addresses/60" + }, + "message": { + "type": "string", + "description": "Human-readable explanation. Never contains secrets or the full profile." + } + }, + "required": [ + "code", + "kind", + "path", + "message" + ] + }, + "EndpointValidationReport": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "description": "`true` only when `issues` is empty." + }, + "checkedAt": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string", + "description": "The normalized username that was requested." + }, + "requestUrl": { + "type": "string", + "description": "The URL that was called. Never includes credentials." + }, + "httpStatus": { + "type": "integer", + "nullable": true, + "description": "`null` when no response arrived." + }, + "latencyMs": { + "type": "integer", + "nullable": true + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationIssue" + }, + "description": "Every transport, envelope and record issue found." + }, + "preview": { + "type": "object", + "nullable": true, + "description": "The records that are valid, in human-readable form. `null` when a transport or envelope issue stopped validation.", + "properties": { + "texts": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "addresses": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Keyed by decimal ENS coin type." + }, + "contenthash": { + "type": "string", + "nullable": true + } + }, + "required": [ + "texts", + "addresses", + "contenthash" + ] + }, + "onchainConflict": { + "type": "boolean", + "description": "Set when the sample username already has an onchain owner. That name keeps resolving onchain and never reaches the endpoint." + } + }, + "required": [ + "ok", + "checkedAt", + "username", + "requestUrl", + "httpStatus", + "latencyMs", + "issues", + "preview" + ] + }, + "UsernameConnectionStatus": { + "type": "string", + "enum": [ + "draft", + "validated", + "verifying", + "active", + "paused", + "suspended", + "disconnected" + ] + }, + "UsernameRevisionState": { + "type": "string", + "enum": [ + "candidate", + "validated", + "active", + "superseded", + "rejected" + ] + }, + "UsernameConnectionRevision": { + "type": "object", + "properties": { + "revisionNo": { + "type": "integer" + }, + "endpointUrl": { + "type": "string" + }, + "authMode": { + "type": "string", + "enum": [ + "none", + "bearer" + ] + }, + "hasCredential": { + "type": "boolean", + "description": "Whether a bearer credential is stored. The value itself is never returned." + }, + "credentialUpdatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sampleUsername": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/UsernameRevisionState" + }, + "validation": { + "allOf": [ + { + "$ref": "#/components/schemas/EndpointValidationReport" + } + ], + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "createdBy": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$", + "description": "Address that signed the save." + } + }, + "required": [ + "revisionNo", + "endpointUrl", + "authMode", + "hasCredential", + "credentialUpdatedAt", + "sampleUsername", + "state", + "validation", + "createdAt", + "createdBy" + ] + }, + "UsernameConnectionActivationSuccess": { + "type": "object", + "properties": { + "verifiedAt": { + "type": "string", + "format": "date-time" + }, + "checkedRecords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "verifiedAt", + "checkedRecords" + ] + }, + "UsernameConnectionActivationFailure": { + "type": "object", + "properties": { + "failedAt": { + "type": "string", + "format": "date-time" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationIssue" + } + } + }, + "required": [ + "failedAt", + "errors" + ] + }, + "UsernameConnection": { + "type": "object", + "properties": { + "parentName": { + "type": "string", + "example": "brand.eth" + }, + "parentNode": { + "type": "string", + "description": "Namehash of the parent name." + }, + "chain": { + "type": "string", + "enum": [ + "mainnet", + "sepolia" + ] + }, + "status": { + "$ref": "#/components/schemas/UsernameConnectionStatus" + }, + "revision": { + "type": "integer", + "description": "Concurrency counter. Send it back as `expectedRevision` with the next change." + }, + "activeRevision": { + "allOf": [ + { + "$ref": "#/components/schemas/UsernameConnectionRevision" + } + ], + "nullable": true, + "description": "The revision lookups are served from." + }, + "latestRevision": { + "allOf": [ + { + "$ref": "#/components/schemas/UsernameConnectionRevision" + } + ], + "nullable": true, + "description": "The most recently saved revision. Differs from `activeRevision` while a candidate is pending." + }, + "owner": { + "type": "object", + "properties": { + "address": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "source": { + "type": "string", + "enum": [ + "v1", + "v1-wrapped", + "v2" + ], + "description": "Where ownership comes from: the ENS registry, the NameWrapper, or the ENSv2 registry." + }, + "checkedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "address", + "source", + "checkedAt" + ] + }, + "storedSubnamesAcknowledged": { + "type": "boolean" + }, + "suspendedReason": { + "type": "string", + "enum": [ + "owner-changed", + "expired" + ], + "nullable": true + }, + "activation": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/UsernameConnectionActivationSuccess" + }, + { + "$ref": "#/components/schemas/UsernameConnectionActivationFailure" + } + ], + "description": "Result of the last activation." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "parentName", + "parentNode", + "chain", + "status", + "revision", + "activeRevision", + "latestRevision", + "owner", + "storedSubnamesAcknowledged", + "suspendedReason", + "activation", + "createdAt", + "updatedAt" + ] + }, + "ParentInspection": { + "type": "object", + "properties": { + "parentName": { + "type": "string" + }, + "parentNode": { + "type": "string" + }, + "supported": { + "type": "boolean", + "description": "`true` for a normalized, unexpired .eth name directly below .eth." + }, + "owner": { + "type": "object", + "properties": { + "address": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "source": { + "type": "string", + "enum": [ + "v1", + "v1-wrapped", + "v2" + ], + "description": "Where ownership comes from: the ENS registry, the NameWrapper, or the ENSv2 registry." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "address", + "source", + "expiresAt" + ] + }, + "resolver": { + "type": "object", + "properties": { + "address": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$", + "nullable": true + }, + "isHybridResolverV2": { + "type": "boolean" + } + }, + "required": [ + "address", + "isHybridResolverV2" + ] + }, + "route": { + "type": "object", + "description": "The HybridResolverV2 route for the parent. `null` id when the resolver is not HybridResolverV2.", + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "isOffchain": { + "type": "boolean" + } + }, + "required": [ + "id", + "isOffchain" + ] + }, + "canSetResolver": { + "type": "boolean", + "description": "`false` when the name is wrapped with CANNOT_SET_RESOLVER burned." + }, + "storedSubnames": { + "type": "object", + "properties": { + "count": { + "type": "integer" + } + }, + "required": [ + "count" + ] + }, + "blockers": { + "type": "array", + "description": "Reasons the parent cannot be connected as it is, such as `L2_ROUTE`, `CANNOT_SET_RESOLVER`, `EXPIRED` or `NOT_ETH_2LD`.", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + } + } + }, + "required": [ + "parentName", + "parentNode", + "supported", + "owner", + "resolver", + "route", + "canSetResolver", + "storedSubnames", + "blockers" + ] + }, + "UsernameConnectionEvent": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "save", + "validate", + "activate", + "activation-failed", + "rotate-credential", + "pause", + "resume", + "suspend", + "disconnect", + "acknowledge-stored-subnames" + ] + }, + "actor": { + "type": "string", + "description": "The signer address, `namespace-admin`, or `system`." + }, + "revisionNo": { + "type": "integer", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Redacted details. Never contains secrets." + } + }, + "required": [ + "action", + "actor", + "revisionNo", + "createdAt", + "details" + ] + }, + "UsernameConnectionError": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "UNSUPPORTED_PARENT", + "INVALID_SESSION", + "INVALID_OPERATION_SIGNATURE", + "NOT_PARENT_OWNER", + "CONNECTION_NOT_FOUND", + "REVISION_CONFLICT", + "INVALID_STATE", + "STORED_SUBNAMES_NOT_ACKNOWLEDGED", + "ENDPOINT_BACKED_PARENT", + "PRECONDITION_FAILED", + "VALIDATION_FAILED", + "RATE_LIMITED", + "UPSTREAM_UNAVAILABLE" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "description": "Present on `PRECONDITION_FAILED`: one entry per failed precondition.", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + }, + "required": [ + "code", + "message" + ] + }, + "ValidateUsernameEndpointRequest": { + "type": "object", + "properties": { + "endpointUrl": { + "type": "string", + "description": "HTTPS base URL with no query string, fragment or credentials, at most 2048 characters. The username is appended as one path segment.", + "example": "https://api.brand.example/ens/users" + }, + "authMode": { + "type": "string", + "enum": [ + "none", + "bearer" + ] + }, + "credential": { + "type": "string", + "description": "Bearer token Namespace sends as `Authorization: Bearer `. Required when `authMode` is `bearer`." + }, + "username": { + "type": "string", + "description": "A username that exists in your system.", + "example": "happy" + } + }, + "required": [ + "endpointUrl", + "authMode", + "username" + ] + }, + "SaveUsernameConnectionRequest": { + "type": "object", + "properties": { + "endpointUrl": { + "type": "string", + "example": "https://api.brand.example/ens/users" + }, + "authMode": { + "type": "string", + "enum": [ + "none", + "bearer" + ] + }, + "credential": { + "type": "string", + "description": "New bearer token. Omit with `keepCredential: true` to reuse the stored one." + }, + "keepCredential": { + "type": "boolean", + "description": "Reuse the credential stored on the latest revision." + }, + "sampleUsername": { + "type": "string", + "description": "A username that exists in your system. Validation and activation check it.", + "example": "happy" + }, + "acknowledgeStoredSubnames": { + "type": "boolean", + "description": "Required when the parent has stored offchain subnames. They stop resolving while the parent is connected. Recorded in the events." + }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "The current `revision`, including after a disconnect, or `0` when the parent has never had a connection." + } + }, + "required": [ + "endpointUrl", + "authMode", + "sampleUsername", + "expectedRevision" + ] + }, + "ExpectedRevisionRequest": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "The current `revision` of the connection." + } + }, + "required": [ + "expectedRevision" + ] + }, + "PreviewUsernameRequest": { + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "happy" + } + }, + "required": [ + "username" + ] + }, + "RotateUsernameCredentialRequest": { + "type": "object", + "properties": { + "credential": { + "type": "string" + }, + "expectedRevision": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "credential", + "expectedRevision" + ] } } } diff --git a/overview/universal-usernames.mdx b/overview/universal-usernames.mdx index 1a7b9f7..aa46d85 100644 --- a/overview/universal-usernames.mdx +++ b/overview/universal-usernames.mdx @@ -86,8 +86,14 @@ Universal usernames is rolling out to selected brands first. [Talk to us](/user- ## Next steps - - See how universal usernames compare with offchain and onchain subnames. + + Build the endpoint, connect your `.eth` name, and activate it step by step. + + + The request, profile schema, status codes, and limits your endpoint follows. + + + Start from a tested Hono endpoint and plug in your user store. Plan your parent name, endpoint, and rollout with the Namespace team. diff --git a/package-lock.json b/package-lock.json index bb38a42..de6620b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@thenamespace/mint-manager": "2.0.0", "@thenamespace/offchain-manager": "1.0.13", "@types/node": "22.20.1", + "ajv": "8.20.0", "dotenv": "17.4.2", "mint": "4.2.694", "typescript": "6.0.3", diff --git a/package.json b/package.json index ae04de3..fb4e722 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ "check:a11y": "mint a11y", "check:content": "node scripts/check-docs.mjs", "check:examples": "tsc --noEmit --project examples/tsconfig.json", - "check": "npm run validate && npm run check:links && npm run check:a11y && npm run check:content && npm run check:examples" + "check:uun-schema": "node scripts/check-uun-schema.mjs", + "check:adapter": "npm ci --prefix examples/universal-usernames-adapter && npm run typecheck --prefix examples/universal-usernames-adapter && npm test --prefix examples/universal-usernames-adapter", + "check": "npm run validate && npm run check:links && npm run check:a11y && npm run check:content && npm run check:examples && npm run check:uun-schema && npm run check:adapter" }, "devDependencies": { "@thenamespace/mint-manager": "2.0.0", "@thenamespace/offchain-manager": "1.0.13", "@types/node": "22.20.1", + "ajv": "8.20.0", "dotenv": "17.4.2", "mint": "4.2.694", "typescript": "6.0.3", diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index ce79f5b..f54f87a 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -27,7 +27,11 @@ function walk(directory) { } const files = walk(root); -const mdxFiles = files.filter((file) => file.endsWith('.mdx')); +// Snippets are imported into pages and never rendered on their own, so they +// need no frontmatter or navigation entry. +const mdxFiles = files.filter( + (file) => file.endsWith('.mdx') && !relative(root, file).startsWith('snippets/'), +); const routes = new Set( mdxFiles.map((file) => relative(root, file).replace(/\.mdx$/, '')), ); diff --git a/scripts/check-uun-schema.mjs b/scripts/check-uun-schema.mjs new file mode 100644 index 0000000..afa68d4 --- /dev/null +++ b/scripts/check-uun-schema.mjs @@ -0,0 +1,130 @@ +// Keeps every copy of the universal usernames contract in this repository in +// step with one source: snippets/universal-usernames/profile-v1.schema.json. +// +// Fails when: +// - a copy of profile schema v1 (the docs snippet, the adapter template, or any +// JSON code block titled "Universal Usernames profile v1") differs from it; +// - an example profile in the docs or the template README doesn't validate +// against it (code blocks whose title says "invalid" or "wrong" are skipped); +// - a code block on the adapter template page that is titled with a template +// file path differs from that file; +// - the issue codes on the troubleshooting page differ from the +// ValidationIssueCode enum in the OpenAPI spec. +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { Ajv2020 } from 'ajv/dist/2020.js'; + +const root = process.cwd(); +const SCHEMA_PATH = 'snippets/universal-usernames/profile-v1.schema.json'; +const SCHEMA_SNIPPET = 'snippets/universal-usernames/profile-v1-schema.mdx'; +const TEMPLATE_DIR = 'examples/universal-usernames-adapter'; +const TEMPLATE_SCHEMA = `${TEMPLATE_DIR}/schema/profile-v1.schema.json`; +const TEMPLATE_PAGE = 'developer-guide/universal-usernames/adapter-template.mdx'; +const TROUBLESHOOTING_PAGE = 'developer-guide/universal-usernames/troubleshooting.mdx'; +const OPENAPI_PATH = 'openapi/offchain-openapi.json'; +const ignoredDirectories = new Set(['.git', 'node_modules', '.mintlify', 'dist']); + +const failures = []; +const read = (path) => readFileSync(join(root, path), 'utf8'); + +function walk(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return ignoredDirectories.has(entry.name) ? [] : walk(path); + return /\.mdx?$/.test(entry.name) ? [relative(root, path)] : []; + }); +} + +// Code fences, including ones indented inside components such as . +function codeBlocks(content) { + const blocks = []; + for (const match of content.matchAll(/^( *)```(\w*)([^\n]*)\n([\s\S]*?)^\1```/gm)) { + const [, indent, language, meta, body] = match; + const code = body + .split('\n') + .map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line)) + .join('\n'); + blocks.push({ language, title: meta.trim(), code }); + } + return blocks; +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +const schema = JSON.parse(read(SCHEMA_PATH)); +const validateProfile = new Ajv2020({ allErrors: true, strict: false }).compile(schema); + +if (!isDeepStrictEqual(JSON.parse(read(TEMPLATE_SCHEMA)), schema)) { + failures.push(`${TEMPLATE_SCHEMA}: differs from ${SCHEMA_PATH}`); +} + +let snippetShowsSchema = false; +let examplesChecked = 0; + +for (const file of walk(root)) { + for (const block of codeBlocks(read(file))) { + if (block.language !== 'json') continue; + const value = parseJson(block.code); + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + + if (value.title === schema.title) { + if (file === SCHEMA_SNIPPET) snippetShowsSchema = true; + if (!isDeepStrictEqual(value, schema)) { + failures.push(`${file}: profile schema v1 differs from ${SCHEMA_PATH}`); + } + continue; + } + + const looksLikeProfile = value.version === 1 && 'username' in value; + if (!looksLikeProfile || /\b(invalid|wrong)\b/i.test(block.title)) continue; + examplesChecked += 1; + if (!validateProfile(value)) { + const errors = validateProfile.errors + .map((error) => `${error.instancePath || '/'} ${error.message}`) + .join('; '); + failures.push(`${file}: example profile "${block.title}" is not valid schema v1: ${errors}`); + } + } +} + +if (!snippetShowsSchema) { + failures.push(`${SCHEMA_SNIPPET}: no JSON code block with the profile schema v1`); +} + +for (const block of codeBlocks(read(TEMPLATE_PAGE))) { + if (!/^(src|tests|schema)\//.test(block.title)) continue; + const source = join(TEMPLATE_DIR, block.title); + if (!existsSync(join(root, source))) { + failures.push(`${TEMPLATE_PAGE}: code block "${block.title}" has no file at ${source}`); + } else if (read(source).trimEnd() !== block.code.trimEnd()) { + failures.push(`${TEMPLATE_PAGE}: code block "${block.title}" differs from ${source}`); + } +} + +const issueCodes = JSON.parse(read(OPENAPI_PATH)).components.schemas.ValidationIssueCode.enum; +const documentedCodes = [...read(TROUBLESHOOTING_PAGE).matchAll(/^### `([A-Z_]+)`$/gm)].map( + (match) => match[1], +); +for (const code of issueCodes.filter((code) => !documentedCodes.includes(code))) { + failures.push(`${TROUBLESHOOTING_PAGE}: issue code ${code} is not documented`); +} +for (const code of documentedCodes.filter((code) => !issueCodes.includes(code))) { + failures.push(`${TROUBLESHOOTING_PAGE}: ${code} is not in the ValidationIssueCode enum in ${OPENAPI_PATH}`); +} + +if (failures.length > 0) { + console.error(`Universal usernames contract checks failed (${failures.length}):`); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log( + `Universal usernames contract checks passed: schema copies match, ${examplesChecked} example profiles are valid, ${issueCodes.length} issue codes are documented.`, +); diff --git a/snippets/universal-usernames/profile-v1-example.mdx b/snippets/universal-usernames/profile-v1-example.mdx new file mode 100644 index 0000000..70243e6 --- /dev/null +++ b/snippets/universal-usernames/profile-v1-example.mdx @@ -0,0 +1,18 @@ +```json GET https://api.brand.example/ens/users/happy +{ + "version": 1, + "username": "happy", + "texts": { + "name": "Happy", + "avatar": "https://cdn.brand.example/happy.png", + "com.github": "happy", + "com.brand.role": "member" + }, + "addresses": { + "60": "0x1111111111111111111111111111111111111111", + "2147492101": "0x2222222222222222222222222222222222222222", + "0": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + }, + "contenthash": "ipfs://QmYwAPJzv5CZsnAzt8auVZRnGiRAzQmmJYDh4T7SEBuwNA" +} +``` diff --git a/snippets/universal-usernames/profile-v1-schema.mdx b/snippets/universal-usernames/profile-v1-schema.mdx new file mode 100644 index 0000000..61ea19a --- /dev/null +++ b/snippets/universal-usernames/profile-v1-schema.mdx @@ -0,0 +1,26 @@ +```json profile-v1.schema.json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Universal Usernames profile v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "username", "texts", "addresses"], + "properties": { + "version": { "const": 1 }, + "username": { "type": "string", "minLength": 1 }, + "texts": { + "type": "object", + "maxProperties": 128, + "propertyNames": { "minLength": 1, "maxLength": 256 }, + "additionalProperties": { "type": "string", "maxLength": 4096 } + }, + "addresses": { + "type": "object", + "maxProperties": 128, + "propertyNames": { "pattern": "^(0|[1-9][0-9]*)$" }, + "additionalProperties": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "contenthash": { "type": "string", "minLength": 1, "maxLength": 4096 } + } +} +``` diff --git a/snippets/universal-usernames/profile-v1.schema.json b/snippets/universal-usernames/profile-v1.schema.json new file mode 100644 index 0000000..f75237a --- /dev/null +++ b/snippets/universal-usernames/profile-v1.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Universal Usernames profile v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "username", "texts", "addresses"], + "properties": { + "version": { "const": 1 }, + "username": { "type": "string", "minLength": 1 }, + "texts": { + "type": "object", + "maxProperties": 128, + "propertyNames": { "minLength": 1, "maxLength": 256 }, + "additionalProperties": { "type": "string", "maxLength": 4096 } + }, + "addresses": { + "type": "object", + "maxProperties": 128, + "propertyNames": { "pattern": "^(0|[1-9][0-9]*)$" }, + "additionalProperties": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "contenthash": { "type": "string", "minLength": 1, "maxLength": 4096 } + } +}