@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,12 +1,30 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { tmpdir } from "node:os";
3
- import { BridgeAction, BridgeKind, type BridgeRow, BridgeSetupStepKind, type BridgeValues } from "../core";
3
+ import {
4
+ BridgeAction,
5
+ BridgeKind,
6
+ type BridgeRow,
7
+ BridgeSetupStepKind,
8
+ type BridgeUnavailable,
9
+ type BridgeValues,
10
+ type LocalizedText,
11
+ } from "../core";
4
12
  import type { BridgeDriver } from "../modules";
5
- import { BridgeLayout } from "../panel";
6
- import { type BridgeConfig, BridgeConfirm, BridgeEventName } from "../protocol";
13
+ import { BridgeControl, BridgeFormTarget, BridgeLayout } from "../panel";
14
+ import {
15
+ BRIDGE_ACTIVITY_KEYS,
16
+ BRIDGE_TEXT_LENGTH_LIMIT,
17
+ type BridgeConfig,
18
+ BridgeConfirm,
19
+ BridgeEventName,
20
+ } from "../protocol";
21
+ import { bridgeAvailabilitySchema } from "../wire";
22
+ import { type ActivityNormalizeOptions, createActivityReporter } from "./bridgeActivity";
7
23
  import { createContext } from "./bridgeContext";
8
24
  import { createEventRing } from "./bridgeEvents";
9
25
  import { log } from "./bridgeLog";
26
+ import { secretFieldKeys } from "./bridgeRedaction";
27
+ import { BridgeRefusalError } from "./bridgeRefusalError";
10
28
  import { createRegistry } from "./bridgeRegistry";
11
29
  import { createSupervisor } from "./bridgeSupervisor";
12
30
 
@@ -39,7 +57,7 @@ const baseDriver = (): BridgeDriver => ({
39
57
  },
40
58
  });
41
59
 
