@relaymessenger/openclaw-plugin 0.3.0 → 0.3.4
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.
- package/dist/src/channel.js +110 -73
- package/dist/src/client.js +13 -219
- package/dist/src/inbound.js +5 -0
- package/dist/src/invocations.js +47 -0
- package/dist/src/outbound.js +2 -0
- package/dist/src/poll-loop.js +12 -0
- package/dist/src/responding.js +36 -0
- package/dist/src/state-files.js +114 -38
- package/dist/src/vendor/relay-sdk/client.js +163 -0
- package/dist/src/vendor/relay-sdk/errors.js +45 -0
- package/dist/src/vendor/relay-sdk/types.js +2 -0
- package/dist/src/vendor/relay-sdk/url.js +39 -0
- package/package.json +1 -1
- package/src/channel.ts +111 -74
- package/src/client.ts +38 -300
- package/src/inbound.ts +11 -0
- package/src/invocations.ts +58 -0
- package/src/outbound.ts +11 -4
- package/src/poll-loop.ts +12 -0
- package/src/responding.ts +52 -0
- package/src/state-files.ts +130 -44
- package/src/types.ts +15 -1
- package/src/vendor/relay-sdk/README.md +28 -0
- package/src/vendor/relay-sdk/client.ts +293 -0
- package/src/vendor/relay-sdk/errors.ts +61 -0
- package/src/vendor/relay-sdk/types.ts +82 -0
- package/src/vendor/relay-sdk/url.ts +43 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RelayApiError,
|
|
3
|
+
classifyRelayHttpStatus,
|
|
4
|
+
isAbortError,
|
|
5
|
+
} from "./errors.js";
|
|
6
|
+
import type {
|
|
7
|
+
RelayAgentProfile,
|
|
8
|
+
RelayEventsPage,
|
|
9
|
+
RelayOutgoingPart,
|
|
10
|
+
RelayReplyRef,
|
|
11
|
+
RelaySendResult,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
import { normalizeRelayBaseUrl } from "./url.js";
|
|
14
|
+
|
|
15
|
+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
16
|
+
|
|
17
|
+
export type RelayClientOptions = {
|
|
18
|
+
token: string;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
fetchImpl?: FetchLike;
|
|
21
|
+
requestTimeoutMs?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type RelayClient = {
|
|
25
|
+
readonly baseUrl: string;
|
|
26
|
+
getMe: (params?: { signal?: AbortSignal }) => Promise<RelayAgentProfile>;
|
|
27
|
+
pollEvents: (params: {
|
|
28
|
+
cursor: number;
|
|
29
|
+
timeoutSeconds?: number;
|
|
30
|
+
limit?: number;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}) => Promise<RelayEventsPage>;
|
|
33
|
+
sendMessage: (params: {
|
|
34
|
+
conversationId: string;
|
|
35
|
+
parts: RelayOutgoingPart[];
|
|
36
|
+
replyTo?: RelayReplyRef;
|
|
37
|
+
invocationId?: string;
|
|
38
|
+
idempotencyKey: string;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}) => Promise<RelaySendResult>;
|
|
41
|
+
sendText: (params: {
|
|
42
|
+
conversationId: string;
|
|
43
|
+
text: string;
|
|
44
|
+
replyTo?: RelayReplyRef;
|
|
45
|
+
invocationId?: string;
|
|
46
|
+
idempotencyKey: string;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}) => Promise<RelaySendResult>;
|
|
49
|
+
setTyping: (params: {
|
|
50
|
+
conversationId: string;
|
|
51
|
+
started: boolean;
|
|
52
|
+
label?: string;
|
|
53
|
+
invocationId?: string;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}) => Promise<void>;
|
|
56
|
+
setResponding: (params: {
|
|
57
|
+
conversationId: string;
|
|
58
|
+
messageId: string;
|
|
59
|
+
label?: string;
|
|
60
|
+
invocationId?: string;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}) => Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Advance the delivered watermark to `messageId`, and every earlier message
|
|
65
|
+
* from other participants with it.
|
|
66
|
+
*
|
|
67
|
+
* Most agents never call this. Delivered means the agent's endpoint has the
|
|
68
|
+
* message, so Relay records it from the transport itself: a webhook gets it
|
|
69
|
+
* when the endpoint answers `2xx`, and a `GET /v1/events` consumer gets it
|
|
70
|
+
* when the cursor moves past the event. Neither needs a line of code, and
|
|
71
|
+
* neither can suppress it.
|
|
72
|
+
*
|
|
73
|
+
* The exception is a transcript poller — a client that reads
|
|
74
|
+
* `GET /v1/conversations/:id/messages` on a timer. Reading history records
|
|
75
|
+
* no receipt, so nothing on the server ever learns the message arrived.
|
|
76
|
+
* That client, and only that client, has to say so itself.
|
|
77
|
+
*
|
|
78
|
+
* Send it on ingest, before anything that implies a read. The server
|
|
79
|
+
* advances the delivered watermark whenever it records a read, so a
|
|
80
|
+
* delivered receipt that arrives after a read for the same message is
|
|
81
|
+
* silently dropped: the sender goes straight from "Sent" to "Read" and never
|
|
82
|
+
* sees "Delivered". Skipping this call costs the middle rung of the ladder,
|
|
83
|
+
* not the top one.
|
|
84
|
+
*/
|
|
85
|
+
markDelivered: (params: {
|
|
86
|
+
conversationId: string;
|
|
87
|
+
messageId: string;
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
}) => Promise<void>;
|
|
90
|
+
markRead: (params: {
|
|
91
|
+
conversationId: string;
|
|
92
|
+
messageId: string;
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
}) => Promise<void>;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
async function readErrorDetail(response: Response): Promise<{
|
|
98
|
+
code?: string;
|
|
99
|
+
message: string;
|
|
100
|
+
details?: Record<string, unknown>;
|
|
101
|
+
}> {
|
|
102
|
+
try {
|
|
103
|
+
const body = (await response.json()) as {
|
|
104
|
+
error?: { code?: string; message?: string; details?: Record<string, unknown> };
|
|
105
|
+
message?: string;
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
...(body?.error?.code ? { code: body.error.code } : {}),
|
|
109
|
+
...(body?.error?.details ? { details: body.error.details } : {}),
|
|
110
|
+
message: body?.error?.message ?? body?.message ?? "",
|
|
111
|
+
};
|
|
112
|
+
} catch {
|
|
113
|
+
return { message: "" };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createRelayClient(options: RelayClientOptions): RelayClient {
|
|
118
|
+
if (!options.token.trim()) {
|
|
119
|
+
throw new Error("relay: Agent Token is required");
|
|
120
|
+
}
|
|
121
|
+
const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
|
|
122
|
+
const fetchImpl: FetchLike =
|
|
123
|
+
options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
124
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
125
|
+
|
|
126
|
+
const request = async (params: {
|
|
127
|
+
method: string;
|
|
128
|
+
path: string;
|
|
129
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
130
|
+
body?: unknown;
|
|
131
|
+
headers?: Record<string, string>;
|
|
132
|
+
signal?: AbortSignal;
|
|
133
|
+
timeoutMs?: number;
|
|
134
|
+
}): Promise<Response> => {
|
|
135
|
+
const url = new URL(`${baseUrl}${params.path}`);
|
|
136
|
+
for (const [key, value] of Object.entries(params.query ?? {})) {
|
|
137
|
+
if (value !== undefined) url.searchParams.set(key, String(value));
|
|
138
|
+
}
|
|
139
|
+
const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
|
|
140
|
+
const signal = params.signal
|
|
141
|
+
? AbortSignal.any([params.signal, timeoutSignal])
|
|
142
|
+
: timeoutSignal;
|
|
143
|
+
let response: Response;
|
|
144
|
+
try {
|
|
145
|
+
response = await fetchImpl(url.toString(), {
|
|
146
|
+
method: params.method,
|
|
147
|
+
headers: {
|
|
148
|
+
authorization: `Bearer ${options.token}`,
|
|
149
|
+
...(params.body === undefined ? {} : { "content-type": "application/json" }),
|
|
150
|
+
...params.headers,
|
|
151
|
+
},
|
|
152
|
+
...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
|
|
153
|
+
signal,
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (timeoutSignal.aborted && !params.signal?.aborted) {
|
|
157
|
+
throw new RelayApiError(
|
|
158
|
+
`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`,
|
|
159
|
+
{ kind: "retryable" },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (isAbortError(error)) throw error;
|
|
163
|
+
throw new RelayApiError(`relay: network error: ${String(error)}`, {
|
|
164
|
+
kind: "retryable",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const detail = await readErrorDetail(response);
|
|
169
|
+
throw new RelayApiError(
|
|
170
|
+
`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`,
|
|
171
|
+
{
|
|
172
|
+
status: response.status,
|
|
173
|
+
kind: classifyRelayHttpStatus(response.status),
|
|
174
|
+
...(detail.code ? { code: detail.code } : {}),
|
|
175
|
+
...(detail.details ? { details: detail.details } : {}),
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return response;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const client: RelayClient = {
|
|
183
|
+
baseUrl,
|
|
184
|
+
|
|
185
|
+
getMe: async (params) => {
|
|
186
|
+
const response = await request({
|
|
187
|
+
method: "GET",
|
|
188
|
+
path: "/v1/agents/me",
|
|
189
|
+
...(params?.signal ? { signal: params.signal } : {}),
|
|
190
|
+
});
|
|
191
|
+
const body = (await response.json()) as { agent: RelayAgentProfile };
|
|
192
|
+
return body.agent;
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
pollEvents: async (params) => {
|
|
196
|
+
const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
|
|
197
|
+
const response = await request({
|
|
198
|
+
method: "GET",
|
|
199
|
+
path: "/v1/events",
|
|
200
|
+
query: {
|
|
201
|
+
cursor: params.cursor,
|
|
202
|
+
timeout: timeoutSeconds,
|
|
203
|
+
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
204
|
+
},
|
|
205
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
206
|
+
timeoutMs: (timeoutSeconds + 15) * 1_000,
|
|
207
|
+
});
|
|
208
|
+
const body = (await response.json()) as {
|
|
209
|
+
events?: RelayEventsPage["events"];
|
|
210
|
+
next_cursor?: number;
|
|
211
|
+
};
|
|
212
|
+
const events = Array.isArray(body.events) ? body.events : [];
|
|
213
|
+
const nextCursor =
|
|
214
|
+
typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
|
|
215
|
+
? body.next_cursor
|
|
216
|
+
: params.cursor;
|
|
217
|
+
return { events, nextCursor };
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
sendMessage: async (params) => {
|
|
221
|
+
const response = await request({
|
|
222
|
+
method: "POST",
|
|
223
|
+
path: "/v1/messages",
|
|
224
|
+
headers: { "idempotency-key": params.idempotencyKey },
|
|
225
|
+
body: {
|
|
226
|
+
conversation_id: params.conversationId,
|
|
227
|
+
parts: params.parts,
|
|
228
|
+
...(params.invocationId ? { invocation_id: params.invocationId } : {}),
|
|
229
|
+
...(params.replyTo ? { reply_to: params.replyTo } : {}),
|
|
230
|
+
},
|
|
231
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
232
|
+
});
|
|
233
|
+
const body = (await response.json()) as {
|
|
234
|
+
messages: RelaySendResult["messages"];
|
|
235
|
+
};
|
|
236
|
+
return { messages: body.messages };
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
sendText: async (params) => {
|
|
240
|
+
const { text, ...rest } = params;
|
|
241
|
+
return client.sendMessage({
|
|
242
|
+
...rest,
|
|
243
|
+
parts: [{ type: "text", text }],
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
setTyping: async (params) => {
|
|
248
|
+
await request({
|
|
249
|
+
method: "POST",
|
|
250
|
+
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
|
|
251
|
+
body: {
|
|
252
|
+
started: params.started,
|
|
253
|
+
...(params.label ? { label: params.label } : {}),
|
|
254
|
+
...(params.invocationId ? { invocation_id: params.invocationId } : {}),
|
|
255
|
+
},
|
|
256
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
setResponding: async (params) => {
|
|
261
|
+
await request({
|
|
262
|
+
method: "POST",
|
|
263
|
+
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
|
|
264
|
+
body: {
|
|
265
|
+
message_id: params.messageId,
|
|
266
|
+
...(params.label ? { label: params.label } : {}),
|
|
267
|
+
...(params.invocationId ? { invocation_id: params.invocationId } : {}),
|
|
268
|
+
},
|
|
269
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
markDelivered: async (params) => {
|
|
274
|
+
await request({
|
|
275
|
+
method: "POST",
|
|
276
|
+
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/delivered`,
|
|
277
|
+
body: { message_id: params.messageId },
|
|
278
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
markRead: async (params) => {
|
|
283
|
+
await request({
|
|
284
|
+
method: "POST",
|
|
285
|
+
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
|
|
286
|
+
body: { message_id: params.messageId },
|
|
287
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return client;
|
|
293
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type RelayApiErrorKind = "auth" | "conflict" | "retryable" | "rejected";
|
|
2
|
+
|
|
3
|
+
/** Classified Relay API failure. `terminal` means retrying the same request cannot succeed. */
|
|
4
|
+
export class RelayApiError extends Error {
|
|
5
|
+
readonly status: number | undefined;
|
|
6
|
+
readonly kind: RelayApiErrorKind;
|
|
7
|
+
readonly code: string | undefined;
|
|
8
|
+
/** Structured `error.details` from the response body, e.g. `highest_delivered_cursor` on 422. */
|
|
9
|
+
readonly details: Record<string, unknown> | undefined;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
message: string,
|
|
13
|
+
params: {
|
|
14
|
+
status?: number;
|
|
15
|
+
kind: RelayApiErrorKind;
|
|
16
|
+
code?: string;
|
|
17
|
+
details?: Record<string, unknown>;
|
|
18
|
+
},
|
|
19
|
+
) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "RelayApiError";
|
|
22
|
+
this.status = params.status;
|
|
23
|
+
this.kind = params.kind;
|
|
24
|
+
this.code = params.code;
|
|
25
|
+
this.details = params.details;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get terminal(): boolean {
|
|
29
|
+
return this.kind !== "retryable";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get retryable(): boolean {
|
|
33
|
+
return this.kind === "retryable";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class WebhookVerificationError extends Error {
|
|
38
|
+
constructor(message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "WebhookVerificationError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function classifyRelayHttpStatus(status: number): RelayApiErrorKind {
|
|
45
|
+
if (status === 401) return "auth";
|
|
46
|
+
if (status === 409) return "conflict";
|
|
47
|
+
if (status === 408 || status === 429 || status >= 500) return "retryable";
|
|
48
|
+
return "rejected";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isAbortError(error: unknown): boolean {
|
|
52
|
+
return error instanceof Error && error.name === "AbortError";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isRelayWebhookConflict(error: unknown): error is RelayApiError {
|
|
56
|
+
return (
|
|
57
|
+
error instanceof RelayApiError &&
|
|
58
|
+
error.status === 409 &&
|
|
59
|
+
error.code !== "terminated_by_other_consumer"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Wire types for the Relay v1 developer API used by plugins and examples. */
|
|
2
|
+
|
|
3
|
+
export type RelaySender = {
|
|
4
|
+
kind: "user" | "agent" | "system";
|
|
5
|
+
id: string;
|
|
6
|
+
display_name?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type RelayOutgoingPart =
|
|
10
|
+
| { type: "text"; text: string }
|
|
11
|
+
| { type: "media"; attachment_id?: string; url?: string }
|
|
12
|
+
| { type: "voice_memo"; attachment_id?: string; url?: string; duration_ms?: number }
|
|
13
|
+
| { type: "link_preview"; url: string }
|
|
14
|
+
| { type: "data"; data: Record<string, unknown> };
|
|
15
|
+
|
|
16
|
+
export type RelayPart = {
|
|
17
|
+
type: string;
|
|
18
|
+
part_index?: number;
|
|
19
|
+
text?: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
attachment_id?: string;
|
|
22
|
+
duration_ms?: number;
|
|
23
|
+
data?: Record<string, unknown>;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type RelayReplyRef = {
|
|
28
|
+
message_id: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type RelayMessage = {
|
|
32
|
+
id: string;
|
|
33
|
+
conversation_id: string;
|
|
34
|
+
sequence: number;
|
|
35
|
+
sender: RelaySender;
|
|
36
|
+
parts: RelayPart[];
|
|
37
|
+
reply_to?: RelayReplyRef | null;
|
|
38
|
+
fallback_text?: string;
|
|
39
|
+
status?: string;
|
|
40
|
+
created_at: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type RelayEventEnvelope<TData = Record<string, unknown>> = {
|
|
44
|
+
event_id: string;
|
|
45
|
+
event_type: string;
|
|
46
|
+
agent_id: string;
|
|
47
|
+
created_at: string;
|
|
48
|
+
data: TData;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type MessageReceivedData = {
|
|
52
|
+
message: RelayMessage;
|
|
53
|
+
invocation_id?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type MessageReceivedEvent = RelayEventEnvelope<MessageReceivedData>;
|
|
57
|
+
|
|
58
|
+
export type RelayAgentProfile = {
|
|
59
|
+
id: string;
|
|
60
|
+
owner_user_id?: string;
|
|
61
|
+
handle: string;
|
|
62
|
+
display_name: string;
|
|
63
|
+
tagline?: string;
|
|
64
|
+
avatar_url?: string | null;
|
|
65
|
+
visibility?: "private" | "unlisted" | "public";
|
|
66
|
+
created_at?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type RelayEventsPage = {
|
|
70
|
+
events: RelayEventEnvelope[];
|
|
71
|
+
nextCursor: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The 202 from `POST /v1/messages`. The server splits the accepted parts at
|
|
76
|
+
* ingest: each visible non-media part becomes its own message, contiguous
|
|
77
|
+
* media parts stay one media message, and a voice memo always commits alone,
|
|
78
|
+
* so one send commits one or more messages, in display order.
|
|
79
|
+
*/
|
|
80
|
+
export type RelaySendResult = {
|
|
81
|
+
messages: RelayMessage[];
|
|
82
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
|
|
4
|
+
|
|
5
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
6
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
7
|
+
const ipVersion = isIP(normalized);
|
|
8
|
+
if (ipVersion === 4) return normalized.split(".")[0] === "127";
|
|
9
|
+
if (ipVersion === 6) return normalized === "::1";
|
|
10
|
+
return normalized === "localhost" || normalized.endsWith(".localhost");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validate and canonicalize the API origin before a bearer token can be sent.
|
|
15
|
+
* Remote origins must use HTTPS. Plain HTTP is allowed only on loopback.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeRelayBaseUrl(raw?: string): string {
|
|
18
|
+
const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
|
|
19
|
+
let url: URL;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(candidate);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
|
|
24
|
+
}
|
|
25
|
+
if (url.username || url.password) {
|
|
26
|
+
throw new Error("relay: baseUrl must not contain credentials");
|
|
27
|
+
}
|
|
28
|
+
if (url.search || url.hash) {
|
|
29
|
+
throw new Error("relay: baseUrl must not contain a query or fragment");
|
|
30
|
+
}
|
|
31
|
+
if (!/^\/+$/u.test(url.pathname)) {
|
|
32
|
+
throw new Error("relay: baseUrl must be an origin without a path");
|
|
33
|
+
}
|
|
34
|
+
if (
|
|
35
|
+
url.protocol !== "https:" &&
|
|
36
|
+
!(url.protocol === "http:" && isLoopbackHostname(url.hostname))
|
|
37
|
+
) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return url.origin;
|
|
43
|
+
}
|