@serverkgg/bridge 0.1.1 → 0.1.3
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.
- package/package.json +3 -2
- package/rules/package.md +60 -0
- package/src/blog/articleFrontmatter.test.ts +10 -10
- package/src/blog/compile.test.ts +9 -9
- package/src/bot/botDeploy.test.ts +16 -16
- package/src/bot/botEnvironment.test.ts +8 -8
- package/src/bot/botIdentity.test.ts +12 -12
- package/src/bot/botPanel.test.ts +41 -11
- package/src/bot/botPanel.ts +13 -5
- package/src/bot/botState.test.ts +3 -3
- package/src/bot/botTerminal.test.ts +5 -5
- package/src/bot/botVariables.test.ts +8 -8
- package/src/bot/detect/botToken.test.ts +6 -6
- package/src/bot/detect/detect.test.ts +21 -21
- package/src/bot/detect/detectTable.test.ts +5 -5
- package/src/bot/detect/envExample.test.ts +2 -2
- package/src/bot/detect/envScan.test.ts +4 -4
- package/src/bot/detect/inferBotPlatform.test.ts +4 -4
- package/src/bot/detect/keys.test.ts +3 -3
- package/src/bot/detect/sensitiveKey.test.ts +5 -5
- package/src/bot/detect/serverkJson.test.ts +5 -5
- package/src/catalogs/curseforge.test.ts +35 -35
- package/src/catalogs/thunderstore.test.ts +9 -9
- package/src/cli/detectTable.test.ts +3 -3
- package/src/cli/link.test.ts +11 -11
- package/src/cli/main.ts +8 -0
- package/src/cli/rules.test.ts +198 -0
- package/src/cli/rules.ts +188 -0
- package/src/cli/validate.test.ts +123 -29
- package/src/cli/validate.ts +37 -1
- package/src/codecs/ini.test.ts +13 -13
- package/src/codecs/javaProperties.test.ts +11 -11
- package/src/codecs/sourceCfg.test.ts +14 -14
- package/src/codecs/xmlProperties.test.ts +10 -10
- package/src/context/bridgeContext.ts +6 -1
- package/src/core/bridgeAction.ts +2 -0
- package/src/core/bridgeActivity.ts +9 -0
- package/src/core/bridgeAvailability.ts +10 -0
- package/src/core/bridgePort.test.ts +6 -6
- package/src/core/bridgeUserError.test.ts +13 -3
- package/src/core/bridgeUserError.ts +6 -2
- package/src/core/bridgeValue.test.ts +5 -5
- package/src/core/describeDownloadFailure.test.ts +58 -0
- package/src/core/describeDownloadFailure.ts +69 -0
- package/src/core/index.ts +3 -0
- package/src/guides/markdown.test.ts +31 -31
- package/src/install/installStamp.test.ts +9 -9
- package/src/manifest/extension.ts +36 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +208 -90
- package/src/manifest/manifest.ts +34 -0
- package/src/modules/bridge.ts +8 -1
- package/src/modules/bridgeModule.ts +13 -1
- package/src/net/loopback.test.ts +10 -10
- package/src/panel/bridgeDetail.ts +2 -1
- package/src/presence/rosterSync.test.ts +7 -7
- package/src/probes/a2s.test.ts +5 -5
- package/src/probes/minecraftPing.test.ts +17 -17
- package/src/probes/raknetPing.test.ts +13 -13
- package/src/protocol/bridgeActivity.ts +54 -0
- package/src/protocol/bridgeEvents.ts +30 -0
- package/src/protocol/bridgeFrame.ts +10 -1
- package/src/protocol/index.ts +1 -0
- package/src/rcon/rconAccess.test.ts +20 -20
- package/src/rcon/rconAccess.ts +13 -13
- package/src/rcon/rconClient.test.ts +49 -49
- package/src/rcon/rconFrame.test.ts +14 -14
- package/src/rcon/rconIds.test.ts +4 -4
- package/src/rcon/rconWriter.test.ts +6 -6
- package/src/runtime/bridgeActions.ts +19 -2
- package/src/runtime/bridgeActivity.test.ts +318 -0
- package/src/runtime/bridgeActivity.ts +268 -0
- package/src/runtime/bridgeBackupLease.test.ts +9 -9
- package/src/runtime/bridgeConfigReader.test.ts +5 -5
- package/src/runtime/bridgeConsoleHandler.test.ts +8 -8
- package/src/runtime/bridgeContext.test.ts +11 -5
- package/src/runtime/bridgeContext.ts +8 -1
- package/src/runtime/bridgeDriver.test.ts +114 -93
- package/src/runtime/bridgeDriver.ts +10 -7
- package/src/runtime/bridgeEvents.test.ts +15 -15
- package/src/runtime/bridgeFiles.test.ts +54 -54
- package/src/runtime/bridgeInstall.test.ts +10 -10
- package/src/runtime/bridgeLiveConfig.test.ts +6 -6
- package/src/runtime/bridgeLog.ts +10 -0
- package/src/runtime/bridgePending.test.ts +6 -6
- package/src/runtime/bridgeRedaction.test.ts +16 -16
- package/src/runtime/bridgeRefusalError.ts +13 -0
- package/src/runtime/bridgeRegistry.test.ts +1077 -34
- package/src/runtime/bridgeRegistry.ts +464 -65
- package/src/runtime/bridgeScopedContext.ts +55 -0
- package/src/runtime/bridgeServer.test.ts +424 -24
- package/src/runtime/bridgeServer.ts +125 -35
- package/src/runtime/bridgeSetup.test.ts +27 -27
- package/src/runtime/bridgeSetup.ts +1 -1
- package/src/runtime/bridgeSetupHandlers.test.ts +80 -17
- package/src/runtime/bridgeSetupHandlers.ts +45 -10
- package/src/runtime/bridgeSignal.test.ts +7 -7
- package/src/runtime/bridgeSupervisor.test.ts +42 -42
- package/src/runtime/serve.ts +45 -3
- package/src/steam/avatars.test.ts +13 -13
- package/src/steam/steamApp.test.ts +8 -8
- package/src/steam/steamcmd.test.ts +44 -13
- package/src/steam/steamcmd.ts +27 -4
- package/src/steam/steamcmdNarrator.test.ts +4 -4
- package/src/steam/workshop.test.ts +5 -5
- package/src/utils/changedSettingKeys.test.ts +65 -0
- package/src/utils/changedSettingKeys.ts +6 -0
- package/src/utils/describeExecFailure.test.ts +34 -0
- package/src/utils/describeExecFailure.ts +9 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/sameSettingValue.test.ts +35 -0
- package/src/utils/sameSettingValue.ts +40 -0
- package/src/utils/token.test.ts +7 -7
- package/src/wine/protonCommand.test.ts +5 -5
- package/src/wine/wineCommand.test.ts +6 -6
- package/src/wine/winePrefix.test.ts +6 -6
- package/src/wire/activity.test.ts +95 -0
- package/src/wire/activity.ts +41 -0
- package/src/wire/index.ts +1 -0
- package/src/wire/panel.test.ts +91 -25
- package/src/wire/panel.ts +11 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { BridgeActivity, BridgeValues } from "../core";
|
|
3
|
+
import {
|
|
4
|
+
BRIDGE_ACTIVITY_DETAILS_LIMIT,
|
|
5
|
+
BRIDGE_ACTIVITY_KEYS,
|
|
6
|
+
BRIDGE_ACTIVITY_LIMIT,
|
|
7
|
+
BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT,
|
|
8
|
+
BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT,
|
|
9
|
+
BridgeEventName,
|
|
10
|
+
} from "../protocol";
|
|
11
|
+
import {
|
|
12
|
+
type ActivityNormalizeOptions,
|
|
13
|
+
createActivityReporter,
|
|
14
|
+
createActivityScope,
|
|
15
|
+
normalizeActivity,
|
|
16
|
+
} from "./bridgeActivity";
|
|
17
|
+
|
|
18
|
+
const SECRET = "tok-1234567890-secret";
|
|
19
|
+
|
|
20
|
+
const recorder = () => {
|
|
21
|
+
const warnings: {
|
|
22
|
+
message: string;
|
|
23
|
+
fields?: Record<string, unknown>;
|
|
24
|
+
}[] = [];
|
|
25
|
+
|
|
26
|
+
const emitted: {
|
|
27
|
+
event: string;
|
|
28
|
+
payload: BridgeValues;
|
|
29
|
+
}[] = [];
|
|
30
|
+
|
|
31
|
+
const options: ActivityNormalizeOptions = {
|
|
32
|
+
secretKeys: new Set([
|
|
33
|
+
"RCON_PASSWORD",
|
|
34
|
+
]),
|
|
35
|
+
redact: (text) => text.split(SECRET).join("••••"),
|
|
36
|
+
warn: (message, fields) => {
|
|
37
|
+
warnings.push({
|
|
38
|
+
message,
|
|
39
|
+
fields,
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const reporter = createActivityReporter({
|
|
45
|
+
...options,
|
|
46
|
+
onEmit(event, payload) {
|
|
47
|
+
emitted.push({
|
|
48
|
+
event,
|
|
49
|
+
payload,
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
options,
|
|
56
|
+
reporter,
|
|
57
|
+
warnings,
|
|
58
|
+
emitted,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const granted = (subject = "Steve"): BridgeActivity => ({
|
|
63
|
+
name: BridgeEventName.AccessGranted,
|
|
64
|
+
text: {
|
|
65
|
+
ar: `أضفت ${subject} للقائمة البيضاء`,
|
|
66
|
+
en: `Added ${subject} to the whitelist`,
|
|
67
|
+
},
|
|
68
|
+
subject,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("normalizeActivity", () => {
|
|
72
|
+
it("keeps a well-formed entry as it is", () => {
|
|
73
|
+
const { options, warnings } = recorder();
|
|
74
|
+
|
|
75
|
+
expect(normalizeActivity(granted(), options)).toEqual(granted());
|
|
76
|
+
expect(warnings).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("drops a name the protocol does not know, with a warning", () => {
|
|
80
|
+
const { options, warnings } = recorder();
|
|
81
|
+
|
|
82
|
+
expect(
|
|
83
|
+
normalizeActivity(
|
|
84
|
+
{
|
|
85
|
+
...granted(),
|
|
86
|
+
name: "WhitelistAdded" as BridgeEventName,
|
|
87
|
+
},
|
|
88
|
+
options,
|
|
89
|
+
),
|
|
90
|
+
).toBeNull();
|
|
91
|
+
expect(warnings).toEqual([
|
|
92
|
+
{
|
|
93
|
+
message: "activity entry normalized",
|
|
94
|
+
fields: {
|
|
95
|
+
name: "WhitelistAdded",
|
|
96
|
+
reason: "unknown-name",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("drops an entry whose text is empty in one language", () => {
|
|
103
|
+
const { options } = recorder();
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
normalizeActivity(
|
|
107
|
+
{
|
|
108
|
+
...granted(),
|
|
109
|
+
text: {
|
|
110
|
+
ar: " ",
|
|
111
|
+
en: "Added Steve",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
options,
|
|
115
|
+
),
|
|
116
|
+
).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("cuts the text and the subject to their limits", () => {
|
|
120
|
+
const { options, warnings } = recorder();
|
|
121
|
+
|
|
122
|
+
const normalized = normalizeActivity(
|
|
123
|
+
{
|
|
124
|
+
...granted(),
|
|
125
|
+
text: {
|
|
126
|
+
ar: "ا".repeat(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT + 10),
|
|
127
|
+
en: "a".repeat(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT + 10),
|
|
128
|
+
},
|
|
129
|
+
subject: "s".repeat(BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT + 10),
|
|
130
|
+
},
|
|
131
|
+
options,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(normalized?.text.ar).toHaveLength(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT);
|
|
135
|
+
expect(normalized?.text.en).toHaveLength(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT);
|
|
136
|
+
expect(normalized?.subject).toHaveLength(BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT);
|
|
137
|
+
expect(warnings.map((warning) => warning.fields?.reason)).toEqual([
|
|
138
|
+
"long-text",
|
|
139
|
+
"long-subject",
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("keeps at most sixteen details", () => {
|
|
144
|
+
const { options } = recorder();
|
|
145
|
+
|
|
146
|
+
const details: BridgeValues = {};
|
|
147
|
+
|
|
148
|
+
for (let index = 0; index < BRIDGE_ACTIVITY_DETAILS_LIMIT + 4; index++) {
|
|
149
|
+
details[`key${index}`] = index;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const normalized = normalizeActivity(
|
|
153
|
+
{
|
|
154
|
+
...granted(),
|
|
155
|
+
details,
|
|
156
|
+
},
|
|
157
|
+
options,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(Object.keys(normalized?.details ?? {})).toHaveLength(BRIDGE_ACTIVITY_DETAILS_LIMIT);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("removes secret fields, reserved keys and values that are not scalars", () => {
|
|
164
|
+
const { options } = recorder();
|
|
165
|
+
|
|
166
|
+
const normalized = normalizeActivity(
|
|
167
|
+
{
|
|
168
|
+
...granted(),
|
|
169
|
+
details: {
|
|
170
|
+
RCON_PASSWORD: "hunter22hunter22",
|
|
171
|
+
[BRIDGE_ACTIVITY_KEYS.ar]: "مزوّر",
|
|
172
|
+
"bad key": "x",
|
|
173
|
+
nested: {
|
|
174
|
+
deep: true,
|
|
175
|
+
} as unknown as string,
|
|
176
|
+
count: Number.NaN,
|
|
177
|
+
from: "1.20.4",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
options,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(normalized?.details).toEqual({
|
|
184
|
+
from: "1.20.4",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("blanks a secret value in the text, the subject and the details", () => {
|
|
189
|
+
const { options } = recorder();
|
|
190
|
+
|
|
191
|
+
const normalized = normalizeActivity(
|
|
192
|
+
{
|
|
193
|
+
name: BridgeEventName.BroadcastSent,
|
|
194
|
+
text: {
|
|
195
|
+
ar: `أرسلت ${SECRET}`,
|
|
196
|
+
en: `Sent ${SECRET}`,
|
|
197
|
+
},
|
|
198
|
+
subject: SECRET,
|
|
199
|
+
details: {
|
|
200
|
+
message: `hello ${SECRET}`,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
options,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(JSON.stringify(normalized)).not.toContain(SECRET);
|
|
207
|
+
expect(normalized?.details).toEqual({
|
|
208
|
+
message: "hello ••••",
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("createActivityReporter", () => {
|
|
214
|
+
it("emits the entry as an event carrying the text under the reserved keys", () => {
|
|
215
|
+
const { reporter, emitted } = recorder();
|
|
216
|
+
|
|
217
|
+
reporter.report({
|
|
218
|
+
...granted(),
|
|
219
|
+
details: {
|
|
220
|
+
source: "panel",
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(emitted).toEqual([
|
|
225
|
+
{
|
|
226
|
+
event: BridgeEventName.AccessGranted,
|
|
227
|
+
payload: {
|
|
228
|
+
source: "panel",
|
|
229
|
+
[BRIDGE_ACTIVITY_KEYS.ar]: "أضفت Steve للقائمة البيضاء",
|
|
230
|
+
[BRIDGE_ACTIVITY_KEYS.en]: "Added Steve to the whitelist",
|
|
231
|
+
[BRIDGE_ACTIVITY_KEYS.subject]: "Steve",
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("emits nothing for an entry normalisation drops", () => {
|
|
238
|
+
const { reporter, emitted } = recorder();
|
|
239
|
+
|
|
240
|
+
reporter.report({
|
|
241
|
+
...granted(),
|
|
242
|
+
name: "Nope" as BridgeEventName,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(emitted).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("createActivityScope", () => {
|
|
250
|
+
it("collects entries for the answer instead of emitting them", () => {
|
|
251
|
+
const { options, reporter, emitted } = recorder();
|
|
252
|
+
const scope = createActivityScope(reporter, options);
|
|
253
|
+
|
|
254
|
+
scope.reporter.report(granted("Steve"));
|
|
255
|
+
scope.reporter.report(granted("Alex"));
|
|
256
|
+
|
|
257
|
+
expect(scope.reported).toBe(2);
|
|
258
|
+
expect(scope.close()).toEqual([
|
|
259
|
+
granted("Steve"),
|
|
260
|
+
granted("Alex"),
|
|
261
|
+
]);
|
|
262
|
+
expect(emitted).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("drains what it collected to the ring when the handler throws", () => {
|
|
266
|
+
const { options, reporter, emitted } = recorder();
|
|
267
|
+
const scope = createActivityScope(reporter, options);
|
|
268
|
+
|
|
269
|
+
scope.reporter.report(granted());
|
|
270
|
+
scope.drain();
|
|
271
|
+
|
|
272
|
+
expect(emitted.map((entry) => entry.event)).toEqual([
|
|
273
|
+
BridgeEventName.AccessGranted,
|
|
274
|
+
]);
|
|
275
|
+
expect(scope.close()).toEqual([]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("sends a report made after the answer to the ring", () => {
|
|
279
|
+
const { options, reporter, emitted } = recorder();
|
|
280
|
+
const scope = createActivityScope(reporter, options);
|
|
281
|
+
|
|
282
|
+
expect(scope.close()).toEqual([]);
|
|
283
|
+
|
|
284
|
+
scope.reporter.report(granted());
|
|
285
|
+
|
|
286
|
+
expect(emitted.map((entry) => entry.payload[BRIDGE_ACTIVITY_KEYS.subject])).toEqual([
|
|
287
|
+
"Steve",
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("keeps at most eight entries on one answer", () => {
|
|
292
|
+
const { options, reporter, warnings } = recorder();
|
|
293
|
+
const scope = createActivityScope(reporter, options);
|
|
294
|
+
|
|
295
|
+
for (let index = 0; index < BRIDGE_ACTIVITY_LIMIT + 2; index++) {
|
|
296
|
+
scope.reporter.report(granted(`player${index}`));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
expect(scope.close()).toHaveLength(BRIDGE_ACTIVITY_LIMIT);
|
|
300
|
+
expect(warnings.filter((warning) => warning.fields?.reason === "extra-entries")).toHaveLength(2);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("never throws into the caller, whatever the entry holds", () => {
|
|
304
|
+
const { options, reporter, warnings } = recorder();
|
|
305
|
+
const scope = createActivityScope(reporter, options);
|
|
306
|
+
|
|
307
|
+
const hostile = {
|
|
308
|
+
...granted(),
|
|
309
|
+
get details(): BridgeValues {
|
|
310
|
+
throw new Error("a getter that throws");
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
expect(() => scope.reporter.report(hostile)).not.toThrow();
|
|
315
|
+
expect(() => reporter.report(hostile)).not.toThrow();
|
|
316
|
+
expect(warnings.filter((warning) => warning.message === "activity report failed")).toHaveLength(2);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { BridgeActivityReporter } from "../context";
|
|
2
|
+
import { type BridgeActivity, type BridgeValue, type BridgeValues, isLocalizedText } from "../core";
|
|
3
|
+
import {
|
|
4
|
+
activityPayload,
|
|
5
|
+
BRIDGE_ACTIVITY_DETAIL_KEY_LENGTH_LIMIT,
|
|
6
|
+
BRIDGE_ACTIVITY_DETAIL_KEY_PATTERN,
|
|
7
|
+
BRIDGE_ACTIVITY_DETAILS_LIMIT,
|
|
8
|
+
BRIDGE_ACTIVITY_KEYS,
|
|
9
|
+
BRIDGE_ACTIVITY_LIMIT,
|
|
10
|
+
BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT,
|
|
11
|
+
BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT,
|
|
12
|
+
isBridgeEventName,
|
|
13
|
+
} from "../protocol";
|
|
14
|
+
import { type BridgeLogger, errorText } from "./bridgeLog";
|
|
15
|
+
import type { BridgeRedact } from "./bridgeRedaction";
|
|
16
|
+
|
|
17
|
+
export interface ActivityNormalizeOptions {
|
|
18
|
+
secretKeys: ReadonlySet<string>;
|
|
19
|
+
redact: BridgeRedact;
|
|
20
|
+
warn: BridgeLogger;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ActivityReporterOptions extends ActivityNormalizeOptions {
|
|
24
|
+
onEmit: (event: string, payload: BridgeValues) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BridgeActivityAnswer<Result> {
|
|
28
|
+
result: Result;
|
|
29
|
+
activity: BridgeActivity[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BridgeActivityScope {
|
|
33
|
+
readonly reporter: BridgeActivityReporter;
|
|
34
|
+
readonly reported: number;
|
|
35
|
+
close(): BridgeActivity[];
|
|
36
|
+
drain(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RESERVED_KEYS: ReadonlySet<string> = new Set(Object.values(BRIDGE_ACTIVITY_KEYS));
|
|
40
|
+
|
|
41
|
+
const cut = (value: string, limit: number, reason: string, report: (reason: string) => void) => {
|
|
42
|
+
if (value.length <= limit) {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
report(`long-${reason}`);
|
|
47
|
+
|
|
48
|
+
return value.slice(0, limit);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isScalar = (value: unknown): value is BridgeValue => {
|
|
52
|
+
return (
|
|
53
|
+
value === null
|
|
54
|
+
|| typeof value === "boolean"
|
|
55
|
+
|| typeof value === "string"
|
|
56
|
+
|| (typeof value === "number" && Number.isFinite(value))
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const normalizeDetails = (
|
|
61
|
+
details: unknown,
|
|
62
|
+
options: ActivityNormalizeOptions,
|
|
63
|
+
report: (reason: string) => void,
|
|
64
|
+
): BridgeValues | null => {
|
|
65
|
+
if (details === undefined || details === null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof details !== "object" || Array.isArray(details)) {
|
|
70
|
+
report("unusable-details");
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const kept: BridgeValues = {};
|
|
76
|
+
|
|
77
|
+
let count = 0;
|
|
78
|
+
|
|
79
|
+
for (const [key, value] of Object.entries(details)) {
|
|
80
|
+
if (RESERVED_KEYS.has(key)) {
|
|
81
|
+
report("reserved-detail");
|
|
82
|
+
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.secretKeys.has(key)) {
|
|
87
|
+
report("secret-detail");
|
|
88
|
+
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (key.length > BRIDGE_ACTIVITY_DETAIL_KEY_LENGTH_LIMIT || !BRIDGE_ACTIVITY_DETAIL_KEY_PATTERN.test(key)) {
|
|
93
|
+
report("unusable-detail-key");
|
|
94
|
+
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!isScalar(value)) {
|
|
99
|
+
report("unusable-detail-value");
|
|
100
|
+
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (count >= BRIDGE_ACTIVITY_DETAILS_LIMIT) {
|
|
105
|
+
report("extra-details");
|
|
106
|
+
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
kept[key] =
|
|
111
|
+
typeof value === "string"
|
|
112
|
+
? cut(options.redact(value), BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT, "detail", report)
|
|
113
|
+
: value;
|
|
114
|
+
count += 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return count > 0 ? kept : null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const normalizeActivity = (entry: BridgeActivity, options: ActivityNormalizeOptions): BridgeActivity | null => {
|
|
121
|
+
const name = String(entry.name);
|
|
122
|
+
const reported = new Set<string>();
|
|
123
|
+
|
|
124
|
+
const report = (reason: string) => {
|
|
125
|
+
if (reported.has(reason)) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
reported.add(reason);
|
|
130
|
+
|
|
131
|
+
options.warn("activity entry normalized", {
|
|
132
|
+
name,
|
|
133
|
+
reason,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (!isBridgeEventName(name)) {
|
|
138
|
+
report("unknown-name");
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!isLocalizedText(entry.text)) {
|
|
144
|
+
report("unusable-text");
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ar = cut(options.redact(entry.text.ar).trim(), BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT, "text", report);
|
|
150
|
+
const en = cut(options.redact(entry.text.en).trim(), BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT, "text", report);
|
|
151
|
+
|
|
152
|
+
if (ar.length === 0 || en.length === 0) {
|
|
153
|
+
report("empty-text");
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const subject =
|
|
159
|
+
typeof entry.subject === "string"
|
|
160
|
+
? cut(options.redact(entry.subject).trim(), BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT, "subject", report)
|
|
161
|
+
: "";
|
|
162
|
+
|
|
163
|
+
if (entry.subject !== undefined && subject.length === 0) {
|
|
164
|
+
report("empty-subject");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const details = normalizeDetails(entry.details, options, report);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
name: entry.name,
|
|
171
|
+
text: {
|
|
172
|
+
ar,
|
|
173
|
+
en,
|
|
174
|
+
},
|
|
175
|
+
...(subject.length > 0
|
|
176
|
+
? {
|
|
177
|
+
subject,
|
|
178
|
+
}
|
|
179
|
+
: {}),
|
|
180
|
+
...(details
|
|
181
|
+
? {
|
|
182
|
+
details,
|
|
183
|
+
}
|
|
184
|
+
: {}),
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const safely = (options: ActivityNormalizeOptions, run: () => void) => {
|
|
189
|
+
try {
|
|
190
|
+
run();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
options.warn("activity report failed", {
|
|
193
|
+
error: errorText(error),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const createActivityReporter = (options: ActivityReporterOptions): BridgeActivityReporter => {
|
|
199
|
+
return {
|
|
200
|
+
report(entry) {
|
|
201
|
+
safely(options, () => {
|
|
202
|
+
const normalized = normalizeActivity(entry, options);
|
|
203
|
+
|
|
204
|
+
if (normalized) {
|
|
205
|
+
options.onEmit(normalized.name, activityPayload(normalized));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const createActivityScope = (
|
|
213
|
+
fallback: BridgeActivityReporter,
|
|
214
|
+
options: ActivityNormalizeOptions,
|
|
215
|
+
): BridgeActivityScope => {
|
|
216
|
+
const entries: BridgeActivity[] = [];
|
|
217
|
+
|
|
218
|
+
let open = true;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
reporter: {
|
|
222
|
+
report(entry) {
|
|
223
|
+
safely(options, () => {
|
|
224
|
+
if (!open) {
|
|
225
|
+
fallback.report(entry);
|
|
226
|
+
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const normalized = normalizeActivity(entry, options);
|
|
231
|
+
|
|
232
|
+
if (!normalized) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (entries.length >= BRIDGE_ACTIVITY_LIMIT) {
|
|
237
|
+
options.warn("activity entry dropped", {
|
|
238
|
+
name: normalized.name,
|
|
239
|
+
reason: "extra-entries",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
entries.push(normalized);
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
get reported() {
|
|
251
|
+
return entries.length;
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
close() {
|
|
255
|
+
open = false;
|
|
256
|
+
|
|
257
|
+
return entries.splice(0);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
drain() {
|
|
261
|
+
open = false;
|
|
262
|
+
|
|
263
|
+
for (const entry of entries.splice(0)) {
|
|
264
|
+
fallback.report(entry);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { createBackupLease } from "./bridgeBackupLease";
|
|
3
3
|
|
|
4
4
|
describe("backup save ownership", () => {
|
|
5
|
-
|
|
5
|
+
it("keeps a long backup frozen while renewed and releases when its owner disappears", async () => {
|
|
6
6
|
let now = 0;
|
|
7
7
|
let releases = 0;
|
|
8
8
|
const lease = createBackupLease(
|
|
@@ -25,7 +25,7 @@ describe("backup save ownership", () => {
|
|
|
25
25
|
expect(releases).toBe(1);
|
|
26
26
|
expect(lease.active).toBe(false);
|
|
27
27
|
});
|
|
28
|
-
|
|
28
|
+
it("refuses overlapping holds and retains ownership after a failed resume", async () => {
|
|
29
29
|
let fail = true;
|
|
30
30
|
const lease = createBackupLease(async () => {
|
|
31
31
|
if (fail) {
|
|
@@ -43,7 +43,7 @@ describe("backup save ownership", () => {
|
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
it("reserves ownership while the quiesce operation is still awaiting confirmation", async () => {
|
|
47
47
|
const prepared = Promise.withResolvers<void>();
|
|
48
48
|
const lease = createBackupLease(async () => {});
|
|
49
49
|
const owner = lease.begin();
|
|
@@ -58,7 +58,7 @@ test("reserves ownership while the quiesce operation is still awaiting confirmat
|
|
|
58
58
|
await lease.finish(owner);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
it("expiration waits for in-flight preparation before resuming saves", async () => {
|
|
62
62
|
let now = 0;
|
|
63
63
|
let releases = 0;
|
|
64
64
|
const prepared = Promise.withResolvers<void>();
|
|
@@ -83,7 +83,7 @@ test("expiration waits for in-flight preparation before resuming saves", async (
|
|
|
83
83
|
expect(lease.active).toBe(false);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
it("late releases and tokenless callers cannot release a newer owner", async () => {
|
|
87
87
|
let releases = 0;
|
|
88
88
|
const lease = createBackupLease(async () => {
|
|
89
89
|
releases++;
|
|
@@ -99,7 +99,7 @@ test("late releases and tokenless callers cannot release a newer owner", async (
|
|
|
99
99
|
expect(releases).toBe(2);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
it("legacy release is accepted only for a legacy hold", async () => {
|
|
103
103
|
let releases = 0;
|
|
104
104
|
const lease = createBackupLease(async () => {
|
|
105
105
|
releases++;
|
|
@@ -111,7 +111,7 @@ test("legacy release is accepted only for a legacy hold", async () => {
|
|
|
111
111
|
expect(releases).toBe(1);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
it("failed quiesce cleanup keeps the owner until resume succeeds", async () => {
|
|
115
115
|
let releases = 0;
|
|
116
116
|
const lease = createBackupLease(async () => {
|
|
117
117
|
releases++;
|
|
@@ -127,7 +127,7 @@ test("failed quiesce cleanup keeps the owner until resume succeeds", async () =>
|
|
|
127
127
|
expect(lease.active).toBe(false);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
it("legacy owners retain the historical hold until explicit release without renewal", async () => {
|
|
131
131
|
let now = 0;
|
|
132
132
|
let releases = 0;
|
|
133
133
|
const lease = createBackupLease(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { BridgeConfigError, parseConfig } from "./bridgeConfigReader";
|
|
3
3
|
|
|
4
4
|
const written = (extra: Record<string, unknown> = {}) => {
|
|
@@ -22,7 +22,7 @@ const written = (extra: Record<string, unknown> = {}) => {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
describe("parseConfig", () => {
|
|
25
|
-
|
|
25
|
+
it("reads a file an older agent wrote, with no public address and no port counts", () => {
|
|
26
26
|
const config = parseConfig(written());
|
|
27
27
|
|
|
28
28
|
expect(config.publicAddress).toBeUndefined();
|
|
@@ -33,7 +33,7 @@ describe("parseConfig", () => {
|
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
it("reads the public address and the port counts when the agent wrote them", () => {
|
|
37
37
|
const config = parseConfig(
|
|
38
38
|
written({
|
|
39
39
|
publicAddress: "203.0.113.7",
|
|
@@ -51,7 +51,7 @@ describe("parseConfig", () => {
|
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
it("ignores a malformed public address and drops non-numeric counts", () => {
|
|
55
55
|
const config = parseConfig(
|
|
56
56
|
written({
|
|
57
57
|
publicAddress: 7,
|
|
@@ -68,7 +68,7 @@ describe("parseConfig", () => {
|
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
it("still refuses a game that would run as root", () => {
|
|
72
72
|
expect(() =>
|
|
73
73
|
parseConfig(
|
|
74
74
|
written({
|