@serverkgg/bridge 0.1.9 → 0.1.10
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/package.json +1 -1
- package/src/bot/botCauses.test.ts +56 -0
- package/src/bot/botCauses.ts +8 -0
- package/src/bot/botEnvironment.test.ts +218 -1
- package/src/bot/botEnvironment.ts +85 -3
- package/src/bot/botVariables.test.ts +32 -0
- package/src/bot/botVariables.ts +9 -0
- package/src/bot/detect/detect.test.ts +22 -0
- package/src/bot/detect/keys.test.ts +50 -0
- package/src/bot/detect/keys.ts +45 -1
- package/src/cli/validate.test.ts +33 -3
- package/src/cli/validate.ts +13 -5
- package/src/manifest/manifest.test.ts +229 -0
- package/src/manifest/manifest.ts +109 -1
- package/src/protocol/bridgeCrash.ts +5 -0
- package/src/protocol/bridgeEvents.ts +1 -0
- package/src/runtime/bridgeDriver.test.ts +24 -0
- package/src/runtime/bridgeDriver.ts +6 -2
- package/src/runtime/bridgeEvents.test.ts +13 -0
package/package.json
CHANGED
|
@@ -109,6 +109,42 @@ const CASES: readonly {
|
|
|
109
109
|
line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running)",
|
|
110
110
|
cause: BridgeCrashCause.TokenInUse,
|
|
111
111
|
},
|
|
112
|
+
{
|
|
113
|
+
line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first)",
|
|
114
|
+
cause: BridgeCrashCause.WebhookActive,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
line: "TelegramError: 409: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first",
|
|
118
|
+
cause: BridgeCrashCause.WebhookActive,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
line: "telegram.error.Conflict: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first",
|
|
122
|
+
cause: BridgeCrashCause.WebhookActive,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
line: "aiogram.exceptions.TelegramConflictError: Telegram server says - Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first",
|
|
126
|
+
cause: BridgeCrashCause.WebhookActive,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
line: "Error: listen EADDRINUSE: address already in use :::8080",
|
|
130
|
+
cause: BridgeCrashCause.PortBusy,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
line: "Error: listen EADDRINUSE: address already in use 0.0.0.0:8080",
|
|
134
|
+
cause: BridgeCrashCause.PortBusy,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
line: "OSError: [Errno 98] Address already in use",
|
|
138
|
+
cause: BridgeCrashCause.PortBusy,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
line: "OSError: [Errno 98] error while attempting to bind on address ('0.0.0.0', 8080): address already in use",
|
|
142
|
+
cause: BridgeCrashCause.PortBusy,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
line: "ERROR: [Errno 98] error while attempting to bind on address ('0.0.0.0', 8080): address already in use",
|
|
146
|
+
cause: BridgeCrashCause.PortBusy,
|
|
147
|
+
},
|
|
112
148
|
{
|
|
113
149
|
line: "Error: Cannot find module 'discord.js'",
|
|
114
150
|
cause: BridgeCrashCause.MissingDependency,
|
|
@@ -285,6 +321,26 @@ describe("BOT_CRASH_EVIDENCE", () => {
|
|
|
285
321
|
});
|
|
286
322
|
}
|
|
287
323
|
|
|
324
|
+
it("names an active webhook before the token in use its 409 line also matches", () => {
|
|
325
|
+
const { ring, print } = botRing();
|
|
326
|
+
|
|
327
|
+
print(
|
|
328
|
+
"GrammyError: Call to 'getUpdates' failed! (409: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first)",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.WebhookActive);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("still names a second poller on the token as the token in use", () => {
|
|
335
|
+
const { ring, print } = botRing();
|
|
336
|
+
|
|
337
|
+
print(
|
|
338
|
+
"GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running)",
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.TokenInUse);
|
|
342
|
+
});
|
|
343
|
+
|
|
288
344
|
it("names the removed audioop module before any other missing module", () => {
|
|
289
345
|
const { ring, print } = botRing();
|
|
290
346
|
|
package/src/bot/botCauses.ts
CHANGED
|
@@ -22,10 +22,18 @@ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
|
|
|
22
22
|
/Used disallowed intents|\[DisallowedIntents\]|Privileged intent provided is not enabled or whitelisted|PrivilegedIntentsRequired|close code:? 4014\b/,
|
|
23
23
|
emit: BridgeEventName.IntentsDisallowed,
|
|
24
24
|
},
|
|
25
|
+
{
|
|
26
|
+
match: /can't use getUpdates method while webhook is active/,
|
|
27
|
+
emit: BridgeEventName.WebhookActive,
|
|
28
|
+
},
|
|
25
29
|
{
|
|
26
30
|
match: /terminated by other getUpdates request|\b409:? Conflict\b/,
|
|
27
31
|
emit: BridgeEventName.TokenInUse,
|
|
28
32
|
},
|
|
33
|
+
{
|
|
34
|
+
match: /EADDRINUSE|\[Errno 98\][^\n]*[Aa]ddress already in use|OSError: [^\n]*Address already in use/,
|
|
35
|
+
emit: BridgeEventName.PortBindFailed,
|
|
36
|
+
},
|
|
29
37
|
{
|
|
30
38
|
match:
|
|
31
39
|
/Cannot find module|ERR_MODULE_NOT_FOUND|ModuleNotFoundError: No module named|(?:davey|PyNaCl) library needed in order to use voice/,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
BOT_NO_EXPOSURE,
|
|
4
|
+
type BotExposure,
|
|
5
|
+
botExposureOf,
|
|
3
6
|
botProcessEnvironment,
|
|
4
7
|
customerEnvironment,
|
|
5
8
|
LAUNCHER_DEPLOYMENT_ID,
|
|
@@ -17,7 +20,17 @@ import {
|
|
|
17
20
|
platformEnvironment,
|
|
18
21
|
} from "./botEnvironment";
|
|
19
22
|
import { type BotConfig, DEFAULT_REPO_BRANCH } from "./botVariables";
|
|
20
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
BOT_PORT_1_KEY,
|
|
25
|
+
BOT_PORT_2_KEY,
|
|
26
|
+
BOT_WEB_PORT_KEY,
|
|
27
|
+
BotPlatform,
|
|
28
|
+
PORT,
|
|
29
|
+
SERVERK_PORT_1,
|
|
30
|
+
SERVERK_PORT_2,
|
|
31
|
+
SERVERK_WEB_HOST,
|
|
32
|
+
SERVERK_WEB_URL,
|
|
33
|
+
} from "./detect";
|
|
21
34
|
|
|
22
35
|
const SHA = "0123456789abcdef0123456789abcdef01234567";
|
|
23
36
|
|
|
@@ -32,9 +45,33 @@ const config = (overrides: Partial<BotConfig> = {}): BotConfig => ({
|
|
|
32
45
|
deployment: null,
|
|
33
46
|
snapshot: null,
|
|
34
47
|
gitToken: null,
|
|
48
|
+
webUrl: null,
|
|
49
|
+
...overrides,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const WEB_URL = "https://my-bot.srvk.gg";
|
|
53
|
+
|
|
54
|
+
const exposureOf = (overrides: Partial<BotExposure> = {}): BotExposure => ({
|
|
55
|
+
...BOT_NO_EXPOSURE,
|
|
35
56
|
...overrides,
|
|
36
57
|
});
|
|
37
58
|
|
|
59
|
+
const contextWith = (ports: Record<string, number>, variables: Record<string, string> = {}) => {
|
|
60
|
+
return {
|
|
61
|
+
port: (key: string) => {
|
|
62
|
+
const value = ports[key];
|
|
63
|
+
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
throw new Error(`this game declares no port named "${key}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return value;
|
|
69
|
+
},
|
|
70
|
+
variable: (key: string) => variables[key] ?? null,
|
|
71
|
+
secret: () => null,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
38
75
|
describe("customerEnvironment", () => {
|
|
39
76
|
it("hands the process every vault key and nothing the platform or the runtime owns", () => {
|
|
40
77
|
expect(
|
|
@@ -152,6 +189,153 @@ describe("platformEnvironment", () => {
|
|
|
152
189
|
SERVERK_SERVER_CODE: "abc",
|
|
153
190
|
});
|
|
154
191
|
});
|
|
192
|
+
|
|
193
|
+
it("hands the bot PORT, the web address and its host while the web port is active", () => {
|
|
194
|
+
expect(
|
|
195
|
+
platformEnvironment(
|
|
196
|
+
"abc",
|
|
197
|
+
null,
|
|
198
|
+
exposureOf({
|
|
199
|
+
web: 8080,
|
|
200
|
+
webUrl: WEB_URL,
|
|
201
|
+
}),
|
|
202
|
+
),
|
|
203
|
+
).toEqual({
|
|
204
|
+
SERVERK_BOT: "1",
|
|
205
|
+
SERVERK_SERVER_CODE: "abc",
|
|
206
|
+
[PORT]: "8080",
|
|
207
|
+
[SERVERK_WEB_URL]: WEB_URL,
|
|
208
|
+
[SERVERK_WEB_HOST]: "my-bot.srvk.gg",
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("hands the bot no PORT and no address while the web port is off, even with an address delivered", () => {
|
|
213
|
+
expect(
|
|
214
|
+
platformEnvironment(
|
|
215
|
+
"abc",
|
|
216
|
+
null,
|
|
217
|
+
exposureOf({
|
|
218
|
+
webUrl: WEB_URL,
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
).toEqual({
|
|
222
|
+
SERVERK_BOT: "1",
|
|
223
|
+
SERVERK_SERVER_CODE: "abc",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("hands the bot PORT alone while the web port is active before its address is known", () => {
|
|
228
|
+
expect(
|
|
229
|
+
platformEnvironment(
|
|
230
|
+
"abc",
|
|
231
|
+
null,
|
|
232
|
+
exposureOf({
|
|
233
|
+
web: 8080,
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
).toEqual({
|
|
237
|
+
SERVERK_BOT: "1",
|
|
238
|
+
SERVERK_SERVER_CODE: "abc",
|
|
239
|
+
[PORT]: "8080",
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("drops an address that is not a bare https host", () => {
|
|
244
|
+
for (const webUrl of [
|
|
245
|
+
"http://my-bot.srvk.gg",
|
|
246
|
+
"https://my-bot.srvk.gg/",
|
|
247
|
+
"https://my-bot.srvk.gg:8443",
|
|
248
|
+
"https://My-Bot.srvk.gg",
|
|
249
|
+
]) {
|
|
250
|
+
expect(
|
|
251
|
+
platformEnvironment(
|
|
252
|
+
"abc",
|
|
253
|
+
null,
|
|
254
|
+
exposureOf({
|
|
255
|
+
web: 8080,
|
|
256
|
+
webUrl,
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
259
|
+
webUrl,
|
|
260
|
+
).toEqual({
|
|
261
|
+
SERVERK_BOT: "1",
|
|
262
|
+
SERVERK_SERVER_CODE: "abc",
|
|
263
|
+
[PORT]: "8080",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("hands the bot each raw port only while it is active", () => {
|
|
269
|
+
expect(
|
|
270
|
+
platformEnvironment(
|
|
271
|
+
"abc",
|
|
272
|
+
null,
|
|
273
|
+
exposureOf({
|
|
274
|
+
port1: 29_004,
|
|
275
|
+
port2: 30_017,
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
).toEqual({
|
|
279
|
+
SERVERK_BOT: "1",
|
|
280
|
+
SERVERK_SERVER_CODE: "abc",
|
|
281
|
+
[SERVERK_PORT_1]: "29004",
|
|
282
|
+
[SERVERK_PORT_2]: "30017",
|
|
283
|
+
});
|
|
284
|
+
expect(
|
|
285
|
+
platformEnvironment(
|
|
286
|
+
"abc",
|
|
287
|
+
null,
|
|
288
|
+
exposureOf({
|
|
289
|
+
port2: 30_017,
|
|
290
|
+
}),
|
|
291
|
+
),
|
|
292
|
+
).toEqual({
|
|
293
|
+
SERVERK_BOT: "1",
|
|
294
|
+
SERVERK_SERVER_CODE: "abc",
|
|
295
|
+
[SERVERK_PORT_2]: "30017",
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("botExposureOf", () => {
|
|
301
|
+
it("reads the active ports and the validated address from the bridge context", () => {
|
|
302
|
+
expect(
|
|
303
|
+
botExposureOf(
|
|
304
|
+
contextWith(
|
|
305
|
+
{
|
|
306
|
+
[BOT_WEB_PORT_KEY]: 8080,
|
|
307
|
+
[BOT_PORT_2_KEY]: 30_017,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
[SERVERK_WEB_URL]: WEB_URL,
|
|
311
|
+
},
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
).toEqual({
|
|
315
|
+
web: 8080,
|
|
316
|
+
webUrl: WEB_URL,
|
|
317
|
+
port1: null,
|
|
318
|
+
port2: 30_017,
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("answers no exposure for a bundle that declares no port", () => {
|
|
323
|
+
expect(botExposureOf(contextWith({}))).toEqual(BOT_NO_EXPOSURE);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("reads a raw port without the web port", () => {
|
|
327
|
+
expect(
|
|
328
|
+
botExposureOf(
|
|
329
|
+
contextWith({
|
|
330
|
+
[BOT_PORT_1_KEY]: 29_004,
|
|
331
|
+
}),
|
|
332
|
+
),
|
|
333
|
+
).toEqual(
|
|
334
|
+
exposureOf({
|
|
335
|
+
port1: 29_004,
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
});
|
|
155
339
|
});
|
|
156
340
|
|
|
157
341
|
describe("botProcessEnvironment", () => {
|
|
@@ -186,4 +370,37 @@ describe("botProcessEnvironment", () => {
|
|
|
186
370
|
BOT_PLATFORM: BotPlatform.Telegram,
|
|
187
371
|
});
|
|
188
372
|
});
|
|
373
|
+
|
|
374
|
+
it("lets the platform's PORT win over the customer's while the web port is active", () => {
|
|
375
|
+
const customer = customerEnvironment(
|
|
376
|
+
{
|
|
377
|
+
[PORT]: "3000",
|
|
378
|
+
},
|
|
379
|
+
{},
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
expect(
|
|
383
|
+
botProcessEnvironment({
|
|
384
|
+
tool: {},
|
|
385
|
+
customer,
|
|
386
|
+
launcher: {},
|
|
387
|
+
platform: platformEnvironment(
|
|
388
|
+
"abc",
|
|
389
|
+
null,
|
|
390
|
+
exposureOf({
|
|
391
|
+
web: 8080,
|
|
392
|
+
webUrl: WEB_URL,
|
|
393
|
+
}),
|
|
394
|
+
),
|
|
395
|
+
})[PORT],
|
|
396
|
+
).toBe("8080");
|
|
397
|
+
expect(
|
|
398
|
+
botProcessEnvironment({
|
|
399
|
+
tool: {},
|
|
400
|
+
customer,
|
|
401
|
+
launcher: {},
|
|
402
|
+
platform: platformEnvironment("abc", null),
|
|
403
|
+
})[PORT],
|
|
404
|
+
).toBe("3000");
|
|
405
|
+
});
|
|
189
406
|
});
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BridgeContext } from "../context";
|
|
2
|
+
import { type BotConfig, readBotConfig, WEB_URL_PATTERN } from "./botVariables";
|
|
2
3
|
import type { BotPlatform } from "./detect";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
BOT_PLATFORM,
|
|
6
|
+
BOT_PORT_1_KEY,
|
|
7
|
+
BOT_PORT_2_KEY,
|
|
8
|
+
BOT_WEB_PORT_KEY,
|
|
9
|
+
isReservedBotKey,
|
|
10
|
+
PORT,
|
|
11
|
+
SERVERK_BOT,
|
|
12
|
+
SERVERK_PORT_1,
|
|
13
|
+
SERVERK_PORT_2,
|
|
14
|
+
SERVERK_SERVER_CODE,
|
|
15
|
+
SERVERK_WEB_HOST,
|
|
16
|
+
SERVERK_WEB_URL,
|
|
17
|
+
} from "./detect";
|
|
4
18
|
|
|
5
19
|
export const LAUNCHER_REPO_URL = "SERVERK_REPO_URL";
|
|
6
20
|
|
|
@@ -112,7 +126,73 @@ export const launcherEnvironment = (config: BotConfig, stateDir: string): Record
|
|
|
112
126
|
return environment;
|
|
113
127
|
};
|
|
114
128
|
|
|
115
|
-
export
|
|
129
|
+
export interface BotExposure {
|
|
130
|
+
web: number | null;
|
|
131
|
+
webUrl: string | null;
|
|
132
|
+
port1: number | null;
|
|
133
|
+
port2: number | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const BOT_NO_EXPOSURE: BotExposure = {
|
|
137
|
+
web: null,
|
|
138
|
+
webUrl: null,
|
|
139
|
+
port1: null,
|
|
140
|
+
port2: null,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const activePortOf = (context: Pick<BridgeContext, "port">, key: string) => {
|
|
144
|
+
try {
|
|
145
|
+
return context.port(key);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const botExposureOf = (context: Pick<BridgeContext, "port" | "variable" | "secret">): BotExposure => {
|
|
152
|
+
return {
|
|
153
|
+
web: activePortOf(context, BOT_WEB_PORT_KEY),
|
|
154
|
+
webUrl: readBotConfig(context).webUrl,
|
|
155
|
+
port1: activePortOf(context, BOT_PORT_1_KEY),
|
|
156
|
+
port2: activePortOf(context, BOT_PORT_2_KEY),
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const webEnvironment = (exposure: BotExposure): Record<string, string> => {
|
|
161
|
+
if (exposure.web === null) {
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const environment: Record<string, string> = {
|
|
166
|
+
[PORT]: String(exposure.web),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (exposure.webUrl !== null && WEB_URL_PATTERN.test(exposure.webUrl)) {
|
|
170
|
+
environment[SERVERK_WEB_URL] = exposure.webUrl;
|
|
171
|
+
environment[SERVERK_WEB_HOST] = new URL(exposure.webUrl).hostname;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return environment;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const rawPortEnvironment = (exposure: BotExposure): Record<string, string> => {
|
|
178
|
+
const environment: Record<string, string> = {};
|
|
179
|
+
|
|
180
|
+
if (exposure.port1 !== null) {
|
|
181
|
+
environment[SERVERK_PORT_1] = String(exposure.port1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (exposure.port2 !== null) {
|
|
185
|
+
environment[SERVERK_PORT_2] = String(exposure.port2);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return environment;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export const platformEnvironment = (
|
|
192
|
+
code: string,
|
|
193
|
+
platform: BotPlatform | null,
|
|
194
|
+
exposure: BotExposure = BOT_NO_EXPOSURE,
|
|
195
|
+
): Record<string, string> => {
|
|
116
196
|
return {
|
|
117
197
|
[SERVERK_BOT]: "1",
|
|
118
198
|
[SERVERK_SERVER_CODE]: code,
|
|
@@ -121,6 +201,8 @@ export const platformEnvironment = (code: string, platform: BotPlatform | null):
|
|
|
121
201
|
[BOT_PLATFORM]: platform,
|
|
122
202
|
}
|
|
123
203
|
: {}),
|
|
204
|
+
...webEnvironment(exposure),
|
|
205
|
+
...rawPortEnvironment(exposure),
|
|
124
206
|
};
|
|
125
207
|
};
|
|
126
208
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ROOT_DIRECTORY_LENGTH_LIMIT,
|
|
10
10
|
readBotConfig,
|
|
11
11
|
SNAPSHOT_ID_PATTERN,
|
|
12
|
+
WEB_URL_PATTERN,
|
|
12
13
|
} from "./botVariables";
|
|
13
14
|
import {
|
|
14
15
|
BOT_PLATFORM,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
REPO_REF,
|
|
21
22
|
REPO_URL,
|
|
22
23
|
ROOT_DIRECTORY,
|
|
24
|
+
SERVERK_WEB_URL,
|
|
23
25
|
SNAPSHOT_ID,
|
|
24
26
|
START_COMMAND,
|
|
25
27
|
} from "./detect";
|
|
@@ -49,6 +51,7 @@ describe("readBotConfig", () => {
|
|
|
49
51
|
[DEPLOYMENT_ID]: "dep_a1b2c3d4",
|
|
50
52
|
[SNAPSHOT_ID]: "snap_1",
|
|
51
53
|
[GIT_TOKEN]: "ignored-in-variables",
|
|
54
|
+
[SERVERK_WEB_URL]: " https://my-bot.srvk.gg ",
|
|
52
55
|
},
|
|
53
56
|
{
|
|
54
57
|
[GIT_TOKEN]: "ghs_installation",
|
|
@@ -66,6 +69,7 @@ describe("readBotConfig", () => {
|
|
|
66
69
|
deployment: "dep_a1b2c3d4",
|
|
67
70
|
snapshot: "snap_1",
|
|
68
71
|
gitToken: "ghs_installation",
|
|
72
|
+
webUrl: "https://my-bot.srvk.gg",
|
|
69
73
|
});
|
|
70
74
|
});
|
|
71
75
|
|
|
@@ -88,8 +92,29 @@ describe("readBotConfig", () => {
|
|
|
88
92
|
deployment: null,
|
|
89
93
|
snapshot: null,
|
|
90
94
|
gitToken: null,
|
|
95
|
+
webUrl: null,
|
|
91
96
|
});
|
|
92
97
|
});
|
|
98
|
+
|
|
99
|
+
it("drops a web address that is not a bare https host", () => {
|
|
100
|
+
for (const value of [
|
|
101
|
+
"http://my-bot.srvk.gg",
|
|
102
|
+
"https://my-bot.srvk.gg/webhook",
|
|
103
|
+
"https://my-bot.srvk.gg:443",
|
|
104
|
+
"https://user@my-bot.srvk.gg",
|
|
105
|
+
"https://My-Bot.srvk.gg",
|
|
106
|
+
"https://",
|
|
107
|
+
]) {
|
|
108
|
+
expect(
|
|
109
|
+
readBotConfig(
|
|
110
|
+
contextWith({
|
|
111
|
+
[SERVERK_WEB_URL]: value,
|
|
112
|
+
}),
|
|
113
|
+
).webUrl,
|
|
114
|
+
value,
|
|
115
|
+
).toBeNull();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
93
118
|
});
|
|
94
119
|
|
|
95
120
|
describe("the patterns", () => {
|
|
@@ -145,6 +170,13 @@ describe("the patterns", () => {
|
|
|
145
170
|
}
|
|
146
171
|
});
|
|
147
172
|
|
|
173
|
+
it("take a web address only as https and a lowercase host", () => {
|
|
174
|
+
expect(WEB_URL_PATTERN.test("https://my-bot.srvk.gg")).toBe(true);
|
|
175
|
+
expect(WEB_URL_PATTERN.test("https://abc123.srvk.localhost")).toBe(true);
|
|
176
|
+
expect(WEB_URL_PATTERN.test("https://my-bot.srvk.gg/")).toBe(false);
|
|
177
|
+
expect(WEB_URL_PATTERN.test("http://my-bot.srvk.gg")).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
148
180
|
it("accept https repositories and git branch names", () => {
|
|
149
181
|
expect(REPO_URL_PATTERN.test("https://github.com/you/bot.git")).toBe(true);
|
|
150
182
|
expect(REPO_URL_PATTERN.test("https://user:pass@github.com/you/bot")).toBe(false);
|
package/src/bot/botVariables.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
REPO_REF,
|
|
11
11
|
REPO_URL,
|
|
12
12
|
ROOT_DIRECTORY,
|
|
13
|
+
SERVERK_WEB_URL,
|
|
13
14
|
SNAPSHOT_ID,
|
|
14
15
|
START_COMMAND,
|
|
15
16
|
} from "./detect";
|
|
@@ -30,6 +31,8 @@ export const ROOT_DIRECTORY_PATTERN =
|
|
|
30
31
|
|
|
31
32
|
export const ROOT_DIRECTORY_LENGTH_LIMIT = 255;
|
|
32
33
|
|
|
34
|
+
export const WEB_URL_PATTERN = /^https:\/\/[a-z0-9.-]+$/;
|
|
35
|
+
|
|
33
36
|
export const DEFAULT_REPO_BRANCH = "main";
|
|
34
37
|
|
|
35
38
|
export const isRootDirectory = (value: string) => {
|
|
@@ -47,6 +50,7 @@ export interface BotConfig {
|
|
|
47
50
|
deployment: string | null;
|
|
48
51
|
snapshot: string | null;
|
|
49
52
|
gitToken: string | null;
|
|
53
|
+
webUrl: string | null;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
const present = (value: string | null) => {
|
|
@@ -55,6 +59,10 @@ const present = (value: string | null) => {
|
|
|
55
59
|
return trimmed.length === 0 ? null : trimmed;
|
|
56
60
|
};
|
|
57
61
|
|
|
62
|
+
const webUrlOf = (value: string | null) => {
|
|
63
|
+
return value !== null && WEB_URL_PATTERN.test(value) ? value : null;
|
|
64
|
+
};
|
|
65
|
+
|
|
58
66
|
export const readBotConfig = (context: Pick<BridgeContext, "variable" | "secret">): BotConfig => {
|
|
59
67
|
const platform = present(context.variable(BOT_PLATFORM));
|
|
60
68
|
|
|
@@ -69,5 +77,6 @@ export const readBotConfig = (context: Pick<BridgeContext, "variable" | "secret"
|
|
|
69
77
|
deployment: present(context.variable(DEPLOYMENT_ID)),
|
|
70
78
|
snapshot: present(context.variable(SNAPSHOT_ID)),
|
|
71
79
|
gitToken: present(context.secret(GIT_TOKEN)),
|
|
80
|
+
webUrl: webUrlOf(present(context.variable(SERVERK_WEB_URL))),
|
|
72
81
|
};
|
|
73
82
|
};
|
|
@@ -678,6 +678,28 @@ describe("detect warnings", () => {
|
|
|
678
678
|
|
|
679
679
|
expect(quiet.warnings).not.toContain(BotInspectWarning.ReservedEnvKey);
|
|
680
680
|
});
|
|
681
|
+
|
|
682
|
+
it("keeps quiet about the web address and raw port variables an example file lists, and warns about the switches", () => {
|
|
683
|
+
const quiet = project({
|
|
684
|
+
...DISCORD_NODE,
|
|
685
|
+
".env.example": "DISCORD_TOKEN=\nSERVERK_WEB_URL=\nSERVERK_WEB_HOST=\nSERVERK_PORT_1=\nSERVERK_PORT_2=\n",
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
expect(quiet.env.map((entry) => entry.key)).toEqual([
|
|
689
|
+
"DISCORD_TOKEN",
|
|
690
|
+
]);
|
|
691
|
+
expect(quiet.warnings).not.toContain(BotInspectWarning.ReservedEnvKey);
|
|
692
|
+
|
|
693
|
+
const loud = project({
|
|
694
|
+
...DISCORD_NODE,
|
|
695
|
+
".env.example": "DISCORD_TOKEN=\nBOT_WEB=on\n",
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
expect(loud.env.map((entry) => entry.key)).toEqual([
|
|
699
|
+
"DISCORD_TOKEN",
|
|
700
|
+
]);
|
|
701
|
+
expect(loud.warnings).toContain(BotInspectWarning.ReservedEnvKey);
|
|
702
|
+
});
|
|
681
703
|
});
|
|
682
704
|
|
|
683
705
|
describe("detect with serverk.json", () => {
|
|
@@ -3,9 +3,21 @@ import {
|
|
|
3
3
|
BOT_AMBIENT_KEYS,
|
|
4
4
|
BOT_PLATFORM_KEYS,
|
|
5
5
|
BOT_PLATFORM_SECRET_KEYS,
|
|
6
|
+
BOT_PORT_1,
|
|
7
|
+
BOT_PORT_1_KEY,
|
|
8
|
+
BOT_PORT_2,
|
|
9
|
+
BOT_PORT_2_KEY,
|
|
6
10
|
BOT_PROTECTED_KEYS,
|
|
7
11
|
BOT_PROVIDED_KEYS,
|
|
12
|
+
BOT_RAW_PORT_KEYS,
|
|
13
|
+
BOT_WEB,
|
|
14
|
+
BOT_WEB_PORT_KEY,
|
|
8
15
|
isReservedBotKey,
|
|
16
|
+
PORT,
|
|
17
|
+
SERVERK_PORT_1,
|
|
18
|
+
SERVERK_PORT_2,
|
|
19
|
+
SERVERK_WEB_HOST,
|
|
20
|
+
SERVERK_WEB_URL,
|
|
9
21
|
} from "./keys";
|
|
10
22
|
|
|
11
23
|
describe("isReservedBotKey", () => {
|
|
@@ -21,6 +33,21 @@ describe("isReservedBotKey", () => {
|
|
|
21
33
|
}
|
|
22
34
|
});
|
|
23
35
|
|
|
36
|
+
it("reserves the web and port switches the platform sets", () => {
|
|
37
|
+
for (const key of [
|
|
38
|
+
BOT_WEB,
|
|
39
|
+
BOT_PORT_1,
|
|
40
|
+
BOT_PORT_2,
|
|
41
|
+
]) {
|
|
42
|
+
expect(BOT_PLATFORM_KEYS).toContain(key);
|
|
43
|
+
expect(isReservedBotKey(key)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("leaves PORT to the customer, so a bot without the web port keeps its own", () => {
|
|
48
|
+
expect(isReservedBotKey(PORT)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
24
51
|
it("leaves the customer's own keys alone, case included", () => {
|
|
25
52
|
for (const key of [
|
|
26
53
|
"DISCORD_TOKEN",
|
|
@@ -47,6 +74,19 @@ describe("BOT_AMBIENT_KEYS", () => {
|
|
|
47
74
|
}
|
|
48
75
|
});
|
|
49
76
|
|
|
77
|
+
it("holds the web address and raw port variables, so detection never asks for them", () => {
|
|
78
|
+
for (const key of [
|
|
79
|
+
SERVERK_WEB_URL,
|
|
80
|
+
SERVERK_WEB_HOST,
|
|
81
|
+
SERVERK_PORT_1,
|
|
82
|
+
SERVERK_PORT_2,
|
|
83
|
+
]) {
|
|
84
|
+
expect(BOT_PROVIDED_KEYS).toContain(key);
|
|
85
|
+
expect(BOT_AMBIENT_KEYS).toContain(key);
|
|
86
|
+
expect(isReservedBotKey(key)).toBe(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
50
90
|
it("leaves a bot's own keys out", () => {
|
|
51
91
|
for (const key of [
|
|
52
92
|
"DISCORD_TOKEN",
|
|
@@ -57,3 +97,13 @@ describe("BOT_AMBIENT_KEYS", () => {
|
|
|
57
97
|
}
|
|
58
98
|
});
|
|
59
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("BOT_RAW_PORT_KEYS", () => {
|
|
102
|
+
it("names the two raw ports apart from the web port", () => {
|
|
103
|
+
expect(BOT_RAW_PORT_KEYS).toEqual([
|
|
104
|
+
BOT_PORT_1_KEY,
|
|
105
|
+
BOT_PORT_2_KEY,
|
|
106
|
+
]);
|
|
107
|
+
expect(BOT_RAW_PORT_KEYS).not.toContain(BOT_WEB_PORT_KEY);
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/bot/detect/keys.ts
CHANGED
|
@@ -18,12 +18,49 @@ export const SNAPSHOT_ID = "SNAPSHOT_ID";
|
|
|
18
18
|
|
|
19
19
|
export const BOT_PLATFORM = "BOT_PLATFORM";
|
|
20
20
|
|
|
21
|
+
export const BOT_WEB = "BOT_WEB";
|
|
22
|
+
|
|
23
|
+
export const BOT_PORT_1 = "BOT_PORT_1";
|
|
24
|
+
|
|
25
|
+
export const BOT_PORT_2 = "BOT_PORT_2";
|
|
26
|
+
|
|
27
|
+
export const BOT_SWITCH_ON = "on";
|
|
28
|
+
|
|
21
29
|
export const GIT_TOKEN = "GIT_TOKEN";
|
|
22
30
|
|
|
23
31
|
export const SERVERK_BOT = "SERVERK_BOT";
|
|
24
32
|
|
|
25
33
|
export const SERVERK_SERVER_CODE = "SERVERK_SERVER_CODE";
|
|
26
34
|
|
|
35
|
+
export const SERVERK_WEB_URL = "SERVERK_WEB_URL";
|
|
36
|
+
|
|
37
|
+
export const SERVERK_WEB_HOST = "SERVERK_WEB_HOST";
|
|
38
|
+
|
|
39
|
+
export const SERVERK_PORT_1 = "SERVERK_PORT_1";
|
|
40
|
+
|
|
41
|
+
export const SERVERK_PORT_2 = "SERVERK_PORT_2";
|
|
42
|
+
|
|
43
|
+
export const PORT = "PORT";
|
|
44
|
+
|
|
45
|
+
export const BOT_WEB_PORT_KEY = "web";
|
|
46
|
+
|
|
47
|
+
export const BOT_PORT_1_KEY = "port1";
|
|
48
|
+
|
|
49
|
+
export const BOT_PORT_2_KEY = "port2";
|
|
50
|
+
|
|
51
|
+
export const BOT_RAW_PORT_KEYS = [
|
|
52
|
+
BOT_PORT_1_KEY,
|
|
53
|
+
BOT_PORT_2_KEY,
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export type BotRawPortKey = (typeof BOT_RAW_PORT_KEYS)[number];
|
|
57
|
+
|
|
58
|
+
export const BOT_PORT_SWITCHES = {
|
|
59
|
+
[BOT_WEB_PORT_KEY]: BOT_WEB,
|
|
60
|
+
[BOT_PORT_1_KEY]: BOT_PORT_1,
|
|
61
|
+
[BOT_PORT_2_KEY]: BOT_PORT_2,
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
27
64
|
export const BOT_PLATFORM_KEYS: readonly string[] = [
|
|
28
65
|
REPO_URL,
|
|
29
66
|
REPO_REF,
|
|
@@ -34,6 +71,9 @@ export const BOT_PLATFORM_KEYS: readonly string[] = [
|
|
|
34
71
|
DEPLOYMENT_ID,
|
|
35
72
|
SNAPSHOT_ID,
|
|
36
73
|
BOT_PLATFORM,
|
|
74
|
+
BOT_WEB,
|
|
75
|
+
BOT_PORT_1,
|
|
76
|
+
BOT_PORT_2,
|
|
37
77
|
];
|
|
38
78
|
|
|
39
79
|
export const BOT_PLATFORM_SECRET_KEYS: readonly string[] = [
|
|
@@ -52,6 +92,10 @@ export const BOT_PROVIDED_KEYS: readonly string[] = [
|
|
|
52
92
|
SERVERK_BOT,
|
|
53
93
|
SERVERK_SERVER_CODE,
|
|
54
94
|
BOT_PLATFORM,
|
|
95
|
+
SERVERK_WEB_URL,
|
|
96
|
+
SERVERK_WEB_HOST,
|
|
97
|
+
SERVERK_PORT_1,
|
|
98
|
+
SERVERK_PORT_2,
|
|
55
99
|
];
|
|
56
100
|
|
|
57
101
|
export const BOT_AMBIENT_KEYS: readonly string[] = [
|
|
@@ -59,7 +103,7 @@ export const BOT_AMBIENT_KEYS: readonly string[] = [
|
|
|
59
103
|
...BOT_PROVIDED_KEYS,
|
|
60
104
|
"NODE_ENV",
|
|
61
105
|
"NODE_OPTIONS",
|
|
62
|
-
|
|
106
|
+
PORT,
|
|
63
107
|
"HOST",
|
|
64
108
|
"HOSTNAME",
|
|
65
109
|
"DEBUG",
|
package/src/cli/validate.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { renderDetectTable } from "../bot/detect";
|
|
5
|
+
import { BOT_SWITCH_ON, BOT_WEB, BOT_WEB_PORT_KEY, renderDetectTable } from "../bot/detect";
|
|
6
6
|
import { BridgeKind, BridgeSetupStepKind } from "../core";
|
|
7
7
|
import { GuideOpenTab, type GuidePack, type ParsedBlock, type ParsedLeafBlock } from "../guides";
|
|
8
8
|
import { MANIFEST_SCHEMA_VERSION, PackageKind, type RunnableManifest } from "../manifest";
|
|
@@ -600,7 +600,7 @@ describe("validating a bot package", () => {
|
|
|
600
600
|
);
|
|
601
601
|
});
|
|
602
602
|
|
|
603
|
-
it("warns about a port
|
|
603
|
+
it("warns about a bot port with no activeWhen but still validates it", async () => {
|
|
604
604
|
writeBot(
|
|
605
605
|
botManifest({
|
|
606
606
|
container: {
|
|
@@ -616,10 +616,40 @@ describe("validating a bot package", () => {
|
|
|
616
616
|
|
|
617
617
|
expect(lines).toContain("bot-node is valid");
|
|
618
618
|
expect(lines).toContain(
|
|
619
|
-
" warning: a bot package declares 1 port(s);
|
|
619
|
+
" warning: a bot package declares 1 port(s) with no activeWhen; a bot publishes a port only while its author turns it on",
|
|
620
620
|
);
|
|
621
621
|
});
|
|
622
622
|
|
|
623
|
+
it("validates a bot web port its author turns on without a warning", async () => {
|
|
624
|
+
writeBot(
|
|
625
|
+
botManifest({
|
|
626
|
+
container: {
|
|
627
|
+
image: "serverkgg/bot-node",
|
|
628
|
+
ports: [
|
|
629
|
+
{
|
|
630
|
+
...WEBHOOK_PORT,
|
|
631
|
+
key: BOT_WEB_PORT_KEY,
|
|
632
|
+
web: true,
|
|
633
|
+
activeWhen: [
|
|
634
|
+
{
|
|
635
|
+
variable: BOT_WEB,
|
|
636
|
+
values: [
|
|
637
|
+
BOT_SWITCH_ON,
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
},
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
await validate();
|
|
648
|
+
|
|
649
|
+
expect(lines).toContain("bot-node is valid");
|
|
650
|
+
expect(lines.some((line) => line.includes("warning:"))).toBe(false);
|
|
651
|
+
});
|
|
652
|
+
|
|
623
653
|
it("fails a bot that declares platform secrets", async () => {
|
|
624
654
|
writeBot(
|
|
625
655
|
botManifest({
|
package/src/cli/validate.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url";
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { type ArticlePack, compileArticles } from "../blog";
|
|
7
|
-
import { DETECT_TABLE_FILE, renderDetectTable } from "../bot/detect";
|
|
7
|
+
import { BOT_PORT_SWITCHES, DETECT_TABLE_FILE, renderDetectTable } from "../bot/detect";
|
|
8
8
|
import { BridgeKind, PortProtocol } from "../core";
|
|
9
9
|
import {
|
|
10
10
|
GuideOpenTab,
|
|
@@ -599,14 +599,18 @@ export const setupReportLines = (setup: BridgeSetupModule | undefined): string[]
|
|
|
599
599
|
};
|
|
600
600
|
|
|
601
601
|
export const botPortLines = (manifest: RunnableManifest): string[] => {
|
|
602
|
-
|
|
602
|
+
if (!isBotManifest(manifest)) {
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
603
605
|
|
|
604
|
-
|
|
606
|
+
const ungated = manifest.container.ports.filter((port) => port.activeWhen.length === 0).length;
|
|
607
|
+
|
|
608
|
+
if (ungated === 0) {
|
|
605
609
|
return [];
|
|
606
610
|
}
|
|
607
611
|
|
|
608
612
|
return [
|
|
609
|
-
` warning: a bot package declares ${
|
|
613
|
+
` warning: a bot package declares ${ungated} port(s) with no activeWhen; a bot publishes a port only while its author turns it on`,
|
|
610
614
|
];
|
|
611
615
|
};
|
|
612
616
|
|
|
@@ -680,7 +684,11 @@ const validateRunnablePackage = async (
|
|
|
680
684
|
}
|
|
681
685
|
|
|
682
686
|
validateDriver(driver);
|
|
683
|
-
validatePortActivation(
|
|
687
|
+
validatePortActivation(
|
|
688
|
+
manifest.container.ports,
|
|
689
|
+
driver,
|
|
690
|
+
isBotManifest(manifest) ? Object.values(BOT_PORT_SWITCHES) : [],
|
|
691
|
+
);
|
|
684
692
|
|
|
685
693
|
const confirmLines = confirmReportLines(driver);
|
|
686
694
|
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
BOT_PORT_1,
|
|
5
|
+
BOT_PORT_1_KEY,
|
|
6
|
+
BOT_PORT_2,
|
|
7
|
+
BOT_PORT_2_KEY,
|
|
8
|
+
BOT_SWITCH_ON,
|
|
9
|
+
BOT_WEB,
|
|
10
|
+
BOT_WEB_PORT_KEY,
|
|
11
|
+
} from "../bot/detect";
|
|
3
12
|
import { PORT_BLOCK_MAX_COUNT, PortJoin, PortProtocol } from "../core";
|
|
4
13
|
import { GameCategory } from "./category";
|
|
5
14
|
import { ManifestExtensionKind, ManifestExtensionSource } from "./extension";
|
|
@@ -1766,6 +1775,146 @@ describe("port pairs and blocks", () => {
|
|
|
1766
1775
|
});
|
|
1767
1776
|
});
|
|
1768
1777
|
|
|
1778
|
+
describe("web ports", () => {
|
|
1779
|
+
const gamePort = () => {
|
|
1780
|
+
const [game] = base().container.ports;
|
|
1781
|
+
|
|
1782
|
+
if (!game) {
|
|
1783
|
+
throw new Error("fixture is missing its port");
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
return game;
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
const webPort = (key = "web", min = 31_000) => ({
|
|
1790
|
+
key,
|
|
1791
|
+
containerPort: 8080,
|
|
1792
|
+
protocol: PortProtocol.Tcp,
|
|
1793
|
+
hostRange: {
|
|
1794
|
+
default: min,
|
|
1795
|
+
min,
|
|
1796
|
+
max: min + 99,
|
|
1797
|
+
},
|
|
1798
|
+
web: true,
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
it("reads a port from a bundle published before the web flag as not the web port", () => {
|
|
1802
|
+
expect(manifestSchema.parse(base()).container.ports[0]?.web).toBe(false);
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
it("keeps the one web port a package declares", () => {
|
|
1806
|
+
const manifest = manifestSchema.parse(
|
|
1807
|
+
withPorts([
|
|
1808
|
+
gamePort(),
|
|
1809
|
+
webPort(),
|
|
1810
|
+
]),
|
|
1811
|
+
);
|
|
1812
|
+
|
|
1813
|
+
expect(manifest.container.ports.map((port) => port.web)).toEqual([
|
|
1814
|
+
false,
|
|
1815
|
+
true,
|
|
1816
|
+
]);
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
it("refuses a second web port", () => {
|
|
1820
|
+
expect(
|
|
1821
|
+
failure(
|
|
1822
|
+
withPorts([
|
|
1823
|
+
gamePort(),
|
|
1824
|
+
webPort(),
|
|
1825
|
+
webPort("panel", 31_100),
|
|
1826
|
+
]),
|
|
1827
|
+
),
|
|
1828
|
+
).toEqual([
|
|
1829
|
+
{
|
|
1830
|
+
path: "container.ports.2.web",
|
|
1831
|
+
message: 'port "web" is already the web port — a package declares at most one',
|
|
1832
|
+
},
|
|
1833
|
+
]);
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
for (const protocol of [
|
|
1837
|
+
PortProtocol.Udp,
|
|
1838
|
+
PortProtocol.TcpUdp,
|
|
1839
|
+
]) {
|
|
1840
|
+
it(`refuses a web port carrying ${protocol}`, () => {
|
|
1841
|
+
expect(
|
|
1842
|
+
failure(
|
|
1843
|
+
withPorts([
|
|
1844
|
+
gamePort(),
|
|
1845
|
+
{
|
|
1846
|
+
...webPort(),
|
|
1847
|
+
protocol,
|
|
1848
|
+
},
|
|
1849
|
+
]),
|
|
1850
|
+
),
|
|
1851
|
+
).toEqual([
|
|
1852
|
+
{
|
|
1853
|
+
path: "container.ports.1.protocol",
|
|
1854
|
+
message: `a web port carries tcp only, not ${protocol}`,
|
|
1855
|
+
},
|
|
1856
|
+
]);
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
it("refuses a web port that is a block", () => {
|
|
1861
|
+
expect(
|
|
1862
|
+
failure(
|
|
1863
|
+
withPorts([
|
|
1864
|
+
gamePort(),
|
|
1865
|
+
{
|
|
1866
|
+
...webPort(),
|
|
1867
|
+
count: 2,
|
|
1868
|
+
},
|
|
1869
|
+
]),
|
|
1870
|
+
),
|
|
1871
|
+
).toEqual([
|
|
1872
|
+
{
|
|
1873
|
+
path: "container.ports.1.count",
|
|
1874
|
+
message: "a web port is a single port, not a block of 2",
|
|
1875
|
+
},
|
|
1876
|
+
]);
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
it("refuses srv, follows and mirror on a web port", () => {
|
|
1880
|
+
expect(
|
|
1881
|
+
failure(
|
|
1882
|
+
withPorts([
|
|
1883
|
+
gamePort(),
|
|
1884
|
+
{
|
|
1885
|
+
...webPort(),
|
|
1886
|
+
srv: {
|
|
1887
|
+
service: "_http",
|
|
1888
|
+
},
|
|
1889
|
+
follows: {
|
|
1890
|
+
key: "game",
|
|
1891
|
+
offset: 1,
|
|
1892
|
+
},
|
|
1893
|
+
mirror: true,
|
|
1894
|
+
},
|
|
1895
|
+
]),
|
|
1896
|
+
),
|
|
1897
|
+
).toEqual([
|
|
1898
|
+
{
|
|
1899
|
+
path: "container.ports.1.srv",
|
|
1900
|
+
message: "a web port is reached only by the server's name and takes no srv",
|
|
1901
|
+
},
|
|
1902
|
+
{
|
|
1903
|
+
path: "container.ports.1.follows",
|
|
1904
|
+
message: "a web port is reached only by the server's name and takes no follows",
|
|
1905
|
+
},
|
|
1906
|
+
{
|
|
1907
|
+
path: "container.ports.1.mirror",
|
|
1908
|
+
message: "a web port is reached only by the server's name and takes no mirror",
|
|
1909
|
+
},
|
|
1910
|
+
]);
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
it("renders its description into the JSON schema", () => {
|
|
1914
|
+
expect(JSON.stringify(buildJsonSchema())).toContain("reached at the server's name over HTTPS");
|
|
1915
|
+
});
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1769
1918
|
const botBase = () => ({
|
|
1770
1919
|
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
1771
1920
|
kind: PackageKind.Bot,
|
|
@@ -1928,6 +2077,86 @@ describe("bot packages", () => {
|
|
|
1928
2077
|
expect(manifest.container.ports).toHaveLength(1);
|
|
1929
2078
|
});
|
|
1930
2079
|
|
|
2080
|
+
it("keep a web port and two raw ports the author turns on", () => {
|
|
2081
|
+
const switched = (variable: string) => [
|
|
2082
|
+
{
|
|
2083
|
+
variable,
|
|
2084
|
+
values: [
|
|
2085
|
+
BOT_SWITCH_ON,
|
|
2086
|
+
],
|
|
2087
|
+
},
|
|
2088
|
+
];
|
|
2089
|
+
|
|
2090
|
+
const manifest = botManifestSchema.parse({
|
|
2091
|
+
...botBase(),
|
|
2092
|
+
container: {
|
|
2093
|
+
...botBase().container,
|
|
2094
|
+
ports: [
|
|
2095
|
+
{
|
|
2096
|
+
key: BOT_WEB_PORT_KEY,
|
|
2097
|
+
containerPort: 8080,
|
|
2098
|
+
protocol: PortProtocol.Tcp,
|
|
2099
|
+
hostRange: {
|
|
2100
|
+
default: 31_000,
|
|
2101
|
+
min: 31_000,
|
|
2102
|
+
max: 31_999,
|
|
2103
|
+
},
|
|
2104
|
+
web: true,
|
|
2105
|
+
activeWhen: switched(BOT_WEB),
|
|
2106
|
+
},
|
|
2107
|
+
{
|
|
2108
|
+
key: BOT_PORT_1_KEY,
|
|
2109
|
+
containerPort: 29_000,
|
|
2110
|
+
protocol: PortProtocol.TcpUdp,
|
|
2111
|
+
hostRange: {
|
|
2112
|
+
default: 29_000,
|
|
2113
|
+
min: 29_000,
|
|
2114
|
+
max: 29_999,
|
|
2115
|
+
},
|
|
2116
|
+
mirror: true,
|
|
2117
|
+
activeWhen: switched(BOT_PORT_1),
|
|
2118
|
+
},
|
|
2119
|
+
{
|
|
2120
|
+
key: BOT_PORT_2_KEY,
|
|
2121
|
+
containerPort: 30_000,
|
|
2122
|
+
protocol: PortProtocol.TcpUdp,
|
|
2123
|
+
hostRange: {
|
|
2124
|
+
default: 30_000,
|
|
2125
|
+
min: 30_000,
|
|
2126
|
+
max: 30_999,
|
|
2127
|
+
},
|
|
2128
|
+
mirror: true,
|
|
2129
|
+
activeWhen: switched(BOT_PORT_2),
|
|
2130
|
+
},
|
|
2131
|
+
],
|
|
2132
|
+
},
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
expect(
|
|
2136
|
+
manifest.container.ports.map((port) => ({
|
|
2137
|
+
key: port.key,
|
|
2138
|
+
web: port.web,
|
|
2139
|
+
variable: port.activeWhen[0]?.variable,
|
|
2140
|
+
})),
|
|
2141
|
+
).toEqual([
|
|
2142
|
+
{
|
|
2143
|
+
key: BOT_WEB_PORT_KEY,
|
|
2144
|
+
web: true,
|
|
2145
|
+
variable: BOT_WEB,
|
|
2146
|
+
},
|
|
2147
|
+
{
|
|
2148
|
+
key: BOT_PORT_1_KEY,
|
|
2149
|
+
web: false,
|
|
2150
|
+
variable: BOT_PORT_1,
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
key: BOT_PORT_2_KEY,
|
|
2154
|
+
web: false,
|
|
2155
|
+
variable: BOT_PORT_2,
|
|
2156
|
+
},
|
|
2157
|
+
]);
|
|
2158
|
+
});
|
|
2159
|
+
|
|
1931
2160
|
it("reach the runtime reader like a game does", () => {
|
|
1932
2161
|
expect(manifestRuntimeOf(botManifestSchema.parse(botBase()))).toEqual({
|
|
1933
2162
|
...MANIFEST_RUNTIME_DEFAULTS,
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -103,6 +103,10 @@ const manifestPortSchema = z.object({
|
|
|
103
103
|
description:
|
|
104
104
|
"Reserves this many contiguous host ports, bound one to one from containerPort upward. A block is infrastructure, never a player address: it takes no name, hint, join, srv or follows.",
|
|
105
105
|
}),
|
|
106
|
+
web: z.boolean().default(false).meta({
|
|
107
|
+
description:
|
|
108
|
+
"This port is the server's HTTP surface: bound inside the machine and reached at the server's name over HTTPS.",
|
|
109
|
+
}),
|
|
106
110
|
});
|
|
107
111
|
|
|
108
112
|
export type ManifestPort = z.infer<typeof manifestPortSchema>;
|
|
@@ -145,7 +149,8 @@ const gameContainerSchema = containerSchemaWith(z.array(manifestPortSchema).min(
|
|
|
145
149
|
|
|
146
150
|
const botContainerSchema = containerSchemaWith(
|
|
147
151
|
z.array(manifestPortSchema).max(MANIFEST_PORTS_LIMIT).default([]).meta({
|
|
148
|
-
description:
|
|
152
|
+
description:
|
|
153
|
+
"A bot listens on nothing until its author turns a port on. Gate every port with activeWhen on a variable, and mark the HTTP one web.",
|
|
149
154
|
}),
|
|
150
155
|
);
|
|
151
156
|
|
|
@@ -543,6 +548,108 @@ const portShapeIssues = (port: ManifestPort, index: number): CheckIssue[] => {
|
|
|
543
548
|
return issues;
|
|
544
549
|
};
|
|
545
550
|
|
|
551
|
+
const WEB_REFUSED_FIELDS = [
|
|
552
|
+
"srv",
|
|
553
|
+
"follows",
|
|
554
|
+
] as const;
|
|
555
|
+
|
|
556
|
+
const webPortIssues = (port: ManifestPort, index: number): CheckIssue[] => {
|
|
557
|
+
const issues: CheckIssue[] = [];
|
|
558
|
+
const path = [
|
|
559
|
+
"container",
|
|
560
|
+
"ports",
|
|
561
|
+
index,
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
if (port.protocol !== PortProtocol.Tcp) {
|
|
565
|
+
issues.push({
|
|
566
|
+
code: "custom",
|
|
567
|
+
message: `a web port carries ${PortProtocol.Tcp} only, not ${port.protocol}`,
|
|
568
|
+
input: port.protocol,
|
|
569
|
+
path: [
|
|
570
|
+
...path,
|
|
571
|
+
"protocol",
|
|
572
|
+
],
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (port.count !== 1) {
|
|
577
|
+
issues.push({
|
|
578
|
+
code: "custom",
|
|
579
|
+
message: `a web port is a single port, not a block of ${port.count}`,
|
|
580
|
+
input: port.count,
|
|
581
|
+
path: [
|
|
582
|
+
...path,
|
|
583
|
+
"count",
|
|
584
|
+
],
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
for (const field of WEB_REFUSED_FIELDS) {
|
|
589
|
+
if (port[field] === undefined) {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
issues.push({
|
|
594
|
+
code: "custom",
|
|
595
|
+
message: `a web port is reached only by the server's name and takes no ${field}`,
|
|
596
|
+
input: port[field],
|
|
597
|
+
path: [
|
|
598
|
+
...path,
|
|
599
|
+
field,
|
|
600
|
+
],
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (port.mirror) {
|
|
605
|
+
issues.push({
|
|
606
|
+
code: "custom",
|
|
607
|
+
message: "a web port is reached only by the server's name and takes no mirror",
|
|
608
|
+
input: port.mirror,
|
|
609
|
+
path: [
|
|
610
|
+
...path,
|
|
611
|
+
"mirror",
|
|
612
|
+
],
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return issues;
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const webPortsIssues = (ports: ManifestPort[]): CheckIssue[] => {
|
|
620
|
+
const issues: CheckIssue[] = [];
|
|
621
|
+
|
|
622
|
+
let first: ManifestPort | null = null;
|
|
623
|
+
|
|
624
|
+
for (const [index, port] of ports.entries()) {
|
|
625
|
+
if (!port.web) {
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
issues.push(...webPortIssues(port, index));
|
|
630
|
+
|
|
631
|
+
if (first === null) {
|
|
632
|
+
first = port;
|
|
633
|
+
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
issues.push({
|
|
638
|
+
code: "custom",
|
|
639
|
+
message: `port "${first.key}" is already the web port — a package declares at most one`,
|
|
640
|
+
input: port.web,
|
|
641
|
+
path: [
|
|
642
|
+
"container",
|
|
643
|
+
"ports",
|
|
644
|
+
index,
|
|
645
|
+
"web",
|
|
646
|
+
],
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return issues;
|
|
651
|
+
};
|
|
652
|
+
|
|
546
653
|
const socketBindingIssues = (ports: ManifestPort[]): CheckIssue[] => {
|
|
547
654
|
const bindings = ports.reduce((total, port) => total + socketProtocolsOf(port.protocol).length, 0);
|
|
548
655
|
|
|
@@ -610,6 +717,7 @@ const portIssues = (ports: ManifestPort[]): CheckIssue[] => {
|
|
|
610
717
|
...ports.flatMap(portShapeIssues),
|
|
611
718
|
...socketBindingIssues(ports),
|
|
612
719
|
...followIssues(ports),
|
|
720
|
+
...webPortsIssues(ports),
|
|
613
721
|
];
|
|
614
722
|
};
|
|
615
723
|
|
|
@@ -18,6 +18,7 @@ export enum BridgeCrashCause {
|
|
|
18
18
|
AudioopRemoved = "audioop-removed",
|
|
19
19
|
RateLimited = "rate-limited",
|
|
20
20
|
VariableMissing = "variable-missing",
|
|
21
|
+
WebhookActive = "webhook-active",
|
|
21
22
|
Unknown = "unknown",
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -88,6 +89,10 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
|
|
|
88
89
|
name: BridgeEventName.TokenInvalid,
|
|
89
90
|
cause: BridgeCrashCause.TokenInvalid,
|
|
90
91
|
},
|
|
92
|
+
{
|
|
93
|
+
name: BridgeEventName.WebhookActive,
|
|
94
|
+
cause: BridgeCrashCause.WebhookActive,
|
|
95
|
+
},
|
|
91
96
|
{
|
|
92
97
|
name: BridgeEventName.TokenInUse,
|
|
93
98
|
cause: BridgeCrashCause.TokenInUse,
|
|
@@ -48,6 +48,7 @@ export enum BridgeEventName {
|
|
|
48
48
|
TokenInUse = "TokenInUse",
|
|
49
49
|
TokenInvalid = "TokenInvalid",
|
|
50
50
|
VariableMissing = "VariableMissing",
|
|
51
|
+
WebhookActive = "WebhookActive",
|
|
51
52
|
WorldActivated = "WorldActivated",
|
|
52
53
|
WorldCloned = "WorldCloned",
|
|
53
54
|
WorldCorrupt = "WorldCorrupt",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { BOT_PORT_SWITCHES, BOT_SWITCH_ON, BOT_WEB } from "../bot/detect";
|
|
2
3
|
import { BridgeKind, BridgeSetupStepKind, PortJoin, PortProtocol } from "../core";
|
|
3
4
|
import { GuideOpenTab } from "../guides";
|
|
4
5
|
import type { ManifestPort, ManifestPortActivation } from "../manifest";
|
|
@@ -242,6 +243,7 @@ const port = (activeWhen: ManifestPortActivation[]): ManifestPort => ({
|
|
|
242
243
|
activeWhen,
|
|
243
244
|
mirror: false,
|
|
244
245
|
count: 1,
|
|
246
|
+
web: false,
|
|
245
247
|
});
|
|
246
248
|
|
|
247
249
|
const driverWithTabs = (tabs: number, sections: number): BridgeDriver => ({
|
|
@@ -663,6 +665,7 @@ describe("validatePortActivation", () => {
|
|
|
663
665
|
]),
|
|
664
666
|
],
|
|
665
667
|
driverWithVariables(),
|
|
668
|
+
[],
|
|
666
669
|
),
|
|
667
670
|
).not.toThrow();
|
|
668
671
|
});
|
|
@@ -681,9 +684,29 @@ describe("validatePortActivation", () => {
|
|
|
681
684
|
]),
|
|
682
685
|
],
|
|
683
686
|
driverWithVariables(),
|
|
687
|
+
[],
|
|
684
688
|
),
|
|
685
689
|
).toThrow('port "bedrock" activeWhen[0] names "CROSSPLAY", which no variables form, catalog or detail declares');
|
|
686
690
|
});
|
|
691
|
+
|
|
692
|
+
it("accepts a variable the platform sets for the package's kind", () => {
|
|
693
|
+
expect(() =>
|
|
694
|
+
validatePortActivation(
|
|
695
|
+
[
|
|
696
|
+
port([
|
|
697
|
+
{
|
|
698
|
+
variable: BOT_WEB,
|
|
699
|
+
values: [
|
|
700
|
+
BOT_SWITCH_ON,
|
|
701
|
+
],
|
|
702
|
+
},
|
|
703
|
+
]),
|
|
704
|
+
],
|
|
705
|
+
driverWithVariables(),
|
|
706
|
+
Object.values(BOT_PORT_SWITCHES),
|
|
707
|
+
),
|
|
708
|
+
).not.toThrow();
|
|
709
|
+
});
|
|
687
710
|
});
|
|
688
711
|
|
|
689
712
|
const driverWithDetail = (): BridgeDriver => ({
|
|
@@ -874,6 +897,7 @@ describe("detail sections", () => {
|
|
|
874
897
|
]),
|
|
875
898
|
],
|
|
876
899
|
driverWithDetail(),
|
|
900
|
+
[],
|
|
877
901
|
),
|
|
878
902
|
).not.toThrow();
|
|
879
903
|
});
|
|
@@ -905,12 +905,16 @@ const declaredVariables = (driver: BridgeDriver) => {
|
|
|
905
905
|
return declared;
|
|
906
906
|
};
|
|
907
907
|
|
|
908
|
-
export const validatePortActivation = (
|
|
908
|
+
export const validatePortActivation = (
|
|
909
|
+
ports: ManifestPort[],
|
|
910
|
+
driver: BridgeDriver,
|
|
911
|
+
platformVariables: readonly string[],
|
|
912
|
+
): void => {
|
|
909
913
|
const declared = declaredVariables(driver);
|
|
910
914
|
|
|
911
915
|
for (const port of ports) {
|
|
912
916
|
for (const [index, rule] of port.activeWhen.entries()) {
|
|
913
|
-
if (declared.has(rule.variable)) {
|
|
917
|
+
if (declared.has(rule.variable) || platformVariables.includes(rule.variable)) {
|
|
914
918
|
continue;
|
|
915
919
|
}
|
|
916
920
|
|
|
@@ -271,6 +271,19 @@ describe("the crash record the ring builds", () => {
|
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
+
it("names an active webhook before the token in use when both are held", () => {
|
|
275
|
+
const ring = idleRing();
|
|
276
|
+
|
|
277
|
+
ring.emit(BridgeEventName.TokenInUse);
|
|
278
|
+
ring.emit(BridgeEventName.WebhookActive);
|
|
279
|
+
|
|
280
|
+
ring.crash(crashRecord());
|
|
281
|
+
|
|
282
|
+
expect(ring.drain(0).events.at(0)?.payload).toMatchObject({
|
|
283
|
+
cause: "webhook-active",
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
274
287
|
it("lets the out of memory log pattern outrank the java version", () => {
|
|
275
288
|
const ring = idleRing();
|
|
276
289
|
|