@serverkgg/bridge 0.0.21 → 0.1.2

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.
Files changed (119) hide show
  1. package/package.json +3 -1
  2. package/src/bot/botDeploy.test.ts +363 -0
  3. package/src/bot/botDeploy.ts +136 -0
  4. package/src/bot/botEnvironment.test.ts +189 -0
  5. package/src/bot/botEnvironment.ts +141 -0
  6. package/src/bot/botIdentity.test.ts +251 -0
  7. package/src/bot/botIdentity.ts +140 -0
  8. package/src/bot/botPanel.test.ts +246 -0
  9. package/src/bot/botPanel.ts +129 -0
  10. package/src/bot/botState.test.ts +45 -0
  11. package/src/bot/botState.ts +54 -0
  12. package/src/bot/botTerminal.test.ts +79 -0
  13. package/src/bot/botTerminal.ts +62 -0
  14. package/src/bot/botVariables.test.ts +193 -0
  15. package/src/bot/botVariables.ts +73 -0
  16. package/src/bot/detect/botFramework.ts +23 -0
  17. package/src/bot/detect/botFrameworks.ts +198 -0
  18. package/src/bot/detect/botInspectWarning.ts +15 -0
  19. package/src/bot/detect/botPackageManager.ts +6 -0
  20. package/src/bot/detect/botPlatform.ts +10 -0
  21. package/src/bot/detect/botRuntime.ts +15 -0
  22. package/src/bot/detect/botToken.test.ts +71 -0
  23. package/src/bot/detect/botToken.ts +126 -0
  24. package/src/bot/detect/detect.test.ts +543 -0
  25. package/src/bot/detect/detect.ts +842 -0
  26. package/src/bot/detect/detectResult.ts +40 -0
  27. package/src/bot/detect/detectTable.test.ts +72 -0
  28. package/src/bot/detect/detectTable.ts +166 -0
  29. package/src/bot/detect/envExample.test.ts +29 -0
  30. package/src/bot/detect/envExample.ts +48 -0
  31. package/src/bot/detect/envKey.ts +7 -0
  32. package/src/bot/detect/envScan.test.ts +39 -0
  33. package/src/bot/detect/envScan.ts +87 -0
  34. package/src/bot/detect/index.ts +17 -0
  35. package/src/bot/detect/inferBotPlatform.test.ts +60 -0
  36. package/src/bot/detect/inferBotPlatform.ts +28 -0
  37. package/src/bot/detect/keys.test.ts +28 -0
  38. package/src/bot/detect/keys.ts +64 -0
  39. package/src/bot/detect/sensitiveKey.test.ts +91 -0
  40. package/src/bot/detect/sensitiveKey.ts +44 -0
  41. package/src/bot/detect/serverkJson.test.ts +88 -0
  42. package/src/bot/detect/serverkJson.ts +174 -0
  43. package/src/bot/index.ts +8 -0
  44. package/src/cli/detectTable.test.ts +66 -0
  45. package/src/cli/detectTable.ts +43 -0
  46. package/src/cli/main.ts +12 -4
  47. package/src/cli/renderedFile.ts +35 -0
  48. package/src/cli/schema.ts +7 -26
  49. package/src/cli/validate.test.ts +283 -3
  50. package/src/cli/validate.ts +105 -7
  51. package/src/context/bridgeContext.ts +17 -1
  52. package/src/core/bridgeAction.ts +2 -0
  53. package/src/core/bridgeActivity.ts +9 -0
  54. package/src/core/bridgeAvailability.ts +10 -0
  55. package/src/core/bridgePort.test.ts +45 -0
  56. package/src/core/bridgePort.ts +26 -0
  57. package/src/core/bridgeUserError.test.ts +10 -0
  58. package/src/core/bridgeUserError.ts +6 -2
  59. package/src/core/describeDownloadFailure.test.ts +58 -0
  60. package/src/core/describeDownloadFailure.ts +69 -0
  61. package/src/core/index.ts +3 -0
  62. package/src/guides/compile.ts +2 -2
  63. package/src/manifest/index.ts +1 -0
  64. package/src/manifest/kind.ts +5 -0
  65. package/src/manifest/manifest.test.ts +630 -3
  66. package/src/manifest/manifest.ts +391 -87
  67. package/src/manifest/runtime.ts +2 -2
  68. package/src/manifest/source.ts +4 -4
  69. package/src/modules/bridge.ts +8 -1
  70. package/src/modules/bridgeLifecycleSubject.ts +8 -0
  71. package/src/modules/bridgeModule.ts +19 -1
  72. package/src/modules/bridgeStopSignal.ts +13 -0
  73. package/src/modules/index.ts +2 -0
  74. package/src/panel/bridgeDetail.ts +4 -1
  75. package/src/protocol/bridgeActivity.ts +54 -0
  76. package/src/protocol/bridgeConfig.ts +2 -0
  77. package/src/protocol/bridgeEvents.ts +30 -0
  78. package/src/protocol/bridgeFrame.ts +10 -1
  79. package/src/protocol/index.ts +1 -0
  80. package/src/runtime/bridgeActions.ts +19 -2
  81. package/src/runtime/bridgeActivity.test.ts +318 -0
  82. package/src/runtime/bridgeActivity.ts +268 -0
  83. package/src/runtime/bridgeConfigReader.test.ts +80 -0
  84. package/src/runtime/bridgeConfigReader.ts +20 -0
  85. package/src/runtime/bridgeContext.test.ts +247 -0
  86. package/src/runtime/bridgeContext.ts +43 -10
  87. package/src/runtime/bridgeDriver.test.ts +153 -1
  88. package/src/runtime/bridgeDriver.ts +64 -8
  89. package/src/runtime/bridgeFiles.test.ts +62 -0
  90. package/src/runtime/bridgeFiles.ts +12 -2
  91. package/src/runtime/bridgeLiveConfig.ts +18 -0
  92. package/src/runtime/bridgeLog.ts +10 -0
  93. package/src/runtime/bridgeRedaction.test.ts +410 -0
  94. package/src/runtime/bridgeRedaction.ts +214 -0
  95. package/src/runtime/bridgeRefusalError.ts +13 -0
  96. package/src/runtime/bridgeRegistry.test.ts +1056 -10
  97. package/src/runtime/bridgeRegistry.ts +464 -50
  98. package/src/runtime/bridgeScopedContext.ts +55 -0
  99. package/src/runtime/bridgeServer.test.ts +534 -4
  100. package/src/runtime/bridgeServer.ts +145 -38
  101. package/src/runtime/bridgeSetupHandlers.test.ts +156 -5
  102. package/src/runtime/bridgeSetupHandlers.ts +59 -14
  103. package/src/runtime/bridgeSupervisor.test.ts +670 -2
  104. package/src/runtime/bridgeSupervisor.ts +219 -67
  105. package/src/runtime/serve.ts +87 -11
  106. package/src/steam/steamcmd.test.ts +32 -1
  107. package/src/steam/steamcmd.ts +27 -4
  108. package/src/utils/changedSettingKeys.test.ts +65 -0
  109. package/src/utils/changedSettingKeys.ts +6 -0
  110. package/src/utils/describeExecFailure.test.ts +34 -0
  111. package/src/utils/describeExecFailure.ts +9 -0
  112. package/src/utils/index.ts +3 -0
  113. package/src/utils/sameSettingValue.test.ts +35 -0
  114. package/src/utils/sameSettingValue.ts +40 -0
  115. package/src/wire/activity.test.ts +95 -0
  116. package/src/wire/activity.ts +41 -0
  117. package/src/wire/index.ts +1 -0
  118. package/src/wire/panel.test.ts +67 -1
  119. package/src/wire/panel.ts +11 -0
