@sleep2agi/commhub-server 0.9.0-preview.28 → 0.9.0-preview.29
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 +4 -0
- package/package.json +4 -4
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-networks-http.test.ts +84 -0
- package/src/api-host-supervisors-fallback.test.ts +1 -6
- package/src/auth.ts +88 -14
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +279 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +10 -17
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +1 -1
- package/src/file-download-authz.test.ts +21 -1
- package/src/file-download-date-bucket.test.ts +1 -1
- package/src/file-network-scope-dev-open.test.ts +1 -1
- package/src/file-network-scope.test.ts +1 -1
- package/src/health-redaction.test.ts +12 -0
- package/src/index.ts +6 -2
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +20 -0
- package/src/node-attrs.test.ts +1 -1
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/rest-explicit-columns-http.test.ts +149 -0
- package/src/rest-identity.test.ts +120 -0
- package/src/rest-identity.ts +93 -0
- package/src/rest-projections.ts +67 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +460 -0
- package/src/scheduled-tasks.ts +538 -0
- package/src/send-reply-agent-warning.test.ts +38 -38
- package/src/server.ts +315 -60
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +329 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +82 -0
- package/src/task-diagnostic.ts +89 -0
- package/src/task-idempotency-mcp.test.ts +137 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +160 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +635 -107
- package/src/uploads-http.test.ts +1 -3
- package/src/uploads.ts +14 -3
|
@@ -27,7 +27,7 @@ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-495-devopen-fs-"));
|
|
|
27
27
|
|
|
28
28
|
// Set env BEFORE importing anything that touches db-adapter.ts or
|
|
29
29
|
// server.ts (both capture env at module load).
|
|
30
|
-
process.env.COMMHUB_DB
|
|
30
|
+
process.env.COMMHUB_DB ||= SERVER_DB;
|
|
31
31
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
32
32
|
process.env.HOST = "127.0.0.1";
|
|
33
33
|
process.env.COMMHUB_DEV_OPEN = "1";
|
|
@@ -34,7 +34,7 @@ const MASTER_TOKEN = process.env.COMMHUB_AUTH_TOKEN ?? `master-495-${Date.now()}
|
|
|
34
34
|
// Set env BEFORE importing anything that pulls in db-adapter.ts (which
|
|
35
35
|
// refuses to open a default DB under NODE_ENV=test) or server.ts (which
|
|
36
36
|
// captures DEV_OPEN + AUTH_TOKEN at module load).
|
|
37
|
-
process.env.COMMHUB_DB
|
|
37
|
+
process.env.COMMHUB_DB ||= SERVER_DB;
|
|
38
38
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
39
39
|
process.env.HOST = "127.0.0.1";
|
|
40
40
|
process.env.COMMHUB_AUTH_TOKEN = MASTER_TOKEN;
|
|
@@ -168,6 +168,10 @@ async function downloadAs(token: string | null, fileId: string): Promise<Respons
|
|
|
168
168
|
return fetch(`${BASE}/api/files/${fileId}`, { headers });
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
async function downloadWithQueryToken(token: string, fileId: string): Promise<Response> {
|
|
172
|
+
return fetch(`${BASE}/api/files/${fileId}?token=${encodeURIComponent(token)}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
171
175
|
describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (owner-only staged)", () => {
|
|
172
176
|
let userBFileId = "";
|
|
173
177
|
|
|
@@ -238,6 +242,13 @@ describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (ow
|
|
|
238
242
|
expect(res.status).toBe(401);
|
|
239
243
|
});
|
|
240
244
|
|
|
245
|
+
test("🔴 valid query token is refused on file GET (credential must stay out of URLs)", async () => {
|
|
246
|
+
const res = await downloadWithQueryToken(userBToken, userBFileId);
|
|
247
|
+
expect(res.status).toBe(401);
|
|
248
|
+
const body: any = await res.json();
|
|
249
|
+
expect(body.error).toBe("unauthorized");
|
|
250
|
+
});
|
|
251
|
+
|
|
241
252
|
test("non-owner request for an UNKNOWN file_id → 404 with same shape as owner-denied (no enumeration signal)", async () => {
|
|
242
253
|
const res = await downloadAs(userAToken, "0123456789abcdef0123456789abcdef");
|
|
243
254
|
expect(res.status).toBe(404);
|
|
@@ -257,6 +268,10 @@ async function headAs(token: string | null, fileId: string): Promise<Response> {
|
|
|
257
268
|
return fetch(`${BASE}/api/files/${fileId}`, { method: "HEAD", headers });
|
|
258
269
|
}
|
|
259
270
|
|
|
271
|
+
async function headWithQueryToken(token: string, fileId: string): Promise<Response> {
|
|
272
|
+
return fetch(`${BASE}/api/files/${fileId}?token=${encodeURIComponent(token)}`, { method: "HEAD" });
|
|
273
|
+
}
|
|
274
|
+
|
|
260
275
|
describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorization (parity with GET)", () => {
|
|
261
276
|
let userBFileId = "";
|
|
262
277
|
|
|
@@ -296,6 +311,11 @@ describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorizatio
|
|
|
296
311
|
expect(res.status).toBe(401);
|
|
297
312
|
});
|
|
298
313
|
|
|
314
|
+
test("🔴 valid query token is refused on file HEAD (same gate as GET)", async () => {
|
|
315
|
+
const res = await headWithQueryToken(userBToken, userBFileId);
|
|
316
|
+
expect(res.status).toBe(401);
|
|
317
|
+
});
|
|
318
|
+
|
|
299
319
|
test("admin HEAD any file → 200 (operational access preserved on HEAD)", async () => {
|
|
300
320
|
const res = await headAs(adminToken, userBFileId);
|
|
301
321
|
expect(res.status).toBe(200);
|
|
@@ -34,7 +34,7 @@ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-509-fs-"));
|
|
|
34
34
|
|
|
35
35
|
// Set env BEFORE importing anything that pulls in db-adapter.ts or
|
|
36
36
|
// server.ts (which capture DEV_OPEN + AUTH_TOKEN at module load).
|
|
37
|
-
process.env.COMMHUB_DB
|
|
37
|
+
process.env.COMMHUB_DB ||= SERVER_DB;
|
|
38
38
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
39
39
|
process.env.HOST = "127.0.0.1";
|
|
40
40
|
delete process.env.COMMHUB_DEV_OPEN;
|
|
@@ -20,7 +20,7 @@ import { tmpdir } from "os";
|
|
|
20
20
|
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-503-devopen-db-")) + "/commhub.db";
|
|
21
21
|
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-503-devopen-fs-"));
|
|
22
22
|
|
|
23
|
-
process.env.COMMHUB_DB
|
|
23
|
+
process.env.COMMHUB_DB ||= SERVER_DB;
|
|
24
24
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
25
25
|
process.env.HOST = "127.0.0.1";
|
|
26
26
|
process.env.COMMHUB_DEV_OPEN = "1";
|
|
@@ -39,7 +39,7 @@ const MASTER_TOKEN = `master-503-${Date.now()}-${Math.floor(Math.random() * 1000
|
|
|
39
39
|
// Env must be set before anything pulls in db-adapter.ts (which refuses
|
|
40
40
|
// the default DB under NODE_ENV=test) or server.ts (which freezes
|
|
41
41
|
// DEV_OPEN + AUTH_TOKEN at module load).
|
|
42
|
-
process.env.COMMHUB_DB
|
|
42
|
+
process.env.COMMHUB_DB ||= SERVER_DB;
|
|
43
43
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
44
44
|
process.env.HOST = "127.0.0.1";
|
|
45
45
|
process.env.COMMHUB_AUTH_TOKEN = MASTER_TOKEN;
|
|
@@ -17,6 +17,7 @@ import { join } from "path";
|
|
|
17
17
|
import { tmpdir } from "os";
|
|
18
18
|
import { register, addNetworkMember, removeNetworkMember } from "./auth.js";
|
|
19
19
|
import { db } from "./db.js";
|
|
20
|
+
import { MAX_REQUEST_CONTENT_LENGTH, MAX_UPLOAD_BYTES } from "./uploads.js";
|
|
20
21
|
|
|
21
22
|
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-health-red-db-")) + "/commhub.db";
|
|
22
23
|
|
|
@@ -91,6 +92,17 @@ describe("#473 anonymous /health — watchdog contract intact, no topology leak"
|
|
|
91
92
|
expect(body.sse_connections).toBeGreaterThanOrEqual(1);
|
|
92
93
|
});
|
|
93
94
|
|
|
95
|
+
test("exposes stable upload limits anonymously without exposing topology", async () => {
|
|
96
|
+
const res = await fetch(`${BASE}/health`);
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
const body = await res.json() as any;
|
|
99
|
+
expect(body.limits).toEqual({
|
|
100
|
+
max_upload_bytes: MAX_UPLOAD_BYTES,
|
|
101
|
+
max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
|
|
102
|
+
});
|
|
103
|
+
expect(body.limits.max_request_content_length).toBeGreaterThan(body.limits.max_upload_bytes);
|
|
104
|
+
});
|
|
105
|
+
|
|
94
106
|
test("CLI count source stays truthful: health.sse_connections == /api/stats/sse total (no fake 0)", async () => {
|
|
95
107
|
// The CLI's `anet doctor` line reads health.sse_connections now
|
|
96
108
|
// (was Object.keys(sse_sessions).length). This pins that aggregate to
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
// tests and tooling import "./server.js" and get bootServer/startHub
|
|
7
7
|
// without binding any port. Do NOT add logic here, and do NOT import
|
|
8
8
|
// this module to "get at the server" — that starts one.
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// The default production DB path is deliberately unavailable to ordinary
|
|
10
|
+
// imports / bun -e probes. This run-entry is one of the two canonical places
|
|
11
|
+
// allowed to opt in, and it must do so before dynamically importing server.ts
|
|
12
|
+
// (whose dependency graph constructs the DB adapter at module evaluation).
|
|
13
|
+
process.env.COMMHUB_SERVER = "1";
|
|
14
|
+
const { startHub } = await import("./server.js");
|
|
11
15
|
startHub();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
6
|
+
import { db } from "./db.js";
|
|
7
|
+
|
|
8
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-network-name-http-"));
|
|
9
|
+
let server: any;
|
|
10
|
+
let base = "";
|
|
11
|
+
let userToken = "";
|
|
12
|
+
let nodeToken = "";
|
|
13
|
+
let networkId = "";
|
|
14
|
+
let username = "";
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
18
|
+
username = `network_name_${Date.now()}_${process.pid}`;
|
|
19
|
+
const registered = register(username, "NetworkName123!", undefined, "seed");
|
|
20
|
+
expect(registered.ok).toBe(true);
|
|
21
|
+
userToken = registered.token!;
|
|
22
|
+
networkId = registered.network_id!;
|
|
23
|
+
const userId = db.get<{ user_id: string }>(
|
|
24
|
+
"SELECT user_id FROM users WHERE username = ?1",
|
|
25
|
+
[username],
|
|
26
|
+
)!.user_id;
|
|
27
|
+
const minted = createNetworkTokenForNode(userId, networkId, "network-name-node");
|
|
28
|
+
expect(minted.ok).toBe(true);
|
|
29
|
+
nodeToken = minted.token!;
|
|
30
|
+
const mod: any = await import("./server.js");
|
|
31
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
32
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
try { server?.stop?.(true); } catch {}
|
|
37
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function networks(token: string): Promise<any[]> {
|
|
41
|
+
const response = await fetch(`${base}/api/networks`, {
|
|
42
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
43
|
+
});
|
|
44
|
+
expect(response.status).toBe(200);
|
|
45
|
+
const body = await response.json() as any;
|
|
46
|
+
expect(body.ok).toBe(true);
|
|
47
|
+
return body.networks;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("GET /api/networks name compatibility (#449)", () => {
|
|
51
|
+
test("utok rows expose equal non-null name and network_name", async () => {
|
|
52
|
+
const rows = await networks(userToken);
|
|
53
|
+
expect(rows).toHaveLength(1);
|
|
54
|
+
expect(rows[0].network_id).toBe(networkId);
|
|
55
|
+
// #449 is about `name` and `network_name` agreeing and being non-null.
|
|
56
|
+
// Pin them to the registered username rather than the literal "default":
|
|
57
|
+
// the auto-created network is named after its owner, so hardcoding the
|
|
58
|
+
// old literal would test the naming scheme instead of the compat shim.
|
|
59
|
+
expect(rows[0].network_name).toBe(username);
|
|
60
|
+
expect(rows[0].name).toBe(username);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("network-bound ntok rows expose the same alias", async () => {
|
|
64
|
+
const rows = await networks(nodeToken);
|
|
65
|
+
expect(rows).toHaveLength(1);
|
|
66
|
+
expect(rows[0].network_id).toBe(networkId);
|
|
67
|
+
expect(rows[0].network_name).toBe(username);
|
|
68
|
+
expect(rows[0].name).toBe(rows[0].network_name);
|
|
69
|
+
expect(rows[0].name).not.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/network-scope.ts
CHANGED
|
@@ -72,6 +72,26 @@ export function singleNetworkId(scope: RestNetworkScope): string | null {
|
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Resolve one unambiguous network for a REST write.
|
|
77
|
+
*
|
|
78
|
+
* Admin read scope is intentionally global (`networkIds=null`), so it cannot
|
|
79
|
+
* by itself express that an admin happens to have exactly one membership.
|
|
80
|
+
* Writes must not inherit that ambiguity: use the sole real membership when
|
|
81
|
+
* one exists, and keep requiring an explicit network for zero or 2+.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveRestWriteNetworkId(
|
|
84
|
+
scope: RestNetworkScope,
|
|
85
|
+
authCtx: { userId: string; networkId: string | null } | null,
|
|
86
|
+
isAdmin: boolean,
|
|
87
|
+
): string | null {
|
|
88
|
+
const scoped = singleNetworkId(scope);
|
|
89
|
+
if (scoped) return scoped;
|
|
90
|
+
if (!authCtx || !isAdmin) return null;
|
|
91
|
+
const memberships = getUserNetworkIds(authCtx.userId);
|
|
92
|
+
return memberships.length === 1 ? memberships[0] : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
export function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
76
96
|
if (!authCtx) return true; // legacy global token or open dev mode
|
|
77
97
|
if (isAdmin) return true;
|
package/src/node-attrs.test.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { tmpdir } from "os";
|
|
|
25
25
|
import { join } from "path";
|
|
26
26
|
|
|
27
27
|
const tmpDb = join(mkdtempSync(join(tmpdir(), "anet-attrs-")), "commhub.db");
|
|
28
|
-
process.env.COMMHUB_DB
|
|
28
|
+
process.env.COMMHUB_DB ||= tmpDb;
|
|
29
29
|
const PORT = 23000 + Math.floor(Math.random() * 1500);
|
|
30
30
|
process.env.PORT = String(PORT);
|
|
31
31
|
process.env.HOST = "127.0.0.1";
|