Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
506feac
feat(ens): @libid/ens, the ENS name of a handle
SupremaLex Oct 5, 2026
2fd7371
fix(ens): refuse every label ENSIP-15 or DNS would not carry
SupremaLex Oct 5, 2026
4055f41
feat(ens): take the chain's handle rules and the parent name as options
SupremaLex Oct 5, 2026
504e977
build(ens): pin @libid/contracts 0.15.1 and re-export HandleError
SupremaLex Oct 5, 2026
2cd4f6c
refactor(ens): keep handles.link only as the parent option's default
SupremaLex Oct 5, 2026
66a70d5
test(ens): one vector table for the transform and the gateway's inverse
SupremaLex Oct 5, 2026
21e2277
docs(ens): link the spec at a pinned commit and list ens for release
SupremaLex Oct 5, 2026
5cd583b
test(packages): import @libid/ens in the packed-package consumer
SupremaLex Oct 5, 2026
9fe0b74
docs(ens): say where rulesOf comes from in the options example
SupremaLex Oct 5, 2026
3cc90c2
docs(ens): link the ENS spec at #93's current head
SupremaLex Oct 5, 2026
cd21a89
fix(ens)!: normalize with the released rules only
SupremaLex Oct 5, 2026
f79fbf8
test(ens): check vector handles are normalized and use viem's ENSIP-15
SupremaLex Oct 5, 2026
81a8b8e
build: pin @libid/contracts once, in the pnpm catalog
SupremaLex Oct 5, 2026
d10137a
test(packages): check ledger, popup and ceremony load without viem
SupremaLex Oct 5, 2026
80a3395
docs(ens): link the vector table at a pinned commit
SupremaLex Oct 5, 2026
3ea670d
docs(ens): link the ENS spec at #93's current head
SupremaLex Oct 5, 2026
a32a852
refactor(ens): one platform check and one label rule
SupremaLex Oct 5, 2026
268032c
build(ts): check the viem-free install in the one consumer
SupremaLex Oct 5, 2026
0392d6a
docs(ens): state the vector table's readers as they are
SupremaLex Oct 5, 2026
59d12df
chore: merge main into feat/ens-names
SupremaLex Oct 19, 2026
cf84f85
chore: merge main into feat/ens-names
SupremaLex Oct 27, 2026
1470721
refactor(ens): load handle rules from the viem-free contracts entry
SupremaLex Oct 27, 2026
09d2f57
fix(ens): follow the handle rules of @libid/contracts 0.19
SupremaLex Oct 27, 2026
dc46acf
docs(ens): link the spec and vectors on main
SupremaLex Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions ts/RELEASING.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# npm packages

`@libid/popup`, `@libid/ledger` and `@libid/ceremony` are independently versioned
`@libid/popup`, `@libid/ledger`, `@libid/ceremony` and `@libid/ens` are independently versioned
ESM packages with TypeScript declarations. Their initial package versions are
`0.1.0`; publication is a separate maintainer action.

Popup exports its main API, `/worker` and `/testing`. Ceremony exports its
application client API; it requires the app's compatible popup installation as a
peer so both use the same popup classes. Ledger is a regular dependency because
ceremony's declarations use `LedgerId`.
ceremony's declarations use `LedgerId`. Ens depends on `@libid/contracts`, pinned to
the version ceremony uses, for handle normalization.