@@ -1,5 +1,13 @@
1
1
  import type { BridgeContext, BridgeReply } from "../context";
2
- import type { BridgeInstallOutcome, BridgeKind, BridgeOption, BridgeRow, BridgeStream, BridgeValues } from "../core";
2
+ import type {
3
+ BridgeInstallOutcome,
4
+ BridgeKind,
5
+ BridgeOption,
6
+ BridgeRow,
7
+ BridgeStream,
8
+ BridgeUnavailable,
9
+ BridgeValues,
10
+ } from "../core";
3
11
  import type { BridgeAddOutcome, BridgeDetail, BridgeDetailOutcome } from "../panel";
4
12
  import type {
5
13
  BridgeCatalogEntry,
@@ -11,6 +19,8 @@ import type { BridgeBackupSnapshot, BridgeDescribeResult, BridgePendingFile } fr
11
19
  import type { BridgePreview } from "../protocol/bridgePreview";
12
20
  import type { BridgeSetupStep } from "../protocol/bridgeSetup";
13
21
  import type { BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
22
+ import type { BridgeLifecycleSubject } from "./bridgeLifecycleSubject";
23
+ import type { BridgeStopSignal } from "./bridgeStopSignal";
14
24
 
15
25
  export type BridgeDescription = Omit<BridgeDescribeResult, "supported">;
16
26
 
@@ -25,7 +35,11 @@ export interface BridgeLifecycleModule {
25
35
  ready?: RegExp;
26
36
  failed?: RegExp;
27
37
  stopTimeoutSeconds?: number;
38
+ readyTimeoutSeconds?: number;
39
+ stopSignal?: BridgeStopSignal;
40
+ subject?: BridgeLifecycleSubject;
28
41
  command(context: BridgeContext): Promise<string[]>;
42
+ environment?(context: BridgeContext): Promise<Record<string, string>>;
29
43
  stop?(context: BridgeContext): Promise<void>;
30
44
  onReady?(context: BridgeContext): Promise<void>;
31
45
  }
@@ -109,6 +123,7 @@ export interface BridgeSettingsModule {
109
123
  protectedActions?: string[];
110
124
  read(context: BridgeContext): Promise<BridgeValues>;
111
125
  write?(context: BridgeContext, values: BridgeValues): Promise<void>;
126
+ unavailable?(context: BridgeContext): Promise<BridgeUnavailable>;
112
127
  }
113
128
 
114
129
  export type BridgeRowAction<Row extends BridgeRow = BridgeRow> = (
@@ -124,6 +139,7 @@ export interface BridgeActionsModule {
124
139
  protectedActions?: string[];
125
140
  requiresRunning?: boolean;
126
141
  actions: Record<string, BridgeServerAction>;
142
+ unavailable?(context: BridgeContext): Promise<BridgeUnavailable>;
127
143
  }
128
144
 
129
145
  export interface BridgeCollectionModule<Row extends BridgeRow = BridgeRow> {
@@ -134,6 +150,7 @@ export interface BridgeCollectionModule<Row extends BridgeRow = BridgeRow> {
134
150
  list(context: BridgeContext): Promise<Row[]>;
135
151
  add?(context: BridgeContext, input: string): Promise<BridgeAddOutcome | undefined>;
136
152
  actions?: Record<string, BridgeRowAction<Row>>;
153
+ unavailable?(context: BridgeContext, row: Row): Promise<BridgeUnavailable>;
137
154
  }
138
155
 
139
156
  export type BridgeDetailAction = (context: BridgeContext, args: BridgeValues) => Promise<BridgeDetailOutcome | null>;
@@ -158,6 +175,7 @@ export interface BridgeCatalogModule {
158
175
  install(context: BridgeContext, id: string, releaseId?: string): Promise<BridgeCatalogEntry>;
159
176
  remove(context: BridgeContext, id: string): Promise<void>;
160
177
  toggle?(context: BridgeContext, id: string, enabled: boolean): Promise<void>;
178
+ unavailable?(context: BridgeContext, entry: BridgeCatalogEntry): Promise<BridgeUnavailable>;
161
179
  }
162
180
 
163
181
  export type BridgePanelModule =
@@ -0,0 +1,13 @@
1
+ export enum BridgeStopSignal {
2
+ Term = "SIGTERM",
3
+ Interrupt = "SIGINT",
4
+ }
5
+
6
+ export const BRIDGE_STOP_SIGNALS: Record<BridgeStopSignal, NodeJS.Signals> = {
7
+ [BridgeStopSignal.Term]: "SIGTERM",
8
+ [BridgeStopSignal.Interrupt]: "SIGINT",
9
+ };
10
+
11
+ export const isBridgeStopSignal = (value: unknown): value is BridgeStopSignal => {
12
+ return typeof value === "string" && Object.hasOwn(BRIDGE_STOP_SIGNALS, value);
13
+ };
@@ -1,3 +1,5 @@
1
1
  export * from "./bridge";
2
2
  export * from "./bridgeDriver";
3
+ export * from "./bridgeLifecycleSubject";
3
4
  export * from "./bridgeModule";
5
+ export * from "./bridgeStopSignal";
@@ -1,4 +1,5 @@
1
- import type { BridgeCopy, BridgeValue, LocalizedText } from "../core";
1
+ import type { BridgeCopy, BridgeUnavailable, BridgeValue, LocalizedText } from "../core";
2
+ import type { BridgeIcon } from "./bridgeIcon";
2
3
 
3
4
  export enum BridgeDetailTone {
4
5
  Neutral = "neutral",
@@ -38,11 +39,13 @@ export interface BridgeDetail {
38
39
  subtitle: BridgeCopy | null;
39
40
  description: BridgeCopy | null;
40
41
  image: string | null;
42
+ icon?: BridgeIcon;
41
43
  badges: BridgeDetailBadge[];
42
44
  stats: BridgeDetailStat[];
43
45
  links: BridgeDetailLink[];
44
46
  stale: boolean;
45
47
  actions: string[];
48
+ unavailable?: BridgeUnavailable;
46
49
  }
47
50
 
48
51
  export interface BridgeDetailOutcome {
@@ -0,0 +1,54 @@
1
+ import type { BridgeActivity, BridgeValues } from "../core";
2
+
3
+ export const BRIDGE_ACTIVITY_KEYS = {
4
+ ar: "activityAr",
5
+ en: "activityEn",
6
+ subject: "subject",
7
+ } as const;
8
+
9
+ export const BRIDGE_ACTIVITY_LIMIT = 8;
10
+
11
+ export const BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT = 256;
12
+
13
+ export const BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT = 64;
14
+
15
+ export const BRIDGE_ACTIVITY_DETAILS_LIMIT = 16;
16
+
17
+ export const BRIDGE_ACTIVITY_DETAIL_KEY_LENGTH_LIMIT = 64;
18
+
19
+ export const BRIDGE_ACTIVITY_EXCERPT_LENGTH_LIMIT = 80;
20
+
21
+ export const BRIDGE_ACTIVITY_NAME_LENGTH_LIMIT = 64;
22
+
23
+ export const BRIDGE_ACTIVITY_NAME_PATTERN = /^[A-Z][A-Za-z0-9]*$/;
24
+
25
+ export const BRIDGE_ACTIVITY_DETAIL_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
26
+
27
+ const ELLIPSIS = "…";
28
+
29
+ export const isBridgeActivity = (payload: BridgeValues | null): boolean => {
30
+ return typeof payload?.[BRIDGE_ACTIVITY_KEYS.ar] === "string" && typeof payload[BRIDGE_ACTIVITY_KEYS.en] === "string";
31
+ };
32
+
33
+ export const activityPayload = (entry: BridgeActivity): BridgeValues => {
34
+ return {
35
+ ...entry.details,
36
+ [BRIDGE_ACTIVITY_KEYS.ar]: entry.text.ar,
37
+ [BRIDGE_ACTIVITY_KEYS.en]: entry.text.en,
38
+ ...(entry.subject === undefined
39
+ ? {}
40
+ : {
41
+ [BRIDGE_ACTIVITY_KEYS.subject]: entry.subject,
42
+ }),
43
+ };
44
+ };
45
+
46
+ export const activityExcerpt = (text: string): string => {
47
+ const line = text.trim();
48
+
49
+ if (line.length <= BRIDGE_ACTIVITY_EXCERPT_LENGTH_LIMIT) {
50
+ return line;
51
+ }
52
+
53
+ return `${line.slice(0, BRIDGE_ACTIVITY_EXCERPT_LENGTH_LIMIT - ELLIPSIS.length).trimEnd()}${ELLIPSIS}`;
54
+ };
@@ -4,6 +4,8 @@ export interface BridgeConfig {
4
4
  cwd: string;
5
5
  run: string;
6
6
  ports: Record<string, number>;
7
+ portCounts?: Record<string, number>;
8
+ publicAddress?: string;
7
9
  memoryMb: number;
8
10
  cpuPercent: number;
9
11
  gameUid: number;
@@ -1,23 +1,53 @@
1
1
  export enum BridgeEventName {
2
+ AccessGranted = "AccessGranted",
3
+ AccessRevoked = "AccessRevoked",
4
+ BroadcastSent = "BroadcastSent",
5
+ GameInstalled = "GameInstalled",
2
6
  GameModeChanged = "GameModeChanged",
7
+ GameplayChanged = "GameplayChanged",
8
+ InstallFailed = "InstallFailed",
3
9
  MemoryUndersized = "MemoryUndersized",
4
10
  MissingDependency = "MissingDependency",
5
11
  ModCrashed = "ModCrashed",
12
+ ModDisabled = "ModDisabled",
13
+ ModEnabled = "ModEnabled",
14
+ ModInstalled = "ModInstalled",
6
15
  ModLoaded = "ModLoaded",
16
+ ModLoaderInstalled = "ModLoaderInstalled",
17
+ ModLoaderRemoved = "ModLoaderRemoved",
18
+ ModRemoved = "ModRemoved",
19
+ ModpackInstalled = "ModpackInstalled",
20
+ ModpackRemoved = "ModpackRemoved",
21
+ ModpackSelected = "ModpackSelected",
7
22
  PlayerAdvanced = "PlayerAdvanced",
8
23
  PlayerBanned = "PlayerBanned",
24
+ PlayerChanged = "PlayerChanged",
9
25
  PlayerDied = "PlayerDied",
10
26
  PlayerJoined = "PlayerJoined",
11
27
  PlayerKicked = "PlayerKicked",
12
28
  PlayerLeft = "PlayerLeft",
29
+ PlayerUnbanned = "PlayerUnbanned",
13
30
  PortBindFailed = "PortBindFailed",
31
+ SecretRotated = "SecretRotated",
14
32
  ServerCrashed = "ServerCrashed",
15
33
  ServerStarted = "ServerStarted",
16
34
  ServerStopping = "ServerStopping",
17
35
  ServerUpdated = "ServerUpdated",
36
+ SettingsApplied = "SettingsApplied",
37
+ SettingsChanged = "SettingsChanged",
38
+ SetupAction = "SetupAction",
39
+ SetupCompleted = "SetupCompleted",
40
+ SetupRejected = "SetupRejected",
41
+ SignInExpired = "SignInExpired",
18
42
  TickLagging = "TickLagging",
43
+ WorldActivated = "WorldActivated",
44
+ WorldCloned = "WorldCloned",
19
45
  WorldCorrupt = "WorldCorrupt",
46
+ WorldCreated = "WorldCreated",
47
+ WorldDeleted = "WorldDeleted",
48
+ WorldImported = "WorldImported",
20
49
  WorldSaved = "WorldSaved",
50
+ WorldWiped = "WorldWiped",
21
51
  WrongJavaVersion = "WrongJavaVersion",
22
52
  }
23
53
 
@@ -1,4 +1,11 @@
1
- import type { BridgeDesired, BridgeInstallHold, BridgeRuntime, BridgeValues, LocalizedText } from "../core";
1
+ import type {
2
+ BridgeActivity,
3
+ BridgeDesired,
4
+ BridgeInstallHold,
5
+ BridgeRuntime,
6
+ BridgeValues,
7
+ LocalizedText,
8
+ } from "../core";
2
9
  import type { BridgeSetupSummary } from "./bridgeSetup";
3
10
 
4
11
  export const BRIDGE_SOCKET_NAME = "bridge.sock";
@@ -59,6 +66,7 @@ export interface BridgeSuccess {
59
66
  id: number | null;
60
67
  ok: true;
61
68
  result: unknown;
69
+ activity?: BridgeActivity[];
62
70
  }
63
71
 
64
72
  export interface BridgeFailure {
@@ -68,6 +76,7 @@ export interface BridgeFailure {
68
76
  secret?: string;
69
77
  failure?: string;
70
78
  text?: LocalizedText;
79
+ unchanged?: boolean;
71
80
  }
72
81
 
73
82
  export type BridgeResponse = BridgeFailure | BridgeSuccess;
@@ -1,6 +1,7 @@
1
1
  export * from "../core";
2
2
  export * from "../panel";
3
3
  export * from "../terminal";
4
+ export * from "./bridgeActivity";
4
5
  export * from "./bridgeCatalog";
5
6
  export * from "./bridgeConfig";
6
7
  export * from "./bridgeCrash";
@@ -1,10 +1,21 @@
1
1
  import { BridgeAction, BridgeKind } from "../core";
2
2
  import type { BridgePanelModule } from "../modules";
3
3
 
4
+ const advertisedAvailability = (advertised: boolean): string[] => {
5
+ return advertised
6
+ ? [
7
+ BridgeAction.Availability,
8
+ ]
9
+ : [];
10
+ };
11
+
4
12
  export const actionsOf = (module: BridgePanelModule): string[] => {
5
13
  switch (module.kind) {
6
14
  case BridgeKind.Actions: {
7
- return Object.keys(module.actions);
15
+ return [
16
+ ...Object.keys(module.actions),
17
+ ...advertisedAvailability(module.unavailable !== undefined),
18
+ ];
8
19
  }
9
20
 
10
21
  case BridgeKind.Options: {
@@ -27,6 +38,7 @@ export const actionsOf = (module: BridgePanelModule): string[] => {
27
38
  BridgeAction.Write,
28
39
  ]
29
40
  : []),
41
+ ...advertisedAvailability(module.unavailable !== undefined),
30
42
  ];
31
43
  }
32
44
 
@@ -39,13 +51,17 @@ export const actionsOf = (module: BridgePanelModule): string[] => {
39
51
  ]
40
52
  : []),
41
53
  ...Object.keys(module.actions ?? {}),
54
+ ...advertisedAvailability(module.unavailable !== undefined),
42
55
  ];
43
56
  }
44
57
 
45
58
  case BridgeKind.Detail: {
59
+ const actions = Object.keys(module.actions ?? {});
60
+
46
61
  return [
47
62
  BridgeAction.Read,
48
- ...Object.keys(module.actions ?? {}),
63
+ ...actions,
64
+ ...advertisedAvailability(actions.length > 0),
49
65
  ];
50
66
  }
51
67
 
@@ -70,6 +86,7 @@ export const actionsOf = (module: BridgePanelModule): string[] => {
70
86
  BridgeAction.Toggle,
71
87
  ]
72
88
  : []),
89
+ ...advertisedAvailability(module.unavailable !== undefined),
73
90
  ];
74
91
  }
75
92
  }
@@ -0,0 +1,318 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeActivity, BridgeValues } from "../core";
3
+ import {
4
+ BRIDGE_ACTIVITY_DETAILS_LIMIT,
5
+ BRIDGE_ACTIVITY_KEYS,
6
+ BRIDGE_ACTIVITY_LIMIT,
7
+ BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT,
8
+ BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT,
9
+ BridgeEventName,
10
+ } from "../protocol";
11
+ import {
12
+ type ActivityNormalizeOptions,
13
+ createActivityReporter,
14
+ createActivityScope,
15
+ normalizeActivity,
16
+ } from "./bridgeActivity";
17
+
18
+ const SECRET = "tok-1234567890-secret";
19
+
20
+ const recorder = () => {
21
+ const warnings: {
22
+ message: string;
23
+ fields?: Record<string, unknown>;
24
+ }[] = [];
25
+
26
+ const emitted: {
27
+ event: string;
28
+ payload: BridgeValues;
29
+ }[] = [];
30
+
31
+ const options: ActivityNormalizeOptions = {
32
+ secretKeys: new Set([
33
+ "RCON_PASSWORD",
34
+ ]),
35
+ redact: (text) => text.split(SECRET).join("••••"),
36
+ warn: (message, fields) => {
37
+ warnings.push({
38
+ message,
39
+ fields,
40
+ });
41
+ },
42
+ };
43
+
44
+ const reporter = createActivityReporter({
45
+ ...options,
46
+ onEmit(event, payload) {
47
+ emitted.push({
48
+ event,
49
+ payload,
50
+ });
51
+ },
52
+ });
53
+
54
+ return {
55
+ options,
56
+ reporter,
57
+ warnings,
58
+ emitted,
59
+ };
60
+ };
61
+
62
+ const granted = (subject = "Steve"): BridgeActivity => ({
63
+ name: BridgeEventName.AccessGranted,
64
+ text: {
65
+ ar: `أضفت ${subject} للقائمة البيضاء`,
66
+ en: `Added ${subject} to the whitelist`,
67
+ },
68
+ subject,
69
+ });
70
+
71
+ describe("normalizeActivity", () => {
72
+ test("keeps a well-formed entry as it is", () => {
73
+ const { options, warnings } = recorder();
74
+
75
+ expect(normalizeActivity(granted(), options)).toEqual(granted());
76
+ expect(warnings).toEqual([]);
77
+ });
78
+
79
+ test("drops a name the protocol does not know, with a warning", () => {
80
+ const { options, warnings } = recorder();
81
+
82
+ expect(
83
+ normalizeActivity(
84
+ {
85
+ ...granted(),
86
+ name: "WhitelistAdded" as BridgeEventName,
87
+ },
88
+ options,
89
+ ),
90
+ ).toBeNull();
91
+ expect(warnings).toEqual([
92
+ {
93
+ message: "activity entry normalized",
94
+ fields: {
95
+ name: "WhitelistAdded",
96
+ reason: "unknown-name",
97
+ },
98
+ },
99
+ ]);
100
+ });
101
+
102
+ test("drops an entry whose text is empty in one language", () => {
103
+ const { options } = recorder();
104
+
105
+ expect(
106
+ normalizeActivity(
107
+ {
108
+ ...granted(),
109
+ text: {
110
+ ar: " ",
111
+ en: "Added Steve",
112
+ },
113
+ },
114
+ options,
115
+ ),
116
+ ).toBeNull();
117
+ });
118
+
119
+ test("cuts the text and the subject to their limits", () => {
120
+ const { options, warnings } = recorder();
121
+
122
+ const normalized = normalizeActivity(
123
+ {
124
+ ...granted(),
125
+ text: {
126
+ ar: "ا".repeat(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT + 10),
127
+ en: "a".repeat(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT + 10),
128
+ },
129
+ subject: "s".repeat(BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT + 10),
130
+ },
131
+ options,
132
+ );
133
+
134
+ expect(normalized?.text.ar).toHaveLength(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT);
135
+ expect(normalized?.text.en).toHaveLength(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT);
136
+ expect(normalized?.subject).toHaveLength(BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT);
137
+ expect(warnings.map((warning) => warning.fields?.reason)).toEqual([
138
+ "long-text",
139
+ "long-subject",
140
+ ]);
141
+ });
142
+
143
+ test("keeps at most sixteen details", () => {
144
+ const { options } = recorder();
145
+
146
+ const details: BridgeValues = {};
147
+
148
+ for (let index = 0; index < BRIDGE_ACTIVITY_DETAILS_LIMIT + 4; index++) {
149
+ details[`key${index}`] = index;
150
+ }
151
+
152
+ const normalized = normalizeActivity(
153
+ {
154
+ ...granted(),
155
+ details,
156
+ },
157
+ options,
158
+ );
159
+
160
+ expect(Object.keys(normalized?.details ?? {})).toHaveLength(BRIDGE_ACTIVITY_DETAILS_LIMIT);
161
+ });
162
+
163
+ test("removes secret fields, reserved keys and values that are not scalars", () => {
164
+ const { options } = recorder();
165
+
166
+ const normalized = normalizeActivity(
167
+ {
168
+ ...granted(),
169
+ details: {
170
+ RCON_PASSWORD: "hunter22hunter22",
171
+ [BRIDGE_ACTIVITY_KEYS.ar]: "مزوّر",
172
+ "bad key": "x",
173
+ nested: {
174
+ deep: true,
175
+ } as unknown as string,
176
+ count: Number.NaN,
177
+ from: "1.20.4",
178
+ },
179
+ },
180
+ options,
181
+ );
182
+
183
+ expect(normalized?.details).toEqual({
184
+ from: "1.20.4",
185
+ });
186
+ });
187
+
188
+ test("blanks a secret value in the text, the subject and the details", () => {
189
+ const { options } = recorder();
190
+
191
+ const normalized = normalizeActivity(
192
+ {
193
+ name: BridgeEventName.BroadcastSent,
194
+ text: {
195
+ ar: `أرسلت ${SECRET}`,
196
+ en: `Sent ${SECRET}`,
197
+ },
198
+ subject: SECRET,
199
+ details: {
200
+ message: `hello ${SECRET}`,
201
+ },
202
+ },
203
+ options,
204
+ );
205
+
206
+ expect(JSON.stringify(normalized)).not.toContain(SECRET);
207
+ expect(normalized?.details).toEqual({
208
+ message: "hello ••••",
209
+ });
210
+ });
211
+ });
212
+
213
+ describe("createActivityReporter", () => {
214
+ test("emits the entry as an event carrying the text under the reserved keys", () => {
215
+ const { reporter, emitted } = recorder();
216
+
217
+ reporter.report({
218
+ ...granted(),
219
+ details: {
220
+ source: "panel",
221
+ },
222
+ });
223
+
224
+ expect(emitted).toEqual([
225
+ {
226
+ event: BridgeEventName.AccessGranted,
227
+ payload: {
228
+ source: "panel",
229
+ [BRIDGE_ACTIVITY_KEYS.ar]: "أضفت Steve للقائمة البيضاء",
230
+ [BRIDGE_ACTIVITY_KEYS.en]: "Added Steve to the whitelist",
231
+ [BRIDGE_ACTIVITY_KEYS.subject]: "Steve",
232
+ },
233
+ },
234
+ ]);
235
+ });
236
+
237
+ test("emits nothing for an entry normalisation drops", () => {
238
+ const { reporter, emitted } = recorder();
239
+
240
+ reporter.report({
241
+ ...granted(),
242
+ name: "Nope" as BridgeEventName,
243
+ });
244
+
245
+ expect(emitted).toEqual([]);
246
+ });
247
+ });
248
+
249
+ describe("createActivityScope", () => {
250
+ test("collects entries for the answer instead of emitting them", () => {
251
+ const { options, reporter, emitted } = recorder();
252
+ const scope = createActivityScope(reporter, options);
253
+
254
+ scope.reporter.report(granted("Steve"));
255
+ scope.reporter.report(granted("Alex"));
256
+
257
+ expect(scope.reported).toBe(2);
258
+ expect(scope.close()).toEqual([
259
+ granted("Steve"),
260
+ granted("Alex"),
261
+ ]);
262
+ expect(emitted).toEqual([]);
263
+ });
264
+
265
+ test("drains what it collected to the ring when the handler throws", () => {
266
+ const { options, reporter, emitted } = recorder();
267
+ const scope = createActivityScope(reporter, options);
268
+
269
+ scope.reporter.report(granted());
270
+ scope.drain();
271
+
272
+ expect(emitted.map((entry) => entry.event)).toEqual([
273
+ BridgeEventName.AccessGranted,
274
+ ]);
275
+ expect(scope.close()).toEqual([]);
276
+ });
277
+
278
+ test("sends a report made after the answer to the ring", () => {
279
+ const { options, reporter, emitted } = recorder();
280
+ const scope = createActivityScope(reporter, options);
281
+
282
+ expect(scope.close()).toEqual([]);
283
+
284
+ scope.reporter.report(granted());
285
+
286
+ expect(emitted.map((entry) => entry.payload[BRIDGE_ACTIVITY_KEYS.subject])).toEqual([
287
+ "Steve",
288
+ ]);
289
+ });
290
+
291
+ test("keeps at most eight entries on one answer", () => {
292
+ const { options, reporter, warnings } = recorder();
293
+ const scope = createActivityScope(reporter, options);
294
+
295
+ for (let index = 0; index < BRIDGE_ACTIVITY_LIMIT + 2; index++) {
296
+ scope.reporter.report(granted(`player${index}`));
297
+ }
298
+
299
+ expect(scope.close()).toHaveLength(BRIDGE_ACTIVITY_LIMIT);
300
+ expect(warnings.filter((warning) => warning.fields?.reason === "extra-entries")).toHaveLength(2);
301
+ });
302
+
303
+ test("never throws into the caller, whatever the entry holds", () => {
304
+ const { options, reporter, warnings } = recorder();
305
+ const scope = createActivityScope(reporter, options);
306
+
307
+ const hostile = {
308
+ ...granted(),
309
+ get details(): BridgeValues {
310
+ throw new Error("a getter that throws");
311
+ },
312
+ };
313
+
314
+ expect(() => scope.reporter.report(hostile)).not.toThrow();
315
+ expect(() => reporter.report(hostile)).not.toThrow();
316
+ expect(warnings.filter((warning) => warning.message === "activity report failed")).toHaveLength(2);
317
+ });
318
+ });