@x1id/resolve 0.14.0 → 0.15.1

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
@@ -366,10 +366,22 @@ export async function POST(req: Request) { // any server runtime with
366
366
  }
367
367
  ```
368
368
 
369
- Fail-closed by default (re-throws if the decision can't be obtained; pass
370
- `{ onError: "allow" }` to fail-open). `checkGate()` is the lower-level client for
371
- the full decision; pass a stored policy's `gp_…` id as the policy to reuse a named
372
- policy. The hosted check is keyed/metered — see `services/dev-api`'s README.
369
+ Fail-closed by default (re-throws if the decision can't be obtained). `onError:
370
+ "deny"` fails closed without throwing (clean `allowed: false`). `checkGate()` is
371
+ the lower-level client for the full decision; pass a stored policy's `gp_…` id as
372
+ the policy to reuse a named policy. The hosted check is keyed/metered — see
373
+ `services/dev-api`'s README.
374
+
375
+ > **⚠️ Never use `onError: "allow"` on a real gate.** It FAILS OPEN: on *any*
376
+ > error — a network blip, a timeout, a 5xx, an auth failure, a malformed
377
+ > response — it fabricates a `passed: true` decision and admits the caller
378
+ > **unverified**. Anyone who can make your gate call fail (trivially: induce a
379
+ > timeout) then bypasses the gate completely, so it is worse than no gate at all
380
+ > for anything it protects — a route, a mint, an airdrop, a write, an allowlist.
381
+ > It exists ONLY for soft, non-security personalization where a missing
382
+ > verification must not degrade UX (e.g. optionally showing a "verified"
383
+ > flourish). Using it logs a one-time `console.warn`. If the decision gates
384
+ > anything, use the default `"throw"` or `"deny"`.
373
385
 
374
386
  ## What works today
375
387
 
@@ -0,0 +1,174 @@
1
+ /**
2
+ * X1ID Connect — the TRANSACTION-signature sign-in client (#8452, verify-tx).
3
+ *
4
+ * # Why a transaction, not a message
5
+ *
6
+ * The message path ({@link import("./signin.js").signInWithX1ID}) asks the
7
+ * wallet to `signMessage` the challenge bytes. Two real wallets break that on
8
+ * X1:
9
+ * - a Ledger (via Phantom/Backpack) REFUSES off-chain message signing; and
10
+ * - Backpack on X1 returns `signMessage` signatures that DO NOT verify under
11
+ * the wallet's own pubkey, even though its TRANSACTION signatures are valid
12
+ * on-chain.
13
+ *
14
+ * So this flow proves wallet control a different, robust way: the wallet signs a
15
+ * throwaway MEMO TRANSACTION — one SPL-Memo instruction carrying
16
+ * `x1id-verify:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
17
+ * `POST /v1/auth/verify-tx` endpoint deserializes the signed artifact, asserts
18
+ * it is EXACTLY that memo (no disguised transfer/approval), that the fee payer
19
+ * is the wallet, that the memo binds our single-use nonce, and verifies the
20
+ * ed25519 transaction signature. A valid proof mints the same Bearer session the
21
+ * message path does.
22
+ *
23
+ * This is the WALLET-control (no primary `@handle`) path — the app.x1id.io model
24
+ * where the connected wallet IS the identity, with the `@handle` as optional
25
+ * metadata. A handle-gated sign-in keeps the full on-chain control proof.
26
+ *
27
+ * # web3.js is injected, not depended on
28
+ *
29
+ * `@solana/web3.js` is an OPTIONAL peer dependency of this package (see
30
+ * scoped.ts / universalSns.ts). {@link buildVerifyTxChallenge} therefore takes
31
+ * the `{ Transaction, TransactionInstruction, PublicKey }` constructors as an
32
+ * injected {@link VerifyTxWeb3} rather than importing them — the SDK core stays
33
+ * free of a hard web3 dependency and tests can supply a fake.
34
+ */
35
+ /** Memo prefix the verify-tx proof binds to. The signed memo instruction's
36
+ * UTF-8 data MUST be EXACTLY `x1id-verify:<nonce>` — the server rejects any
37
+ * other data, which is what kills replay of an unrelated signed transaction. */
38
+ export declare const VERIFY_TX_MEMO_PREFIX: "x1id-verify:";
39
+ /** Canonical SPL Memo program id — the program the challenge memo rides on. */
40
+ export declare const MEMO_PROGRAM_ID: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
41
+ /** The exact memo string for a given challenge nonce. */
42
+ export declare function verifyTxMemo(nonce: string): string;
43
+ /** The slice of a web3.js `PublicKey` used here. */
44
+ export interface PublicKeyInstance {
45
+ toBytes(): Uint8Array;
46
+ toBase58(): string;
47
+ }
48
+ export interface PublicKeyCtor {
49
+ new (value: string | Uint8Array): PublicKeyInstance;
50
+ }
51
+ /** The slice of a web3.js `TransactionInstruction` constructor used here. */
52
+ export interface TransactionInstructionCtor {
53
+ new (args: {
54
+ keys: ReadonlyArray<{
55
+ pubkey: PublicKeyInstance;
56
+ isSigner: boolean;
57
+ isWritable: boolean;
58
+ }>;
59
+ programId: PublicKeyInstance;
60
+ data: Uint8Array;
61
+ }): unknown;
62
+ }
63
+ /** The slice of a web3.js legacy `Transaction` instance used here. */
64
+ export interface TransactionInstance {
65
+ add(instruction: unknown): TransactionInstance;
66
+ feePayer: PublicKeyInstance | null | undefined;
67
+ recentBlockhash: string | undefined;
68
+ /** Serialize the fully-signed transaction to raw bytes. */
69
+ serialize(options?: unknown): Uint8Array;
70
+ }
71
+ export interface TransactionCtor {
72
+ new (): TransactionInstance;
73
+ }
74
+ /** The three web3.js constructors {@link buildVerifyTxChallenge} needs. Pass the
75
+ * module (`import * as web3 from "@solana/web3.js"`) or a `{ Transaction,
76
+ * TransactionInstruction, PublicKey }` subset — exactly as the scoped deriver
77
+ * takes `web3`. */
78
+ export interface VerifyTxWeb3 {
79
+ readonly Transaction: TransactionCtor;
80
+ readonly TransactionInstruction: TransactionInstructionCtor;
81
+ readonly PublicKey: PublicKeyCtor;
82
+ }
83
+ /** 32 zero-bytes in base58 — a structurally valid blockhash. The transaction is
84
+ * NEVER broadcast, so a placeholder blockhash is fine; a wallet that insists on
85
+ * a fresh one can pass its own in {@link BuildVerifyTxChallengeParams.blockhash}. */
86
+ export declare const PLACEHOLDER_BLOCKHASH: "11111111111111111111111111111111";
87
+ export interface BuildVerifyTxChallengeParams {
88
+ /** The single-use nonce from the tx-mode challenge. */
89
+ readonly nonce: string;
90
+ /** The memo the server returned (`x1id-verify:<nonce>`). Optional — derived
91
+ * from `nonce` when omitted; when supplied it MUST equal that, or the build
92
+ * throws (catches a client that paired the wrong nonce/memo). */
93
+ readonly memo?: string;
94
+ /** The signing wallet, base58 — becomes the fee payer AND the memo signer. */
95
+ readonly feePayer: string;
96
+ /** A recent blockhash. Defaults to {@link PLACEHOLDER_BLOCKHASH} (the tx is
97
+ * never submitted). */
98
+ readonly blockhash?: string;
99
+ }
100
+ /**
101
+ * Build the memo-only challenge transaction the wallet signs for verify-tx.
102
+ *
103
+ * The result is a legacy `Transaction` with EXACTLY one instruction: an SPL-Memo
104
+ * instruction whose data is the UTF-8 bytes of `x1id-verify:<nonce>`, with the
105
+ * fee payer listed as a required signer on that instruction. Nothing else — no
106
+ * transfer, no compute-budget, no durable nonce — so the signed artifact can
107
+ * only ever be a sign-in proof, never a disguised value-moving transaction. The
108
+ * server enforces the same shape on receipt.
109
+ */
110
+ export declare function buildVerifyTxChallenge(web3: VerifyTxWeb3, params: BuildVerifyTxChallengeParams): TransactionInstance;
111
+ /** Default hosted X1ID /v1 base URL. */
112
+ export declare const DEFAULT_CONNECT_BASE_URL = "https://api.x1id.io";
113
+ export interface ConnectClientConfig {
114
+ /** Your X1ID API key (`x1id_…` / `x1idlive_…`) — identifies the dapp. */
115
+ readonly apiKey: string;
116
+ /** Base URL of the /v1 API. Defaults to {@link DEFAULT_CONNECT_BASE_URL}. */
117
+ readonly baseUrl?: string;
118
+ /** Inject a `fetch` (tests, non-global-fetch runtimes). */
119
+ readonly fetchImpl?: typeof fetch;
120
+ /** Per-request timeout in ms (default 15_000). */
121
+ readonly timeoutMs?: number;
122
+ }
123
+ /** The Bearer session verify-tx mints — identical shape to the message path. */
124
+ export interface ConnectSession {
125
+ readonly token: string;
126
+ readonly token_type: string;
127
+ readonly expires_at: number;
128
+ readonly handle: string | null;
129
+ readonly address: string;
130
+ readonly verified: readonly string[];
131
+ readonly source: string;
132
+ readonly proves: string;
133
+ readonly disclaimer: string;
134
+ }
135
+ /** The tx-mode challenge response (the fields this flow uses). */
136
+ export interface TxChallenge {
137
+ readonly nonce: string;
138
+ readonly memo: string;
139
+ readonly handle: string | null;
140
+ readonly expires_at: number;
141
+ }
142
+ /** Thrown when sign-in could not complete (network, auth, a typed service
143
+ * error, a wallet failure, or tx-mode being unavailable for this wallet). */
144
+ export declare class ConnectError extends Error {
145
+ readonly code: string;
146
+ readonly status?: number | undefined;
147
+ constructor(code: string, message: string, status?: number | undefined);
148
+ }
149
+ export interface SigninWithTxParams {
150
+ /** Injected web3.js constructors (see {@link VerifyTxWeb3}). */
151
+ readonly web3: VerifyTxWeb3;
152
+ /** The connected wallet's address, base58 — the fee payer / identity. */
153
+ readonly wallet: string;
154
+ /**
155
+ * The wallet's transaction signer. Takes the memo transaction built here and
156
+ * returns it fully signed (the wallet's `signTransaction`). The returned
157
+ * object must `serialize()` to the signed transaction bytes.
158
+ */
159
+ readonly signTransaction: (tx: TransactionInstance) => Promise<TransactionInstance>;
160
+ /** Optional recent blockhash; defaults to {@link PLACEHOLDER_BLOCKHASH}. */
161
+ readonly blockhash?: string;
162
+ }
163
+ /**
164
+ * Sign in with a transaction-signature proof of wallet control.
165
+ *
166
+ * Requests a tx-mode challenge, builds the memo transaction, has the caller's
167
+ * wallet sign it, and posts the signed artifact to `POST /v1/auth/verify-tx`,
168
+ * returning the minted session. Throws {@link ConnectError} on any failure.
169
+ *
170
+ * The wallet must have NO primary `@handle` for the tx path (it proves wallet
171
+ * control only); if it has one, the challenge comes back without a memo and this
172
+ * throws `TX_MODE_UNAVAILABLE` (use the handle-gated message path instead).
173
+ */
174
+ export declare function signinWithTx(cfg: ConnectClientConfig, params: SigninWithTxParams): Promise<ConnectSession>;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * X1ID Connect — the TRANSACTION-signature sign-in client (#8452, verify-tx).
3
+ *
4
+ * # Why a transaction, not a message
5
+ *
6
+ * The message path ({@link import("./signin.js").signInWithX1ID}) asks the
7
+ * wallet to `signMessage` the challenge bytes. Two real wallets break that on
8
+ * X1:
9
+ * - a Ledger (via Phantom/Backpack) REFUSES off-chain message signing; and
10
+ * - Backpack on X1 returns `signMessage` signatures that DO NOT verify under
11
+ * the wallet's own pubkey, even though its TRANSACTION signatures are valid
12
+ * on-chain.
13
+ *
14
+ * So this flow proves wallet control a different, robust way: the wallet signs a
15
+ * throwaway MEMO TRANSACTION — one SPL-Memo instruction carrying
16
+ * `x1id-verify:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
17
+ * `POST /v1/auth/verify-tx` endpoint deserializes the signed artifact, asserts
18
+ * it is EXACTLY that memo (no disguised transfer/approval), that the fee payer
19
+ * is the wallet, that the memo binds our single-use nonce, and verifies the
20
+ * ed25519 transaction signature. A valid proof mints the same Bearer session the
21
+ * message path does.
22
+ *
23
+ * This is the WALLET-control (no primary `@handle`) path — the app.x1id.io model
24
+ * where the connected wallet IS the identity, with the `@handle` as optional
25
+ * metadata. A handle-gated sign-in keeps the full on-chain control proof.
26
+ *
27
+ * # web3.js is injected, not depended on
28
+ *
29
+ * `@solana/web3.js` is an OPTIONAL peer dependency of this package (see
30
+ * scoped.ts / universalSns.ts). {@link buildVerifyTxChallenge} therefore takes
31
+ * the `{ Transaction, TransactionInstruction, PublicKey }` constructors as an
32
+ * injected {@link VerifyTxWeb3} rather than importing them — the SDK core stays
33
+ * free of a hard web3 dependency and tests can supply a fake.
34
+ */
35
+ /** Memo prefix the verify-tx proof binds to. The signed memo instruction's
36
+ * UTF-8 data MUST be EXACTLY `x1id-verify:<nonce>` — the server rejects any
37
+ * other data, which is what kills replay of an unrelated signed transaction. */
38
+ export const VERIFY_TX_MEMO_PREFIX = "x1id-verify:";
39
+ /** Canonical SPL Memo program id — the program the challenge memo rides on. */
40
+ export const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
41
+ /** The exact memo string for a given challenge nonce. */
42
+ export function verifyTxMemo(nonce) {
43
+ return VERIFY_TX_MEMO_PREFIX + nonce;
44
+ }
45
+ /** 32 zero-bytes in base58 — a structurally valid blockhash. The transaction is
46
+ * NEVER broadcast, so a placeholder blockhash is fine; a wallet that insists on
47
+ * a fresh one can pass its own in {@link BuildVerifyTxChallengeParams.blockhash}. */
48
+ export const PLACEHOLDER_BLOCKHASH = "11111111111111111111111111111111";
49
+ /**
50
+ * Build the memo-only challenge transaction the wallet signs for verify-tx.
51
+ *
52
+ * The result is a legacy `Transaction` with EXACTLY one instruction: an SPL-Memo
53
+ * instruction whose data is the UTF-8 bytes of `x1id-verify:<nonce>`, with the
54
+ * fee payer listed as a required signer on that instruction. Nothing else — no
55
+ * transfer, no compute-budget, no durable nonce — so the signed artifact can
56
+ * only ever be a sign-in proof, never a disguised value-moving transaction. The
57
+ * server enforces the same shape on receipt.
58
+ */
59
+ export function buildVerifyTxChallenge(web3, params) {
60
+ const { nonce, feePayer, blockhash } = params;
61
+ if (typeof nonce !== "string" || nonce.length === 0)
62
+ throw new Error("nonce is required");
63
+ if (typeof feePayer !== "string" || feePayer.length === 0)
64
+ throw new Error("feePayer is required");
65
+ const memo = params.memo ?? verifyTxMemo(nonce);
66
+ if (memo !== verifyTxMemo(nonce)) {
67
+ throw new Error(`memo must be exactly "${verifyTxMemo(nonce)}" for this nonce`);
68
+ }
69
+ const payer = new web3.PublicKey(feePayer);
70
+ const memoProgram = new web3.PublicKey(MEMO_PROGRAM_ID);
71
+ const ix = new web3.TransactionInstruction({
72
+ // The signer must appear in the instruction keys for the memo to be a
73
+ // "signed memo" and for the wallet to require THIS key's signature.
74
+ keys: [{ pubkey: payer, isSigner: true, isWritable: false }],
75
+ programId: memoProgram,
76
+ data: new TextEncoder().encode(memo),
77
+ });
78
+ const tx = new web3.Transaction();
79
+ tx.add(ix);
80
+ tx.feePayer = payer;
81
+ tx.recentBlockhash = blockhash ?? PLACEHOLDER_BLOCKHASH;
82
+ return tx;
83
+ }
84
+ // -----------------------------------------------------------------------------
85
+ // signinWithTx — one call: tx-mode challenge -> sign -> verify-tx -> session
86
+ // -----------------------------------------------------------------------------
87
+ /** Default hosted X1ID /v1 base URL. */
88
+ export const DEFAULT_CONNECT_BASE_URL = "https://api.x1id.io";
89
+ /** Thrown when sign-in could not complete (network, auth, a typed service
90
+ * error, a wallet failure, or tx-mode being unavailable for this wallet). */
91
+ export class ConnectError extends Error {
92
+ code;
93
+ status;
94
+ constructor(code, message, status) {
95
+ super(message);
96
+ this.code = code;
97
+ this.status = status;
98
+ this.name = "ConnectError";
99
+ }
100
+ }
101
+ function baseUrlOf(cfg) {
102
+ return (cfg.baseUrl ?? DEFAULT_CONNECT_BASE_URL).replace(/\/+$/, "");
103
+ }
104
+ function bytesToBase64(bytes) {
105
+ let bin = "";
106
+ const CHUNK = 0x8000;
107
+ for (let i = 0; i < bytes.length; i += CHUNK) {
108
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
109
+ }
110
+ // `btoa` is available in browsers and Node 18+ (the SDK's floor). register.ts
111
+ // already relies on the sibling `atob`.
112
+ return btoa(bin);
113
+ }
114
+ async function postJson(cfg, path, body) {
115
+ const doFetch = cfg.fetchImpl ?? globalThis.fetch;
116
+ if (typeof doFetch !== "function")
117
+ throw new ConnectError("NO_FETCH", "no fetch available; pass fetchImpl in the config");
118
+ if (!cfg.apiKey)
119
+ throw new ConnectError("NO_API_KEY", "an X1ID apiKey is required");
120
+ const controller = new AbortController();
121
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 15_000);
122
+ let res;
123
+ try {
124
+ res = await doFetch(`${baseUrlOf(cfg)}${path}`, {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json", "x-api-key": cfg.apiKey },
127
+ body: JSON.stringify(body),
128
+ signal: controller.signal,
129
+ });
130
+ }
131
+ catch (e) {
132
+ throw new ConnectError("REQUEST_FAILED", `request to ${path} failed: ${e instanceof Error ? e.message : String(e)}`);
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ }
137
+ let parsed;
138
+ try {
139
+ parsed = await res.json();
140
+ }
141
+ catch {
142
+ throw new ConnectError("BAD_RESPONSE", `${path} returned non-JSON (HTTP ${res.status})`, res.status);
143
+ }
144
+ if (!res.ok) {
145
+ const b = (parsed ?? {});
146
+ throw new ConnectError(b.code ?? "HTTP_ERROR", b.message ?? `${path} returned HTTP ${res.status}`, res.status);
147
+ }
148
+ return parsed;
149
+ }
150
+ /**
151
+ * Sign in with a transaction-signature proof of wallet control.
152
+ *
153
+ * Requests a tx-mode challenge, builds the memo transaction, has the caller's
154
+ * wallet sign it, and posts the signed artifact to `POST /v1/auth/verify-tx`,
155
+ * returning the minted session. Throws {@link ConnectError} on any failure.
156
+ *
157
+ * The wallet must have NO primary `@handle` for the tx path (it proves wallet
158
+ * control only); if it has one, the challenge comes back without a memo and this
159
+ * throws `TX_MODE_UNAVAILABLE` (use the handle-gated message path instead).
160
+ */
161
+ export async function signinWithTx(cfg, params) {
162
+ const challenge = await postJson(cfg, "/v1/auth/challenge", {
163
+ wallet: params.wallet,
164
+ mode: "tx",
165
+ });
166
+ if (!challenge.memo || !challenge.nonce) {
167
+ throw new ConnectError("TX_MODE_UNAVAILABLE", "the transaction sign-in path is for a wallet with no primary @handle; this wallet returned no tx memo");
168
+ }
169
+ const tx = buildVerifyTxChallenge(params.web3, {
170
+ nonce: challenge.nonce,
171
+ memo: challenge.memo,
172
+ feePayer: params.wallet,
173
+ ...(params.blockhash !== undefined ? { blockhash: params.blockhash } : {}),
174
+ });
175
+ let signed;
176
+ try {
177
+ signed = await params.signTransaction(tx);
178
+ }
179
+ catch (e) {
180
+ throw new ConnectError("WALLET_ERROR", `wallet failed to sign the challenge transaction: ${String(e)}`, undefined);
181
+ }
182
+ let signedTransaction;
183
+ try {
184
+ signedTransaction = bytesToBase64(signed.serialize());
185
+ }
186
+ catch (e) {
187
+ throw new ConnectError("WALLET_ERROR", `could not serialize the signed transaction: ${String(e)}`);
188
+ }
189
+ return postJson(cfg, "/v1/auth/verify-tx", {
190
+ nonce: challenge.nonce,
191
+ signedTransaction,
192
+ });
193
+ }
@@ -80,14 +80,16 @@ export interface GuardResult {
80
80
  * silently admitting an unverified caller. `onError: "deny"` instead resolves to
81
81
  * a clean `allowed: false` (fail-closed without a throw).
82
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"`.
83
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN, and is NEVER acceptable on a real
84
+ * gate. On ANY error (network blip, timeout, 5xx, auth failure, a malformed
85
+ * response) it fabricates a `passed: true` decision and admits the caller
86
+ * UNVERIFIED — so anyone who can make your gate call fail (trivial: induce a
87
+ * timeout) bypasses it completely. That makes it strictly WORSE than no gate for
88
+ * anything it is supposed to protect: a route, a mint, an airdrop, a write, an
89
+ * allowlist. It exists ONLY for soft, non-security personalization where a
90
+ * missing verification must not degrade UX (e.g. optionally showing a "verified"
91
+ * flourish). Constructing a guard with it logs a one-time `console.warn`. If the
92
+ * decision gates ANYTHING, use the default `"throw"` or `"deny"` (fail-closed).
91
93
  *
92
94
  * @example
93
95
  * ```ts
@@ -81,14 +81,16 @@ export async function checkGate(cfg, subject, policy) {
81
81
  * silently admitting an unverified caller. `onError: "deny"` instead resolves to
82
82
  * a clean `allowed: false` (fail-closed without a throw).
83
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"`.
84
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN, and is NEVER acceptable on a real
85
+ * gate. On ANY error (network blip, timeout, 5xx, auth failure, a malformed
86
+ * response) it fabricates a `passed: true` decision and admits the caller
87
+ * UNVERIFIED — so anyone who can make your gate call fail (trivial: induce a
88
+ * timeout) bypasses it completely. That makes it strictly WORSE than no gate for
89
+ * anything it is supposed to protect: a route, a mint, an airdrop, a write, an
90
+ * allowlist. It exists ONLY for soft, non-security personalization where a
91
+ * missing verification must not degrade UX (e.g. optionally showing a "verified"
92
+ * flourish). Constructing a guard with it logs a one-time `console.warn`. If the
93
+ * decision gates ANYTHING, use the default `"throw"` or `"deny"` (fail-closed).
92
94
  *
93
95
  * @example
94
96
  * ```ts
@@ -106,6 +108,10 @@ export async function checkGate(cfg, subject, policy) {
106
108
  */
107
109
  export function requireGate(cfg, policy, opts = {}) {
108
110
  const onError = opts.onError ?? "throw";
111
+ // Warn HARD (once) the moment a fail-open guard is built — `onError: "allow"`
112
+ // admits unverified callers on any error and must never guard anything real.
113
+ if (onError === "allow")
114
+ warnFailOpenOnce();
109
115
  return async (subject) => {
110
116
  try {
111
117
  const decision = await checkGate(cfg, subject, policy);
@@ -126,3 +132,16 @@ export function requireGate(cfg, policy, opts = {}) {
126
132
  }
127
133
  };
128
134
  }
135
+ /** Process-wide latch so the fail-open warning fires at most once, not on every
136
+ * guard construction. */
137
+ let failOpenWarned = false;
138
+ function warnFailOpenOnce() {
139
+ if (failOpenWarned)
140
+ return;
141
+ failOpenWarned = true;
142
+ // eslint-disable-next-line no-console
143
+ console.warn("[@x1id/resolve] requireGate({ onError: \"allow\" }) FAILS OPEN: on any gate-call " +
144
+ "error it admits the caller UNVERIFIED. Never use it to protect a route, mint, " +
145
+ "airdrop, write, or allowlist — use the default \"throw\" or \"deny\" instead. " +
146
+ "(This warning is for soft personalization-only uses; it logs once.)");
147
+ }
package/dist/index.d.ts CHANGED
@@ -51,6 +51,7 @@ export * from "./clearRecords.js";
51
51
  export * from "./commitReveal.js";
52
52
  export * from "./pnftTransfer.js";
53
53
  export * from "./signin.js";
54
+ export * from "./connect.js";
54
55
  export * from "./domainProof.js";
55
56
  export { scopedTldCandidate, namespaceIsActive, makeScopedHandleDeriver, type ScopedCandidate, type ScopedHandleDeriver, } from "./scoped.js";
56
57
  import { type Chain, type Resolved } from "./types.js";
package/dist/index.js CHANGED
@@ -58,6 +58,7 @@ export * from "./clearRecords.js";
58
58
  export * from "./commitReveal.js";
59
59
  export * from "./pnftTransfer.js";
60
60
  export * from "./signin.js";
61
+ export * from "./connect.js";
61
62
  export * from "./domainProof.js";
62
63
  // Named (not `export *`): `NAMESPACE_MAX_LEN` is already exported by
63
64
  // adminMarket.js, and two `export *` sharing a name would make it ambiguous
package/dist/signin.js CHANGED
@@ -78,6 +78,14 @@ export class SignInError extends Error {
78
78
  * belt-and-suspenders CR/LF guard in {@link createSignInChallenge} mirrors the
79
79
  * `uri`/`statement` fields and is the real anti-injection control. */
80
80
  const DOMAIN_RE = /^[A-Za-z0-9.-]{1,253}(:\d{1,5})?$/;
81
+ /** Any character a line-oriented parser could mistake for a boundary: every C0
82
+ * control (so `\n`, `\r`, `\t`, and the rest), DEL, and — the trap the old
83
+ * `\n`/`\r`-only guard missed — the Unicode line/paragraph separators
84
+ * U+2028/U+2029, which `^`/`$` under the regex `m` flag honor just like `\n`
85
+ * (see {@link parseSignInChallenge}'s field parser). FORBIDDEN in every value
86
+ * interpolated into a challenge line (domain, statement, uri) so a crafted
87
+ * value can never forge a second line — challenge-line injection (audit L5). */
88
+ const FORBIDDEN_LINE_CHAR_RE = /[\u0000-\u001f\u007f\u2028\u2029]/;
81
89
  /** Nonce charset — ASCII alphanumeric, matching the rest of the SDK's nonces so
82
90
  * it can never contain a separator or a newline. SIWE requires >= 8 chars. */
83
91
  const NONCE_RE = /^[0-9A-Za-z]{8,128}$/;
@@ -124,11 +132,14 @@ export function generateSignInNonce(bytes = 16) {
124
132
  */
125
133
  export function createSignInChallenge(params) {
126
134
  const { domain } = params;
127
- // Explicit CR/LF guard first — mirrors the `uri`/`statement` fields below and
128
- // makes the anti-challenge-injection intent impossible to regress even if the
129
- // charset in DOMAIN_RE is ever loosened.
130
- if (typeof domain !== "string" || domain.includes("\n") || domain.includes("\r") || !DOMAIN_RE.test(domain)) {
131
- throw new SignInError("invalid-params", "domain must be a single DNS host (letters, digits, '.', '-'), no newlines");
135
+ // Explicit control/line-terminator guard first — mirrors the `uri`/`statement`
136
+ // fields below and makes the anti-challenge-injection intent impossible to
137
+ // regress even if the charset in DOMAIN_RE is ever loosened. DOMAIN_RE already
138
+ // admits only `[A-Za-z0-9.-]` (+ optional `:port`), so this is belt-and-
139
+ // suspenders — but it is the control that catches EVERY forbidden boundary
140
+ // char (incl. `\t`, U+2028/U+2029), not just `\n`/`\r` (audit L5).
141
+ if (typeof domain !== "string" || FORBIDDEN_LINE_CHAR_RE.test(domain) || !DOMAIN_RE.test(domain)) {
142
+ throw new SignInError("invalid-params", "domain must be a single DNS host (letters, digits, '.', '-'), no control characters");
132
143
  }
133
144
  if (!decodeBase58_32(params.address)) {
134
145
  throw new SignInError("invalid-params", `"${params.address}" is not a valid X1 address`);
@@ -137,12 +148,12 @@ export function createSignInChallenge(params) {
137
148
  // the signer and the verifier both compare against.
138
149
  const address = encodeBase58(decodeBase58_32(params.address));
139
150
  const statement = params.statement ?? null;
140
- if (statement !== null && (statement.includes("\n") || statement.includes("\r") || statement.length === 0)) {
141
- throw new SignInError("invalid-params", "statement must be a single non-empty line");
151
+ if (statement !== null && (FORBIDDEN_LINE_CHAR_RE.test(statement) || statement.length === 0)) {
152
+ throw new SignInError("invalid-params", "statement must be a single non-empty line (no control characters)");
142
153
  }
143
154
  const uri = params.uri ?? `https://${domain}`;
144
- if (uri.includes("\n") || uri.includes("\r")) {
145
- throw new SignInError("invalid-params", "uri must be a single line");
155
+ if (FORBIDDEN_LINE_CHAR_RE.test(uri)) {
156
+ throw new SignInError("invalid-params", "uri must be a single line (no control characters)");
146
157
  }
147
158
  const nonce = params.nonce ?? generateSignInNonce();
148
159
  if (!NONCE_RE.test(nonce)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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",