@alfe.ai/openclaw-chat 0.0.20 → 0.0.22
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/dist/plugin2.cjs +152 -8
- package/dist/plugin2.js +156 -9
- package/openclaw.plugin.json +4 -0
- package/package.json +1 -1
package/dist/plugin2.cjs
CHANGED
|
@@ -402,6 +402,107 @@ function buildA2ATools(chatClient, log) {
|
|
|
402
402
|
];
|
|
403
403
|
}
|
|
404
404
|
//#endregion
|
|
405
|
+
//#region src/attachment-url.ts
|
|
406
|
+
/**
|
|
407
|
+
* Attachment URL validation for the openclaw-chat plugin.
|
|
408
|
+
*
|
|
409
|
+
* This package runs inside user-installed OpenClaw agents and has no
|
|
410
|
+
* dependency on the internal @alfe/api-core SSRF validator, so the
|
|
411
|
+
* plumbing is inlined here. The rules are deliberately conservative:
|
|
412
|
+
*
|
|
413
|
+
* - `https:` only.
|
|
414
|
+
* - Userinfo (`https://u:p@host/...`) is rejected.
|
|
415
|
+
* - IP-literal hosts (v4 or bracketed v6) are rejected outright — only
|
|
416
|
+
* DNS names for known provider CDNs are acceptable.
|
|
417
|
+
* - The host must match a curated allowlist of Alfe-issued / channel-
|
|
418
|
+
* provider CDNs. Anything else is dropped before we fetch it.
|
|
419
|
+
*
|
|
420
|
+
* Operators running an agent in a private network that needs to dereference
|
|
421
|
+
* additional hosts can extend the list via the
|
|
422
|
+
* `ALFE_ATTACHMENT_ALLOWED_HOSTS` env var (comma-separated, entries prefixed
|
|
423
|
+
* with `.` match suffixes).
|
|
424
|
+
*/
|
|
425
|
+
const DEFAULT_EXACT_HOSTS = [
|
|
426
|
+
"s3.amazonaws.com",
|
|
427
|
+
"api.twilio.com",
|
|
428
|
+
"graph.microsoft.com",
|
|
429
|
+
"mmg.whatsapp.net"
|
|
430
|
+
];
|
|
431
|
+
const DEFAULT_SUFFIX_HOSTS = [
|
|
432
|
+
".s3.amazonaws.com",
|
|
433
|
+
".amazonaws.com",
|
|
434
|
+
".twiliocdn.com",
|
|
435
|
+
".cdn.discordapp.com",
|
|
436
|
+
".discordapp.net",
|
|
437
|
+
".telegram.org",
|
|
438
|
+
".alfe.ai"
|
|
439
|
+
];
|
|
440
|
+
function parseExtraHosts(raw) {
|
|
441
|
+
const exact = /* @__PURE__ */ new Set();
|
|
442
|
+
const suffix = [];
|
|
443
|
+
if (!raw) return {
|
|
444
|
+
exact,
|
|
445
|
+
suffix
|
|
446
|
+
};
|
|
447
|
+
for (const part of raw.split(",")) {
|
|
448
|
+
const trimmed = part.trim().toLowerCase();
|
|
449
|
+
if (!trimmed) continue;
|
|
450
|
+
if (trimmed.startsWith(".")) suffix.push(trimmed);
|
|
451
|
+
else exact.add(trimmed);
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
exact,
|
|
455
|
+
suffix
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function isIpLiteral(host) {
|
|
459
|
+
if (host.startsWith("[") && host.endsWith("]")) return true;
|
|
460
|
+
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
461
|
+
}
|
|
462
|
+
function validateAttachmentUrl(input, opts = {}) {
|
|
463
|
+
if (!input || input.trim() === "") return {
|
|
464
|
+
ok: false,
|
|
465
|
+
reason: "empty"
|
|
466
|
+
};
|
|
467
|
+
let url;
|
|
468
|
+
try {
|
|
469
|
+
url = new URL(input);
|
|
470
|
+
} catch {
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
reason: "invalid_url"
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (url.protocol !== "https:") return {
|
|
477
|
+
ok: false,
|
|
478
|
+
reason: "not_https"
|
|
479
|
+
};
|
|
480
|
+
if (url.username !== "" || url.password !== "") return {
|
|
481
|
+
ok: false,
|
|
482
|
+
reason: "userinfo_not_allowed"
|
|
483
|
+
};
|
|
484
|
+
const host = url.hostname.toLowerCase();
|
|
485
|
+
if (!host) return {
|
|
486
|
+
ok: false,
|
|
487
|
+
reason: "empty_host"
|
|
488
|
+
};
|
|
489
|
+
if (isIpLiteral(host)) return {
|
|
490
|
+
ok: false,
|
|
491
|
+
reason: "ip_literal"
|
|
492
|
+
};
|
|
493
|
+
if (host === "localhost" || host === "metadata" || host === "metadata.google.internal") return {
|
|
494
|
+
ok: false,
|
|
495
|
+
reason: "blocked_host"
|
|
496
|
+
};
|
|
497
|
+
const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
|
|
498
|
+
if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
|
|
499
|
+
if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
|
|
500
|
+
return {
|
|
501
|
+
ok: false,
|
|
502
|
+
reason: "host_not_in_allowlist"
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
//#endregion
|
|
405
506
|
//#region src/plugin.ts
|
|
406
507
|
/**
|
|
407
508
|
* @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
|
|
@@ -418,17 +519,29 @@ function buildA2ATools(chatClient, log) {
|
|
|
418
519
|
*/
|
|
419
520
|
let dispatchInbound = null;
|
|
420
521
|
/**
|
|
421
|
-
* Resolve OpenClaw SDK functions from the
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
522
|
+
* Resolve OpenClaw SDK functions from the running process.
|
|
523
|
+
*
|
|
524
|
+
* The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
|
|
525
|
+
* Since the plugin runs inside OpenClaw's process, we anchor resolution to
|
|
526
|
+
* OpenClaw's own entry module via require.main, then fall back to deriving
|
|
527
|
+
* the global modules path from process.execPath.
|
|
425
528
|
*/
|
|
426
529
|
function resolveOpenClawSdk(log) {
|
|
427
|
-
|
|
428
|
-
|
|
530
|
+
const anchors = [require.main?.filename, process.argv[1]].filter(Boolean);
|
|
531
|
+
for (const anchor of anchors) try {
|
|
532
|
+
const channelInbound = (0, node_module.createRequire)(anchor)("openclaw/plugin-sdk/channel-inbound");
|
|
533
|
+
if (channelInbound.dispatchInboundDirectDmWithRuntime) {
|
|
534
|
+
dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
|
|
535
|
+
log.info(`Resolved OpenClaw SDK from ${anchor}`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
} catch {}
|
|
539
|
+
try {
|
|
540
|
+
const derivedPath = (0, node_path.join)((0, node_path.resolve)((0, node_path.dirname)(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
|
|
541
|
+
const channelInbound = (0, node_module.createRequire)(derivedPath)("openclaw/plugin-sdk/channel-inbound");
|
|
429
542
|
if (channelInbound.dispatchInboundDirectDmWithRuntime) {
|
|
430
543
|
dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
|
|
431
|
-
log.info(`Resolved OpenClaw SDK from ${
|
|
544
|
+
log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
|
|
432
545
|
return;
|
|
433
546
|
}
|
|
434
547
|
} catch {}
|
|
@@ -440,6 +553,37 @@ let connectingPromise = null;
|
|
|
440
553
|
let metricsClient = null;
|
|
441
554
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
442
555
|
const DOWNLOAD_TIMEOUT_MS = 3e4;
|
|
556
|
+
const MAX_REDIRECTS = 5;
|
|
557
|
+
/**
|
|
558
|
+
* Fetch `url` with `redirect: "manual"` and revalidate every hop against the
|
|
559
|
+
* attachment allowlist. This defeats presigned-URL substitution attacks
|
|
560
|
+
* where an attacker points us at a trusted host that 302s to metadata
|
|
561
|
+
* or an internal service.
|
|
562
|
+
*/
|
|
563
|
+
async function fetchAttachmentWithValidation(url, signal) {
|
|
564
|
+
let currentUrl = url;
|
|
565
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
566
|
+
const verdict = validateAttachmentUrl(currentUrl);
|
|
567
|
+
if (!verdict.ok) throw new Error(`attachment_url_rejected:${verdict.reason ?? "unknown"}`);
|
|
568
|
+
const res = await fetch(currentUrl, {
|
|
569
|
+
redirect: "manual",
|
|
570
|
+
signal
|
|
571
|
+
});
|
|
572
|
+
const status = res.status;
|
|
573
|
+
if (status >= 300 && status < 400) {
|
|
574
|
+
const location = res.headers.get("location");
|
|
575
|
+
if (!location) throw new Error("redirect_without_location");
|
|
576
|
+
try {
|
|
577
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
578
|
+
} catch {
|
|
579
|
+
throw new Error("invalid_redirect_target");
|
|
580
|
+
}
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
return res;
|
|
584
|
+
}
|
|
585
|
+
throw new Error("too_many_redirects");
|
|
586
|
+
}
|
|
443
587
|
async function downloadAttachments(attachments, log) {
|
|
444
588
|
const attachDir = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "attachments");
|
|
445
589
|
await (0, node_fs_promises.mkdir)(attachDir, { recursive: true });
|
|
@@ -452,7 +596,7 @@ async function downloadAttachments(attachments, log) {
|
|
|
452
596
|
controller.abort();
|
|
453
597
|
}, DOWNLOAD_TIMEOUT_MS);
|
|
454
598
|
try {
|
|
455
|
-
const res = await
|
|
599
|
+
const res = await fetchAttachmentWithValidation(att.url, controller.signal);
|
|
456
600
|
if (!res.ok) {
|
|
457
601
|
log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
|
|
458
602
|
continue;
|
package/dist/plugin2.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
|
|
6
6
|
import { resolveConfig } from "@alfe.ai/config";
|
|
7
7
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
+
//#region \0rolldown/runtime.js
|
|
10
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
11
|
+
//#endregion
|
|
9
12
|
//#region src/alfe-channel.ts
|
|
10
13
|
const CHANNEL_ID = "alfe";
|
|
11
14
|
const DEFAULT_ACCOUNT_ID = "default";
|
|
@@ -402,6 +405,107 @@ function buildA2ATools(chatClient, log) {
|
|
|
402
405
|
];
|
|
403
406
|
}
|
|
404
407
|
//#endregion
|
|
408
|
+
//#region src/attachment-url.ts
|
|
409
|
+
/**
|
|
410
|
+
* Attachment URL validation for the openclaw-chat plugin.
|
|
411
|
+
*
|
|
412
|
+
* This package runs inside user-installed OpenClaw agents and has no
|
|
413
|
+
* dependency on the internal @alfe/api-core SSRF validator, so the
|
|
414
|
+
* plumbing is inlined here. The rules are deliberately conservative:
|
|
415
|
+
*
|
|
416
|
+
* - `https:` only.
|
|
417
|
+
* - Userinfo (`https://u:p@host/...`) is rejected.
|
|
418
|
+
* - IP-literal hosts (v4 or bracketed v6) are rejected outright — only
|
|
419
|
+
* DNS names for known provider CDNs are acceptable.
|
|
420
|
+
* - The host must match a curated allowlist of Alfe-issued / channel-
|
|
421
|
+
* provider CDNs. Anything else is dropped before we fetch it.
|
|
422
|
+
*
|
|
423
|
+
* Operators running an agent in a private network that needs to dereference
|
|
424
|
+
* additional hosts can extend the list via the
|
|
425
|
+
* `ALFE_ATTACHMENT_ALLOWED_HOSTS` env var (comma-separated, entries prefixed
|
|
426
|
+
* with `.` match suffixes).
|
|
427
|
+
*/
|
|
428
|
+
const DEFAULT_EXACT_HOSTS = [
|
|
429
|
+
"s3.amazonaws.com",
|
|
430
|
+
"api.twilio.com",
|
|
431
|
+
"graph.microsoft.com",
|
|
432
|
+
"mmg.whatsapp.net"
|
|
433
|
+
];
|
|
434
|
+
const DEFAULT_SUFFIX_HOSTS = [
|
|
435
|
+
".s3.amazonaws.com",
|
|
436
|
+
".amazonaws.com",
|
|
437
|
+
".twiliocdn.com",
|
|
438
|
+
".cdn.discordapp.com",
|
|
439
|
+
".discordapp.net",
|
|
440
|
+
".telegram.org",
|
|
441
|
+
".alfe.ai"
|
|
442
|
+
];
|
|
443
|
+
function parseExtraHosts(raw) {
|
|
444
|
+
const exact = /* @__PURE__ */ new Set();
|
|
445
|
+
const suffix = [];
|
|
446
|
+
if (!raw) return {
|
|
447
|
+
exact,
|
|
448
|
+
suffix
|
|
449
|
+
};
|
|
450
|
+
for (const part of raw.split(",")) {
|
|
451
|
+
const trimmed = part.trim().toLowerCase();
|
|
452
|
+
if (!trimmed) continue;
|
|
453
|
+
if (trimmed.startsWith(".")) suffix.push(trimmed);
|
|
454
|
+
else exact.add(trimmed);
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
exact,
|
|
458
|
+
suffix
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function isIpLiteral(host) {
|
|
462
|
+
if (host.startsWith("[") && host.endsWith("]")) return true;
|
|
463
|
+
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
464
|
+
}
|
|
465
|
+
function validateAttachmentUrl(input, opts = {}) {
|
|
466
|
+
if (!input || input.trim() === "") return {
|
|
467
|
+
ok: false,
|
|
468
|
+
reason: "empty"
|
|
469
|
+
};
|
|
470
|
+
let url;
|
|
471
|
+
try {
|
|
472
|
+
url = new URL(input);
|
|
473
|
+
} catch {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
reason: "invalid_url"
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
if (url.protocol !== "https:") return {
|
|
480
|
+
ok: false,
|
|
481
|
+
reason: "not_https"
|
|
482
|
+
};
|
|
483
|
+
if (url.username !== "" || url.password !== "") return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: "userinfo_not_allowed"
|
|
486
|
+
};
|
|
487
|
+
const host = url.hostname.toLowerCase();
|
|
488
|
+
if (!host) return {
|
|
489
|
+
ok: false,
|
|
490
|
+
reason: "empty_host"
|
|
491
|
+
};
|
|
492
|
+
if (isIpLiteral(host)) return {
|
|
493
|
+
ok: false,
|
|
494
|
+
reason: "ip_literal"
|
|
495
|
+
};
|
|
496
|
+
if (host === "localhost" || host === "metadata" || host === "metadata.google.internal") return {
|
|
497
|
+
ok: false,
|
|
498
|
+
reason: "blocked_host"
|
|
499
|
+
};
|
|
500
|
+
const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
|
|
501
|
+
if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
|
|
502
|
+
if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
reason: "host_not_in_allowlist"
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
//#endregion
|
|
405
509
|
//#region src/plugin.ts
|
|
406
510
|
/**
|
|
407
511
|
* @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
|
|
@@ -418,17 +522,29 @@ function buildA2ATools(chatClient, log) {
|
|
|
418
522
|
*/
|
|
419
523
|
let dispatchInbound = null;
|
|
420
524
|
/**
|
|
421
|
-
* Resolve OpenClaw SDK functions from the
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
525
|
+
* Resolve OpenClaw SDK functions from the running process.
|
|
526
|
+
*
|
|
527
|
+
* The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
|
|
528
|
+
* Since the plugin runs inside OpenClaw's process, we anchor resolution to
|
|
529
|
+
* OpenClaw's own entry module via require.main, then fall back to deriving
|
|
530
|
+
* the global modules path from process.execPath.
|
|
425
531
|
*/
|
|
426
532
|
function resolveOpenClawSdk(log) {
|
|
427
|
-
|
|
428
|
-
|
|
533
|
+
const anchors = [__require.main?.filename, process.argv[1]].filter(Boolean);
|
|
534
|
+
for (const anchor of anchors) try {
|
|
535
|
+
const channelInbound = createRequire(anchor)("openclaw/plugin-sdk/channel-inbound");
|
|
536
|
+
if (channelInbound.dispatchInboundDirectDmWithRuntime) {
|
|
537
|
+
dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
|
|
538
|
+
log.info(`Resolved OpenClaw SDK from ${anchor}`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
} catch {}
|
|
542
|
+
try {
|
|
543
|
+
const derivedPath = join(resolve(dirname(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
|
|
544
|
+
const channelInbound = createRequire(derivedPath)("openclaw/plugin-sdk/channel-inbound");
|
|
429
545
|
if (channelInbound.dispatchInboundDirectDmWithRuntime) {
|
|
430
546
|
dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
|
|
431
|
-
log.info(`Resolved OpenClaw SDK from ${
|
|
547
|
+
log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
|
|
432
548
|
return;
|
|
433
549
|
}
|
|
434
550
|
} catch {}
|
|
@@ -440,6 +556,37 @@ let connectingPromise = null;
|
|
|
440
556
|
let metricsClient = null;
|
|
441
557
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
442
558
|
const DOWNLOAD_TIMEOUT_MS = 3e4;
|
|
559
|
+
const MAX_REDIRECTS = 5;
|
|
560
|
+
/**
|
|
561
|
+
* Fetch `url` with `redirect: "manual"` and revalidate every hop against the
|
|
562
|
+
* attachment allowlist. This defeats presigned-URL substitution attacks
|
|
563
|
+
* where an attacker points us at a trusted host that 302s to metadata
|
|
564
|
+
* or an internal service.
|
|
565
|
+
*/
|
|
566
|
+
async function fetchAttachmentWithValidation(url, signal) {
|
|
567
|
+
let currentUrl = url;
|
|
568
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
569
|
+
const verdict = validateAttachmentUrl(currentUrl);
|
|
570
|
+
if (!verdict.ok) throw new Error(`attachment_url_rejected:${verdict.reason ?? "unknown"}`);
|
|
571
|
+
const res = await fetch(currentUrl, {
|
|
572
|
+
redirect: "manual",
|
|
573
|
+
signal
|
|
574
|
+
});
|
|
575
|
+
const status = res.status;
|
|
576
|
+
if (status >= 300 && status < 400) {
|
|
577
|
+
const location = res.headers.get("location");
|
|
578
|
+
if (!location) throw new Error("redirect_without_location");
|
|
579
|
+
try {
|
|
580
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
581
|
+
} catch {
|
|
582
|
+
throw new Error("invalid_redirect_target");
|
|
583
|
+
}
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
return res;
|
|
587
|
+
}
|
|
588
|
+
throw new Error("too_many_redirects");
|
|
589
|
+
}
|
|
443
590
|
async function downloadAttachments(attachments, log) {
|
|
444
591
|
const attachDir = join(homedir(), ".alfe", "attachments");
|
|
445
592
|
await mkdir(attachDir, { recursive: true });
|
|
@@ -452,7 +599,7 @@ async function downloadAttachments(attachments, log) {
|
|
|
452
599
|
controller.abort();
|
|
453
600
|
}, DOWNLOAD_TIMEOUT_MS);
|
|
454
601
|
try {
|
|
455
|
-
const res = await
|
|
602
|
+
const res = await fetchAttachmentWithValidation(att.url, controller.signal);
|
|
456
603
|
if (!res.ok) {
|
|
457
604
|
log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
|
|
458
605
|
continue;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "@alfe.ai/openclaw-chat",
|
|
3
|
+
"name": "Chat Plugin",
|
|
4
|
+
"version": "0.0.21",
|
|
5
|
+
"description": "Chat conversation channel — web widget and mobile app share unified chat sessions",
|
|
6
|
+
"entry": "./dist/plugin.js",
|
|
3
7
|
"configSchema": {
|
|
4
8
|
"type": "object",
|
|
5
9
|
"additionalProperties": false,
|