@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -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
 
@@ -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 { BotPlatform } from "./detect";
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 { BotConfig } from "./botVariables";
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 { BOT_PLATFORM, isReservedBotKey, SERVERK_BOT, SERVERK_SERVER_CODE } from "./detect";
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 const platformEnvironment = (code: string, platform: BotPlatform | null): Record<string, string> => {
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);
@@ -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
+ });
@@ -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
- "PORT",
106
+ PORT,
63
107
  "HOST",
64
108
  "HOSTNAME",
65
109
  "DEBUG",
@@ -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 on a bot but still validates it", async () => {
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); bots need none — remove them unless a webhook needs a public port",
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({
@@ -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
- const ports = manifest.container.ports.length;
602
+ if (!isBotManifest(manifest)) {
603
+ return [];
604
+ }
603
605
 
604
- if (!isBotManifest(manifest) || ports === 0) {
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 ${ports} port(s); bots need none — remove them unless a webhook needs a public port`,
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(manifest.container.ports, driver);
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,
@@ -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: "A bot listens on nothing. Declare a port only when a webhook needs a public address.",
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 = (ports: ManifestPort[], driver: BridgeDriver): void => {
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