The npm packages contain only the public entry graphs, source maps with embedded
sources, README and repository license/notice files. Source, tests and CCDP
Expand All @@ -23,7 +24,7 @@ pnpm -C ts install --frozen-lockfile
pnpm -C ts test:packages
```

This builds and packs all three packages into `ts/.cache/npm/`, installs those
This builds and packs all four packages into `ts/.cache/npm/`, installs those
exact tarballs into an isolated consumer, then typechecks the public imports
with both the workspace TypeScript and TypeScript 5, and bundles them. The
worker entry is typechecked separately from the browser entry. It also checks
Expand All @@ -34,16 +35,17 @@ in ignored local paths.

## First publication

The npm organization must permit publishing all three names. npm requires a
The npm organization must permit publishing all four names. npm requires a
package to exist before a trusted publisher can be configured. From a checked
main commit, run the command above, authenticate to npm with a maintainer
account, and publish ledger and popup before ceremony. These first versions
carry no provenance; every later one does:
account, and publish ledger and popup before ceremony; ens depends on none of
them. These first versions carry no provenance; every later one does:

```sh
npm publish ts/.cache/npm/ledger.tgz --access public
npm publish ts/.cache/npm/popup.tgz --access public
npm publish ts/.cache/npm/ceremony.tgz --access public
npm publish ts/.cache/npm/ens.tgz --access public
```

Once per repository, set up two settings that cover every package:
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/ceremony/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"vitest": "catalog:"
},
"dependencies": {
"@libid/contracts": "0.19.0",
"@libid/contracts": "catalog:",
"@libid/ledger": "workspace:^",
"@noble/hashes": "^2.4.0"
},
Expand Down
82 changes: 82 additions & 0 deletions ts/packages/ens/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# @libid/ens

The ENS name of a libID handle. A name is derived from the handle alone, so
there is nothing to register: the libID gateway reads the handle back out of
the labels and answers with its holder.

```ts
import { ensName } from '@libid/ens'

