@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
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-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 +294 -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 +598 -49
- 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/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 +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -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 +107 -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 +933 -36
- 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 +260 -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/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/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -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/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/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 +781 -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 +130 -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-skills-transport.test.ts +209 -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 +431 -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/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 +22 -2
- 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 +150 -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 +127 -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 +288 -0
- package/src/requirements-stats.ts +167 -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 +139 -0
- package/src/rest-projections.ts +68 -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-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-reply-unread-http.test.ts +212 -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 +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -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 +5257 -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 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -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 +3940 -325
- 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,781 @@
|
|
|
1
|
+
// Caller identity goes through one resolver (resolveNodeCaller).
|
|
2
|
+
//
|
|
3
|
+
// Real HTTP, both SQLite and PostgreSQL:
|
|
4
|
+
// cd server && COMMHUB_DB=/tmp/x.db bun test src/node-caller-identity-http.test.ts
|
|
5
|
+
// PG: COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1
|
|
6
|
+
// (tests/node-caller-identity runs both, plus six mutations)
|
|
7
|
+
//
|
|
8
|
+
// An unbound token with zero rows keeps the name in the token. First
|
|
9
|
+
// registration still depends on that. A bound token whose row is gone is
|
|
10
|
+
// refused and must not fall back to the name. Several rows with one alias
|
|
11
|
+
// are not refused outright for an unbound token: the issuer's own row wins,
|
|
12
|
+
// then the node_id on the latest session. Still not unique, then refuse.
|
|
13
|
+
// The mutation harness deletes the shared owner check and expects every
|
|
14
|
+
// "caller identity boundary:" test to go assertion-red together (12). A
|
|
15
|
+
// second mutation restores a name lookup for a missing bound row and expects
|
|
16
|
+
// "bound row missing does not fall back to the name" to go red. A third
|
|
17
|
+
// drops the single-owner shortcut and expects the duplicate-alias heartbeat
|
|
18
|
+
// to go red. A fourth treats every failed ntok_ resolve as impersonation
|
|
19
|
+
// and expects "registration network token signs as the username" to go red.
|
|
20
|
+
// A fifth drops the ownerless-row compatibility and expects "duplicate
|
|
21
|
+
// ownerless row keeps an unbound epoch 0 token online" to go red. A sixth
|
|
22
|
+
// treats not_a_node_token on REST as impersonation and expects "unreported
|
|
23
|
+
// registration token signs as api" to go red. New tests must not use the
|
|
24
|
+
// "caller identity boundary:" prefix.
|
|
25
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
26
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
30
|
+
import { db, generateNetworkToken, hashToken } from "./db.js";
|
|
31
|
+
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-caller-identity-"));
|
|
33
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
34
|
+
if (!activeDbPath) throw new Error("node-caller-identity requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
35
|
+
|
|
36
|
+
const stamp = Date.now().toString(36);
|
|
37
|
+
let server: any;
|
|
38
|
+
let base = "";
|
|
39
|
+
let ownerToken = "";
|
|
40
|
+
let ownerId = "";
|
|
41
|
+
let outsiderId = "";
|
|
42
|
+
let NET = "";
|
|
43
|
+
let NET2 = "";
|
|
44
|
+
|
|
45
|
+
const victimAlias = `idvvic${stamp}`;
|
|
46
|
+
const otherAlias = `idvoth${stamp}`;
|
|
47
|
+
const sinkAlias = `idvsnk${stamp}`;
|
|
48
|
+
const sharedAlias = `idvshr${stamp}`;
|
|
49
|
+
const sink2Alias = `idvsk2${stamp}`;
|
|
50
|
+
const freshAlias = `idvfrs${stamp}`;
|
|
51
|
+
const ownerlessAlias = `idvoln${stamp}`;
|
|
52
|
+
const dupAlias = `idvdup${stamp}`;
|
|
53
|
+
const victimId = `n_idv_vic_${stamp}`;
|
|
54
|
+
const otherId = `n_idv_oth_${stamp}`;
|
|
55
|
+
const sinkId = `n_idv_snk_${stamp}`;
|
|
56
|
+
const sharedId = `n_idv_shr_${stamp}`;
|
|
57
|
+
const shared2Id = `n_idv_sh2_${stamp}`;
|
|
58
|
+
const sink2Id = `n_idv_sk2_${stamp}`;
|
|
59
|
+
const freshId = `n_idv_frs_${stamp}`;
|
|
60
|
+
const ownerlessId = `n_idv_oln_${stamp}`;
|
|
61
|
+
const missingBoundId = `n_idv_gone_${stamp}`;
|
|
62
|
+
|
|
63
|
+
type Tok = { token: string; tokenId: string };
|
|
64
|
+
let victimTok: Tok;
|
|
65
|
+
let otherTok: Tok;
|
|
66
|
+
let squatter: Tok;
|
|
67
|
+
let boundMissing: Tok;
|
|
68
|
+
let freshTok: Tok;
|
|
69
|
+
let ownerlessTok: Tok;
|
|
70
|
+
let dupTok: Tok;
|
|
71
|
+
let sharedHome: Tok;
|
|
72
|
+
let sharedForeign: Tok;
|
|
73
|
+
let p10Owner: Tok;
|
|
74
|
+
|
|
75
|
+
type Sub = { events: any[]; done: boolean; abort: () => void; ready: Promise<void> };
|
|
76
|
+
const openSubs: Sub[] = [];
|
|
77
|
+
|
|
78
|
+
function subscribe(path: string, token: string, headers: Record<string, string> = {}): Sub {
|
|
79
|
+
const ctrl = new AbortController();
|
|
80
|
+
const sub: Sub = { events: [], done: false, abort: () => ctrl.abort(), ready: Promise.resolve() };
|
|
81
|
+
let markReady!: () => void;
|
|
82
|
+
sub.ready = new Promise<void>((r) => { markReady = r; });
|
|
83
|
+
openSubs.push(sub);
|
|
84
|
+
(async () => {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`${base}${path}`, {
|
|
87
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", ...headers },
|
|
88
|
+
signal: ctrl.signal,
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok || !res.body) throw new Error(`subscribe ${path} → ${res.status}`);
|
|
91
|
+
const reader = res.body.getReader();
|
|
92
|
+
const dec = new TextDecoder();
|
|
93
|
+
let buf = "";
|
|
94
|
+
while (true) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done) break;
|
|
97
|
+
buf += dec.decode(value, { stream: true });
|
|
98
|
+
const lines = buf.split("\n");
|
|
99
|
+
buf = lines.pop() || "";
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (!line.startsWith("data: ")) continue;
|
|
102
|
+
const ev = JSON.parse(line.slice(6));
|
|
103
|
+
sub.events.push(ev);
|
|
104
|
+
if (ev.type === "connected") markReady();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch { /* aborted */ }
|
|
108
|
+
sub.done = true;
|
|
109
|
+
markReady();
|
|
110
|
+
})();
|
|
111
|
+
return sub;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function until(cond: () => boolean, what: string, ms = 3000) {
|
|
115
|
+
const t0 = Date.now();
|
|
116
|
+
while (!cond()) {
|
|
117
|
+
if (Date.now() - t0 > ms) throw new Error(`timed out waiting for: ${what}`);
|
|
118
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function api(token: string, method: string, path: string, body?: unknown) {
|
|
123
|
+
const res = await fetch(`${base}${path}`, {
|
|
124
|
+
method,
|
|
125
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
126
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
127
|
+
});
|
|
128
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
132
|
+
const res = await fetch(`${base}/mcp`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
Authorization: `Bearer ${token}`,
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
Accept: "application/json, text/event-stream",
|
|
138
|
+
"MCP-Protocol-Version": "2025-03-26",
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
141
|
+
});
|
|
142
|
+
const raw = await res.text();
|
|
143
|
+
const data = raw.split("\n").filter((x) => x.startsWith("data:"));
|
|
144
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
145
|
+
const text = payload.result?.content?.[0]?.text;
|
|
146
|
+
if (typeof text !== "string" || !text.startsWith("{")) throw new Error(`${name}: ${raw.slice(0, 500)}`);
|
|
147
|
+
return JSON.parse(text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let mintSeq = 0;
|
|
151
|
+
function mint(userId: string, networkId: string, alias: string, epoch: number, boundNodeId: string | null): Tok {
|
|
152
|
+
const token = generateNetworkToken();
|
|
153
|
+
const tokenId = `tok_idv_${++mintSeq}_${stamp}`;
|
|
154
|
+
db.run(
|
|
155
|
+
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id, node_identity_epoch)
|
|
156
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6, ?7)`,
|
|
157
|
+
[tokenId, hashToken(token), userId, networkId, `node:${alias}`, boundNodeId, epoch],
|
|
158
|
+
);
|
|
159
|
+
return { token, tokenId };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function minted(userId: string, networkId: string, alias: string, nodeId: string): Tok {
|
|
163
|
+
const row = createNetworkTokenForNode(userId, networkId, alias, nodeId);
|
|
164
|
+
if (!row.ok || !row.token || !row.token_id) throw new Error(`mint ${alias}: ${row.error ?? "no token"}`);
|
|
165
|
+
return { token: row.token, tokenId: row.token_id };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function taskCount(content: string): number {
|
|
169
|
+
return Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM tasks WHERE content = ?1", content)?.n ?? 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sessionSnap(alias: string, networkId: string) {
|
|
173
|
+
return db.get(
|
|
174
|
+
`SELECT resume_id, alias, status, task, output, node_id, network_id, updated_at
|
|
175
|
+
FROM sessions WHERE alias = ?1 AND network_id = ?2`,
|
|
176
|
+
alias, networkId,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function tokenName(tokenId: string): string | null {
|
|
181
|
+
return db.get<{ name: string }>("SELECT name FROM api_tokens WHERE token_id = ?1", tokenId)?.name ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const report = (tok: Tok, alias: string, nodeId: string, networkId: string, extra: Record<string, unknown> = {}) =>
|
|
185
|
+
tool(tok.token, "report_status", {
|
|
186
|
+
resume_id: `sdk-${alias}`,
|
|
187
|
+
alias,
|
|
188
|
+
status: "idle",
|
|
189
|
+
output: "steady",
|
|
190
|
+
node_id: nodeId,
|
|
191
|
+
network_id: networkId,
|
|
192
|
+
...extra,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
beforeAll(async () => {
|
|
196
|
+
process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
|
|
197
|
+
process.env.HOST = "127.0.0.1";
|
|
198
|
+
const owner = register(`idvown${stamp}`, "CallerIdentOwner123!", undefined, "seed");
|
|
199
|
+
if (!owner.ok || !owner.token || !owner.user || !owner.network_id) throw new Error(owner.error || "owner register failed");
|
|
200
|
+
ownerToken = owner.token;
|
|
201
|
+
ownerId = owner.user.user_id;
|
|
202
|
+
NET = owner.network_id;
|
|
203
|
+
const outsider = register(`idvadm${stamp}`, "CallerIdentAdmin123!", undefined, "seed");
|
|
204
|
+
if (!outsider.ok || !outsider.user || !outsider.network_id) throw new Error(outsider.error || "admin register failed");
|
|
205
|
+
outsiderId = outsider.user.user_id;
|
|
206
|
+
NET2 = outsider.network_id;
|
|
207
|
+
const joined = addNetworkMember(NET, outsiderId, "admin", ownerId);
|
|
208
|
+
if (!joined.ok) throw new Error(joined.error || "add member failed");
|
|
209
|
+
|
|
210
|
+
victimTok = minted(ownerId, NET, victimAlias, victimId);
|
|
211
|
+
otherTok = minted(ownerId, NET, otherAlias, otherId);
|
|
212
|
+
db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${victimAlias}`, otherTok.tokenId]);
|
|
213
|
+
minted(ownerId, NET, sinkAlias, sinkId);
|
|
214
|
+
minted(ownerId, NET, sharedAlias, sharedId);
|
|
215
|
+
sharedHome = minted(outsiderId, NET2, sharedAlias, shared2Id);
|
|
216
|
+
minted(outsiderId, NET2, sink2Alias, sink2Id);
|
|
217
|
+
db.run(
|
|
218
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
219
|
+
VALUES (?1, ?2, ?2, ?3, NULL, datetime('now'))`,
|
|
220
|
+
[ownerlessId, ownerlessAlias, NET],
|
|
221
|
+
);
|
|
222
|
+
db.run(
|
|
223
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
224
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
|
|
225
|
+
[`n_idv_dup_a_${stamp}`, dupAlias, NET, ownerId],
|
|
226
|
+
);
|
|
227
|
+
db.run(
|
|
228
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
229
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
|
|
230
|
+
[`n_idv_dup_b_${stamp}`, dupAlias, NET, ownerId],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
squatter = mint(outsiderId, NET, victimAlias, 2, null);
|
|
234
|
+
boundMissing = mint(ownerId, NET, victimAlias, 2, missingBoundId);
|
|
235
|
+
freshTok = mint(ownerId, NET, freshAlias, 2, null);
|
|
236
|
+
ownerlessTok = mint(outsiderId, NET, ownerlessAlias, 0, null);
|
|
237
|
+
dupTok = mint(ownerId, NET, dupAlias, 2, null);
|
|
238
|
+
sharedForeign = mint(outsiderId, NET, sharedAlias, 2, null);
|
|
239
|
+
p10Owner = mint(ownerId, NET, victimAlias, 2, null);
|
|
240
|
+
|
|
241
|
+
const mod: any = await import("./server.js");
|
|
242
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
243
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
244
|
+
db.run("UPDATE nodes SET permission_mode = 'readonly' WHERE node_id = ?1", [otherId]);
|
|
245
|
+
for (const row of [
|
|
246
|
+
[victimTok, victimAlias, victimId, NET],
|
|
247
|
+
[otherTok, otherAlias, otherId, NET],
|
|
248
|
+
[sharedHome, sharedAlias, shared2Id, NET2],
|
|
249
|
+
] as const) {
|
|
250
|
+
const reported = await report(row[0], row[1], row[2], row[3]);
|
|
251
|
+
if (!reported.ok) throw new Error(`report ${row[1]}: ${JSON.stringify(reported)}`);
|
|
252
|
+
}
|
|
253
|
+
const sinkReport = await report(minted(ownerId, NET, sinkAlias, sinkId), sinkAlias, sinkId, NET);
|
|
254
|
+
if (!sinkReport.ok) throw new Error(`report sink: ${JSON.stringify(sinkReport)}`);
|
|
255
|
+
const sink2Tok = minted(outsiderId, NET2, sink2Alias, sink2Id);
|
|
256
|
+
const sink2Report = await report(sink2Tok, sink2Alias, sink2Id, NET2);
|
|
257
|
+
if (!sink2Report.ok) throw new Error(`report sink2: ${JSON.stringify(sink2Report)}`);
|
|
258
|
+
}, 30_000);
|
|
259
|
+
|
|
260
|
+
afterAll(() => {
|
|
261
|
+
for (const sub of openSubs) sub.abort();
|
|
262
|
+
try { server?.stop?.(true); } catch {}
|
|
263
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
async function mcpSend(token: string, task: string, from: string | undefined) {
|
|
267
|
+
const args: Record<string, unknown> = { alias: sinkAlias, task, network_id: NET };
|
|
268
|
+
if (from !== undefined) args.from_session = from;
|
|
269
|
+
return tool(token, "send_task", args);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
test("inbox recipient: bound identity wins over the token name", async () => {
|
|
273
|
+
const id = `inbox-bound-${stamp}`;
|
|
274
|
+
db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'own inbox', ?3)", [id, otherAlias, NET]);
|
|
275
|
+
const read = await tool(otherTok.token, "get_inbox", { alias: otherAlias });
|
|
276
|
+
expect(read.ok).toBe(true);
|
|
277
|
+
expect(read.messages.some((row: any) => row.id === id)).toBe(true);
|
|
278
|
+
expect((await tool(otherTok.token, "ack_inbox", { alias: otherAlias, message_id: id })).ok).toBe(true);
|
|
279
|
+
expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("inbox recipient: another node and invalid node identities cannot read or ACK", async () => {
|
|
283
|
+
const id = `inbox-denied-${stamp}`;
|
|
284
|
+
db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'private inbox', ?3)", [id, victimAlias, NET]);
|
|
285
|
+
for (const caller of [otherTok, squatter, boundMissing]) {
|
|
286
|
+
const read = await tool(caller.token, "get_inbox", { alias: victimAlias });
|
|
287
|
+
expect(read.ok).toBe(false);
|
|
288
|
+
expect(read.error).toBe("inbox_identity_mismatch");
|
|
289
|
+
expect(read.messages).toBeUndefined();
|
|
290
|
+
const ack = await tool(caller.token, "ack_inbox", { alias: victimAlias, message_id: id });
|
|
291
|
+
expect(ack.ok).toBe(false);
|
|
292
|
+
expect(ack.error).toBe("inbox_identity_mismatch");
|
|
293
|
+
expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(0);
|
|
294
|
+
}
|
|
295
|
+
expect((await tool(victimTok.token, "ack_inbox", { alias: victimAlias, message_id: id })).ok).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("inbox recipient: same alias stays inside the token network", async () => {
|
|
299
|
+
const home = `inbox-net-home-${stamp}`, foreign = `inbox-net-foreign-${stamp}`;
|
|
300
|
+
db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'home', ?3)", [home, sharedAlias, NET2]);
|
|
301
|
+
db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'foreign', ?3)", [foreign, sharedAlias, NET]);
|
|
302
|
+
const read = await tool(sharedHome.token, "get_inbox", { alias: sharedAlias, network_id: NET });
|
|
303
|
+
expect(read.ok).toBe(true);
|
|
304
|
+
expect(read.messages.some((row: any) => row.id === home)).toBe(true);
|
|
305
|
+
expect(read.messages.some((row: any) => row.id === foreign)).toBe(false);
|
|
306
|
+
expect((await tool(sharedHome.token, "ack_inbox", { alias: sharedAlias, message_id: foreign, network_id: NET })).ok).toBe(false);
|
|
307
|
+
expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", foreign)?.acked).toBe(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("inbox recipient: legacy ownerless identity and authorized human access remain", async () => {
|
|
311
|
+
const id = `inbox-legacy-${stamp}`;
|
|
312
|
+
db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'legacy', ?3)", [id, ownerlessAlias, NET]);
|
|
313
|
+
expect((await tool(ownerlessTok.token, "get_inbox", { alias: ownerlessAlias })).ok).toBe(true);
|
|
314
|
+
expect((await tool(ownerToken, "get_inbox", { alias: ownerlessAlias, network_id: NET })).ok).toBe(true);
|
|
315
|
+
expect((await tool(ownerToken, "ack_inbox", { alias: ownerlessAlias, message_id: id, network_id: NET })).ok).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("inbox recipient: a full-access member cannot act as another owner's node", async () => {
|
|
319
|
+
const member = register(`inboxmem${stamp}`, "InboxMember123!", undefined, "seed");
|
|
320
|
+
expect(member.ok).toBe(true);
|
|
321
|
+
expect(addNetworkMember(NET, member.user!.user_id, "member", ownerId).ok).toBe(true);
|
|
322
|
+
db.run("UPDATE network_members SET agent_access = 'all' WHERE user_id = ?1 AND network_id = ?2", [member.user!.user_id, NET]);
|
|
323
|
+
const id = `inbox-member-${stamp}`;
|
|
324
|
+
db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'private', ?3)", [id, victimAlias, NET]);
|
|
325
|
+
for (const name of ["get_inbox", "ack_inbox"]) {
|
|
326
|
+
const args: Record<string, unknown> = { alias: victimAlias, network_id: NET };
|
|
327
|
+
if (name === "ack_inbox") args.message_id = id;
|
|
328
|
+
const result = await tool(member.token!, name, args);
|
|
329
|
+
expect(result.ok).toBe(false);
|
|
330
|
+
expect(result.error).toBe("inbox_identity_mismatch");
|
|
331
|
+
}
|
|
332
|
+
expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(0);
|
|
333
|
+
const own = `inbox-member-own-${stamp}`;
|
|
334
|
+
db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'own', ?3)", [own, member.user!.username, NET]);
|
|
335
|
+
expect((await tool(member.token!, "get_inbox", { alias: member.user!.username, network_id: NET })).ok).toBe(true);
|
|
336
|
+
expect((await tool(member.token!, "ack_inbox", { alias: member.user!.username, message_id: own, network_id: NET })).ok).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("caller identity boundary: mcp send omits from_session", async () => {
|
|
340
|
+
const task = `boundary omit ${stamp}`;
|
|
341
|
+
const sent = await mcpSend(squatter.token, task, undefined);
|
|
342
|
+
expect(sent.ok).toBe(false);
|
|
343
|
+
expect(sent.error).toBe("from_session_identity_mismatch");
|
|
344
|
+
expect(taskCount(task)).toBe(0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("caller identity boundary: mcp send names the victim", async () => {
|
|
348
|
+
const task = `boundary named ${stamp}`;
|
|
349
|
+
const sent = await mcpSend(squatter.token, task, victimAlias);
|
|
350
|
+
expect(sent.ok).toBe(false);
|
|
351
|
+
expect(sent.error).toBe("from_session_identity_mismatch");
|
|
352
|
+
expect(sent.token_alias).toBe("");
|
|
353
|
+
expect(taskCount(task)).toBe(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("caller identity boundary: mcp send names hub", async () => {
|
|
357
|
+
const task = `boundary hub ${stamp}`;
|
|
358
|
+
const sent = await mcpSend(squatter.token, task, "hub");
|
|
359
|
+
expect(sent.ok).toBe(false);
|
|
360
|
+
expect(sent.error).toBe("from_session_identity_mismatch");
|
|
361
|
+
expect(sent.token_alias).toBe("");
|
|
362
|
+
expect(taskCount(task)).toBe(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("caller identity boundary: rest send omits from", async () => {
|
|
366
|
+
const task = `boundary rest omit ${stamp}`;
|
|
367
|
+
const sent = await api(squatter.token, "POST", "/api/task", { alias: sinkAlias, task, network_id: NET });
|
|
368
|
+
expect(sent.status).toBe(403);
|
|
369
|
+
expect(sent.body.error).toBe("from_session_identity_mismatch");
|
|
370
|
+
expect(taskCount(task)).toBe(0);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("caller identity boundary: rest send names the victim", async () => {
|
|
374
|
+
const task = `boundary rest named ${stamp}`;
|
|
375
|
+
const sent = await api(squatter.token, "POST", "/api/task", { alias: sinkAlias, task, from: victimAlias, network_id: NET });
|
|
376
|
+
expect(sent.status).toBe(403);
|
|
377
|
+
expect(sent.body.error).toBe("from_session_identity_mismatch");
|
|
378
|
+
expect(sent.body.token_alias).toBe("");
|
|
379
|
+
expect(taskCount(task)).toBe(0);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("caller identity boundary: rest send names hub", async () => {
|
|
383
|
+
const task = `boundary rest hub ${stamp}`;
|
|
384
|
+
const sent = await api(squatter.token, "POST", "/api/task", { alias: sinkAlias, task, from: "hub", network_id: NET });
|
|
385
|
+
expect(sent.status).toBe(403);
|
|
386
|
+
expect(sent.body.token_alias).toBe("");
|
|
387
|
+
expect(taskCount(task)).toBe(0);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("caller identity boundary: rename refuses the victim alias", async () => {
|
|
391
|
+
const sent = await api(squatter.token, "POST", "/api/node-rename/prepare", {
|
|
392
|
+
network_id: NET,
|
|
393
|
+
old_alias: victimAlias,
|
|
394
|
+
new_alias: `idvren${stamp}`,
|
|
395
|
+
});
|
|
396
|
+
expect(sent.status).toBe(403);
|
|
397
|
+
expect(sent.body.error).toBe("user_token_required");
|
|
398
|
+
if (sent.body?.txn_id) await api(ownerToken, "POST", "/api/node-rename/abort", { txn_id: sent.body.txn_id });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("caller identity boundary: auth me hides the victim alias", async () => {
|
|
402
|
+
const me = await api(squatter.token, "GET", "/api/auth/me");
|
|
403
|
+
expect(me.status).toBe(200);
|
|
404
|
+
expect(me.body.credential.kind).toBe("node");
|
|
405
|
+
expect(me.body.credential.node_alias).toBeNull();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("caller identity boundary: requirement actor is the token", async () => {
|
|
409
|
+
const created = await api(squatter.token, "POST", "/api/requirements", { name: `boundary ${stamp}` });
|
|
410
|
+
expect(created.status).toBe(201);
|
|
411
|
+
expect(created.body.requirement.created_by).toEqual({ kind: "node", id: `token:${squatter.tokenId}` });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("caller identity boundary: sse does not supersede the victim", async () => {
|
|
415
|
+
const real = subscribe(`/events/${encodeURIComponent(victimAlias)}`, victimTok.token, { "X-Anet-Instance-Id": `inst-a-${stamp}` });
|
|
416
|
+
await real.ready;
|
|
417
|
+
const squat = subscribe(`/events/${encodeURIComponent(victimAlias)}`, squatter.token, { "X-Anet-Instance-Id": `inst-s-${stamp}` });
|
|
418
|
+
await squat.ready;
|
|
419
|
+
const t0 = Date.now();
|
|
420
|
+
while (!real.events.some((e) => e.type === "node_connection_superseded") && Date.now() - t0 < 400) {
|
|
421
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
422
|
+
}
|
|
423
|
+
expect(real.events.some((e) => e.type === "node_connection_superseded")).toBe(false);
|
|
424
|
+
expect(real.done).toBe(false);
|
|
425
|
+
const again = subscribe(`/events/${encodeURIComponent(victimAlias)}`, victimTok.token, { "X-Anet-Instance-Id": `inst-b-${stamp}` });
|
|
426
|
+
await again.ready;
|
|
427
|
+
await until(() => real.done, "the node's own second connection to close the first");
|
|
428
|
+
const notice = real.events.find((e) => e.type === "node_connection_superseded");
|
|
429
|
+
expect(notice?.reason).toBe("superseded_by_new_connection");
|
|
430
|
+
expect(squat.done).toBe(false);
|
|
431
|
+
real.abort();
|
|
432
|
+
squat.abort();
|
|
433
|
+
again.abort();
|
|
434
|
+
}, 15_000);
|
|
435
|
+
|
|
436
|
+
test("caller identity boundary: daemon list does not bind the victim", async () => {
|
|
437
|
+
const listed = await tool(squatter.token, "list_my_pending_create_requests", {});
|
|
438
|
+
expect(listed.ok).toBe(false);
|
|
439
|
+
expect(listed.error).toBe("caller_not_a_daemon");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("caller identity boundary: other network token does not write here", async () => {
|
|
443
|
+
const task = `boundary foreign ${stamp}`;
|
|
444
|
+
const sent = await mcpSend(sharedForeign.token, task, undefined);
|
|
445
|
+
expect(sent.ok).toBe(false);
|
|
446
|
+
expect(sent.error).toBe("from_session_identity_mismatch");
|
|
447
|
+
expect(taskCount(task)).toBe(0);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("bound row missing does not fall back to the name", async () => {
|
|
451
|
+
const me = await api(boundMissing.token, "GET", "/api/auth/me");
|
|
452
|
+
expect(me.status).toBe(200);
|
|
453
|
+
expect(me.body.credential.node_alias).toBeNull();
|
|
454
|
+
const task = `boundary missing-bound ${stamp}`;
|
|
455
|
+
const sent = await mcpSend(boundMissing.token, task, undefined);
|
|
456
|
+
expect(sent.ok).toBe(false);
|
|
457
|
+
expect(sent.error).toBe("from_session_identity_mismatch");
|
|
458
|
+
expect(taskCount(task)).toBe(0);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("bound token speaks as the bound node when the token name disagrees", async () => {
|
|
462
|
+
const me = await api(otherTok.token, "GET", "/api/auth/me");
|
|
463
|
+
expect(me.body.credential.node_alias).toBe(otherAlias);
|
|
464
|
+
const selfTask = `boundary other self ${stamp}`;
|
|
465
|
+
const self = await tool(otherTok.token, "send_task", { alias: otherAlias, task: selfTask, network_id: NET });
|
|
466
|
+
expect(self.ok).toBe(true);
|
|
467
|
+
const row = db.get<{ from_name: string; network_id: string }>("SELECT from_name, network_id FROM tasks WHERE content = ?1", selfTask);
|
|
468
|
+
expect(row?.from_name).toBe(otherAlias);
|
|
469
|
+
expect(row?.network_id).toBe(NET);
|
|
470
|
+
const stolen = `boundary other stolen ${stamp}`;
|
|
471
|
+
const sent = await tool(otherTok.token, "send_task", { alias: victimAlias, task: stolen, network_id: NET });
|
|
472
|
+
expect(sent.ok).toBe(false);
|
|
473
|
+
expect(sent.error).toBe("node_permission_denied");
|
|
474
|
+
expect(sent.reason).toBe("mode_readonly");
|
|
475
|
+
expect(taskCount(stolen)).toBe(0);
|
|
476
|
+
const real = subscribe(`/events/${encodeURIComponent(victimAlias)}`, victimTok.token, { "X-Anet-Instance-Id": `inst-r-${stamp}` });
|
|
477
|
+
await real.ready;
|
|
478
|
+
const other = subscribe(`/events/${encodeURIComponent(victimAlias)}`, otherTok.token, { "X-Anet-Instance-Id": `inst-o-${stamp}` });
|
|
479
|
+
await other.ready;
|
|
480
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
481
|
+
expect(real.events.some((e) => e.type === "node_connection_superseded")).toBe(false);
|
|
482
|
+
expect(real.done).toBe(false);
|
|
483
|
+
real.abort();
|
|
484
|
+
other.abort();
|
|
485
|
+
}, 15_000);
|
|
486
|
+
|
|
487
|
+
test("ownerless epoch 0 token still speaks as that alias and does not claim the row", async () => {
|
|
488
|
+
const task = `boundary ownerless ${stamp}`;
|
|
489
|
+
const sent = await tool(ownerlessTok.token, "send_task", { alias: sinkAlias, task, network_id: NET });
|
|
490
|
+
expect(sent.ok).toBe(true);
|
|
491
|
+
const row = db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", task);
|
|
492
|
+
expect(row?.from_name).toBe(ownerlessAlias);
|
|
493
|
+
const owner = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", ownerlessId);
|
|
494
|
+
expect(owner?.owner_user_id ?? null).toBeNull();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("two node rows with one alias stay unresolved", async () => {
|
|
498
|
+
const me = await api(dupTok.token, "GET", "/api/auth/me");
|
|
499
|
+
expect(me.body.credential.node_alias).toBeNull();
|
|
500
|
+
const task = `boundary dup ${stamp}`;
|
|
501
|
+
const sent = await mcpSend(dupTok.token, task, undefined);
|
|
502
|
+
expect(sent.ok).toBe(false);
|
|
503
|
+
expect(sent.error).toBe("from_session_identity_mismatch");
|
|
504
|
+
expect(taskCount(task)).toBe(0);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("unbound token with no row keeps its name and first report_status binds it", async () => {
|
|
508
|
+
const before = await api(freshTok.token, "GET", "/api/auth/me");
|
|
509
|
+
expect(before.body.credential.node_alias).toBe(freshAlias);
|
|
510
|
+
const early = `boundary fresh send ${stamp}`;
|
|
511
|
+
const sent = await tool(freshTok.token, "send_task", { alias: sinkAlias, task: early, network_id: NET });
|
|
512
|
+
expect(sent.ok).toBe(true);
|
|
513
|
+
expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", early)?.from_name).toBe(freshAlias);
|
|
514
|
+
expect(db.get("SELECT node_id FROM nodes WHERE node_id = ?1", freshId)).toBeNull();
|
|
515
|
+
const reported = await report(freshTok, freshAlias, freshId, NET);
|
|
516
|
+
expect(reported.ok).toBe(true);
|
|
517
|
+
const node = db.get<{ alias: string; network_id: string }>("SELECT alias, network_id FROM nodes WHERE node_id = ?1", freshId);
|
|
518
|
+
expect(node?.alias).toBe(freshAlias);
|
|
519
|
+
expect(node?.network_id).toBe(NET);
|
|
520
|
+
const bound = db.get<{ bound_node_id: string | null; name: string }>(
|
|
521
|
+
"SELECT bound_node_id, name FROM api_tokens WHERE token_id = ?1",
|
|
522
|
+
freshTok.tokenId,
|
|
523
|
+
);
|
|
524
|
+
expect(bound?.bound_node_id).toBe(freshId);
|
|
525
|
+
expect(bound?.name).toBe(`node:${freshAlias}`);
|
|
526
|
+
const after = await api(freshTok.token, "GET", "/api/auth/me");
|
|
527
|
+
expect(after.body.credential.node_alias).toBe(freshAlias);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("a copied node_id does not relabel the row and a squatter still cannot heartbeat", async () => {
|
|
531
|
+
const boundName = tokenName(victimTok.tokenId);
|
|
532
|
+
const unboundName = tokenName(p10Owner.tokenId);
|
|
533
|
+
const claimId = `n_idv_claim_${stamp}`;
|
|
534
|
+
expect(sessionSnap(victimAlias, NET)).toBeTruthy();
|
|
535
|
+
// Legitimate owner, unbound token, a node_id that is not the row. The
|
|
536
|
+
// heartbeat succeeds and the row stays; the session must not keep the claim.
|
|
537
|
+
const mismatch = await tool(p10Owner.token, "report_status", {
|
|
538
|
+
resume_id: `sdk-${victimAlias}`,
|
|
539
|
+
alias: victimAlias,
|
|
540
|
+
status: "working",
|
|
541
|
+
output: "mutated-output",
|
|
542
|
+
node_id: claimId,
|
|
543
|
+
network_id: NET,
|
|
544
|
+
});
|
|
545
|
+
expect(mismatch.ok).toBe(true);
|
|
546
|
+
expect(db.get("SELECT node_id FROM nodes WHERE node_id = ?1", claimId)).toBeNull();
|
|
547
|
+
expect(db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id = ?1", victimId)?.alias).toBe(victimAlias);
|
|
548
|
+
const cleared = sessionSnap(victimAlias, NET) as { node_id: string | null } | null;
|
|
549
|
+
expect(cleared).toBeTruthy();
|
|
550
|
+
expect(cleared?.node_id ?? null).toBeNull();
|
|
551
|
+
expect(tokenName(p10Owner.tokenId)).toBe(unboundName);
|
|
552
|
+
// Bound token reporting another node's id: row stays, heartbeat still ok.
|
|
553
|
+
const wrongNode = await tool(victimTok.token, "report_status", {
|
|
554
|
+
resume_id: `sdk-${victimAlias}`,
|
|
555
|
+
alias: victimAlias,
|
|
556
|
+
status: "working",
|
|
557
|
+
output: "mutated-output",
|
|
558
|
+
node_id: otherId,
|
|
559
|
+
network_id: NET,
|
|
560
|
+
});
|
|
561
|
+
expect(wrongNode.ok).toBe(true);
|
|
562
|
+
expect(db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id = ?1", otherId)?.alias).toBe(otherAlias);
|
|
563
|
+
expect(db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id = ?1", victimId)?.alias).toBe(victimAlias);
|
|
564
|
+
expect(tokenName(victimTok.tokenId)).toBe(boundName);
|
|
565
|
+
const afterCollision = sessionSnap(victimAlias, NET);
|
|
566
|
+
const squat = await tool(squatter.token, "report_status", {
|
|
567
|
+
resume_id: `sdk-squat-${stamp}`,
|
|
568
|
+
alias: victimAlias,
|
|
569
|
+
status: "working",
|
|
570
|
+
output: "squatter-output",
|
|
571
|
+
node_id: victimId,
|
|
572
|
+
network_id: NET,
|
|
573
|
+
});
|
|
574
|
+
expect(squat.ok).toBe(false);
|
|
575
|
+
expect(squat.error).toBe("alias_identity_mismatch");
|
|
576
|
+
expect(sessionSnap(victimAlias, NET)).toEqual(afterCollision);
|
|
577
|
+
expect(db.get("SELECT resume_id FROM sessions WHERE resume_id = ?1", `sdk-squat-${stamp}`)).toBeNull();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("duplicate alias unbound token keeps the owning node online", async () => {
|
|
581
|
+
// Epoch 0 is the old unbound shape. Epoch 2 would still trip first-binding
|
|
582
|
+
// and clear the session node_id; that is a heartbeat success, not this case.
|
|
583
|
+
const alias = `idvlive${stamp}`;
|
|
584
|
+
const mine = `n_idv_live_${stamp}`;
|
|
585
|
+
const other = `n_idv_liveo_${stamp}`;
|
|
586
|
+
db.run(
|
|
587
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
588
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
|
|
589
|
+
[mine, alias, NET, ownerId],
|
|
590
|
+
);
|
|
591
|
+
db.run(
|
|
592
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
593
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
|
|
594
|
+
[other, alias, NET, outsiderId],
|
|
595
|
+
);
|
|
596
|
+
const tok = mint(ownerId, NET, alias, 0, null);
|
|
597
|
+
const reported = await report(tok, alias, mine, NET, { hostname: "idv-live-host" });
|
|
598
|
+
expect(reported.ok).toBe(true);
|
|
599
|
+
expect(db.get<{ alias: string; hostname: string | null }>(
|
|
600
|
+
"SELECT alias, hostname FROM nodes WHERE node_id = ?1", mine,
|
|
601
|
+
)).toEqual({ alias, hostname: "idv-live-host" });
|
|
602
|
+
expect(db.get<{ alias: string; hostname: string | null }>(
|
|
603
|
+
"SELECT alias, hostname FROM nodes WHERE node_id = ?1", other,
|
|
604
|
+
)).toEqual({ alias, hostname: null });
|
|
605
|
+
expect(db.get<{ node_id: string | null }>(
|
|
606
|
+
"SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, NET,
|
|
607
|
+
)?.node_id).toBe(mine);
|
|
608
|
+
const task = `dup live ${stamp}`;
|
|
609
|
+
const sent = await tool(tok.token, "send_task", { alias: sinkAlias, task, network_id: NET });
|
|
610
|
+
expect(sent.ok).toBe(true);
|
|
611
|
+
expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", task)?.from_name).toBe(alias);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("duplicate alias unbound token follows the latest session node", async () => {
|
|
615
|
+
const alias = `idvsess${stamp}`;
|
|
616
|
+
const rowA = `n_idv_sesa_${stamp}`;
|
|
617
|
+
const rowB = `n_idv_sesb_${stamp}`;
|
|
618
|
+
const requestId = `cr_idv_ses_${stamp}`;
|
|
619
|
+
for (const id of [rowA, rowB]) {
|
|
620
|
+
db.run(
|
|
621
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
622
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
|
|
623
|
+
[id, alias, NET, ownerId],
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
db.run(
|
|
627
|
+
`INSERT INTO sessions (resume_id, alias, node_id, network_id, status, updated_at)
|
|
628
|
+
VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
629
|
+
[`sdk-${alias}`, alias, rowB, NET],
|
|
630
|
+
);
|
|
631
|
+
db.run(
|
|
632
|
+
`INSERT INTO node_create_requests (request_id, daemon_node_id, child_name, network_id, runtime, flags_json, env_keys, status, created_at, created_by_token)
|
|
633
|
+
VALUES (?1, ?2, ?3, ?4, 'fixture', '{}', '[]', 'pending', ?5, 'fixture')`,
|
|
634
|
+
[requestId, rowB, `child-${stamp}`, NET, Date.now()],
|
|
635
|
+
);
|
|
636
|
+
const tok = mint(ownerId, NET, alias, 0, null);
|
|
637
|
+
const listed = await tool(tok.token, "list_my_pending_create_requests", {});
|
|
638
|
+
expect(listed.ok).toBe(true);
|
|
639
|
+
expect(listed.count).toBe(1);
|
|
640
|
+
expect(listed.requests[0].request_id).toBe(requestId);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("home network of a shared alias still accepts its own token", async () => {
|
|
644
|
+
const task = `boundary home ${stamp}`;
|
|
645
|
+
const sent = await tool(sharedHome.token, "send_task", { alias: sink2Alias, task, network_id: NET2 });
|
|
646
|
+
expect(sent.ok).toBe(true);
|
|
647
|
+
const row = db.get<{ from_name: string; network_id: string }>("SELECT from_name, network_id FROM tasks WHERE content = ?1", task);
|
|
648
|
+
expect(row).toEqual({ from_name: sharedAlias, network_id: NET2 });
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("a resolved node token can list its own daemon requests", async () => {
|
|
652
|
+
const listed = await tool(victimTok.token, "list_my_pending_create_requests", {});
|
|
653
|
+
expect(listed.ok).toBe(true);
|
|
654
|
+
expect(listed.count).toBe(0);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("unreported registration token signs as api", async () => {
|
|
658
|
+
const username = `idvure${stamp}`;
|
|
659
|
+
const created = register(username, "CallerIdentUre123!", undefined, "seed");
|
|
660
|
+
if (!created.ok || !created.network_token || !created.network_id) {
|
|
661
|
+
throw new Error(created.error || "unreported network token register failed");
|
|
662
|
+
}
|
|
663
|
+
const net = created.network_id;
|
|
664
|
+
const tokenRow = db.get<{ name: string }>(
|
|
665
|
+
"SELECT name FROM api_tokens WHERE token_hash = ?1",
|
|
666
|
+
hashToken(created.network_token),
|
|
667
|
+
);
|
|
668
|
+
expect(tokenRow?.name).toBe("default-network");
|
|
669
|
+
const sink = `idvurk${stamp}`;
|
|
670
|
+
db.run(
|
|
671
|
+
`INSERT INTO sessions (resume_id, alias, network_id, status, updated_at)
|
|
672
|
+
VALUES (?1, ?2, ?3, 'idle', datetime('now'))`,
|
|
673
|
+
[`sdk-${sink}`, sink, net],
|
|
674
|
+
);
|
|
675
|
+
const open = `unreported rest open ${stamp}`;
|
|
676
|
+
const sent = await api(created.network_token, "POST", "/api/task", { alias: sink, task: open, network_id: net });
|
|
677
|
+
expect(sent.status).toBe(200);
|
|
678
|
+
expect(sent.body.ok).toBe(true);
|
|
679
|
+
expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", open)?.from_name).toBe("api");
|
|
680
|
+
const claimed = `unreported rest claim ${stamp}`;
|
|
681
|
+
const other = `idvuro${stamp}`;
|
|
682
|
+
const refused = await api(created.network_token, "POST", "/api/task", {
|
|
683
|
+
alias: sink, task: claimed, from: other, network_id: net,
|
|
684
|
+
});
|
|
685
|
+
expect(refused.status).toBe(403);
|
|
686
|
+
expect(refused.body.error).toBe("from_session_identity_mismatch");
|
|
687
|
+
expect(refused.body.token_alias).toBe("");
|
|
688
|
+
expect(taskCount(claimed)).toBe(0);
|
|
689
|
+
expect(db.get<{ name: string }>(
|
|
690
|
+
"SELECT name FROM api_tokens WHERE token_hash = ?1",
|
|
691
|
+
hashToken(created.network_token),
|
|
692
|
+
)?.name).toBe("default-network");
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("registration network token signs as the username", async () => {
|
|
696
|
+
const username = `idvreg${stamp}`;
|
|
697
|
+
const created = register(username, "CallerIdentReg123!", undefined, "seed");
|
|
698
|
+
if (!created.ok || !created.network_token || !created.network_id || !created.user) {
|
|
699
|
+
throw new Error(created.error || "plain network token register failed");
|
|
700
|
+
}
|
|
701
|
+
const net = created.network_id;
|
|
702
|
+
const tokenRow = db.get<{ name: string }>(
|
|
703
|
+
"SELECT name FROM api_tokens WHERE token_hash = ?1",
|
|
704
|
+
hashToken(created.network_token),
|
|
705
|
+
);
|
|
706
|
+
expect(tokenRow?.name).toBe("default-network");
|
|
707
|
+
const sink = `idvrgk${stamp}`;
|
|
708
|
+
db.run(
|
|
709
|
+
`INSERT INTO sessions (resume_id, alias, network_id, status, updated_at)
|
|
710
|
+
VALUES (?1, ?2, ?3, 'idle', datetime('now'))`,
|
|
711
|
+
[`sdk-${sink}`, sink, net],
|
|
712
|
+
);
|
|
713
|
+
const task = `plain ntok send ${stamp}`;
|
|
714
|
+
const sent = await tool(created.network_token, "send_task", { alias: sink, task, network_id: net });
|
|
715
|
+
expect(sent.ok).toBe(true);
|
|
716
|
+
expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", task)?.from_name).toBe(username);
|
|
717
|
+
const reported = await tool(created.network_token, "report_status", {
|
|
718
|
+
resume_id: `sdk-${username}`,
|
|
719
|
+
alias: username,
|
|
720
|
+
status: "idle",
|
|
721
|
+
output: "steady",
|
|
722
|
+
network_id: net,
|
|
723
|
+
});
|
|
724
|
+
expect(reported.ok).toBe(true);
|
|
725
|
+
expect(db.get<{ name: string }>(
|
|
726
|
+
"SELECT name FROM api_tokens WHERE token_hash = ?1",
|
|
727
|
+
hashToken(created.network_token),
|
|
728
|
+
)?.name).toBe(`node:${username}`);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("duplicate ownerless row keeps an unbound epoch 0 token online", async () => {
|
|
732
|
+
// Issuer owns neither row. The session points at the ownerless row.
|
|
733
|
+
// Epoch 0 keeps the ownerless-row compatibility; epoch 2 would first-bind.
|
|
734
|
+
const alias = `idvord${stamp}`;
|
|
735
|
+
const ownedId = `n_idv_orda_${stamp}`;
|
|
736
|
+
const ownerlessNode = `n_idv_ordb_${stamp}`;
|
|
737
|
+
db.run(
|
|
738
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
739
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
|
|
740
|
+
[ownedId, alias, NET, ownerId],
|
|
741
|
+
);
|
|
742
|
+
db.run(
|
|
743
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
744
|
+
VALUES (?1, ?2, ?2, ?3, NULL, datetime('now'))`,
|
|
745
|
+
[ownerlessNode, alias, NET],
|
|
746
|
+
);
|
|
747
|
+
db.run(
|
|
748
|
+
`INSERT INTO sessions (resume_id, alias, node_id, network_id, status, updated_at)
|
|
749
|
+
VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
750
|
+
[`sdk-${alias}`, alias, ownerlessNode, NET],
|
|
751
|
+
);
|
|
752
|
+
const tok = mint(outsiderId, NET, alias, 0, null);
|
|
753
|
+
const registered = await report(tok, alias, ownerlessNode, NET, { hostname: "idv-ord-reg" });
|
|
754
|
+
expect(registered.ok).toBe(true);
|
|
755
|
+
const heartbeat = await report(tok, alias, ownerlessNode, NET, { hostname: "idv-ord-beat" });
|
|
756
|
+
expect(heartbeat.ok).toBe(true);
|
|
757
|
+
expect(db.get<{ node_id: string | null }>(
|
|
758
|
+
"SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, NET,
|
|
759
|
+
)?.node_id).toBe(ownerlessNode);
|
|
760
|
+
expect(db.get<{ alias: string; owner_user_id: string | null; hostname: string | null }>(
|
|
761
|
+
"SELECT alias, owner_user_id, hostname FROM nodes WHERE node_id = ?1", ownerlessNode,
|
|
762
|
+
)).toEqual({ alias, owner_user_id: null, hostname: "idv-ord-beat" });
|
|
763
|
+
expect(db.get<{ alias: string; owner_user_id: string | null; hostname: string | null }>(
|
|
764
|
+
"SELECT alias, owner_user_id, hostname FROM nodes WHERE node_id = ?1", ownedId,
|
|
765
|
+
)).toEqual({ alias, owner_user_id: ownerId, hostname: null });
|
|
766
|
+
const task = `ownerless dup send ${stamp}`;
|
|
767
|
+
const sent = await tool(tok.token, "send_task", { alias: sinkAlias, task, network_id: NET });
|
|
768
|
+
expect(sent.ok).toBe(true);
|
|
769
|
+
expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", task)?.from_name).toBe(alias);
|
|
770
|
+
const first = subscribe(`/events/${encodeURIComponent(alias)}`, tok.token, { "X-Anet-Instance-Id": `inst-ord-a-${stamp}` });
|
|
771
|
+
await first.ready;
|
|
772
|
+
expect(first.events.some((e) => e.type === "connected")).toBe(true);
|
|
773
|
+
expect(first.done).toBe(false);
|
|
774
|
+
const second = subscribe(`/events/${encodeURIComponent(alias)}`, tok.token, { "X-Anet-Instance-Id": `inst-ord-b-${stamp}` });
|
|
775
|
+
await second.ready;
|
|
776
|
+
await until(() => first.done, "the ownerless row's own second connection to close the first");
|
|
777
|
+
expect(first.events.some((e) => e.type === "node_connection_superseded")).toBe(true);
|
|
778
|
+
expect(second.done).toBe(false);
|
|
779
|
+
first.abort();
|
|
780
|
+
second.abort();
|
|
781
|
+
}, 15_000);
|