diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b76e8e..45e7dd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: - name: Lint run: yarn lint + - name : Test + run: yarn test + typos: name: Detect typos runs-on: ubuntu-latest diff --git a/package.json b/package.json index eaf75ef..8ed99e1 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "fix": "run-s fix:*", "fix:format": "prettier --write --log-level warn src/**/*.ts", "fix:js": "eslint --fix src/", + "test": "vitest run", "build": "node build.js", "bump-version": "lerna version", "release": "lerna publish from-package" diff --git a/src/helpers.ts b/src/helpers.ts index 84709b4..7362948 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -22,7 +22,7 @@ export const parseSecKey = (secKey: string): { hex: string; bytes: Uint8Array } return undefined; }; -// parse the given public key of any string format (hex/bech32) +// parse the given public key of any string format (hex/bech32) as hex // returns undefined if the input is invalid export const parsePubkey = (pubkey: string): string | undefined => { if (pubkey.startsWith("npub1")) { diff --git a/src/nip46.test.ts b/src/nip46.test.ts new file mode 100644 index 0000000..0273aa0 --- /dev/null +++ b/src/nip46.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "vitest"; +import { parseConnToken } from "./nip46"; + +const testPubkey = { + hex: "d1d1747115d16751a97c239f46ec1703292c3b7e9988b9ebdd4ec4705b15ed44", + npub: "npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc", +}; + +describe("parseConnToken", () => { + describe("should parse bunker:// token", () => { + test("pubkey only", () => { + const { remotePubkey, relayUrls, secretToken } = parseConnToken(`bunker://${testPubkey.npub}`); + expect(remotePubkey).toBe(testPubkey.hex); + expect(relayUrls).toHaveLength(0); + expect(secretToken).toBeUndefined(); + }); + test("pubkey, relay URLs and secret token", () => { + const { remotePubkey, relayUrls, secretToken } = parseConnToken( + `bunker://${testPubkey.npub}?relay=wss%3A%2F%2Fyabu.me&relay=wss%3A%2F%2Fnrelay.c-stellar.net&secret=123456`, + ); + expect(remotePubkey).toBe(testPubkey.hex); + expect(relayUrls).toEqual(["wss://yabu.me", "wss://nrelay.c-stellar.net"]); + expect(secretToken).toBe("123456"); + }); + }); + + describe("should parse legacy token", () => { + test("pubkey only", () => { + const { remotePubkey, relayUrls, secretToken } = parseConnToken(`${testPubkey.hex}`); + expect(remotePubkey).toBe(testPubkey.hex); + expect(secretToken).toBeUndefined(); + expect(relayUrls).toHaveLength(0); + }); + test("pubkey and secret token", () => { + const { remotePubkey, relayUrls, secretToken } = parseConnToken(`${testPubkey.hex}#123456`); + expect(remotePubkey).toBe(testPubkey.hex); + expect(secretToken).toBe("123456"); + expect(relayUrls).toHaveLength(0); + }); + test("pubkey and relay URLs", () => { + const { remotePubkey, relayUrls, secretToken } = parseConnToken( + `${testPubkey.hex}?relay=wss%3A%2F%2Fyabu.me&relay=wss%3A%2F%2Fnrelay.c-stellar.net`, + ); + expect(remotePubkey).toBe(testPubkey.hex); + expect(secretToken).toBeUndefined(); + expect(relayUrls).toEqual(["wss://yabu.me", "wss://nrelay.c-stellar.net"]); + }); + test("pubkey, relay URLs and secret token", () => { + const { remotePubkey, relayUrls, secretToken } = parseConnToken( + `${testPubkey.hex}#123456?relay=wss%3A%2F%2Fyabu.me&relay=wss%3A%2F%2Fnrelay.c-stellar.net`, + ); + expect(remotePubkey).toBe(testPubkey.hex); + expect(secretToken).toBe("123456"); + expect(relayUrls).toEqual(["wss://yabu.me", "wss://nrelay.c-stellar.net"]); + }); + }); + + describe("should throw error when invalid connection token", () => { + test("URL token: invalid schema", () => { + expect(() => { + parseConnToken( + `invalid://${testPubkey.npub}?relay=wss%3A%2F%2Fyabu.me&relay=wss%3A%2F%2Fnrelay.c-stellar.net&secret=123456`, + ); + }).toThrowError(); + }); + test("URL token: invalid pubkey", () => { + expect(() => { + parseConnToken(`bunker://hoge`); + }).toThrowError(); + }); + test("legacy token: invalid pubkey", () => { + expect(() => { + parseConnToken(`hoge#123456`); + }).toThrowError(); + }); + }); +}); diff --git a/src/nip46.ts b/src/nip46.ts index 5d68418..3826f66 100644 --- a/src/nip46.ts +++ b/src/nip46.ts @@ -203,26 +203,63 @@ const nip46RpcResultDecoders: Nip46RpcResultDecoders = { export type Nip46ConnectionParams = { remotePubkey: string; secretToken?: string | undefined; - relayUrls?: string[] | undefined; + relayUrls: string[]; }; -const parseConnToken = (token: string): Nip46ConnectionParams => { +export const parseConnToken = (token: string): Nip46ConnectionParams => { + const isUriFormat = token.startsWith("bunker://"); + return isUriFormat ? parseUriConnToken(token) : parseLegacyConnToken(token); +}; + +// parse connection token (new URI format: bunker://?relay=wss://...&relay=wss://...&secret=) +const parseUriConnToken = (token: string): Nip46ConnectionParams => { + let u: URL; + try { + u = new URL(token); + } catch { + throw Error("invalid connection token"); + } + if (u.protocol !== "bunker:") { + throw Error("invalid connection token"); + } + + const rawPubkey = u.host; + const remotePubkey = parsePubkey(rawPubkey); + if (remotePubkey === undefined) { + throw Error("connection token contains invalid pubkey"); + } + const secretToken = u.searchParams.get("secret") ?? undefined; + const relayUrls = u.searchParams.getAll("relay"); + + return { + remotePubkey, + secretToken, + relayUrls, + }; +}; + +// parse connection token (legacy format: #?relay=wss://...&relay=wss://...) +const parseLegacyConnToken = (token: string): Nip46ConnectionParams => { let parts: { pubkey: string; secret?: string; relays?: string; }; + const splitRelaysPart = (s: string): [string, string] => { + const qi = s.indexOf("?"); + const ri = s.indexOf("relay="); + if (ri < qi) { + throw Error("invalid connection token"); + } + return s.split("?", 2) as [string, string]; + }; + if (token.includes("#")) { const [pubkey, rest] = token.split("#", 2) as [string, string]; - if (token.includes("?")) { - const qi = token.indexOf("?"); - const ri = token.indexOf("relay="); - if (ri < qi) { - throw Error("invalid connection token"); - } + if (rest.includes("?")) { // #? - const [secret, relays] = rest.split("?", 2) as [string, string]; + const [secret, relays] = splitRelaysPart(rest); parts = { pubkey, secret, relays }; } else { // # @@ -230,13 +267,8 @@ const parseConnToken = (token: string): Nip46ConnectionParams => { } } else { if (token.includes("?")) { - const qi = token.indexOf("?"); - const ri = token.indexOf("relay="); - if (ri < qi) { - throw Error("invalid connection token"); - } // ? - const [pubkey, relays] = token.split("?", 2) as [string, string]; + const [pubkey, relays] = splitRelaysPart(token); parts = { pubkey, relays }; } else { // @@ -249,10 +281,11 @@ const parseConnToken = (token: string): Nip46ConnectionParams => { throw Error("connection token contains invalid pubkey"); } - const relayUrls = parts.relays - ?.replace("relay=", "") - .split("&relay=") - .map((r) => decodeURIComponent(r)); + const relayUrls = + parts.relays + ?.replace("relay=", "") + .split("&relay=") + .map((r) => decodeURIComponent(r)) ?? []; try { relayUrls?.forEach((r) => new URL(r)); } catch {