@serverkgg/bridge 0.1.8 → 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.8",
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",
package/rules/package.md CHANGED
@@ -56,6 +56,7 @@ One Serverk package (a game or a bot runtime) on `@serverkgg/bridge`, with no pi
56
56
  ## Guides and image
57
57
 
58
58
  - A guide is `guides/<id>/ar.md` and `en.md`, starting at `##` with no `#` title; its title and summary live in `serverk.yml`. Every package has a `getting-started` guide tagged `start`.
59
+ - A heading may end with `{#<id>}`, lowercase letters, digits and single hyphens, to fix its anchor. A bot package's `errors` guide gives each cause section its cause id, the same in both languages. Why: the panel links every named cause to `errors#<cause id>`.
59
60
  - `image/Dockerfile` keeps the shared contract: `ARG BRIDGE_VERSION` with `LABEL gg.serverk.bridge.version`, a `container` user with uid 1000 and home `/home/container`, `COPY dist/bridge /serverk/bridge`, `ENTRYPOINT ["/usr/bin/tini", "--", "/serverk/bridge"]`, and no `USER` line. Game files download at install, a bot's code arrives with a deploy; neither is baked in.
60
61
  - The outermost start wrapper ends in `exec`, with no `setsid`, `nohup` or double fork. Why: the bridge checks the uid of the pid it spawned, and stop escalation signals the process group, so an orphan outside it keeps the port.
61
62
 
@@ -29,6 +29,10 @@ const CASES: readonly {
29
29
  line: "telegram.error.InvalidToken: The token `123:abc` was rejected by the server.",
30
30
  cause: BridgeCrashCause.TokenInvalid,
31
31
  },
