@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.60
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -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-nodes-shape.test.ts +26 -10
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +55 -7
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +710 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -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 +320 -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 +403 -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 +731 -0
- package/src/health-redaction.test.ts +641 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2227
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +104 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/observer-avatar-http.test.ts +428 -0
- package/src/observer-push.test.ts +252 -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/push.ts +163 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -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 +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/retention.ts +10 -1
- 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/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +483 -0
- package/src/scheduled-tasks.ts +549 -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/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +3842 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -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 +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +91 -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-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -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 +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3231 -204
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { db } from "./db.js";
|
|
3
|
+
import { parseStoredTags } from "./node-attrs-validate.js";
|
|
3
4
|
|
|
4
5
|
// Regression test for SELECT * fragility on GET /api/nodes.
|
|
5
6
|
//
|
|
@@ -41,7 +42,12 @@ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
41
42
|
catch { /* malformed */ }
|
|
42
43
|
}
|
|
43
44
|
const { config_snapshot, ...rest } = r;
|
|
44
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
...rest,
|
|
47
|
+
tags: parseStoredTags(r.tags),
|
|
48
|
+
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
49
|
+
role,
|
|
50
|
+
};
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
|
|
@@ -66,7 +72,7 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
66
72
|
`SELECT node_id, node_name, alias, runtime, model,
|
|
67
73
|
config_path, channels, server, hostname,
|
|
68
74
|
network_id, created_at, updated_at,
|
|
69
|
-
config_snapshot
|
|
75
|
+
config_snapshot, display_name, team, tags, attrs_revision
|
|
70
76
|
FROM nodes WHERE node_id = ?1`,
|
|
71
77
|
TEST_NODE,
|
|
72
78
|
);
|
|
@@ -85,11 +91,17 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
85
91
|
expect(row).not.toHaveProperty("config_snapshot");
|
|
86
92
|
// role is EXTRACTED from config_snapshot JSON, not the raw blob
|
|
87
93
|
expect(row.role).toBe("host_supervisor");
|
|
88
|
-
// Belt:
|
|
94
|
+
// Belt: the key set is exactly the dashboard-facing contract.
|
|
95
|
+
// NOTE: this file MIRRORS the handler's SQL rather than calling it, so
|
|
96
|
+
// it can drift — it already lagged behind `lifecycle_state` /
|
|
97
|
+
// `avatar_url`, which the handler selects but this mirror does not.
|
|
98
|
+
// Treat a diff here as "re-read the handler", not as proof of the wire
|
|
99
|
+
// shape; node-attrs.test.ts asserts the real HTTP response.
|
|
89
100
|
expect(Object.keys(row).sort()).toEqual([
|
|
90
|
-
"alias", "
|
|
91
|
-
"
|
|
92
|
-
"server", "
|
|
101
|
+
"alias", "attrs_revision", "channels", "config_path", "created_at",
|
|
102
|
+
"display_name", "hostname", "model", "network_id", "node_id",
|
|
103
|
+
"node_name", "role", "runtime", "server", "tags", "team",
|
|
104
|
+
"updated_at",
|
|
93
105
|
]);
|
|
94
106
|
});
|
|
95
107
|
|
|
@@ -102,7 +114,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
102
114
|
);
|
|
103
115
|
let row = mapRow(db.get<Record<string, unknown>>(
|
|
104
116
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
105
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
117
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
118
|
+
display_name, team, tags, attrs_revision
|
|
106
119
|
FROM nodes WHERE node_id = ?1`,
|
|
107
120
|
TEST_NODE,
|
|
108
121
|
)!);
|
|
@@ -113,7 +126,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
113
126
|
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
|
|
114
127
|
row = mapRow(db.get<Record<string, unknown>>(
|
|
115
128
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
116
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
129
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
130
|
+
display_name, team, tags, attrs_revision
|
|
117
131
|
FROM nodes WHERE node_id = ?1`,
|
|
118
132
|
TEST_NODE,
|
|
119
133
|
)!);
|
|
@@ -124,7 +138,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
124
138
|
[JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
|
|
125
139
|
row = mapRow(db.get<Record<string, unknown>>(
|
|
126
140
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
127
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
141
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
142
|
+
display_name, team, tags, attrs_revision
|
|
128
143
|
FROM nodes WHERE node_id = ?1`,
|
|
129
144
|
TEST_NODE,
|
|
130
145
|
)!);
|
|
@@ -141,7 +156,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
141
156
|
);
|
|
142
157
|
const rows = db.all<Record<string, unknown>>(
|
|
143
158
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
144
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
159
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
160
|
+
display_name, team, tags, attrs_revision
|
|
145
161
|
FROM nodes WHERE network_id = ?1`,
|
|
146
162
|
TEST_NETWORK,
|
|
147
163
|
).map(mapRow);
|
|
@@ -10,13 +10,17 @@ import { register } from "./auth.js";
|
|
|
10
10
|
|
|
11
11
|
// First user gets RELAXED rules (admin bootstrap allows 4-char "anethub" etc.)
|
|
12
12
|
// To test full-strength rules, seed a dummy admin first.
|
|
13
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
14
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
15
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
16
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
13
17
|
beforeAll(() => {
|
|
14
18
|
// Idempotent: if DB file already has users (from a previous run on the
|
|
15
19
|
// same temp path), this errors with "username already taken" → that's
|
|
16
20
|
// fine, the goal is just "ensure at least one user exists so subsequent
|
|
17
21
|
// registers hit the strict path".
|
|
18
22
|
register("_seed_admin", "BootstrapPw1", undefined, "seed");
|
|
19
|
-
});
|
|
23
|
+
}, 30_000);
|
|
20
24
|
|
|
21
25
|
describe("register — username rules", () => {
|
|
22
26
|
it("rejects empty username", () => {
|
package/src/auth.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
|
|
5
5
|
import { WEAK_PASSWORDS } from "./password-dict.js";
|
|
6
|
+
import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
|
|
6
7
|
|
|
7
8
|
// Round-6 A1 hardening — dummy hash for username-enumeration timing
|
|
8
9
|
// close. We compute ONE scrypt hash of a throwaway password and reuse
|
|
@@ -87,11 +88,32 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
87
88
|
[userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
|
|
88
89
|
);
|
|
89
90
|
|
|
90
|
-
// Auto-create
|
|
91
|
+
// Auto-create the user's own network + add as owner member.
|
|
92
|
+
//
|
|
93
|
+
// Name it after the user rather than the literal "default". Every
|
|
94
|
+
// registration used to mint a network called "default", so a hub with N
|
|
95
|
+
// users showed N identical "default" rows in the Dashboard sidebar with
|
|
96
|
+
// no way to tell them apart (23 of them on the live hub, 22 empty).
|
|
97
|
+
// Three CLI call sites (bin/cli.ts debate / demo-social / pr-review) resolve
|
|
98
|
+
// a network as:
|
|
99
|
+
// me.user.default_network_id
|
|
100
|
+
// || networks.find(n => n.network_name === "default")
|
|
101
|
+
// || networks[0]
|
|
102
|
+
// Checked against the live hub: GET /api/auth/me returns
|
|
103
|
+
// {user_id, username, display_name, email, role} — there is no
|
|
104
|
+
// default_network_id field, and nothing in server/ ever writes one. So the
|
|
105
|
+
// first term is always undefined and the name lookup is the operative path,
|
|
106
|
+
// not a fallback.
|
|
107
|
+
//
|
|
108
|
+
// After this change that lookup misses for new users and they land on
|
|
109
|
+
// networks[0]. That still resolves to this network because listNetworks()
|
|
110
|
+
// is `ORDER BY created_at` ascending (auth.ts:296) and this row is the
|
|
111
|
+
// user's oldest. Reordering that query would silently point those three
|
|
112
|
+
// commands at the wrong network.
|
|
91
113
|
const networkId = generateId("net");
|
|
92
114
|
db.run(
|
|
93
115
|
"INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
|
|
94
|
-
[networkId,
|
|
116
|
+
[networkId, username, userId, `Auto-created network for ${username}`]
|
|
95
117
|
);
|
|
96
118
|
db.run(
|
|
97
119
|
"INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
|
|
@@ -191,18 +213,67 @@ export function login(username: string, password: string): AuthResult {
|
|
|
191
213
|
};
|
|
192
214
|
}
|
|
193
215
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Create a network-scoped token (ntok_) for a specific node.
|
|
218
|
+
*
|
|
219
|
+
* RFC-036: callers that provide nodeId atomically establish the immutable
|
|
220
|
+
* node-owner binding before the plaintext token is returned. The 3-argument
|
|
221
|
+
* legacy form remains available for old clients, but its token has no bound
|
|
222
|
+
* node id and therefore cannot consume external-schedule edit intents.
|
|
223
|
+
*/
|
|
224
|
+
export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string, nodeId?: string): { ok: boolean; token?: string; token_id?: string; node_id?: string; error?: string } {
|
|
196
225
|
// Verify user is a member of this network with write access
|
|
197
226
|
const role = getUserNetworkRole(userId, networkId);
|
|
198
227
|
if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
|
|
228
|
+
if (!nodeName || nodeName.length > 200) return { ok: false, error: "invalid_node_name" };
|
|
229
|
+
if (nodeId !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(nodeId))) {
|
|
230
|
+
return { ok: false, error: "invalid_node_id" };
|
|
231
|
+
}
|
|
199
232
|
const token = generateNetworkToken();
|
|
200
233
|
const tokenId = generateId("tok");
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
234
|
+
try {
|
|
235
|
+
db.transaction(() => {
|
|
236
|
+
if (nodeId) {
|
|
237
|
+
const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
|
|
238
|
+
"SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
|
|
239
|
+
nodeId,
|
|
240
|
+
);
|
|
241
|
+
if (existing?.network_id && existing.network_id !== networkId) throw new Error("cross_network_node");
|
|
242
|
+
// Existing pre-RFC-036 rows have no trustworthy owner anchor. A token
|
|
243
|
+
// refresh must not turn knowledge of a legacy node_id into ownership.
|
|
244
|
+
// They remain read-only until a separately authorized migration.
|
|
245
|
+
if (existing && !existing.owner_user_id) throw new Error("node_owner_unclaimed");
|
|
246
|
+
if (existing?.owner_user_id && existing.owner_user_id !== userId) throw new Error("node_owner_mismatch");
|
|
247
|
+
db.run(
|
|
248
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
249
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))
|
|
250
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
251
|
+
node_name = COALESCE(nodes.node_name, ?2),
|
|
252
|
+
alias = COALESCE(nodes.alias, ?2),
|
|
253
|
+
network_id = COALESCE(nodes.network_id, ?3),
|
|
254
|
+
owner_user_id = COALESCE(nodes.owner_user_id, ?4),
|
|
255
|
+
updated_at = datetime('now')`,
|
|
256
|
+
[nodeId, nodeName, networkId, userId],
|
|
257
|
+
);
|
|
258
|
+
if (!existing) {
|
|
259
|
+
db.run(
|
|
260
|
+
`INSERT INTO audit_log (user_id, action, target_type, target_id, detail, network_id)
|
|
261
|
+
VALUES (?1, 'external_schedule.owner_claimed', 'node', ?2, ?3, ?4)`,
|
|
262
|
+
[userId, nodeId, JSON.stringify({ node_id: nodeId, network_id: networkId }), networkId],
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
db.run(
|
|
267
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
|
|
268
|
+
[tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, nodeId ?? null],
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
} catch (error: any) {
|
|
272
|
+
const code = String(error?.message || "node_token_create_failed");
|
|
273
|
+
if (code === "cross_network_node" || code === "node_owner_unclaimed" || code === "node_owner_mismatch") return { ok: false, error: code };
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, token, token_id: tokenId, ...(nodeId ? { node_id: nodeId } : {}) };
|
|
206
277
|
}
|
|
207
278
|
|
|
208
279
|
export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
|
|
@@ -235,7 +306,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
235
306
|
|
|
236
307
|
export function getUserNetworks(userId: string) {
|
|
237
308
|
return db.all<any>(
|
|
238
|
-
|
|
309
|
+
`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE owner_id = ?1 ORDER BY created_at`,
|
|
239
310
|
userId);
|
|
240
311
|
}
|
|
241
312
|
|
|
@@ -280,7 +351,7 @@ export function listTokens(userId: string) {
|
|
|
280
351
|
}
|
|
281
352
|
|
|
282
353
|
export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
|
|
283
|
-
const net = db.get<any>("SELECT
|
|
354
|
+
const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
|
|
284
355
|
if (!net) return { ok: false, error: "network not found" };
|
|
285
356
|
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
286
357
|
const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
|
|
@@ -290,13 +361,18 @@ export function renameNetwork(userId: string, networkId: string, newName: string
|
|
|
290
361
|
}
|
|
291
362
|
|
|
292
363
|
export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
|
|
293
|
-
const net = db.get<any>("SELECT
|
|
364
|
+
const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
|
|
294
365
|
if (!net) return { ok: false, error: "network not found" };
|
|
295
366
|
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
296
367
|
// Check if any sessions/tasks still reference this network
|
|
297
368
|
const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
298
369
|
if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
|
|
299
370
|
db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
|
|
371
|
+
// PR #519 codex catch: leaving membership rows behind made deleted
|
|
372
|
+
// networks count toward getUserNetworkIds — a stale row could flip a
|
|
373
|
+
// single-network user to "ambiguous" or auto-resolve writes INTO the
|
|
374
|
+
// deleted network. Remove memberships with the network.
|
|
375
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
|
|
300
376
|
return { ok: true };
|
|
301
377
|
}
|
|
302
378
|
|
|
@@ -444,7 +520,10 @@ export function createInvite(networkId: string, createdBy: string, role: string
|
|
|
444
520
|
}
|
|
445
521
|
|
|
446
522
|
export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
|
|
447
|
-
const invite = db.get<any>(
|
|
523
|
+
const invite = db.get<any>(
|
|
524
|
+
"SELECT invite_code, network_id, role, created_by, max_uses, used_count, expires_at, created_at FROM network_invites WHERE invite_code = ?1",
|
|
525
|
+
inviteCode,
|
|
526
|
+
);
|
|
448
527
|
if (!invite) return { ok: false, error: "invalid invite code" };
|
|
449
528
|
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
|
|
450
529
|
if (invite.expires_at) {
|
|
@@ -469,7 +548,7 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
|
|
|
469
548
|
/** Get all networks a user is a member of (replaces owner-only query) */
|
|
470
549
|
export function getUserAllNetworks(userId: string) {
|
|
471
550
|
return db.all<any>(
|
|
472
|
-
`SELECT n
|
|
551
|
+
`SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role
|
|
473
552
|
FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
|
|
474
553
|
WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
|
|
475
554
|
userId);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// #462 — unit tests for the avatar_url XSS boundary (avatar-validate.ts).
|
|
2
|
+
//
|
|
3
|
+
// The validator's whole job is to keep hostile values out of a dashboard
|
|
4
|
+
// <img src>; every rejection case here is a concrete injection shape.
|
|
5
|
+
// Live-gate check: replace validateAvatarUrl with `return { ok: true,
|
|
6
|
+
// value: String(raw) }` and the javascript:/data:/control-char tests go
|
|
7
|
+
// red.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { MAX_AVATAR_URL_LENGTH, validateAvatarUrl } from "./avatar-validate";
|
|
11
|
+
|
|
12
|
+
describe("#462 validateAvatarUrl", () => {
|
|
13
|
+
test("accepts plain https URL", () => {
|
|
14
|
+
const r = validateAvatarUrl("https://cdn.example.com/a/b.png");
|
|
15
|
+
expect(r).toEqual({ ok: true, value: "https://cdn.example.com/a/b.png" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("accepts http URL and trims surrounding whitespace", () => {
|
|
19
|
+
const r = validateAvatarUrl(" http://example.com/x.jpg ");
|
|
20
|
+
expect(r).toEqual({ ok: true, value: "http://example.com/x.jpg" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("accepts URL with query string", () => {
|
|
24
|
+
const r = validateAvatarUrl("https://example.com/img?size=64&v=2");
|
|
25
|
+
expect(r.ok).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("null / undefined / empty / whitespace-only → clear (value null)", () => {
|
|
29
|
+
expect(validateAvatarUrl(null)).toEqual({ ok: true, value: null });
|
|
30
|
+
expect(validateAvatarUrl(undefined)).toEqual({ ok: true, value: null });
|
|
31
|
+
expect(validateAvatarUrl("")).toEqual({ ok: true, value: null });
|
|
32
|
+
expect(validateAvatarUrl(" ")).toEqual({ ok: true, value: null });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rejects non-string types", () => {
|
|
36
|
+
expect(validateAvatarUrl(42).ok).toBe(false);
|
|
37
|
+
expect(validateAvatarUrl({ url: "https://x" }).ok).toBe(false);
|
|
38
|
+
expect(validateAvatarUrl(["https://x"]).ok).toBe(false);
|
|
39
|
+
expect(validateAvatarUrl(true).ok).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects javascript: pseudo-protocol", () => {
|
|
43
|
+
expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
|
|
44
|
+
expect(validateAvatarUrl("JavaScript:alert(1)").ok).toBe(false);
|
|
45
|
+
expect(validateAvatarUrl("JAVASCRIPT:alert(document.cookie)").ok).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects javascript: smuggled via embedded tab/newline (URL() would strip them)", () => {
|
|
49
|
+
expect(validateAvatarUrl("java\tscript:alert(1)").ok).toBe(false);
|
|
50
|
+
expect(validateAvatarUrl("java\nscript:alert(1)").ok).toBe(false);
|
|
51
|
+
expect(validateAvatarUrl("java\rscript:alert(1)").ok).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rejects data: / vbscript: / file: / blob: and other non-http protocols", () => {
|
|
55
|
+
expect(validateAvatarUrl("data:text/html,<script>alert(1)</script>").ok).toBe(false);
|
|
56
|
+
expect(validateAvatarUrl("data:image/png;base64,iVBORw0KGgo=").ok).toBe(false);
|
|
57
|
+
expect(validateAvatarUrl("vbscript:msgbox(1)").ok).toBe(false);
|
|
58
|
+
expect(validateAvatarUrl("file:///etc/passwd").ok).toBe(false);
|
|
59
|
+
expect(validateAvatarUrl("blob:https://example.com/uuid").ok).toBe(false);
|
|
60
|
+
expect(validateAvatarUrl("ftp://example.com/a.png").ok).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects protocol-relative / non-URL / schemeless strings", () => {
|
|
64
|
+
// SPEC CHANGE (通信龙 裁定, avatar 接线单): same-origin pool paths
|
|
65
|
+
// (/avatars/<name>.<ext>) are now ACCEPTED — that case moved to the
|
|
66
|
+
// dedicated relative-branch suite below. Everything here stays rejected.
|
|
67
|
+
expect(validateAvatarUrl("//cdn.example.com/1.png").ok).toBe(false);
|
|
68
|
+
expect(validateAvatarUrl("not a url").ok).toBe(false);
|
|
69
|
+
expect(validateAvatarUrl("example.com/x.png").ok).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("rejects embedded whitespace and control characters", () => {
|
|
73
|
+
expect(validateAvatarUrl("https://example.com/a b.png").ok).toBe(false);
|
|
74
|
+
expect(validateAvatarUrl("https://example.com/a\u0000.png").ok).toBe(false);
|
|
75
|
+
expect(validateAvatarUrl("https://example.com/a\u009f.png").ok).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("normalizes to parsed.href: markup-hostile chars come out percent-encoded (finding 3)", () => {
|
|
79
|
+
const r1 = validateAvatarUrl('https://example.com/a"b.png');
|
|
80
|
+
expect(r1).toEqual({ ok: true, value: "https://example.com/a%22b.png" });
|
|
81
|
+
const r2 = validateAvatarUrl("https://example.com/</script><script>.png");
|
|
82
|
+
expect(r2.ok).toBe(true);
|
|
83
|
+
if (r2.ok) {
|
|
84
|
+
expect(r2.value).not.toContain("<");
|
|
85
|
+
expect(r2.value).not.toContain(">");
|
|
86
|
+
}
|
|
87
|
+
const r3 = validateAvatarUrl("https://example.com/a`b.png");
|
|
88
|
+
expect(r3.ok).toBe(true);
|
|
89
|
+
if (r3.ok) expect(r3.value).not.toContain("`");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("rejects values whose href still carries attribute-breaking chars (single quote)", () => {
|
|
93
|
+
// WHATWG URL leaves ' un-encoded in the PATH component — href alone
|
|
94
|
+
// doesn't neutralize it there, so the validator must refuse.
|
|
95
|
+
expect(validateAvatarUrl("https://example.com/a'b.png").ok).toBe(false);
|
|
96
|
+
// In the QUERY component ' IS percent-encoded by href → accepted,
|
|
97
|
+
// but the stored value must carry no raw quote.
|
|
98
|
+
const q = validateAvatarUrl("https://example.com/x.png?a='1'");
|
|
99
|
+
expect(q.ok).toBe(true);
|
|
100
|
+
if (q.ok) expect(q.value).not.toContain("'");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("rejects URLs with embedded credentials (userinfo)", () => {
|
|
104
|
+
expect(validateAvatarUrl("https://user:pass@example.com/a.png").ok).toBe(false);
|
|
105
|
+
expect(validateAvatarUrl("https://user@example.com/a.png").ok).toBe(false);
|
|
106
|
+
expect(validateAvatarUrl("http://:secret@example.com/a.png").ok).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("enforces the length cap", () => {
|
|
110
|
+
const base = "https://example.com/";
|
|
111
|
+
const ok = base + "a".repeat(MAX_AVATAR_URL_LENGTH - base.length);
|
|
112
|
+
expect(validateAvatarUrl(ok).ok).toBe(true);
|
|
113
|
+
expect(validateAvatarUrl(ok + "a").ok).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── Same-origin relative branch (通信龙 裁定: relax to portable pool paths;
|
|
118
|
+
// absolute URLs would weld one hostname into the DB across multi-origin
|
|
119
|
+
// entry points). Each direction is its OWN test so any single guard
|
|
120
|
+
// being deleted turns its OWN assertion red (no merged catch-all).
|
|
121
|
+
describe("validateAvatarUrl — same-origin relative pool paths", () => {
|
|
122
|
+
test("accepts a pool path: /avatars/avatar-09.webp", () => {
|
|
123
|
+
const r = validateAvatarUrl("/avatars/avatar-09.webp");
|
|
124
|
+
expect(r).toEqual({ ok: true, value: "/avatars/avatar-09.webp" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("accepts png and svg pool files", () => {
|
|
128
|
+
expect(validateAvatarUrl("/avatars/intern_avatar.png")).toEqual({ ok: true, value: "/avatars/intern_avatar.png" });
|
|
129
|
+
expect(validateAvatarUrl("/avatars/team-logo.svg")).toEqual({ ok: true, value: "/avatars/team-logo.svg" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("rejects protocol-relative //host (classic same-origin bypass)", () => {
|
|
133
|
+
const r = validateAvatarUrl("//evil.com/x.png");
|
|
134
|
+
expect(r.ok).toBe(false);
|
|
135
|
+
if (!r.ok) expect(r.reason).toContain("protocol-relative");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("rejects javascript: scheme (still, after the relax)", () => {
|
|
139
|
+
expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("rejects non-/avatars/ absolute paths (/etc/passwd)", () => {
|
|
143
|
+
expect(validateAvatarUrl("/etc/passwd").ok).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("rejects non-whitelisted extensions under /avatars/", () => {
|
|
147
|
+
expect(validateAvatarUrl("/avatars/payload.html").ok).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("rejects nested/traversal paths (charset forbids further slashes)", () => {
|
|
151
|
+
expect(validateAvatarUrl("/avatars/../secrets.webp").ok).toBe(false);
|
|
152
|
+
expect(validateAvatarUrl("/avatars/sub/x.webp").ok).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("rejects percent-encoding inside the filename (no encoded surprises)", () => {
|
|
156
|
+
expect(validateAvatarUrl("/avatars/a%2f..%2fx.webp").ok).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("absolute http(s) branch is UNTOUCHED by the relax (positive control)", () => {
|
|
160
|
+
const r = validateAvatarUrl("https://example.com/pic.png");
|
|
161
|
+
expect(r.ok).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// #462 — validator for the user-set custom avatar URL (nodes.avatar_url).
|
|
2
|
+
//
|
|
3
|
+
// The value ends up in an <img src> on every dashboard client, so this is
|
|
4
|
+
// an XSS trust boundary: protocol MUST be http/https (rejects javascript:,
|
|
5
|
+
// data:, vbscript:, file:, blob:, …), no whitespace/control characters,
|
|
6
|
+
// bounded length. Pure function, unit-tested in avatar-validate.test.ts.
|
|
7
|
+
|
|
8
|
+
export const MAX_AVATAR_URL_LENGTH = 2048;
|
|
9
|
+
|
|
10
|
+
export type AvatarValidation =
|
|
11
|
+
| { ok: true; value: string | null }
|
|
12
|
+
| { ok: false; reason: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize + validate an untrusted avatar_url patch value.
|
|
16
|
+
*
|
|
17
|
+
* null / undefined / "" / whitespace-only → { ok, value: null } (clear)
|
|
18
|
+
* same-origin pool path /avatars/<name>.<ext> → { ok, value: trimmed }
|
|
19
|
+
* valid absolute http(s) URL → { ok, value: normalized }
|
|
20
|
+
* anything else → { ok: false, reason }
|
|
21
|
+
*
|
|
22
|
+
* Relative branch rationale (通信龙 裁定, avatar 接线单): the hub is
|
|
23
|
+
* reached through several origins (localhost dev, public domains) — an
|
|
24
|
+
* absolute URL would weld ONE hostname into the DB and break the image
|
|
25
|
+
* for every other entry point; a same-origin relative path is portable.
|
|
26
|
+
* 🔴 Trap this branch must block: "starts with /" ≠ "same-origin" —
|
|
27
|
+
* "//evil.com/x.png" ALSO starts with "/" but is a protocol-relative URL
|
|
28
|
+
* the browser resolves to evil.com. Hence: single leading slash with the
|
|
29
|
+
* SECOND char not "/", then an exact value-set match (the /avatars/ pool
|
|
30
|
+
* prefix + extension whitelist), not a shape match.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Exact allowed set for same-origin values: the dashboard's designed pool
|
|
34
|
+
// under /avatars/. Filename charset excludes "/" (no traversal, no nested
|
|
35
|
+
// paths) and "%" (no encoded surprises); extensions mirror the actual
|
|
36
|
+
// pool contents. Widen ONLY by extending this list deliberately.
|
|
37
|
+
const SAME_ORIGIN_AVATAR_RE = /^\/avatars\/[A-Za-z0-9._-]+\.(webp|png|svg)$/;
|
|
38
|
+
export function validateAvatarUrl(raw: unknown): AvatarValidation {
|
|
39
|
+
if (raw === null || raw === undefined) return { ok: true, value: null };
|
|
40
|
+
if (typeof raw !== "string") {
|
|
41
|
+
return { ok: false, reason: "avatar_url must be a string or null" };
|
|
42
|
+
}
|
|
43
|
+
const trimmed = raw.trim();
|
|
44
|
+
if (trimmed.length === 0) return { ok: true, value: null };
|
|
45
|
+
if (trimmed.length > MAX_AVATAR_URL_LENGTH) {
|
|
46
|
+
return { ok: false, reason: `avatar_url must be ≤ ${MAX_AVATAR_URL_LENGTH} chars` };
|
|
47
|
+
}
|
|
48
|
+
// Reject embedded whitespace + C0/C1 control chars outright. URL()
|
|
49
|
+
// silently strips some of these (e.g. tabs/newlines), which would let
|
|
50
|
+
// "java\tscript:" style payloads normalize into a hostile protocol —
|
|
51
|
+
// so this check must run BEFORE parsing, on the raw trimmed string.
|
|
52
|
+
if (/[\u0000-\u001f\u007f-\u009f\s]/.test(trimmed)) {
|
|
53
|
+
return { ok: false, reason: "avatar_url must not contain whitespace or control characters" };
|
|
54
|
+
}
|
|
55
|
+
// Same-origin relative branch (runs AFTER the whitespace/control gate
|
|
56
|
+
// above — that check protects this branch too). Order of the two tests
|
|
57
|
+
// matters for the reason string, not for safety: the exact-set regex
|
|
58
|
+
// alone already rejects "//…" (second char is "/", first segment must
|
|
59
|
+
// literally be "avatars"), the explicit double-slash check just names
|
|
60
|
+
// the classic bypass in its own words.
|
|
61
|
+
if (trimmed.startsWith("/")) {
|
|
62
|
+
if (trimmed.startsWith("//")) {
|
|
63
|
+
return { ok: false, reason: "avatar_url must not be protocol-relative (//host/…)" };
|
|
64
|
+
}
|
|
65
|
+
if (!SAME_ORIGIN_AVATAR_RE.test(trimmed)) {
|
|
66
|
+
return { ok: false, reason: "relative avatar_url must match /avatars/<name>.(webp|png|svg)" };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, value: trimmed };
|
|
69
|
+
}
|
|
70
|
+
// Absolute http(s) branch — unchanged from #462 (do not touch while
|
|
71
|
+
// relaxing: 通信龙 裁定 condition 3).
|
|
72
|
+
let parsed: URL;
|
|
73
|
+
try {
|
|
74
|
+
parsed = new URL(trimmed);
|
|
75
|
+
} catch {
|
|
76
|
+
return { ok: false, reason: "avatar_url must be an absolute URL" };
|
|
77
|
+
}
|
|
78
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
79
|
+
return { ok: false, reason: "avatar_url protocol must be http or https" };
|
|
80
|
+
}
|
|
81
|
+
// Embedded credentials (https://user:pass@host/…) would be persisted,
|
|
82
|
+
// leaked to the avatar host on every render, and can spoof the visible
|
|
83
|
+
// origin in UIs. Reject outright rather than silently stripping — the
|
|
84
|
+
// caller should know their input carried secrets (通信龙 review round-2
|
|
85
|
+
// follow-up).
|
|
86
|
+
if (parsed.username || parsed.password) {
|
|
87
|
+
return { ok: false, reason: "avatar_url must not contain credentials (userinfo)" };
|
|
88
|
+
}
|
|
89
|
+
// Persist the NORMALIZED href, not the raw input: href percent-encodes
|
|
90
|
+
// markup-hostile characters (" < > ` → %22 %3C %3E %60), so what lands
|
|
91
|
+
// in the DB is already safe to interpolate into an <img src> attribute.
|
|
92
|
+
// The dashboard renderer lives in a different package this repo cannot
|
|
93
|
+
// audit — the server must not bet on it escaping (审查修复 per 通信龙
|
|
94
|
+
// #461/#462 review, finding 3).
|
|
95
|
+
const normalized = parsed.href;
|
|
96
|
+
// Belt-and-braces: reject anything href does NOT encode that could
|
|
97
|
+
// still break out of an attribute context (WHATWG URL leaves ' and \
|
|
98
|
+
// untouched in some positions).
|
|
99
|
+
if (/["'<>\\`]/.test(normalized)) {
|
|
100
|
+
return { ok: false, reason: "avatar_url contains characters not allowed in a URL" };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, value: normalized };
|
|
103
|
+
}
|