@serverkgg/bridge 0.1.13 → 0.1.15

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 (35) hide show
  1. package/package.json +1 -1
  2. package/rules/package.md +3 -0
  3. package/src/catalogs/curseforge.test.ts +46 -0
  4. package/src/catalogs/curseforge.ts +20 -0
  5. package/src/context/bridgeContext.ts +1 -0
  6. package/src/manifest/extension.ts +1 -0
  7. package/src/manifest/manifest.test.ts +18 -0
  8. package/src/modules/bridge.ts +13 -2
  9. package/src/modules/bridgeModule.ts +20 -4
  10. package/src/panel/bridgePanel.ts +2 -0
  11. package/src/protocol/bridgeCatalog.ts +7 -0
  12. package/src/protocol/bridgeCatalogSource.ts +9 -0
  13. package/src/protocol/bridgeConfig.ts +1 -0
  14. package/src/protocol/bridgeFrame.ts +1 -0
  15. package/src/protocol/bridgeLimits.ts +6 -0
  16. package/src/protocol/bridgeManifest.ts +2 -0
  17. package/src/protocol/index.ts +1 -0
  18. package/src/runtime/bridgeActions.ts +9 -2
  19. package/src/runtime/bridgeConfigReader.test.ts +31 -0
  20. package/src/runtime/bridgeConfigReader.ts +1 -0
  21. package/src/runtime/bridgeContext.test.ts +53 -0
  22. package/src/runtime/bridgeContext.ts +3 -0
  23. package/src/runtime/bridgeDriver.test.ts +347 -1
  24. package/src/runtime/bridgeDriver.ts +130 -18
  25. package/src/runtime/bridgeLiveConfig.test.ts +137 -1
  26. package/src/runtime/bridgeLiveConfig.ts +46 -2
  27. package/src/runtime/bridgeRegistry.test.ts +220 -0
  28. package/src/runtime/bridgeRegistry.ts +27 -1
  29. package/src/runtime/bridgeSample.test.ts +63 -0
  30. package/src/runtime/bridgeSample.ts +22 -0
  31. package/src/runtime/serve.ts +5 -12
  32. package/src/steam/workshop.test.ts +19 -1
  33. package/src/steam/workshop.ts +12 -0
  34. package/src/wire/panel.test.ts +177 -0
  35. package/src/wire/panel.ts +16 -0
@@ -4,10 +4,12 @@ import { BridgeKind, BridgeSetupStepKind, PortJoin, PortProtocol } from "../core
4
4
  import { GuideOpenTab } from "../guides";
5
5
  import type { ManifestPort, ManifestPortActivation } from "../manifest";
6
6
  import {
7
+ type BridgeCatalogModule,
7
8
  type BridgeDriver,
8
9
  BridgeLifecycleSubject,
9
10
  type BridgePendingModule,
10
11
  type BridgeSetupModule,
12
+ type BridgeSourcedCatalogModule,
11
13
  BridgeStopSignal,
12
14
  } from "../modules";
