@sleep2agi/commhub-server 0.9.0-preview.14 â 0.9.0-preview.140
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/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-department-http.test.ts +148 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +423 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +604 -51
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/chain-attempt-dedup.test.ts +447 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +49 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +199 -7
- package/src/create-node-validate.ts +232 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +88 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +109 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +1111 -42
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +276 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/env-transport-gate-http.test.ts +146 -0
- package/src/env-transport-gate.ts +74 -0
- package/src/env-transport-policy.ts +21 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/host-supervisor-identity-http.test.ts +152 -0
- package/src/host-supervisor-identity.ts +92 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-catalog-read-http.test.ts +187 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/human-inbox.ts +42 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/list-host-supervisors.test.ts +5 -4
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +1083 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +135 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-secret-rest-http.test.ts +190 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-identity.ts +35 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +442 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/password-network-tokens.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +38 -10
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +184 -0
- package/src/report-status-secret-keys.test.ts +140 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +131 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +357 -0
- package/src/requirements-stats.ts +195 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +130 -0
- package/src/rest-identity.ts +141 -0
- package/src/rest-projections.ts +70 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-backoff-http.test.ts +193 -0
- package/src/scheduled-backoff.ts +64 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-outcome-guidance-http.test.ts +139 -0
- package/src/scheduled-outcome-guidance.ts +9 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-requirement-wake-http.test.ts +264 -0
- package/src/scheduled-requirement-wake.ts +125 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +312 -0
- package/src/scheduled-stuck.ts +186 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +819 -0
- package/src/scheduled-work-outcome-http.test.ts +154 -0
- package/src/scheduled-work-outcome.ts +50 -0
- package/src/scoped-secrets.ts +139 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5398 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -2
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-activity.ts +60 -0
- package/src/task-auth-origin-http.test.ts +93 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +4008 -355
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// #1510 â the secret-transport gate, shipped as log-only first.
|
|
2
|
+
//
|
|
3
|
+
// The load-bearing property is symmetric: with the gate in log mode a write to a
|
|
4
|
+
// `plain` node MUST still succeed (nobody's workflow breaks on upgrade) but MUST
|
|
5
|
+
// leave a hit behind, and with `enforce` the same write MUST be refused. A
|
|
6
|
+
// half-implemented gate that logs but also refuses would break every plain-HTTP
|
|
7
|
+
// node on the next deploy; one that refuses but never logs leaves nothing to
|
|
8
|
+
// decide with.
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-transport-gate-"));
|
|
15
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
16
|
+
|
|
17
|
+
let net = "", ownerId = "", ownerToken = "";
|
|
18
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
19
|
+
let base = "";
|
|
20
|
+
const PLAIN = "n_gate_plain";
|
|
21
|
+
const TLS = "n_gate_tls";
|
|
22
|
+
|
|
23
|
+
const post = async (path: string, token: string, body: unknown) => {
|
|
24
|
+
const r = await fetch(`${base}${path}`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
return { status: r.status, body: await r.json() as any };
|
|
30
|
+
};
|
|
31
|
+
const get = async (path: string, token: string) => {
|
|
32
|
+
const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
33
|
+
return { status: r.status, body: await r.json() as any };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
const { db } = await import("./db.js");
|
|
38
|
+
const { register } = await import("./auth.js");
|
|
39
|
+
const stamp = Date.now();
|
|
40
|
+
const owner = register(`gate_owner_${stamp}`, "GateOwner-Strong-1!");
|
|
41
|
+
expect(owner.ok).toBe(true);
|
|
42
|
+
ownerToken = owner.token!;
|
|
43
|
+
net = owner.network_id!;
|
|
44
|
+
ownerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `gate_owner_${stamp}`)!.user_id;
|
|
45
|
+
|
|
46
|
+
const seed = (nodeId: string, transport: string) => db.run(
|
|
47
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, lifecycle_state, config_snapshot)
|
|
48
|
+
VALUES (?1, ?1, ?1, ?2, 'active', ?3)`,
|
|
49
|
+
[nodeId, net, JSON.stringify({ node_secrets_capable: true })],
|
|
50
|
+
);
|
|
51
|
+
seed(PLAIN, "plain");
|
|
52
|
+
seed(TLS, "https");
|
|
53
|
+
// The transport is reported on the session row (that is where report_status
|
|
54
|
+
// writes it).
|
|
55
|
+
const session = (nodeId: string, transport: string) => db.run(
|
|
56
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, env_transport, updated_at, last_seen_at)
|
|
57
|
+
VALUES (?1, ?1, 'idle', ?1, ?2, ?3, datetime('now'), datetime('now'))`,
|
|
58
|
+
[nodeId, net, transport],
|
|
59
|
+
);
|
|
60
|
+
session(PLAIN, "plain");
|
|
61
|
+
session(TLS, "https");
|
|
62
|
+
|
|
63
|
+
const mod = await import("./server.js");
|
|
64
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
65
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
try { server?.stop(true); } catch {}
|
|
70
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
// The lane allows one pending request per node; drain whatever the previous
|
|
75
|
+
// case left so the next one starts clean.
|
|
76
|
+
const { pullNodeSecrets, ackNodeSecrets } = await import("./scoped-secrets.js");
|
|
77
|
+
for (const nodeId of [PLAIN, TLS]) {
|
|
78
|
+
const daemon = { ok: true as const, networkId: net, daemonNodeId: nodeId, daemonAlias: nodeId };
|
|
79
|
+
const pulled = pullNodeSecrets(daemon) as any;
|
|
80
|
+
for (const r of (pulled?.requests ?? [])) ackNodeSecrets(daemon, r.request_id, null, true);
|
|
81
|
+
}
|
|
82
|
+
process.env.ANET_SECRET_TRANSPORT_GATE = "log";
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("log mode (default)", () => {
|
|
86
|
+
test("a put to a plain node still succeeds AND leaves a hit", async () => {
|
|
87
|
+
const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "A_KEY", value: "v", revision: 1 });
|
|
88
|
+
expect(r.status).toBe(200);
|
|
89
|
+
expect(r.body.ok).toBe(true); // not refused: that is the whole point of log mode
|
|
90
|
+
|
|
91
|
+
const log = await get(`/api/networks/${net}/env-transport-log`, ownerToken);
|
|
92
|
+
expect(log.status).toBe(200);
|
|
93
|
+
expect(log.body.total).toBe(1);
|
|
94
|
+
expect(log.body.rows[0]).toMatchObject({ node_id: PLAIN, route: "node_secret_put", hits: 1 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a put to a TLS node leaves no hit", async () => {
|
|
98
|
+
const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
99
|
+
const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "B_KEY", value: "v", revision: 1 });
|
|
100
|
+
expect(r.status).toBe(200);
|
|
101
|
+
const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
102
|
+
expect(after).toBe(before);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("list is not observed (it carries no secret)", async () => {
|
|
106
|
+
const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
107
|
+
await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "list" });
|
|
108
|
+
const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
109
|
+
expect(after).toBe(before);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("enforce mode", () => {
|
|
114
|
+
test("the same put is refused, and nothing is queued", async () => {
|
|
115
|
+
process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
|
|
116
|
+
const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "C_KEY", value: "v", revision: 1 });
|
|
117
|
+
expect(r.status).toBe(400);
|
|
118
|
+
expect(r.body.error).toBe("secret_transport_plain");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a TLS node is unaffected", async () => {
|
|
122
|
+
process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
|
|
123
|
+
const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "D_KEY", value: "v", revision: 1 });
|
|
124
|
+
expect(r.status).toBe(200);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("off mode", () => {
|
|
129
|
+
test("records nothing at all", async () => {
|
|
130
|
+
process.env.ANET_SECRET_TRANSPORT_GATE = "off";
|
|
131
|
+
const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
132
|
+
const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "E_KEY", value: "v", revision: 1 });
|
|
133
|
+
expect(r.status).toBe(200);
|
|
134
|
+
const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
135
|
+
expect(after).toBe(before);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("the log endpoint is owner/admin only", () => {
|
|
140
|
+
test("an unrelated user gets 403", async () => {
|
|
141
|
+
const { register } = await import("./auth.js");
|
|
142
|
+
const other = register(`gate_other_${Date.now()}`, "GateOther-Strong-1!");
|
|
143
|
+
const r = await get(`/api/networks/${net}/env-transport-log`, other.token!);
|
|
144
|
+
expect(r.status).toBe(403);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// #1510 (log-only) â record the secret writes that a future transport gate would
|
|
2
|
+
// have refused, without refusing any of them.
|
|
3
|
+
//
|
|
4
|
+
// Why log-only first: refusing makes an existing workflow fail on upgrade, and
|
|
5
|
+
// the nodes that talk plain HTTP today are exactly the ones we cannot see from
|
|
6
|
+
// the Hub. This table answers "how many writes would have been blocked, on which
|
|
7
|
+
// node" *before* anyone decides to turn the gate on â the same rollout shape as
|
|
8
|
+
// #519's stale-open sweep and #525's human_only log mode.
|
|
9
|
+
//
|
|
10
|
+
// Hourly aggregation, like node_permission_log: a chatty node must not turn this
|
|
11
|
+
// into one row per write.
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import { getEnvTransportMode } from "./env-transport-policy.js";
|
|
14
|
+
|
|
15
|
+
const HOURS_RETAINED = 30 * 24;
|
|
16
|
+
const MAX_ROWS = 20_000;
|
|
17
|
+
|
|
18
|
+
function hourOf(now = new Date()): string {
|
|
19
|
+
return now.toISOString().slice(0, 13);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Record one would-refuse hit. Never throws: logging must not break a write. */
|
|
23
|
+
export function noteEnvTransportWouldRefuse(networkId: string, nodeId: string, route: string, sample?: string): void {
|
|
24
|
+
try {
|
|
25
|
+
const now = new Date();
|
|
26
|
+
if (Math.random() < 0.02) {
|
|
27
|
+
db.run("DELETE FROM env_transport_would_refuse WHERE hour < ?1",
|
|
28
|
+
[hourOf(new Date(now.getTime() - HOURS_RETAINED * 86_400_000))]);
|
|
29
|
+
}
|
|
30
|
+
const count = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM env_transport_would_refuse")?.n ?? 0);
|
|
31
|
+
const hour = hourOf(now);
|
|
32
|
+
const s = sample ? sample.slice(0, 120) : null;
|
|
33
|
+
if (count >= MAX_ROWS) {
|
|
34
|
+
db.run(
|
|
35
|
+
"UPDATE env_transport_would_refuse SET hits = hits + 1, sample = COALESCE(sample, ?5) WHERE network_id = ?1 AND node_id = ?2 AND route = ?3 AND hour = ?4",
|
|
36
|
+
[networkId, nodeId, route, hour, s],
|
|
37
|
+
);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO env_transport_would_refuse (network_id, node_id, route, hour, hits, sample) VALUES (?1, ?2, ?3, ?4, 1, ?5)
|
|
42
|
+
ON CONFLICT(network_id, node_id, route, hour) DO UPDATE SET hits = env_transport_would_refuse.hits + 1`,
|
|
43
|
+
[networkId, nodeId, route, hour, s],
|
|
44
|
+
);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error(`[env-transport] would-refuse log failed: ${(e as Error).message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Where a secret write is aimed: the node's own row, or a daemon's. Returns the
|
|
51
|
+
* transport the node last reported, or null when it is unknown. */
|
|
52
|
+
export function transportOf(networkId: string, nodeId: string): string | null {
|
|
53
|
+
const row = db.get<{ env_transport: string | null }>(
|
|
54
|
+
"SELECT env_transport FROM sessions WHERE network_id = ?1 AND (node_id = ?2 OR alias = ?2) ORDER BY updated_at DESC LIMIT 1",
|
|
55
|
+
[networkId, nodeId],
|
|
56
|
+
);
|
|
57
|
+
return row?.env_transport ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Called from the write lanes. Logs when the current policy would refuse. */
|
|
61
|
+
export function observeSecretWrite(networkId: string, nodeId: string, route: string): void {
|
|
62
|
+
if (getEnvTransportMode() !== "log") return;
|
|
63
|
+
const transport = transportOf(networkId, nodeId);
|
|
64
|
+
if (transport !== "plain") return;
|
|
65
|
+
noteEnvTransportWouldRefuse(networkId, nodeId, route, `transport=${transport}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function readEnvTransportLog(networkId: string, sinceHour: string) {
|
|
69
|
+
return db.all<{ node_id: string; route: string; hour: string; hits: number | string; sample: string | null }>(
|
|
70
|
+
`SELECT node_id, route, hour, hits, sample FROM env_transport_would_refuse
|
|
71
|
+
WHERE network_id = ?1 AND hour >= ?2 ORDER BY hour DESC, node_id, route`,
|
|
72
|
+
[networkId, sinceHour],
|
|
73
|
+
).map(r => ({ ...r, hits: Number(r.hits) }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// #1510 â the switch for the secret-transport gate.
|
|
2
|
+
//
|
|
3
|
+
// (unset) / "log" â record what a gate WOULD refuse; refuse nothing.
|
|
4
|
+
// "enforce" â refuse secret writes to nodes that report `plain`.
|
|
5
|
+
// "off" â do nothing at all (no recording either).
|
|
6
|
+
//
|
|
7
|
+
// Reading the env on every call (like the stale-open sweep) means an operator can
|
|
8
|
+
// change the mode without a code change and tests can flip it per case.
|
|
9
|
+
export type EnvTransportMode = "off" | "log" | "enforce";
|
|
10
|
+
|
|
11
|
+
export function getEnvTransportMode(env: Record<string, string | undefined> = process.env): EnvTransportMode {
|
|
12
|
+
const raw = (env.ANET_SECRET_TRANSPORT_GATE ?? "").trim().toLowerCase();
|
|
13
|
+
if (raw === "off") return "off";
|
|
14
|
+
if (raw === "enforce") return "enforce";
|
|
15
|
+
return "log";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** True when a write to a node reporting `plain` must be refused. */
|
|
19
|
+
export function refusesPlainTransport(transport: string | null, env: Record<string, string | undefined> = process.env): boolean {
|
|
20
|
+
return getEnvTransportMode(env) === "enforce" && transport === "plain";
|
|
21
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildStoragePath,
|
|
4
|
+
pathForExistingBlob,
|
|
5
|
+
validateIndexEntry,
|
|
6
|
+
} from "./uploads.js";
|
|
7
|
+
|
|
8
|
+
const fileId = "0123456789abcdef0123456789abcdef";
|
|
9
|
+
const dateBucket = "2026-08-09";
|
|
10
|
+
const uploadsRoot = "/srv/uploads";
|
|
11
|
+
|
|
12
|
+
const malicious = [
|
|
13
|
+
"../x",
|
|
14
|
+
"/abs/x",
|
|
15
|
+
".png/../../y",
|
|
16
|
+
".p ng",
|
|
17
|
+
".p\0ng",
|
|
18
|
+
".\\..\\x",
|
|
19
|
+
"." + "a".repeat(17),
|
|
20
|
+
".tar.gz",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
|
|
24
|
+
|
|
25
|
+
describe("#527 shared extension-token invariant", () => {
|
|
26
|
+
test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
|
|
27
|
+
for (const ext of malicious) {
|
|
28
|
+
expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
29
|
+
}
|
|
30
|
+
for (const ext of legal) {
|
|
31
|
+
expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
|
|
36
|
+
for (const ext of malicious) {
|
|
37
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
38
|
+
}
|
|
39
|
+
for (const ext of legal) {
|
|
40
|
+
expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
|
|
45
|
+
for (const ext of malicious) {
|
|
46
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
for (const ext of legal) {
|
|
49
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("all three boundaries fail closed on non-string runtime values", () => {
|
|
54
|
+
for (const ext of [undefined, null, 7, {}, []]) {
|
|
55
|
+
expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
56
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
57
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Task #25 â regression pins for ext validation in validateIndexEntry.
|
|
2
|
+
//
|
|
3
|
+
// đ´ SCOPE: this suite exercises the stored-index boundary through
|
|
4
|
+
// `validateIndexEntry`. Issue #527 removed the three independent copies
|
|
5
|
+
// of the stored extension-token grammar: buildStoragePath,
|
|
6
|
+
// pathForExistingBlob and validateIndexEntry now use one shared private
|
|
7
|
+
// validator. `ext-token-shared.test.ts` separately pins all three call
|
|
8
|
+
// sites. sanitizeExt remains intentionally independent because it parses
|
|
9
|
+
// a client filename rather than validating an already-extracted token.
|
|
10
|
+
//
|
|
11
|
+
// Background (éäżĄéž d01bb8ce): the stored extension-token grammar
|
|
12
|
+
// `/^\.[A-Za-z0-9]{1,16}$/` correctly rejects 11 known malicious ext
|
|
13
|
+
// values (path-escape, traversal, absolute paths, doubles, NUL bytes,
|
|
14
|
+
// backslash forms, over-length, whitespace). Verified via real-run on
|
|
15
|
+
// origin/main by lead â 11/11 malicious values â false.
|
|
16
|
+
//
|
|
17
|
+
// But NO test locked that behavior. Anyone who widens the regex in the
|
|
18
|
+
// future (accidentally or on purpose) would not hit a red test â the
|
|
19
|
+
// live protection would degrade silently. This suite pins current
|
|
20
|
+
// behavior in two directions:
|
|
21
|
+
//
|
|
22
|
+
// 1. SAFETY (ćŹ äżŽ direction) â 11 malicious values â false, one row
|
|
23
|
+
// per value with the exact literal. If regex is widened to accept
|
|
24
|
+
// any of them, that row turns red.
|
|
25
|
+
// 2. NON-REGRESSION (čżäżŽ direction) â a set of common legitimate
|
|
26
|
+
// extensions â true. If regex is tightened to reject any of them
|
|
27
|
+
// (e.g. someone locks down to a whitelist), that row turns red.
|
|
28
|
+
//
|
|
29
|
+
// Witnessed-red for the SAFETY rows: temporarily widen the regex to
|
|
30
|
+
// `/./` (or comment it out) in validateIndexEntry, run this suite,
|
|
31
|
+
// confirm all 11 malicious rows turn red, then revert. The mutation
|
|
32
|
+
// itself is not committed; the assertion IS the mutation-red proof
|
|
33
|
+
// for the reader. Recording the mutation output in the followup PR
|
|
34
|
+
// body (per Constraint 3 discipline established in #503).
|
|
35
|
+
//
|
|
36
|
+
// Pure unit test â no server setup, no DB, no filesystem. The regex
|
|
37
|
+
// lives in a pure function, so the guard is testable at that level.
|
|
38
|
+
|
|
39
|
+
import { describe, expect, test } from "bun:test";
|
|
40
|
+
import { validateIndexEntry } from "./uploads.js";
|
|
41
|
+
|
|
42
|
+
// A structurally-valid base entry â file_id / date_bucket / size all
|
|
43
|
+
// pass their own checks â so `ext` is the only field a test row varies.
|
|
44
|
+
// This isolates the ext regex as the deciding factor.
|
|
45
|
+
const validBase = {
|
|
46
|
+
file_id: "0123456789abcdef0123456789abcdef",
|
|
47
|
+
date_bucket: "2026-07-30",
|
|
48
|
+
size: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("Task #25 (#503 followup) â ext validation regression pins", () => {
|
|
52
|
+
describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
|
|
53
|
+
// Each row uses the exact literal from lead's verification list so
|
|
54
|
+
// a widening regex catches on the same value the manual audit did.
|
|
55
|
+
const malicious: Array<{ label: string; ext: string }> = [
|
|
56
|
+
{ label: "path-escape prefix", ext: "../x" },
|
|
57
|
+
{ label: "deep traversal to /etc", ext: "../../etc/passwd" },
|
|
58
|
+
{ label: "absolute path", ext: "/abs/x" },
|
|
59
|
+
{ label: "double-dot alone", ext: ".." },
|
|
60
|
+
{ label: "single-dot alone", ext: "." },
|
|
61
|
+
{ label: "chained after legit prefix", ext: ".png/../../y" },
|
|
62
|
+
{ label: "embedded whitespace", ext: ".p ng" },
|
|
63
|
+
{ label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
|
|
64
|
+
{ label: "embedded NUL byte", ext: ".p\x00ng" },
|
|
65
|
+
{ label: "backslash traversal", ext: ".\\..\\x" },
|
|
66
|
+
{ label: "double-backslash prefix", ext: "..\\x" },
|
|
67
|
+
];
|
|
68
|
+
for (const { label, ext } of malicious) {
|
|
69
|
+
test(`rejects: ${label} â ext=${JSON.stringify(ext)}`, () => {
|
|
70
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
|
|
75
|
+
// If someone deletes rows from the malicious list, this fails
|
|
76
|
+
// instead of the deletion silently narrowing the guard. Number
|
|
77
|
+
// matches lead's verification list â bump only when lead adds a
|
|
78
|
+
// case and confirms it via real-run.
|
|
79
|
+
expect(malicious.length).toBe(11);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("NON-REGRESSION: common legitimate extensions all return true (é˛čżäżŽ)", () => {
|
|
84
|
+
// These are the extensions the live upload path actually produces
|
|
85
|
+
// via sanitizeExt / mime-typed multipart uploads. If someone
|
|
86
|
+
// tightens the regex to a stricter whitelist, any of these turning
|
|
87
|
+
// red flags a break in a normal user path.
|
|
88
|
+
const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
|
|
89
|
+
for (const ext of legal) {
|
|
90
|
+
test(`accepts: ext=${JSON.stringify(ext)}`, () => {
|
|
91
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
|
|
96
|
+
expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("precondition: at least 10 legitimate ext values pinned", () => {
|
|
100
|
+
// Same discipline as the malicious side â reject silent shrink.
|
|
101
|
+
expect(legal.length).toBeGreaterThanOrEqual(10);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("regex shape assumptions (documented, not enforced by the function)", () => {
|
|
106
|
+
// These rows document facts the current regex enforces beyond the
|
|
107
|
+
// 11 malicious literals. They are close to the boundary and would
|
|
108
|
+
// be the first things to move if someone reworked the regex.
|
|
109
|
+
test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
|
|
110
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
test("mixed-case extensions accepted", () => {
|
|
113
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
|
|
116
|
+
expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
|
|
119
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
|
|
122
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
|
|
125
|
+
// Documented: the current regex does not support compound
|
|
126
|
+
// extensions. If we ever want .tar.gz support, it needs its
|
|
127
|
+
// own explicit design + this row updated with lead sign-off.
|
|
128
|
+
expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|