pi-harness-runtime 1.1.387 → 1.1.388
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/AGENTS.md +9 -4
- package/index.ts +20 -18
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/dist/workspace-env-format.d.ts +71 -0
- package/packages/contracts/dist/workspace-env-format.js +120 -0
- package/packages/contracts/dist/workspace-env.d.ts +7 -30
- package/packages/contracts/dist/workspace-env.js +18 -57
- package/packages/contracts/package.json +1 -1
- package/packages/contracts/src/workspace-env-format.ts +165 -0
- package/packages/contracts/src/workspace-env.ts +27 -99
- package/packages/contracts/test/workspace-env.test.ts +144 -0
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/dist/index.d.ts +3 -0
- package/packages/relay/dist/index.js +3 -0
- package/packages/relay/dist/relay-endpoint.d.ts +94 -0
- package/packages/relay/dist/relay-endpoint.js +239 -0
- package/packages/relay/package.json +1 -1
- package/packages/relay/src/index.ts +17 -0
- package/packages/relay/src/relay-endpoint.ts +304 -0
- package/packages/relay/test/relay-endpoint.test.ts +278 -0
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/tui/test/harness-footer.test.ts +1 -1
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/relay-client.ts +24 -4
- package/scripts/unified-webhook-server.ts +29 -22
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay endpoint resolution.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the relay WebSocket URL and the auth token from the SAME sources
|
|
5
|
+
* the relay server uses (see scripts/unified-webhook-server.ts), so a client
|
|
6
|
+
* and the server always agree:
|
|
7
|
+
*
|
|
8
|
+
* URL: RELAY_WS_URL env -> keys/relay-ws-url.txt -> derived from keys/telegram-webhook-url.txt
|
|
9
|
+
* Token: RELAY_AUTH_TOKEN env -> keys/relay-auth-token.txt -> keys/telegram-webhook-secret.txt
|
|
10
|
+
*
|
|
11
|
+
* The token value is never logged: only the NAME of the source it came from.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
/** Environment variables consulted by the resolver. */
|
|
18
|
+
export interface RelayEndpointEnv {
|
|
19
|
+
RELAY_WS_URL?: string;
|
|
20
|
+
RELAY_AUTH_TOKEN?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Result of a relay endpoint resolution. */
|
|
24
|
+
export interface RelayEndpointResolution {
|
|
25
|
+
/** WebSocket URL, or null when it could not be resolved. */
|
|
26
|
+
url: string | null;
|
|
27
|
+
/** Where the URL came from (e.g. "env RELAY_WS_URL"). Never contains a secret. */
|
|
28
|
+
urlSource: string | null;
|
|
29
|
+
/** Auth token, or null when it could not be resolved. */
|
|
30
|
+
token: string | null;
|
|
31
|
+
/** Where the token came from (e.g. "keys/telegram-webhook-secret.txt"). Never contains the token. */
|
|
32
|
+
tokenSource: string | null;
|
|
33
|
+
/** Human readable description of what is missing (never contains a token). */
|
|
34
|
+
missing: string[];
|
|
35
|
+
/** Human readable caveats about the resolved endpoint (never contains a token). */
|
|
36
|
+
notes: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Options for {@link resolveRelayEndpoint}. */
|
|
40
|
+
export interface ResolveRelayEndpointOptions {
|
|
41
|
+
/** Environment to read (defaults to process.env). */
|
|
42
|
+
env?: RelayEndpointEnv;
|
|
43
|
+
/** Directory holding the key files (e.g. ~/.pi-harness-runtime/keys). */
|
|
44
|
+
keysDir: string;
|
|
45
|
+
/** Optional file reader override (used by the tests). */
|
|
46
|
+
readFile?: (path: string) => string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Source names, in the order they are consulted. */
|
|
50
|
+
export const RELAY_URL_SOURCES = [
|
|
51
|
+
"env RELAY_WS_URL",
|
|
52
|
+
"keys/relay-ws-url.txt",
|
|
53
|
+
"keys/telegram-webhook-url.txt (derived)",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export const RELAY_TOKEN_SOURCES = [
|
|
57
|
+
"env RELAY_AUTH_TOKEN",
|
|
58
|
+
"keys/relay-auth-token.txt",
|
|
59
|
+
"keys/telegram-webhook-secret.txt",
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The token sources are the SAME chain the relay server uses, so the client
|
|
64
|
+
* and the server can never disagree about the credential.
|
|
65
|
+
*
|
|
66
|
+
* Note: `keys/telegram-webhook-secret.txt` is also the secret the Telegram
|
|
67
|
+
* webhook expects in its `x-telegram-bot-api-secret-token` header. Sharing it
|
|
68
|
+
* with the WebSocket relay widens the blast radius of that secret - put a
|
|
69
|
+
* dedicated token in `keys/relay-auth-token.txt` (it is preferred) when the
|
|
70
|
+
* relay and the webhook do not have to share one.
|
|
71
|
+
*
|
|
72
|
+
* The Telegram webhook secret itself may also come from the environment
|
|
73
|
+
* variable TELEGRAM_WEBHOOK_SECRET; that variable belongs to the HTTP
|
|
74
|
+
* webhook, not to the relay, and is therefore not part of this chain.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Derive the relay WebSocket URL from the Telegram webhook URL.
|
|
79
|
+
*
|
|
80
|
+
* The derivation keeps the host (and port) of the webhook URL and replaces the
|
|
81
|
+
* path with /ws, because the relay is exposed on the same origin as the
|
|
82
|
+
* webhook:
|
|
83
|
+
*
|
|
84
|
+
* https://webhook.bunchee.online/telegram -> wss://webhook.bunchee.online/ws
|
|
85
|
+
*
|
|
86
|
+
* That only works when the origin serves /ws, i.e. behind the reverse proxy
|
|
87
|
+
* that maps /ws to the relay port (nginx: location /ws -> 127.0.0.1:8444).
|
|
88
|
+
* When the webhook URL addresses the application DIRECTLY, the relay listens
|
|
89
|
+
* on WS_PORT (default 8444), not on the HTTP port - use RELAY_WS_URL or
|
|
90
|
+
* keys/relay-ws-url.txt for that setup.
|
|
91
|
+
*/
|
|
92
|
+
export function deriveRelayWsUrl(webhookUrl: string): string | null {
|
|
93
|
+
const trimmed = webhookUrl?.trim();
|
|
94
|
+
if (!trimmed) return null;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = new URL(trimmed);
|
|
97
|
+
if (!parsed.host) return null;
|
|
98
|
+
const secure = parsed.protocol === "https:" || parsed.protocol === "wss:";
|
|
99
|
+
const scheme = secure ? "wss:" : "ws:";
|
|
100
|
+
return `${scheme}//${parsed.host}/ws`;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True when the value looks like a WebSocket URL.
|
|
108
|
+
*
|
|
109
|
+
* Credentials ("wss://user:pass@host") and query strings are rejected: the
|
|
110
|
+
* resolved URL is written to the log, and neither belongs there.
|
|
111
|
+
*/
|
|
112
|
+
export function isRelayWsUrl(value: string): boolean {
|
|
113
|
+
const trimmed = value?.trim() ?? "";
|
|
114
|
+
if (!/^wss?:\/\//i.test(trimmed)) return false;
|
|
115
|
+
if (trimmed.includes("@") || trimmed.includes("?")) return false;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = new URL(trimmed);
|
|
118
|
+
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") return false;
|
|
119
|
+
return parsed.host.length > 0;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function defaultReadFile(path: string): string | undefined {
|
|
126
|
+
try {
|
|
127
|
+
return readFileSync(path, "utf-8");
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Host of a URL, or an empty string when it cannot be parsed. */
|
|
134
|
+
function hostOf(url: string): string {
|
|
135
|
+
try {
|
|
136
|
+
return new URL(url).host;
|
|
137
|
+
} catch {
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True for hosts that address the machine itself. */
|
|
143
|
+
function isLoopbackHost(host: string): boolean {
|
|
144
|
+
const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
|
|
145
|
+
return name === "127.0.0.1" || name === "localhost" || name === "::1" || name.startsWith("127.");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface UrlResolution {
|
|
149
|
+
url: string | null;
|
|
150
|
+
source: string | null;
|
|
151
|
+
rejected: string[];
|
|
152
|
+
notes: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface TokenResolution {
|
|
156
|
+
token: string | null;
|
|
157
|
+
source: string | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Transport warnings for a resolved URL.
|
|
162
|
+
*
|
|
163
|
+
* A plaintext ws:// URL to a remote host sends the auth token in cleartext.
|
|
164
|
+
*/
|
|
165
|
+
function transportNotes(url: string): string[] {
|
|
166
|
+
if (!url.toLowerCase().startsWith("ws://")) return [];
|
|
167
|
+
if (isLoopbackHost(hostOf(url))) return [];
|
|
168
|
+
return [
|
|
169
|
+
"the relay url is plaintext (ws://) for a remote host - the auth token is sent unencrypted, use wss://",
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the WebSocket URL: env -> dedicated key file -> derived from the
|
|
175
|
+
* telegram webhook url. The first usable source wins; a source that holds a
|
|
176
|
+
* rejected value is reported so the operator can see why it was skipped.
|
|
177
|
+
*/
|
|
178
|
+
function resolveUrl(
|
|
179
|
+
env: RelayEndpointEnv,
|
|
180
|
+
readKey: (name: string) => string | undefined,
|
|
181
|
+
): UrlResolution {
|
|
182
|
+
const rejected: string[] = [];
|
|
183
|
+
const notes: string[] = [];
|
|
184
|
+
|
|
185
|
+
const envUrl = env.RELAY_WS_URL?.trim();
|
|
186
|
+
if (envUrl) {
|
|
187
|
+
if (isRelayWsUrl(envUrl)) {
|
|
188
|
+
notes.push(...transportNotes(envUrl));
|
|
189
|
+
return { url: envUrl, source: "env RELAY_WS_URL", rejected, notes };
|
|
190
|
+
}
|
|
191
|
+
rejected.push("env RELAY_WS_URL (not a ws:// or wss:// URL)");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const fileUrl = readKey("relay-ws-url.txt");
|
|
195
|
+
if (fileUrl) {
|
|
196
|
+
if (isRelayWsUrl(fileUrl)) {
|
|
197
|
+
notes.push(...transportNotes(fileUrl));
|
|
198
|
+
return { url: fileUrl, source: "keys/relay-ws-url.txt", rejected, notes };
|
|
199
|
+
}
|
|
200
|
+
rejected.push("keys/relay-ws-url.txt (not a ws:// or wss:// URL)");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const webhookUrl = readKey("telegram-webhook-url.txt");
|
|
204
|
+
const derived = webhookUrl ? deriveRelayWsUrl(webhookUrl) : null;
|
|
205
|
+
if (derived) {
|
|
206
|
+
if (isLoopbackHost(hostOf(derived))) {
|
|
207
|
+
notes.push(
|
|
208
|
+
"derived url points at a loopback host: the relay listens on WS_PORT (default 8444), not on the webhook port - set RELAY_WS_URL or keys/relay-ws-url.txt",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
notes.push(...transportNotes(derived));
|
|
212
|
+
return { url: derived, source: "keys/telegram-webhook-url.txt (derived)", rejected, notes };
|
|
213
|
+
}
|
|
214
|
+
if (webhookUrl) {
|
|
215
|
+
rejected.push("keys/telegram-webhook-url.txt (no ws:// or wss:// URL could be derived)");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { url: null, source: null, rejected, notes };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Resolve the auth token: env -> dedicated key file -> webhook secret. */
|
|
222
|
+
function resolveToken(
|
|
223
|
+
env: RelayEndpointEnv,
|
|
224
|
+
readKey: (name: string) => string | undefined,
|
|
225
|
+
): TokenResolution {
|
|
226
|
+
const envToken = env.RELAY_AUTH_TOKEN?.trim();
|
|
227
|
+
if (envToken) {
|
|
228
|
+
return { token: envToken, source: "env RELAY_AUTH_TOKEN" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const fileToken = readKey("relay-auth-token.txt");
|
|
232
|
+
if (fileToken) {
|
|
233
|
+
return { token: fileToken, source: "keys/relay-auth-token.txt" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const secret = readKey("telegram-webhook-secret.txt");
|
|
237
|
+
if (secret) {
|
|
238
|
+
return { token: secret, source: "keys/telegram-webhook-secret.txt" };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { token: null, source: null };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve the relay WebSocket URL and auth token.
|
|
246
|
+
*
|
|
247
|
+
* The first non-empty source wins. A blank file counts as missing (an empty
|
|
248
|
+
* value is a configuration mistake, not a valid endpoint). A source that holds
|
|
249
|
+
* a value which was REJECTED is reported in `missing` so the operator can see
|
|
250
|
+
* why the resolver moved on.
|
|
251
|
+
*/
|
|
252
|
+
export function resolveRelayEndpoint(options: ResolveRelayEndpointOptions): RelayEndpointResolution {
|
|
253
|
+
const env = options.env ?? (process.env as RelayEndpointEnv);
|
|
254
|
+
const read = options.readFile ?? defaultReadFile;
|
|
255
|
+
|
|
256
|
+
const readKey = (name: string): string | undefined => {
|
|
257
|
+
try {
|
|
258
|
+
const raw = read(join(options.keysDir, name));
|
|
259
|
+
const value = raw?.trim();
|
|
260
|
+
return value ? value : undefined;
|
|
261
|
+
} catch {
|
|
262
|
+
// An unreadable key file is treated as absent - the source list in
|
|
263
|
+
// `missing` tells the operator where to look.
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const urlResolution = resolveUrl(env, readKey);
|
|
269
|
+
const tokenResolution = resolveToken(env, readKey);
|
|
270
|
+
|
|
271
|
+
const missing: string[] = [];
|
|
272
|
+
if (!urlResolution.url) {
|
|
273
|
+
missing.push(`url not resolved (looked at ${RELAY_URL_SOURCES.join(", ")})`);
|
|
274
|
+
}
|
|
275
|
+
if (!tokenResolution.token) {
|
|
276
|
+
missing.push(`token not resolved (looked at ${RELAY_TOKEN_SOURCES.join(", ")})`);
|
|
277
|
+
}
|
|
278
|
+
for (const reason of urlResolution.rejected) {
|
|
279
|
+
missing.push(`rejected source: ${reason}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
url: urlResolution.url,
|
|
284
|
+
urlSource: urlResolution.source,
|
|
285
|
+
token: tokenResolution.token,
|
|
286
|
+
tokenSource: tokenResolution.source,
|
|
287
|
+
missing,
|
|
288
|
+
notes: urlResolution.notes,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Log-safe description of a resolution (contains no token value).
|
|
294
|
+
*/
|
|
295
|
+
export function describeRelayEndpoint(resolution: RelayEndpointResolution): string {
|
|
296
|
+
const parts: string[] = [];
|
|
297
|
+
parts.push(resolution.url ? `url from ${resolution.urlSource}` : "url NOT resolved");
|
|
298
|
+
parts.push(resolution.token ? `token from ${resolution.tokenSource}` : "token NOT resolved");
|
|
299
|
+
let description = parts.join(", ");
|
|
300
|
+
if (resolution.notes.length > 0) {
|
|
301
|
+
description += ` (${resolution.notes.join("; ")})`;
|
|
302
|
+
}
|
|
303
|
+
return description;
|
|
304
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
deriveRelayWsUrl,
|
|
4
|
+
describeRelayEndpoint,
|
|
5
|
+
isRelayWsUrl,
|
|
6
|
+
resolveRelayEndpoint,
|
|
7
|
+
} from "../src/relay-endpoint.js";
|
|
8
|
+
|
|
9
|
+
/** Build a fake key directory reader. */
|
|
10
|
+
function makeKeys(files: Record<string, string>) {
|
|
11
|
+
return (path: string): string | undefined => {
|
|
12
|
+
const name = path.split("/").pop() ?? path;
|
|
13
|
+
return Object.prototype.hasOwnProperty.call(files, name) ? files[name] : undefined;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("deriveRelayWsUrl", () => {
|
|
18
|
+
test("maps an https webhook url to the relay websocket url", () => {
|
|
19
|
+
expect(deriveRelayWsUrl("https://webhook.bunchee.online/telegram")).toBe(
|
|
20
|
+
"wss://webhook.bunchee.online/ws",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("documents the direct-app case: the relay listens on WS_PORT, not on the webhook port", () => {
|
|
25
|
+
// Behind a proxy (nginx `location /ws -> 127.0.0.1:8444`) the derived
|
|
26
|
+
// URL is correct. When the webhook URL addresses the app DIRECTLY the
|
|
27
|
+
// relay listens on WS_PORT (default 8444), so the derived URL is only
|
|
28
|
+
// a starting point - resolveRelayEndpoint() adds a note about it.
|
|
29
|
+
expect(deriveRelayWsUrl("http://127.0.0.1:8443/telegram")).toBe("ws://127.0.0.1:8443/ws");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("keeps a wss url on the same host", () => {
|
|
33
|
+
expect(deriveRelayWsUrl("wss://webhook.bunchee.online/ws")).toBe("wss://webhook.bunchee.online/ws");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns null for garbage", () => {
|
|
37
|
+
expect(deriveRelayWsUrl("not a url")).toBeNull();
|
|
38
|
+
expect(deriveRelayWsUrl("")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("isRelayWsUrl", () => {
|
|
43
|
+
test("accepts ws and wss urls only", () => {
|
|
44
|
+
expect(isRelayWsUrl("ws://127.0.0.1:8444/ws")).toBe(true);
|
|
45
|
+
expect(isRelayWsUrl("wss://host/ws")).toBe(true);
|
|
46
|
+
expect(isRelayWsUrl("https://host/telegram")).toBe(false);
|
|
47
|
+
expect(isRelayWsUrl("")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejects urls that carry credentials or a query string", () => {
|
|
51
|
+
expect(isRelayWsUrl("wss://user:pass@host/ws")).toBe(false);
|
|
52
|
+
expect(isRelayWsUrl("wss://host/ws?token=secret")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("resolveRelayEndpoint", () => {
|
|
57
|
+
test("prefers the environment over the key files", () => {
|
|
58
|
+
const resolution = resolveRelayEndpoint({
|
|
59
|
+
env: { RELAY_WS_URL: "wss://from-env/ws", RELAY_AUTH_TOKEN: "env-token" },
|
|
60
|
+
keysDir: "/keys",
|
|
61
|
+
readFile: makeKeys({
|
|
62
|
+
"relay-ws-url.txt": "wss://from-file/ws",
|
|
63
|
+
"relay-auth-token.txt": "file-token",
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(resolution.url).toBe("wss://from-env/ws");
|
|
68
|
+
expect(resolution.urlSource).toBe("env RELAY_WS_URL");
|
|
69
|
+
expect(resolution.token).toBe("env-token");
|
|
70
|
+
expect(resolution.tokenSource).toBe("env RELAY_AUTH_TOKEN");
|
|
71
|
+
expect(resolution.missing).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("falls back to the dedicated key files", () => {
|
|
75
|
+
const resolution = resolveRelayEndpoint({
|
|
76
|
+
env: {},
|
|
77
|
+
keysDir: "/keys",
|
|
78
|
+
readFile: makeKeys({
|
|
79
|
+
"relay-ws-url.txt": "wss://from-file/ws",
|
|
80
|
+
"relay-auth-token.txt": "file-token",
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(resolution.url).toBe("wss://from-file/ws");
|
|
85
|
+
expect(resolution.urlSource).toBe("keys/relay-ws-url.txt");
|
|
86
|
+
expect(resolution.token).toBe("file-token");
|
|
87
|
+
expect(resolution.tokenSource).toBe("keys/relay-auth-token.txt");
|
|
88
|
+
expect(resolution.missing).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("derives the url from the telegram webhook url and the token from the webhook secret", () => {
|
|
92
|
+
const resolution = resolveRelayEndpoint({
|
|
93
|
+
env: {},
|
|
94
|
+
keysDir: "/keys",
|
|
95
|
+
readFile: makeKeys({
|
|
96
|
+
"telegram-webhook-url.txt": "https://webhook.bunchee.online/telegram",
|
|
97
|
+
"telegram-webhook-secret.txt": "shared-secret",
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(resolution.url).toBe("wss://webhook.bunchee.online/ws");
|
|
102
|
+
expect(resolution.urlSource).toBe("keys/telegram-webhook-url.txt (derived)");
|
|
103
|
+
expect(resolution.token).toBe("shared-secret");
|
|
104
|
+
expect(resolution.tokenSource).toBe("keys/telegram-webhook-secret.txt");
|
|
105
|
+
expect(resolution.missing).toEqual([]);
|
|
106
|
+
expect(resolution.notes).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("reports what is missing when nothing is configured", () => {
|
|
110
|
+
const resolution = resolveRelayEndpoint({
|
|
111
|
+
env: {},
|
|
112
|
+
keysDir: "/keys",
|
|
113
|
+
readFile: makeKeys({}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(resolution.url).toBeNull();
|
|
117
|
+
expect(resolution.token).toBeNull();
|
|
118
|
+
expect(resolution.missing).toHaveLength(2);
|
|
119
|
+
expect(resolution.missing[0]).toContain("url");
|
|
120
|
+
expect(resolution.missing[1]).toContain("token");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("treats a blank key file as missing", () => {
|
|
124
|
+
const resolution = resolveRelayEndpoint({
|
|
125
|
+
env: {},
|
|
126
|
+
keysDir: "/keys",
|
|
127
|
+
readFile: makeKeys({
|
|
128
|
+
"relay-ws-url.txt": " \n",
|
|
129
|
+
"relay-auth-token.txt": "",
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(resolution.url).toBeNull();
|
|
134
|
+
expect(resolution.token).toBeNull();
|
|
135
|
+
expect(resolution.missing).toHaveLength(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("ignores a key file that does not hold a websocket url", () => {
|
|
139
|
+
const resolution = resolveRelayEndpoint({
|
|
140
|
+
env: {},
|
|
141
|
+
keysDir: "/keys",
|
|
142
|
+
readFile: makeKeys({
|
|
143
|
+
"relay-ws-url.txt": "https://webhook.bunchee.online/telegram",
|
|
144
|
+
"telegram-webhook-url.txt": "https://webhook.bunchee.online/telegram",
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(resolution.url).toBe("wss://webhook.bunchee.online/ws");
|
|
149
|
+
expect(resolution.urlSource).toBe("keys/telegram-webhook-url.txt (derived)");
|
|
150
|
+
expect(resolution.missing.join(" ")).toContain(
|
|
151
|
+
"rejected source: keys/relay-ws-url.txt (not a ws:// or wss:// URL)",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("rejects an env url that carries credentials", () => {
|
|
156
|
+
const resolution = resolveRelayEndpoint({
|
|
157
|
+
env: { RELAY_WS_URL: "wss://user:pass@host/ws" },
|
|
158
|
+
keysDir: "/keys",
|
|
159
|
+
readFile: makeKeys({ "relay-ws-url.txt": "wss://from-file/ws" }),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(resolution.url).toBe("wss://from-file/ws");
|
|
163
|
+
expect(resolution.missing.join(" ")).toContain("rejected source: env RELAY_WS_URL");
|
|
164
|
+
// The rejected value must not be echoed back - it may hold a secret.
|
|
165
|
+
expect(resolution.missing.join(" ")).not.toContain("user:pass");
|
|
166
|
+
expect(describeRelayEndpoint(resolution)).not.toContain("user:pass");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("warns when a remote host would be used over plaintext ws", () => {
|
|
170
|
+
const resolution = resolveRelayEndpoint({
|
|
171
|
+
env: { RELAY_WS_URL: "ws://relay.example.com/ws" },
|
|
172
|
+
keysDir: "/keys",
|
|
173
|
+
readFile: makeKeys({}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(resolution.url).toBe("ws://relay.example.com/ws");
|
|
177
|
+
expect(resolution.notes.join(" ")).toContain("plaintext");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("does not warn about a plaintext ws url on the loopback interface", () => {
|
|
181
|
+
const resolution = resolveRelayEndpoint({
|
|
182
|
+
env: { RELAY_WS_URL: "ws://127.0.0.1:8444/ws" },
|
|
183
|
+
keysDir: "/keys",
|
|
184
|
+
readFile: makeKeys({}),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(resolution.notes.join(" ")).not.toContain("plaintext");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("never leaks the token in the description", () => {
|
|
191
|
+
const resolution = resolveRelayEndpoint({
|
|
192
|
+
env: {},
|
|
193
|
+
keysDir: "/keys",
|
|
194
|
+
readFile: makeKeys({
|
|
195
|
+
"relay-ws-url.txt": "wss://host/ws",
|
|
196
|
+
"relay-auth-token.txt": "super-secret-token",
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const description = describeRelayEndpoint(resolution);
|
|
201
|
+
expect(description).toContain("url from keys/relay-ws-url.txt");
|
|
202
|
+
expect(description).toContain("token from keys/relay-auth-token.txt");
|
|
203
|
+
expect(description).not.toContain("super-secret-token");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("describes a missing endpoint without a secret", () => {
|
|
207
|
+
const resolution = resolveRelayEndpoint({ env: {}, keysDir: "/keys", readFile: makeKeys({}) });
|
|
208
|
+
const description = describeRelayEndpoint(resolution);
|
|
209
|
+
expect(description).toContain("url NOT resolved");
|
|
210
|
+
expect(description).toContain("token NOT resolved");
|
|
211
|
+
// The source list lives in `missing`; the description must not repeat it.
|
|
212
|
+
expect(description).not.toContain("looked at");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("records a rejected env url so the operator can see why it was skipped", () => {
|
|
216
|
+
const resolution = resolveRelayEndpoint({
|
|
217
|
+
env: { RELAY_WS_URL: "https://host/telegram" },
|
|
218
|
+
keysDir: "/keys",
|
|
219
|
+
readFile: makeKeys({ "relay-ws-url.txt": "wss://from-file/ws" }),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(resolution.url).toBe("wss://from-file/ws");
|
|
223
|
+
expect(resolution.urlSource).toBe("keys/relay-ws-url.txt");
|
|
224
|
+
expect(resolution.missing.join(" ")).toContain("rejected source: env RELAY_WS_URL");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("treats an unreadable key file as absent", () => {
|
|
228
|
+
const resolution = resolveRelayEndpoint({
|
|
229
|
+
env: {},
|
|
230
|
+
keysDir: "/keys",
|
|
231
|
+
readFile: () => {
|
|
232
|
+
throw new Error("EACCES");
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(resolution.url).toBeNull();
|
|
237
|
+
expect(resolution.token).toBeNull();
|
|
238
|
+
expect(resolution.missing).toHaveLength(2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("resolves nothing when the keys directory does not exist", () => {
|
|
242
|
+
const resolution = resolveRelayEndpoint({
|
|
243
|
+
env: {},
|
|
244
|
+
keysDir: "/definitely/not/a/keys/dir",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(resolution.url).toBeNull();
|
|
248
|
+
expect(resolution.token).toBeNull();
|
|
249
|
+
expect(resolution.missing).toHaveLength(2);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("trims a token that ends with CRLF", () => {
|
|
253
|
+
const resolution = resolveRelayEndpoint({
|
|
254
|
+
env: {},
|
|
255
|
+
keysDir: "/keys",
|
|
256
|
+
readFile: makeKeys({
|
|
257
|
+
"relay-ws-url.txt": "wss://host/ws",
|
|
258
|
+
"relay-auth-token.txt": "token-with-crlf\r\n",
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(resolution.token).toBe("token-with-crlf");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("warns when the derived url points at a loopback host", () => {
|
|
266
|
+
const resolution = resolveRelayEndpoint({
|
|
267
|
+
env: {},
|
|
268
|
+
keysDir: "/keys",
|
|
269
|
+
readFile: makeKeys({
|
|
270
|
+
"telegram-webhook-url.txt": "http://127.0.0.1:8443/telegram",
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(resolution.url).toBe("ws://127.0.0.1:8443/ws");
|
|
275
|
+
expect(resolution.notes.join(" ")).toContain("WS_PORT");
|
|
276
|
+
expect(describeRelayEndpoint(resolution)).toContain("WS_PORT");
|
|
277
|
+
});
|
|
278
|
+
});
|