feat: use customer video key for demo access

This commit is contained in:
xuejianwu
2026-07-30 09:12:42 +08:00
parent fa1e8627e1
commit 1db9cc1f90
6 changed files with 144 additions and 40 deletions

View File

@@ -1,5 +1,5 @@
# 快快 AI Hub 统一 Key素材库与 Seedance 2.0 均使用 Bearer 鉴权
KK_API_KEY=
# 服务端统一使用的素材库 Token不会下发到浏览器
KK_ASSET_TOKEN=
# 1 = 使用 Mock 数据评审流程0 = 调用真实接口
DEMO_MOCK=1

View File

@@ -14,7 +14,7 @@
## 已串联的流程
1. 输入数字人正面、侧面、背面三张公网图片 URL。
2. 逐张调用 `POST /v1/assets/upload`,以统一 API Key 提交三张素材审核。
2. 逐张调用 `POST /v1/assets/upload`,以服务端素材库 Token 提交三张素材审核。
3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至三张全部 `ready` 或出现 `failed`
4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。
5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`
@@ -37,7 +37,7 @@
## 1020 人试用的部署边界
单实例 Node 服务可用于 1020 人低频试用,但上线必须配置 HTTPS、访问鉴权/限流、进程守护和持久日志目录。所有用户共享同一个服务商 Key需要另行确认上游 QPS、同时生成任务数和额度`assetReviewTasks` 当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。
单实例 Node 服务可用于 1020 人低频试用,但上线必须配置 HTTPS、限流、进程守护和持久日志目录。素材库 Token 由服务端统一配置;每位客户首次访问时输入自己的视频 Key视频生成费用归属该 Key`assetReviewTasks` 与视频任务 Key 映射当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。
页面会分别展示正面、侧面、背面的审核状态与失败原因。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选齐人物素材时不会解锁视频生成。
@@ -45,7 +45,7 @@
生产页支持文本、图片和视频三类输入。人物图片与场景、道具等其他参考图片共同占用单次最多 9 张图片额度;参考视频单独计数,当前 Demo 最多 3 条。最终请求按“文本 → 人物图片 → 其他参考图片 → 参考视频”的顺序写入 v3 `content` 数组,页面会同步生成图号和角色映射。
所有第三方 token 只放在本地服务端环境变量中,不进入浏览器,也不会写入 Git。
素材库 Token 只放在服务端环境变量中,不进入浏览器,也不会写入 Git。客户视频 Key 保存在客户当前浏览器的 `localStorage`,并随视频请求提交给本 Demo 服务端;服务端只在内存中建立任务与 Key 的临时映射,不写入日志或 Git。清理网站数据或更换浏览器后需重新输入。
## 本地运行
@@ -58,10 +58,10 @@ npm start
请优先通过这个地址访问,不要把 `public/index.html` 当成普通文档直接打开。页面现已兼容直接打开时的样式加载,但接口交互仍依赖本地服务。
默认 `.env.example` 使用 Mock 模式,可以先完整评审交互。连接真实接口时设置:
默认 `.env.example` 使用 Mock 模式,可以先完整评审交互。连接真实接口时设置服务端素材库 Token视频 Key 由客户在页面首次访问时输入
```bash
KK_API_KEY=sk-统一接口Key
KK_ASSET_TOKEN=素材库Token
DEMO_MOCK=0
```
@@ -78,25 +78,26 @@ npm start
上线前请完成以下配置:
1. 在服务端填写 `KK_API_KEY`,并设置 `DEMO_MOCK=0`;不要把 `.env.local` 提交到 Git。
1. 在服务端填写 `KK_ASSET_TOKEN`,并设置 `DEMO_MOCK=0`;不要把 `.env.local` 提交到 Git。视频 Key 不配置在服务器,由客户在页面输入。
2. 设置 `HOST=0.0.0.0` 和实际监听端口,由 Nginx/网关反向代理并提供 HTTPS。
3.`AUDIT_LOG_DIR` 指向持久化磁盘,设置随机的 `AUDIT_HASH_SALT`,同时配置日志轮转与清理周期。
4. 使用 systemd、PM2 或容器编排守护 Node 进程,异常退出后自动重启。
5. 在入口层增加客户访问鉴权、请求频率限制与并发生成限制;服务商 Key 仅保留在服务端。
6. 发布后访问 `/api/config` 检查 `mockMode=false`素材与视频能力均为可用,再分别跑一条素材审核和视频生成链路。
5. 在入口层增加请求频率限制与并发生成限制;素材库 Token 仅保留在服务端。
6. 发布后访问页面输入客户视频 Key检查素材与视频能力均为可用,再分别跑一条素材审核和视频生成链路。
当前素材审核任务状态保存在单进程内存中,首次客户交付建议只运行一个应用实例。若需要多实例扩容,应先把任务状态迁移到 Redis 或数据库。
## 当前还缺的真实联调材料
- **快快 AI Hub 统一 Key**:形如 `sk-...`,同时用于新版素材库与 Seedance 2.0
- **快快素材库 Token**:由服务端统一配置,用于素材上传、审核与历史素材查询
- **客户视频 Key**:形如 `sk-...`,由客户首次打开页面时输入,用于视频提交和结果查询。
- **数字人三视图的公网 URL**:正面、侧面、背面各一张;接口不支持直接上传本地文件或 Base64URL 需带 `.png``.jpg` 等扩展名。
- **客户希望验证的第一段分镜**人物动作、台词、场景、时长、横竖屏及是否需要同步声音。Demo 已提供一段可替换示例。
- **视频任务成功响应**:新版接口在 `content.video_url` 返回临时签名视频地址。
## 接口边界
- 素材审核与视频生成统一使用 `https://ai-api.kkidc.com` 和同一个 Bearer Key。
- 素材审核与视频生成统一请求 `https://ai-api.kkidc.com`,但分别使用服务端素材库 Token 与客户视频 Key。
- 新版素材库只在列表中返回 `ready` 素材;`pending` / `failed` 通过单素材接口查询。
- 视频生成应长期保存 `asset://mat_*`,不要依赖临时签名视频地址。
- 参考图/参考视频/首帧/首尾帧属于不同互斥调用方式;本 Demo 采用“多模态参考图”方式。

