@serverkgg/bridge 0.0.12 → 0.0.13

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 (70) hide show
  1. package/package.json +9 -1
  2. package/src/catalogs/curseforge.test.ts +580 -0
  3. package/src/catalogs/curseforge.ts +461 -0
  4. package/src/catalogs/index.ts +2 -0
  5. package/src/catalogs/thunderstore.test.ts +94 -0
  6. package/src/catalogs/thunderstore.ts +239 -0
  7. package/src/codecs/index.ts +3 -0
  8. package/src/codecs/ini.test.ts +152 -0
  9. package/src/codecs/ini.ts +158 -0
  10. package/src/codecs/sourceCfg.test.ts +100 -0
  11. package/src/codecs/sourceCfg.ts +111 -0
  12. package/src/codecs/xmlProperties.test.ts +104 -0
  13. package/src/codecs/xmlProperties.ts +133 -0
  14. package/src/context/bridgeContext.ts +20 -0
  15. package/src/core/bridgeRconError.ts +19 -0
  16. package/src/core/index.ts +1 -0
  17. package/src/install/index.ts +1 -0
  18. package/src/install/installStamp.test.ts +125 -0
  19. package/src/install/installStamp.ts +40 -0
  20. package/src/main.ts +1 -0
  21. package/src/manifest/index.ts +1 -0
  22. package/src/manifest/manifest.test.ts +427 -2
  23. package/src/manifest/manifest.ts +141 -13
  24. package/src/manifest/primitives.ts +6 -0
  25. package/src/manifest/runtime.ts +89 -0
  26. package/src/net/index.ts +2 -0
  27. package/src/net/loopback.test.ts +152 -0
  28. package/src/net/loopback.ts +109 -0
  29. package/src/net/loopbackMethod.ts +7 -0
  30. package/src/presence/index.ts +1 -0
  31. package/src/presence/rosterSync.test.ts +179 -0
  32. package/src/presence/rosterSync.ts +57 -0
  33. package/src/protocol/bridgeEvents.ts +4 -0
  34. package/src/rcon/index.ts +3 -0
  35. package/src/rcon/sourceRcon.test.ts +308 -0
  36. package/src/rcon/sourceRcon.ts +276 -0
  37. package/src/rcon/sourceRconFrame.test.ts +152 -0
  38. package/src/rcon/sourceRconFrame.ts +99 -0
  39. package/src/runtime/bridgeCodec.ts +45 -2
  40. package/src/runtime/bridgeContext.ts +13 -6
  41. package/src/runtime/bridgeDriver.test.ts +1 -0
  42. package/src/runtime/bridgeLiveConfig.test.ts +149 -0
  43. package/src/runtime/bridgeLiveConfig.ts +63 -0
  44. package/src/runtime/bridgeRcon.ts +15 -0
  45. package/src/runtime/bridgeRegistry.ts +1 -0
  46. package/src/steam/index.ts +7 -0
  47. package/src/steam/steamApp.test.ts +78 -0
  48. package/src/steam/steamApp.ts +35 -0
  49. package/src/steam/steamcmd.test.ts +157 -0
  50. package/src/steam/steamcmd.ts +273 -0
  51. package/src/steam/steamcmdLogin.ts +34 -0
  52. package/src/steam/steamcmdNarrator.test.ts +90 -0
  53. package/src/steam/steamcmdNarrator.ts +43 -0
  54. package/src/steam/steamcmdPlatform.ts +4 -0
  55. package/src/steam/webApi.ts +70 -0
  56. package/src/steam/workshop.test.ts +45 -0
  57. package/src/steam/workshop.ts +148 -0
  58. package/src/utils/execDetail.ts +16 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/token.test.ts +50 -0
  61. package/src/utils/token.ts +35 -0
  62. package/src/wine/index.ts +3 -0
  63. package/src/wine/protonCommand.test.ts +67 -0
  64. package/src/wine/protonCommand.ts +49 -0
  65. package/src/wine/wineCommand.test.ts +92 -0
  66. package/src/wine/wineCommand.ts +54 -0
  67. package/src/wine/winePrefix.test.ts +111 -0
  68. package/src/wine/winePrefix.ts +88 -0
  69. package/src/runtime/bridgeSecrets.test.ts +0 -121
  70. package/src/runtime/bridgeSecrets.ts +0 -43
