@sleep2agi/commhub-server 0.9.0-preview.22 → 0.9.0-preview.23
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 +1 -1
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/db.ts +32 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +349 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/server.ts +199 -10
- package/src/tools.ts +18 -2
package/src/server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { db, logTaskEvent, logAudit } from "./db.js";
|
|
|
6
6
|
import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats } from "./push.js";
|
|
7
7
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
8
8
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
9
|
+
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
9
10
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
10
11
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
11
12
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
@@ -213,6 +214,45 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
|
|
|
213
214
|
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
// #495 — shared authorization gate for /api/files/:file_id downloads.
|
|
218
|
+
// Called from the GET handler and any future HEAD/Range/dashboard-proxy
|
|
219
|
+
// entry so the allow-list can't drift between methods (通信龙 clause 2:
|
|
220
|
+
// "授权若只在 GET 分支实现, HEAD 或 Range 走别路径 = 等于没修").
|
|
221
|
+
//
|
|
222
|
+
// Returns true if the caller may read this entry; false otherwise. The
|
|
223
|
+
// caller emits 404 on false — never 403; 403 would leak that the
|
|
224
|
+
// file_id exists and enables enumeration.
|
|
225
|
+
//
|
|
226
|
+
// Allow-list (staged P0 hotfix; same-network non-owner is NOT included
|
|
227
|
+
// here — see follow-up #503):
|
|
228
|
+
// - Legacy master AUTH_TOKEN (RFC-001 read-only) → allow.
|
|
229
|
+
// - admin utok_ → allow (mirrors requireAdminAuth).
|
|
230
|
+
// - Owner match: caller's resolved user_id === entry.owner_id.
|
|
231
|
+
// - null owner_id + DEV_OPEN → allow. DEV_OPEN uploads carry
|
|
232
|
+
// owner_id=null (authCtx=null in DEV_OPEN mode); rejecting them
|
|
233
|
+
// would break the intended local-dev anonymous flow. Encoded as
|
|
234
|
+
// an explicit env-gated branch so if DEV_OPEN semantics change,
|
|
235
|
+
// the code fails loudly instead of trusting a stale note.
|
|
236
|
+
// - Everything else → deny (404). New uploads in production always
|
|
237
|
+
// carry a truthy owner_id (requireAuth blocks the null-owner-
|
|
238
|
+
// producing path when DEV_OPEN=off and no legacy master token is
|
|
239
|
+
// configured), so denying null-owner in production closes the
|
|
240
|
+
// enumeration vector for legacy blobs without breaking new uploads.
|
|
241
|
+
export function authorizeFileDownload(req: Request, entry: { owner_id?: unknown }): boolean {
|
|
242
|
+
const token = requestToken(req);
|
|
243
|
+
const authCtx = resolveRequestAuth(req);
|
|
244
|
+
const resolved = token ? resolveToken(token) : null;
|
|
245
|
+
const isLegacyMasterCaller = !authCtx && isLegacyAuthToken(req);
|
|
246
|
+
const isAdminCaller = !!resolved && resolved.user.role === "admin";
|
|
247
|
+
const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
|
|
248
|
+
? entry.owner_id
|
|
249
|
+
: null;
|
|
250
|
+
const isOwnerCaller = !!authCtx && !!ownerId && authCtx.userId === ownerId;
|
|
251
|
+
if (isLegacyMasterCaller || isAdminCaller || isOwnerCaller) return true;
|
|
252
|
+
if (ownerId === null && DEV_OPEN) return true;
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
216
256
|
type RestNetworkScope = {
|
|
217
257
|
networkId: string | null;
|
|
218
258
|
networkIds: string[] | null;
|
|
@@ -1619,14 +1659,27 @@ return Bun.serve({
|
|
|
1619
1659
|
}));
|
|
1620
1660
|
}
|
|
1621
1661
|
|
|
1622
|
-
// ── REST: file download (#221) ──
|
|
1623
|
-
// GET /api/files/:file_id with Bearer auth
|
|
1624
|
-
// Content-Disposition: attachment +
|
|
1625
|
-
// so the served file can never be
|
|
1626
|
-
// The file_id is validated against
|
|
1627
|
-
// generation time before any
|
|
1662
|
+
// ── REST: file download (#221 + #495 + #500 CR2 HEAD) ──
|
|
1663
|
+
// GET or HEAD /api/files/:file_id with Bearer auth + ownership
|
|
1664
|
+
// check. Always forces Content-Disposition: attachment +
|
|
1665
|
+
// X-Content-Type-Options: nosniff so the served file can never be
|
|
1666
|
+
// executed or rendered as HTML. The file_id is validated against
|
|
1667
|
+
// the same strict regex used at generation time before any
|
|
1668
|
+
// filesystem access.
|
|
1669
|
+
//
|
|
1670
|
+
// Ownership gate is factored into `authorizeFileDownload` (see
|
|
1671
|
+
// src/server.ts near resolveRequestAuth). HEAD MUST route through
|
|
1672
|
+
// the same helper — pre-#500-CR2, HEAD fell through to a fallback
|
|
1673
|
+
// 200 page whose body happened to be byte-identical to the
|
|
1674
|
+
// "unknown file" case, so no enumeration oracle formed in practice.
|
|
1675
|
+
// That was coincidence, not a gate. A change to the fallback (e.g.
|
|
1676
|
+
// adding "Requested resource: <hint>") would materialize the oracle
|
|
1677
|
+
// with zero failing tests. Any future Range, conditional-request,
|
|
1678
|
+
// or dashboard-proxy path for /api/files MUST route through this
|
|
1679
|
+
// handler (via authorizeFileDownload) — do not duplicate the
|
|
1680
|
+
// allow-list inline, or the branches will drift.
|
|
1628
1681
|
const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
|
|
1629
|
-
if (fileMatch && req.method === "GET") {
|
|
1682
|
+
if (fileMatch && (req.method === "GET" || req.method === "HEAD")) {
|
|
1630
1683
|
const authErr = requireAuth(req);
|
|
1631
1684
|
if (authErr) return withCors(req, authErr);
|
|
1632
1685
|
|
|
@@ -1648,6 +1701,15 @@ return Bun.serve({
|
|
|
1648
1701
|
return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
|
|
1649
1702
|
}
|
|
1650
1703
|
|
|
1704
|
+
// #495 ownership gate — placed AFTER file-exists so denied
|
|
1705
|
+
// callers cannot distinguish "you don't own this" from
|
|
1706
|
+
// "no such file". Both branches return the same 404 shape
|
|
1707
|
+
// for BOTH GET and HEAD (HEAD honours body-omission but the
|
|
1708
|
+
// status code and headers are the authoritative signal).
|
|
1709
|
+
if (!authorizeFileDownload(req, entry)) {
|
|
1710
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1651
1713
|
let storage;
|
|
1652
1714
|
try {
|
|
1653
1715
|
storage = buildStoragePath(entry.file_id, entry.ext);
|
|
@@ -1675,7 +1737,6 @@ return Bun.serve({
|
|
|
1675
1737
|
const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
|
|
1676
1738
|
? entry.name
|
|
1677
1739
|
: `${fileId}${entry.ext}`;
|
|
1678
|
-
const blob = Bun.file(storage.absolutePath);
|
|
1679
1740
|
const responseHeaders: Record<string, string> = {
|
|
1680
1741
|
...corsHeaders(req),
|
|
1681
1742
|
"Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
|
|
@@ -1684,6 +1745,15 @@ return Bun.serve({
|
|
|
1684
1745
|
"X-Content-Type-Options": "nosniff",
|
|
1685
1746
|
"Cache-Control": "private, no-store",
|
|
1686
1747
|
};
|
|
1748
|
+
// HEAD: return the same status and headers as GET, but no body,
|
|
1749
|
+
// per RFC 9110 §9.3.2. This ensures HEAD passes through the same
|
|
1750
|
+
// authorization helper — a cross-owner HEAD is 404 (same as GET),
|
|
1751
|
+
// not a fallback 200. Status/headers are the authoritative
|
|
1752
|
+
// signal; body omission is HTTP-standard for HEAD.
|
|
1753
|
+
if (req.method === "HEAD") {
|
|
1754
|
+
return new Response(null, { status: 200, headers: responseHeaders });
|
|
1755
|
+
}
|
|
1756
|
+
const blob = Bun.file(storage.absolutePath);
|
|
1687
1757
|
return new Response(blob, { status: 200, headers: responseHeaders });
|
|
1688
1758
|
}
|
|
1689
1759
|
|
|
@@ -2129,6 +2199,115 @@ return Bun.serve({
|
|
|
2129
2199
|
// legacy master token). Value passes validateAvatarUrl (http/https
|
|
2130
2200
|
// only, ≤ 2048 chars, no control chars — XSS boundary, see
|
|
2131
2201
|
// avatar-validate.ts).
|
|
2202
|
+
// ── REST: node DISPLAY attributes (display_name / team / tags) ──
|
|
2203
|
+
// Companion to the avatar route below: hub-side presentation fields that
|
|
2204
|
+
// agent-node does not consume, so they deliberately bypass the RFC-024
|
|
2205
|
+
// config-apply pipeline (no doorbell, no ack, no config_revision bump).
|
|
2206
|
+
//
|
|
2207
|
+
// Unlike avatar, these are multi-field and multi-editor, so they carry
|
|
2208
|
+
// their OWN optimistic lock (`attrs_revision`). A stale base revision is
|
|
2209
|
+
// a 409 with the current value, mirroring update_node_config's contract
|
|
2210
|
+
// so the dashboard can reuse its conflict-resolution UX.
|
|
2211
|
+
//
|
|
2212
|
+
// NOT editable here: `alias` (message-routing key) and `node_name`
|
|
2213
|
+
// (addressing key). Renaming those must go through the rename 2PC at
|
|
2214
|
+
// /api/node-rename/* which cascades sessions + api_tokens + SSE cleanup;
|
|
2215
|
+
// a single-table UPDATE would strand the node (see #146).
|
|
2216
|
+
const nodeAttrsMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/attrs$/);
|
|
2217
|
+
if (nodeAttrsMatch && req.method === "PUT") {
|
|
2218
|
+
let attrsBody: any;
|
|
2219
|
+
try {
|
|
2220
|
+
attrsBody = await req.json();
|
|
2221
|
+
} catch {
|
|
2222
|
+
return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
2223
|
+
}
|
|
2224
|
+
const ref = decodeURIComponent(nodeAttrsMatch[1]);
|
|
2225
|
+
const params: any[] = [ref, ref, ref];
|
|
2226
|
+
let sql = "SELECT node_id, alias, network_id, display_name, team, tags, attrs_revision FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
|
|
2227
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2228
|
+
sql += " ORDER BY updated_at DESC LIMIT 1";
|
|
2229
|
+
const node = db.get<any>(sql, ...params);
|
|
2230
|
+
if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
|
|
2231
|
+
|
|
2232
|
+
const nodeNetId = node.network_id ?? singleNetworkId(restScope);
|
|
2233
|
+
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
2234
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// Optimistic lock BEFORE any narrowing work, so a losing writer never
|
|
2238
|
+
// has side effects.
|
|
2239
|
+
const currentRev = Number(node.attrs_revision ?? 0);
|
|
2240
|
+
const baseRev = attrsBody?.base_attrs_revision;
|
|
2241
|
+
if (typeof baseRev !== "number" || !Number.isInteger(baseRev) || baseRev < 0) {
|
|
2242
|
+
return withCors(req, Response.json({
|
|
2243
|
+
ok: false, error: "base_attrs_revision required (integer >= 0)",
|
|
2244
|
+
}, { status: 400 }));
|
|
2245
|
+
}
|
|
2246
|
+
if (baseRev !== currentRev) {
|
|
2247
|
+
return withCors(req, Response.json({
|
|
2248
|
+
ok: false,
|
|
2249
|
+
error: "attrs_revision_conflict",
|
|
2250
|
+
current_attrs_revision: currentRev,
|
|
2251
|
+
message: "another editor changed this node's attributes; re-read and retry",
|
|
2252
|
+
}, { status: 409 }));
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// Scalars reject on wrong type (never coerce); tags drop junk per item.
|
|
2256
|
+
const nextDisplay = "display_name" in attrsBody
|
|
2257
|
+
? validateScalarAttr(attrsBody.display_name, "display_name")
|
|
2258
|
+
: null;
|
|
2259
|
+
if (nextDisplay && !nextDisplay.ok) {
|
|
2260
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_display_name", reason: nextDisplay.reason }, { status: 400 }));
|
|
2261
|
+
}
|
|
2262
|
+
const nextTeam = "team" in attrsBody ? validateScalarAttr(attrsBody.team, "team") : null;
|
|
2263
|
+
if (nextTeam && !nextTeam.ok) {
|
|
2264
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_team", reason: nextTeam.reason }, { status: 400 }));
|
|
2265
|
+
}
|
|
2266
|
+
const nextTags = "tags" in attrsBody ? narrowTags(attrsBody.tags) : null;
|
|
2267
|
+
|
|
2268
|
+
// Only the supplied fields move; the rest keep their stored value.
|
|
2269
|
+
const sets: string[] = [];
|
|
2270
|
+
const vals: any[] = [];
|
|
2271
|
+
if (nextDisplay) { sets.push(`display_name = ?${vals.length + 1}`); vals.push(nextDisplay.value); }
|
|
2272
|
+
if (nextTeam) { sets.push(`team = ?${vals.length + 1}`); vals.push(nextTeam.value); }
|
|
2273
|
+
if (nextTags) { sets.push(`tags = ?${vals.length + 1}`); vals.push(JSON.stringify(nextTags)); }
|
|
2274
|
+
// A no-op patch still bumps the revision: the caller observed a state
|
|
2275
|
+
// and asserted it, so honouring the CAS keeps client counters in step.
|
|
2276
|
+
sets.push(`attrs_revision = ?${vals.length + 1}`); vals.push(currentRev + 1);
|
|
2277
|
+
sets.push(`updated_at = datetime('now')`);
|
|
2278
|
+
vals.push(node.node_id);
|
|
2279
|
+
// Guard the UPDATE with the same revision so two concurrent writers
|
|
2280
|
+
// that both passed the read above cannot both land.
|
|
2281
|
+
vals.push(currentRev);
|
|
2282
|
+
const res = db.run(
|
|
2283
|
+
`UPDATE nodes SET ${sets.join(", ")} WHERE node_id = ?${vals.length - 1} AND attrs_revision = ?${vals.length}`,
|
|
2284
|
+
vals,
|
|
2285
|
+
);
|
|
2286
|
+
if (!res.changes) {
|
|
2287
|
+
const fresh = db.get<any>("SELECT attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
|
|
2288
|
+
return withCors(req, Response.json({
|
|
2289
|
+
ok: false,
|
|
2290
|
+
error: "attrs_revision_conflict",
|
|
2291
|
+
current_attrs_revision: Number(fresh?.attrs_revision ?? currentRev),
|
|
2292
|
+
message: "another editor changed this node's attributes; re-read and retry",
|
|
2293
|
+
}, { status: 409 }));
|
|
2294
|
+
}
|
|
2295
|
+
logAudit(restAuth?.userId ?? null, restAuth?.username ?? null, "node_attrs_updated", "node", node.node_id,
|
|
2296
|
+
JSON.stringify({ display_name: nextDisplay?.value ?? undefined, team: nextTeam?.value ?? undefined, tags: nextTags ?? undefined }),
|
|
2297
|
+
undefined, nodeNetId ?? undefined);
|
|
2298
|
+
const after = db.get<any>(
|
|
2299
|
+
"SELECT display_name, team, tags, attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
|
|
2300
|
+
return withCors(req, Response.json({
|
|
2301
|
+
ok: true,
|
|
2302
|
+
node_id: node.node_id,
|
|
2303
|
+
alias: node.alias,
|
|
2304
|
+
display_name: after?.display_name ?? null,
|
|
2305
|
+
team: after?.team ?? null,
|
|
2306
|
+
tags: parseStoredTags(after?.tags),
|
|
2307
|
+
attrs_revision: Number(after?.attrs_revision ?? currentRev + 1),
|
|
2308
|
+
}));
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2132
2311
|
const nodeAvatarMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/avatar$/);
|
|
2133
2312
|
if (nodeAvatarMatch && req.method === "PUT") {
|
|
2134
2313
|
let avatarBody: any;
|
|
@@ -2315,7 +2494,8 @@ return Bun.serve({
|
|
|
2315
2494
|
let sql = `SELECT node_id, node_name, alias, runtime, model,
|
|
2316
2495
|
config_path, channels, server, hostname,
|
|
2317
2496
|
network_id, created_at, updated_at,
|
|
2318
|
-
config_snapshot, lifecycle_state, avatar_url
|
|
2497
|
+
config_snapshot, lifecycle_state, avatar_url,
|
|
2498
|
+
display_name, team, tags, attrs_revision
|
|
2319
2499
|
FROM nodes WHERE 1=1`;
|
|
2320
2500
|
const params: any[] = [];
|
|
2321
2501
|
sql = addNetworkScope(sql, params, restScope);
|
|
@@ -2337,7 +2517,16 @@ return Bun.serve({
|
|
|
2337
2517
|
catch { /* malformed snapshot — leave role null */ }
|
|
2338
2518
|
}
|
|
2339
2519
|
const { config_snapshot, ...rest } = r;
|
|
2340
|
-
|
|
2520
|
+
// `tags` is stored as JSON text; hand the dashboard a real array so
|
|
2521
|
+
// it can map() without a guard, and normalise legacy/absent values
|
|
2522
|
+
// to [] rather than null. attrs_revision is normalised to a number
|
|
2523
|
+
// so pre-migration rows (NULL) read as 0.
|
|
2524
|
+
return {
|
|
2525
|
+
...rest,
|
|
2526
|
+
tags: parseStoredTags(r.tags),
|
|
2527
|
+
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
2528
|
+
role,
|
|
2529
|
+
};
|
|
2341
2530
|
});
|
|
2342
2531
|
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
2343
2532
|
}
|
package/src/tools.ts
CHANGED
|
@@ -1058,7 +1058,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1058
1058
|
// ── V2: send_reply (关联 task_id,不触发 think) ──
|
|
1059
1059
|
server.tool(
|
|
1060
1060
|
"send_reply",
|
|
1061
|
-
"Send a reply to a task.
|
|
1061
|
+
"Send a reply to a Dashboard/UI-originated task (updates task row, emits new_reply SSE — Dashboard reads it). ⚠ NOT for agent-to-agent replies: agent nodes only log new_reply and do not processInbox on it, so the peer will not see the reply in real time. Use send_task for peer-to-peer replies (RFC-030, Vincent 2026-07-28 全网规则). If the target alias resolves to a live agent node, the response includes a `warning` field pointing to send_task.",
|
|
1062
1062
|
{
|
|
1063
1063
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
1064
1064
|
text: z.string().min(1).max(10000).describe("Reply content"),
|
|
@@ -1191,10 +1191,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1191
1191
|
// #461 network observer summary — ids + routing only, no reply text.
|
|
1192
1192
|
pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: alias, status: replyStatus });
|
|
1193
1193
|
|
|
1194
|
+
// #498 — When the reply target is a live agent node (not Dashboard/UI),
|
|
1195
|
+
// warn the caller that agent peers only log new_reply and do not
|
|
1196
|
+
// processInbox on it. Points at the correct next action (send_task)
|
|
1197
|
+
// so a follow-up turn corrects course rather than "why didn't they
|
|
1198
|
+
// respond" spelunking. Absent field when target is Dashboard/hub/api
|
|
1199
|
+
// (the correct path for commhub_reply) — silence is affirmation.
|
|
1200
|
+
const targetIsAgent = replyTargetNodeId !== null && alias !== "hub" && alias !== "api";
|
|
1201
|
+
const warning = targetIsAgent
|
|
1202
|
+
? `Target "${alias}" is an agent node. commhub_reply/send_reply does NOT wake agent peers (they only log new_reply, they do not processInbox on it) — the peer will not see this reply in real time. For agent-to-agent replies, use commhub_send_task(alias="${alias}", task="<your reply>") so the peer wakes via new_task SSE and processes it. (RFC-030, Vincent 2026-07-28 全网规则.)`
|
|
1203
|
+
: undefined;
|
|
1204
|
+
|
|
1194
1205
|
return {
|
|
1195
1206
|
content: [{
|
|
1196
1207
|
type: "text" as const,
|
|
1197
|
-
text: JSON.stringify({
|
|
1208
|
+
text: JSON.stringify({
|
|
1209
|
+
ok: true,
|
|
1210
|
+
message_id: id,
|
|
1211
|
+
session_status: session?.status ?? "unknown",
|
|
1212
|
+
...(warning ? { warning } : {}),
|
|
1213
|
+
}),
|
|
1198
1214
|
}],
|
|
1199
1215
|
};
|
|
1200
1216
|
}
|