@vellumai/cli 0.10.11-dev.202607222325.35fc367 → 0.10.11-dev.202607230140.b414d01
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -100,3 +100,93 @@ export interface RemoteWebPairingTokenApprovedResponse {
|
|
|
100
100
|
export type RemoteWebPairingTokenResponse =
|
|
101
101
|
| RemoteWebPairingTokenPendingResponse
|
|
102
102
|
| RemoteWebPairingTokenApprovedResponse;
|
|
103
|
+
|
|
104
|
+
// ── Shared pairing URL helpers ──────────────────────────────────────────────
|
|
105
|
+
//
|
|
106
|
+
// Every surface that mints a pairing (the `vellum pair --qr` CLI, the web
|
|
107
|
+
// settings "Pair a device" card) must accept the same public URLs and build
|
|
108
|
+
// the same scannable links. The helpers are environment-neutral (WHATWG URL
|
|
109
|
+
// only) so both Node and browser callers share one implementation.
|
|
110
|
+
|
|
111
|
+
/** Why a public base URL can't be advertised in a pairing challenge. */
|
|
112
|
+
export type PublicBaseUrlRejection = "unparseable" | "loopback" | "non-https";
|
|
113
|
+
|
|
114
|
+
export type PublicBaseUrlResult =
|
|
115
|
+
| { ok: true; url: string }
|
|
116
|
+
| { ok: false; reason: PublicBaseUrlRejection };
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
|
|
120
|
+
* encodes a loopback address is unreachable from the scanning device.
|
|
121
|
+
*/
|
|
122
|
+
export function isLoopbackPublicUrl(url: string): boolean {
|
|
123
|
+
try {
|
|
124
|
+
// WHATWG URL canonicalizes hostnames, so IPv6 loopback is always "[::1]".
|
|
125
|
+
const hostname = new URL(url).hostname;
|
|
126
|
+
return (
|
|
127
|
+
hostname === "localhost" ||
|
|
128
|
+
hostname === "[::1]" ||
|
|
129
|
+
/^127(?:\.\d{1,3}){3}$/.test(hostname)
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize an address to the public base a scanning device opens:
|
|
138
|
+
* query/hash stripped, the `assistant` path segment (and everything after it)
|
|
139
|
+
* removed so a pasted pair-page URL collapses to its base, and trailing
|
|
140
|
+
* slashes trimmed. Throws if the value is not a parseable URL.
|
|
141
|
+
*/
|
|
142
|
+
export function normalizePublicBaseUrl(value: string): string {
|
|
143
|
+
const url = new URL(value);
|
|
144
|
+
url.search = "";
|
|
145
|
+
url.hash = "";
|
|
146
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
147
|
+
const assistantIndex = parts.indexOf("assistant");
|
|
148
|
+
if (assistantIndex >= 0) {
|
|
149
|
+
parts.splice(assistantIndex);
|
|
150
|
+
}
|
|
151
|
+
url.pathname = parts.length ? `/${parts.join("/")}` : "/";
|
|
152
|
+
return url.toString().replace(/\/+$/, "");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve an address to the public https base URL to advertise in a pairing
|
|
157
|
+
* challenge, or report why it can't be used. Loopback and non-https links are
|
|
158
|
+
* refused with a specific reason callers turn into their own guidance.
|
|
159
|
+
*/
|
|
160
|
+
export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
|
|
161
|
+
let normalized: string;
|
|
162
|
+
try {
|
|
163
|
+
normalized = normalizePublicBaseUrl(raw);
|
|
164
|
+
} catch {
|
|
165
|
+
return { ok: false, reason: "unparseable" };
|
|
166
|
+
}
|
|
167
|
+
if (isLoopbackPublicUrl(normalized)) {
|
|
168
|
+
return { ok: false, reason: "loopback" };
|
|
169
|
+
}
|
|
170
|
+
if (new URL(normalized).protocol !== "https:") {
|
|
171
|
+
return { ok: false, reason: "non-https" };
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, url: normalized };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The scannable pair URL: the challenge's verification URI with the device
|
|
178
|
+
* code carried in the fragment (`#device_code=…`), matching what the pair
|
|
179
|
+
* page reads on load. Fragments never reach the wire.
|
|
180
|
+
*/
|
|
181
|
+
export function buildRemoteWebPairingUrl(
|
|
182
|
+
challenge: Pick<
|
|
183
|
+
RemoteWebPairingChallengeResponse,
|
|
184
|
+
"verificationUri" | "deviceCode"
|
|
185
|
+
>,
|
|
186
|
+
): string {
|
|
187
|
+
const url = new URL(challenge.verificationUri);
|
|
188
|
+
url.hash = new URLSearchParams({
|
|
189
|
+
device_code: challenge.deviceCode,
|
|
190
|
+
}).toString();
|
|
191
|
+
return url.toString();
|
|
192
|
+
}
|
package/package.json
CHANGED
package/src/commands/pair.ts
CHANGED
|
@@ -15,11 +15,15 @@ import { nanoid } from "nanoid";
|
|
|
15
15
|
// error-correction level off `this`, so a destructured import renders nothing.
|
|
16
16
|
import qrcodeTerminal from "qrcode-terminal";
|
|
17
17
|
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
import {
|
|
19
|
+
buildRemoteWebPairingUrl,
|
|
20
|
+
normalizePublicBaseUrl,
|
|
21
|
+
resolvePublicBaseUrl,
|
|
22
|
+
type PublicBaseUrlRejection,
|
|
23
|
+
type RemoteWebPairingChallengeRequest,
|
|
24
|
+
type RemoteWebPairingChallengeResponse,
|
|
25
|
+
type RemoteWebPairingVerificationRequest,
|
|
26
|
+
type RemoteWebPairingVerificationResponse,
|
|
23
27
|
} from "@vellumai/service-contracts/remote-web-pairing";
|
|
24
28
|
|
|
25
29
|
import { extractFlag } from "../lib/arg-utils.js";
|
|
@@ -127,29 +131,6 @@ interface PairResponse {
|
|
|
127
131
|
refreshAfter?: string;
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
function normalizePublicBaseUrl(value: string): string {
|
|
131
|
-
const url = new URL(value);
|
|
132
|
-
url.search = "";
|
|
133
|
-
url.hash = "";
|
|
134
|
-
const parts = url.pathname.split("/").filter(Boolean);
|
|
135
|
-
const assistantIndex = parts.indexOf("assistant");
|
|
136
|
-
if (assistantIndex >= 0) {
|
|
137
|
-
parts.splice(assistantIndex);
|
|
138
|
-
}
|
|
139
|
-
url.pathname = parts.length ? `/${parts.join("/")}` : "/";
|
|
140
|
-
return url.toString().replace(/\/+$/, "");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function buildRemoteWebPairingUrl(
|
|
144
|
-
challenge: RemoteWebPairingChallengeResponse,
|
|
145
|
-
): string {
|
|
146
|
-
const url = new URL(challenge.verificationUri);
|
|
147
|
-
url.hash = new URLSearchParams({
|
|
148
|
-
device_code: challenge.deviceCode,
|
|
149
|
-
}).toString();
|
|
150
|
-
return url.toString();
|
|
151
|
-
}
|
|
152
|
-
|
|
153
134
|
/** Default URL scheme registered by production builds of the iOS app. */
|
|
154
135
|
const DEFAULT_APP_CONNECT_SCHEME = "vellum-assistant";
|
|
155
136
|
|
|
@@ -167,35 +148,6 @@ export function buildAppConnectUrl(
|
|
|
167
148
|
return `${scheme}://connect?${params.toString()}`;
|
|
168
149
|
}
|
|
169
150
|
|
|
170
|
-
type QrPublicBaseUrlFailureReason = "unparseable" | "loopback" | "non-https";
|
|
171
|
-
|
|
172
|
-
type QrPublicBaseUrlResult =
|
|
173
|
-
| { ok: true; url: string }
|
|
174
|
-
| { ok: false; reason: QrPublicBaseUrlFailureReason };
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Normalize the advertised URL to the public https origin a scanning phone can
|
|
178
|
-
* open, or report why it isn't internet-reachable. Stricter than the copy-paste
|
|
179
|
-
* bundle path's loopback guard: a QR that encodes a loopback or plain-http link
|
|
180
|
-
* is unusable from another device, so both are refused. The failure reason is
|
|
181
|
-
* returned so the caller can emit an accurate message per case.
|
|
182
|
-
*/
|
|
183
|
-
function resolveQrPublicBaseUrl(advertisedUrl: string): QrPublicBaseUrlResult {
|
|
184
|
-
let normalized: string;
|
|
185
|
-
try {
|
|
186
|
-
normalized = normalizePublicBaseUrl(advertisedUrl);
|
|
187
|
-
} catch {
|
|
188
|
-
return { ok: false, reason: "unparseable" };
|
|
189
|
-
}
|
|
190
|
-
if (isLoopbackUrl(normalized)) {
|
|
191
|
-
return { ok: false, reason: "loopback" };
|
|
192
|
-
}
|
|
193
|
-
if (new URL(normalized).protocol !== "https:") {
|
|
194
|
-
return { ok: false, reason: "non-https" };
|
|
195
|
-
}
|
|
196
|
-
return { ok: true, url: normalized };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
151
|
/**
|
|
200
152
|
* POST a JSON body to a loopback gateway route, exiting with a clear message
|
|
201
153
|
* when the gateway is unreachable or answers non-2xx. Every pairing subcommand
|
|
@@ -493,9 +445,9 @@ export async function pair(): Promise<void> {
|
|
|
493
445
|
if (qrPairing) {
|
|
494
446
|
// Validate the public URL before any network call — a QR that encodes a
|
|
495
447
|
// loopback or plain-http link is unscannable from another device.
|
|
496
|
-
const qrResult =
|
|
448
|
+
const qrResult = resolvePublicBaseUrl(advertisedUrl);
|
|
497
449
|
if (!qrResult.ok) {
|
|
498
|
-
const detailByReason: Record<
|
|
450
|
+
const detailByReason: Record<PublicBaseUrlRejection, string> = {
|
|
499
451
|
unparseable: `${advertisedUrl} isn't a valid URL`,
|
|
500
452
|
loopback: `${advertisedUrl} is a loopback address`,
|
|
501
453
|
"non-https": `${advertisedUrl} is not https`,
|