@x1id/resolve 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,6 +116,44 @@ to the other". `@jack.x1` throws `ambiguous` rather than guessing. Use
116
116
  `looksLikeName(input)` to decide whether to attempt resolution at all, so
117
117
  pasting base58 does not surface a validation error.
118
118
 
119
+ ## Scoped names under customer TLDs
120
+
121
+ Anyone can launch their own X1ID TLD (e.g. `.e2eshib`), and names registered
122
+ under it — `hello.e2eshib` — are first-class X1ID names. These are **not** X1NS
123
+ or SNS: `.x1` / `.xnt` / `.xen` are the separate **X1NS** domain system and
124
+ `.sol` is **SNS** (Solana Name Service), each resolved on its own path. A
125
+ customer TLD is resolved natively by this SDK against the `@handle` registry.
126
+
127
+ Scoped resolution derives a per-TLD PDA, which needs `@solana/web3.js`. It is
128
+ **opt-in and explicit** — you wire a deriver in; the SDK never auto-imports
129
+ web3 for you:
130
+
131
+ ```ts
132
+ import { createResolver, WasmResolver, makeScopedHandleDeriver } from "@x1id/resolve";
133
+ import * as web3 from "@solana/web3.js";
134
+
135
+ const wasm = await WasmResolver.fromBytes(/* module bytes */);
136
+
137
+ // `makeScopedHandleDeriver` is ASYNC — await it. Pass the RESULT (not the
138
+ // Promise) as `scopedHandleDeriver`.
139
+ const scopedHandleDeriver = await makeScopedHandleDeriver(web3);
140
+
141
+ const x1id = createResolver({
142
+ rpcUrl: "https://rpc.testnet.x1.xyz",
143
+ wasm,
144
+ scopedHandleDeriver,
145
+ });
146
+
147
+ await x1id.resolve("hello.e2eshib"); // resolves the scoped name under .e2eshib
148
+ ```
149
+
150
+ Without `scopedHandleDeriver`, resolving a launched customer TLD throws
151
+ `ResolveError { code: "not-configured" }` even with `@solana/web3.js` installed —
152
+ the deriver is what the SDK actually calls. And because `makeScopedHandleDeriver`
153
+ returns a Promise, passing it **un-awaited** is a common footgun; the SDK
154
+ detects that and throws a clear `not-configured` error telling you to `await` it,
155
+ rather than a cryptic "scopedHandleKey is not a function".
156
+
119
157
  ## Reads chain state, not an API
120
158
 
121
159
  X1NS names resolve by deriving accounts and reading them over RPC. This library
