@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41
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 +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +107 -13
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -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-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- 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-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +654 -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 +316 -0
- package/src/external-schedule-edits.ts +314 -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 +637 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- 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/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -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/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -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/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 +212 -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/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 +479 -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 +437 -0
- package/src/server.ts +3664 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -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 +229 -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 +88 -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 +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 +471 -0
- package/src/task-diagnostic-http.test.ts +145 -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 +200 -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 +2994 -191
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-read-http.test.ts +220 -0
- package/src/user-inbox-schema.test.ts +53 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// #380 fix — REST /api/host-supervisors utok→default-network fallback.
|
|
2
|
+
//
|
|
3
|
+
// Before this patch the endpoint hard-4xxed when the client omitted the
|
|
4
|
+
// `network_id` query param. The dashboard's create-node wizard was
|
|
5
|
+
// reported as "hub 400" against a prod hub that had zero daemons AND was
|
|
6
|
+
// being polled without the query — the frontend read that as "no
|
|
7
|
+
// available servers" and blocked the wizard.
|
|
8
|
+
//
|
|
9
|
+
// Post-fix contract:
|
|
10
|
+
// - ntok caller → uses bound network (existing)
|
|
11
|
+
// - utok caller + `?network_id=...` verified → uses that (existing)
|
|
12
|
+
// - utok caller + NO query + 1 accessible net → fallback to that net (200)
|
|
13
|
+
// - utok caller + NO query + 2+ accessible net → 400, error=network_id_required_multi
|
|
14
|
+
// - utok caller + NO query + 0 accessible net → 400, error=missing_network_id
|
|
15
|
+
//
|
|
16
|
+
// The multi-network case is the authz boundary — we refuse to guess
|
|
17
|
+
// which network to list from (per 通信龙 spec "别 fallback 到错 network").
|
|
18
|
+
//
|
|
19
|
+
// Test pattern mirrors uploads-http.test.ts: bind the real Bun.serve
|
|
20
|
+
// server on an ephemeral port with a temp DB, hit it with fetch().
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
23
|
+
import "./require-explicit-test-db.js";
|
|
24
|
+
import { register, login, createNetwork } from "./auth.js";
|
|
25
|
+
import { db } from "./db.js";
|
|
26
|
+
|
|
27
|
+
// #438 corrective: private bootServer({ port: 0 }) instance (see
|
|
28
|
+
// uploads-http.test.ts for rationale).
|
|
29
|
+
let BASE = "";
|
|
30
|
+
let server: any = null;
|
|
31
|
+
|
|
32
|
+
// Three users:
|
|
33
|
+
// soloUser — single accessible network (fallback should work)
|
|
34
|
+
// multiUser — two accessible networks (fallback should refuse)
|
|
35
|
+
// orphanUser — zero networks (fallback has nothing to derive)
|
|
36
|
+
let soloToken = "", multiToken = "", orphanToken = "", adminToken = "";
|
|
37
|
+
let soloNetworkId = "";
|
|
38
|
+
let multiNetworkA = "", multiNetworkB = "";
|
|
39
|
+
let soloDaemonAlias = "";
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
process.env.HOST = "127.0.0.1";
|
|
43
|
+
|
|
44
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
45
|
+
const password = "BootstrapPw123Aa!";
|
|
46
|
+
|
|
47
|
+
// Pre-register a first user so subsequent test users are NOT auto-admin.
|
|
48
|
+
// resolveRestNetworkScope's admin branch bypasses fallback (admin can
|
|
49
|
+
// legitimately query any network), which would make solo's no-query
|
|
50
|
+
// case a false-negative for our fix contract.
|
|
51
|
+
const seed = register(`seed_admin_${suffix}`, password, undefined, "seed");
|
|
52
|
+
if (!seed.ok || !seed.token) throw new Error("seed admin failed: " + JSON.stringify(seed));
|
|
53
|
+
// "First registered user is auto-admin" only holds when this suite has
|
|
54
|
+
// the DB to itself. In an aggregate run another suite registers first,
|
|
55
|
+
// the seed lands as a plain user, and paths 7/8 silently test the
|
|
56
|
+
// wrong role. Promote explicitly — resolveToken reads users.role at
|
|
57
|
+
// request time, so promotion after token issuance is effective.
|
|
58
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`seed_admin_${suffix}`]);
|
|
59
|
+
// Save the admin token so path 7 can hit /api/host-supervisors as an
|
|
60
|
+
// admin directly (通信龙 review point 1: verify admin+no-query goes 400
|
|
61
|
+
// explicitly, not only implicitly via seed_admin's shadow presence).
|
|
62
|
+
adminToken = seed.token;
|
|
63
|
+
|
|
64
|
+
// Solo user — register() auto-creates a default network; nothing to add.
|
|
65
|
+
const solo = register(`solo_${suffix}`, password, undefined, "seed");
|
|
66
|
+
if (!solo.ok || !solo.token) throw new Error("solo register failed: " + JSON.stringify(solo));
|
|
67
|
+
soloToken = solo.token;
|
|
68
|
+
soloNetworkId = solo.network_id ?? "";
|
|
69
|
+
if (!soloNetworkId) throw new Error("solo default network missing");
|
|
70
|
+
|
|
71
|
+
// Multi user — register + create a second owned network.
|
|
72
|
+
const multi = register(`multi_${suffix}`, password, undefined, "seed");
|
|
73
|
+
if (!multi.ok || !multi.token) throw new Error("multi register failed: " + JSON.stringify(multi));
|
|
74
|
+
multiToken = multi.token;
|
|
75
|
+
multiNetworkA = multi.network_id ?? "";
|
|
76
|
+
if (!multiNetworkA) throw new Error("multi default network missing");
|
|
77
|
+
const authCtx = login(`multi_${suffix}`, password);
|
|
78
|
+
const secondNet: any = createNetwork(authCtx.user!.user_id, `multi_second_${suffix}`);
|
|
79
|
+
if (!secondNet.ok || !secondNet.network_id) throw new Error("multi second-net create failed: " + JSON.stringify(secondNet));
|
|
80
|
+
multiNetworkB = secondNet.network_id;
|
|
81
|
+
|
|
82
|
+
// Orphan user — register then delete membership in the default net so
|
|
83
|
+
// the user has zero networks. Direct DB manipulation is fine here;
|
|
84
|
+
// we're modeling an edge case the production API would never let a
|
|
85
|
+
// normal user hit (they'd always keep at least their default), but
|
|
86
|
+
// the endpoint contract still needs to be locked.
|
|
87
|
+
const orphan = register(`orphan_${suffix}`, password, undefined, "seed");
|
|
88
|
+
if (!orphan.ok || !orphan.token) throw new Error("orphan register failed: " + JSON.stringify(orphan));
|
|
89
|
+
orphanToken = orphan.token;
|
|
90
|
+
const orphanUserId = login(`orphan_${suffix}`, password).user!.user_id;
|
|
91
|
+
db.run("DELETE FROM network_members WHERE user_id = ?1", [orphanUserId]);
|
|
92
|
+
|
|
93
|
+
// 通信龙 review point 2: path-2's fallback response must land the
|
|
94
|
+
// daemon list *from soloNetworkId specifically*, not from the whole
|
|
95
|
+
// sessions/nodes table. The original test-2 asserted daemons was an
|
|
96
|
+
// array but a clean fixture (0 daemons in the DB) would trivially
|
|
97
|
+
// satisfy that. Insert:
|
|
98
|
+
// (a) a host_supervisor daemon in soloNetworkId — should show up in
|
|
99
|
+
// fallback response
|
|
100
|
+
// (b) a host_supervisor daemon in multiNetworkA — should NOT show
|
|
101
|
+
// up in solo's fallback (cross-tenant isolation lock)
|
|
102
|
+
soloDaemonAlias = `solo_daemon_${suffix}`;
|
|
103
|
+
seedHostSupervisorDaemon({
|
|
104
|
+
alias: soloDaemonAlias,
|
|
105
|
+
networkId: soloNetworkId,
|
|
106
|
+
userIdForToken: login(`solo_${suffix}`, password).user!.user_id,
|
|
107
|
+
});
|
|
108
|
+
seedHostSupervisorDaemon({
|
|
109
|
+
alias: `other_daemon_${suffix}`,
|
|
110
|
+
networkId: multiNetworkA,
|
|
111
|
+
userIdForToken: login(`multi_${suffix}`, password).user!.user_id,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// server.ts is side-effect-free to import; boot a private instance.
|
|
115
|
+
const { bootServer } = await import("./server.js");
|
|
116
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
117
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
118
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Insert a minimal `nodes` row + `api_tokens` row so the daemon shows up
|
|
122
|
+
// in /api/host-supervisors' JOIN-then-role-filter query (index.ts
|
|
123
|
+
// GET /api/host-supervisors handler, at the SELECT ... FROM nodes n ...
|
|
124
|
+
// EXISTS (SELECT 1 FROM api_tokens ...) branch). The api_tokens row
|
|
125
|
+
// with name='node:<alias>' + revoked_at IS NULL is the "still active"
|
|
126
|
+
// witness — without it EXISTS drops the row.
|
|
127
|
+
function seedHostSupervisorDaemon(opts: {
|
|
128
|
+
alias: string;
|
|
129
|
+
networkId: string;
|
|
130
|
+
userIdForToken: string;
|
|
131
|
+
}) {
|
|
132
|
+
const nodeId = `n_test_${opts.alias}`;
|
|
133
|
+
db.run(
|
|
134
|
+
`INSERT OR REPLACE INTO nodes (
|
|
135
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
136
|
+
channels, server, hostname, network_id,
|
|
137
|
+
config_revision, config_snapshot
|
|
138
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
|
|
139
|
+
[nodeId, opts.alias, opts.alias, "claude-agent-sdk", "claude-sonnet-4-5",
|
|
140
|
+
"/tmp/cfg.json", "[]", "test-host", "test-host", opts.networkId,
|
|
141
|
+
0, JSON.stringify({ role: "host_supervisor", daemon_capabilities: {} })],
|
|
142
|
+
);
|
|
143
|
+
db.run(
|
|
144
|
+
`INSERT OR REPLACE INTO api_tokens (
|
|
145
|
+
token_id, token_hash, user_id, network_id, name, scope
|
|
146
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
147
|
+
[`tok_test_${opts.alias}`, `hash_test_${opts.alias}`,
|
|
148
|
+
opts.userIdForToken, opts.networkId, `node:${opts.alias}`, "network"],
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
afterAll(() => {
|
|
153
|
+
try { server?.stop?.(true); } catch {}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
async function get(path: string, token: string): Promise<{ status: number; body: any }> {
|
|
157
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
158
|
+
headers: { "Authorization": `Bearer ${token}` },
|
|
159
|
+
});
|
|
160
|
+
const body = await res.json().catch(() => null);
|
|
161
|
+
return { status: res.status, body };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe("#380 — /api/host-supervisors utok→default-network fallback", () => {
|
|
165
|
+
test("path 1 — utok with 1 network, WITH ?network_id → 200 (existing behavior)", async () => {
|
|
166
|
+
const r = await get(`/api/host-supervisors?network_id=${soloNetworkId}`, soloToken);
|
|
167
|
+
expect(r.status).toBe(200);
|
|
168
|
+
expect(r.body?.ok).toBe(true);
|
|
169
|
+
expect(Array.isArray(r.body?.daemons)).toBe(true);
|
|
170
|
+
expect(typeof r.body?.count).toBe("number");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("path 2 — utok with 1 network, NO ?network_id → 200 (fallback lands AND scopes correctly)", async () => {
|
|
174
|
+
// This is the core #380 fix: dashboard forgot the query but the user
|
|
175
|
+
// only has one network anyway, so we can safely fall back to it.
|
|
176
|
+
//
|
|
177
|
+
// 通信龙 review point 2: harden the assertion beyond "daemons is an
|
|
178
|
+
// array". We seeded one daemon in soloNetworkId and one in
|
|
179
|
+
// multiNetworkA (multi user); the fallback response MUST include the
|
|
180
|
+
// solo one and NOT the multi one. Otherwise a future regression that
|
|
181
|
+
// wired the fallback to "all networks the user knows about" would
|
|
182
|
+
// slip through — cross-tenant leak.
|
|
183
|
+
const r = await get(`/api/host-supervisors`, soloToken);
|
|
184
|
+
expect(r.status).toBe(200);
|
|
185
|
+
expect(r.body?.ok).toBe(true);
|
|
186
|
+
expect(Array.isArray(r.body?.daemons)).toBe(true);
|
|
187
|
+
|
|
188
|
+
const aliases: string[] = r.body.daemons.map((d: any) => d.alias);
|
|
189
|
+
expect(aliases).toContain(soloDaemonAlias);
|
|
190
|
+
// Belt: nothing from the multi user's network snuck through.
|
|
191
|
+
expect(aliases.every((a: string) => !a.startsWith("other_daemon_"))).toBe(true);
|
|
192
|
+
// Every returned daemon's daemon_node_id should be one this user can
|
|
193
|
+
// legitimately see — locking the shape end-to-end.
|
|
194
|
+
for (const d of r.body.daemons) {
|
|
195
|
+
expect(typeof d.daemon_node_id).toBe("string");
|
|
196
|
+
expect(typeof d.alias).toBe("string");
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("path 3 — utok with 2 networks, NO ?network_id → 400 network_id_required_multi", async () => {
|
|
201
|
+
// Authz boundary: refuse to guess which network to list. Distinguishing
|
|
202
|
+
// error field lets the client tell the user "pick a network" vs
|
|
203
|
+
// "you're not in any networks".
|
|
204
|
+
const r = await get(`/api/host-supervisors`, multiToken);
|
|
205
|
+
expect(r.status).toBe(400);
|
|
206
|
+
expect(r.body?.ok).toBe(false);
|
|
207
|
+
expect(r.body?.error).toBe("network_id_required_multi");
|
|
208
|
+
expect(r.body?.memberships).toBe(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("path 4 — utok with 2 networks, WITH ?network_id → 200 (explicit choice honored)", async () => {
|
|
212
|
+
const r = await get(`/api/host-supervisors?network_id=${multiNetworkA}`, multiToken);
|
|
213
|
+
expect(r.status).toBe(200);
|
|
214
|
+
expect(r.body?.ok).toBe(true);
|
|
215
|
+
const r2 = await get(`/api/host-supervisors?network_id=${multiNetworkB}`, multiToken);
|
|
216
|
+
expect(r2.status).toBe(200);
|
|
217
|
+
expect(r2.body?.ok).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("path 5 — utok with 2 networks, WITH ?network_id (not a member) → denied by scope layer", async () => {
|
|
221
|
+
// Requesting a network the user isn't in must never leak — this is
|
|
222
|
+
// enforced upstream at resolveRestNetworkScope, but re-lock here.
|
|
223
|
+
const foreignNet = "net_absolutely_not_a_member";
|
|
224
|
+
const r = await get(`/api/host-supervisors?network_id=${foreignNet}`, multiToken);
|
|
225
|
+
expect(r.status).toBe(403);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("path 7 — admin utok, NO ?network_id → 400 (admin can span every network; no cross-network guessing)", async () => {
|
|
229
|
+
// 通信龙 review point 1: the previous suite only asserted admin
|
|
230
|
+
// indirectly (seed_admin was created first so other test users
|
|
231
|
+
// weren't auto-admin). This test hits /api/host-supervisors with
|
|
232
|
+
// seed_admin's own utok — resolveRestNetworkScope's isAdmin branch
|
|
233
|
+
// returns networkIds:null, singleNetworkId returns null, endpoint
|
|
234
|
+
// 400s. The response distinguishes it from the multi-network case
|
|
235
|
+
// via `memberships` being absent-or-0 (admin doesn't populate
|
|
236
|
+
// scope.networkIds), so the client sees an explicit "you're admin,
|
|
237
|
+
// pass network_id" affordance.
|
|
238
|
+
const r = await get(`/api/host-supervisors`, adminToken);
|
|
239
|
+
expect(r.status).toBe(400);
|
|
240
|
+
expect(r.body?.ok).toBe(false);
|
|
241
|
+
// Not the multi-network error — admin path goes through the "no
|
|
242
|
+
// scope.networkIds" branch → memberships defaults to 0 (the ??
|
|
243
|
+
// coalesce in the handler). This asymmetry lets the client
|
|
244
|
+
// distinguish "you own too many" from "you can see all of them".
|
|
245
|
+
expect(r.body?.error).toBe("missing_network_id");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("path 8 — admin utok, WITH explicit ?network_id → 200 (admin can inspect any network)", async () => {
|
|
249
|
+
// Complement to path 7: even without membership in a target network,
|
|
250
|
+
// admin can query it. Locks resolveRestNetworkScope's isAdmin =>
|
|
251
|
+
// {networkId: requested, networkIds: null} branch.
|
|
252
|
+
const r = await get(`/api/host-supervisors?network_id=${multiNetworkA}`, adminToken);
|
|
253
|
+
expect(r.status).toBe(200);
|
|
254
|
+
expect(r.body?.ok).toBe(true);
|
|
255
|
+
// The daemon we seeded in multiNetworkA must be reachable.
|
|
256
|
+
const aliases: string[] = r.body.daemons.map((d: any) => d.alias);
|
|
257
|
+
expect(aliases.some(a => a.startsWith("other_daemon_"))).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("path 6 — utok with 0 networks, NO ?network_id → 400 missing_network_id (memberships=0)", async () => {
|
|
261
|
+
const r = await get(`/api/host-supervisors`, orphanToken);
|
|
262
|
+
expect(r.status).toBe(400);
|
|
263
|
+
expect(r.body?.ok).toBe(false);
|
|
264
|
+
expect(r.body?.error).toBe("missing_network_id");
|
|
265
|
+
expect(r.body?.memberships).toBe(0);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -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
|
//
|
|
@@ -29,8 +30,28 @@ afterAll(() => {
|
|
|
29
30
|
try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
|
|
30
31
|
});
|
|
31
32
|
|
|
33
|
+
// Helper: mirror handler's role-extraction pass to mimic the production
|
|
34
|
+
// response shape exactly. Keeps the test the source of truth for the
|
|
35
|
+
// contract; if the handler changes, the assertions still lock what
|
|
36
|
+
// the dashboard / external clients see.
|
|
37
|
+
function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
38
|
+
let role: string | null = null;
|
|
39
|
+
const snap = r.config_snapshot;
|
|
40
|
+
if (snap) {
|
|
41
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
42
|
+
catch { /* malformed */ }
|
|
43
|
+
}
|
|
44
|
+
const { config_snapshot, ...rest } = r;
|
|
45
|
+
return {
|
|
46
|
+
...rest,
|
|
47
|
+
tags: parseStoredTags(r.tags),
|
|
48
|
+
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
49
|
+
role,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
|
|
33
|
-
test("explicit columns:
|
|
54
|
+
test("explicit columns + role extracted: config_snapshot NOT in response, role IS", () => {
|
|
34
55
|
// Insert a row with the leak-prone columns populated. Use INSERT OR
|
|
35
56
|
// REPLACE so concurrent test runs don't trip on PRIMARY KEY.
|
|
36
57
|
db.run(
|
|
@@ -43,38 +64,111 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
43
64
|
)`,
|
|
44
65
|
[TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
|
|
45
66
|
"/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
|
|
46
|
-
7, JSON.stringify({ model: "claude-opus", flags: { x: true } })],
|
|
67
|
+
7, JSON.stringify({ model: "claude-opus", flags: { x: true }, role: "host_supervisor" })],
|
|
47
68
|
);
|
|
48
69
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
const rows = db.all<Record<string, unknown>>(
|
|
70
|
+
// Mirror handler SQL + mapping
|
|
71
|
+
const raw = db.all<Record<string, unknown>>(
|
|
52
72
|
`SELECT node_id, node_name, alias, runtime, model,
|
|
53
73
|
config_path, channels, server, hostname,
|
|
54
|
-
network_id, created_at, updated_at
|
|
74
|
+
network_id, created_at, updated_at,
|
|
75
|
+
config_snapshot, display_name, team, tags, attrs_revision
|
|
55
76
|
FROM nodes WHERE node_id = ?1`,
|
|
56
77
|
TEST_NODE,
|
|
57
78
|
);
|
|
79
|
+
const rows = raw.map(mapRow);
|
|
58
80
|
|
|
59
81
|
expect(rows).toHaveLength(1);
|
|
60
82
|
const row = rows[0]!;
|
|
61
|
-
// Expected fields present
|
|
83
|
+
// Expected fields present (13 = 12 contract + new role)
|
|
62
84
|
for (const k of ["node_id", "node_name", "alias", "runtime", "model",
|
|
63
85
|
"config_path", "channels", "server", "hostname",
|
|
64
|
-
"network_id", "created_at", "updated_at"]) {
|
|
86
|
+
"network_id", "created_at", "updated_at", "role"]) {
|
|
65
87
|
expect(row).toHaveProperty(k);
|
|
66
88
|
}
|
|
67
|
-
// Internal fields NOT present
|
|
89
|
+
// Internal fields NOT present
|
|
68
90
|
expect(row).not.toHaveProperty("config_revision");
|
|
69
91
|
expect(row).not.toHaveProperty("config_snapshot");
|
|
70
|
-
//
|
|
92
|
+
// role is EXTRACTED from config_snapshot JSON, not the raw blob
|
|
93
|
+
expect(row.role).toBe("host_supervisor");
|
|
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.
|
|
71
100
|
expect(Object.keys(row).sort()).toEqual([
|
|
72
|
-
"alias", "
|
|
73
|
-
"
|
|
74
|
-
"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",
|
|
75
105
|
]);
|
|
76
106
|
});
|
|
77
107
|
|
|
108
|
+
test("role is null when config_snapshot is empty / missing / malformed", () => {
|
|
109
|
+
// Empty snapshot → null
|
|
110
|
+
db.run(
|
|
111
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
112
|
+
VALUES (?1, ?2, ?3, ?4, NULL)`,
|
|
113
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK],
|
|
114
|
+
);
|
|
115
|
+
let row = mapRow(db.get<Record<string, unknown>>(
|
|
116
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
117
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
118
|
+
display_name, team, tags, attrs_revision
|
|
119
|
+
FROM nodes WHERE node_id = ?1`,
|
|
120
|
+
TEST_NODE,
|
|
121
|
+
)!);
|
|
122
|
+
expect(row.role).toBeNull();
|
|
123
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
124
|
+
|
|
125
|
+
// Malformed JSON → null (handler doesn't throw, just leaves role null)
|
|
126
|
+
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
|
|
127
|
+
row = mapRow(db.get<Record<string, unknown>>(
|
|
128
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
129
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
130
|
+
display_name, team, tags, attrs_revision
|
|
131
|
+
FROM nodes WHERE node_id = ?1`,
|
|
132
|
+
TEST_NODE,
|
|
133
|
+
)!);
|
|
134
|
+
expect(row.role).toBeNull();
|
|
135
|
+
|
|
136
|
+
// Snapshot without role key → null
|
|
137
|
+
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
|
|
138
|
+
[JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
|
|
139
|
+
row = mapRow(db.get<Record<string, unknown>>(
|
|
140
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
141
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
142
|
+
display_name, team, tags, attrs_revision
|
|
143
|
+
FROM nodes WHERE node_id = ?1`,
|
|
144
|
+
TEST_NODE,
|
|
145
|
+
)!);
|
|
146
|
+
expect(row.role).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("daemon discovery use-case: dashboard finds role=host_supervisor without prefix heuristic", () => {
|
|
150
|
+
// Daemon registered with arbitrary node_id (NO `node_daemon_` prefix)
|
|
151
|
+
db.run(
|
|
152
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
153
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
154
|
+
[TEST_NODE, "my-daemon", "my-daemon", TEST_NETWORK,
|
|
155
|
+
JSON.stringify({ role: "host_supervisor", runtime: "claude-agent-sdk" })],
|
|
156
|
+
);
|
|
157
|
+
const rows = db.all<Record<string, unknown>>(
|
|
158
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
159
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
160
|
+
display_name, team, tags, attrs_revision
|
|
161
|
+
FROM nodes WHERE network_id = ?1`,
|
|
162
|
+
TEST_NETWORK,
|
|
163
|
+
).map(mapRow);
|
|
164
|
+
// Dashboard's lookup: find first node with role==='host_supervisor'
|
|
165
|
+
const daemon = rows.find(r => r.role === "host_supervisor");
|
|
166
|
+
expect(daemon).toBeTruthy();
|
|
167
|
+
expect(daemon!.node_id).toBe(TEST_NODE);
|
|
168
|
+
// Even though node_id does NOT start with `node_daemon_` (the prior heuristic)
|
|
169
|
+
expect(String(daemon!.node_id).startsWith("node_daemon_")).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
78
172
|
test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
|
|
79
173
|
db.run(
|
|
80
174
|
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
|
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);
|