@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/bin/commhub.ts +82 -9
- package/package.json +1 -1
- package/src/agent-acl-http.test.ts +3 -1
- package/src/auth.ts +16 -7
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +5 -0
- package/src/db-adapter.ts +14 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/human-dm.ts +35 -8
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/push.ts +80 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +73 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-http.test.ts +3 -2
- package/src/requirements-migrate.ts +60 -0
- package/src/requirements-seq-http.test.ts +2 -1
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-migration.test.ts +13 -0
- package/src/requirements.ts +193 -19
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/server.ts +97 -12
- package/src/skillhub-http.test.ts +16 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/task-access-http.test.ts +375 -0
- package/src/task-access.ts +284 -0
- package/src/tools.ts +50 -9
- package/src/uploads.ts +8 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1638
|
-
if (
|
|
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
|
+
});
|