erlc-v2 1.1.0 → 1.1.2

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.
@@ -1,538 +1,535 @@
1
- const http = require("http");
2
- const { QUERY_FLAG_MAP } = require("../util/constants");
3
- const { searchVehicles } = require("../util/vehicleSearch");
4
- const {
5
- getHeader,
6
- verifyWebhookSignature,
7
- normalizeWebhookPayload,
8
- } = require("../util/webhook");
9
-
10
- function cleanPath(value, fallback) {
11
- const input = String(value || fallback || "/").trim();
12
- const withLeadingSlash = input.startsWith("/") ? input : `/${input}`;
13
- if (withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")) {
14
- return withLeadingSlash.slice(0, -1);
15
- }
16
- return withLeadingSlash;
17
- }
18
-
19
- function buildUrl(base, path) {
20
- if (!base) return null;
21
- return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
22
- }
23
-
24
- function sendJson(res, status, payload) {
25
- const body = Buffer.from(JSON.stringify(payload));
26
- res.statusCode = status;
27
- res.setHeader("Content-Type", "application/json; charset=utf-8");
28
- res.setHeader("Content-Length", body.length);
29
- res.end(body);
30
- }
31
-
32
- function sendEmpty(res, status) {
33
- res.statusCode = status;
34
- res.end();
35
- }
36
-
37
- function readBody(req) {
38
- return new Promise((resolve, reject) => {
39
- const chunks = [];
40
- req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
41
- req.on("end", () => resolve(Buffer.concat(chunks)));
42
- req.on("error", reject);
43
- });
44
- }
45
-
46
- function parseJson(rawBody) {
47
- if (!rawBody || rawBody.length === 0) return {};
48
- return JSON.parse(rawBody.toString("utf8"));
49
- }
50
-
51
- function parseBool(value) {
52
- if (typeof value !== "string") return false;
53
- const text = value.trim().toLowerCase();
54
- return text === "true" || text === "1" || text === "yes";
55
- }
56
-
57
- function bodyPreview(body) {
58
- if (!body || typeof body !== "object") return body ?? null;
59
- try {
60
- return JSON.parse(JSON.stringify(body));
61
- } catch {
62
- return { message: "unserializable body" };
63
- }
64
- }
65
-
66
- function getRoutePath(url, basePath) {
67
- if (url.pathname === basePath) return "";
68
- if (!url.pathname.startsWith(`${basePath}/`)) return null;
69
- return url.pathname.slice(basePath.length);
70
- }
71
-
72
- class LocalApiServer {
73
- constructor(client, options = {}) {
74
- this.client = client;
75
- this.options = options || {};
76
- this.server = null;
77
- this.startPromise = null;
78
- }
79
-
80
- info() {
81
- const basePath = cleanPath(this.options.path, "/erlc");
82
- const webhookPath = cleanPath(
83
- this.options.webhookPath || `${basePath}/events`,
84
- `${basePath}/events`,
85
- );
86
- const address = this.server?.address();
87
- const host =
88
- typeof this.options.host === "string" && this.options.host.trim()
89
- ? this.options.host.trim()
90
- : "127.0.0.1";
91
- const port =
92
- address && typeof address === "object" ? address.port : this.options.port;
93
- const localHost =
94
- host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
95
- const localUrl =
96
- Number.isInteger(port) && port >= 0
97
- ? `http://${localHost}:${port}${basePath}`
98
- : null;
99
-
100
- return {
101
- running: Boolean(this.server?.listening),
102
- host,
103
- port: Number.isInteger(port) ? port : null,
104
- path: basePath,
105
- webhookPath,
106
- localUrl,
107
- webhookUrl: buildUrl(this.options.publicUrl, webhookPath),
108
- publicUrl: this.options.publicUrl || null,
109
- tokenEnabled: Boolean(this.options.token),
110
- };
111
- }
112
-
113
- async start() {
114
- if (this.server?.listening) {
115
- return this.info();
116
- }
117
- if (this.startPromise) {
118
- return this.startPromise;
119
- }
120
-
121
- const port = Number(this.options.port);
122
- if (!Number.isInteger(port) || port < 0) {
123
- throw new Error("Local API requires a valid api.port");
124
- }
125
-
126
- const host =
127
- typeof this.options.host === "string" && this.options.host.trim()
128
- ? this.options.host.trim()
129
- : "127.0.0.1";
130
-
131
- this.server = http.createServer((req, res) => {
132
- void this._handle(req, res);
133
- });
134
-
135
- this.startPromise = new Promise((resolve, reject) => {
136
- const onError = (error) => {
137
- this.server?.off("listening", onListening);
138
- this.startPromise = null;
139
- reject(error);
140
- };
141
- const onListening = () => {
142
- this.server?.off("error", onError);
143
- this.startPromise = null;
144
- this.client.logger.info({
145
- msg: "local_api_started",
146
- ...this.info(),
147
- });
148
- resolve(this.info());
149
- };
150
-
151
- this.server.once("error", onError);
152
- this.server.once("listening", onListening);
153
- this.server.listen(port, host);
154
- });
155
-
156
- return this.startPromise;
157
- }
158
-
159
- stop() {
160
- if (!this.server) return;
161
- const server = this.server;
162
- this.server = null;
163
- this.startPromise = null;
164
- server.close();
165
- }
166
-
167
- async _handle(req, res) {
168
- const info = this.info();
169
- const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
170
- const startedAt = Date.now();
171
-
172
- try {
173
- if (url.pathname === info.webhookPath && req.method === "POST") {
174
- await this._handleWebhook(req, res);
175
- return;
176
- }
177
-
178
- const routePath = getRoutePath(url, info.path);
179
- if (routePath === null) {
180
- this._recordRequest({
181
- req,
182
- url,
183
- startedAt,
184
- kind: "route",
185
- status: 404,
186
- note: "not_found",
187
- });
188
- sendJson(res, 404, { message: "Not found" });
189
- return;
190
- }
191
-
192
- if (!this._isAuthorized(req)) {
193
- this._recordRequest({
194
- req,
195
- url,
196
- startedAt,
197
- kind: "route",
198
- status: 401,
199
- note: "unauthorized",
200
- });
201
- sendJson(res, 401, { message: "Unauthorized" });
202
- return;
203
- }
204
-
205
- if (req.method === "GET" && routePath === "") {
206
- this._recordRequest({
207
- req,
208
- url,
209
- startedAt,
210
- kind: "route",
211
- status: 200,
212
- });
213
- sendJson(res, 200, this.info());
214
- return;
215
- }
216
-
217
- if (req.method === "GET" && routePath === "/health") {
218
- this._recordRequest({
219
- req,
220
- url,
221
- startedAt,
222
- kind: "route",
223
- status: 200,
224
- });
225
- sendJson(res, 200, {
226
- ok: true,
227
- polling: this.client.poller?.running === true,
228
- disconnected: this.client.state?.disconnected === true,
229
- });
230
- return;
231
- }
232
-
233
- if (req.method === "GET" && routePath === "/server") {
234
- const snapshot = await this.client.server.fetch(
235
- this._getServerFlags(url.searchParams),
236
- {
237
- bypassCache: parseBool(url.searchParams.get("bypassCache")),
238
- },
239
- );
240
- this._recordRequest({
241
- req,
242
- url,
243
- startedAt,
244
- kind: "route",
245
- status: 200,
246
- note: "server_snapshot",
247
- });
248
- sendJson(res, 200, snapshot);
249
- return;
250
- }
251
-
252
- if (req.method === "GET" && routePath === "/players") {
253
- const players = await this.client.players.list({
254
- bypassCache: parseBool(url.searchParams.get("bypassCache")),
255
- });
256
- this._recordRequest({
257
- req,
258
- url,
259
- startedAt,
260
- kind: "route",
261
- status: 200,
262
- note: `players=${players.length}`,
263
- });
264
- sendJson(res, 200, {
265
- count: players.length,
266
- players,
267
- });
268
- return;
269
- }
270
-
271
- if (req.method === "GET" && routePath === "/vehicles") {
272
- const vehicles = await this.client.vehicles.list({
273
- bypassCache: parseBool(url.searchParams.get("bypassCache")),
274
- });
275
- const filtered = searchVehicles(vehicles, {
276
- query: url.searchParams.get("query") || url.searchParams.get("search"),
277
- plate: url.searchParams.get("plate"),
278
- owner: url.searchParams.get("owner"),
279
- name: url.searchParams.get("name"),
280
- color: url.searchParams.get("color"),
281
- texture: url.searchParams.get("texture"),
282
- exact: parseBool(url.searchParams.get("exact")),
283
- limit: url.searchParams.get("limit"),
284
- });
285
- this._recordRequest({
286
- req,
287
- url,
288
- startedAt,
289
- kind: "route",
290
- status: 200,
291
- note: `vehicles=${filtered.length}`,
292
- });
293
- sendJson(res, 200, {
294
- count: filtered.length,
295
- vehicles: filtered,
296
- });
297
- return;
298
- }
299
-
300
- if (req.method === "GET" && routePath.startsWith("/vehicles/")) {
301
- const plate = decodeURIComponent(routePath.slice("/vehicles/".length));
302
- const vehicle = await this.client.vehicles.findByPlate(plate, {
303
- bypassCache: parseBool(url.searchParams.get("bypassCache")),
304
- });
305
- if (!vehicle) {
306
- this._recordRequest({
307
- req,
308
- url,
309
- startedAt,
310
- kind: "route",
311
- status: 404,
312
- note: `plate=${plate}`,
313
- });
314
- sendJson(res, 404, { message: "Vehicle not found" });
315
- return;
316
- }
317
- this._recordRequest({
318
- req,
319
- url,
320
- startedAt,
321
- kind: "route",
322
- status: 200,
323
- note: `plate=${plate}`,
324
- });
325
- sendJson(res, 200, vehicle);
326
- return;
327
- }
328
-
329
- if (req.method === "GET" && routePath === "/emergency-calls") {
330
- const calls = await this.client.logs.emergencyCalls({
331
- bypassCache: parseBool(url.searchParams.get("bypassCache")),
332
- });
333
- this._recordRequest({
334
- req,
335
- url,
336
- startedAt,
337
- kind: "route",
338
- status: 200,
339
- note: `emergencyCalls=${calls.length}`,
340
- });
341
- sendJson(res, 200, {
342
- count: calls.length,
343
- emergencyCalls: calls,
344
- });
345
- return;
346
- }
347
-
348
- if (req.method === "POST" && routePath === "/command") {
349
- const rawBody = await readBody(req);
350
- const body = parseJson(rawBody);
351
- const result = await this.client.commands.execute(body.command);
352
- this._recordRequest({
353
- req,
354
- url,
355
- startedAt,
356
- kind: "route",
357
- status: 200,
358
- body,
359
- note: "command",
360
- });
361
- sendJson(res, 200, result);
362
- return;
363
- }
364
-
365
- this._recordRequest({
366
- req,
367
- url,
368
- startedAt,
369
- kind: "route",
370
- status: 404,
371
- note: "not_found",
372
- });
373
- sendJson(res, 404, { message: "Not found" });
374
- } catch (error) {
375
- const status = Number(error?.status) || 500;
376
- this._recordRequest({
377
- req,
378
- url,
379
- startedAt,
380
- kind: "route",
381
- status,
382
- note: error?.message || "internal_error",
383
- });
384
- sendJson(res, status, {
385
- message: error?.message || "Internal server error",
386
- status,
387
- code: error?.errorCode ?? null,
388
- });
389
- }
390
- }
391
-
392
- async _handleWebhook(req, res) {
393
- const timestamp = getHeader(req, "x-signature-timestamp");
394
- const signature = getHeader(req, "x-signature-ed25519");
395
- const rawBody = await readBody(req);
396
- const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
397
- const startedAt = Date.now();
398
-
399
- if (!timestamp || !signature) {
400
- this._recordRequest({
401
- req,
402
- url,
403
- startedAt,
404
- kind: "webhook",
405
- status: 400,
406
- note: "missing_signature_headers",
407
- });
408
- sendJson(res, 400, { message: "Missing signature headers" });
409
- return;
410
- }
411
-
412
- if (!verifyWebhookSignature(timestamp, signature, rawBody)) {
413
- this._recordRequest({
414
- req,
415
- url,
416
- startedAt,
417
- kind: "webhook",
418
- status: 401,
419
- note: "invalid_signature",
420
- });
421
- sendJson(res, 401, { message: "Invalid signature" });
422
- return;
423
- }
424
-
425
- let body;
426
- try {
427
- body = parseJson(rawBody);
428
- } catch {
429
- this._recordRequest({
430
- req,
431
- url,
432
- startedAt,
433
- kind: "webhook",
434
- status: 400,
435
- note: "invalid_json",
436
- });
437
- sendJson(res, 400, { message: "Invalid JSON body" });
438
- return;
439
- }
440
-
441
- const payload = normalizeWebhookPayload(body, {
442
- timestamp,
443
- signature,
444
- rawBody,
445
- headers: req.headers,
446
- });
447
-
448
- this._recordRequest({
449
- req,
450
- url,
451
- startedAt,
452
- kind: "webhook",
453
- status: 204,
454
- type: payload.type,
455
- body,
456
- note: "signature_valid",
457
- });
1
+ const http = require("http");
2
+ const { QUERY_FLAG_MAP } = require("../util/constants");
3
+ const { searchVehicles } = require("../util/vehicleSearch");
4
+ const {
5
+ getHeader,
6
+ verifyWebhookSignature,
7
+ normalizeWebhookPayload,
8
+ } = require("../util/webhook");
9
+
10
+ function cleanPath(value, fallback) {
11
+ const input = String(value || fallback || "/").trim();
12
+ const withLeadingSlash = input.startsWith("/") ? input : `/${input}`;
13
+ if (withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")) {
14
+ return withLeadingSlash.slice(0, -1);
15
+ }
16
+ return withLeadingSlash;
17
+ }
18
+
19
+ function buildUrl(base, path) {
20
+ if (!base) return null;
21
+ return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
22
+ }
23
+
24
+ function sendJson(res, status, payload) {
25
+ const body = Buffer.from(JSON.stringify(payload));
26
+ res.statusCode = status;
27
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
28
+ res.setHeader("Content-Length", body.length);
29
+ res.end(body);
30
+ }
31
+
32
+ function sendEmpty(res, status) {
33
+ res.statusCode = status;
34
+ res.end();
35
+ }
36
+
37
+ function readBody(req) {
38
+ return new Promise((resolve, reject) => {
39
+ const chunks = [];
40
+ req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
41
+ req.on("end", () => resolve(Buffer.concat(chunks)));
42
+ req.on("error", reject);
43
+ });
44
+ }
45
+
46
+ function parseJson(rawBody) {
47
+ if (!rawBody || rawBody.length === 0) return {};
48
+ return JSON.parse(rawBody.toString("utf8"));
49
+ }
50
+
51
+ function parseBool(value) {
52
+ if (typeof value !== "string") return false;
53
+ const text = value.trim().toLowerCase();
54
+ return text === "true" || text === "1" || text === "yes";
55
+ }
56
+
57
+ function bodyPreview(body) {
58
+ if (!body || typeof body !== "object") return body ?? null;
59
+ try {
60
+ return JSON.parse(JSON.stringify(body));
61
+ } catch {
62
+ return { message: "unserializable body" };
63
+ }
64
+ }
65
+
66
+ function getRoutePath(url, basePath) {
67
+ if (url.pathname === basePath) return "";
68
+ if (!url.pathname.startsWith(`${basePath}/`)) return null;
69
+ return url.pathname.slice(basePath.length);
70
+ }
71
+
72
+ class LocalApiServer {
73
+ constructor(client, options = {}) {
74
+ this.client = client;
75
+ this.options = options || {};
76
+ this.server = null;
77
+ this.startPromise = null;
78
+ }
79
+
80
+ info() {
81
+ const basePath = cleanPath(this.options.path, "/erlc");
82
+ const webhookPath = cleanPath(
83
+ this.options.webhookPath || `${basePath}/events`,
84
+ `${basePath}/events`,
85
+ );
86
+ const address = this.server?.address();
87
+ const host =
88
+ typeof this.options.host === "string" && this.options.host.trim()
89
+ ? this.options.host.trim()
90
+ : "127.0.0.1";
91
+ const port =
92
+ address && typeof address === "object" ? address.port : this.options.port;
93
+ const localHost =
94
+ host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
95
+ const localUrl =
96
+ Number.isInteger(port) && port >= 0
97
+ ? `http://${localHost}:${port}${basePath}`
98
+ : null;
99
+
100
+ return {
101
+ running: Boolean(this.server?.listening),
102
+ host,
103
+ port: Number.isInteger(port) ? port : null,
104
+ path: basePath,
105
+ webhookPath,
106
+ localUrl,
107
+ webhookUrl: buildUrl(this.options.publicUrl, webhookPath),
108
+ publicUrl: this.options.publicUrl || null,
109
+ tokenEnabled: Boolean(this.options.token),
110
+ };
111
+ }
112
+
113
+ async start() {
114
+ if (this.server?.listening) {
115
+ return this.info();
116
+ }
117
+ if (this.startPromise) {
118
+ return this.startPromise;
119
+ }
120
+
121
+ const port = Number(this.options.port);
122
+ if (!Number.isInteger(port) || port < 0) {
123
+ throw new Error("Local API requires a valid api.port");
124
+ }
125
+
126
+ const host =
127
+ typeof this.options.host === "string" && this.options.host.trim()
128
+ ? this.options.host.trim()
129
+ : "127.0.0.1";
130
+
131
+ this.server = http.createServer((req, res) => {
132
+ void this._handle(req, res);
133
+ });
134
+
135
+ this.startPromise = new Promise((resolve, reject) => {
136
+ const onError = (error) => {
137
+ this.server?.off("listening", onListening);
138
+ this.startPromise = null;
139
+ reject(error);
140
+ };
141
+ const onListening = () => {
142
+ this.server?.off("error", onError);
143
+ this.startPromise = null;
144
+ this.client.logger.info({
145
+ msg: "local_api_started",
146
+ ...this.info(),
147
+ });
148
+ resolve(this.info());
149
+ };
150
+
151
+ this.server.once("error", onError);
152
+ this.server.once("listening", onListening);
153
+ this.server.listen(port, host);
154
+ });
155
+
156
+ return this.startPromise;
157
+ }
158
+
159
+ stop() {
160
+ if (!this.server) return;
161
+ const server = this.server;
162
+ this.server = null;
163
+ this.startPromise = null;
164
+ server.close();
165
+ }
166
+
167
+ async _handle(req, res) {
168
+ const info = this.info();
169
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
170
+ const startedAt = Date.now();
171
+
172
+ try {
173
+ if (url.pathname === info.webhookPath && req.method === "POST") {
174
+ await this._handleWebhook(req, res);
175
+ return;
176
+ }
177
+
178
+ const routePath = getRoutePath(url, info.path);
179
+ if (routePath === null) {
180
+ this._recordRequest({
181
+ req,
182
+ url,
183
+ startedAt,
184
+ kind: "route",
185
+ status: 404,
186
+ note: "not_found",
187
+ });
188
+ sendJson(res, 404, { message: "Not found" });
189
+ return;
190
+ }
191
+
192
+ if (!this._isAuthorized(req)) {
193
+ this._recordRequest({
194
+ req,
195
+ url,
196
+ startedAt,
197
+ kind: "route",
198
+ status: 401,
199
+ note: "unauthorized",
200
+ });
201
+ sendJson(res, 401, { message: "Unauthorized" });
202
+ return;
203
+ }
204
+
205
+ if (req.method === "GET" && routePath === "") {
206
+ this._recordRequest({
207
+ req,
208
+ url,
209
+ startedAt,
210
+ kind: "route",
211
+ status: 200,
212
+ });
213
+ sendJson(res, 200, this.info());
214
+ return;
215
+ }
216
+
217
+ if (req.method === "GET" && routePath === "/health") {
218
+ this._recordRequest({
219
+ req,
220
+ url,
221
+ startedAt,
222
+ kind: "route",
223
+ status: 200,
224
+ });
225
+ sendJson(res, 200, {
226
+ ok: true,
227
+ polling: this.client.poller?.running === true,
228
+ disconnected: this.client.state?.disconnected === true,
229
+ });
230
+ return;
231
+ }
232
+
233
+ if (req.method === "GET" && routePath === "/server") {
234
+ const snapshot = await this.client.server.fetch(
235
+ this._getServerFlags(url.searchParams),
236
+ {
237
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
238
+ },
239
+ );
240
+ this._recordRequest({
241
+ req,
242
+ url,
243
+ startedAt,
244
+ kind: "route",
245
+ status: 200,
246
+ note: "server_snapshot",
247
+ });
248
+ sendJson(res, 200, snapshot);
249
+ return;
250
+ }
251
+
252
+ if (req.method === "GET" && routePath === "/players") {
253
+ const players = await this.client.players.list({
254
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
255
+ });
256
+ this._recordRequest({
257
+ req,
258
+ url,
259
+ startedAt,
260
+ kind: "route",
261
+ status: 200,
262
+ note: `players=${players.length}`,
263
+ });
264
+ sendJson(res, 200, {
265
+ count: players.length,
266
+ players,
267
+ });
268
+ return;
269
+ }
270
+
271
+ if (req.method === "GET" && routePath === "/vehicles") {
272
+ const vehicles = await this.client.vehicles.list({
273
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
274
+ });
275
+ const filtered = searchVehicles(vehicles, {
276
+ query: url.searchParams.get("query") || url.searchParams.get("search"),
277
+ plate: url.searchParams.get("plate"),
278
+ owner: url.searchParams.get("owner"),
279
+ name: url.searchParams.get("name"),
280
+ color: url.searchParams.get("color"),
281
+ texture: url.searchParams.get("texture"),
282
+ exact: parseBool(url.searchParams.get("exact")),
283
+ limit: url.searchParams.get("limit"),
284
+ });
285
+ this._recordRequest({
286
+ req,
287
+ url,
288
+ startedAt,
289
+ kind: "route",
290
+ status: 200,
291
+ note: `vehicles=${filtered.length}`,
292
+ });
293
+ sendJson(res, 200, {
294
+ count: filtered.length,
295
+ vehicles: filtered,
296
+ });
297
+ return;
298
+ }
299
+
300
+ if (req.method === "GET" && routePath.startsWith("/vehicles/")) {
301
+ const plate = decodeURIComponent(routePath.slice("/vehicles/".length));
302
+ const vehicle = await this.client.vehicles.findByPlate(plate, {
303
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
304
+ });
305
+ if (!vehicle) {
306
+ this._recordRequest({
307
+ req,
308
+ url,
309
+ startedAt,
310
+ kind: "route",
311
+ status: 404,
312
+ note: `plate=${plate}`,
313
+ });
314
+ sendJson(res, 404, { message: "Vehicle not found" });
315
+ return;
316
+ }
317
+ this._recordRequest({
318
+ req,
319
+ url,
320
+ startedAt,
321
+ kind: "route",
322
+ status: 200,
323
+ note: `plate=${plate}`,
324
+ });
325
+ sendJson(res, 200, vehicle);
326
+ return;
327
+ }
328
+
329
+ if (req.method === "GET" && routePath === "/emergency-calls") {
330
+ const calls = await this.client.logs.emergencyCalls({
331
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
332
+ });
333
+ this._recordRequest({
334
+ req,
335
+ url,
336
+ startedAt,
337
+ kind: "route",
338
+ status: 200,
339
+ note: `emergencyCalls=${calls.length}`,
340
+ });
341
+ sendJson(res, 200, {
342
+ count: calls.length,
343
+ emergencyCalls: calls,
344
+ });
345
+ return;
346
+ }
347
+
348
+ if (req.method === "POST" && routePath === "/command") {
349
+ const rawBody = await readBody(req);
350
+ const body = parseJson(rawBody);
351
+ const result = await this.client.commands.execute(body.command);
352
+ this._recordRequest({
353
+ req,
354
+ url,
355
+ startedAt,
356
+ kind: "route",
357
+ status: 200,
358
+ body,
359
+ note: "command",
360
+ });
361
+ sendJson(res, 200, result);
362
+ return;
363
+ }
364
+
365
+ this._recordRequest({
366
+ req,
367
+ url,
368
+ startedAt,
369
+ kind: "route",
370
+ status: 404,
371
+ note: "not_found",
372
+ });
373
+ sendJson(res, 404, { message: "Not found" });
374
+ } catch (error) {
375
+ const status = Number(error?.status) || 500;
376
+ this._recordRequest({
377
+ req,
378
+ url,
379
+ startedAt,
380
+ kind: "route",
381
+ status,
382
+ note: error?.message || "internal_error",
383
+ });
384
+ sendJson(res, status, {
385
+ message: error?.message || "Internal server error",
386
+ status,
387
+ code: error?.errorCode ?? null,
388
+ });
389
+ }
390
+ }
391
+
392
+ async _handleWebhook(req, res) {
393
+ const timestamp = getHeader(req, "x-signature-timestamp");
394
+ const signature = getHeader(req, "x-signature-ed25519");
395
+ const rawBody = await readBody(req);
396
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
397
+ const startedAt = Date.now();
398
+
399
+ if (!timestamp || !signature) {
400
+ this._recordRequest({
401
+ req,
402
+ url,
403
+ startedAt,
404
+ kind: "webhook",
405
+ status: 400,
406
+ note: "missing_signature_headers",
407
+ });
408
+ sendJson(res, 400, { message: "Missing signature headers" });
409
+ return;
410
+ }
411
+
412
+ if (!verifyWebhookSignature(timestamp, signature, rawBody)) {
413
+ this._recordRequest({
414
+ req,
415
+ url,
416
+ startedAt,
417
+ kind: "webhook",
418
+ status: 401,
419
+ note: "invalid_signature",
420
+ });
421
+ sendJson(res, 401, { message: "Invalid signature" });
422
+ return;
423
+ }
424
+
425
+ let body;
426
+ try {
427
+ body = parseJson(rawBody);
428
+ } catch {
429
+ this._recordRequest({
430
+ req,
431
+ url,
432
+ startedAt,
433
+ kind: "webhook",
434
+ status: 400,
435
+ note: "invalid_json",
436
+ });
437
+ sendJson(res, 400, { message: "Invalid JSON body" });
438
+ return;
439
+ }
440
+
441
+ const payload = normalizeWebhookPayload(body, {
442
+ timestamp,
443
+ signature,
444
+ rawBody,
445
+ headers: req.headers,
446
+ });
447
+
448
+ this._recordRequest({
449
+ req,
450
+ url,
451
+ startedAt,
452
+ kind: "webhook",
453
+ status: 204,
454
+ type: payload.type,
455
+ body,
456
+ note: "signature_valid",
457
+ });
458
458
 
459
459
  this.client.emit("webhook", payload);
460
- if (payload.type === "command") {
461
- this.client.emit("webhookCommand", payload);
462
- }
463
460
  if (payload.type === "emergencyCall") {
464
461
  this.client.emit("webhookEmergencyCall", payload);
465
462
  }
466
-
467
- sendEmpty(res, 204);
468
- }
469
-
470
- _recordRequest({
471
- req,
472
- url,
473
- startedAt,
474
- kind,
475
- status,
476
- type = null,
477
- body = null,
478
- note = "",
479
- }) {
480
- const payload = {
481
- method: req.method || "GET",
482
- path: url.pathname,
483
- query: Object.fromEntries(url.searchParams.entries()),
484
- kind,
485
- status,
486
- type,
487
- body: bodyPreview(body),
488
- note,
489
- tookMs: Date.now() - startedAt,
490
- ip:
491
- req.socket?.remoteAddress ||
492
- req.headers["x-forwarded-for"] ||
493
- null,
494
- at: new Date().toISOString(),
495
- };
496
-
497
- this.client.emit("apiRequest", payload);
498
-
499
- if (this.options.logRequests === false) return;
500
-
501
- const parts = [
502
- `[ERLC API] ${payload.method} ${payload.path}`,
503
- `status=${payload.status}`,
504
- `kind=${payload.kind}`,
505
- ];
506
- if (payload.type) parts.push(`type=${payload.type}`);
507
- if (payload.note) parts.push(`note=${payload.note}`);
508
- parts.push(`took=${payload.tookMs}ms`);
509
-
510
- console.log(parts.join(" | "));
511
- if (payload.body) {
512
- console.log(JSON.stringify(payload.body, null, 2));
513
- }
514
- }
515
-
516
- _isAuthorized(req) {
517
- const token = String(this.options.token || "").trim();
518
- if (!token) return true;
519
-
520
- const auth = getHeader(req, "authorization");
521
- if (auth === `Bearer ${token}`) return true;
522
-
523
- const alt = getHeader(req, "x-api-token");
524
- return alt === token;
525
- }
526
-
527
- _getServerFlags(searchParams) {
528
- const flags = {};
529
- for (const [key, apiKey] of Object.entries(QUERY_FLAG_MAP)) {
530
- if (parseBool(searchParams.get(key)) || parseBool(searchParams.get(apiKey))) {
531
- flags[key] = true;
532
- }
533
- }
534
- return flags;
535
- }
536
- }
537
-
538
- module.exports = LocalApiServer;
463
+
464
+ sendEmpty(res, 204);
465
+ }
466
+
467
+ _recordRequest({
468
+ req,
469
+ url,
470
+ startedAt,
471
+ kind,
472
+ status,
473
+ type = null,
474
+ body = null,
475
+ note = "",
476
+ }) {
477
+ const payload = {
478
+ method: req.method || "GET",
479
+ path: url.pathname,
480
+ query: Object.fromEntries(url.searchParams.entries()),
481
+ kind,
482
+ status,
483
+ type,
484
+ body: bodyPreview(body),
485
+ note,
486
+ tookMs: Date.now() - startedAt,
487
+ ip:
488
+ req.socket?.remoteAddress ||
489
+ req.headers["x-forwarded-for"] ||
490
+ null,
491
+ at: new Date().toISOString(),
492
+ };
493
+
494
+ this.client.emit("apiRequest", payload);
495
+
496
+ if (this.options.logRequests === false) return;
497
+
498
+ const parts = [
499
+ `[ERLC API] ${payload.method} ${payload.path}`,
500
+ `status=${payload.status}`,
501
+ `kind=${payload.kind}`,
502
+ ];
503
+ if (payload.type) parts.push(`type=${payload.type}`);
504
+ if (payload.note) parts.push(`note=${payload.note}`);
505
+ parts.push(`took=${payload.tookMs}ms`);
506
+
507
+ console.log(parts.join(" | "));
508
+ if (payload.body) {
509
+ console.log(JSON.stringify(payload.body, null, 2));
510
+ }
511
+ }
512
+
513
+ _isAuthorized(req) {
514
+ const token = String(this.options.token || "").trim();
515
+ if (!token) return true;
516
+
517
+ const auth = getHeader(req, "authorization");
518
+ if (auth === `Bearer ${token}`) return true;
519
+
520
+ const alt = getHeader(req, "x-api-token");
521
+ return alt === token;
522
+ }
523
+
524
+ _getServerFlags(searchParams) {
525
+ const flags = {};
526
+ for (const [key, apiKey] of Object.entries(QUERY_FLAG_MAP)) {
527
+ if (parseBool(searchParams.get(key)) || parseBool(searchParams.get(apiKey))) {
528
+ flags[key] = true;
529
+ }
530
+ }
531
+ return flags;
532
+ }
533
+ }
534
+
535
+ module.exports = LocalApiServer;