@serverkgg/bridge 0.0.7 → 0.0.9

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 (52) hide show
  1. package/package.json +4 -3
  2. package/src/cli/validate.ts +1 -0
  3. package/src/context/bridgeContext.ts +10 -0
  4. package/src/context/index.ts +1 -0
  5. package/src/core/bridgeDownloadError.ts +10 -0
  6. package/src/core/bridgeHold.ts +12 -0
  7. package/src/core/bridgeKind.ts +2 -0
  8. package/src/core/bridgeUserError.test.ts +23 -0
  9. package/src/core/bridgeUserError.ts +12 -0
  10. package/src/core/index.ts +3 -0
  11. package/src/manifest/index.ts +1 -0
  12. package/src/manifest/manifest.test.ts +85 -0
  13. package/src/manifest/manifest.ts +21 -0
  14. package/src/manifest/presence.ts +48 -0
  15. package/src/manifest/primitives.ts +6 -0
  16. package/src/modules/bridge.ts +45 -2
  17. package/src/modules/bridgeDriver.ts +2 -0
  18. package/src/modules/bridgeModule.ts +25 -3
  19. package/src/panel/bridgeDetail.ts +49 -0
  20. package/src/panel/bridgeLayout.ts +1 -0
  21. package/src/panel/bridgePanel.ts +30 -0
  22. package/src/panel/bridgeUploadMode.ts +4 -0
  23. package/src/panel/index.ts +2 -0
  24. package/src/protocol/bridgeEgress.ts +8 -1
  25. package/src/protocol/bridgeEvents.ts +4 -0
  26. package/src/protocol/bridgeFrame.ts +28 -1
  27. package/src/protocol/bridgeLimits.ts +10 -0
  28. package/src/runtime/bridgeActions.ts +7 -0
  29. package/src/runtime/bridgeDriver.test.ts +477 -2
  30. package/src/runtime/bridgeDriver.ts +116 -3
  31. package/src/runtime/bridgeEgress.ts +3 -0
  32. package/src/runtime/bridgeEvents.ts +7 -5
  33. package/src/runtime/bridgeFiles.test.ts +625 -15
  34. package/src/runtime/bridgeFiles.ts +152 -45
  35. package/src/runtime/bridgeInstall.test.ts +118 -0
  36. package/src/runtime/bridgeInstall.ts +55 -0
  37. package/src/runtime/bridgeNet.ts +32 -3
  38. package/src/runtime/bridgePending.test.ts +148 -0
  39. package/src/runtime/bridgePending.ts +43 -0
  40. package/src/runtime/bridgePrivilege.test.ts +77 -0
  41. package/src/runtime/bridgePrivilege.ts +5 -3
  42. package/src/runtime/bridgeRegistry.test.ts +134 -0
  43. package/src/runtime/bridgeRegistry.ts +26 -4
  44. package/src/runtime/bridgeServer.test.ts +338 -0
  45. package/src/runtime/bridgeServer.ts +25 -3
  46. package/src/runtime/bridgeSupervisor.ts +18 -0
  47. package/src/runtime/bridgeZip.ts +179 -58
  48. package/src/runtime/serve.ts +64 -4
  49. package/src/wire/index.ts +2 -0
  50. package/src/wire/panel.test.ts +451 -0
  51. package/src/wire/panel.ts +295 -0
  52. package/src/wire/zEnvKey.ts +7 -0
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BRIDGE_PENDING_FILES_LIMIT, type BridgePendingFile } from "../protocol";
3
+ import type { BridgeLeveledLogger } from "./bridgeLog";
4
+ import { pendingResult, rejectedPending, unsupportedPending } from "./bridgePending";
5
+
6
+ const file = (id: string): BridgePendingFile => ({
7
+ id,
8
+ title: {
9
+ ar: id,
10
+ en: id,
11
+ },
12
+ fileName: `${id}.jar`,
13
+ sizeBytes: null,
14
+ digest: null,
15
+ sourceUrl: null,
16
+ note: null,
17
+ });
18
+
19
+ const files = (count: number) => {
20
+ return Array.from({
21
+ length: count,
22
+ }).map((_entry, index) => file(`file-${index}`));
23
+ };
24
+
25
+ const recorder = () => {
26
+ const warnings: string[] = [];
27
+
28
+ const onLog: BridgeLeveledLogger = Object.assign(
29
+ () => {
30
+ return;
31
+ },
32
+ {
33
+ warn: (message: string) => {
34
+ warnings.push(message);
35
+ },
36
+ error: () => {
37
+ return;
38
+ },
39
+ },
40
+ );
41
+
42
+ return {
43
+ onLog,
44
+ warnings,
45
+ };
46
+ };
47
+
48
+ describe("unsupportedPending", () => {
49
+ test("answers the sentinel a driver without a pending module gets", () => {
50
+ expect(unsupportedPending()).toEqual({
51
+ supported: false,
52
+ staging: "",
53
+ extensions: [],
54
+ files: [],
55
+ rejected: null,
56
+ });
57
+ });
58
+ });
59
+
60
+ describe("pendingResult", () => {
61
+ test("carries the staging path, the extensions and the files the driver listed", () => {
62
+ const { onLog, warnings } = recorder();
63
+
64
+ expect(
65
+ pendingResult(
66
+ ".serverk-staging/pending-upload",
67
+ [
68
+ "jar",
69
+ ],
70
+ files(2),
71
+ onLog,
72
+ ),
73
+ ).toEqual({
74
+ supported: true,
75
+ staging: ".serverk-staging/pending-upload",
76
+ extensions: [
77
+ "jar",
78
+ ],
79
+ files: files(2),
80
+ rejected: null,
81
+ });
82
+
83
+ expect(warnings).toEqual([]);
84
+ });
85
+
86
+ test("truncates a list longer than the platform takes and warns instead of throwing", () => {
87
+ const { onLog, warnings } = recorder();
88
+
89
+ const result = pendingResult(
90
+ ".serverk-staging/pending-upload",
91
+ [
92
+ "jar",
93
+ ],
94
+ files(BRIDGE_PENDING_FILES_LIMIT + 5),
95
+ onLog,
96
+ );
97
+
98
+ expect(result.files).toHaveLength(BRIDGE_PENDING_FILES_LIMIT);
99
+ expect(result.files.at(0)?.id).toBe("file-0");
100
+ expect(warnings).toEqual([
101
+ "pending list truncated",
102
+ ]);
103
+ });
104
+ });
105
+
106
+ describe("rejectedPending", () => {
107
+ test("names every file still pending as what the upload was expected to be", () => {
108
+ const { onLog } = recorder();
109
+
110
+ const result = rejectedPending(
111
+ pendingResult(
112
+ ".serverk-staging/pending-upload",
113
+ [
114
+ "jar",
115
+ ],
116
+ files(2),
117
+ onLog,
118
+ ),
119
+ );
120
+
121
+ expect(result.rejected).toEqual({
122
+ expected: [
123
+ "file-0.jar",
124
+ "file-1.jar",
125
+ ],
126
+ });
127
+ expect(result.files).toHaveLength(2);
128
+ });
129
+
130
+ test("leaves an empty expectation when the driver has nothing pending left", () => {
131
+ const { onLog } = recorder();
132
+
133
+ expect(
134
+ rejectedPending(
135
+ pendingResult(
136
+ ".serverk-staging/pending-upload",
137
+ [
138
+ "jar",
139
+ ],
140
+ [],
141
+ onLog,
142
+ ),
143
+ ).rejected,
144
+ ).toEqual({
145
+ expected: [],
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,43 @@
1
+ import { BRIDGE_PENDING_FILES_LIMIT, type BridgePendingFile, type BridgePendingResult } from "../protocol";
2
+ import type { BridgeLeveledLogger } from "./bridgeLog";
3
+
4
+ export const unsupportedPending = (): BridgePendingResult => {
5
+ return {
6
+ supported: false,
7
+ staging: "",
8
+ extensions: [],
9
+ files: [],
10
+ rejected: null,
11
+ };
12
+ };
13
+
14
+ export const pendingResult = (
15
+ staging: string,
16
+ extensions: string[],
17
+ files: BridgePendingFile[],
18
+ onLog: BridgeLeveledLogger,
19
+ ): BridgePendingResult => {
20
+ if (files.length > BRIDGE_PENDING_FILES_LIMIT) {
21
+ onLog.warn("pending list truncated", {
22
+ files: files.length,
23
+ limit: BRIDGE_PENDING_FILES_LIMIT,
24
+ });
25
+ }
26
+
27
+ return {
28
+ supported: true,
29
+ staging,
30
+ extensions,
31
+ files: files.slice(0, BRIDGE_PENDING_FILES_LIMIT),
32
+ rejected: null,
33
+ };
34
+ };
35
+
36
+ export const rejectedPending = (result: BridgePendingResult): BridgePendingResult => {
37
+ return {
38
+ ...result,
39
+ rejected: {
40
+ expected: result.files.map((file) => file.fileName),
41
+ },
42
+ };
43
+ };
@@ -0,0 +1,77 @@
1
+ import { afterEach, describe, expect, it, spyOn } from "bun:test";
2
+ import { privilegePlan, readEffectiveUid, setprivArgv, spawnCredentials } from "./bridgePrivilege";
3
+
4
+ const COMMAND = [
5
+ "java",
6
+ "-jar",
7
+ "server.jar",
8
+ ];
9
+
10
+ describe("spawnCredentials", () => {
11
+ it("carries the uid and gid for the spawn options", () => {
12
+ expect(spawnCredentials(1000, 1000)).toEqual({
13
+ uid: 1000,
14
+ gid: 1000,
15
+ });
16
+ });
17
+ });
18
+
19
+ describe("setprivArgv", () => {
20
+ afterEach(() => {
21
+ spyOn(Bun, "which").mockRestore();
22
+ });
23
+
24
+ it("wraps the command with a full privilege drop", () => {
25
+ spyOn(Bun, "which").mockReturnValue("/usr/bin/setpriv");
26
+
27
+ expect(setprivArgv(COMMAND, 1000, 1000)).toEqual([
28
+ "setpriv",
29
+ "--reuid=1000",
30
+ "--regid=1000",
31
+ "--clear-groups",
32
+ "--",
33
+ ...COMMAND,
34
+ ]);
35
+ });
36
+
37
+ it("refuses to build a wrapper the image cannot run", () => {
38
+ spyOn(Bun, "which").mockReturnValue(null);
39
+
40
+ expect(() => setprivArgv(COMMAND, 1000, 1000)).toThrow("setpriv is missing");
41
+ });
42
+ });
43
+
44
+ describe("privilegePlan", () => {
45
+ afterEach(() => {
46
+ spyOn(Bun, "which").mockRestore();
47
+ });
48
+
49
+ it("passes credentials to the runtime when the spawn uid option is honoured", () => {
50
+ const plan = privilegePlan(true, COMMAND, 1000, 1000);
51
+
52
+ expect(plan.argv, "a native runtime must run the bare command").toEqual(COMMAND);
53
+ expect(plan.credentials, "the drop must ride the spawn options or the game runs as root").toEqual({
54
+ uid: 1000,
55
+ gid: 1000,
56
+ });
57
+ });
58
+
59
+ it("falls back to setpriv with no spawn credentials otherwise", () => {
60
+ spyOn(Bun, "which").mockReturnValue("/usr/bin/setpriv");
61
+
62
+ const plan = privilegePlan(false, COMMAND, 1000, 1000);
63
+
64
+ expect(plan.argv.at(0), "the wrapper must lead the argv").toBe("setpriv");
65
+ expect(plan.credentials, "credentials on a setpriv wrapper would fail the spawn").toEqual({});
66
+ });
67
+ });
68
+
69
+ describe("readEffectiveUid", () => {
70
+ it("reads this process's own effective uid", async () => {
71
+ expect(await readEffectiveUid(process.pid)).toBe(process.getuid?.() ?? 0);
72
+ });
73
+
74
+ it("answers null for a pid that does not exist", async () => {
75
+ expect(await readEffectiveUid(2 ** 22 - 1)).toBeNull();
76
+ });
77
+ });
@@ -91,11 +91,13 @@ export const setprivArgv = (command: string[], uid: number, gid: number) => {
91
91
  ];
92
92
  };
93
93
 
94
- export const unprivilegedArgv = async (command: string[], uid: number, gid: number) => {
95
- const native = await probeNativeUid(uid, gid);
96
-
94
+ export const privilegePlan = (native: boolean, command: string[], uid: number, gid: number) => {
97
95
  return {
98
96
  argv: native ? command : setprivArgv(command, uid, gid),
99
97
  credentials: native ? spawnCredentials(uid, gid) : {},
100
98
  };
101
99
  };
100
+
101
+ export const unprivilegedArgv = async (command: string[], uid: number, gid: number) => {
102
+ return privilegePlan(await probeNativeUid(uid, gid), command, uid, gid);
103
+ };
@@ -426,3 +426,137 @@ describe("preview modules", () => {
426
426
  );
427
427
  });
428
428
  });
429
+
430
+ describe("detail modules", () => {
431
+ test("lists read and its own action ids in the manifest", () => {
432
+ const registry = registryFor({
433
+ ...baseDriver(),
434
+ modules: {
435
+ modpackStatus: {
436
+ kind: BridgeKind.Detail,
437
+ read: async () => null,
438
+ actions: {
439
+ update: async () => null,
440
+ },
441
+ },
442
+ },
443
+ });
444
+
445
+ expect(registry.manifest().modules).toEqual([
446
+ {
447
+ id: "modpackStatus",
448
+ kind: BridgeKind.Detail,
449
+ requiresRunning: false,
450
+ refreshSeconds: null,
451
+ pageSize: null,
452
+ dependsOn: [],
453
+ actions: [
454
+ BridgeAction.Read,
455
+ "update",
456
+ ],
457
+ },
458
+ ]);
459
+ });
460
+
461
+ test("returns what read answers", async () => {
462
+ const registry = registryFor({
463
+ ...baseDriver(),
464
+ modules: {
465
+ modpackStatus: {
466
+ kind: BridgeKind.Detail,
467
+ read: async () => ({
468
+ id: "modrinth:abc",
469
+ title: "Better MC",
470
+ subtitle: "1.21.1 · NeoForge",
471
+ description: null,
472
+ image: null,
473
+ badges: [],
474
+ stats: [],
475
+ links: [],
476
+ stale: false,
477
+ actions: [],
478
+ }),
479
+ },
480
+ },
481
+ });
482
+
483
+ expect(await registry.invoke("modpackStatus", BridgeAction.Read)).toMatchObject({
484
+ id: "modrinth:abc",
485
+ title: "Better MC",
486
+ });
487
+ });
488
+
489
+ test("hands the argument bag to an action and returns its outcome", async () => {
490
+ const received: BridgeValues[] = [];
491
+
492
+ const registry = registryFor({
493
+ ...baseDriver(),
494
+ modules: {
495
+ modpackStatus: {
496
+ kind: BridgeKind.Detail,
497
+ read: async () => null,
498
+ actions: {
499
+ update: async (_context, args) => {
500
+ received.push(args);
501
+
502
+ return {
503
+ variables: {
504
+ MODPACK: "modrinth:abc:v2",
505
+ },
506
+ };
507
+ },
508
+ },
509
+ },
510
+ },
511
+ });
512
+
513
+ expect(
514
+ await registry.invoke("modpackStatus", "update", {
515
+ id: "modrinth:abc",
516
+ }),
517
+ ).toEqual({
518
+ variables: {
519
+ MODPACK: "modrinth:abc:v2",
520
+ },
521
+ });
522
+
523
+ expect(received).toEqual([
524
+ {
525
+ id: "modrinth:abc",
526
+ },
527
+ ]);
528
+ });
529
+
530
+ test("refuses an action named after a read", () => {
531
+ expect(() =>
532
+ registryFor({
533
+ ...baseDriver(),
534
+ modules: {
535
+ modpackStatus: {
536
+ kind: BridgeKind.Detail,
537
+ read: async () => null,
538
+ actions: {
539
+ read: async () => null,
540
+ },
541
+ },
542
+ },
543
+ }),
544
+ ).toThrow('module "modpackStatus" declares action "read", which is reserved for reads');
545
+ });
546
+
547
+ test("refuses an action the module does not answer", async () => {
548
+ const registry = registryFor({
549
+ ...baseDriver(),
550
+ modules: {
551
+ modpackStatus: {
552
+ kind: BridgeKind.Detail,
553
+ read: async () => null,
554
+ },
555
+ },
556
+ });
557
+
558
+ await expect(registry.invoke("modpackStatus", "update")).rejects.toThrow(
559
+ 'module "modpackStatus" does not support "update"',
560
+ );
561
+ });
562
+ });
@@ -18,7 +18,11 @@ interface CacheEntry {
18
18
  }
19
19
 
20
20
  const requiresRunning = (module: BridgePanelModule) => {
21
- if (module.kind === BridgeKind.Actions || module.kind === BridgeKind.Collection) {
21
+ if (
22
+ module.kind === BridgeKind.Actions
23
+ || module.kind === BridgeKind.Collection
24
+ || module.kind === BridgeKind.Detail
25
+ ) {
22
26
  return module.requiresRunning ?? false;
23
27
  }
24
28
 
@@ -26,7 +30,11 @@ const requiresRunning = (module: BridgePanelModule) => {
26
30
  };
27
31
 
28
32
  const refreshOf = (module: BridgePanelModule) => {
29
- return module.kind === BridgeKind.Collection ? (module.refreshSeconds ?? null) : null;
33
+ if (module.kind === BridgeKind.Collection || module.kind === BridgeKind.Detail) {
34
+ return module.refreshSeconds ?? null;
35
+ }
36
+
37
+ return null;
30
38
  };
31
39
 
32
40
  const dependsOf = (module: BridgePanelModule) => {
@@ -138,11 +146,11 @@ export const createRegistry = (
138
146
  const modules = driver.modules ?? {};
139
147
 
140
148
  for (const [id, module] of Object.entries(modules)) {
141
- if (module.kind !== BridgeKind.Actions) {
149
+ if (module.kind !== BridgeKind.Actions && module.kind !== BridgeKind.Detail) {
142
150
  continue;
143
151
  }
144
152
 
145
- for (const action of Object.keys(module.actions)) {
153
+ for (const action of Object.keys(module.actions ?? {})) {
146
154
  if (isReadAction(action)) {
147
155
  throw new BridgeDriverError(
148
156
  `module "${id}" declares action "${action}", which is reserved for reads — pick another id`,
@@ -290,6 +298,20 @@ export const createRegistry = (
290
298
  break;
291
299
  }
292
300
 
301
+ case BridgeKind.Detail: {
302
+ if (action === BridgeAction.Read) {
303
+ return await module.read(context);
304
+ }
305
+
306
+ const detailAction = module.actions?.[action];
307
+
308
+ if (detailAction) {
309
+ return await detailAction(context, args);
310
+ }
311
+
312
+ break;
313
+ }
314
+
293
315
  case BridgeKind.Catalog: {
294
316
  if (action === BridgeAction.Search) {
295
317
  return await module.search(context, {