42
- const registryFor = (driver: BridgeDriver, variables: Record<string, string> = {}) => {
60
+ const harnessFor = (driver: BridgeDriver, variables: Record<string, string> = {}) => {
43
61
  const settings = {
44
62
  ...config(),
45
63
  variables,
@@ -58,6 +76,19 @@ const registryFor = (driver: BridgeDriver, variables: Record<string, string> = {
58
76
 
59
77
  const ring = createEventRing(driver.events, supervisor);
60
78
 
79
+ const activity: ActivityNormalizeOptions = {
80
+ secretKeys: new Set(secretFieldKeys(driver)),
81
+ redact: (text) => text,
82
+ warn: log.warn,
83
+ };
84
+
85
+ const reporter = createActivityReporter({
86
+ ...activity,
87
+ onEmit(event, payload) {
88
+ ring.emit(event, payload);
89
+ },
90
+ });
91
+
61
92
  const context = createContext(settings, supervisor, {
62
93
  onLog: log,
63
94
  onEmit(event, payload) {
@@ -66,9 +97,22 @@ const registryFor = (driver: BridgeDriver, variables: Record<string, string> = {
66
97
  onSetup() {
67
98
  return;
68
99
  },
100
+ onActivity(entry) {
101
+ reporter.report(entry);
102
+ },
103
+ async onRestart() {
104
+ return;
105
+ },
69
106
  });
70
107
 
71
- return createRegistry(driver, context, supervisor, ring);
108
+ return {
109
+ registry: createRegistry(driver, context, supervisor, ring, activity),
110
+ ring,
111
+ };
112
+ };
113
+
114
+ const registryFor = (driver: BridgeDriver, variables: Record<string, string> = {}) => {
115
+ return harnessFor(driver, variables).registry;
72
116
  };
73
117
 
74
118
  describe("actions modules", () => {
@@ -495,7 +539,7 @@ describe("preview modules", () => {
495
539
  },
496
540
  });
497
541
 
498
- expect(await registry.invoke("transition", BridgeAction.Preview)).toEqual({
542
+ expect((await registry.invoke("transition", BridgeAction.Preview)).result).toEqual({
499
543
  confirm: BridgeConfirm.Strong,
500
544
  lines: [
501
545
  {
@@ -552,6 +596,7 @@ describe("detail modules", () => {
552
596
  actions: [
553
597
  BridgeAction.Read,
554
598
  "update",
599
+ BridgeAction.Availability,
555
600
  ],
556
601
  },
557
602
  ]);
@@ -579,7 +624,7 @@ describe("detail modules", () => {
579
624
  },
580
625
  });
581
626
 
582
- expect(await registry.invoke("modpackStatus", BridgeAction.Read)).toMatchObject({
627
+ expect((await registry.invoke("modpackStatus", BridgeAction.Read)).result).toMatchObject({
583
628
  id: "modrinth:abc",
584
629
  title: "Better MC",
585
630
  });
@@ -610,9 +655,11 @@ describe("detail modules", () => {
610
655
  });
611
656
 
612
657
  expect(
613
- await registry.invoke("modpackStatus", "update", {
614
- id: "modrinth:abc",
615
- }),
658
+ (
659
+ await registry.invoke("modpackStatus", "update", {
660
+ id: "modrinth:abc",
661
+ })
662
+ ).result,
616
663
  ).toEqual({
617
664
  variables: {
618
665
  MODPACK: "modrinth:abc:v2",
@@ -754,3 +801,1002 @@ describe("protected action dispatch", () => {
754
801
  }
755
802
  });
756
803
  });
804
+
805
+ describe("activity", () => {
806
+ const granted = {
807
+ name: BridgeEventName.AccessGranted,
808
+ text: {
809
+ ar: "أضفت Steve للقائمة البيضاء",
810
+ en: "Added Steve to the whitelist",
811
+ },
812
+ subject: "Steve",
813
+ };
814
+
815
+ const whitelistDriver = (fail = false): BridgeDriver => ({
816
+ ...baseDriver(),
817
+ modules: {
818
+ whitelist: {
819
+ kind: BridgeKind.Actions,
820
+ actions: {
821
+ add: async (context) => {
822
+ context.activity.report(granted);
823
+
824
+ if (fail) {
825
+ throw new Error("the whitelist file is read-only");
826
+ }
827
+ },
828
+ },
829
+ },
830
+ },
831
+ });
832
+
833
+ test("answers an invoke with its result and the entries the handler reported", async () => {
834
+ const { registry, ring } = harnessFor(whitelistDriver());
835
+
836
+ expect(await registry.invoke("whitelist", "add")).toEqual({
837
+ result: null,
838
+ activity: [
839
+ granted,
840
+ ],
841
+ });
842
+ expect(ring.drain(0).events).toEqual([]);
843
+ });
844
+
845
+ test("answers an empty list when the handler reported nothing", async () => {
846
+ const { registry } = harnessFor({
847
+ ...baseDriver(),
848
+ modules: {
849
+ gameplay: {
850
+ kind: BridgeKind.Actions,
851
+ actions: {
852
+ timeDay: async () => {
853
+ return;
854
+ },
855
+ },
856
+ },
857
+ },
858
+ });
859
+
860
+ expect(await registry.invoke("gameplay", "timeDay")).toEqual({
861
+ result: null,
862
+ activity: [],
863
+ });
864
+ });
865
+
866
+ test("sends what a failing handler reported to the events ring", async () => {
867
+ const { registry, ring } = harnessFor(whitelistDriver(true));
868
+
869
+ await expect(registry.invoke("whitelist", "add")).rejects.toThrow("read-only");
870
+
871
+ expect(ring.drain(0).events).toEqual([
872
+ {
873
+ seq: 1,
874
+ event: BridgeEventName.AccessGranted,
875
+ payload: {
876
+ [BRIDGE_ACTIVITY_KEYS.ar]: granted.text.ar,
877
+ [BRIDGE_ACTIVITY_KEYS.en]: granted.text.en,
878
+ [BRIDGE_ACTIVITY_KEYS.subject]: "Steve",
879
+ },
880
+ },
881
+ ]);
882
+ });
883
+
884
+ describe("settings writes", () => {
885
+ const settingsDriver = (options: {
886
+ stored: BridgeValues;
887
+ read?: () => Promise<BridgeValues>;
888
+ report?: boolean;
889
+ }): BridgeDriver => ({
890
+ ...baseDriver(),
891
+ panel: {
892
+ tabs: [
893
+ {
894
+ id: "settings",
895
+ title: {
896
+ ar: "الإعدادات",
897
+ en: "Settings",
898
+ },
899
+ sections: [
900
+ {
901
+ layout: BridgeLayout.Form,
902
+ id: "game",
903
+ target: BridgeFormTarget.Settings,
904
+ module: "gameSettings",
905
+ fields: [
906
+ {
907
+ key: "difficulty",
908
+ control: BridgeControl.Select,
909
+ label: {
910
+ ar: "الصعوبة",
911
+ en: "Difficulty",
912
+ },
913
+ },
914
+ {
915
+ key: "rconPassword",
916
+ control: BridgeControl.Secret,
917
+ label: {
918
+ ar: "كلمة سر RCON",
919
+ en: "RCON password",
920
+ },
921
+ },
922
+ ],
923
+ },
924
+ ],
925
+ },
926
+ ],
927
+ },
928
+ modules: {
929
+ gameSettings: {
930
+ kind: BridgeKind.Settings,
931
+ read: options.read ?? (async () => options.stored),
932
+ write: async (context, values) => {
933
+ Object.assign(options.stored, values);
934
+
935
+ if (options.report) {
936
+ context.activity.report({
937
+ name: BridgeEventName.SettingsApplied,
938
+ text: {
939
+ ar: "تطبّقت الإعدادات",
940
+ en: "Settings applied",
941
+ },
942
+ });
943
+ }
944
+ },
945
+ },
946
+ },
947
+ });
948
+
949
+ test("reports the keys whose value changed when the handler reported nothing", async () => {
950
+ const { registry } = harnessFor(
951
+ settingsDriver({
952
+ stored: {
953
+ difficulty: "easy",
954
+ maxPlayers: 10,
955
+ expRate: "1.000000",
956
+ pvp: "True",
957
+ },
958
+ }),
959
+ );
960
+
961
+ const { activity } = await registry.invoke("gameSettings", BridgeAction.Write, {
962
+ difficulty: "hard",
963
+ maxPlayers: "10",
964
+ expRate: 1,
965
+ pvp: true,
966
+ });
967
+
968
+ expect(activity).toEqual([
969
+ {
970
+ name: BridgeEventName.SettingsChanged,
971
+ text: {
972
+ ar: "تم تغيير الإعدادات",
973
+ en: "Changed the settings",
974
+ },
975
+ details: {
976
+ keys: "difficulty",
977
+ count: 1,
978
+ },
979
+ },
980
+ ]);
981
+ });
982
+
983
+ test("finishes the write even when a stored value cannot be compared", async () => {
984
+ const { registry } = harnessFor(
985
+ settingsDriver({
986
+ stored: {},
987
+ read: async () =>
988
+ ({
989
+ difficulty: {
990
+ toString() {
991
+ throw new Error("not a scalar");
992
+ },
993
+ },
994
+ }) as unknown as BridgeValues,
995
+ }),
996
+ );
997
+
998
+ expect(
999
+ await registry.invoke("gameSettings", BridgeAction.Write, {
1000
+ difficulty: "hard",
1001
+ }),
1002
+ ).toEqual({
1003
+ result: null,
1004
+ activity: [],
1005
+ });
1006
+ });
1007
+
1008
+ test("reports nothing when no value changed", async () => {
1009
+ const { registry } = harnessFor(
1010
+ settingsDriver({
1011
+ stored: {
1012
+ difficulty: "easy",
1013
+ },
1014
+ }),
1015
+ );
1016
+
1017
+ expect(
1018
+ (
1019
+ await registry.invoke("gameSettings", BridgeAction.Write, {
1020
+ difficulty: "easy",
1021
+ })
1022
+ ).activity,
1023
+ ).toEqual([]);
1024
+ });
1025
+
1026
+ test("keeps the handler's own entry instead of the default", async () => {
1027
+ const { registry } = harnessFor(
1028
+ settingsDriver({
1029
+ stored: {
1030
+ difficulty: "easy",
1031
+ },
1032
+ report: true,
1033
+ }),
1034
+ );
1035
+
1036
+ const { activity } = await registry.invoke("gameSettings", BridgeAction.Write, {
1037
+ difficulty: "hard",
1038
+ });
1039
+
1040
+ expect(activity.map((entry) => entry.name)).toEqual([
1041
+ BridgeEventName.SettingsApplied,
1042
+ ]);
1043
+ });
1044
+
1045
+ test("reports every written key when the settings could not be read first", async () => {
1046
+ const { registry } = harnessFor(
1047
+ settingsDriver({
1048
+ stored: {},
1049
+ read: async () => {
1050
+ throw new Error("the rest api is not up yet");
1051
+ },
1052
+ }),
1053
+ );
1054
+
1055
+ const { activity } = await registry.invoke("gameSettings", BridgeAction.Write, {
1056
+ difficulty: "hard",
1057
+ pvp: true,
1058
+ });
1059
+
1060
+ expect(activity.at(0)?.details).toEqual({
1061
+ keys: "difficulty,pvp",
1062
+ count: 2,
1063
+ });
1064
+ });
1065
+
1066
+ test("never carries the value of a secret field", async () => {
1067
+ const secret = "hunter22hunter22";
1068
+
1069
+ const { registry } = harnessFor(
1070
+ settingsDriver({
1071
+ stored: {
1072
+ rconPassword: "old-password-1234",
1073
+ },
1074
+ }),
1075
+ );
1076
+
1077
+ const { activity } = await registry.invoke("gameSettings", BridgeAction.Write, {
1078
+ rconPassword: secret,
1079
+ });
1080
+
1081
+ expect(activity.at(0)?.details).toEqual({
1082
+ keys: "rconPassword",
1083
+ count: 1,
1084
+ });
1085
+ expect(JSON.stringify(activity)).not.toContain(secret);
1086
+ });
1087
+
1088
+ test("drops a detail a handler keyed by a secret field", async () => {
1089
+ const { registry } = harnessFor({
1090
+ ...settingsDriver({
1091
+ stored: {},
1092
+ }),
1093
+ modules: {
1094
+ gameSettings: {
1095
+ kind: BridgeKind.Settings,
1096
+ read: async () => ({}),
1097
+ write: async (context, values) => {
1098
+ context.activity.report({
1099
+ name: BridgeEventName.SecretRotated,
1100
+ text: {
1101
+ ar: "تغيّرت كلمة سر RCON",
1102
+ en: "RCON password rotated",
1103
+ },
1104
+ details: {
1105
+ rconPassword: String(values.rconPassword),
1106
+ },
1107
+ });
1108
+ },
1109
+ },
1110
+ },
1111
+ });
1112
+
1113
+ const { activity } = await registry.invoke("gameSettings", BridgeAction.Write, {
1114
+ rconPassword: "hunter22hunter22",
1115
+ });
1116
+
1117
+ expect(activity).toEqual([
1118
+ {
1119
+ name: BridgeEventName.SecretRotated,
1120
+ text: {
1121
+ ar: "تغيّرت كلمة سر RCON",
1122
+ en: "RCON password rotated",
1123
+ },
1124
+ },
1125
+ ]);
1126
+ });
1127
+ });
1128
+
1129
+ test("reports no default for a catalog install", async () => {
1130
+ const { registry } = harnessFor({
1131
+ ...baseDriver(),
1132
+ modules: {
1133
+ mods: {
1134
+ kind: BridgeKind.Catalog,
1135
+ search: async () => ({
1136
+ hits: [],
1137
+ total: 0,
1138
+ providers: [],
1139
+ categories: [],
1140
+ sorts: [],
1141
+ }),
1142
+ installed: async () => [],
1143
+ install: async (_context, id) => ({
1144
+ id,
1145
+ provider: null,
1146
+ path: `mods/${id}.jar`,
1147
+ title: id,
1148
+ version: null,
1149
+ sizeBytes: 0,
1150
+ enabled: true,
1151
+ gameVersion: null,
1152
+ stale: false,
1153
+ pageUrl: null,
1154
+ icon: null,
1155
+ }),
1156
+ remove: async () => {
1157
+ return;
1158
+ },
1159
+ },
1160
+ },
1161
+ });
1162
+
1163
+ expect(
1164
+ (
1165
+ await registry.invoke("mods", BridgeAction.Install, {
1166
+ id: "sodium",
1167
+ })
1168
+ ).activity,
1169
+ ).toEqual([]);
1170
+ });
1171
+ });
1172
+
1173
+ describe("availability", () => {
1174
+ const activeWorld: LocalizedText = {
1175
+ ar: "ما تقدر تحذف العالم الشغّال",
1176
+ en: "You cannot delete the active world",
1177
+ };
1178
+
1179
+ const alreadyActive: LocalizedText = {
1180
+ ar: "هذا العالم شغّال من قبل",
1181
+ en: "This world is already active",
1182
+ };
1183
+
1184
+ const worldsDriver = (spies: { listed: number; deleted: string[]; hookThrows?: boolean }): BridgeDriver => ({
1185
+ ...baseDriver(),
1186
+ modules: {
1187
+ worlds: {
1188
+ kind: BridgeKind.Collection,
1189
+ list: async () => {
1190
+ spies.listed += 1;
1191
+
1192
+ return [
1193
+ {
1194
+ id: "world",
1195
+ active: true,
1196
+ },
1197
+ {
1198
+ id: "old",
1199
+ active: false,
1200
+ },
1201
+ ];
1202
+ },
1203
+ actions: {
1204
+ delete: async (_context, row) => {
1205
+ spies.deleted.push(row.id);
1206
+ },
1207
+ activate: async () => {
1208
+ return;
1209
+ },
1210
+ },
1211
+ unavailable: async (_context, row): Promise<BridgeUnavailable> => {
1212
+ if (spies.hookThrows) {
1213
+ throw new Error("the level file could not be read");
1214
+ }
1215
+
1216
+ return row.active === true
1217
+ ? {
1218
+ delete: activeWorld,
1219
+ activate: alreadyActive,
1220
+ }
1221
+ : {};
1222
+ },
1223
+ },
1224
+ },
1225
+ });
1226
+
1227
+ const refusalOf = async (promise: Promise<unknown>) => {
1228
+ try {
1229
+ await promise;
1230
+ } catch (error) {
1231
+ return error;
1232
+ }
1233
+
1234
+ throw new Error("the invoke did not refuse");
1235
+ };
1236
+
1237
+ test("advertises availability only for a collection that declares the hook", () => {
1238
+ const registry = registryFor({
1239
+ ...baseDriver(),
1240
+ modules: {
1241
+ players: {
1242
+ kind: BridgeKind.Collection,
1243
+ list: async () => [],
1244
+ actions: {
1245
+ kick: async () => {
1246
+ return;
1247
+ },
1248
+ },
1249
+ },
1250
+ worlds: {
1251
+ kind: BridgeKind.Collection,
1252
+ list: async () => [],
1253
+ actions: {
1254
+ delete: async () => {
1255
+ return;
1256
+ },
1257
+ },
1258
+ unavailable: async () => ({}),
1259
+ },
1260
+ },
1261
+ });
1262
+
1263
+ expect(registry.manifest().modules.map((module) => module.actions)).toEqual([
1264
+ [
1265
+ BridgeAction.List,
1266
+ "kick",
1267
+ ],
1268
+ [
1269
+ BridgeAction.List,
1270
+ "delete",
1271
+ BridgeAction.Availability,
1272
+ ],
1273
+ ]);
1274
+ });
1275
+
1276
+ test("answers the reasons per row and leaves available rows out", async () => {
1277
+ const registry = registryFor(
1278
+ worldsDriver({
1279
+ listed: 0,
1280
+ deleted: [],
1281
+ }),
1282
+ );
1283
+
1284
+ expect((await registry.invoke("worlds", BridgeAction.Availability)).result).toEqual({
1285
+ module: {},
1286
+ subjects: {
1287
+ world: {
1288
+ delete: activeWorld,
1289
+ activate: alreadyActive,
1290
+ },
1291
+ },
1292
+ });
1293
+ });
1294
+
1295
+ test("narrows the answer to one row when asked for its id", async () => {
1296
+ const registry = registryFor(
1297
+ worldsDriver({
1298
+ listed: 0,
1299
+ deleted: [],
1300
+ }),
1301
+ );
1302
+
1303
+ expect(
1304
+ (
1305
+ await registry.invoke("worlds", BridgeAction.Availability, {
1306
+ id: "old",
1307
+ })
1308
+ ).result,
1309
+ ).toEqual({
1310
+ module: {},
1311
+ subjects: {},
1312
+ });
1313
+ });
1314
+
1315
+ test("refuses an unavailable row action before the driver is entered", async () => {
1316
+ const spies = {
1317
+ listed: 0,
1318
+ deleted: [] as string[],
1319
+ };
1320
+ const registry = registryFor(worldsDriver(spies));
1321
+
1322
+ const error = await refusalOf(
1323
+ registry.invoke("worlds", "delete", {
1324
+ id: "world",
1325
+ }),
1326
+ );
1327
+
1328
+ expect(error).toBeInstanceOf(BridgeRefusalError);
1329
+ expect((error as BridgeRefusalError).text).toEqual(activeWorld);
1330
+ expect(spies.deleted).toEqual([]);
1331
+
1332
+ await registry.invoke("worlds", "delete", {
1333
+ id: "old",
1334
+ });
1335
+
1336
+ expect(spies.deleted).toEqual([
1337
+ "old",
1338
+ ]);
1339
+ });
1340
+
1341
+ test("lets the action run when the hook itself fails", async () => {
1342
+ const spies = {
1343
+ listed: 0,
1344
+ deleted: [] as string[],
1345
+ hookThrows: true,
1346
+ };
1347
+ const registry = registryFor(worldsDriver(spies));
1348
+
1349
+ await registry.invoke("worlds", "delete", {
1350
+ id: "world",
1351
+ });
1352
+
1353
+ expect(spies.deleted).toEqual([
1354
+ "world",
1355
+ ]);
1356
+ });
1357
+
1358
+ test("lets an availability read join a list already in flight and never reuses a settled one", async () => {
1359
+ const spies = {
1360
+ listed: 0,
1361
+ deleted: [] as string[],
1362
+ };
1363
+ const registry = registryFor(worldsDriver(spies));
1364
+
1365
+ await Promise.all([
1366
+ registry.invoke("worlds", BridgeAction.List),
1367
+ registry.invoke("worlds", BridgeAction.Availability),
1368
+ ]);
1369
+
1370
+ expect(spies.listed).toBe(1);
1371
+
1372
+ await registry.invoke("worlds", BridgeAction.Availability);
1373
+ await registry.invoke("worlds", BridgeAction.List);
1374
+
1375
+ expect(spies.listed).toBe(3);
1376
+ });
1377
+
1378
+ test("lists fresh for every list read and every action", async () => {
1379
+ const spies = {
1380
+ listed: 0,
1381
+ deleted: [] as string[],
1382
+ };
1383
+ const registry = registryFor(worldsDriver(spies));
1384
+
1385
+ await Promise.all([
1386
+ registry.invoke("worlds", BridgeAction.List),
1387
+ registry.invoke("worlds", BridgeAction.List),
1388
+ ]);
1389
+ await registry.invoke("worlds", "delete", {
1390
+ id: "old",
1391
+ });
1392
+
1393
+ expect(spies.listed).toBe(3);
1394
+ });
1395
+
1396
+ test("forgets a list in flight when any module mutates", async () => {
1397
+ let listed = 0;
1398
+ let release = () => {};
1399
+ const gate = new Promise<void>((resolve) => {
1400
+ release = resolve;
1401
+ });
1402
+
1403
+ const registry = registryFor({
1404
+ ...baseDriver(),
1405
+ modules: {
1406
+ worlds: {
1407
+ kind: BridgeKind.Collection,
1408
+ list: async () => {
1409
+ listed += 1;
1410
+
1411
+ if (listed === 1) {
1412
+ await gate;
1413
+ }
1414
+
1415
+ return [];
1416
+ },
1417
+ actions: {
1418
+ delete: async () => {
1419
+ return;
1420
+ },
1421
+ },
1422
+ unavailable: async () => ({}),
1423
+ },
1424
+ gameplay: {
1425
+ kind: BridgeKind.Actions,
1426
+ actions: {
1427
+ activate: async () => {
1428
+ return;
1429
+ },
1430
+ },
1431
+ },
1432
+ },
1433
+ });
1434
+
1435
+ const first = registry.invoke("worlds", BridgeAction.List);
1436
+
1437
+ await Bun.sleep(0);
1438
+ await registry.invoke("gameplay", "activate");
1439
+ await registry.invoke("worlds", BridgeAction.Availability);
1440
+
1441
+ expect(listed).toBe(2);
1442
+
1443
+ release();
1444
+ await first;
1445
+ });
1446
+
1447
+ test("drops reasons for actions the module cannot refuse and clamps long ones", async () => {
1448
+ const registry = registryFor({
1449
+ ...baseDriver(),
1450
+ modules: {
1451
+ worlds: {
1452
+ kind: BridgeKind.Collection,
1453
+ list: async () => [
1454
+ {
1455
+ id: "world",
1456
+ },
1457
+ ],
1458
+ actions: {
1459
+ delete: async () => {
1460
+ return;
1461
+ },
1462
+ },
1463
+ unavailable: async () => ({
1464
+ list: activeWorld,
1465
+ rename: activeWorld,
1466
+ delete: {
1467
+ ar: "ا".repeat(BRIDGE_TEXT_LENGTH_LIMIT + 20),
1468
+ en: "a".repeat(BRIDGE_TEXT_LENGTH_LIMIT + 20),
1469
+ },
1470
+ }),
1471
+ },
1472
+ },
1473
+ });
1474
+
1475
+ expect((await registry.invoke("worlds", BridgeAction.Availability)).result).toEqual({
1476
+ module: {},
1477
+ subjects: {
1478
+ world: {
1479
+ delete: {
1480
+ ar: "ا".repeat(BRIDGE_TEXT_LENGTH_LIMIT),
1481
+ en: "a".repeat(BRIDGE_TEXT_LENGTH_LIMIT),
1482
+ },
1483
+ },
1484
+ },
1485
+ });
1486
+ });
1487
+
1488
+ test("drops entries the wire would refuse so the answer always validates", async () => {
1489
+ const registry = registryFor({
1490
+ ...baseDriver(),
1491
+ modules: {
1492
+ worlds: {
1493
+ kind: BridgeKind.Collection,
1494
+ list: async () => [
1495
+ {
1496
+ id: "",
1497
+ },
1498
+ {
1499
+ id: "world",
1500
+ },
1501
+ ],
1502
+ actions: {
1503
+ "not a key": async () => {
1504
+ return;
1505
+ },
1506
+ delete: async () => {
1507
+ return;
1508
+ },
1509
+ },
1510
+ unavailable: async () => ({
1511
+ "not a key": activeWorld,
1512
+ delete: activeWorld,
1513
+ }),
1514
+ },
1515
+ },
1516
+ });
1517
+
1518
+ const { result } = await registry.invoke("worlds", BridgeAction.Availability);
1519
+
1520
+ expect(result).toEqual({
1521
+ module: {},
1522
+ subjects: {
1523
+ world: {
1524
+ delete: activeWorld,
1525
+ },
1526
+ },
1527
+ });
1528
+ expect(bridgeAvailabilitySchema.safeParse(result).success).toBe(true);
1529
+ });
1530
+
1531
+ test("enforces the allow-list of a detail and answers its reasons", async () => {
1532
+ const updates: BridgeValues[] = [];
1533
+ let pressable: string[] = [];
1534
+ const stale: LocalizedText = {
1535
+ ar: "المود باك محدّث",
1536
+ en: "The modpack is up to date",
1537
+ };
1538
+
1539
+ const registry = registryFor({
1540
+ ...baseDriver(),
1541
+ modules: {
1542
+ modpackStatus: {
1543
+ kind: BridgeKind.Detail,
1544
+ read: async () => ({
1545
+ id: "modrinth:abc",
1546
+ title: "Better MC",
1547
+ subtitle: null,
1548
+ description: null,
1549
+ image: null,
1550
+ badges: [],
1551
+ stats: [],
1552
+ links: [],
1553
+ stale: false,
1554
+ actions: pressable,
1555
+ unavailable: {
1556
+ update: stale,
1557
+ },
1558
+ }),
1559
+ actions: {
1560
+ update: async (_context, args) => {
1561
+ updates.push(args);
1562
+
1563
+ return null;
1564
+ },
1565
+ repair: async () => null,
1566
+ },
1567
+ },
1568
+ },
1569
+ });
1570
+
1571
+ expect((await registry.invoke("modpackStatus", BridgeAction.Availability)).result).toEqual({
1572
+ module: {
1573
+ update: stale,
1574
+ repair: {
1575
+ ar: "هذا الإجراء مو متاح الحين",
1576
+ en: "This action is not available right now",
1577
+ },
1578
+ },
1579
+ subjects: {},
1580
+ });
1581
+
1582
+ const error = await refusalOf(registry.invoke("modpackStatus", "update"));
1583
+
1584
+ expect(error).toBeInstanceOf(BridgeRefusalError);
1585
+ expect((error as BridgeRefusalError).text).toEqual(stale);
1586
+ expect(updates).toEqual([]);
1587
+
1588
+ pressable = [
1589
+ "update",
1590
+ ];
1591
+
1592
+ await registry.invoke("modpackStatus", "update");
1593
+
1594
+ expect(updates).toHaveLength(1);
1595
+ });
1596
+
1597
+ test("refuses an actions module, a settings write and a catalog removal through their hooks", async () => {
1598
+ const closed: LocalizedText = {
1599
+ ar: "مقفل الحين",
1600
+ en: "Closed right now",
1601
+ };
1602
+ const entered: string[] = [];
1603
+
1604
+ const registry = registryFor({
1605
+ ...baseDriver(),
1606
+ modules: {
1607
+ gameplay: {
1608
+ kind: BridgeKind.Actions,
1609
+ actions: {
1610
+ wipe: async () => {
1611
+ entered.push("wipe");
1612
+ },
1613
+ },
1614
+ unavailable: async () => ({
1615
+ wipe: closed,
1616
+ }),
1617
+ },
1618
+ gameSettings: {
1619
+ kind: BridgeKind.Settings,
1620
+ read: async () => ({}),
1621
+ write: async () => {
1622
+ entered.push("write");
1623
+ },
1624
+ unavailable: async () => ({
1625
+ write: closed,
1626
+ }),
1627
+ },
1628
+ mods: {
1629
+ kind: BridgeKind.Catalog,
1630
+ search: async () => ({
1631
+ hits: [],
1632
+ total: 0,
1633
+ providers: [],
1634
+ categories: [],
1635
+ sorts: [],
1636
+ }),
1637
+ installed: async () => [
1638
+ {
1639
+ id: "jei",
1640
+ provider: null,
1641
+ path: "mods/jei.jar",
1642
+ title: "JEI",
1643
+ version: null,
1644
+ sizeBytes: 1,
1645
+ enabled: true,
1646
+ gameVersion: null,
1647
+ stale: false,
1648
+ pageUrl: null,
1649
+ icon: null,
1650
+ },
1651
+ ],
1652
+ install: async () => {
1653
+ throw new Error("not in this test");
1654
+ },
1655
+ remove: async () => {
1656
+ entered.push("remove");
1657
+ },
1658
+ unavailable: async () => ({
1659
+ remove: closed,
1660
+ }),
1661
+ },
1662
+ },
1663
+ });
1664
+
1665
+ expect(registry.manifest().modules.map((module) => module.actions.includes(BridgeAction.Availability))).toEqual([
1666
+ true,
1667
+ true,
1668
+ true,
1669
+ ]);
1670
+
1671
+ for (const [module, action, args] of [
1672
+ [
1673
+ "gameplay",
1674
+ "wipe",
1675
+ {},
1676
+ ],
1677
+ [
1678
+ "gameSettings",
1679
+ BridgeAction.Write,
1680
+ {
1681
+ motd: "hi",
1682
+ },
1683
+ ],
1684
+ [
1685
+ "mods",
1686
+ BridgeAction.Remove,
1687
+ {
1688
+ id: "jei",
1689
+ },
1690
+ ],
1691
+ ] as const) {
1692
+ const error = await refusalOf(registry.invoke(module, action, args));
1693
+
1694
+ expect(error).toBeInstanceOf(BridgeRefusalError);
1695
+ expect((error as BridgeRefusalError).text).toEqual(closed);
1696
+ }
1697
+
1698
+ expect(entered).toEqual([]);
1699
+
1700
+ expect((await registry.invoke("mods", BridgeAction.Availability)).result).toEqual({
1701
+ module: {},
1702
+ subjects: {
1703
+ jei: {
1704
+ remove: closed,
1705
+ },
1706
+ },
1707
+ });
1708
+ expect((await registry.invoke("gameplay", BridgeAction.Availability)).result).toEqual({
1709
+ module: {
1710
+ wipe: closed,
1711
+ },
1712
+ subjects: {},
1713
+ });
1714
+ });
1715
+
1716
+ test("does not answer availability for a module that declares no hook", async () => {
1717
+ const registry = registryFor({
1718
+ ...baseDriver(),
1719
+ modules: {
1720
+ players: {
1721
+ kind: BridgeKind.Collection,
1722
+ list: async () => [],
1723
+ },
1724
+ },
1725
+ });
1726
+
1727
+ await expect(registry.invoke("players", BridgeAction.Availability)).rejects.toThrow(
1728
+ 'module "players" does not support "availability"',
1729
+ );
1730
+ });
1731
+
1732
+ test("refuses a driver that names a row action availability", () => {
1733
+ expect(() =>
1734
+ registryFor({
1735
+ ...baseDriver(),
1736
+ modules: {
1737
+ worlds: {
1738
+ kind: BridgeKind.Collection,
1739
+ list: async () => [],
1740
+ actions: {
1741
+ availability: async () => {
1742
+ return;
1743
+ },
1744
+ },
1745
+ },
1746
+ },
1747
+ }),
1748
+ ).toThrow('module "worlds" declares action "availability", which is reserved for reads');
1749
+ });
1750
+
1751
+ test("turns the gates that never enter the driver into refusals without text", async () => {
1752
+ const registry = registryFor({
1753
+ ...baseDriver(),
1754
+ modules: {
1755
+ gameplay: {
1756
+ kind: BridgeKind.Actions,
1757
+ requiresRunning: true,
1758
+ actions: {
1759
+ timeDay: async () => {
1760
+ return;
1761
+ },
1762
+ },
1763
+ },
1764
+ world: {
1765
+ kind: BridgeKind.Actions,
1766
+ protectedActions: [
1767
+ "replace",
1768
+ ],
1769
+ actions: {
1770
+ replace: async () => {
1771
+ return;
1772
+ },
1773
+ },
1774
+ },
1775
+ players: {
1776
+ kind: BridgeKind.Collection,
1777
+ list: async () => [],
1778
+ actions: {
1779
+ kick: async () => {
1780
+ return;
1781
+ },
1782
+ },
1783
+ },
1784
+ },
1785
+ });
1786
+
1787
+ for (const invoke of [
1788
+ registry.invoke("missing", "timeDay"),
1789
+ registry.invoke("gameplay", "timeDay"),
1790
+ registry.invoke("world", "replace"),
1791
+ registry.invoke("players", "kick", {
1792
+ id: "meslzy",
1793
+ }),
1794
+ registry.invoke("gameplay", "nightTime"),
1795
+ ]) {
1796
+ const error = await refusalOf(invoke);
1797
+
1798
+ expect(error).toBeInstanceOf(BridgeRefusalError);
1799
+ expect((error as BridgeRefusalError).text).toBeNull();
1800
+ }
1801
+ });
1802
+ });