View File

@@ -14,6 +14,7 @@ const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup";
const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1";
const CHARACTER_LIBRARY_KEY = "yingxiangli.characterLibrary.v1";
const DRAMA_PROJECT_KEY = "yingxiangli.dramaProject.v1";
const VIDEO_KEY_STORAGE = "yingxiangli.videoKey.v1";
const AUDIT_CLIENT_KEY = "yingxiangli.auditClientId.v1";
const AUDIT_SESSION_KEY = "yingxiangli.auditSessionId.v1";
const VIEW_LABELS = ["正面", "侧面", "背面"];
@@ -53,6 +54,7 @@ const el = Object.fromEntries([
"referenceVideoInputs", "addExtraImageButton", "addReferenceVideoButton", "inputQuota",
"addShotButton", "shotList", "timelineSummary", "shotProgressBar", "continuityMode", "versionCount",
"versionList", "assemblyPanel", "assemblyList", "assemblyHint", "closeAssemblyButton",
"videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton",
].map((id) => [id, document.getElementById(id)]));
const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack];
@@ -63,6 +65,7 @@ const auditInputTimers = new Map();
let auditFlushTimer = null;
setupClientAudit();
setupVideoKeyGate();
init();
document.querySelectorAll(".module-tab").forEach((button) => {
@@ -76,12 +79,13 @@ async function init() {
restoreCharacterLibrary();
restoreDramaProject();
renderConnection();
if (!state.config.mockMode && !currentVideoKey()) showVideoKeyGate(false);
await loadAssets();
loadActiveShotIntoForm();
renderScene();
renderStoryboard();
resumePendingReview();
resumePendingVideo();
if (state.config.mockMode || currentVideoKey()) resumePendingVideo();
} catch (error) {
showToast(error.message, true);
el.connectionState.textContent = "本地服务连接失败";
@@ -89,6 +93,42 @@ async function init() {
}
}
function setupVideoKeyGate() {
el.videoKeyForm.addEventListener("submit", (event) => {
event.preventDefault();
const key = el.videoKeyInput.value.trim();
if (!/^sk-[A-Za-z0-9_-]{8,}$/.test(key)) {
el.videoKeyError.textContent = "请输入正确格式的生视频 Keysk-...";
el.videoKeyError.classList.remove("hidden");
return;
}
localStorage.setItem(VIDEO_KEY_STORAGE, key);
logClientOperation("video_key.saved", { keySuffix: key.slice(-4), keyLength: key.length });
location.reload();
});
el.changeVideoKeyButton.addEventListener("click", () => showVideoKeyGate(true));
el.cancelVideoKeyButton.addEventListener("click", hideVideoKeyGate);
}
function currentVideoKey() {
try { return localStorage.getItem(VIDEO_KEY_STORAGE)?.trim() || ""; }
catch { return ""; }
}
function showVideoKeyGate(canCancel = Boolean(currentVideoKey())) {
el.videoKeyInput.value = "";
el.videoKeyError.textContent = "";
el.videoKeyError.classList.add("hidden");
el.cancelVideoKeyButton.classList.toggle("hidden", !canCancel);
el.videoKeyGate.classList.remove("hidden");
requestAnimationFrame(() => el.videoKeyInput.focus());
}
function hideVideoKeyGate() {
if (!currentVideoKey() && !state.config?.mockMode) return;
el.videoKeyGate.classList.add("hidden");
}
el.addRoleButton.addEventListener("click", () => {
if (state.roles.length >= 3) return showToast("当前 Demo 最多支持三个场景角色,人物图片总数不超过 9 张", true);
const role = createRole(`角色 ${ROLE_LETTERS[state.roles.length]}`);
@@ -1403,6 +1443,9 @@ async function resumePendingVideo() {
}
function renderConnection() {
const videoKey = currentVideoKey();
el.changeVideoKeyButton.classList.toggle("hidden", state.config.mockMode || !videoKey);
if (videoKey) el.changeVideoKeyButton.textContent = `更换视频 Key · ${videoKey.slice(-4)}`;
if (state.config.mockMode) {
el.connectionState.textContent = "Mock 模式 · 可评审完整流程";
el.connectionState.className = "connection mock";
@@ -1414,7 +1457,7 @@ function renderConnection() {
el.reviewButton.textContent = "提交真实素材审核";
el.mockHint.classList.add("hidden");
} else if (state.config.assetReady) {
el.connectionState.textContent = "素材审核 Live · 视频 Key 待配置";
el.connectionState.textContent = "请输入视频 Key";
el.connectionState.className = "connection live";
el.reviewButton.textContent = "提交真实素材审核";
el.mockHint.classList.add("hidden");
@@ -1663,11 +1706,15 @@ async function api(path, options = {}) {
const startedAt = Date.now();
const method = options.method || "GET";
const shouldLog = shouldLogClientApi(path);
const videoKey = currentVideoKey();
const videoHeaders = videoKey && (/^\/api\/videos(?:\/|$)/.test(path) || path === "/api/config")
? { "X-Video-Key": videoKey }
: {};
if (shouldLog) logClientOperation("api.request", { method, path });
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId, ...(options.headers || {}) },
headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId, ...videoHeaders, ...(options.headers || {}) },
});
const result = await response.json().catch(() => ({ error: "服务返回了无法解析的内容" }));
if (shouldLog || !response.ok) {
@@ -1683,6 +1730,10 @@ async function api(path, options = {}) {
const error = new Error(extractApiError(result) || `请求失败(${response.status}`);
error.payload = result;
error.status = response.status;
if (response.status === 401 && /^\/api\/videos(?:\/|$)/.test(path)) {
localStorage.removeItem(VIDEO_KEY_STORAGE);
showVideoKeyGate(false);
}
throw error;
}
return result;

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>营响力AI 短剧数字人 Demo</title>
<link rel="stylesheet" href="./styles.css?v=21" />
<link rel="stylesheet" href="./styles.css?v=22" />
</head>
<body data-active-module="library">
<header class="topbar">
@@ -12,7 +12,10 @@
<span class="eyebrow">营响力 · 真实客户验证</span>
<h1>AI 短剧数字人生产</h1>
</div>
<div class="topbar-actions">
<button class="key-change-button hidden" type="button" id="changeVideoKeyButton">更换视频 Key</button>
<div class="connection" id="connectionState">正在检查接口配置…</div>
</div>
</header>
<main>
@@ -262,7 +265,24 @@
</div>
</main>
<div class="key-gate hidden" id="videoKeyGate">
<form class="key-gate-card" id="videoKeyForm">
<span class="section-kicker">首次使用</span>
<h2>输入生视频 Key</h2>
<p>该 Key 用于本次客户的视频生成与费用统计,仅保存在当前浏览器。</p>
<label class="field">
<span>生视频 Key</span>
<input id="videoKeyInput" type="password" autocomplete="off" spellcheck="false" placeholder="sk-..." required />
</label>
<p class="key-gate-error hidden" id="videoKeyError"></p>
<div class="key-gate-actions">
<button class="secondary hidden" type="button" id="cancelVideoKeyButton">取消</button>
<button class="primary" type="submit">进入使用</button>
</div>
</form>
</div>
<div class="toast hidden" id="toast"></div>
<script src="./app.js?v=25"></script>
<script src="./app.js?v=26"></script>
</body>
</html>

View File

@@ -38,6 +38,17 @@ button { cursor: pointer; }
.connection.live { background: rgba(15,159,110,.2); color: #91ebca; }
.connection.mock { background: rgba(255,183,77,.15); color: #ffd28a; }
.connection.blocked { background: rgba(239,68,68,.16); color: #ffb1b1; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.key-change-button { padding: 7px 10px; border: 1px solid rgba(255,255,255,.22); border-radius: 8px; background: transparent; color: #dce5ff; font-size: 12px; }
.key-change-button:hover { background: rgba(255,255,255,.08); }
.key-gate { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 20px; background: rgba(9,18,38,.72); backdrop-filter: blur(5px); }
.key-gate-card { width: min(440px, 100%); padding: 28px; border-radius: 18px; background: white; box-shadow: 0 24px 80px rgba(5,14,34,.28); }
.key-gate-card h2 { margin: 7px 0 8px; font-size: 24px; }
.key-gate-card > p { margin: 0 0 20px; color: var(--muted); font-size: 13px; line-height: 1.6; }
.key-gate-card .field { margin-top: 4px; }
.key-gate-error { margin: 9px 0 0 !important; color: #c03945 !important; }
.key-gate-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
main { max-width: 1480px; margin: 0 auto; padding: 24px; }
.panel, .sidebar { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
@@ -255,6 +266,8 @@ hr { border: 0; border-top: 1px solid var(--line); margin: 26px 0 0; }
@media (max-width: 680px) {
.topbar { align-items: flex-start; padding: 16px; }
.topbar-actions { align-items: flex-end; flex-direction: column-reverse; }
.key-change-button { white-space: nowrap; }
main { padding: 14px; }
.panel { padding: 18px; }
.module-tabs { grid-template-columns: 1fr; }

View File

@@ -21,14 +21,15 @@ loadLocalEnv();
const port = Number(process.env.PORT || 4173);
const host = process.env.HOST || "127.0.0.1";
const apiToken = process.env.KK_API_KEY || process.env.KK_VIDEO_TOKEN || "";
const mockMode = process.env.DEMO_MOCK === "1" || !apiToken;
const assetToken = process.env.KK_ASSET_TOKEN || process.env.KK_API_KEY || "";
const mockMode = process.env.DEMO_MOCK === "1";
const auditLogDir = process.env.AUDIT_LOG_DIR || join(root, "logs");
const auditHashSalt = process.env.AUDIT_HASH_SALT || "yingxiangli-demo-audit";
const mockAssets = [];
const mockVideos = new Map();
const assetReviewTasks = new Map();
const videoTaskTokens = new Map();
const server = createServer((request, response) => {
const startedAt = Date.now();
@@ -115,12 +116,13 @@ async function handleApi(request, response, url) {
}
if (request.method === "GET" && url.pathname === "/api/config") {
const videoToken = videoTokenFromRequest(request);
sendJson(response, 200, {
mockMode,
assetReady: mockMode || Boolean(apiToken),
videoReady: mockMode || Boolean(apiToken),
assetReady: mockMode || Boolean(assetToken),
videoReady: mockMode || Boolean(videoToken),
apiVersion: "v3",
missing: !mockMode && !apiToken ? ["KK_API_KEY"] : [],
missing: !mockMode && !assetToken ? ["KK_ASSET_TOKEN"] : [],
});
return;
}
@@ -174,13 +176,13 @@ async function handleApi(request, response, url) {
return;
}
requireToken(apiToken, "KK_API_KEY");
requireToken(assetToken, "KK_ASSET_TOKEN");
const labels = { front: "正面", side: "侧面", back: "背面" };
const uploaded = await Promise.all(imageInputs.map(async (input, index) => {
const viewLabel = labels[input.view] || `参考图${index + 1}`;
const upstream = await callJson("https://ai-api.kkidc.com/v1/assets/upload", {
method: "POST",
headers: bearerHeaders(),
headers: bearerHeaders(assetToken),
body: JSON.stringify({
url: input.url,
type: "image",
@@ -208,7 +210,7 @@ async function handleApi(request, response, url) {
}
if (request.method === "GET" && url.pathname.startsWith("/api/assets/tasks/")) {
requireToken(apiToken, "KK_API_KEY");
requireToken(assetToken, "KK_ASSET_TOKEN");
const taskId = decodeURIComponent(url.pathname.split("/").pop());
const task = assetReviewTasks.get(taskId);
if (!task) throw new ClientError("素材审核任务不存在或本地服务已重启,请在素材库刷新状态", 404);
@@ -227,12 +229,12 @@ async function handleApi(request, response, url) {
});
return;
}
requireToken(apiToken, "KK_API_KEY");
requireToken(assetToken, "KK_ASSET_TOKEN");
const upstreamUrl = new URL("https://ai-api.kkidc.com/v1/assets");
for (const key of ["page", "page_size", "purpose", "sort_order", "group_id"]) {
for (const value of url.searchParams.getAll(key)) upstreamUrl.searchParams.append(key, value);
}
const upstream = await callJson(upstreamUrl, { headers: bearerHeaders(false) });
const upstream = await callJson(upstreamUrl, { headers: bearerHeaders(assetToken, false) });
sendJson(response, upstream.status, upstream.body, upstream.trackId);
return;
}
@@ -257,19 +259,21 @@ async function handleApi(request, response, url) {
return;
}
requireToken(apiToken, "KK_API_KEY");
const videoToken = requireVideoToken(request);
const upstream = await callJson("https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks", {
method: "POST",
headers: bearerHeaders(),
headers: bearerHeaders(videoToken),
body: JSON.stringify(payload),
});
const taskId = extractVideoTaskId(upstream.body);
if (taskId) videoTaskTokens.set(taskId, videoToken);
sendJson(response, upstream.status, upstream.body, upstream.trackId);
return;
}
const videoFileMatch = request.method === "GET" && url.pathname.match(/^\/api\/videos\/([^/]+)\/video\.mp4$/);
if (videoFileMatch) {
await streamVideoTask(response, decodeURIComponent(videoFileMatch[1]));
await streamVideoTask(request, response, decodeURIComponent(videoFileMatch[1]));
return;
}
@@ -292,9 +296,10 @@ async function handleApi(request, response, url) {
return;
}
requireToken(apiToken, "KK_API_KEY");
const videoToken = requireVideoToken(request, taskId);
videoTaskTokens.set(taskId, videoToken);
const upstream = await callJson(`https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
headers: bearerHeaders(false),
headers: bearerHeaders(videoToken, false),
});
const normalized = {
...upstream.body,
@@ -311,15 +316,15 @@ async function handleApi(request, response, url) {
sendJson(response, 404, { error: "接口不存在" });
}
async function streamVideoTask(response, taskId) {
async function streamVideoTask(request, response, taskId) {
if (mockMode) throw new ClientError("Mock 视频没有可下载的真实文件", 404);
requireToken(apiToken, "KK_API_KEY");
const videoToken = requireVideoToken(request, taskId);
const startedAt = Date.now();
const context = requestContext.getStore() || {};
writeAuditLog({ level: "info", event: "video.proxy.start", requestId: context.requestId, sessionId: context.sessionId, taskId });
const task = await callJson(`https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
headers: bearerHeaders(false),
headers: bearerHeaders(videoToken, false),
});
if (task.status < 200 || task.status >= 300) {
throw new ClientError(task.body?.message || "无法读取视频任务", task.status);
@@ -495,6 +500,20 @@ function requireToken(value, name) {
if (!value) throw new ClientError(`服务端缺少 ${name} 配置`, 503);
}
function videoTokenFromRequest(request, taskId = "") {
const supplied = String(request.headers["x-video-key"] || "").trim();
if (supplied) return supplied.slice(0, 500);
if (taskId && videoTaskTokens.has(taskId)) return videoTaskTokens.get(taskId);
return "";
}
function requireVideoToken(request, taskId = "") {
const token = videoTokenFromRequest(request, taskId);
if (!token) throw new ClientError("请先输入生视频 Key", 401);
if (!/^sk-[A-Za-z0-9_-]{8,}$/.test(token)) throw new ClientError("生视频 Key 格式不正确", 401);
return token;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
@@ -568,9 +587,9 @@ function resolveVideoModel(value = "") {
return "doubao-seedance-2-0-260128";
}
function bearerHeaders(includeContentType = true) {
function bearerHeaders(token, includeContentType = true) {
return {
Authorization: `Bearer ${apiToken}`,
Authorization: `Bearer ${token}`,
...(includeContentType ? { "Content-Type": "application/json" } : {}),
};
}
@@ -586,7 +605,7 @@ async function refreshAssetReviewTask(task) {
if (["ready", "failed"].includes(item.status)) return;
const upstreamUrl = new URL(`https://ai-api.kkidc.com/v1/assets/${encodeURIComponent(item.assetId)}`);
if (item.groupId) upstreamUrl.searchParams.set("group_id", item.groupId);
const upstream = await callJson(upstreamUrl, { headers: bearerHeaders(false) });
const upstream = await callJson(upstreamUrl, { headers: bearerHeaders(assetToken, false) });
if (upstream.status >= 400 || upstream.body?.success === false) {
item.status = "failed";
item.errorMessage = extractUpstreamMessage(upstream.body) || "素材状态查询失败";
@@ -714,7 +733,7 @@ function sendJson(response, status, body, trackId = "") {
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, X-Session-Id",
"Access-Control-Allow-Headers": "Content-Type, X-Session-Id, X-Video-Key",
"Access-Control-Expose-Headers": "X-Request-Id",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Private-Network": "true",