@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 +16 -4
- package/dist/connect.d.ts +174 -0
- package/dist/connect.js +193 -0
- package/dist/gateClient.d.ts +10 -8
- package/dist/gateClient.js +27 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/signin.js +20 -9
- package/package.json +1 -1
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
|
|
370
|
-
`
|
|
371
|
-
the full decision; pass a stored policy's `gp_…` id as
|
|
372
|
-
policy. The hosted check is keyed/metered — see
|
|
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>;
|
package/dist/connect.js
ADDED
|
@@ -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
|
+
}
|
package/dist/gateClient.d.ts
CHANGED
|
@@ -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
|
|
84
|
-
* timeout, 5xx, auth failure, a malformed
|
|
85
|
-
* `passed: true` decision and admits the caller
|
|
86
|
-
*
|
|
87
|
-
* timeout)
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
package/dist/gateClient.js
CHANGED
|
@@ -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
|
|
85
|
-
* timeout, 5xx, auth failure, a malformed
|
|
86
|
-
* `passed: true` decision and admits the caller
|
|
87
|
-
*
|
|
88
|
-
* timeout)
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
|
128
|
-
// makes the anti-challenge-injection intent impossible to
|
|
129
|
-
// charset in DOMAIN_RE is ever loosened.
|
|
130
|
-
|
|
131
|
-
|
|
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 && (
|
|
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 (
|
|
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)) {
|