32
+ {
33
+ line: "telegram.error.InvalidToken: You must pass the token you received from https://t.me/Botfather!",
34
+ cause: BridgeCrashCause.TokenInvalid,
35
+ },
32
36
  {
33
37
  line: "GrammyError: Call to 'deleteWebhook' failed! (404: Not Found)",
34
38
  cause: BridgeCrashCause.TokenInvalid,
@@ -105,6 +109,42 @@ const CASES: readonly {
105
109
  line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running)",
106
110
  cause: BridgeCrashCause.TokenInUse,
107
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
+ },
108
148
  {
109
149
  line: "Error: Cannot find module 'discord.js'",
110
150
  cause: BridgeCrashCause.MissingDependency,
@@ -165,6 +205,10 @@ const CASES: readonly {
165
205
  line: "ModuleNotFoundError: No module named 'audioop'",
166
206
  cause: BridgeCrashCause.AudioopRemoved,
167
207
  },
208
+ {
209
+ line: "KeyError: 'TELEGRAM_TOKEN'",
210
+ cause: BridgeCrashCause.VariableMissing,
211
+ },
168
212
  {
169
213
  line: "discord.errors.HTTPException: 429 Too Many Requests (error code: 0): You are being blocked from accessing our API temporarily due to exceeding our rate limits frequently.",
170
214
  cause: BridgeCrashCause.RateLimited,
@@ -253,6 +297,50 @@ describe("BOT_CRASH_EVIDENCE", () => {
253
297
  });
254
298
  }
255
299
 
300
+ it("names the variable the code read from an empty vault", () => {
301
+ const { ring, print } = botRing();
302
+
303
+ print("KeyError: 'TELEGRAM_TOKEN'");
304
+
305
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.VariableMissing);
306
+ expect(ring.drain(0).events.at(0)?.payload?.variable).toBe("TELEGRAM_TOKEN");
307
+ });
308
+
309
+ for (const line of [
310
+ "KeyError: 0",
311
+ "KeyError: 'user'",
312
+ "KeyError: 'X'",
313
+ ]) {
314
+ it(`leaves a key that is not a variable name unexplained from "${line}"`, () => {
315
+ const { ring, print } = botRing();
316
+
317
+ print(line);
318
+
319
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
320
+ expect(ring.drain(0).events.at(0)?.payload?.variable).toBeUndefined();
321
+ });
322
+ }
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
+
256
344
  it("names the removed audioop module before any other missing module", () => {
257
345
  const { ring, print } = botRing();
258
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/,
@@ -48,6 +56,10 @@ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
48
56
  match: /ffmpeg was not found|\bENOENT\b.*\bffmpeg\b|\bffmpeg\b.*\bENOENT\b/i,
49
57
  emit: BridgeEventName.FfmpegMissing,
50
58
  },
59
+ {
60
+ match: /^KeyError: '(?<variable>[A-Z][A-Z0-9_]{1,63})'\s*$/,
61
+ emit: BridgeEventName.VariableMissing,
62
+ },
51
63
  {
52
64
  match: /No module named 'audioop'/,
53
65
  emit: BridgeEventName.AudioopRemoved,
@@ -8,8 +8,10 @@ import {
8
8
  DEPLOY_MARKER_DRAFT,
9
9
  DEPLOYMENT_EVENT_PATTERN,
10
10
  DEPLOYMENT_LINE,
11
+ DEPLOYMENT_STAGE_LINE,
11
12
  type DeployMarker,
12
13
  deploymentLine,
14
+ deploymentStageLine,
13
15
  deployPending,
14
16
  pendingDeploymentOf,
15
17
  requestDeploy,
@@ -216,6 +218,36 @@ describe("deploymentLine", () => {
216
218
  });
217
219
  });
218
220
 
221
+ describe("deploymentStageLine", () => {
222
+ it("names the deployment and the stage it entered", () => {
223
+ expect(deploymentStageLine("dep_a1b2c3d4", BotDeploymentStage.Install)).toBe(
224
+ "serverk: deployment dep_a1b2c3d4 stage install",
225
+ );
226
+ });
227
+
228
+ it("round-trips through the stage pattern for every stage", () => {
229
+ for (const stage of Object.values(BotDeploymentStage)) {
230
+ expect(DEPLOYMENT_STAGE_LINE.exec(deploymentStageLine("dep_a1b2c3d4", stage))?.groups).toEqual({
231
+ deployment: "dep_a1b2c3d4",
232
+ stage,
233
+ });
234
+ }
235
+ });
236
+
237
+ it("is never read as a deployment outcome, so it raises no event", () => {
238
+ const line = deploymentStageLine("dep_a1b2c3d4", BotDeploymentStage.Start);
239
+
240
+ expect(DEPLOYMENT_LINE.test(line)).toBe(false);
241
+ expect(DEPLOYMENT_EVENT_PATTERN.match.test(line)).toBe(false);
242
+ expect(DEPLOYMENT_STAGE_LINE.test("serverk: deployment dep_a1b2c3d4 ready")).toBe(false);
243
+ });
244
+
245
+ it("reads only deployment ids of at most 64 characters", () => {
246
+ expect(DEPLOYMENT_STAGE_LINE.test(`serverk: deployment ${"d".repeat(64)} stage sync`)).toBe(true);
247
+ expect(DEPLOYMENT_STAGE_LINE.test(`serverk: deployment ${"d".repeat(65)} stage sync`)).toBe(false);
248
+ });
249
+ });
250
+
219
251
  describe("requestDeploy", () => {
220
252
  it("writes the request as JSON and restarts a running bot", async () => {
221
253
  const { context, stored, events } = deployContext(true);
@@ -21,6 +21,8 @@ export const DEPLOYMENT_EVENT_KIND = "deployment";
21
21
  export const DEPLOYMENT_LINE =
22
22
  /^serverk: deployment (?<deployment>\S{1,64}) (?<outcome>ready|failed)(?: at (?<commit>[0-9a-f]{7,64}))?(?:: (?<stage>[a-z-]+): (?<detail>.{1,200}))?$/u;
23
23
 
24
+ export const DEPLOYMENT_STAGE_LINE = /^serverk: deployment (?<deployment>\S{1,64}) stage (?<stage>[a-z-]+)$/u;
25
+
24
26
  export const DEPLOYMENT_DETAIL_LIMIT = 200;
25
27
 
26
28
  export const DEPLOYMENT_COMMIT_LENGTH = 12;
@@ -64,6 +66,10 @@ export const deploymentLine = (input: DeploymentLineInput) => {
64
66
  return `serverk: deployment ${input.deployment} failed: ${input.stage}: ${detail}`;
65
67
  };
66
68
 
69
+ export const deploymentStageLine = (deployment: string, stage: BotDeploymentStage) => {
70
+ return `serverk: deployment ${deployment} stage ${stage}`;
71
+ };
72
+
67
73
  export const DEPLOYMENT_EVENT_PATTERN: BridgeEventPattern = {
68
74
  match: DEPLOYMENT_LINE,
69
75
  emit: BridgeEventName.ServerUpdated,
@@ -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
  };