@sleep2agi/commhub-server 0.9.0-preview.72 → 0.9.0-preview.74

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/src/server.ts CHANGED
@@ -10,12 +10,13 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
10
10
  import { z } from "zod/v4";
11
11
  import { registerTools } from "./tools.js";
12
12
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
13
- import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork } from "./push.js";
13
+ import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
14
14
  import { assertNodeActive } from "./lifecycle-guard.js";
15
15
  import { pendingInboxCount } from "./inbox-count.js";
16
16
  import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
17
17
  import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
18
- import { listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
18
+ import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
19
+ import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
19
20
  import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
20
21
  import { validateAvatarUrl } from "./avatar-validate.js";
21
22
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
@@ -41,6 +42,7 @@ import {
41
42
  stripHostLocalPathsForCrossHostSafe,
42
43
  validateAttachments,
43
44
  validateIndexEntry,
45
+ DM_FILE_SCOPE,
44
46
  } from "./uploads.js";
45
47
  import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
46
48
  import { dirname as pathDirname, join as pathJoin } from "path";
@@ -238,6 +240,16 @@ function isTmuxAllowedIP(ip: string): boolean {
238
240
  function isNodeCredential(resolved: { networkId: string | null } | null | undefined): boolean {
239
241
  return !!resolved?.networkId;
240
242
  }
243
+ // 人员列表的在线点:某人的第一条用户流连上 / 最后一条断开时,推给他所在每个网络里其他连着用户流的成员。
244
+ // 收件人本来就是该网络成员(用户流入口按成员资格鉴权),与 GET /api/networks/:id/humans 同一可见范围。
245
+ // 字段叫 member_user_id:pushUserEvent 会把 user_id 写成**收件人**自己。
246
+ onUserPresenceChange((userId, online, lastSeenAt) => {
247
+ const nets = db.all<{ network_id: string }>("SELECT network_id FROM network_members WHERE user_id = ?1", userId);
248
+ for (const { network_id } of nets) {
249
+ pushUserEventToNetwork(network_id, { type: "member_presence", member_user_id: userId, online, last_seen_at: lastSeenAt }, userId);
250
+ }
251
+ });
252
+
241
253
  function isHubAdminCredential(resolved: { networkId: string | null; user: { role: string } } | null | undefined): boolean {
242
254
  return !!resolved && !resolved.networkId && resolved.user.role === "admin";
243
255
  }
@@ -375,15 +387,16 @@ export function resolvePrincipal(req: Request, options: RequestTokenOptions = {}
375
387
  // these two fields lives here and nowhere else, so the authz rules can be
376
388
  // read as rules rather than as string-checks.
377
389
  export function normalizeEntry(
378
- entry: { owner_id?: unknown; network_id?: unknown },
379
- ): { ownerId: string | null; networkId: string | null } {
390
+ entry: { owner_id?: unknown; network_id?: unknown; scope?: unknown },
391
+ ): { ownerId: string | null; networkId: string | null; dmScoped?: true } {
380
392
  const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
381
393
  ? entry.owner_id
382
394
  : null;
383
395
  const networkId = typeof entry.network_id === "string" && entry.network_id.length > 0
384
396
  ? entry.network_id
385
397
  : null;
386
- return { ownerId, networkId };
398
+ // dmScoped 只在是私信文件时出现:其余条目的形状与之前逐字相同。
399
+ return { ownerId, networkId, ...(entry.scope === DM_FILE_SCOPE ? { dmScoped: true as const } : {}) };
387
400
  }
388
401
 
389
402
  // #495/#503 — shared authorization gate for /api/files/:file_id downloads.
@@ -404,6 +417,13 @@ export function normalizeEntry(
404
417
  // a task but not open its attachment is a broken product).
405
418
  // - entry.network_id absent (legacy / unattributed) → the pre-#503
406
419
  // rules stand unchanged: owner match, or null owner_id + DEV_OPEN.
420
+ // - entry.dmScoped (uploaded with ?purpose=dm — its first use is a
421
+ // human DM) → only the uploader and the users on either side of a DM
422
+ // that carries it (human-dm.ts dmParticipantSeesFile). Other members of
423
+ // the network and node tokens (agents) are denied. admin-utok and the
424
+ // legacy master token still pass above, as for every other file.
425
+ // Files uploaded any other way never become DM-scoped, including when
426
+ // they are later forwarded into a DM.
407
427
  // - Everything else → deny (404). New uploads in production always
408
428
  // carry a truthy owner_id (requireAuth blocks the null-owner-
409
429
  // producing path when DEV_OPEN=off and no legacy master token is
@@ -418,7 +438,7 @@ export function normalizeEntry(
418
438
  // 所有文件在同一 network → 无实际影响, 但语义变更须明写, 不能悄悄发生.
419
439
  export function authorizeFileDownload(
420
440
  principal: Principal,
421
- entry: { ownerId: string | null; networkId: string | null },
441
+ entry: { ownerId: string | null; networkId: string | null; dmScoped?: boolean },
422
442
  fileId?: string,
423
443
  ): boolean {
424
444
  if (principal.kind === "legacy-master") return true;
@@ -426,8 +446,13 @@ export function authorizeFileDownload(
426
446
 
427
447
  if (entry.networkId !== null) {
428
448
  if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
429
- if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId;
449
+ if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId && !entry.dmScoped;
430
450
  if (!getUserNetworkRole(principal.userId, entry.networkId)) return false;
451
+ // 私信文件:只看是不是这段私信里的人,与受限 / 不受限无关。
452
+ if (entry.dmScoped) {
453
+ if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
454
+ return !!fileId && dmParticipantSeesFile(principal.userId, entry.networkId, fileId);
455
+ }
431
456
  // 多用户 Agent 权限:受限成员只能下载自己传的,或者出现在自己与授权 Agent 往来 / 自己收件箱里的文件。
432
457
  if (!isAgentRestricted(principal.userId, entry.networkId)) return true;
433
458
  if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
@@ -1538,6 +1563,51 @@ return Bun.serve({
1538
1563
  // ── 多用户 Agent 权限:成员可访问的 Agent(owner/admin 管理) ──
1539
1564
  // GET → { agent_access: 'all'|'granted', grants: [{node_id, alias, can_message}] }
1540
1565
  // PUT { grants?: [{node_id | alias, can_message?}] | string[], agent_access?: 'all'|'granted' } → 整体替换
1566
+ // ── 任务(需求卡)的人员权限(RFC-038 §9):成员能看 / 能改哪些卡。owner/admin/Hub 管理员管理 ──
1567
+ // GET → { task_access: 'all'|'scoped', restricted, project_grants: [{project_id, can_edit}] }
1568
+ // PUT { task_access?, project_grants?: [{project_id, can_edit?}] | string[] } → 不传的字段保持原样;传了整体替换
1569
+ const taskGrantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/task-grants$/);
1570
+ if (taskGrantsMatch && (req.method === "GET" || req.method === "PUT")) {
1571
+ const token = requestToken(req, { allowQueryToken: false });
1572
+ const resolved = token ? resolveToken(token) : null;
1573
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1574
+ if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
1575
+ const netId = decodeURIComponent(taskGrantsMatch[1]);
1576
+ const targetUid = decodeURIComponent(taskGrantsMatch[2]);
1577
+ const hubAdmin = resolved.user.role === "admin";
1578
+ const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
1579
+ if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
1580
+ return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1581
+ }
1582
+ const targetRole = getUserNetworkRole(targetUid, netId);
1583
+ if (!targetRole) return withCors(req, Response.json({ ok: false, error: "member_not_found" }, { status: 404 }));
1584
+ const view = () => ({
1585
+ ok: true, network_id: netId, user_id: targetUid, role: targetRole,
1586
+ task_access: getTaskAccessMode(netId, targetUid), restricted: isTaskScoped(targetUid, netId),
1587
+ project_grants: listProjectGrants(netId, targetUid),
1588
+ });
1589
+ if (req.method === "GET") return withCors(req, Response.json(view()));
1590
+ // 网络 admin 不能改 owner / 其他 admin(对他们本来也不生效)。
1591
+ if (!hubAdmin && callerRole !== "owner" && (targetRole === "owner" || targetRole === "admin")) {
1592
+ return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
1593
+ }
1594
+ let body: any;
1595
+ try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
1596
+ if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
1597
+ if (body.task_access === undefined && body.project_grants === undefined) {
1598
+ return withCors(req, Response.json({ ok: false, error: "task_access or project_grants required" }, { status: 400 }));
1599
+ }
1600
+ const before = { task_access: getTaskAccessMode(netId, targetUid), project_grants: listProjectGrants(netId, targetUid) };
1601
+ const result = replaceTaskGrants({ networkId: netId, userId: targetUid, taskAccess: body.task_access, projectGrants: body.project_grants, actorUserId: resolved.user.user_id });
1602
+ if (!result.ok) {
1603
+ return withCors(req, Response.json({ ok: false, error: result.error, ...(result.detail ? { detail: result.detail } : {}) }, { status: result.status }));
1604
+ }
1605
+ logAudit(resolved.user.user_id, resolved.user.username, "member_task_grants_changed", "network", netId,
1606
+ JSON.stringify({ user_id: targetUid, before, after: { task_access: result.task_access, project_grants: result.project_grants } }).slice(0, 4000),
1607
+ undefined, netId);
1608
+ return withCors(req, Response.json(view()));
1609
+ }
1610
+
1541
1611
  const grantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/agent-grants$/);
1542
1612
  if (grantsMatch && (req.method === "GET" || req.method === "PUT")) {
1543
1613
  const token = requestToken(req, { allowQueryToken: false });
@@ -1606,7 +1676,11 @@ return Bun.serve({
1606
1676
  if (resolved.user.role !== "admin" && !getUserNetworkRole(resolved.user.user_id, netId)) {
1607
1677
  return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1608
1678
  }
1609
- return withCors(req, Response.json({ ok: true, network_id: netId, humans: getNetworkHumans(netId) }));
1679
+ const humans = getNetworkHumans(netId);
1680
+ // 在线状态(online / last_seen_at)只给用户令牌:节点令牌拿到的仍是原来的身份字段,不多得任何东西。
1681
+ if (isNodeCredential(resolved)) return withCors(req, Response.json({ ok: true, network_id: netId, humans }));
1682
+ const presence = getUserPresence(humans.map((h) => h.user_id));
1683
+ return withCors(req, Response.json({ ok: true, network_id: netId, humans: humans.map((h) => ({ ...h, ...presence.get(h.user_id)! })) }));
1610
1684
  }
1611
1685
 
1612
1686
  // ── V3.13: Network members + invites ──
@@ -1634,8 +1708,12 @@ return Bun.serve({
1634
1708
  if (body.agent_access !== undefined && body.agent_access !== "all" && body.agent_access !== "granted") {
1635
1709
  return withCors(req, Response.json({ ok: false, error: "invalid_agent_access" }, { status: 400 }));
1636
1710
  }
1637
- const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access });
1638
- if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"}`);
1711
+ // task_access 缺省 NEW_MEMBER_TASK_ACCESS(RFC-038 §9.4);显式传 'all' / 'scoped' 覆盖。
1712
+ if (body.task_access !== undefined && body.task_access !== "all" && body.task_access !== "scoped") {
1713
+ return withCors(req, Response.json({ ok: false, error: "invalid_task_access" }, { status: 400 }));
1714
+ }
1715
+ const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access, taskAccess: body.task_access });
1716
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"} task_access=${body.task_access ?? NEW_MEMBER_TASK_ACCESS}`, undefined, netId);
1639
1717
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1640
1718
  }
1641
1719
  if (req.method === "PUT" && targetUid) {
@@ -1643,7 +1721,7 @@ return Bun.serve({
1643
1721
  const body = await req.json() as any;
1644
1722
  const result = updateMemberRole(netId, targetUid, body.role);
1645
1723
  if (result.ok) {
1646
- logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
1724
+ logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`, undefined, netId);
1647
1725
  closeUserStreamsInNetwork(netId, targetUid);
1648
1726
  }
1649
1727
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
@@ -1652,7 +1730,7 @@ return Bun.serve({
1652
1730
  if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1653
1731
  const result = removeNetworkMember(netId, targetUid);
1654
1732
  if (result.ok) {
1655
- logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
1733
+ logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid, undefined, netId);
1656
1734
  closeUserStreamsInNetwork(netId, targetUid);
1657
1735
  }
1658
1736
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
@@ -2440,6 +2518,12 @@ return Bun.serve({
2440
2518
  // uploaded and never gets their multipart envelope parsed.
2441
2519
  const principal = resolvePrincipal(req);
2442
2520
  const requestedNetId = url.searchParams.get("network_id");
2521
+ // ?purpose=dm:这个文件是为一条人与人私信传的(见 UploadIndexEntry.scope)。缺省 = 旧行为。
2522
+ // 拼错的值拒掉,不静默当成缺省 —— 否则私信图会不声不响地变成全网可见。
2523
+ const purpose = url.searchParams.get("purpose");
2524
+ if (purpose !== null && purpose !== DM_FILE_SCOPE) {
2525
+ return earlyReject(Response.json({ ok: false, error: "bad_purpose", message: `purpose must be "${DM_FILE_SCOPE}" or absent` }, { status: 400 }));
2526
+ }
2443
2527
  let uploadNetId: string | null = null;
2444
2528
 
2445
2529
  switch (principal.kind) {
@@ -2616,6 +2700,7 @@ return Bun.serve({
2616
2700
  owner_id: authCtx?.userId ?? null,
2617
2701
  // Key omitted (not null) when unattributed — see UploadIndexEntry.
2618
2702
  ...(uploadNetId ? { network_id: uploadNetId } : {}),
2703
+ ...(purpose === DM_FILE_SCOPE ? { scope: DM_FILE_SCOPE } : {}),
2619
2704
  uploaded_at: new Date().toISOString(),
2620
2705
  }, null, 2));
2621
2706
  } else {
@@ -84,6 +84,22 @@ describe("SkillHub real Hub + SQLite", () => {
84
84
  expect(detail.created_by_user).toBeUndefined(); expect(detail.reviewed_by_user).toBeUndefined(); expect(detail.content_hash).toBeUndefined();
85
85
  });
86
86
 
87
+ test("query matches regardless of letter case (SQLite LIKE semantics, kept on PostgreSQL)", async () => {
88
+ for (const query of ["incident", "INCIDENT", "Handoff"]) {
89
+ const found = await tool(nodeToken, "list_skills", { network_id: networkId, query });
90
+ expect(found.skills.map((x: any) => x.skill_id)).toContain(skillId);
91
+ }
92
+ expect((await tool(nodeToken, "list_skills", { network_id: networkId, query: "no-such-skill" })).skills).toHaveLength(0);
93
+ });
94
+
95
+ test("% and _ in the query are literal characters, not wildcards (#2141)", async () => {
96
+ const ids = async (query: string) => (await tool(nodeToken, "list_skills", { network_id: networkId, query })).skills.map((x: any) => x.skill_id);
97
+ expect(await ids("_")).not.toContain(skillId); // was: matched everything
98
+ expect(await ids("%")).not.toContain(skillId);
99
+ expect(await ids("incident_handoff")).not.toContain(skillId); // `_` must not stand for the "-" in the slug
100
+ expect(await ids("incident-handoff")).toContain(skillId);
101
+ });
102
+
87
103
  test("foreign user cannot read or review another network", async () => {
88
104
  expect((await tool(foreignToken, "list_skills", { network_id: networkId })).ok).toBe(false);
89
105
  const review = await tool(foreignToken, "review_skill", { network_id: networkId, skill_id: skillId, decision: "rejected" });
@@ -0,0 +1,78 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { SQLiteAdapter } from "./db-adapter";
8
+
9
+ // #2151 — a cleanly stopped Hub must leave a complete .db: the -wal is absent
10
+ // or empty, and a copy of the .db file alone still has the data.
11
+ const dir = mkdtempSync(join(tmpdir(), "anet-wal-close-"));
12
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
13
+
14
+ const walBytes = (db: string) => (existsSync(`${db}-wal`) ? statSync(`${db}-wal`).size : 0);
15
+
16
+ function copyOnlyDb(db: string): Database {
17
+ const copy = join(dirname(db), `copy-of-${db.split("/").pop()}`);
18
+ copyFileSync(db, copy); // deliberately without -wal / -shm
19
+ return new Database(copy, { readonly: true });
20
+ }
21
+
22
+ describe("#2151 SQLite WAL is checkpointed on close", () => {
23
+ test("SQLiteAdapter.close() after >20 distinct statements leaves a complete .db", () => {
24
+ const path = join(dir, "adapter.db");
25
+ const raw = new Database(path);
26
+ raw.exec("PRAGMA journal_mode=WAL");
27
+ const db = new SQLiteAdapter(raw);
28
+ db.exec("CREATE TABLE t (x TEXT)");
29
+ db.run("INSERT INTO t VALUES (?1)", ["kept"]);
30
+ // bun:sqlite stops being able to close after more than 20 distinct query() strings.
31
+ for (let i = 0; i < 30; i++) db.get(`SELECT ${i} AS v`);
32
+ db.close();
33
+ expect(walBytes(path)).toBe(0);
34
+ const copy = copyOnlyDb(path);
35
+ expect(copy.query("SELECT x FROM t").all()).toEqual([{ x: "kept" }]);
36
+ copy.close();
37
+ });
38
+
39
+ test("a real Hub that served traffic and got SIGTERM leaves a complete .db", async () => {
40
+ const bin = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
41
+ const home = join(dir, "home");
42
+ const path = join(dir, "hub.db");
43
+ const port = String(27000 + Math.floor(Math.random() * 2000));
44
+ const base = `http://127.0.0.1:${port}`;
45
+ const hub = Bun.spawn(["bun", bin, "--port", port, "--host", "127.0.0.1", "--db", path], {
46
+ env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
47
+ });
48
+ try {
49
+ let up = false;
50
+ for (let i = 0; i < 150 && !up; i++) {
51
+ up = await fetch(`${base}/health`).then((r) => r.ok, () => false);
52
+ if (!up) await Bun.sleep(100);
53
+ }
54
+ expect(up).toBe(true);
55
+ const post = (p: string, body: unknown, token?: string) => fetch(`${base}${p}`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
58
+ body: JSON.stringify(body),
59
+ }).then((r) => r.json() as Promise<any>);
60
+ const reg = await post("/api/auth/register", { username: "walcheck", password: "StrongPassw0rd" });
61
+ expect(reg.ok).toBe(true);
62
+ const login = await post("/api/auth/login", { username: "walcheck", password: "StrongPassw0rd" });
63
+ const net = await post("/api/networks", { name: "wal-net" }, login.token);
64
+ expect(net.ok).toBe(true);
65
+ const ntok = await post("/api/auth/node-token", { network_id: net.network_id, node_name: "wal-agent" }, login.token);
66
+ expect(ntok.ok).toBe(true);
67
+ expect(walBytes(path)).toBeGreaterThan(0); // the writes really are in the WAL before the stop
68
+ } finally {
69
+ hub.kill("SIGTERM");
70
+ await hub.exited;
71
+ }
72
+ expect(walBytes(path)).toBe(0);
73
+ const copy = copyOnlyDb(path);
74
+ expect(copy.query("SELECT COUNT(*) AS n FROM users WHERE username = 'walcheck'").get()).toEqual({ n: 1 });
75
+ expect(copy.query("SELECT COUNT(*) AS n FROM networks WHERE network_name = 'wal-net'").get()).toEqual({ n: 1 });
76
+ copy.close();
77
+ }, 40_000);
78
+ });