@serverkgg/bridge 0.1.6 → 0.1.8

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.
@@ -6,8 +6,16 @@ import { z } from "zod";
6
6
  import { type ArticlePack, compileArticles } from "../blog";
7
7
  import { DETECT_TABLE_FILE, renderDetectTable } from "../bot/detect";
8
8
  import { BridgeKind, PortProtocol } from "../core";
9
- import { GuideOpenTab, type GuideOpenTarget, type GuidePack, readGuidePack, readGuidePackage } from "../guides";
10
9
  import {
10
+ GuideOpenTab,
11
+ type GuideOpenTarget,
12
+ type GuidePack,
13
+ type ParsedBlock,
14
+ readGuidePack,
15
+ readGuidePackage,
16
+ } from "../guides";
17
+ import {
18
+ compileGlob,
11
19
  declaredFiles,
12
20
  type GuidePackage,
13
21
  isBotManifest,
@@ -347,6 +355,206 @@ export const confirmReportLines = (driver: BridgeDriver): string[] => {
347
355
  return lines;
348
356
  };
349
357
 
358
+ const REACH_PROBE = "x";
359
+
360
+ const SERVERK_SEGMENT = ".serverk";
361
+
362
+ const WILDCARD = "*";
363
+
364
+ interface UploadStaging {
365
+ owner: string;
366
+ folder: string;
367
+ }
368
+
369
+ interface FilesOpen {
370
+ owner: string;
371
+ path: string;
372
+ }
373
+
374
+ const protectedReaches = (path: string, rule: string) => {
375
+ if (rule === path || path.startsWith(`${rule}/`)) {
376
+ return true;
377
+ }
378
+
379
+ if (!rule.includes(WILDCARD)) {
380
+ return false;
381
+ }
382
+
383
+ const pattern = compileGlob(rule);
384
+
385
+ return pattern.test(path) || pattern.test(`${path}/${REACH_PROBE}`);
386
+ };
387
+
388
+ const isServerkPath = (rule: string) => {
389
+ return rule.split("/").some((segment) => segment === SERVERK_SEGMENT || segment.startsWith(`${SERVERK_SEGMENT}-`));
390
+ };
391
+
392
+ export const protectedNameReportLines = (manifest: RunnableManifest): string[] => {
393
+ const lines: string[] = [];
394
+
395
+ for (const rule of manifest.files.protected) {
396
+ if (isServerkPath(rule)) {
397
+ continue;
398
+ }
399
+
400
+ lines.push(
401
+ ` error: files.protected "${rule}" is not a .serverk path — a package protects only platform state, so drop the rule or move the file under a .serverk name`,
402
+ );
403
+ }
404
+
405
+ return lines;
406
+ };
407
+
408
+ export const uploadStagingReportLines = (manifest: RunnableManifest, driver: BridgeDriver): string[] => {
409
+ const stagings: UploadStaging[] = [];
410
+
411
+ for (const tab of driver.panel?.tabs ?? []) {
412
+ for (const section of tab.sections) {
413
+ if (!("upload" in section) || !section.upload) {
414
+ continue;
415
+ }
416
+
417
+ stagings.push({
418
+ owner: `tab "${tab.id}" section "${section.id}"`,
419
+ folder: section.upload.staging,
420
+ });
421
+ }
422
+ }
423
+
424
+ if (driver.pending) {
425
+ stagings.push({
426
+ owner: "the pending upload",
427
+ folder: driver.pending.staging,
428
+ });
429
+ }
430
+
431
+ const lines: string[] = [];
432
+
433
+ for (const staging of stagings) {
434
+ for (const rule of manifest.files.protected) {
435
+ if (!protectedReaches(staging.folder, rule)) {
436
+ continue;
437
+ }
438
+
439
+ lines.push(
440
+ ` error: upload staging "${staging.folder}" of ${staging.owner} is protected by "${rule}" — a protected path refuses the customer's upload, so take the folder off \`files.protected\``,
441
+ );
442
+ }
443
+ }
444
+
445
+ return lines;
446
+ };
447
+
448
+ const literalPrefixOf = (match: string) => {
449
+ const segments = match.split("/");
450
+ const wild = segments.findIndex((segment) => segment.includes(WILDCARD));
451
+
452
+ return (wild === -1 ? segments : segments.slice(0, wild)).join("/");
453
+ };
454
+
455
+ export const notableReportLines = (manifest: RunnableManifest): string[] => {
456
+ const lines: string[] = [];
457
+
458
+ for (const note of manifest.files.notable) {
459
+ const prefix = literalPrefixOf(note.match);
460
+
461
+ if (prefix === "") {
462
+ continue;
463
+ }
464
+
465
+ for (const rule of manifest.files.protected) {
466
+ if (!protectedReaches(prefix, rule)) {
467
+ continue;
468
+ }
469
+
470
+ lines.push(
471
+ ` error: files.notable "${note.match}" is protected by "${rule}" — the file manager hides a protected path before it annotates it, so the note never shows; take one of the two off`,
472
+ );
473
+ }
474
+ }
475
+
476
+ return lines;
477
+ };
478
+
479
+ const guideOpenTargetsOf = (blocks: ParsedBlock[]) => {
480
+ const leaves = blocks.flatMap((block) =>
481
+ block.type === "conditional"
482
+ ? [
483
+ ...block.when,
484
+ ...block.otherwise,
485
+ ]
486
+ : [
487
+ block,
488
+ ],
489
+ );
490
+
491
+ return leaves.flatMap((block) =>
492
+ block.type === "open"
493
+ ? [
494
+ block.target,
495
+ ]
496
+ : [],
497
+ );
498
+ };
499
+
500
+ const filesPathOf = (target: GuideOpenTarget) => {
501
+ return target.tab === GuideOpenTab.Files ? target.path : null;
502
+ };
503
+
504
+ export const openTargetReportLines = (
505
+ manifest: RunnableManifest,
506
+ driver: BridgeDriver,
507
+ pack: GuidePack | null,
508
+ ): string[] => {
509
+ const opens: FilesOpen[] = [];
510
+
511
+ const collect = (owner: string, targets: GuideOpenTarget[]) => {
512
+ for (const target of targets) {
513
+ const path = filesPathOf(target);
514
+
515
+ if (path === null) {
516
+ continue;
517
+ }
518
+
519
+ opens.push({
520
+ owner,
521
+ path,
522
+ });
523
+ }
524
+ };
525
+
526
+ for (const draft of pack?.drafts ?? []) {
527
+ collect(`guide "${draft.id}" (ar)`, guideOpenTargetsOf(draft.body.ar));
528
+ collect(`guide "${draft.id}" (en)`, guideOpenTargetsOf(draft.body.en));
529
+ }
530
+
531
+ for (const step of driver.setup?.steps ?? []) {
532
+ if (step.kind !== BridgeSetupStepKind.Open) {
533
+ continue;
534
+ }
535
+
536
+ collect(`setup step "${step.id}"`, [
537
+ step.target,
538
+ ]);
539
+ }
540
+
541
+ const lines: string[] = [];
542
+
543
+ for (const open of opens) {
544
+ for (const rule of manifest.files.protected) {
545
+ if (!protectedReaches(open.path, rule)) {
546
+ continue;
547
+ }
548
+
549
+ lines.push(
550
+ ` error: ${open.owner} opens files at "${open.path}", which "${rule}" protects — the panel refuses a protected path, so the link is dead; point it elsewhere or take the rule off \`files.protected\``,
551
+ );
552
+ }
553
+ }
554
+
555
+ return lines;
556
+ };
557
+
350
558
  const openTargetOf = (target: GuideOpenTarget) => {
351
559
  if (target.tab === GuideOpenTab.Panel) {
352
560
  return `panel tab "${target.tabId}"`;
@@ -451,6 +659,12 @@ const validateRunnablePackage = async (
451
659
  }
452
660
 
453
661
  const pack = readGuidePack(source, manifest);
662
+ const nameLines = protectedNameReportLines(manifest);
663
+
664
+ if (nameLines.length > 0) {
665
+ throw new Error(`files.protected names a path that is not platform state:\n${nameLines.join("\n")}`);
666
+ }
667
+
454
668
  const detectLines = botDetectLines(source, manifest);
455
669
  const driverPath = driverArg ? resolve(driverArg) : join(dir, "src/driver.ts");
456
670
 
@@ -476,6 +690,16 @@ const validateRunnablePackage = async (
476
690
  );
477
691
  }
478
692
 
693
+ const reachLines = [
694
+ ...uploadStagingReportLines(manifest, driver),
695
+ ...notableReportLines(manifest),
696
+ ...openTargetReportLines(manifest, driver, pack),
697
+ ];
698
+
699
+ if (reachLines.length > 0) {
700
+ throw new Error(`a protected path is where the customer is sent:\n${reachLines.join("\n")}`);
701
+ }
702
+
479
703
  const conditionalPorts = manifest.container.ports.filter((port) => port.activeWhen.length > 0).length;
480
704
  const pairedPorts = manifest.container.ports.filter((port) => port.protocol === PortProtocol.TcpUdp).length;
481
705
  const blockPorts = manifest.container.ports.filter((port) => port.count > 1).length;
@@ -12,3 +12,4 @@ export * from "./presence";
12
12
  export * from "./primitives";
13
13
  export * from "./runtime";
14
14
  export * from "./source";
15
+ export * from "./steam";
@@ -931,6 +931,85 @@ describe("presence", () => {
931
931
  });
932
932
  });