@@ -1,9 +1,27 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { z } from "zod";
3
- import { PortJoin } from "../core";
3
+ import { PortJoin, PortProtocol } from "../core";
4
4
  import { GameCategory } from "./category";
5
- import { guidePackageSchema, MANIFEST_SCHEMA_VERSION, manifestSchema, packageSchema } from "./manifest";
5
+ import {
6
+ guidePackageSchema,
7
+ MANIFEST_SCHEMA_VERSION,
8
+ type Manifest,
9
+ manifestSchema,
10
+ PORT_FOLLOW_MIN_OFFSET,
11
+ packageSchema,
12
+ } from "./manifest";
6
13
  import { isServerkAvatarTemplate, PRESENCE_AVATAR_LENGTH_LIMIT } from "./presence";
14
+ import {
15
+ BOOT_BUDGET_MIN_MINUTES,
16
+ INSTALL_BUDGET_MAX_MINUTES,
17
+ MANIFEST_RUNTIME_DEFAULTS,
18
+ ManifestCapability,
19
+ ManifestPlatform,
20
+ manifestRuntimeOf,
21
+ PIDS_LIMIT_MIN,
22
+ SHM_MAX_MB,
23
+ WINE_MIN_SHM_MB,
24
+ } from "./runtime";
7
25
 