ensName('github', 'octocat') // 'octocat.github.handles.link'
ensName('x', 'Some_Handle') // 'some-handle.x.handles.link'
ensName('google', 'alice@gmail.com') // 'alice.google.handles.link'
ensName('google', 'alice@company.com') // 'alice._at.company.com.google.handles.link'
ensName('x', 'alice', { chain: 'base' }) // 'alice.x.base.handles.link'
```

`ensName` normalizes the handle first with the handle rules
`@libid/contracts` was released with, which the gateway applies on every
chain. They fold `A`-`Z` and strip nothing: `Some_Handle` gives the name of
`some_handle`, but `@some_handle` and ` some_handle` have none. It returns
`null` when a handle has no name, and throws `HandleError` for text no
circuit hashes, as `@libid/contracts` defines it. `HandleError` is re-exported from
`@libid/ens`, so `instanceof` works without importing `@libid/contracts`.

## Install

```sh
npm install @libid/ens
```

## Rules

| Platform | Handle | Name labels |
| --- | --- | --- |
| GitHub | `octocat` | the handle |
| X | `some_handle` | every `_` becomes `-` |
| Google, Gmail | `alice.smith@gmail.com` | the local part, split at dots |
| Google, other domains | `alice@company.com` | the local part, `_at`, then the domain |

These handles have no name:

- a handle with a character no label holds, such as `@` or a space;
- an X handle with `-`, or whose third and fourth characters are both `_`,
which ENS reserves (`ab__cd` would be `ab--cd`);
- a GitHub handle with `_`, or whose third and fourth characters are both `-`;
- a Gmail address with `+`, `-` or `_` in the local part, or an empty piece
(`.alice@`, `alice.@`, `a..b@`);
- any other Google address with `_` or `+`, an empty piece, `_at` as a piece,
or a piece whose third and fourth characters are both `-` (ENSIP-15 reserves
them, so `xn--` domains have no name);
- a handle with a piece longer than 63 bytes, which DNS cannot carry.

## Options

```ts
ensName('x', 'alice', { chain: 'base' }) // 'alice.x.base.handles.link'
ensName('x', 'alice', { parent: 'testnet.handles.link' }) // 'alice.x.testnet.handles.link'
```

- `chain` narrows a name to one chain. Without one, the wallet's chain
decides. Which chain labels a gateway knows is deployment data; `ensName`
only checks the label's shape and that it is not a platform key.
- `parent` is the name the gateway answers under. It defaults to
`handles.link`.

ENS asks the nearest name that has a resolver. A chain label that names a
subname with a deployment of its own is answered by that deployment, so
`ensName('x', 'alice', { chain: 'testnet' })` and
`ensName('x', 'alice', { parent: 'testnet.handles.link' })` give the same
name. A deployment's chain labels must not name such a subname; `ensName`
cannot see which subnames have one.

A chain label or parent name that is not a lowercase ASCII label of 1 to 63
bytes, or has `--` at its third and fourth characters, throws.

The rules are those of the [ENS integration spec](https://github.com/libid-org/libID/blob/main/specs/ens-integration.md), §5 and §6.

[`vectors/names.json`](https://github.com/libid-org/libID/blob/main/ts/packages/ens/vectors/names.json)
in the repository lists names with their handles, handles with no name, and
names that read back as no handle. The tests here run it forward through
`ensName` and back through this package's own reading of the spec. The
gateway's inverse does not read this file.
43 changes: 43 additions & 0 deletions ts/packages/ens/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@libid/ens",
"version": "0.1.0",
"description": "The ENS name of a libID handle under handles.link.",
"license": "(MIT OR Apache-2.0)",
"repository": {
"type": "git",
"url": "git+https://github.com/libid-org/libID.git",
"directory": "ts/packages/ens"
},
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "node ../../scripts/build-package.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"prepack": "pnpm build"
},
"dependencies": {
"@libid/contracts": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"viem": "^2.48.11",
"vitest": "catalog:"
},
"files": [
"dist",
"LICENSE-MIT",
"LICENSE-APACHE",
"NOTICE"
],
"publishConfig": {
"access": "public"
}
}
81 changes: 81 additions & 0 deletions ts/packages/ens/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { normalize as ensNormalize } from 'viem/ens'
import { describe, expect, it } from 'vitest'
import { ensName, HandleError } from './index.js'

describe('ensName', () => {
// The normalized forms are in vectors/names.json; these fold A-Z first.
it.each([
['github', 'OctoCat', 'octocat.github.handles.link'],
['x', 'Some_Handle', 'some-handle.x.handles.link'],
['google', 'Alice.Smith@Gmail.com', 'alice.smith.google.handles.link'],
['google', 'Alice@Company.com', 'alice._at.company.com.google.handles.link'],
] as const)('folds %s %j first: %s', (platform, handle, name) => {
expect(ensName(platform, handle)).toBe(name)
})

// The contracts' rules fold A-Z and strip nothing; `@` and spaces are not
// ENSIP-15 label characters, so such text has no name.
it.each([
['github', '@OctoCat'],
['x', '@Some_Handle'],
['x', ' alice '],
] as const)('gives %s %j no name', (platform, handle) => {
expect(() => ensNormalize(handle.toLowerCase())).toThrow('disallowed character')
expect(ensName(platform, handle)).toBeNull()
})

it.each([
'',
'handles..link',
'.handles.link',
'Handles.link',
'ab--cd.link',
`${'a'.repeat(64)}.link`,
null,
])('refuses the parent name %j', (parent) => {
expect(() => ensName('x', 'alice', { parent: parent as string })).toThrow('Not a parent name')
})

it('takes a parent label of 63 bytes', () => {
const parent = `${'a'.repeat(63)}.link`
expect(ensName('x', 'alice', { parent })).toBe(`alice.x.${parent}`)
})

it('reads null options as none', () => {
expect(ensName('x', 'alice', null)).toBe('alice.x.handles.link')
})

it.each([
['uppercase', 'Base'],
['a platform key', 'github'],
['empty', ''],
['an underscore', 'op_mainnet'],
['a dot', 'op.mainnet'],
['ENSIP-15 reserved `--`', 'ab--cd'],
['past 63 bytes', 'a'.repeat(64)],
['not a string', null],
])('refuses a chain label that is %s', (_, chain) => {
expect(() => ensName('x', 'alice', { chain: chain as string })).toThrow('Not a chain label')
})

it('takes a chain label of 63 bytes', () => {
const chain = 'a'.repeat(63)
expect(ensName('x', 'alice', { chain })).toBe(`alice.x.${chain}.handles.link`)
})

it.each([
['github', '', 'Empty'],
['x', 'ali"ce', 'BadCharacter'],
['x', 'ali\\ce', 'BadCharacter'],
['x', 'alicé', 'BadCharacter'],
['github', 'a'.repeat(40), 'TooLong'],
] as const)('throws HandleError for %s %j: %s', (platform, handle, kind) => {
expect(() => ensName(platform, handle)).toThrow(HandleError)
expect(() => ensName(platform, handle)).toThrow(expect.objectContaining({ kind }))
})

it('throws for a platform it does not know or a handle that is not a string', () => {
expect(() => ensName('mastodon' as 'x', 'alice')).toThrow('Not a platform')
expect(() => ensName('github', null as unknown as string)).toThrow(TypeError)
})
})
Loading
Loading