From 10a9d5870fc06cd083ddcdbd8e8f3b44c41ce969 Mon Sep 17 00:00:00 2001 From: hundredark Date: Thu, 5 Nov 2026 14:37:14 +0800 Subject: [PATCH 1/2] should request with signature --- README.md | 9 +- README.zh-CN.md | 9 +- example/scheme.js | 3 +- package-lock.json | 534 +++++++++++++++++++++------ package.json | 5 +- src/blaze/client.ts | 2 +- src/blaze/utils.ts | 5 +- src/client/address.ts | 2 +- src/client/app.ts | 3 +- src/client/collectible.ts | 2 +- src/client/conversation.ts | 3 +- src/client/http.ts | 16 +- src/client/message.ts | 3 +- src/client/mixin-client.ts | 4 +- src/client/multisig.ts | 2 +- src/client/network.ts | 2 +- src/client/pin.ts | 2 +- src/client/safe.ts | 2 +- src/client/transfer.ts | 2 +- src/client/types/client.ts | 5 +- src/client/types/keystore.ts | 15 +- src/client/utils/auth.ts | 10 +- src/client/utils/client.ts | 8 +- test/blaze.test.ts | 6 +- test/crypto.ts | 2 +- test/mixin/auth.test.ts | 2 +- test/mixin/client-utils.test.ts | 65 +++- test/mixin/code.test.ts | 3 +- test/mixin/http.test.ts | 71 +++- test/mixin/inscription-utils.test.ts | 6 +- test/mixin/message.test.ts | 8 - test/mixin/network.test.ts | 3 +- test/mixin/safe.test.ts | 2 +- test/tsconfig.json | 10 + test/webview.test.ts | 4 +- tsconfig.json | 3 +- 36 files changed, 646 insertions(+), 187 deletions(-) create mode 100644 test/tsconfig.json diff --git a/README.md b/README.md index f1f08299..fa5485ec 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ async function getMe() { } ``` +Every Mixin API request requires a keystore, including network queries and OAuth token exchange. `MixinApi({ keystore })` and individual clients require this argument; an Authorization header cannot replace it. Requests are signed automatically. + +Use `SessionKeystore` for signing credentials: `app_id`, `session_private_key`, and an optional `session_id`. Before login, a session may not have been issued, so its JWT `sid` is an empty string, matching the Go client. After OAuth login, use the existing `OAuthKeystore` with `authorization_id` and `scope`. `AppKeystore` and `NetworkUserKeystore` retain their additional PIN and Safe fields. + 2. Receive Mixin Messenger messages ```js @@ -81,7 +85,10 @@ const client_secret = ''; // OAuth Client Secret of your bot const { seed, publicKey } = getED25519KeyPair(); // Generate random seed and ed25519 key pairs -let client = MixinApi(); +// Sign the token exchange with the newly generated key before a session exists. +let client = MixinApi({ + keystore: { app_id, session_private_key: Buffer.from(seed).toString('hex') }, +}); const { scope, authorization_id } = await client.oauth.getToken({ client_id: app_id, code, diff --git a/README.zh-CN.md b/README.zh-CN.md index 6e5ad578..b7f64fc9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,6 +44,10 @@ async function getMe() { } ``` +所有 Mixin API 请求(包括网络查询和 OAuth token 交换)都必须传入 keystore。`MixinApi({ keystore })` 和独立客户端均要求该参数,Authorization 请求头不能代替 keystore。SDK 会自动签名请求。 + +请求签名使用 `SessionKeystore`:包含 `app_id`、`session_private_key` 和可选的 `session_id`。登录前尚未获取 session 时,JWT 的 `sid` 为空字符串,与 Go 客户端保持一致。OAuth 登录后使用已有的 `OAuthKeystore`,传入 `authorization_id` 和 `scope`。`AppKeystore` 和 `NetworkUserKeystore` 继续保留 PIN、Safe 操作需要的额外字段。 + 2. 使用 Mixin 的消息功能 ```js @@ -79,7 +83,10 @@ const client_secret = ''; // OAuth Client Secret of your bot const { seed, publicKey } = getED25519KeyPair(); // Generate random seed and ed25519 key pairs -let client = MixinApi(); +// 登录前使用刚生成的私钥为 token 交换请求签名,此时还没有 session。 +let client = MixinApi({ + keystore: { app_id, session_private_key: Buffer.from(seed).toString('hex') }, +}); const { scope, authorization_id } = await client.oauth.getToken({ client_id: app_id, code, diff --git a/example/scheme.js b/example/scheme.js index 69bcba91..d7f93a20 100644 --- a/example/scheme.js +++ b/example/scheme.js @@ -1,9 +1,10 @@ const { MixinApi } = require('../dist'); +const keystore = require('../keystore.json'); const main = async () => { const url = 'https://mixin.one/pay/MINAAAjAgICFJ4rl_85SKOyVF61rP1FStO-4jqB1EYukCoi2unvif8CbkgvwojeRRWr4UUrEd6-Z2YVLAszVTjvnsXK6X4pRyoKMC4wMDAwMDAwMQAJZXh0cmEgb25lAI7oV9hB2EfktLEwsvTmHQmWXlxuQ0w_qbeAxQ9DzZVcCjAuMDAwMDAwMDEACWV4dHJhIHR3bwEBAFguXmU'; - const client = MixinApi(); + const client = MixinApi({ keystore }); const resp = await client.code.schemes(url); console.log(`https://mixin.one/schemes/${resp.scheme_id}`); }; diff --git a/package-lock.json b/package-lock.json index c14a2ff5..e2f7f1f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,9 @@ "@size-limit/esbuild": "^12.1.0", "@size-limit/esbuild-why": "^12.1.0", "@size-limit/preset-small-lib": "^12.1.0", + "@types/jest": "^30.0.0", "@types/md5": "^2.3.6", + "@types/mocha": "^10.0.10", "@types/node": "^25.9.5", "@types/node-forge": "^1.3.14", "@types/pako": "^2.0.4", @@ -1019,6 +1021,101 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jest/diff-sequences": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", + "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1101,9 +1198,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1257,9 +1351,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1277,9 +1368,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1297,9 +1385,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1317,9 +1402,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1337,9 +1419,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1357,9 +1436,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1377,9 +1453,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1616,9 +1689,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1636,9 +1706,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1656,9 +1723,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1676,9 +1740,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1696,9 +1757,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1716,9 +1774,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2017,9 +2072,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2034,9 +2086,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2051,9 +2100,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2068,9 +2114,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2085,9 +2128,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2102,9 +2142,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2119,9 +2156,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2136,9 +2170,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2153,9 +2184,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2170,9 +2198,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2187,9 +2212,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2204,9 +2226,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2221,9 +2240,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2314,6 +2330,13 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, "node_modules/@size-limit/esbuild": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/@size-limit/esbuild/-/esbuild-12.1.0.tgz", @@ -2483,9 +2506,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2503,9 +2523,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2523,9 +2540,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2543,9 +2557,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2563,9 +2574,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2583,9 +2591,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2705,6 +2710,44 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2719,6 +2762,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", @@ -2760,6 +2810,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2770,6 +2827,23 @@ "@types/node": "*" } }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.69.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", @@ -3519,6 +3593,23 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -3528,6 +3619,22 @@ "node": "*" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -4197,6 +4304,24 @@ "node": ">=0.10.0" } }, + "node_modules/expect": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4450,6 +4575,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4800,6 +4932,136 @@ "node": ">=8" } }, + "node_modules/jest-diff": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", + "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.2.tgz", + "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "jest-diff": "30.5.2", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.1", + "picomatch": "^4.0.3", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/jest-message-util/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/jest-message-util/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", + "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -5039,9 +5301,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5063,9 +5322,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5087,9 +5343,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5111,9 +5364,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5649,6 +5899,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -5991,6 +6270,16 @@ } } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6011,6 +6300,29 @@ "node": ">=0.10.0" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index 2644cb71..37ff82a6 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "start": "bunchee --watch", "build": "rm -rf dist && bunchee", "build:umd": "bunchee --runtime browser --format umd --output dist/mixin-node-sdk.umd.js", - "test": "vitest run", + "test": "npm run test:types && vitest run", + "test:types": "tsc --project test/tsconfig.json", "test:coverage": "vitest run --coverage", "test:integration": "MIXIN_INTEGRATION_TESTS=1 vitest run", "test:watch": "vitest", @@ -76,7 +77,9 @@ "@size-limit/esbuild": "^12.1.0", "@size-limit/esbuild-why": "^12.1.0", "@size-limit/preset-small-lib": "^12.1.0", + "@types/jest": "^30.0.0", "@types/md5": "^2.3.6", + "@types/mocha": "^10.0.10", "@types/node": "^25.9.5", "@types/node-forge": "^1.3.14", "@types/pako": "^2.0.4", diff --git a/src/blaze/client.ts b/src/blaze/client.ts index 6a1535cc..c0fe9b30 100644 --- a/src/blaze/client.ts +++ b/src/blaze/client.ts @@ -4,7 +4,7 @@ import type Keystore from '../client/types/keystore'; import type { BlazeOptions, BlazeHandler } from './type'; import { websocket, sendRaw } from './utils'; -export const BlazeKeystoreClient = (keystore: Keystore | undefined, wsOptions: BlazeOptions | undefined) => { +export const BlazeKeystoreClient = (keystore: Keystore, wsOptions: BlazeOptions | undefined) => { const url = 'wss://blaze.mixin.one'; let ws: WebSocket | undefined; diff --git a/src/blaze/utils.ts b/src/blaze/utils.ts index bfa0d3b7..3dd2762d 100644 --- a/src/blaze/utils.ts +++ b/src/blaze/utils.ts @@ -6,7 +6,7 @@ import type { MessageView, BlazeHandler, BlazeOptions, BlazeMessage } from './ty import { signAccessToken } from '../client/utils/auth'; export function websocket( - keystore: Keystore | undefined, + keystore: Keystore, url: string, handler: BlazeHandler, option: BlazeOptions = { @@ -14,7 +14,8 @@ export function websocket( syncAck: false, }, ): WebSocket { - const jwtToken = signAccessToken('GET', '/', '', uuid(), keystore) || ''; + const jwtToken = signAccessToken('GET', '/', '', uuid(), keystore); + if (!jwtToken) throw new Error('A valid keystore is required for Blaze authorization'); const headers = { Authorization: `Bearer ${jwtToken}`, }; diff --git a/src/client/address.ts b/src/client/address.ts index 739a24ef..2132bcb4 100644 --- a/src/client/address.ts +++ b/src/client/address.ts @@ -11,7 +11,7 @@ import { buildClient } from './utils/client'; * tag or memo can be blank. * Detail: https://developers.mixin.one/docs/api/withdrawal/address-add */ -export const AddressKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => ({ +export const AddressKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => ({ /** Get an address by addressID */ fetch: (addressID: string): Promise => axiosInstance.get(`/addresses/${addressID}`), diff --git a/src/client/app.ts b/src/client/app.ts index 2a4bfedd..418ee551 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -22,7 +22,7 @@ import { getOwnershipTransferTipBody, signEd25519PIN, signTipBody } from './util * * Each Mixin user can only create two free apps * https://developers.mixin.one/ */ -export const AppKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => ({ +export const AppKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => ({ /** Get information of current user's a specific app */ fetch: (appID: string): Promise => axiosInstance.get(`/apps/${appID}`), @@ -80,7 +80,6 @@ export const AppKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keysto // Migrate app to receiver id with the keystore of this app // pin is the spend private key of this app migrate: (pin: string, receiverID: string) => { - if (!keystore) throw new Error('invalid keystore to migrate app'); const msg = getOwnershipTransferTipBody(receiverID); const signedTipPin = signTipBody(pin, msg); const pin_base64 = signEd25519PIN(signedTipPin, keystore); diff --git a/src/client/collectible.ts b/src/client/collectible.ts index 5e18c42e..2ea44339 100644 --- a/src/client/collectible.ts +++ b/src/client/collectible.ts @@ -33,7 +33,7 @@ export const GroupThreshold = 5; * * only uncompleted sign transaction can be unlocked. * Docs: https://developers.mixin.one/docs/api/collectibles/request */ -export const CollectibleKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => { +export const CollectibleKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => { const manageRequest = (pin: string, requestID: string, action: CollectibleRequestAction): Promise => { const encrypted = signEd25519PIN(pin, keystore); return axiosInstance.post(`/collectibles/requests/${requestID}/${action}`, { pin: encrypted }); diff --git a/src/client/conversation.ts b/src/client/conversation.ts index d73d669e..68ce0216 100644 --- a/src/client/conversation.ts +++ b/src/client/conversation.ts @@ -13,14 +13,13 @@ import { buildClient } from './utils/client'; * * for group conversation, creator will be the owner and can't be changed. * Docs: https://developers.mixin.one/docs/api/conversations/read */ -export const ConversationKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => { +export const ConversationKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => { const createConversation = (params: ConversationRequest): Promise => axiosInstance.post('/conversations', params); const managerConversation = (conversationID: string, action: ConversationAction, participant: ParticipantRequest[]): Promise => axiosInstance.post(`/conversations/${conversationID}/participants/${action}`, participant); const createContactConversation = (userID: string): Promise => { - if (!keystore) throw new Error('No Keystore Provided'); return createConversation({ category: 'CONTACT', conversation_id: uniqueConversationID(keystore.app_id, userID), diff --git a/src/client/http.ts b/src/client/http.ts index ef967266..fcbc53f8 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -8,7 +8,7 @@ import type { RequestConfig } from './types/client'; import { ResponseError } from './error'; import { signAccessToken } from './utils/auth'; -export function http(keystore?: Keystore, config?: RequestConfig): AxiosInstance { +export function http(keystore: Keystore, config?: RequestConfig): AxiosInstance { const timeout = config?.timeout ?? 3000; const retries = config?.retry ?? 5; @@ -26,14 +26,14 @@ export function http(keystore?: Keystore, config?: RequestConfig): AxiosInstance ins.interceptors.request.use((config: InternalAxiosRequestConfig) => { const { method, data } = config; - const url = axios.getUri(config).slice(config.baseURL?.length); + const { pathname, search } = new URL(axios.getUri(config), 'https://api.mixin.one'); + const url = pathname + search; - if (config.headers) { - const requestID = uuid(); - config.headers['X-Request-Id'] = requestID; - const jwtToken = signAccessToken(method, url, data, requestID, keystore); - if (jwtToken) config.headers.Authorization = `Bearer ${jwtToken}`; - } + const requestID = uuid(); + config.headers['X-Request-Id'] = requestID; + const token = signAccessToken(method, url, data, requestID, keystore); + if (!token) throw new Error('Invalid keystore: unable to sign API request'); + config.headers.set('Authorization', `Bearer ${token}`, true); return config; }); diff --git a/src/client/message.ts b/src/client/message.ts index c8dcd30f..ea451e6b 100644 --- a/src/client/message.ts +++ b/src/client/message.ts @@ -28,12 +28,11 @@ import { uniqueConversationID, base64RawURLEncode, buildClient } from './utils'; * After receiving the message via WebSocket, you need to acknowledge the message to Mixin message service, * otherwise it will keep pushing the message. */ -export const MessageKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => { +export const MessageKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => { const send = (message: MessageRequest) => axiosInstance.post('/messages', [message]); const sendLegacy = (message: MessageRequest) => axiosInstance.post('/messages', message); const sendMsg = async (recipientID: string, category: MessageCategory, data: any): Promise => { - if (!keystore) throw new Error('No Keystore Provided'); if (typeof data === 'object') data = JSON.stringify(data); const messageRequest = { diff --git a/src/client/mixin-client.ts b/src/client/mixin-client.ts index 8268dc1c..7672efe8 100644 --- a/src/client/mixin-client.ts +++ b/src/client/mixin-client.ts @@ -22,7 +22,7 @@ import { UserKeystoreClient } from './user'; import { UtxoKeystoreClient } from './utxo'; import { SafeKeystoreClient } from './safe'; -const KeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => ({ +const KeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => ({ address: AddressKeystoreClient(axiosInstance, keystore), app: AppKeystoreClient(axiosInstance, keystore), asset: AssetKeystoreClient(axiosInstance), @@ -46,7 +46,7 @@ const KeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undef export type KeystoreClientReturnType = ReturnType; -export function MixinApi(config: HTTPConfig = {}): KeystoreClientReturnType & RequestClient { +export function MixinApi(config: HTTPConfig): KeystoreClientReturnType & RequestClient { const { keystore, requestConfig } = config; const axiosInstance = createAxiosClient(keystore, requestConfig); diff --git a/src/client/multisig.ts b/src/client/multisig.ts index 8bed35e2..13966f92 100644 --- a/src/client/multisig.ts +++ b/src/client/multisig.ts @@ -13,7 +13,7 @@ import { buildClient } from './utils/client'; * * only uncompleted sign transaction can be unlocked. * Docs: https://developers.mixin.one/docs/api/multisigs/request */ -export const MultisigKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => { +export const MultisigKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => { const initMultisig = (pin: string, requestID: string, action: MultisigAction): Promise => { const encrypted = signEd25519PIN(pin, keystore); return axiosInstance.post(`/multisigs/requests/${requestID}/${action}`, { pin: encrypted }); diff --git a/src/client/network.ts b/src/client/network.ts index 4138f727..bdf3db63 100644 --- a/src/client/network.ts +++ b/src/client/network.ts @@ -4,7 +4,7 @@ import type { NetworkSnapshotRequest, NetworkInfoResponse, NetworkChainResponse, import { buildClient } from './utils/client'; /** - * Public methods that need no permission + * Network methods require API authorization, including public information endpoints. * Docs: https://developers.mixin.one/docs/api/transfer/snapshots */ export const NetworkBaseClient = (axiosInstance: AxiosInstance) => ({ diff --git a/src/client/pin.ts b/src/client/pin.ts index c352a559..1cad287a 100644 --- a/src/client/pin.ts +++ b/src/client/pin.ts @@ -10,7 +10,7 @@ import { signTipBody, getNanoTime, getTipPinUpdateMsg, getVerifyPinTipBody, sign * * If you forget your PIN, there is no way to retrieve or restore it * Docs: https://developers.mixin.one/docs/api/pin/pin-update */ -export const PinKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => { +export const PinKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => { function updatePin(firstPin: string, secondPin = ''): Promise { const oldEncrypted = firstPin ? signEd25519PIN(firstPin, keystore) : ''; const newEncrypted = signEd25519PIN(secondPin, keystore); diff --git a/src/client/safe.ts b/src/client/safe.ts index 987a6b44..596ae3d2 100644 --- a/src/client/safe.ts +++ b/src/client/safe.ts @@ -17,7 +17,7 @@ import type { import { buildClient, signEd25519PIN, signSafeRegistration, transferInscription, type SafeTransferInscriptionRequest } from './utils'; import { UtxoKeystoreClient } from './utxo'; -export const SafeKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => ({ +export const SafeKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => ({ /** If you want to register safe user, you need to upgrade TIP PIN first. */ register: (user_id: string, tipPin: string, priv: Buffer): Promise => { const data = signSafeRegistration(user_id, tipPin, priv); diff --git a/src/client/transfer.ts b/src/client/transfer.ts index 24597a83..cad918f6 100644 --- a/src/client/transfer.ts +++ b/src/client/transfer.ts @@ -12,7 +12,7 @@ import { buildClient } from './utils/client'; * * Once /transfers API is successfully called, it means data has been confirmed by all nodes, and it is irreversible * Docs: https://developers.mixin.one/docs/api/transfer/transfer */ -export const TransferKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => ({ +export const TransferKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => ({ /** Get transfer information by traceID */ fetch: (traceID: string): Promise => axiosInstance.get(`/transfers/trace/${traceID}`), diff --git a/src/client/types/client.ts b/src/client/types/client.ts index d7f0f39a..b7dd04fd 100644 --- a/src/client/types/client.ts +++ b/src/client/types/client.ts @@ -10,17 +10,16 @@ export interface RequestConfig extends Partial< } export interface HTTPConfig { - keystore?: Keystore; + keystore: Keystore; requestConfig?: RequestConfig; } export interface BaseClient { - (): KeystoreReturnType & RequestClient; (config: HTTPConfig): KeystoreReturnType & RequestClient; } +export type KeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore) => KeystoreReturnType; export type BaseInnerClient = (axiosInstance: AxiosInstance) => KeystoreReturnType; -export type KeystoreClient = (axiosInstance: AxiosInstance, keystore?: Keystore) => KeystoreReturnType; export type UnionKeystoreClient = BaseInnerClient | KeystoreClient; export interface BuildClient { diff --git a/src/client/types/keystore.ts b/src/client/types/keystore.ts index 7f578963..a2bc468d 100644 --- a/src/client/types/keystore.ts +++ b/src/client/types/keystore.ts @@ -1,8 +1,13 @@ -export interface AppKeystore { +/** Credentials used to sign requests, including before a session is issued. */ +export interface SessionKeystore { app_id: string; + session_private_key: string; + session_id?: string; +} + +export interface AppKeystore extends SessionKeystore { session_id: string; server_public_key: string; - session_private_key: string; spend_private_key?: string; } @@ -13,13 +18,11 @@ export interface OAuthKeystore { session_private_key: string; } -export interface NetworkUserKeystore { - app_id: string; +export interface NetworkUserKeystore extends SessionKeystore { session_id: string; - session_private_key: string; pin_token_base64: string; } -export type Keystore = AppKeystore | OAuthKeystore | NetworkUserKeystore; +export type Keystore = SessionKeystore | AppKeystore | OAuthKeystore | NetworkUserKeystore; export default Keystore; diff --git a/src/client/utils/auth.ts b/src/client/utils/auth.ts index f39a1215..dbea140c 100644 --- a/src/client/utils/auth.ts +++ b/src/client/utils/auth.ts @@ -1,7 +1,7 @@ import serialize from 'serialize-javascript'; import { ed25519 } from '@noble/curves/ed25519.js'; import { validate } from 'uuid'; -import type { Keystore, AppKeystore, OAuthKeystore, NetworkUserKeystore } from '../types/keystore'; +import type { Keystore, SessionKeystore, OAuthKeystore } from '../types/keystore'; import { base64RawURLEncode } from './base64'; import { sha256Hash } from './uniq'; import { getKeyPair, getRandomBytes } from './ed25519'; @@ -30,8 +30,8 @@ export const signToken = (payload: Object, private_key: string): string => { * sign an authentication token * sig: sha256(method + uri + params) */ -export const signAuthenticationToken = (methodRaw: string | undefined, uri: string, params: Object | string, requestID: string, keystore: AppKeystore | NetworkUserKeystore) => { - if (!keystore.session_id || !validate(keystore.session_id)) return ''; +export const signAuthenticationToken = (methodRaw: string | undefined, uri: string, params: Object | string, requestID: string, keystore: SessionKeystore) => { + if (keystore.session_id && !validate(keystore.session_id)) return ''; let method = 'GET'; if (methodRaw) method = methodRaw.toLocaleUpperCase(); @@ -49,7 +49,7 @@ export const signAuthenticationToken = (methodRaw: string | undefined, uri: stri const payload = { uid: keystore.app_id, - sid: keystore.session_id, + sid: keystore.session_id ?? '', iat, exp, jti: requestID, @@ -96,7 +96,7 @@ export const signOauthAccessToken = (methodRaw: string | undefined, uri: string, return signToken(payload, keystore.session_private_key); }; -export const signAccessToken = (methodRaw: string | undefined, uri: string, params: Object | string, requestID: string, keystore: Keystore | undefined) => { +export const signAccessToken = (methodRaw: string | undefined, uri: string, params: Object | string, requestID: string, keystore: Keystore) => { if (!keystore || !keystore.app_id || !keystore.session_private_key) return ''; if (!validate(keystore.app_id)) return ''; diff --git a/src/client/utils/client.ts b/src/client/utils/client.ts index 7c0ad700..d18916a5 100644 --- a/src/client/utils/client.ts +++ b/src/client/utils/client.ts @@ -1,9 +1,9 @@ import type { AxiosInstance } from 'axios'; -import type { BaseClient, BuildClient, HTTPConfig, KeystoreClient, RequestClient, RequestConfig, UnionKeystoreClient } from '../types/client'; +import type { BaseClient, BuildClient, HTTPConfig, UnionKeystoreClient, RequestClient, RequestConfig } from '../types/client'; import type { Keystore } from '../types/keystore'; import { http } from '../http'; -export const createAxiosClient = (keystore?: Keystore, requestConfig?: RequestConfig) => http(keystore, requestConfig); +export const createAxiosClient = (keystore: Keystore, requestConfig?: RequestConfig) => http(keystore, requestConfig); export const createRequestClient = (axiosInstance: AxiosInstance): RequestClient => ({ // The HTTP response interceptor unwraps the API payload. @@ -12,13 +12,13 @@ export const createRequestClient = (axiosInstance: AxiosInstance): RequestClient export const buildClient: BuildClient = (KeystoreClient: UnionKeystoreClient): BaseClient => - (config: HTTPConfig = {}): any => { + (config: HTTPConfig) => { if (!KeystoreClient) throw new Error('keystore client is required'); const { keystore, requestConfig } = config; const axiosInstance = createAxiosClient(keystore, requestConfig); const requestClient = createRequestClient(axiosInstance); - const keystoreClient = (KeystoreClient as KeystoreClient)(axiosInstance, keystore); + const keystoreClient = KeystoreClient(axiosInstance, keystore); return Object.assign(keystoreClient, requestClient); }; diff --git a/test/blaze.test.ts b/test/blaze.test.ts index 312d14e6..71619463 100644 --- a/test/blaze.test.ts +++ b/test/blaze.test.ts @@ -1,11 +1,15 @@ import WebSocket from 'ws'; import { gzip, ungzip } from 'pako'; import { describe, expect, it, vi } from 'vitest'; -import { decodeMessage, sendRaw } from '../src/blaze/utils'; +import { decodeMessage, sendRaw, websocket } from '../src/blaze/utils'; const encodedEnvelope = (data: Record) => gzip(Buffer.from(JSON.stringify({ data }))); describe('Blaze utilities', () => { + it('rejects a handshake without valid credentials before opening a socket', () => { + expect(() => websocket(undefined as never, 'wss://blaze.mixin.one', { onMessage: async () => {} })).toThrow('A valid keystore is required for Blaze authorization'); + }); + it('decodes an envelope without changing message data by default', () => { const message = { message_id: 'message-id', data: Buffer.from('hello').toString('base64') }; diff --git a/test/crypto.ts b/test/crypto.ts index 411bd181..3c55b538 100644 --- a/test/crypto.ts +++ b/test/crypto.ts @@ -3,7 +3,7 @@ const nodeCrypto = require('crypto'); Object.defineProperty(globalThis, 'crypto', { configurable: true, value: nodeCrypto.webcrypto ?? { - getRandomValues(buffer) { + getRandomValues(buffer: NodeJS.ArrayBufferView) { return nodeCrypto.randomFillSync(buffer); }, }, diff --git a/test/mixin/auth.test.ts b/test/mixin/auth.test.ts index a326951c..47c7f4af 100644 --- a/test/mixin/auth.test.ts +++ b/test/mixin/auth.test.ts @@ -77,7 +77,7 @@ describe('authentication utilities', () => { { app_id: appID, session_id: 'invalid', session_private_key: privateKey, server_public_key: '02'.repeat(32) }, { app_id: appID, authorization_id: sessionID, scope: '', session_private_key: privateKey }, ])('returns an empty token for incomplete or invalid credentials', keystore => { - expect(signAccessToken('GET', '/me', '', requestID, keystore as Keystore | undefined)).toBe(''); + expect(signAccessToken('GET', '/me', '', requestID, keystore as Keystore)).toBe(''); }); it('creates a PKCE verifier and its SHA-256 challenge', () => { diff --git a/test/mixin/client-utils.test.ts b/test/mixin/client-utils.test.ts index e9a72a3c..fde5bc79 100644 --- a/test/mixin/client-utils.test.ts +++ b/test/mixin/client-utils.test.ts @@ -1,7 +1,10 @@ import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; import { describe, expect, it, vi } from 'vitest'; -import type { AppKeystore } from '../../src/client/types'; +import type { AppKeystore, Keystore } from '../../src/client/types'; import { buildClient } from '../../src/client/utils/client'; +import { MixinApi } from '../../src/client/mixin-client'; +import { base64RawURLDecode } from '../../src/client/utils/base64'; +import { sha256Hash } from '../../src/client/utils/uniq'; type Adapter = NonNullable; @@ -21,7 +24,7 @@ describe('client builders', () => { status: 200, statusText: 'OK', })); - const DomainClient = (axios: AxiosInstance, credentials?: AppKeystore) => ({ + const DomainClient = (axios: AxiosInstance, credentials: Keystore) => ({ fetch: () => axios.get('/domain'), credentials, }); @@ -35,11 +38,67 @@ describe('client builders', () => { await expect(client.request({ url: '/generic', method: 'GET' })).resolves.toBe('/generic'); expect(client.credentials).toBe(keystore); expect(adapter).toHaveBeenCalledTimes(2); + for (const [config] of adapter.mock.calls) { + expect(config.headers.get('Authorization')).toMatch(/^Bearer ey/); + } + }); + + it.each([keystore, { app_id: keystore.app_id, authorization_id: keystore.session_id, session_private_key: keystore.session_private_key, scope: 'PROFILE:READ' }])( + 'authorizes public endpoints, OAuth exchange, and generic requests through MixinApi', + async credentials => { + const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => ({ config, data: { data: true }, headers: {}, status: 200, statusText: 'OK' })); + const client = MixinApi({ keystore: credentials, requestConfig: { retry: 0, adapter } as Parameters[0]['requestConfig'] }); + + await client.network.chains(); + await client.network.topAssets(); + await client.external.exchangeRates(); + await client.code.fetch('code-id'); + await client.safe.fetchInscriptionItem('hash'); + await client.oauth.getToken({ client_id: keystore.app_id, code: 'code', client_secret: 'secret' }); + await client.request({ method: 'POST', url: '/custom', data: { value: 1 } }); + + expect(adapter).toHaveBeenCalledTimes(7); + for (const [config] of adapter.mock.calls) { + const token = config.headers.get('Authorization') as string; + const claims = JSON.parse(base64RawURLDecode(token.split('.')[1]).toString()); + const uri = new URL(`${config.baseURL}${config.url}`); + if (config.params?.kind) uri.searchParams.set('kind', config.params.kind); + expect(claims).toMatchObject({ + ...('session_id' in credentials ? { uid: credentials.app_id, sid: credentials.session_id } : { iss: credentials.app_id, aid: credentials.authorization_id }), + jti: config.headers['X-Request-Id'], + sig: sha256Hash(Buffer.from(`${config.method!.toUpperCase()}${uri.pathname}${uri.search}${config.data ?? ''}`)).toString('hex'), + }); + } + }, + ); + + it('exchanges an OAuth code with only pre-login signing credentials', async () => { + const credentials = { app_id: keystore.app_id, session_private_key: keystore.session_private_key }; + const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => ({ + config, + data: { data: { scope: 'PROFILE:READ', authorization_id: keystore.session_id } }, + headers: {}, + status: 200, + statusText: 'OK', + })); + const client = MixinApi({ keystore: credentials, requestConfig: { retry: 0, adapter } as Parameters[0]['requestConfig'] }); + + await expect(client.oauth.getToken({ client_id: credentials.app_id, code: 'code', ed25519: 'public-key' })).resolves.toMatchObject({ + scope: 'PROFILE:READ', + authorization_id: keystore.session_id, + }); + const config = adapter.mock.calls[0][0]; + const token = config.headers.get('Authorization') as string; + expect(JSON.parse(base64RawURLDecode(token.split('.')[1]).toString())).toMatchObject({ + uid: credentials.app_id, + sid: '', + sig: sha256Hash(Buffer.from(`POST/oauth/token${config.data}`)).toString('hex'), + }); }); it('rejects a missing domain client', () => { const Client = buildClient(undefined as never); - expect(() => Client()).toThrow('keystore client is required'); + expect(() => Client({ keystore })).toThrow('keystore client is required'); }); }); diff --git a/test/mixin/code.test.ts b/test/mixin/code.test.ts index 85db911b..e70cbb4b 100644 --- a/test/mixin/code.test.ts +++ b/test/mixin/code.test.ts @@ -1,8 +1,9 @@ import Code from '../../src/client/code'; import { describeIntegration } from '../integration'; +import keystore from '../keystore'; describeIntegration('Tests for codes', () => { - const code = Code(); + const code = Code({ keystore }); test('Test for fetch conversation', async () => { const resp = await code.fetch('99f90817-9b2a-4d39-ba53-ab2cf0aa2440'); expect(resp.type).toMatch('conversation'); diff --git a/test/mixin/http.test.ts b/test/mixin/http.test.ts index 01643735..f2e69c8a 100644 --- a/test/mixin/http.test.ts +++ b/test/mixin/http.test.ts @@ -3,6 +3,16 @@ import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; import { describe, expect, it, vi } from 'vitest'; import { ResponseError } from '../../src/client/error'; import { http } from '../../src/client/http'; +import type { Keystore } from '../../src/client/types'; +import { base64RawURLDecode } from '../../src/client/utils/base64'; +import { sha256Hash } from '../../src/client/utils/uniq'; + +const keystore = { + app_id: '4b79fe76-0d9d-49e6-85fd-0f6be01147da', + session_id: 'a03a4496-3e59-4abd-bd1e-1ab8a2acb550', + session_private_key: '01'.repeat(32), + server_public_key: '02'.repeat(32), +}; type Adapter = NonNullable; @@ -15,9 +25,62 @@ const response = (config: InternalAxiosRequestConfig, data: unknown) => ({ }); describe('HTTP client', () => { + it.each([{} as Keystore, { ...keystore, session_private_key: '01' }, { ...keystore, session_id: 'invalid' }])( + 'rejects invalid credentials before sending a request', + async credentials => { + const adapter = vi.fn(); + const errorMap = vi.fn(); + const client = http(credentials, { retry: 0, errorMap, adapter } as Parameters[1]); + + await expect(client.get('/network')).rejects.toThrow('Invalid keystore'); + expect(adapter).not.toHaveBeenCalled(); + expect(errorMap).toHaveBeenCalledOnce(); + }, + ); + + it('rejects an unsigned request even when an Authorization header is provided', async () => { + const adapter = vi.fn(); + const client = http(undefined as never, { headers: { Authorization: 'Bearer external' }, retry: 0, adapter } as Parameters[1]); + + await expect(client.get('/network')).rejects.toThrow('Invalid keystore'); + expect(adapter).not.toHaveBeenCalled(); + }); + + it('always signs requests with the supplied keystore', async () => { + const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => response(config, { data: true })); + const client = http(keystore, { retry: 0, adapter } as Parameters[1]); + + await expect(client.get('/network', { headers: { authorization: false } })).resolves.toBe(true); + expect(adapter.mock.calls[0][0].headers.get('Authorization')).toMatch(/^Bearer ey/); + }); + + it.each(['/resource/a%2Fb?name=hello%20world', 'https://example.test/api/resource/a%2Fb?name=hello%20world'])( + 'signs the full request URI and transmitted body for %s', + async url => { + const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => response(config, { data: true })); + const client = http(keystore, { + baseURL: 'https://example.test/api', + headers: { authorization: 'Bearer external' }, + retry: 0, + adapter, + } as Parameters[1]); + await client.post(url, { text: '' }, { params: { order: 'DESC' } }); + + const config = adapter.mock.calls[0][0]; + const token = config.headers.get('Authorization') as string; + const claims = JSON.parse(base64RawURLDecode(token.split('.')[1]).toString()); + expect(claims).toMatchObject({ + uid: keystore.app_id, + sid: keystore.session_id, + jti: config.headers['X-Request-Id'], + sig: sha256Hash(Buffer.from(`POST/api/resource/a%2Fb?name=hello%20world&order=DESC${config.data}`)).toString('hex'), + }); + }, + ); + it('adds request metadata, unwraps data, and honors retry: 0', async () => { const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => response(config, { data: { ok: true } })); - const client = http(undefined, { + const client = http(keystore, { baseURL: 'https://example.test', headers: { 'X-Client': 'test', Authorization: 'Bearer external' }, retry: 0, @@ -32,7 +95,7 @@ describe('HTTP client', () => { }; expect(config.headers['X-Client']).toBe('test'); expect(config.headers['X-Request-Id']).toMatch(/^[0-9a-f-]{36}$/); - expect(config.headers.Authorization).toBe('Bearer external'); + expect(config.headers.Authorization).toMatch(/^Bearer ey/); expect(config.timeout).toBe(0); expect(config['axios-retry'].retries).toBe(0); }); @@ -42,7 +105,7 @@ describe('HTTP client', () => { const responseCallback = vi.fn(); const errorMap = vi.fn(); const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => response(config, { error: apiError })); - const client = http(undefined, { + const client = http(keystore, { retry: 0, responseCallback, errorMap, @@ -69,7 +132,7 @@ describe('HTTP client', () => { const adapter = vi.fn(async () => { throw transportError; }); - const client = http(undefined, { + const client = http(keystore, { retry: 0, responseCallback, errorMap, diff --git a/test/mixin/inscription-utils.test.ts b/test/mixin/inscription-utils.test.ts index 67ba0933..3cf13eb7 100644 --- a/test/mixin/inscription-utils.test.ts +++ b/test/mixin/inscription-utils.test.ts @@ -120,7 +120,7 @@ describe('getInscriptionOutput', () => { const pages = [[inscriptionOutput(3), inscriptionOutput(2), inscriptionOutput(1)], [target]]; const safeOutputs = vi.fn(async () => pages.shift() ?? []); - const found = await getInscriptionOutput({ safeOutputs } as InscriptionUtxoClient, 'dd'.repeat(32), { limit: 3 }); + const found = await getInscriptionOutput({ safeOutputs } as unknown as InscriptionUtxoClient, 'dd'.repeat(32), { limit: 3 }); expect(found).toEqual(target); expect(safeOutputs).toHaveBeenCalledTimes(2); @@ -130,7 +130,7 @@ describe('getInscriptionOutput', () => { it('throws when the inscription is nowhere unspent', async () => { const safeOutputs = vi.fn(async () => [inscriptionOutput(0)]); - await expect(getInscriptionOutput({ safeOutputs } as InscriptionUtxoClient, 'dd'.repeat(32))).rejects.toThrow('unspent inscription output not found'); + await expect(getInscriptionOutput({ safeOutputs } as unknown as InscriptionUtxoClient, 'dd'.repeat(32))).rejects.toThrow('unspent inscription output not found'); }); }); @@ -143,7 +143,7 @@ describe('transferInscription', () => { client: { safeOutputs: vi.fn(async () => [utxo]), ghostKey: vi.fn(async () => [ghost]), - verifyTransaction: vi.fn(async () => [{ views: [viewKey] }]) as unknown as Promise, + verifyTransaction: vi.fn(async () => [{ views: [viewKey] }] as SequencerTransactionRequest[]), sendTransactions, } as InscriptionUtxoClient, sendTransactions, diff --git a/test/mixin/message.test.ts b/test/mixin/message.test.ts index bff04be7..763a8e49 100644 --- a/test/mixin/message.test.ts +++ b/test/mixin/message.test.ts @@ -40,14 +40,6 @@ describe('message client', () => { expect(JSON.parse(base64RawURLDecode(message.data_base64).toString())).toEqual({ sticker_id: 'sticker-id' }); }); - it('rejects convenience sends without a keystore', async () => { - const post = vi.fn(); - const client = MessageKeystoreClient({ post } as unknown as AxiosInstance, undefined); - - await expect(client.sendText(recipientID, 'hello')).rejects.toThrow('No Keystore Provided'); - expect(post).not.toHaveBeenCalled(); - }); - it('forwards acknowledgement, batch, one, and legacy payload shapes', async () => { const post = vi.fn().mockResolvedValue(undefined); const client = MessageKeystoreClient({ post } as unknown as AxiosInstance, keystore); diff --git a/test/mixin/network.test.ts b/test/mixin/network.test.ts index d6aa65b3..dc6c66af 100644 --- a/test/mixin/network.test.ts +++ b/test/mixin/network.test.ts @@ -1,8 +1,9 @@ import Network from '../../src/client/network'; import { describeIntegration } from '../integration'; +import keystore from '../keystore'; describeIntegration('Tests for network', () => { - const network = Network(); + const network = Network({ keystore }); test('Test for fetch network chains', async () => { const resp = await network.chains(); console.log(resp.length); diff --git a/test/mixin/safe.test.ts b/test/mixin/safe.test.ts index 2f236805..696a2c99 100644 --- a/test/mixin/safe.test.ts +++ b/test/mixin/safe.test.ts @@ -116,7 +116,7 @@ describe('Tests for safe', () => { }); test('Test for safe ghost key for mainnet address', async () => { - const client = MixinApi(); + const client = MixinApi({ keystore: { app_id: '4b79fe76-0d9d-49e6-85fd-0f6be01147da', session_private_key: '01'.repeat(32) } }); const seed = Buffer.alloc(64).fill(1); const trace = '9dc60d4c-f301-48ef-97f2-32e9195648cb'; diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..49a68419 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts"], + "compilerOptions": { + "declaration": false, + "rootDir": "..", + "target": "ES2020", + "types": ["node", "vitest/globals"] + } +} diff --git a/test/webview.test.ts b/test/webview.test.ts index 059f8b2f..483965e3 100644 --- a/test/webview.test.ts +++ b/test/webview.test.ts @@ -160,7 +160,7 @@ describe('WebViewApi bridge routing', () => { }); it('reads iOS context from prompt and supplies the default platform', () => { - testWindow.webkit = { messageHandlers: { MixinContext: {} } }; + testWindow.webkit = { messageHandlers: { MixinContext: { postMessage: vi.fn(() => '') } } }; const prompt = vi.fn(() => JSON.stringify({ conversation_id: 'conversation-id' })); Object.assign(globalThis, { prompt }); @@ -199,7 +199,7 @@ describe('WebViewApi bridge routing', () => { it('routes commands to iOS handlers', async () => { const handlers = { - MixinContext: {}, + MixinContext: { postMessage: vi.fn(() => '') }, reloadTheme: { postMessage: vi.fn() }, playlist: { postMessage: vi.fn() }, close: { postMessage: vi.fn() }, diff --git a/tsconfig.json b/tsconfig.json index dba7775f..4c789803 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "include": ["src", "types"], "compilerOptions": { - "baseUrl": "src", "module": "esnext", "lib": ["dom", "esnext"], "importHelpers": true, @@ -13,7 +12,7 @@ "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "jsx": "react", "esModuleInterop": true, From 4ef6901ff6c57041ae9f08f415a7af56a0e798a5 Mon Sep 17 00:00:00 2001 From: hundredark Date: Thu, 5 Nov 2026 16:48:20 +0800 Subject: [PATCH 2/2] check uri in request helper --- src/client/http.ts | 23 +++++++- test/mixin/client-utils.test.ts | 41 +++++++++++++ test/mixin/http.test.ts | 101 +++++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/client/http.ts b/src/client/http.ts index fcbc53f8..6a6ee873 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -8,12 +8,25 @@ import type { RequestConfig } from './types/client'; import { ResponseError } from './error'; import { signAccessToken } from './utils/auth'; +const API_ORIGIN = 'https://api.mixin.one'; + +const validatePath = (uri: string) => { + let pathEnd = uri.search(/[?#]/); + if (pathEnd === -1) { + pathEnd = uri.length; + // WHATWG URL parsing trims trailing C0 controls and ASCII spaces. + while (pathEnd > 0 && uri.charCodeAt(pathEnd - 1) <= 0x20) pathEnd -= 1; + } + const path = uri.slice(0, pathEnd).replace(/[\t\n\r]/g, ''); + if (/(?:^|[/\\])(?:\.|%2e){1,2}(?:[/\\]|$)/i.test(path)) throw new Error('Invalid API path: dot segments are not allowed'); +}; + export function http(keystore: Keystore, config?: RequestConfig): AxiosInstance { const timeout = config?.timeout ?? 3000; const retries = config?.retry ?? 5; const ins = axios.create({ - baseURL: 'https://api.mixin.one', + baseURL: API_ORIGIN, timeout, ...config, headers: { @@ -26,7 +39,13 @@ export function http(keystore: Keystore, config?: RequestConfig): AxiosInstance ins.interceptors.request.use((config: InternalAxiosRequestConfig) => { const { method, data } = config; - const { pathname, search } = new URL(axios.getUri(config), 'https://api.mixin.one'); + // Check before URL normalization, both before and after query parameters are added. + validatePath(axios.getUri({ ...config, params: null })); + const uri = axios.getUri(config); + validatePath(uri); + // Require an absolute URL so browser transports cannot resolve it against the page's origin. + const { origin, pathname, search } = new URL(uri); + if (origin !== API_ORIGIN) throw new Error(`Invalid API origin: requests must target ${API_ORIGIN}`); const url = pathname + search; const requestID = uuid(); diff --git a/test/mixin/client-utils.test.ts b/test/mixin/client-utils.test.ts index fde5bc79..d1419984 100644 --- a/test/mixin/client-utils.test.ts +++ b/test/mixin/client-utils.test.ts @@ -96,6 +96,47 @@ describe('client builders', () => { }); }); + it.each([{ url: 'https://example.test/me' }, { url: '/me', baseURL: 'https://example.test' }])( + 'rejects cross-origin requests through the public generic request method: %j', + async config => { + const adapter = vi.fn(); + const client = MixinApi({ keystore, requestConfig: { retry: 0, adapter } as Parameters[0]['requestConfig'] }); + + await expect(client.request(config)).rejects.toThrow('Invalid API origin'); + expect(adapter).not.toHaveBeenCalled(); + }, + ); + + it.each(['../me', '../safe/assets', '%2e%2e/me', '..\\me', '..'])('rejects traversal through user search before sending: %s', async keyword => { + const adapter = vi.fn(); + const client = MixinApi({ keystore, requestConfig: { retry: 0, adapter } as Parameters[0]['requestConfig'] }); + + await expect(client.user.search(keyword)).rejects.toThrow('Invalid API path'); + expect(adapter).not.toHaveBeenCalled(); + }); + + it.each(['../../../me', '%2e%2e/%2e%2e/%2e%2e/safe/assets', '../../../me/.. ', '../../../me/..\u0000'])( + 'rejects traversal through asset search before sending: %s', + async keyword => { + const adapter = vi.fn(); + const client = MixinApi({ keystore, requestConfig: { retry: 0, adapter } as Parameters[0]['requestConfig'] }); + + await expect(client.network.searchAssets(keyword)).rejects.toThrow('Invalid API path'); + expect(adapter).not.toHaveBeenCalled(); + }, + ); + + it('preserves ordinary user and asset searches', async () => { + const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => ({ config, data: { data: true }, headers: {}, status: 200, statusText: 'OK' })); + const client = MixinApi({ keystore, requestConfig: { retry: 0, adapter } as Parameters[0]['requestConfig'] }); + + await expect(client.user.search('7000100')).resolves.toBe(true); + await expect(client.network.searchAssets('foo..bar')).resolves.toBe(true); + expect(adapter.mock.calls.map(([config]) => config.url)).toEqual(['/search/7000100', '/network/assets/search/foo..bar']); + expect(adapter.mock.calls[1][0].params).toEqual({ kind: 'ALL' }); + for (const [config] of adapter.mock.calls) expect(config.headers.get('Authorization')).toMatch(/^Bearer ey/); + }); + it('rejects a missing domain client', () => { const Client = buildClient(undefined as never); diff --git a/test/mixin/http.test.ts b/test/mixin/http.test.ts index f2e69c8a..a66ea2c7 100644 --- a/test/mixin/http.test.ts +++ b/test/mixin/http.test.ts @@ -54,12 +54,71 @@ describe('HTTP client', () => { expect(adapter.mock.calls[0][0].headers.get('Authorization')).toMatch(/^Bearer ey/); }); - it.each(['/resource/a%2Fb?name=hello%20world', 'https://example.test/api/resource/a%2Fb?name=hello%20world'])( + it.each([ + { url: '/search/../me' }, + { url: '/search/..' }, + { url: '/search/%2e%2e/me' }, + { url: '/search/.%2E/me' }, + { url: '/search/%2e./me' }, + { url: '/search/..\\me' }, + { url: '/search\\..\\me' }, + { url: '/search/.\t./me' }, + { url: '/search/.\n./me' }, + { url: '/search/.\r./me' }, + { url: '/search/.. ' }, + { url: '/search/..\u0000' }, + { url: '/search/..\u001f' }, + { url: '/search/./me' }, + { url: '/search/%2e/me' }, + { url: 'https://api.mixin.one/search/../me' }, + { url: '/search/%2E%2E?kind=ALL' }, + { url: '/search/.. ', params: { kind: 'ALL' } }, + { url: '/search/..\u0000', params: { kind: 'ALL' } }, + { url: '/search/%2e%2e ', params: { kind: 'ALL' } }, + { url: '/me', baseURL: 'https://api.mixin.one/search/%2e%2e' }, + ])('rejects dot path segments before signing or sending: %j', async config => { + const adapter = vi.fn(); + const errorMap = vi.fn(); + const client = http({} as Keystore, { retry: 0, errorMap, adapter } as Parameters[1]); + + await expect(client.request(config)).rejects.toThrow('Invalid API path: dot segments are not allowed'); + expect(adapter).not.toHaveBeenCalled(); + expect(errorMap).toHaveBeenCalledOnce(); + }); + + it.each(['https://api.mixin.one/search/..', 'https://api.mixin.one/search/%2e%2e'])('rejects dot segments in the configured baseURL: %s', async baseURL => { + const adapter = vi.fn(); + const client = http(keystore, { baseURL, retry: 0, adapter } as Parameters[1]); + + await expect(client.get('/me')).rejects.toThrow('Invalid API path'); + expect(adapter).not.toHaveBeenCalled(); + }); + + it.each([ + '/search/foo..bar', + '/search/...', + '/search/..%2Fme', + '/search/%252e%252e/me', + '/search/..\u00a0', + '/search/.. ?kind=ALL', + '/search/bitcoin?q=../me', + '/search/bitcoin?q=..\\me', + '/search/bitcoin#/../me', + ])('preserves ordinary paths and dot sequences in query strings or fragments: %s', async url => { + const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => response(config, { data: true })); + const client = http(keystore, { retry: 0, adapter } as Parameters[1]); + + await expect(client.get(url)).resolves.toBe(true); + expect(adapter).toHaveBeenCalledOnce(); + expect(adapter.mock.calls[0][0].headers.get('Authorization')).toMatch(/^Bearer ey/); + }); + + it.each(['/resource/a%2Fb?name=hello%20world', 'https://api.mixin.one/api/resource/a%2Fb?name=hello%20world', 'HTTPS://API.MIXIN.ONE:443/api/resource/a%2Fb?name=hello%20world'])( 'signs the full request URI and transmitted body for %s', async url => { const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => response(config, { data: true })); const client = http(keystore, { - baseURL: 'https://example.test/api', + baseURL: 'https://api.mixin.one/api', headers: { authorization: 'Bearer external' }, retry: 0, adapter, @@ -78,10 +137,46 @@ describe('HTTP client', () => { }, ); + it.each([ + 'https://example.test/me', + 'https://api.mixin.one.example.test/me', + 'https://api.mixin.one@evil.test/me', + 'https://api.mixin.one:8443/me', + 'http://api.mixin.one/me', + 'https://api.mixin.one./me', + ])('rejects an untrusted absolute URL before sending: %s', async url => { + const adapter = vi.fn(); + const errorMap = vi.fn(); + const client = http(keystore, { retry: 0, errorMap, adapter } as Parameters[1]); + + await expect(client.get(url)).rejects.toThrow('Invalid API origin'); + expect(adapter).not.toHaveBeenCalled(); + expect(errorMap).toHaveBeenCalledOnce(); + }); + + it.each(['https://example.test', 'http://api.mixin.one', 'https://api.mixin.one:8443'])('rejects an untrusted configured baseURL before sending: %s', async baseURL => { + const adapter = vi.fn(); + const client = http(keystore, { baseURL, retry: 0, adapter } as Parameters[1]); + + await expect(client.get('/me')).rejects.toThrow('Invalid API origin'); + expect(adapter).not.toHaveBeenCalled(); + }); + + it.each([{ url: '/me', baseURL: '' }, { url: '/me', baseURL: '/api' }, { url: '/me', baseURL: '//api.mixin.one' }, { url: '//api.mixin.one/me' }, { url: '//example.test/me' }])( + 'rejects a URL that a browser could resolve against its own origin or protocol: %j', + async config => { + const adapter = vi.fn(); + const client = http(keystore, { retry: 0, adapter } as Parameters[1]); + + await expect(client.request(config)).rejects.toThrow(); + expect(adapter).not.toHaveBeenCalled(); + }, + ); + it('adds request metadata, unwraps data, and honors retry: 0', async () => { const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => response(config, { data: { ok: true } })); const client = http(keystore, { - baseURL: 'https://example.test', + baseURL: 'https://api.mixin.one', headers: { 'X-Client': 'test', Authorization: 'Bearer external' }, retry: 0, timeout: 0,