authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:27:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:27:39-07:00
logee3e2dae7880c2f4f0d43e04305438e083f13650
tree8628fb1689b4a3101dbb18cd4684838beae64f99
parent9c98cd60e866fb576e7029cd1152f75aca06452c
signature Signed by PGP key B5690EEEBB952194

[codex] Structure remote pairing input errors (#3393)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 129 insertions(+), 10 deletions(-)

packages/shared/src/remote.test.ts+45-1
...@@ -1,6 +1,12 @@...@@ -1,6 +1,12 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { resolveRemotePairingTarget } from "./remote.ts";3import {
4 RemoteBackendUrlInvalidError,
5 RemoteBackendUrlMissingError,
6 RemotePairingTokenMissingError,
7 RemotePairingUrlInvalidError,
8 resolveRemotePairingTarget,
9} from "./remote.ts";
410
5describe("remote", () => {11describe("remote", () => {
6 it("derives backend urls and token from a pairing url", () => {12 it("derives backend urls and token from a pairing url", () => {
...@@ -65,4 +71,42 @@ describe("remote", () => {...@@ -65,4 +71,42 @@ describe("remote", () => {
65 wsBaseUrl: "wss://myserver.com:3000/",71 wsBaseUrl: "wss://myserver.com:3000/",
66 });72 });
67 });73 });
74
75 it("uses distinct structural errors for missing pairing inputs", () => {
76 expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError);
77 expect(() =>
78 resolveRemotePairingTarget({ pairingUrl: "https://remote.example.com/pair" }),
79 ).toThrowError(RemotePairingTokenMissingError);
80 expect(() =>
81 resolveRemotePairingTarget({
82 host: "https://user:secret@remote.example.com/path?token=sensitive#fragment",
83 }),
84 ).toThrowError(
85 expect.objectContaining({
86 _tag: "RemotePairingCodeMissingError",
87 host: "remote.example.com",
88 }),
89 );
90 });
91
92 it("preserves URL parsing causes with their input source", () => {
93 let pairingUrlError: unknown;
94 try {
95 resolveRemotePairingTarget({ pairingUrl: "not a url" });
96 } catch (cause) {
97 pairingUrlError = cause;
98 }
99 expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError);
100 expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeInstanceOf(TypeError);
101
102 let hostError: unknown;
103 try {
104 resolveRemotePairingTarget({ host: "https://[invalid", pairingCode: "pairing-token" });
105 } catch (cause) {
106 hostError = cause;
107 }
108 expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError);
109 expect(hostError).toMatchObject({ source: "direct-host" });
110 expect((hostError as RemoteBackendUrlInvalidError).cause).toBeInstanceOf(TypeError);
111 });
68});112});
packages/shared/src/remote.ts+84-9
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import * as Schema from "effect/Schema";
2
1const PAIRING_TOKEN_PARAM = "token";3const PAIRING_TOKEN_PARAM = "token";
2const HOSTED_PAIRING_HOST_PARAM = "host";4const HOSTED_PAIRING_HOST_PARAM = "host";
3const HOSTED_PAIRING_LABEL_PARAM = "label";5const HOSTED_PAIRING_LABEL_PARAM = "label";
...@@ -5,17 +7,82 @@ const HOSTED_PAIRING_LABEL_PARAM = "label";...@@ -5,17 +7,82 @@ const HOSTED_PAIRING_LABEL_PARAM = "label";
5const readHashParams = (url: URL): URLSearchParams =>7const readHashParams = (url: URL): URLSearchParams =>
6 new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);8 new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
79
8const normalizeRemoteBaseUrl = (rawValue: string): URL => {10export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass<RemoteBackendUrlMissingError>()(
11 "RemoteBackendUrlMissingError",
12 {},
13) {
14 override get message(): string {
15 return "Enter a backend URL.";
16 }
17}
18
19export class RemotePairingUrlInvalidError extends Schema.TaggedErrorClass<RemotePairingUrlInvalidError>()(
20 "RemotePairingUrlInvalidError",
21 { cause: Schema.Defect() },
22) {
23 override get message(): string {
24 return "Pairing URL is invalid.";
25 }
26}
27
28export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass<RemoteBackendUrlInvalidError>()(
29 "RemoteBackendUrlInvalidError",
30 {
31 source: Schema.Literals(["direct-host", "hosted-pairing-host"]),
32 cause: Schema.Defect(),
33 },
34) {
35 override get message(): string {
36 return "Backend URL is invalid.";
37 }
38}
39
40export class RemotePairingTokenMissingError extends Schema.TaggedErrorClass<RemotePairingTokenMissingError>()(
41 "RemotePairingTokenMissingError",
42 { host: Schema.String },
43) {
44 override get message(): string {
45 return "Pairing URL is missing its token.";
46 }
47}
48
49export class RemotePairingCodeMissingError extends Schema.TaggedErrorClass<RemotePairingCodeMissingError>()(
50 "RemotePairingCodeMissingError",
51 { host: Schema.String },
52) {
53 override get message(): string {
54 return "Enter a pairing code.";
55 }
56}
57
58export const RemotePairingTargetError = Schema.Union([
59 RemoteBackendUrlMissingError,
60 RemotePairingUrlInvalidError,
61 RemoteBackendUrlInvalidError,
62 RemotePairingTokenMissingError,
63 RemotePairingCodeMissingError,
64]);
65export type RemotePairingTargetError = typeof RemotePairingTargetError.Type;
66
67const normalizeRemoteBaseUrl = (
68 rawValue: string,
69 source: RemoteBackendUrlInvalidError["source"],
70): URL => {
9 const trimmed = rawValue.trim();71 const trimmed = rawValue.trim();
10 if (!trimmed) {72 if (!trimmed) {
11 throw new Error("Enter a backend URL.");73 throw new RemoteBackendUrlMissingError();
12 }74 }
1375
14 const normalizedInput =76 const normalizedInput =
15 /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//")77 /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//")
16 ? trimmed78 ? trimmed
17 : `https://${trimmed}`;79 : `https://${trimmed}`;
18 const url = new URL(normalizedInput);80 let url: URL;
81 try {
82 url = new URL(normalizedInput);
83 } catch (cause) {
84 throw new RemoteBackendUrlInvalidError({ source, cause });
85 }
19 url.pathname = "/";86 url.pathname = "/";
20 url.search = "";87 url.search = "";
21 url.hash = "";88 url.hash = "";
...@@ -111,10 +178,18 @@ export const resolveRemotePairingTarget = (input: {...@@ -111,10 +178,18 @@ export const resolveRemotePairingTarget = (input: {
111}): ResolvedRemotePairingTarget => {178}): ResolvedRemotePairingTarget => {
112 const pairingUrl = input.pairingUrl?.trim() ?? "";179 const pairingUrl = input.pairingUrl?.trim() ?? "";
113 if (pairingUrl.length > 0) {180 if (pairingUrl.length > 0) {
114 const url = new URL(pairingUrl);181 let url: URL;
182 try {
183 url = new URL(pairingUrl);
184 } catch (cause) {
185 throw new RemotePairingUrlInvalidError({ cause });
186 }
115 const hostedPairingRequest = readHostedPairingRequest(url);187 const hostedPairingRequest = readHostedPairingRequest(url);
116 if (hostedPairingRequest) {188 if (hostedPairingRequest) {
117 const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host);189 const hostedBackendUrl = normalizeRemoteBaseUrl(
190 hostedPairingRequest.host,
191 "hosted-pairing-host",
192 );
118 return {193 return {
119 credential: hostedPairingRequest.token,194 credential: hostedPairingRequest.token,
120 httpBaseUrl: toHttpBaseUrl(hostedBackendUrl),195 httpBaseUrl: toHttpBaseUrl(hostedBackendUrl),
...@@ -124,7 +199,7 @@ export const resolveRemotePairingTarget = (input: {...@@ -124,7 +199,7 @@ export const resolveRemotePairingTarget = (input: {
124199
125 const credential = getPairingTokenFromUrl(url) ?? "";200 const credential = getPairingTokenFromUrl(url) ?? "";
126 if (!credential) {201 if (!credential) {
127 throw new Error("Pairing URL is missing its token.");202 throw new RemotePairingTokenMissingError({ host: url.host });
128 }203 }
129 return {204 return {
130 credential,205 credential,
...@@ -136,13 +211,13 @@ export const resolveRemotePairingTarget = (input: {...@@ -136,13 +211,13 @@ export const resolveRemotePairingTarget = (input: {
136 const host = input.host?.trim() ?? "";211 const host = input.host?.trim() ?? "";
137 const pairingCode = input.pairingCode?.trim() ?? "";212 const pairingCode = input.pairingCode?.trim() ?? "";
138 if (!host) {213 if (!host) {
139 throw new Error("Enter a backend URL.");214 throw new RemoteBackendUrlMissingError();
140 }215 }
216 const normalizedHost = normalizeRemoteBaseUrl(host, "direct-host");
141 if (!pairingCode) {217 if (!pairingCode) {
142 throw new Error("Enter a pairing code.");218 throw new RemotePairingCodeMissingError({ host: normalizedHost.host });
143 }219 }
144220
145 const normalizedHost = normalizeRemoteBaseUrl(host);
146 return {221 return {
147 credential: pairingCode,222 credential: pairingCode,
148 httpBaseUrl: toHttpBaseUrl(normalizedHost),223 httpBaseUrl: toHttpBaseUrl(normalizedHost),