933
933
 
934
+ describe("steam", () => {
935
+ it("is optional, so a game that declares none parses", () => {
936
+ const result = gameManifestSchema.safeParse(base());
937
+
938
+ expect(result.success).toBe(true);
939
+ expect(result.success && result.data.steam).toBeUndefined();
940
+ });
941
+
942
+ it("accepts the server app id", () => {
943
+ const result = gameManifestSchema.safeParse({
944
+ ...base(),
945
+ steam: {
946
+ appId: 896_660,
947
+ },
948
+ });
949
+
950
+ expect(result.success).toBe(true);
951
+ expect(result.success && result.data.steam).toEqual({
952
+ appId: 896_660,
953
+ });
954
+ });
955
+
956
+ it("accepts the variable that holds the Steam branch", () => {
957
+ const result = gameManifestSchema.safeParse({
958
+ ...base(),
959
+ steam: {
960
+ appId: 896_660,
961
+ branchVariable: "VALHEIM_BRANCH",
962
+ },
963
+ });
964
+
965
+ expect(result.success).toBe(true);
966
+ expect(result.success && result.data.steam).toEqual({
967
+ appId: 896_660,
968
+ branchVariable: "VALHEIM_BRANCH",
969
+ });
970
+ });
971
+
972
+ it("refuses a branch variable that is not an environment key", () => {
973
+ const result = gameManifestSchema.safeParse({
974
+ ...base(),
975
+ steam: {
976
+ appId: 896_660,
977
+ branchVariable: "valheim-branch",
978
+ },
979
+ });
980
+
981
+ expect(result.success).toBe(false);
982
+ expect(result.error && z.prettifyError(result.error)).toContain("must be SCREAMING_SNAKE_CASE");
983
+ });
984
+
985
+ for (const appId of [
986
+ 0,
987
+ -1,
988
+ 2.5,
989
+ "258550",
990
+ ]) {
991
+ it(`refuses ${JSON.stringify(appId)} as an app id`, () => {
992
+ const result = gameManifestSchema.safeParse({
993
+ ...base(),
994
+ steam: {
995
+ appId,
996
+ },
997
+ });
998
+
999
+ expect(result.success).toBe(false);
1000
+ });
1001
+ }
1002
+
1003
+ it("refuses a block with no app id", () => {
1004
+ const result = gameManifestSchema.safeParse({
1005
+ ...base(),
1006
+ steam: {},
1007
+ });
1008
+
1009
+ expect(result.success).toBe(false);
1010
+ });
1011
+ });
1012
+
934
1013
  describe("container runtime", () => {
935
1014
  it("materializes the defaults when the manifest declares no runtime", () => {
936
1015
  const manifest = manifestSchema.parse(base());
@@ -22,6 +22,7 @@ import {
22
22
  zSlug,
23
23
  } from "./primitives";
24
24
  import { ManifestPlatform, manifestRuntimeSchema, WINE_MIN_SHM_MB } from "./runtime";
25
+ import { manifestSteamSchema } from "./steam";
25
26
 
26
27
  export const MANIFEST_SCHEMA_VERSION = "serverk.gg/v1";
27
28
 
@@ -825,6 +826,10 @@ export const gameManifestSchema = z
825
826
  secrets: z.array(manifestSecretSchema).max(16).default([]),
826
827
  files: manifestFilesSchema.prefault({}),
827
828
  presence: manifestPresenceSchema.optional(),
829
+ steam: manifestSteamSchema.optional().meta({
830
+ description:
831
+ "The Steam app behind this game. Declare it when steamcmd installs the server, so the platform sees when a newer game version is out.",
832
+ }),
828
833
  })
829
834
  .check((ctx) => {
830
835
  if (ctx.issues.length > 0) {
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import { zEnvKey } from "./primitives";
3
+
4
+ export const manifestSteamSchema = z.object({
5
+ appId: z.int().positive().meta({
6
+ description: "Steam app id of the dedicated server, the one steamcmd installs with app_update.",
7
+ }),
8
+ branchVariable: zEnvKey.optional().meta({
9
+ description:
10
+ "The server variable that holds the Steam branch the customer picked. Omit it when the package installs the public branch only.",
11
+ }),
12
+ });
13
+
14
+ export type ManifestSteam = z.infer<typeof manifestSteamSchema>;