@@ -77,7 +77,17 @@ export interface GuardResult {
77
77
  *
78
78
  * Fail-closed by default: if the decision cannot be obtained (network/5xx), the
79
79
  * guard RE-THROWS {@link GateError} so your route returns an error rather than
80
- * silently admitting an unverified caller. Pass `onError: "allow"` to fail-open.
80
+ * silently admitting an unverified caller. `onError: "deny"` instead resolves to
81
+ * a clean `allowed: false` (fail-closed without a throw).
82
+ *
83
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN. On ANY error (network blip,
84
+ * timeout, 5xx, auth failure, a malformed response) it fabricates a
85
+ * `passed: true` decision and admits the caller UNVERIFIED. NEVER use it for a
86
+ * real access gate — anyone who can make your gate call fail (trivial: induce a
87
+ * timeout) then bypasses it completely. It exists ONLY for soft, non-security
88
+ * personalization where a missing verification must not degrade UX (e.g.
89
+ * optionally showing a "verified" flourish). If the gate protects anything —
90
+ * a route, a mint, an airdrop, a write — use the default `"throw"` or `"deny"`.
81
91
  *
82
92
  * @example
83
93
  * ```ts
@@ -78,7 +78,17 @@ export async function checkGate(cfg, subject, policy) {
78
78
  *
79
79
  * Fail-closed by default: if the decision cannot be obtained (network/5xx), the
80
80
  * guard RE-THROWS {@link GateError} so your route returns an error rather than
81
- * silently admitting an unverified caller. Pass `onError: "allow"` to fail-open.
81
+ * silently admitting an unverified caller. `onError: "deny"` instead resolves to
82
+ * a clean `allowed: false` (fail-closed without a throw).
83
+ *
84
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN. On ANY error (network blip,
85
+ * timeout, 5xx, auth failure, a malformed response) it fabricates a
86
+ * `passed: true` decision and admits the caller UNVERIFIED. NEVER use it for a
87
+ * real access gate — anyone who can make your gate call fail (trivial: induce a
88
+ * timeout) then bypasses it completely. It exists ONLY for soft, non-security
89
+ * personalization where a missing verification must not degrade UX (e.g.
90
+ * optionally showing a "verified" flourish). If the gate protects anything —
91
+ * a route, a mint, an airdrop, a write — use the default `"throw"` or `"deny"`.
82
92
  *
83
93
  * @example
84
94
  * ```ts
package/dist/index.js CHANGED
@@ -70,6 +70,7 @@ import { scopedTldCandidate, namespaceIsActive, NAMESPACE_MAX_LEN, } from "./sco
70
70
  import { encodeBase58, decodeBase58_32 } from "./base58.js";
71
71
  import { DEFAULT_HANDLE_PROGRAM, TOKEN_ACCOUNT_MIN_LEN, bytesEqual, makeAccountReader, nftHolder, parseHandleAccount, readI64, readU64, } from "./accounts.js";
72
72
  import { fetchRecords, liveRecords } from "./records.js";
73
+ import { fetchSubnames, resolveSubnameRecords } from "./subname.js";
73
74
  /** Root authority for each X1NS TLD — used to verify a fetched account really
74
75
  * belongs to the TLD it claims. Without this check a caller handed an
75
76
  * arbitrary account would read an owner straight out of it. */
@@ -79,6 +80,47 @@ const TLD_ROOT = Object.freeze({
79
80
  xen: "3SUwpSz33AsyJwf6B48cKZuDTswuUEdUhcXszZrFWPqo",
80
81
  });
81
82
  const SPL_NAME_HEADER_LEN = 96;
83
+ /**
84
+ * Suffixes that are NEVER a subname parent: the native X1NS namespaces (handled
85
+ * by `parseName` before we ever reach the subname path) and the external TLDs
86
+ * the universal resolver owns. `foo.sol`/`foo.eth` must stay `unrecognized` to
87
+ * the native resolver — never resolved as a subname under `@sol`/`@eth` — so
88
+ * the whole never-conflate-a-namespace rule holds on the money path too.
89
+ */
90
+ const SUBNAME_RESERVED_SUFFIXES = new Set([
91
+ "x1",
92
+ "xnt",
93
+ "xen",
94
+ "sol",
95
+ "eth",
96
+ ]);
97
+ /**
98
+ * Classify `input` as a `label.parent` SUBNAME candidate by SHAPE ONLY (no
99
+ * chain read) — the FIRST interpretation tried for a dotted, non-`@`, non-X1NS
100
+ * name (owner-approved disambiguation order: subname before scoped-TLD). The
101
+ * shape is a single interior dot with non-empty halves, a `parent` that is not
102
+ * a reserved suffix, and a `label` with no further dot (a deeper `a.b.c` is
103
+ * sub-subname territory the one-level program does not support → stays
104
+ * `unrecognized`). Whether `@parent` is actually a registered handle is a chain
105
+ * fact the resolver establishes separately. Both halves are normalized for real
106
+ * at resolve; a parent that cannot be a handle (e.g. > 32 bytes) simply fails
107
+ * the registration check and falls through to the scoped path.
108
+ */
109
+ function subnameCandidate(input) {
110
+ const t = input.trim().toLowerCase();
111
+ if (!t || t.startsWith("@"))
112
+ return null;
113
+ const dot = t.lastIndexOf(".");
114
+ if (dot <= 0 || dot === t.length - 1)
115
+ return null;
116
+ const label = t.slice(0, dot);
117
+ const parent = t.slice(dot + 1);
118
+ if (label.includes("."))
119
+ return null; // a.b.c — a true sub-subname, unsupported
120
+ if (SUBNAME_RESERVED_SUFFIXES.has(parent))
121
+ return null; // never conflate with X1NS / external
122
+ return { label, parent };
123
+ }
82
124
  // `Primary` pointer account (`["primary", owner]` under the registry):
83
125
  // disc(8) | owner(32) | handle(32) | set_at(i64 LE, 8) | bump(1) = 81 bytes
84
126
  const PRIMARY_LEN = 81;
@@ -240,6 +282,15 @@ export function createResolver(config) {
240
282
  if (!scopedDeriver) {
241
283
  throw new ResolveError("not-configured", `.${label} is a launched X1ID TLD, but scoped resolution needs a PDA deriver — install @solana/web3.js and pass scopedHandleDeriver (see makeScopedHandleDeriver)`, input);
242
284
  }
285
+ // `makeScopedHandleDeriver(web3)` is async; a common footgun is passing the
286
+ // un-awaited Promise as `scopedHandleDeriver`, which would otherwise surface
287
+ // a baffling `scopedDeriver.scopedHandleKey is not a function` wrapped as an
288
+ // rpc-error. Detect the Promise/thenable (or any object missing the method)
289
+ // and fail with an actionable message instead.
290
+ if (typeof scopedDeriver.then === "function" ||
291
+ typeof scopedDeriver.scopedHandleKey !== "function") {
292
+ throw new ResolveError("not-configured", "scopedHandleDeriver must be the awaited result of makeScopedHandleDeriver(web3), not the Promise it returns — did you forget `await`?", input);
293
+ }
243
294
  let pda;
244
295
  try {
245
296
  pda = await scopedDeriver.scopedHandleKey(label, name, programBase58);
@@ -290,6 +341,125 @@ export function createResolver(config) {
290
341
  cache.set(`scoped:${label}:${name}:${chain}`, { value, expires: Date.now() + ttl });
291
342
  return value;
292
343
  }
344
+ /**
345
+ * Resolve a `label.parent` SUBNAME (#7375) — a label under a parent
346
+ * `@handle`, e.g. `pay.alice`. Called ONLY for a dotted, non-`@`, non-X1NS
347
+ * input (the native parser's `unrecognized` fall-through), and BEFORE the
348
+ * scoped customer-TLD path, per the owner-approved disambiguation order.
349
+ *
350
+ * Returns `null` — the signal to try the scoped path next — when `@parent`
351
+ * is NOT a registered handle (so `x.y` was never a subname). Once `@parent`
352
+ * IS a registered handle we are COMMITTED to the subname interpretation:
353
+ * every outcome is a `Resolved` or a throw (`not-found` / `no-record-for-chain`),
354
+ * never a fall-through to scoped.
355
+ *
356
+ * A subname has NO owner; it resolves PURELY through its per-chain `Record`s
357
+ * (like an ETH/BTC record on a handle), so EVERY chain — X1/SOL included —
358
+ * comes from a record, never an owner key. Both staleness layers of
359
+ * docs/record-trust.md are enforced via `subname.ts`:
360
+ * 1. `subname.createdAt >= parent.registeredAt` — else the subname was left
361
+ * behind by a PREVIOUS owner of the parent (any transfer/sale/recovery/
362
+ * re-registration bumps `registered_at`): `not-found`, never resolved.
363
+ * 2. `record.updatedAt >= subname.createdAt` — a record from a previous
364
+ * incarnation of the same `["subname", parent, label]` PDA is dropped.
365
+ * The subname account itself is located by the program-scoped
366
+ * `getProgramAccounts` scan (`fetchSubnames`) — scoping the scan to the
367
+ * registry program id IS the ownership check — then matched by canonical
368
+ * label; nothing is hand-derived here (the WASM module has no subname PDA
369
+ * derivation and this package never hand-rolls the on-curve check).
370
+ */
371
+ async function resolveSubname(labelRaw, parentRaw, chain, input) {
372
+ // The parent half must canonicalize as a handle; if it cannot, `@parent`
373
+ // can never be a registered handle → not a subname, try scoped instead.
374
+ let parentCanonical;
375
+ try {
376
+ parentCanonical = normalizeHandle(parentRaw);
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ // The subname label must also canonicalize as a handle label (same rules).
382
+ // If it cannot we cannot build a cache key yet — defer the verdict until
383
+ // after the parent-registration check decides the branch.
384
+ let subLabel = null;
385
+ try {
386
+ subLabel = normalizeHandle(labelRaw);
387
+ }
388
+ catch {
389
+ subLabel = null;
390
+ }
391
+ if (subLabel !== null && ttl > 0) {
392
+ const hit = cache.get(`subname:${parentCanonical}:${subLabel}:${chain}`);
393
+ if (hit && hit.expires > Date.now())
394
+ return hit.value;
395
+ }
396
+ // Is `@parent` a REGISTERED handle? That — and only that — decides whether
397
+ // `x.y` is a subname. `not-found`/`invalid-handle` → not a subname, fall
398
+ // through to scoped; a real rpc-error/malformed account propagates.
399
+ let parent;
400
+ try {
401
+ parent = await fetchHandle(parentCanonical, input);
402
+ }
403
+ catch (e) {
404
+ if (e instanceof ResolveError && (e.code === "not-found" || e.code === "invalid-handle")) {
405
+ return null;
406
+ }
407
+ throw e;
408
+ }
409
+ // COMMITTED to the subname interpretation: `@parent` is a live handle.
410
+ if (subLabel === null) {
411
+ throw new ResolveError("not-found", `"${input.trim()}" is not a subname of @${parentCanonical}`, input);
412
+ }
413
+ const display = `${subLabel}.${parentCanonical}`;
414
+ // Locate the subname account via the program scan (ownership check), then
415
+ // match the canonical label. `fetchSubnames` already flags each with
416
+ // `live` (staleness layer 1: createdAt >= parent.registeredAt).
417
+ const subs = await fetchSubnames(rpc, programBase58, parent.pda, parent.handle.registeredAt);
418
+ const match = subs.find((s) => s.label === subLabel);
419
+ if (!match) {
420
+ throw new ResolveError("not-found", `${display} is not a subname of @${parentCanonical}`, input);
421
+ }
422
+ // Staleness layer 1 — a subname stranded by a parent transfer/re-registration
423
+ // belongs to a PREVIOUS owner and must NEVER resolve to a payment address.
424
+ if (!match.live) {
425
+ throw new ResolveError("not-found", `${display} was created before the current @${parentCanonical} registration — it belongs to a previous owner`, input);
426
+ }
427
+ // Read the subname's LIVE records. `resolveSubnameRecords` re-applies layer
428
+ // 1 (returns [] when stale) AND layer 2 (record.updatedAt >= createdAt),
429
+ // dropping any stale record — the only safe entry point.
430
+ const subname = {
431
+ parent: match.parent,
432
+ label: match.label,
433
+ createdAt: match.createdAt,
434
+ bump: match.bump,
435
+ };
436
+ const records = await resolveSubnameRecords(rpc, programBase58, match.account, subname, parent.handle.registeredAt);
437
+ const record = records.find((r) => !r.stale && r.coinType === CHAIN_COIN_TYPE[chain]);
438
+ if (!record) {
439
+ throw new ResolveError("no-record-for-chain", `${display} has no ${chain} record`, input);
440
+ }
441
+ const value = {
442
+ input,
443
+ name: display,
444
+ // A subname is its own namespace — never "handle" (so a consumer that
445
+ // branches on `=== "handle"`, e.g. dev-api's attestation enrichment, does
446
+ // not treat it as a top-level handle) and never the parent's.
447
+ namespace: "subname",
448
+ address: record.address,
449
+ chain,
450
+ // Like a handle's ETH/BTC record: `unverified` unless the record itself
451
+ // is co-signed/verified (and `verified` is already forced false for any
452
+ // stale record by records.ts).
453
+ verification: record.verified ? "verified" : "unverified",
454
+ };
455
+ if (ttl > 0) {
456
+ cache.set(`subname:${parentCanonical}:${subLabel}:${chain}`, {
457
+ value,
458
+ expires: Date.now() + ttl,
459
+ });
460
+ }
461
+ return value;
462
+ }
293
463
  /** See `Resolver.records`. */
294
464
  async function records(input) {
295
465
  const parsed = parseName(input); // throws with a specific code
@@ -307,12 +477,22 @@ export function createResolver(config) {
307
477
  }
308
478
  catch (e) {
309
479
  // A dotted, non-`@`, non-X1NS name is `unrecognized` to the strict native
310
- // parser — but it MAY be a scoped customer-TLD name `name.tld` (#8484/
311
- // #8485). Try that before giving up; `resolveScoped` itself re-throws the
312
- // same `unrecognized` when `.tld` is not a launched namespace, so
313
- // `.sol`/`.eth`/unknown TLDs behave exactly as before. Any other code
314
- // (ambiguous / invalid-*) is preserved verbatim.
480
+ // parser — but it MAY be a SUBNAME `label.parent` (#7375) or a scoped
481
+ // customer-TLD name `name.tld` (#8484/#8485). Owner-approved
482
+ // disambiguation order: try the subname interpretation FIRST (when
483
+ // `@parent` is a registered handle), then the scoped path. Each re-throws
484
+ // / falls through so `.sol`/`.eth`/unknown TLDs behave exactly as before.
485
+ // Any other code (ambiguous / invalid-*) is preserved verbatim.
315
486
  if (e instanceof ResolveError && e.code === "unrecognized") {
487
+ const sub = subnameCandidate(input);
488
+ if (sub) {
489
+ // `null` ⇒ `@parent` is not a registered handle ⇒ not a subname;
490
+ // fall through to the scoped path. A committed subname path either
491
+ // returns a `Resolved` or throws (never reaches scoped).
492
+ const resolved = await resolveSubname(sub.label, sub.parent, chain, input);
493
+ if (resolved !== null)
494
+ return resolved;
495
+ }
316
496
  const cand = scopedTldCandidate(input);
317
497
  if (cand)
318
498
  return resolveScoped(cand.sub, cand.tld, chain, input);
@@ -402,8 +582,15 @@ export function createResolver(config) {
402
582
  */
403
583
  async function reverse(address) {
404
584
  const owner = decodeBase58_32(address);
405
- if (!owner)
406
- throw new ResolveError("unrecognized", `"${address}" is not an address`, address);
585
+ // `decodeBase58_32` left-pads a short/tiny base58 input into 32 bytes, so a
586
+ // malformed address like "abc" would otherwise slip through as a valid tiny
587
+ // key. Require the canonical round-trip: a malformed address must THROW
588
+ // (matching the dev-api's 400 INVALID_ADDRESS and the docs/drill checklist),
589
+ // reserving the `null` return exclusively for a WELL-FORMED address that
590
+ // genuinely has no primary.
591
+ if (!owner || encodeBase58(owner) !== address) {
592
+ throw new ResolveError("unrecognized", `"${address}" is not a valid X1 address`, address);
593
+ }
407
594
  const handleName = await reverseHandle(owner);
408
595
  if (handleName !== null)
409
596
  return handleName;
package/dist/signin.d.ts CHANGED
@@ -211,9 +211,21 @@ export type SignInVerification = {
211
211
  * wallet signed. Whether that wallet currently owns a handle is a separate,
212
212
  * on-chain question answered by {@link resolveIdentity}.
213
213
  *
214
- * Replay protection is nonce + expiry, and both need the relying party: pass
215
- * `expectedNonce` (and never accept the same nonce twice), and keep the expiry
216
- * window short. The domain binding needs `expectedDomain` to become a check.
214
+ * ⚠️ `expectedDomain` and `expectedNonce` are the anti-phishing and anti-replay
215
+ * checks, and they are OPT-IN. OMITTING THEM DISABLES THAT PROTECTION:
216
+ *
217
+ * - no `expectedDomain` → the domain binding is NOT enforced. A signature a
218
+ * user produced for a phishing site's challenge will verify here. Always pass
219
+ * your own origin's domain in a real login.
220
+ * - no `expectedNonce` → the nonce binding is NOT enforced, so this call cannot
221
+ * tell a fresh proof from a replayed one. Pass the nonce the RP issued, and
222
+ * never accept the same nonce twice (burn it server-side). Keep the expiry
223
+ * window short regardless — expiry alone only bounds the replay window, it
224
+ * does not close it.
225
+ *
226
+ * {@link signInWithX1ID} passes both for you. A bare `verifySignIn` without
227
+ * them emits a one-time dev-mode `console.warn` so the gap is noticed in
228
+ * development; it is silent in production (and whenever `process` is absent).
217
229
  */
218
230
  export declare function verifySignIn(params: VerifySignInParams): Promise<SignInVerification>;
219
231
  /** One live "verified by [platform]" signal on the signed-in handle. */
package/dist/signin.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Resolve @handles and X1NS names on X1. Never silently picks between namespaces.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",