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 @@
11import { 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
511describe("remote", () => {
612 it("derives backend urls and token from a pairing url", () => {
......@@ -65,4 +71,42 @@ describe("remote", () => {
6571 wsBaseUrl: "wss://myserver.com:3000/",
6672 });
6773 });
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 });
68112});
packages/shared/src/remote.ts+84-9
......@@ -1,3 +1,5 @@
1import * as Schema from "effect/Schema";
2
13const PAIRING_TOKEN_PARAM = "token";
24const HOSTED_PAIRING_HOST_PARAM = "host";
35const HOSTED_PAIRING_LABEL_PARAM = "label";
......@@ -5,17 +7,82 @@ const HOSTED_PAIRING_LABEL_PARAM = "label";
57const readHashParams = (url: URL): URLSearchParams =>
68 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 => {
971 const trimmed = rawValue.trim();
1072 if (!trimmed) {
11 throw new Error("Enter a backend URL.");
73 throw new RemoteBackendUrlMissingError();
1274 }
1375
1476 const normalizedInput =
1577 /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//")
1678 ? trimmed
1779 : `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 }
1986 url.pathname = "/";
2087 url.search = "";
2188 url.hash = "";
......@@ -111,10 +178,18 @@ export const resolveRemotePairingTarget = (input: {
111178}): ResolvedRemotePairingTarget => {
112179 const pairingUrl = input.pairingUrl?.trim() ?? "";
113180 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 }
115187 const hostedPairingRequest = readHostedPairingRequest(url);
116188 if (hostedPairingRequest) {
117 const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host);
189 const hostedBackendUrl = normalizeRemoteBaseUrl(
190 hostedPairingRequest.host,
191 "hosted-pairing-host",
192 );
118193 return {
119194 credential: hostedPairingRequest.token,
120195 httpBaseUrl: toHttpBaseUrl(hostedBackendUrl),
......@@ -124,7 +199,7 @@ export const resolveRemotePairingTarget = (input: {
124199
125200 const credential = getPairingTokenFromUrl(url) ?? "";
126201 if (!credential) {
127 throw new Error("Pairing URL is missing its token.");
202 throw new RemotePairingTokenMissingError({ host: url.host });
128203 }
129204 return {
130205 credential,
......@@ -136,13 +211,13 @@ export const resolveRemotePairingTarget = (input: {
136211 const host = input.host?.trim() ?? "";
137212 const pairingCode = input.pairingCode?.trim() ?? "";
138213 if (!host) {
139 throw new Error("Enter a backend URL.");
214 throw new RemoteBackendUrlMissingError();
140215 }
216 const normalizedHost = normalizeRemoteBaseUrl(host, "direct-host");
141217 if (!pairingCode) {
142 throw new Error("Enter a pairing code.");
218 throw new RemotePairingCodeMissingError({ host: normalizedHost.host });
143219 }
144220
145 const normalizedHost = normalizeRemoteBaseUrl(host);
146221 return {
147222 credential: pairingCode,
148223 httpBaseUrl: toHttpBaseUrl(normalizedHost),