8
26
  const text = (value: string) => ({
9
27
  ar: value,
@@ -99,6 +117,42 @@ const withPort = (extra: Record<string, unknown>) => {
99
117
  };
100
118
  };
101
119
 
120
+ const withPorts = (ports: unknown[]) => {
121
+ const manifest = base();
122
+
123
+ return {
124
+ ...manifest,
125
+ container: {
126
+ ...manifest.container,
127
+ ports,
128
+ },
129
+ };
130
+ };
131
+
132
+ const withRuntime = (runtime: Record<string, unknown>) => {
133
+ const manifest = base();
134
+
135
+ return {
136
+ ...manifest,
137
+ container: {
138
+ ...manifest.container,
139
+ runtime,
140
+ },
141
+ };
142
+ };
143
+
144
+ const udpPort = (key: string, containerPort: number, hostDefault: number) => ({
145
+ key,
146
+ containerPort,
147
+ protocol: PortProtocol.Udp,
148
+ hostRange: {
149
+ default: hostDefault,
150
+ min: hostDefault,
151
+ max: hostDefault + 99,
152
+ },
153
+ mirror: true,
154
+ });
155
+
102
156
  const withPortSrv = (srv: unknown) => {
103
157
  return withPort({
104
158
  srv,
@@ -751,3 +805,374 @@ describe("presence", () => {
751
805
  expect(result.error && z.prettifyError(result.error)).toContain('"platform" is declared more than once');
752
806
  });
753
807
  });
808
+
809
+ describe("container runtime", () => {
810
+ test("materializes the defaults when the manifest declares no runtime", () => {
811
+ const manifest = manifestSchema.parse(base());
812
+
813
+ expect(manifest.container.runtime).toEqual({
814
+ platform: ManifestPlatform.Linux,
815
+ installBudgetMinutes: 15,
816
+ bootBudgetMinutes: 12,
817
+ shmMb: 64,
818
+ pidsLimit: 512,
819
+ capabilities: [],
820
+ });
821
+ expect(MANIFEST_RUNTIME_DEFAULTS).toEqual(manifest.container.runtime);
822
+ });
823
+
824
+ test("keeps every field a manifest declares", () => {
825
+ const manifest = manifestSchema.parse(
826
+ withRuntime({
827
+ platform: ManifestPlatform.Wine,
828
+ installBudgetMinutes: 60,
829
+ bootBudgetMinutes: 30,
830
+ shmMb: 512,
831
+ pidsLimit: 2048,
832
+ capabilities: [
833
+ ManifestCapability.SysNice,
834
+ ManifestCapability.IpcLock,
835
+ ],
836
+ }),
837
+ );
838
+
839
+ expect(manifest.container.runtime).toEqual({
840
+ platform: ManifestPlatform.Wine,
841
+ installBudgetMinutes: 60,
842
+ bootBudgetMinutes: 30,
843
+ shmMb: 512,
844
+ pidsLimit: 2048,
845
+ capabilities: [
846
+ ManifestCapability.SysNice,
847
+ ManifestCapability.IpcLock,
848
+ ],
849
+ });
850
+ });
851
+
852
+ test("clamps an install budget above the ceiling", () => {
853
+ expect(
854
+ failure(
855
+ withRuntime({
856
+ installBudgetMinutes: INSTALL_BUDGET_MAX_MINUTES + 1,
857
+ }),
858
+ ),
859
+ ).toEqual([
860
+ {
861
+ path: "container.runtime.installBudgetMinutes",
862
+ message: `Too big: expected number to be <=${INSTALL_BUDGET_MAX_MINUTES}`,
863
+ },
864
+ ]);
865
+ });
866
+
867
+ test("clamps a boot budget below the floor", () => {
868
+ expect(
869
+ failure(
870
+ withRuntime({
871
+ bootBudgetMinutes: BOOT_BUDGET_MIN_MINUTES - 1,
872
+ }),
873
+ ),
874
+ ).toEqual([
875
+ {
876
+ path: "container.runtime.bootBudgetMinutes",
877
+ message: `Too small: expected number to be >=${BOOT_BUDGET_MIN_MINUTES}`,
878
+ },
879
+ ]);
880
+ });
881
+
882
+ test("clamps shared memory and the pids limit to their range", () => {
883
+ expect(
884
+ failure(
885
+ withRuntime({
886
+ shmMb: SHM_MAX_MB + 1,
887
+ pidsLimit: PIDS_LIMIT_MIN - 1,
888
+ }),
889
+ ),
890
+ ).toEqual([
891
+ {
892
+ path: "container.runtime.shmMb",
893
+ message: `Too big: expected number to be <=${SHM_MAX_MB}`,
894
+ },
895
+ {
896
+ path: "container.runtime.pidsLimit",
897
+ message: `Too small: expected number to be >=${PIDS_LIMIT_MIN}`,
898
+ },
899
+ ]);
900
+ });
901
+
902
+ test("refuses a fourth capability", () => {
903
+ expect(
904
+ failure(
905
+ withRuntime({
906
+ capabilities: [
907
+ ManifestCapability.SysNice,
908
+ ManifestCapability.IpcLock,
909
+ ManifestCapability.SysPtrace,
910
+ ManifestCapability.SysNice,
911
+ ],
912
+ }),
913
+ ),
914
+ ).toEqual([
915
+ {
916
+ path: "container.runtime.capabilities",
917
+ message: "Too big: expected array to have <=3 items",
918
+ },
919
+ ]);
920
+ });
921
+
922
+ test("refuses a capability outside the allow-list", () => {
923
+ expect(
924
+ failure(
925
+ withRuntime({
926
+ capabilities: [
927
+ "NET_ADMIN",
928
+ ],
929
+ }),
930
+ ),
931
+ ).toEqual([
932
+ {
933
+ path: "container.runtime.capabilities.0",
934
+ message: 'Invalid option: expected one of "SYS_NICE"|"IPC_LOCK"|"SYS_PTRACE"',
935
+ },
936
+ ]);
937
+ });
938
+
939
+ test("refuses a wine platform running on the default shared memory", () => {
940
+ expect(
941
+ failure(
942
+ withRuntime({
943
+ platform: ManifestPlatform.Wine,
944
+ }),
945
+ ),
946
+ ).toEqual([
947
+ {
948
+ path: "container.runtime.shmMb",
949
+ message: `must be at least ${WINE_MIN_SHM_MB} when platform is "wine"`,
950
+ },
951
+ ]);
952
+ });
953
+
954
+ test("accepts a wine platform once shared memory clears the floor", () => {
955
+ const manifest = manifestSchema.parse(
956
+ withRuntime({
957
+ platform: ManifestPlatform.Wine,
958
+ shmMb: WINE_MIN_SHM_MB,
959
+ }),
960
+ );
961
+
962
+ expect(manifest.container.runtime.shmMb).toBe(WINE_MIN_SHM_MB);
963
+ });
964
+ });
965
+
966
+ describe("manifestRuntimeOf", () => {
967
+ test("reads the runtime a manifest declares", () => {
968
+ const manifest = manifestSchema.parse(
969
+ withRuntime({
970
+ platform: ManifestPlatform.Wine,
971
+ shmMb: 512,
972
+ }),
973
+ );
974
+
975
+ expect(manifestRuntimeOf(manifest)).toEqual({
976
+ ...MANIFEST_RUNTIME_DEFAULTS,
977
+ platform: ManifestPlatform.Wine,
978
+ shmMb: 512,
979
+ });
980
+ });
981
+
982
+ test("falls back to the defaults for a bundle published before the field existed", () => {
983
+ const manifest = manifestSchema.parse(base());
984
+ const legacy = {
985
+ ...manifest,
986
+ container: {
987
+ image: manifest.container.image,
988
+ ports: manifest.container.ports,
989
+ },
990
+ } as Manifest;
991
+
992
+ expect(manifestRuntimeOf(legacy)).toEqual(MANIFEST_RUNTIME_DEFAULTS);
993
+ });
994
+ });
995
+
996
+ describe("port mirroring", () => {
997
+ test("defaults to no mirror and no follower", () => {
998
+ const manifest = manifestSchema.parse(base());
999
+
1000
+ expect(manifest.container.ports[0]?.mirror).toBe(false);
1001
+ expect(manifest.container.ports[0]?.follows).toBeUndefined();
1002
+ });
1003
+
1004
+ test("keeps the mirror flag and the follow rule a manifest declares", () => {
1005
+ const manifest = manifestSchema.parse(
1006
+ withPorts([
1007
+ udpPort("game", 27_015, 27_015),
1008
+ {
1009
+ ...udpPort("query", 27_016, 27_115),
1010
+ follows: {
1011
+ key: "game",
1012
+ offset: 1,
1013
+ },
1014
+ },
1015
+ ]),
1016
+ );
1017
+
1018
+ expect(manifest.container.ports[0]?.mirror).toBe(true);
1019
+ expect(manifest.container.ports[1]?.follows).toEqual({
1020
+ key: "game",
1021
+ offset: 1,
1022
+ });
1023
+ });
1024
+
1025
+ test("refuses a follow rule naming a port that is not declared", () => {
1026
+ expect(
1027
+ failure(
1028
+ withPorts([
1029
+ {
1030
+ ...udpPort("game", 27_015, 27_015),
1031
+ follows: {
1032
+ key: "query",
1033
+ offset: 1,
1034
+ },
1035
+ },
1036
+ ]),
1037
+ ),
1038
+ ).toEqual([
1039
+ {
1040
+ path: "container.ports.0.follows.key",
1041
+ message: '"query" is not a declared port',
1042
+ },
1043
+ ]);
1044
+ });
1045
+
1046
+ test("refuses a port that follows itself", () => {
1047
+ expect(
1048
+ failure(
1049
+ withPorts([
1050
+ {
1051
+ ...udpPort("game", 27_015, 27_015),
1052
+ follows: {
1053
+ key: "game",
1054
+ offset: 1,
1055
+ },
1056
+ },
1057
+ ]),
1058
+ ),
1059
+ ).toEqual([
1060
+ {
1061
+ path: "container.ports.0.follows.key",
1062
+ message: 'port "game" cannot follow itself',
1063
+ },
1064
+ ]);
1065
+ });
1066
+
1067
+ test("refuses a follow rule crossing protocols", () => {
1068
+ const [tcp] = base().container.ports;
1069
+
1070
+ if (!tcp) {
1071
+ throw new Error("fixture is missing its port");
1072
+ }
1073
+
1074
+ expect(
1075
+ failure(
1076
+ withPorts([
1077
+ tcp,
1078
+ {
1079
+ ...udpPort("query", 27_016, 27_115),
1080
+ follows: {
1081
+ key: "game",
1082
+ offset: 1,
1083
+ },
1084
+ },
1085
+ ]),
1086
+ ),
1087
+ ).toEqual([
1088
+ {
1089
+ path: "container.ports.1.follows.key",
1090
+ message: '"game" is a tcp port — a port only follows one of its own protocol',
1091
+ },
1092
+ ]);
1093
+ });
1094
+
1095
+ test("refuses a port that both follows and is followed", () => {
1096
+ expect(
1097
+ failure(
1098
+ withPorts([
1099
+ udpPort("game", 27_015, 27_015),
1100
+ {
1101
+ ...udpPort("query", 27_016, 27_115),
1102
+ follows: {
1103
+ key: "game",
1104
+ offset: 1,
1105
+ },
1106
+ },
1107
+ {
1108
+ ...udpPort("raw", 27_017, 27_215),
1109
+ follows: {
1110
+ key: "query",
1111
+ offset: 1,
1112
+ },
1113
+ },
1114
+ ]),
1115
+ ),
1116
+ ).toEqual([
1117
+ {
1118
+ path: "container.ports.1.follows.key",
1119
+ message: 'port "query" is followed by another port and cannot follow one itself',
1120
+ },
1121
+ ]);
1122
+ });
1123
+
1124
+ test("lets a follower's host range overlap the port it follows", () => {
1125
+ const manifest = manifestSchema.parse(
1126
+ withPorts([
1127
+ udpPort("game", 27_015, 27_015),
1128
+ {
1129
+ ...udpPort("query", 27_016, 27_016),
1130
+ follows: {
1131
+ key: "game",
1132
+ offset: 1,
1133
+ },
1134
+ },
1135
+ ]),
1136
+ );
1137
+
1138
+ expect(manifest.container.ports[1]?.hostRange.min).toBe(27_016);
1139
+ });
1140
+
1141
+ test("still refuses two independent ports whose host ranges overlap", () => {
1142
+ expect(
1143
+ failure(
1144
+ withPorts([
1145
+ udpPort("game", 27_015, 27_015),
1146
+ udpPort("query", 27_016, 27_016),
1147
+ ]),
1148
+ ),
1149
+ ).toEqual([
1150
+ {
1151
+ path: "container.ports.1.hostRange",
1152
+ message: 'host range overlaps port "game" — two udp ports cannot share host ports',
1153
+ },
1154
+ ]);
1155
+ });
1156
+
1157
+ test("refuses an offset outside its range", () => {
1158
+ expect(
1159
+ failure(
1160
+ withPorts([
1161
+ udpPort("game", 27_015, 27_015),
1162
+ {
1163
+ ...udpPort("query", 27_016, 27_115),
1164
+ follows: {
1165
+ key: "game",
1166
+ offset: 0,
1167
+ },
1168
+ },
1169
+ ]),
1170
+ ),
1171
+ ).toEqual([
1172
+ {
1173
+ path: "container.ports.1.follows.offset",
1174
+ message: `Too small: expected number to be >=${PORT_FOLLOW_MIN_OFFSET}`,
1175
+ },
1176
+ ]);
1177
+ });
1178
+ });
@@ -16,8 +16,10 @@ import {
16
16
  zHostPort,
17
17
  zImageReference,
18
18
  zPort,
19
+ zPortKey,
19
20
  zSlug,
20
21
  } from "./primitives";
22
+ import { ManifestPlatform, manifestRuntimeSchema, WINE_MIN_SHM_MB } from "./runtime";
21
23
 
22
24
  export const MANIFEST_SCHEMA_VERSION = "serverk.gg/v1";
23
25
 
@@ -50,12 +52,19 @@ const manifestPortActivationSchema = z
50
52
 
51
53
  export type ManifestPortActivation = z.infer<typeof manifestPortActivationSchema>;
52
54
 
55
+ export const PORT_FOLLOW_MIN_OFFSET = 1;
56
+
57
+ export const PORT_FOLLOW_MAX_OFFSET = 16;
58
+
59
+ const manifestPortFollowSchema = z.object({
60
+ key: zPortKey,
61
+ offset: z.int().min(PORT_FOLLOW_MIN_OFFSET).max(PORT_FOLLOW_MAX_OFFSET),
62
+ });
63
+
64
+ export type ManifestPortFollow = z.infer<typeof manifestPortFollowSchema>;
65
+
53
66
  const manifestPortSchema = z.object({
54
- key: z
55
- .string()
56
- .min(1)
57
- .max(32)
58
- .regex(/^[a-z][a-z0-9-]*$/, "must be lowercase letters, digits and dashes, starting with a letter"),
67
+ key: zPortKey,
59
68
  containerPort: zPort,
60
69
  protocol: z.enum(PortProtocol).default(PortProtocol.Tcp),
61
70
  hostRange: manifestHostRangeSchema,
@@ -83,16 +92,47 @@ const manifestPortSchema = z.object({
83
92
  description:
84
93
  "Conditions on the server's variables that must all hold for the game to listen on this port. The port is still allocated; the panel hides the address otherwise.",
85
94
  }),
95
+ mirror: z.boolean().default(false).meta({
96
+ description:
97
+ "Binds the allocated host port as the container port too, so the game advertises the number players dial. Declare it when the game reports its own port to a master server or an A2S client.",
98
+ }),
99
+ follows: manifestPortFollowSchema.optional().meta({
100
+ description:
101
+ "Allocates this port at the named port's host port plus the offset, for games that derive one port from another. The named port must use the same protocol.",
102
+ }),
86
103
  });
87
104
 
88
105
  export type ManifestPort = z.infer<typeof manifestPortSchema>;
89
106
 
90
- const manifestContainerSchema = z.object({
91
- image: zImageReference.meta({
92
- description: `Image repository, always "${IMAGE_NAMESPACE}/" followed by metadata.id.`,
93
- }),
94
- ports: z.array(manifestPortSchema).min(1).max(8),
95
- });
107
+ const manifestContainerSchema = z
108
+ .object({
109
+ image: zImageReference.meta({
110
+ description: `Image repository, always "${IMAGE_NAMESPACE}/" followed by metadata.id.`,
111
+ }),
112
+ ports: z.array(manifestPortSchema).min(1).max(8),
113
+ runtime: manifestRuntimeSchema.prefault({}).meta({
114
+ description: "How the container is built and how long the platform waits for it.",
115
+ }),
116
+ })
117
+ .check((ctx) => {
118
+ if (ctx.issues.length > 0) {
119
+ return;
120
+ }
121
+
122
+ const { runtime } = ctx.value;
123
+
124
+ if (runtime.platform === ManifestPlatform.Wine && runtime.shmMb < WINE_MIN_SHM_MB) {
125
+ ctx.issues.push({
126
+ code: "custom",
127
+ message: `must be at least ${WINE_MIN_SHM_MB} when platform is "${ManifestPlatform.Wine}"`,
128
+ input: runtime.shmMb,
129
+ path: [
130
+ "runtime",
131
+ "shmMb",
132
+ ],
133
+ });
134
+ }
135
+ });
96
136
 
97
137
  const manifestMetadataSchema = z.object({
98
138
  id: zSlug.meta({
@@ -228,6 +268,91 @@ const hostRangesOverlap = (a: ManifestPort, b: ManifestPort) => {
228
268
  return a.hostRange.min <= b.hostRange.max && b.hostRange.min <= a.hostRange.max;
229
269
  };
230
270
 
271
+ const followLeaderKey = (port: ManifestPort) => {
272
+ return port.follows?.key ?? port.key;
273
+ };
274
+
275
+ const sameFollowGroup = (a: ManifestPort, b: ManifestPort) => {
276
+ if (!a.follows && !b.follows) {
277
+ return false;
278
+ }
279
+
280
+ return followLeaderKey(a) === followLeaderKey(b);
281
+ };
282
+
283
+ const followIssues = (ports: ManifestPort[]): CheckIssue[] => {
284
+ const issues: CheckIssue[] = [];
285
+ const byKey = new Map(
286
+ ports.map((port) => [
287
+ port.key,
288
+ port,
289
+ ]),
290
+ );
291
+ const followed = new Set(ports.map((port) => port.follows?.key).filter((key): key is string => key !== undefined));
292
+
293
+ ports.forEach((port, index) => {
294
+ const follows = port.follows;
295
+
296
+ if (!follows) {
297
+ return;
298
+ }
299
+
300
+ const path = [
301
+ "container",
302
+ "ports",
303
+ index,
304
+ "follows",
305
+ "key",
306
+ ];
307
+
308
+ if (follows.key === port.key) {
309
+ issues.push({
310
+ code: "custom",
311
+ message: `port "${port.key}" cannot follow itself`,
312
+ input: follows.key,
313
+ path,
314
+ });
315
+
316
+ return;
317
+ }
318
+
319
+ const leader = byKey.get(follows.key);
320
+
321
+ if (!leader) {
322
+ issues.push({
323
+ code: "custom",
324
+ message: `"${follows.key}" is not a declared port`,
325
+ input: follows.key,
326
+ path,
327
+ });
328
+
329
+ return;
330
+ }
331
+
332
+ if (leader.protocol !== port.protocol) {
333
+ issues.push({
334
+ code: "custom",
335
+ message: `"${follows.key}" is a ${leader.protocol} port — a port only follows one of its own protocol`,
336
+ input: follows.key,
337
+ path,
338
+ });
339
+
340
+ return;
341
+ }
342
+
343
+ if (followed.has(port.key)) {
344
+ issues.push({
345
+ code: "custom",
346
+ message: `port "${port.key}" is followed by another port and cannot follow one itself`,
347
+ input: follows.key,
348
+ path,
349
+ });
350
+ }
351
+ });
352
+
353
+ return issues;
354
+ };
355
+
231
356
  const portIssues = (ports: ManifestPort[]): CheckIssue[] => {
232
357
  const issues = duplicateIssues(
233
358
  ports,
@@ -246,7 +371,7 @@ const portIssues = (ports: ManifestPort[]): CheckIssue[] => {
246
371
  const a = ports[index];
247
372
  const b = ports[other];
248
373
 
249
- if (!a || !b || a.protocol !== b.protocol || !hostRangesOverlap(a, b)) {
374
+ if (!a || !b || a.protocol !== b.protocol || !hostRangesOverlap(a, b) || sameFollowGroup(a, b)) {
250
375
  continue;
251
376
  }
252
377
 
@@ -264,7 +389,10 @@ const portIssues = (ports: ManifestPort[]): CheckIssue[] => {
264
389
  }
265
390
  }
266
391
 
267
- return issues;
392
+ return [
393
+ ...issues,
394
+ ...followIssues(ports),
395
+ ];
268
396
  };
269
397
 
270
398
  const guideIssues = (guideTags: GuideTag[], guides: GuideEntry[]): CheckIssue[] => {
@@ -20,6 +20,12 @@ export const zHostPort = z
20
20
 
21
21
  export const zHexColor = z.stringFormat("hexColor", /^#[0-9a-fA-F]{6}$/, "must be a six-digit hex colour like #4a8fd4");
22
22
 
23
+ export const zPortKey = z
24
+ .string()
25
+ .min(1)
26
+ .max(32)
27
+ .regex(/^[a-z][a-z0-9-]*$/, "must be lowercase letters, digits and dashes, starting with a letter");
28
+
23
29
  export const zEnvKey = z
24
30
  .string()
25
31
  .min(2)