13
15
  import {
@@ -23,7 +25,7 @@ import {
23
25
  BridgeUploadMode,
24
26
  type BridgeVisibleWhen,
25
27
  } from "../panel";
26
- import type { BridgeSetupStep } from "../protocol";
28
+ import { type BridgeCatalogEntry, BridgeCatalogSourceKind, type BridgeSetupStep } from "../protocol";
27
29
  import { validateDriver, validatePortActivation } from "./bridgeDriver";
28
30
 
29
31
  const text = (value: string) => ({
@@ -1030,6 +1032,212 @@ describe("table uploads", () => {
1030
1032
  });
1031
1033
  });
1032
1034
 
1035
+ describe("catalog sources and uploads", () => {
1036
+ const entry = (id: string): BridgeCatalogEntry => ({
1037
+ id,
1038
+ provider: null,
1039
+ path: `Mods/${id}`,
1040
+ title: id,
1041
+ version: null,
1042
+ sizeBytes: 0,
1043
+ enabled: true,
1044
+ gameVersion: null,
1045
+ stale: false,
1046
+ pageUrl: null,
1047
+ icon: null,
1048
+ });
1049
+
1050
+ const sourced = (overrides: Partial<BridgeSourcedCatalogModule> = {}): BridgeSourcedCatalogModule => ({
1051
+ kind: BridgeKind.Catalog,
1052
+ source: {
1053
+ kind: BridgeCatalogSourceKind.Workshop,
1054
+ appId: 1_623_730,
1055
+ provided: [
1056
+ "UE4SS",
1057
+ ],
1058
+ },
1059
+ installed: async () => [],
1060
+ install: async (_context, id) => entry(id),
1061
+ remove: async () => {
1062
+ return;
1063
+ },
1064
+ ...overrides,
1065
+ });
1066
+
1067
+ const modsUpload: BridgeSectionUpload = {
1068
+ label: text("Upload a mod"),
1069
+ extensions: [
1070
+ "zip",
1071
+ ],
1072
+ staging: ".serverk-staging/mods-upload",
1073
+ mode: BridgeUploadMode.Archive,
1074
+ };
1075
+
1076
+ const driverWithCatalog = (mods: BridgeCatalogModule, upload?: BridgeSectionUpload): BridgeDriver => ({
1077
+ install: {
1078
+ kind: BridgeKind.Install,
1079
+ run: async () => {
1080
+ return;
1081
+ },
1082
+ },
1083
+ lifecycle: {
1084
+ kind: BridgeKind.Lifecycle,
1085
+ command: async () => [
1086
+ "run",
1087
+ ],
1088
+ },
1089
+ modules: {
1090
+ mods,
1091
+ },
1092
+ panel: {
1093
+ tabs: [
1094
+ {
1095
+ id: "mods",
1096
+ title: text("Mods"),
1097
+ sections: [
1098
+ {
1099
+ layout: BridgeLayout.Catalog,
1100
+ id: "mods",
1101
+ module: "mods",
1102
+ ...(upload
1103
+ ? {
1104
+ upload,
1105
+ }
1106
+ : {}),
1107
+ },
1108
+ ],
1109
+ },
1110
+ ],
1111
+ },
1112
+ });
1113
+
1114
+ it("accepts a sourced catalog that leaves search to the platform", () => {
1115
+ expect(() => validateDriver(driverWithCatalog(sourced()))).not.toThrow();
1116
+ });
1117
+
1118
+ it("rejects a catalog that has neither a source nor its own search", () => {
1119
+ const { source: _source, ...unsourced } = sourced();
1120
+
1121
+ expect(() => validateDriver(driverWithCatalog(unsourced as unknown as BridgeCatalogModule))).toThrow(
1122
+ 'module "mods" declares no search — a catalog without a source answers its own search',
1123
+ );
1124
+ });
1125
+
1126
+ it("rejects a source the platform does not serve", () => {
1127
+ const mods = sourced({
1128
+ source: {
1129
+ kind: "nexus" as BridgeCatalogSourceKind,
1130
+ appId: 1_623_730,
1131
+ },
1132
+ });
1133
+
1134
+ expect(() => validateDriver(driverWithCatalog(mods))).toThrow(
1135
+ 'module "mods" declares source "nexus" — the platform serves only workshop',
1136
+ );
1137
+ });
1138
+
1139
+ it("rejects a source app id that is not a positive whole number", () => {
1140
+ for (const appId of [
1141
+ 0,
1142
+ -1,
1143
+ 1.5,
1144
+ ]) {
1145
+ const mods = sourced({
1146
+ source: {
1147
+ kind: BridgeCatalogSourceKind.Workshop,
1148
+ appId,
1149
+ },
1150
+ });
1151
+
1152
+ expect(() => validateDriver(driverWithCatalog(mods))).toThrow("it must be a positive whole number");
1153
+ }
1154
+ });
1155
+
1156
+ it("rejects a provided name that is not a non-empty string", () => {
1157
+ for (const name of [
1158
+ "",
1159
+ 7,
1160
+ ]) {
1161
+ const mods = sourced({
1162
+ source: {
1163
+ kind: BridgeCatalogSourceKind.Workshop,
1164
+ appId: 1_623_730,
1165
+ provided: [
1166
+ name as string,
1167
+ ],
1168
+ },
1169
+ });
1170
+
1171
+ expect(() => validateDriver(driverWithCatalog(mods))).toThrow(
1172
+ "source declares a provided name that is not a non-empty string",
1173
+ );
1174
+ }
1175
+ });
1176
+
1177
+ it("rejects more provided names than the platform takes", () => {
1178
+ const mods = sourced({
1179
+ source: {
1180
+ kind: BridgeCatalogSourceKind.Workshop,
1181
+ appId: 1_623_730,
1182
+ provided: Array.from(
1183
+ {
1184
+ length: 17,
1185
+ },
1186
+ (_entry, index) => `Pack${index}`,
1187
+ ),
1188
+ },
1189
+ });
1190
+
1191
+ expect(() => validateDriver(driverWithCatalog(mods))).toThrow("declares 17 provided names");
1192
+ });
1193
+
1194
+ it("rejects a sourced catalog that answers its own search", () => {
1195
+ const mods = {
1196
+ ...sourced(),
1197
+ search: async () => ({
1198
+ hits: [],
1199
+ total: 0,
1200
+ providers: [],
1201
+ categories: [],
1202
+ sorts: [],
1203
+ }),
1204
+ } as unknown as BridgeCatalogModule;
1205
+
1206
+ expect(() => validateDriver(driverWithCatalog(mods))).toThrow(
1207
+ 'module "mods" declares a source and its own search — the platform answers search for a sourced catalog',
1208
+ );
1209
+ });
1210
+
1211
+ it("accepts a catalog upload whose module installs it through add", () => {
1212
+ const mods = sourced({
1213
+ add: async () => entry("upload"),
1214
+ });
1215
+
1216
+ expect(() => validateDriver(driverWithCatalog(mods, modsUpload))).not.toThrow();
1217
+ });
1218
+
1219
+ it("rejects a catalog upload whose module has no add handler", () => {
1220
+ expect(() => validateDriver(driverWithCatalog(sourced(), modsUpload))).toThrow(
1221
+ 'tab "mods" section "mods" declares an upload control but module "mods" has no add handler',
1222
+ );
1223
+ });
1224
+
1225
+ it("checks a catalog upload as it checks a table upload", () => {
1226
+ const mods = sourced({
1227
+ add: async () => entry("upload"),
1228
+ });
1229
+
1230
+ expect(() =>
1231
+ validateDriver(
1232
+ driverWithCatalog(mods, {
1233
+ ...modsUpload,
1234
+ extensions: [],
1235
+ }),
1236
+ ),
1237
+ ).toThrow("declares an upload control with no extensions");
1238
+ });
1239
+ });
1240
+
1033
1241
  describe("the pending module", () => {
1034
1242
  const driverWithPending = (pending: BridgePendingModule): BridgeDriver => ({
1035
1243
  install: {
@@ -1691,6 +1899,144 @@ describe("field patterns", () => {
1691
1899
  });
1692
1900
  });
1693
1901
 
1902
+ describe("field defaults", () => {
1903
+ it("takes a default on a boolean, a select and a text field of a variables form", () => {
1904
+ expect(() =>
1905
+ validateDriver(
1906
+ driverWithFields([
1907
+ {
1908
+ key: "WINDOWS_STEAM",
1909
+ control: BridgeControl.Boolean,
1910
+ label: text("Use Steam"),
1911
+ default: "true",
1912
+ },
1913
+ {
1914
+ key: "DIFFICULTY",
1915
+ control: BridgeControl.Select,
1916
+ label: text("Difficulty"),
1917
+ options: [
1918
+ {
1919
+ value: "normal",
1920
+ label: text("Normal"),
1921
+ },
1922
+ ],
1923
+ default: "normal",
1924
+ },
1925
+ {
1926
+ key: "SERVER_NAME",
1927
+ control: BridgeControl.Text,
1928
+ label: text("Name"),
1929
+ maxLength: 8,
1930
+ default: "Serverk",
1931
+ },
1932
+ ]),
1933
+ ),
1934
+ ).not.toThrow();
1935
+ });
1936
+
1937
+ it("rejects a boolean default other than true or false", () => {
1938
+ expect(() =>
1939
+ validateDriver(
1940
+ driverWithFields([
1941
+ {
1942
+ key: "WINDOWS_STEAM",
1943
+ control: BridgeControl.Boolean,
1944
+ label: text("Use Steam"),
1945
+ default: "yes",
1946
+ },
1947
+ ]),
1948
+ ),
1949
+ ).toThrow('declares default "yes" on a boolean control — use "true" or "false"');
1950
+ });
1951
+
1952
+ it("rejects a select default that is not one of its options", () => {
1953
+ expect(() =>
1954
+ validateDriver(
1955
+ driverWithFields([
1956
+ {
1957
+ key: "DIFFICULTY",
1958
+ control: BridgeControl.Select,
1959
+ label: text("Difficulty"),
1960
+ options: [
1961
+ {
1962
+ value: "normal",
1963
+ label: text("Normal"),
1964
+ },
1965
+ ],
1966
+ default: "hard",
1967
+ },
1968
+ ]),
1969
+ ),
1970
+ ).toThrow('declares default "hard", which is not one of its options');
1971
+ });
1972
+
1973
+ it("rejects a default on a secret field", () => {
1974
+ expect(() =>
1975
+ validateDriver(
1976
+ driverWithFields([
1977
+ {
1978
+ key: "ADMIN_PASSWORD",
1979
+ control: BridgeControl.Secret,
1980
+ label: text("Admin password"),
1981
+ default: "letmein1",
1982
+ },
1983
+ ]),
1984
+ ),
1985
+ ).toThrow("declares a default on a secret control — a secret is never preset");
1986
+ });
1987
+
1988
+ it("rejects a default longer than the field takes", () => {
1989
+ expect(() =>
1990
+ validateDriver(
1991
+ driverWithFields([
1992
+ {
1993
+ key: "SERVER_NAME",
1994
+ control: BridgeControl.Text,
1995
+ label: text("Name"),
1996
+ maxLength: 4,
1997
+ default: "Serverk",
1998
+ },
1999
+ ]),
2000
+ ),
2001
+ ).toThrow("default is 7 characters — the platform takes at most 4");
2002
+ });
2003
+
2004
+ it("rejects a default longer than the platform takes, even when the field takes more", () => {
2005
+ expect(() =>
2006
+ validateDriver(
2007
+ driverWithFields([
2008
+ {
2009
+ key: "MOTD",
2010
+ control: BridgeControl.Text,
2011
+ label: text("Message"),
2012
+ maxLength: 1024,
2013
+ default: "a".repeat(257),
2014
+ },
2015
+ ]),
2016
+ ),
2017
+ ).toThrow("default is 257 characters — the platform takes at most 256");
2018
+ });
2019
+
2020
+ it("rejects a default on an action field, which is no variable", () => {
2021
+ expect(() =>
2022
+ validateDriver(
2023
+ driverWithActions([
2024
+ {
2025
+ id: "say",
2026
+ label: text("Say"),
2027
+ fields: [
2028
+ {
2029
+ ...field("message"),
2030
+ default: "hello",
2031
+ },
2032
+ ],
2033
+ },
2034
+ ]),
2035
+ ),
2036
+ ).toThrow("declares a default outside a form that targets variables");
2037
+ });
2038
+ });
2039
+
1694
2040
  describe("section placement", () => {
1695
2041
  const anyVisibleWhen: BridgeVisibleWhen = {
1696
2042
  variable: "MODPACK",
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  BRIDGE_ID_PATTERN,
3
3
  BRIDGE_MODULE_ID_PATTERN,
4
+ BridgeAction,
4
5
  BridgeKind,
5
6
  BridgeSetupStepKind,
6
7
  isReadAction,
@@ -9,6 +10,7 @@ import {
9
10
  import { GuideOpenTab } from "../guides";
10
11
  import type { ManifestPort } from "../manifest";
11
12
  import {
13
+ type BridgeCatalogModule,
12
14
  type BridgeDriver,
13
15
  BridgeLifecycleSubject,
14
16
  type BridgePanelModule,
@@ -24,12 +26,15 @@ import {
24
26
  BridgePlace,
25
27
  type BridgeSection,
26
28
  type BridgeSectionAction,
29
+ type BridgeSectionUpload,
27
30
  type BridgeTab,
28
31
  BridgeUploadMode,
29
32
  type BridgeVisibleWhen,
30
33
  } from "../panel";
31
34
  import {
32
35
  BRIDGE_ACTION_FIELDS_LIMIT,
36
+ BRIDGE_CATALOG_PROVIDED_LIMIT,
37
+ BRIDGE_DEFAULT_LENGTH_LIMIT,
33
38
  BRIDGE_DEPENDS_ON_LIMIT,
34
39
  BRIDGE_EVENTS_LIMIT,
35
40
  BRIDGE_EXTENSION_LENGTH_LIMIT,
@@ -44,6 +49,7 @@ import {
44
49
  BRIDGE_PANEL_TABS_LIMIT,
45
50
  BRIDGE_PATTERN_LENGTH_LIMIT,
46
51
  BRIDGE_PLACEHOLDER_LENGTH_LIMIT,
52
+ BRIDGE_PROVIDED_NAME_LENGTH_LIMIT,
47
53
  BRIDGE_ROW_ACTIONS_LIMIT,
48
54
  BRIDGE_SECTION_ACTIONS_LIMIT,
49
55
  BRIDGE_SETUP_STEPS_LIMIT,
@@ -53,11 +59,14 @@ import {
53
59
  BRIDGE_TEXT_LENGTH_LIMIT,
54
60
  BRIDGE_UPLOAD_EXTENSIONS_LIMIT,
55
61
  BRIDGE_VISIBLE_VALUES_LIMIT,
62
+ BridgeCatalogSourceKind,
56
63
  } from "../protocol";
57
64
  import { actionsOf } from "./bridgeActions";
58
65
 
59
66
  const UPLOAD_MODES = new Set<string>(Object.values(BridgeUploadMode));
60
67
 
68
+ const CATALOG_SOURCE_KINDS = new Set<string>(Object.values(BridgeCatalogSourceKind));
69
+
61
70
  const READY_TIMEOUT_MIN_SECONDS = 1;
62
71
 
63
72
  const READY_TIMEOUT_MAX_SECONDS = 3600;
@@ -256,7 +265,41 @@ const validateVisibleWhen = (visibleWhen: BridgeVisibleWhen | undefined, where:
256
265
  countIn((visibleWhen.values ?? []).length, BRIDGE_VISIBLE_VALUES_LIMIT, "visible values", where);
257
266
  };
258
267
 
259
- const validateFields = (modules: Record<string, BridgePanelModule>, fields: BridgeField[], where: string) => {
268
+ const validateDefault = (field: BridgeField, variables: boolean, at: string) => {
269
+ if (field.default === undefined) {
270
+ return;
271
+ }
272
+
273
+ if (!variables) {
274
+ fail(`${at} declares a default outside a form that targets variables — only a variable has one`);
275
+ }
276
+
277
+ if (field.control === BridgeControl.Secret) {
278
+ fail(`${at} declares a default on a secret control — a secret is never preset`);
279
+ }
280
+
281
+ if (field.control === BridgeControl.Boolean && field.default !== "true" && field.default !== "false") {
282
+ fail(`${at} declares default "${field.default}" on a boolean control — use "true" or "false"`);
283
+ }
284
+
285
+ if (Array.isArray(field.options) && !field.options.some((option) => option.value === field.default)) {
286
+ fail(`${at} declares default "${field.default}", which is not one of its options`);
287
+ }
288
+
289
+ lengthIn(
290
+ field.default,
291
+ Math.min(field.maxLength ?? BRIDGE_DEFAULT_LENGTH_LIMIT, BRIDGE_DEFAULT_LENGTH_LIMIT),
292
+ "default",
293
+ at,
294
+ );
295
+ };
296
+
297
+ const validateFields = (
298
+ modules: Record<string, BridgePanelModule>,
299
+ fields: BridgeField[],
300
+ where: string,
301
+ variables = false,
302
+ ) => {
260
303
  const keys = new Set<string>();
261
304
 
262
305
  for (const field of fields) {
@@ -274,6 +317,7 @@ const validateFields = (modules: Record<string, BridgePanelModule>, fields: Brid
274
317
  validateText(field.warning, "warning", at);
275
318
  validateText(field.patternHint, "pattern hint", at);
276
319
  validateVisibleWhen(field.visibleWhen, at);
320
+ validateDefault(field, variables, at);
277
321
 
278
322
  if (field.placeholder) {
279
323
  lengthIn(field.placeholder, BRIDGE_PLACEHOLDER_LENGTH_LIMIT, "placeholder", at);
@@ -388,6 +432,25 @@ const validateOffline = (section: BridgeSection, where: string) => {
388
432
  }
389
433
  };
390
434
 
435
+ const validateUpload = (upload: BridgeSectionUpload, where: string) => {
436
+ validateText(upload.label, "upload label", where);
437
+ lengthIn(upload.staging, BRIDGE_STAGING_LENGTH_LIMIT, "upload staging path", where);
438
+
439
+ if (upload.extensions.length === 0) {
440
+ fail(`${where} declares an upload control with no extensions`);
441
+ }
442
+
443
+ countIn(upload.extensions.length, BRIDGE_UPLOAD_EXTENSIONS_LIMIT, "upload extensions", where);
444
+
445
+ for (const extension of upload.extensions) {
446
+ lengthIn(extension, BRIDGE_EXTENSION_LENGTH_LIMIT, `upload extension "${extension}"`, where);
447
+ }
448
+
449
+ if (upload.mode !== undefined && !UPLOAD_MODES.has(upload.mode)) {
450
+ fail(`${where} declares an unknown upload mode "${upload.mode}"`);
451
+ }
452
+ };
453
+
391
454
  const validateSection = (modules: Record<string, BridgePanelModule>, section: BridgeSection, where: string) => {
392
455
  validateVisibleWhen(section.visibleWhen, where);
393
456
  validateText(section.title, "title", where);
@@ -462,7 +525,7 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
462
525
  }
463
526
 
464
527
  countIn(section.fields.length, BRIDGE_FORM_FIELDS_LIMIT, "fields", where);
465
- validateFields(modules, section.fields, where);
528
+ validateFields(modules, section.fields, where, section.target === BridgeFormTarget.Variables);
466
529
 
467
530
  const clears = section.clears ?? [];
468
531
 
@@ -558,22 +621,7 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
558
621
  }
559
622
 
560
623
  if (section.upload) {
561
- validateText(section.upload.label, "upload label", where);
562
- lengthIn(section.upload.staging, BRIDGE_STAGING_LENGTH_LIMIT, "upload staging path", where);
563
-
564
- if (section.upload.extensions.length === 0) {
565
- fail(`${where} declares an upload control with no extensions`);
566
- }
567
-
568
- countIn(section.upload.extensions.length, BRIDGE_UPLOAD_EXTENSIONS_LIMIT, "upload extensions", where);
569
-
570
- for (const extension of section.upload.extensions) {
571
- lengthIn(extension, BRIDGE_EXTENSION_LENGTH_LIMIT, `upload extension "${extension}"`, where);
572
- }
573
-
574
- if (section.upload.mode !== undefined && !UPLOAD_MODES.has(section.upload.mode)) {
575
- fail(`${where} declares an unknown upload mode "${section.upload.mode}"`);
576
- }
624
+ validateUpload(section.upload, where);
577
625
  }
578
626
  }
579
627
 
@@ -598,15 +646,75 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
598
646
 
599
647
  if (module.kind !== BridgeKind.Catalog) {
600
648
  fail(`${where} references "${section.module}", which is not a catalog module`);
649
+
650
+ return;
601
651
  }
602
652
 
603
653
  validateText(section.confirmText, "confirm text", where);
604
654
 
655
+ if (section.upload) {
656
+ if (!module.add) {
657
+ fail(`${where} declares an upload control but module "${section.module}" has no add handler`);
658
+ }
659
+
660
+ validateUpload(section.upload, where);
661
+ }
662
+
605
663
  return;
606
664
  }
607
665
  }
608
666
  };
609
667
 
668
+ const validateCatalog = (module: BridgeCatalogModule, where: string) => {
669
+ if (module.source === undefined) {
670
+ if (typeof module.search !== "function") {
671
+ fail(`${where} declares no search — a catalog without a source answers its own search`);
672
+ }
673
+
674
+ return;
675
+ }
676
+
677
+ const { source } = module;
678
+
679
+ if (!CATALOG_SOURCE_KINDS.has(source.kind)) {
680
+ fail(
681
+ `${where} declares source "${String(source.kind)}" — the platform serves only ${[
682
+ ...CATALOG_SOURCE_KINDS,
683
+ ].join(" or ")}`,
684
+ );
685
+ }
686
+
687
+ if (!(Number.isSafeInteger(source.appId) && source.appId > 0)) {
688
+ fail(`${where} declares source app id ${String(source.appId)} — it must be a positive whole number`);
689
+ }
690
+
691
+ const provided: unknown[] = source.provided ?? [];
692
+
693
+ countIn(provided.length, BRIDGE_CATALOG_PROVIDED_LIMIT, "provided names", `${where} source`);
694
+
695
+ for (const name of provided) {
696
+ if (typeof name !== "string" || name.length === 0) {
697
+ fail(`${where} source declares a provided name that is not a non-empty string`);
698
+
699
+ continue;
700
+ }
701
+
702
+ lengthIn(name, BRIDGE_PROVIDED_NAME_LENGTH_LIMIT, `provided name "${name}"`, `${where} source`);
703
+ }
704
+
705
+ const platformAnswers: Record<string, unknown> = {
706
+ [BridgeAction.Search]: module.search,
707
+ [BridgeAction.Releases]: module.releases,
708
+ [BridgeAction.Preview]: module.preview,
709
+ };
710
+
711
+ for (const [action, handler] of Object.entries(platformAnswers)) {
712
+ if (handler !== undefined) {
713
+ fail(`${where} declares a source and its own ${action} — the platform answers ${action} for a sourced catalog`);
714
+ }
715
+ }
716
+ };
717
+
610
718
  const validateLifecycle = (driver: BridgeDriver) => {
611
719
  const environment: unknown = driver.lifecycle.environment;
612
720
 
@@ -827,6 +935,10 @@ export const validateDriver = (driver: BridgeDriver) => {
827
935
  countIn((module.dependsOn ?? []).length, BRIDGE_DEPENDS_ON_LIMIT, "dependencies", `module "${id}"`);
828
936
  }
829
937
 
938
+ if (module.kind === BridgeKind.Catalog) {
939
+ validateCatalog(module, `module "${id}"`);
940
+ }
941
+
830
942
  if (
831
943
  module.kind === BridgeKind.Actions
832
944
  || module.kind === BridgeKind.Collection