@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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 +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -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-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -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 +321 -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 +404 -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 +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -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/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -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 +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- 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/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -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/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -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 +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -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-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -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-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -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/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- 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
package/src/auth.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* V3 Auth module — user registration, login, token management
|
|
3
3
|
*/
|
|
4
|
-
import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
|
|
4
|
+
import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork, groupTx, removeMemberFromChatGroups } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
|
|
5
5
|
import { WEAK_PASSWORDS } from "./password-dict.js";
|
|
6
|
+
import { checkNodeTokenClaim, legacyNodeHolder } from "./node-token-ownership.js";
|
|
7
|
+
import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
|
|
8
|
+
import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, newMemberTaskAccess, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
|
|
6
9
|
|
|
7
10
|
// Round-6 A1 hardening — dummy hash for username-enumeration timing
|
|
8
11
|
// close. We compute ONE scrypt hash of a throwaway password and reuse
|
|
@@ -32,6 +35,7 @@ export interface AuthUser {
|
|
|
32
35
|
user_id: string;
|
|
33
36
|
username: string;
|
|
34
37
|
display_name: string | null;
|
|
38
|
+
avatar_url?: string | null;
|
|
35
39
|
email: string | null;
|
|
36
40
|
role: string;
|
|
37
41
|
}
|
|
@@ -41,6 +45,7 @@ export interface AuthResult {
|
|
|
41
45
|
error?: string;
|
|
42
46
|
user?: AuthUser;
|
|
43
47
|
token?: string; // user token (utok_)
|
|
48
|
+
token_id?: string; // api_tokens.token_id of `token` — lets a client tell which row in GET /api/auth/sessions is itself
|
|
44
49
|
network_token?: string; // network token (ntok_) for default network
|
|
45
50
|
network_id?: string;
|
|
46
51
|
// #261 P0-2 (2026-06-28): true when the logged-in user still has the
|
|
@@ -59,7 +64,7 @@ function validatePasswordStrength(password: string, label = "password"): string
|
|
|
59
64
|
return null;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
|
|
67
|
+
export function register(username: string, password: string, email?: string, displayName?: string, opts: { issueTokens?: boolean; client?: SessionClientInfo } = {}): AuthResult {
|
|
63
68
|
if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
|
|
64
69
|
if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
|
|
65
70
|
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
|
|
@@ -87,30 +92,61 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
87
92
|
[userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
|
|
88
93
|
);
|
|
89
94
|
|
|
90
|
-
// Auto-create
|
|
95
|
+
// Auto-create the user's own network + add as owner member.
|
|
96
|
+
//
|
|
97
|
+
// Name it after the user rather than the literal "default". Every
|
|
98
|
+
// registration used to mint a network called "default", so a hub with N
|
|
99
|
+
// users showed N identical "default" rows in the Dashboard sidebar with
|
|
100
|
+
// no way to tell them apart (23 of them on the live hub, 22 empty).
|
|
101
|
+
// Three CLI call sites (bin/cli.ts debate / demo-social / pr-review) resolve
|
|
102
|
+
// a network as:
|
|
103
|
+
// me.user.default_network_id
|
|
104
|
+
// || networks.find(n => n.network_name === "default")
|
|
105
|
+
// || networks[0]
|
|
106
|
+
// Checked against the live hub: GET /api/auth/me returns
|
|
107
|
+
// {user_id, username, display_name, email, role} — there is no
|
|
108
|
+
// default_network_id field, and nothing in server/ ever writes one. So the
|
|
109
|
+
// first term is always undefined and the name lookup is the operative path,
|
|
110
|
+
// not a fallback.
|
|
111
|
+
//
|
|
112
|
+
// After this change that lookup misses for new users and they land on
|
|
113
|
+
// networks[0]. That still resolves to this network because listNetworks()
|
|
114
|
+
// is `ORDER BY created_at` ascending (auth.ts:296) and this row is the
|
|
115
|
+
// user's oldest. Reordering that query would silently point those three
|
|
116
|
+
// commands at the wrong network.
|
|
91
117
|
const networkId = generateId("net");
|
|
92
118
|
db.run(
|
|
93
119
|
"INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
|
|
94
|
-
[networkId,
|
|
120
|
+
[networkId, username, userId, `Auto-created network for ${username}`]
|
|
95
121
|
);
|
|
96
122
|
db.run(
|
|
97
123
|
"INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
|
|
98
124
|
[networkId, userId]
|
|
99
125
|
);
|
|
100
126
|
|
|
127
|
+
// 管理员代建账号(POST /api/admin/users)时不给管理员发这个用户的令牌:
|
|
128
|
+
// 令牌只该由用户本人登录时拿到。
|
|
129
|
+
if (opts.issueTokens === false) {
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
|
|
133
|
+
network_id: networkId,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
101
137
|
// User token (utok_) — not bound to network, for CLI/Dashboard login
|
|
102
138
|
const userToken = generateUserToken();
|
|
103
139
|
const userTokenId = generateId("tok");
|
|
104
140
|
db.run(
|
|
105
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
106
|
-
[userTokenId, hashToken(userToken), userId, null, "user-login", "user"]
|
|
141
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
|
|
142
|
+
[userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256), userTokenKindFor(opts.client)]
|
|
107
143
|
);
|
|
108
144
|
|
|
109
145
|
// Network token (ntok_) — bound to default network, for agent-node
|
|
110
146
|
const networkToken = generateNetworkToken();
|
|
111
147
|
const networkTokenId = generateId("tok");
|
|
112
148
|
db.run(
|
|
113
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
149
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
|
|
114
150
|
[networkTokenId, hashToken(networkToken), userId, networkId, "default-network", "network"]
|
|
115
151
|
);
|
|
116
152
|
|
|
@@ -118,14 +154,15 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
118
154
|
ok: true,
|
|
119
155
|
user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
|
|
120
156
|
token: userToken,
|
|
157
|
+
token_id: userTokenId,
|
|
121
158
|
network_token: networkToken,
|
|
122
159
|
network_id: networkId,
|
|
123
160
|
};
|
|
124
161
|
}
|
|
125
162
|
|
|
126
|
-
export function login(username: string, password: string): AuthResult {
|
|
163
|
+
export function login(username: string, password: string, client: SessionClientInfo = {}): AuthResult {
|
|
127
164
|
const user = db.get<any>(
|
|
128
|
-
"SELECT user_id, username, password_hash, display_name, email, role, must_change_password FROM users WHERE username = ?1",
|
|
165
|
+
"SELECT user_id, username, password_hash, display_name, avatar_url, email, role, must_change_password FROM users WHERE username = ?1",
|
|
129
166
|
username);
|
|
130
167
|
|
|
131
168
|
if (!user) {
|
|
@@ -161,14 +198,28 @@ export function login(username: string, password: string): AuthResult {
|
|
|
161
198
|
}
|
|
162
199
|
}
|
|
163
200
|
|
|
201
|
+
// #649: a successful login whose plaintext fails the same strength rule as
|
|
202
|
+
// register/changePassword (too short, or in WEAK_PASSWORDS) flags the account.
|
|
203
|
+
// Catches accounts created before #261 added must_change_password (they sit at
|
|
204
|
+
// 0 with the bootstrap default). changePassword clears it. Never log the password.
|
|
205
|
+
let mustChangePassword = user.must_change_password === 1;
|
|
206
|
+
if (!mustChangePassword && validatePasswordStrength(password) !== null) {
|
|
207
|
+
mustChangePassword = true;
|
|
208
|
+
try {
|
|
209
|
+
db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [user.user_id]);
|
|
210
|
+
} catch (e: any) {
|
|
211
|
+
console.log(`[commhub auth] weak-password flag write failed for user ${user.user_id}: ${e?.message ?? e}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
164
215
|
// Issue a NEW user token — do NOT rotate/invalidate existing ones. Each
|
|
165
216
|
// login (cli, dashboard, second machine) gets its own row so they don't
|
|
166
217
|
// kick each other out of session. Tokens can be revoked via /api/auth/tokens.
|
|
167
218
|
const userToken = generateUserToken();
|
|
168
219
|
const tokenId = generateId("tok");
|
|
169
220
|
db.run(
|
|
170
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
171
|
-
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user"]
|
|
221
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
|
|
222
|
+
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256), userTokenKindFor(client)]
|
|
172
223
|
);
|
|
173
224
|
|
|
174
225
|
// Find default network
|
|
@@ -182,47 +233,208 @@ export function login(username: string, password: string): AuthResult {
|
|
|
182
233
|
|
|
183
234
|
return {
|
|
184
235
|
ok: true,
|
|
185
|
-
user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
|
|
236
|
+
user: { user_id: user.user_id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, email: user.email, role: user.role },
|
|
186
237
|
token,
|
|
238
|
+
token_id: tokenId,
|
|
187
239
|
network_id: networkId,
|
|
188
240
|
// #261 P0-2 — only include field when truthy (back-compat; old clients
|
|
189
241
|
// don't see this field at all unless their account is flagged).
|
|
190
|
-
...(
|
|
242
|
+
...(mustChangePassword ? { must_change_password: true } : {}),
|
|
191
243
|
};
|
|
192
244
|
}
|
|
193
245
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
246
|
+
/**
|
|
247
|
+
* Create a network-scoped token (ntok_) for a specific node.
|
|
248
|
+
*
|
|
249
|
+
* RFC-036: callers that provide nodeId atomically establish the immutable
|
|
250
|
+
* node-owner binding before the plaintext token is returned. The 3-argument
|
|
251
|
+
* name-only form remains available: new identities bind on first registration,
|
|
252
|
+
* and a verified same-holder refresh binds to the existing unique node. This
|
|
253
|
+
* does not fill NULL row owners or grant external-schedule ownership.
|
|
254
|
+
*/
|
|
255
|
+
export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string, nodeId?: string): { ok: boolean; token?: string; token_id?: string; node_id?: string; error?: string } {
|
|
196
256
|
// Verify user is a member of this network with write access
|
|
197
257
|
const role = getUserNetworkRole(userId, networkId);
|
|
198
258
|
if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
|
|
259
|
+
if (isAgentRestricted(userId, networkId)) return { ok: false, error: "restricted members cannot create network tokens" };
|
|
260
|
+
if (!nodeName || nodeName.length > 200) return { ok: false, error: "invalid_node_name" };
|
|
261
|
+
if (nodeId !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(nodeId))) {
|
|
262
|
+
return { ok: false, error: "invalid_node_id" };
|
|
263
|
+
}
|
|
199
264
|
const token = generateNetworkToken();
|
|
200
265
|
const tokenId = generateId("tok");
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
266
|
+
try {
|
|
267
|
+
db.transaction(() => {
|
|
268
|
+
nodeId = checkNodeTokenClaim(userId, networkId, nodeName, nodeId);
|
|
269
|
+
if (nodeId) {
|
|
270
|
+
const existing = db.get<{ network_id: string | null; owner_user_id: string | null; alias: string | null }>(
|
|
271
|
+
"SELECT network_id, owner_user_id, alias FROM nodes WHERE node_id = ?1",
|
|
272
|
+
nodeId,
|
|
273
|
+
);
|
|
274
|
+
if (existing?.network_id && existing.network_id !== networkId) throw new Error("cross_network_node");
|
|
275
|
+
if (existing?.alias && existing.alias !== nodeName) throw new Error("node_owner_mismatch");
|
|
276
|
+
// Existing pre-RFC-036 rows have no trustworthy owner anchor. A token
|
|
277
|
+
// refresh must not turn knowledge of a legacy node_id into ownership.
|
|
278
|
+
// A same-holder refresh binds the token, without filling the row owner.
|
|
279
|
+
if (existing && !existing.owner_user_id && !legacyNodeHolder(userId, networkId, nodeName, nodeId)) throw new Error("node_owner_unclaimed");
|
|
280
|
+
if (existing?.owner_user_id && existing.owner_user_id !== userId) throw new Error("node_owner_mismatch");
|
|
281
|
+
db.run(
|
|
282
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
|
|
283
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))
|
|
284
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
285
|
+
node_name = COALESCE(nodes.node_name, ?2),
|
|
286
|
+
alias = COALESCE(nodes.alias, ?2),
|
|
287
|
+
network_id = COALESCE(nodes.network_id, ?3),
|
|
288
|
+
owner_user_id = nodes.owner_user_id,
|
|
289
|
+
updated_at = datetime('now')`,
|
|
290
|
+
[nodeId, nodeName, networkId, userId],
|
|
291
|
+
);
|
|
292
|
+
if (!existing) {
|
|
293
|
+
db.run(
|
|
294
|
+
`INSERT INTO audit_log (user_id, action, target_type, target_id, detail, network_id)
|
|
295
|
+
VALUES (?1, 'external_schedule.owner_claimed', 'node', ?2, ?3, ?4)`,
|
|
296
|
+
[userId, nodeId, JSON.stringify({ node_id: nodeId, network_id: networkId }), networkId],
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
db.run(
|
|
301
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6, 2)",
|
|
302
|
+
[tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, nodeId ?? null],
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
} catch (error: any) {
|
|
306
|
+
const code = String(error?.message || "node_token_create_failed");
|
|
307
|
+
if (code === "cross_network_node" || code === "node_owner_unclaimed" || code === "node_owner_mismatch") return { ok: false, error: code };
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
return { ok: true, token, token_id: tokenId, ...(nodeId ? { node_id: nodeId } : {}) };
|
|
206
311
|
}
|
|
207
312
|
|
|
208
|
-
|
|
313
|
+
// ── 登录会话(login session)令牌 ──
|
|
314
|
+
// 「会话令牌」= scope='user' 且不绑网络的 utok_:登录 / 注册签发的 user-login、改密码后换发的
|
|
315
|
+
// password-change、管理员重置签发的 admin-reset。它们代表「某台设备上的一次登录」,
|
|
316
|
+
// 由 GET /api/auth/sessions 列出、可被「退出其他设备」撤销,并且**闲置 N 天自动过期**(滑动窗口,
|
|
317
|
+
// 以 last_used_at 计,从没用过的以 created_at 计)。
|
|
318
|
+
// 不受影响:POST /api/auth/tokens 显式创建的 API 令牌(scope='full')、节点 / 网络令牌(network_id 非空)。
|
|
319
|
+
// 它们是给脚本和节点长期用的凭据,由各自的管理入口撤销,不该因为一段时间没被用就失效。
|
|
320
|
+
const SESSION_TOKEN_SQL = "scope = 'user' AND network_id IS NULL";
|
|
321
|
+
// last_used_at 最多每小时写一次:每个请求都会解析令牌,原来每次都 UPDATE。
|
|
322
|
+
// 以小时为粒度对一个以天计的闲置窗口没有影响。
|
|
323
|
+
const LAST_USED_WRITE_INTERVAL_SECONDS = 3600;
|
|
324
|
+
|
|
325
|
+
export type SessionClientInfo = { label?: unknown; userAgent?: unknown; kind?: unknown };
|
|
326
|
+
|
|
327
|
+
// #711 不绑网络的用户令牌的种类(api_tokens.kind):
|
|
328
|
+
// 'login' — 浏览器 / app 的登录会话;
|
|
329
|
+
// 'cli' — anet 命令行登录、POST /api/auth/tokens 建的具名脚本令牌。
|
|
330
|
+
// 种类由客户端自报(client_kind / client_label),**只用于展示和「显式保留」那条路**:
|
|
331
|
+
// 改密码默认仍撤销全部不绑网络的令牌(当前这条除外),调用方显式 keep_cli_tokens=true 才保留 'cli'。
|
|
332
|
+
// 所以一个自称 cli 的令牌永远不能靠自报在默认改密码里活下来。
|
|
333
|
+
// 节点 / 网络令牌(network_id 非空)不分类,改密码永远不碰。
|
|
334
|
+
export type UserTokenKind = "login" | "cli";
|
|
335
|
+
|
|
336
|
+
/** 登录 / 注册请求签发的令牌算哪一类:客户端显式报 kind='cli',或 client_label 是 anet 自报的「anet <版本> · …」。 */
|
|
337
|
+
/** anet 自报的 client_label:去掉首尾空白后,区分大小写地以「anet 」开头(启动回填用同一条规则,见 db.ts)。 */
|
|
338
|
+
export function isAnetClientLabel(label: string): boolean {
|
|
339
|
+
return label.trim().startsWith("anet ");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function userTokenKindFor(client: SessionClientInfo = {}): UserTokenKind {
|
|
343
|
+
if (client.kind === "cli") return "cli";
|
|
344
|
+
if (typeof client.label === "string" && isAnetClientLabel(client.label)) return "cli";
|
|
345
|
+
return "login";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function cleanClientField(value: unknown, max: number): string | null {
|
|
349
|
+
if (typeof value !== "string") return null;
|
|
350
|
+
const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
|
|
351
|
+
return cleaned || null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 会话令牌闲置多少天后过期;COMMHUB_SESSION_IDLE_DAYS,默认 30,0 = 关闭闲置过期。每次读,测试可以改。 */
|
|
355
|
+
export function sessionIdleDays(): number {
|
|
356
|
+
const raw = process.env.COMMHUB_SESSION_IDLE_DAYS;
|
|
357
|
+
if (raw === undefined || raw.trim() === "") return 30;
|
|
358
|
+
const n = Number(raw);
|
|
359
|
+
return Number.isFinite(n) && n >= 0 ? n : 30;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// datetime('now', ?N) 的偏移参数;PgAdapter 把它译成 NOW() + $N::INTERVAL。
|
|
363
|
+
function idleCutoffOffset(): string | null {
|
|
364
|
+
const days = sessionIdleDays();
|
|
365
|
+
return days > 0 ? `-${Math.round(days * 86400)} seconds` : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 已按闲置过期的会话令牌:只有 resolveToken 拒绝之后才查,用来把 401 说清楚是「过期」而不是「无效」。
|
|
369
|
+
export function isExpiredSessionToken(token: string): boolean {
|
|
370
|
+
const cutoff = idleCutoffOffset();
|
|
371
|
+
if (!token || !cutoff) return false;
|
|
372
|
+
const row = db.get<any>(
|
|
373
|
+
`SELECT token_id FROM api_tokens
|
|
374
|
+
WHERE token_hash = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL
|
|
375
|
+
AND COALESCE(last_used_at, created_at) < datetime('now', ?2)`,
|
|
376
|
+
hashToken(token), cutoff);
|
|
377
|
+
return !!row;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export type TokenRejection = "node_owner_restricted" | "not_network_member";
|
|
381
|
+
|
|
382
|
+
/** 查令牌行并判它能不能用:没有这一行 → null;有但被拒 → { rejected };能用 → { row }。 */
|
|
383
|
+
function lookupToken(token: string): { row: any; rejected?: undefined } | { row?: undefined; rejected: TokenRejection } | null {
|
|
209
384
|
const tHash = hashToken(token);
|
|
385
|
+
const cutoff = idleCutoffOffset();
|
|
210
386
|
const row = db.get<any>(
|
|
211
387
|
`SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
|
|
212
|
-
u.username, u.display_name, u.email, u.role
|
|
388
|
+
u.username, u.display_name, u.avatar_url, u.email, u.role,
|
|
389
|
+
CASE WHEN t.network_id IS NULL THEN 1
|
|
390
|
+
WHEN EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = t.network_id AND nm.user_id = t.user_id) THEN 1
|
|
391
|
+
ELSE 0 END AS is_member
|
|
213
392
|
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
214
393
|
WHERE t.token_hash = ?1
|
|
215
394
|
AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
216
|
-
AND t.revoked_at IS NULL
|
|
217
|
-
|
|
395
|
+
AND t.revoked_at IS NULL${cutoff ? `
|
|
396
|
+
AND NOT (t.scope = 'user' AND t.network_id IS NULL AND COALESCE(t.last_used_at, t.created_at) < datetime('now', ?2))` : ""}`,
|
|
397
|
+
...(cutoff ? [tHash, cutoff] : [tHash]));
|
|
218
398
|
|
|
219
399
|
if (!row) return null;
|
|
220
400
|
|
|
221
|
-
//
|
|
222
|
-
|
|
401
|
+
// #488 —— 网络令牌只在它的用户还是这个网络的成员时有效。被移出网络的人留下的令牌(移出前签发的、
|
|
402
|
+
// 或移出时还没自动吊销的老数据)以前照样解析:MCP 每个工具都拒,REST 读却能拿到整个网络的名册。
|
|
403
|
+
if (row.network_id && !Number(row.is_member)) return { rejected: "not_network_member" };
|
|
404
|
+
|
|
405
|
+
// 多用户 Agent 权限:网络令牌(ntok_ / 邀请码令牌)的权限是「这个网络里的一切」,
|
|
406
|
+
// 受限成员在受限网络里不能持有它 —— 否则拿它就绕过了授权过滤。这里是唯一的解析入口,
|
|
407
|
+
// 在这里拒绝,所有 REST / MCP / SSE 路径一起生效(含升级前就签发、后来才被设为受限的令牌)。
|
|
408
|
+
if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return { rejected: "node_owner_restricted" };
|
|
409
|
+
return { row };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 令牌存在、但因为主人的网络成员身份被拒时,说清楚为什么(给 401 带上 reason / hint)。
|
|
414
|
+
* 只在调用者真的持有这枚令牌时才有输出 —— 不认识的令牌一律 null,不泄露任何东西。
|
|
415
|
+
*/
|
|
416
|
+
export function tokenRejection(token: string | null | undefined): { reason: TokenRejection; hint: string } | null {
|
|
417
|
+
if (!token) return null;
|
|
418
|
+
const r = lookupToken(token);
|
|
419
|
+
if (!r?.rejected) return null;
|
|
420
|
+
return r.rejected === "not_network_member"
|
|
421
|
+
? { reason: r.rejected, hint: "this token's owner is no longer a member of its network; ask a network admin to re-invite them, then create a new token" }
|
|
422
|
+
: { reason: r.rejected, hint: "this token's owner only has access to granted Agents in this network, and network tokens need full Agent access; ask a network admin to set the owner's Agent access to all" };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
|
|
426
|
+
const found = lookupToken(token);
|
|
427
|
+
if (!found?.row) return null;
|
|
428
|
+
const row = found.row;
|
|
429
|
+
|
|
430
|
+
// Update last_used(节流:见 LAST_USED_WRITE_INTERVAL_SECONDS)
|
|
431
|
+
db.run(
|
|
432
|
+
"UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_id = ?1 AND (last_used_at IS NULL OR last_used_at < datetime('now', ?2))",
|
|
433
|
+
[row.token_id, `-${LAST_USED_WRITE_INTERVAL_SECONDS} seconds`]
|
|
434
|
+
);
|
|
223
435
|
|
|
224
436
|
return {
|
|
225
|
-
user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
|
|
437
|
+
user: { user_id: row.user_id, username: row.username, display_name: row.display_name, avatar_url: row.avatar_url, email: row.email, role: row.role },
|
|
226
438
|
networkId: row.network_id,
|
|
227
439
|
tokenId: row.token_id || null,
|
|
228
440
|
// tokenName carries the binding identity. For node-scoped ntok_, it's
|
|
@@ -235,7 +447,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
235
447
|
|
|
236
448
|
export function getUserNetworks(userId: string) {
|
|
237
449
|
return db.all<any>(
|
|
238
|
-
|
|
450
|
+
`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE owner_id = ?1 ORDER BY created_at`,
|
|
239
451
|
userId);
|
|
240
452
|
}
|
|
241
453
|
|
|
@@ -280,7 +492,7 @@ export function listTokens(userId: string) {
|
|
|
280
492
|
}
|
|
281
493
|
|
|
282
494
|
export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
|
|
283
|
-
const net = db.get<any>("SELECT
|
|
495
|
+
const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
|
|
284
496
|
if (!net) return { ok: false, error: "network not found" };
|
|
285
497
|
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
286
498
|
const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
|
|
@@ -290,13 +502,21 @@ export function renameNetwork(userId: string, networkId: string, newName: string
|
|
|
290
502
|
}
|
|
291
503
|
|
|
292
504
|
export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
|
|
293
|
-
const net = db.get<any>("SELECT
|
|
505
|
+
const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
|
|
294
506
|
if (!net) return { ok: false, error: "network not found" };
|
|
295
507
|
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
296
508
|
// Check if any sessions/tasks still reference this network
|
|
297
509
|
const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
298
510
|
if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
|
|
299
|
-
db.
|
|
511
|
+
db.transaction(() => {
|
|
512
|
+
db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
|
|
513
|
+
// PR #519 codex catch: leaving membership rows behind made deleted
|
|
514
|
+
// networks count toward getUserNetworkIds — a stale row could flip a
|
|
515
|
+
// single-network user to "ambiguous" or auto-resolve writes INTO the
|
|
516
|
+
// deleted network. Remove memberships with the network.
|
|
517
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
|
|
518
|
+
deleteNetworkAgentGroups(networkId); deleteDepartmentsForNetwork(networkId); for (const t of AGENT_TEAM_TABLES) if (tableExists(t)) db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [networkId]); // #2144, #419, #764 (one line: docs pin auth.ts line numbers)
|
|
519
|
+
});
|
|
300
520
|
return { ok: true };
|
|
301
521
|
}
|
|
302
522
|
|
|
@@ -306,12 +526,14 @@ export function createToken(userId: string, name: string, networkId?: string): {
|
|
|
306
526
|
const role = getUserNetworkRole(userId, networkId);
|
|
307
527
|
if (!role) return { ok: false, error: "not a member of this network" };
|
|
308
528
|
if (role === "viewer") return { ok: false, error: "viewer cannot create full-access network tokens" };
|
|
529
|
+
if (isAgentRestricted(userId, networkId)) return { ok: false, error: "restricted members cannot create network tokens" };
|
|
309
530
|
}
|
|
310
531
|
const token = generateToken();
|
|
311
532
|
const tokenId = generateId("tok");
|
|
533
|
+
// #711 不绑网络的具名令牌是给脚本 / 命令行长期用的:kind='cli'(改密码时可显式 keep_cli_tokens 保留)。
|
|
312
534
|
db.run(
|
|
313
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
314
|
-
[tokenId, hashToken(token), userId, networkId || null, name, "full"]
|
|
535
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1)",
|
|
536
|
+
[tokenId, hashToken(token), userId, networkId || null, name, "full", networkId ? null : "cli"]
|
|
315
537
|
);
|
|
316
538
|
return { ok: true, token, token_id: tokenId };
|
|
317
539
|
}
|
|
@@ -321,16 +543,55 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
|
|
|
321
543
|
return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
|
|
322
544
|
}
|
|
323
545
|
|
|
324
|
-
export function issueUserToken(
|
|
546
|
+
export function issueUserToken(
|
|
547
|
+
userId: string,
|
|
548
|
+
name = "user-login",
|
|
549
|
+
opts: { kind?: UserTokenKind; clientLabel?: string | null; userAgent?: string | null } = {},
|
|
550
|
+
): { token: string; token_id: string } {
|
|
325
551
|
const token = generateUserToken();
|
|
326
552
|
const tokenId = generateId("tok");
|
|
327
553
|
db.run(
|
|
328
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, NULL, ?4, 'user')",
|
|
329
|
-
[tokenId, hashToken(token), userId, name]
|
|
554
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', ?5, ?6, ?7, 1)",
|
|
555
|
+
[tokenId, hashToken(token), userId, name, opts.clientLabel ?? null, opts.userAgent ?? null, opts.kind === "cli" ? "cli" : "login"]
|
|
330
556
|
);
|
|
331
557
|
return { token, token_id: tokenId };
|
|
332
558
|
}
|
|
333
559
|
|
|
560
|
+
/** 一条令牌的种类和设备标识(改密码换发新令牌时沿用,命令行的令牌换完还是命令行的)。 */
|
|
561
|
+
export function getUserTokenMeta(userId: string, tokenId: string): { kind: UserTokenKind; client_label: string | null; user_agent: string | null } | null {
|
|
562
|
+
const row = db.get<any>("SELECT kind, client_label, user_agent FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND network_id IS NULL", tokenId, userId);
|
|
563
|
+
if (!row) return null;
|
|
564
|
+
return { kind: row.kind === "cli" ? "cli" : "login", client_label: row.client_label ?? null, user_agent: row.user_agent ?? null };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// #711 改密码撤销哪些令牌。kind 为 NULL 的(启动回填之后由旧版 Hub 签出的)按 'login' 算。
|
|
568
|
+
const CLI_KIND_SQL = "kind = 'cli'";
|
|
569
|
+
const NOT_CLI_KIND_SQL = "COALESCE(kind, 'login') <> 'cli'";
|
|
570
|
+
|
|
571
|
+
/** 改密码后保留下来的命令行 / 脚本令牌(给 UI 列出来、让用户逐条撤销)。**永远不含令牌值。** */
|
|
572
|
+
export type KeptCliToken = { token_id: string; name: string; client_label: string | null; created_at: string; last_used_at: string | null };
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 改密码后的令牌撤销。默认(keepCli=false)= 改密码前的行为:撤销全部不绑网络的令牌;
|
|
576
|
+
* keepCli=true 时保留 kind='cli' 的,并把它们列出来。当前这条(exceptTokenId)和节点 / 网络令牌永远不动。
|
|
577
|
+
*/
|
|
578
|
+
export function revokeTokensAfterPasswordChange(userId: string, exceptTokenId: string | null | undefined, keepCli: boolean): { login: number; cli: number; kept: KeptCliToken[] } {
|
|
579
|
+
const except = exceptTokenId ? " AND token_id != ?2" : "";
|
|
580
|
+
const params = exceptTokenId ? [userId, exceptTokenId] : [userId];
|
|
581
|
+
const login = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND ${NOT_CLI_KIND_SQL}${except}`, params).changes;
|
|
582
|
+
if (!keepCli) {
|
|
583
|
+
const cli = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL${except}`, params).changes;
|
|
584
|
+
return { login, cli, kept: [] };
|
|
585
|
+
}
|
|
586
|
+
const kept = db.all<any>(
|
|
587
|
+
`SELECT token_id, name, client_label, created_at, last_used_at FROM api_tokens
|
|
588
|
+
WHERE user_id = ?1 AND network_id IS NULL AND ${CLI_KIND_SQL}${except}
|
|
589
|
+
ORDER BY created_at DESC`,
|
|
590
|
+
...params,
|
|
591
|
+
).map((r) => ({ token_id: r.token_id, name: r.name, client_label: r.client_label ?? null, created_at: r.created_at, last_used_at: r.last_used_at ?? null }));
|
|
592
|
+
return { login, cli: 0, kept };
|
|
593
|
+
}
|
|
594
|
+
|
|
334
595
|
export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | null): number {
|
|
335
596
|
const result = exceptTokenId
|
|
336
597
|
? db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND token_id != ?2", [userId, exceptTokenId])
|
|
@@ -338,7 +599,57 @@ export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | n
|
|
|
338
599
|
return result.changes;
|
|
339
600
|
}
|
|
340
601
|
|
|
341
|
-
export
|
|
602
|
+
export type LoginSession = {
|
|
603
|
+
token_id: string;
|
|
604
|
+
name: string;
|
|
605
|
+
created_at: string;
|
|
606
|
+
last_used_at: string | null;
|
|
607
|
+
client_label: string | null;
|
|
608
|
+
user_agent: string | null;
|
|
609
|
+
kind: UserTokenKind;
|
|
610
|
+
is_current: boolean;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
/** 我的登录会话(未过期、未撤销),最近使用的在前。 */
|
|
614
|
+
export function listSessions(userId: string, currentTokenId: string | null): LoginSession[] {
|
|
615
|
+
const cutoff = idleCutoffOffset();
|
|
616
|
+
const rows = db.all<any>(
|
|
617
|
+
`SELECT token_id, name, created_at, last_used_at, client_label, user_agent, kind FROM api_tokens
|
|
618
|
+
WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL${cutoff ? `
|
|
619
|
+
AND COALESCE(last_used_at, created_at) >= datetime('now', ?2)` : ""}
|
|
620
|
+
ORDER BY COALESCE(last_used_at, created_at) DESC, created_at DESC`,
|
|
621
|
+
...(cutoff ? [userId, cutoff] : [userId]));
|
|
622
|
+
return rows.map((r) => ({
|
|
623
|
+
token_id: r.token_id,
|
|
624
|
+
name: r.name,
|
|
625
|
+
created_at: r.created_at,
|
|
626
|
+
last_used_at: r.last_used_at ?? null,
|
|
627
|
+
client_label: r.client_label ?? null,
|
|
628
|
+
user_agent: r.user_agent ?? null,
|
|
629
|
+
kind: r.kind === "cli" ? "cli" : "login",
|
|
630
|
+
is_current: !!currentTokenId && r.token_id === currentTokenId,
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** 撤销我的一个登录会话;可以是当前这个(= 退出登录)。不是会话令牌(API 令牌 / 节点令牌)时当作不存在。 */
|
|
635
|
+
export function revokeSession(userId: string, tokenId: string): { ok: boolean; error?: string } {
|
|
636
|
+
const result = db.run(`DELETE FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND ${SESSION_TOKEN_SQL}`, [tokenId, userId]);
|
|
637
|
+
return result.changes > 0 ? { ok: true } : { ok: false, error: "session_not_found" };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** 退出其他所有设备:撤销我除当前之外的全部登录会话(含已闲置过期的)。API 令牌、节点令牌不动。 */
|
|
641
|
+
export function revokeOtherSessions(userId: string, currentTokenId: string): number {
|
|
642
|
+
return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[] };
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* 改密码。默认撤销该用户全部不绑网络的令牌(改密码前的行为,旧 app 依赖它)。#711:opts.keepCliTokens=true
|
|
649
|
+
* 时保留 kind='cli' 的(anet 命令行登录、具名脚本令牌),并在 kept_cli_tokens 里列出。当前会话和节点令牌永远保留。
|
|
650
|
+
* revoked = 两类合计(旧字段,兼容旧客户端)。
|
|
651
|
+
*/
|
|
652
|
+
export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean } = {}): ChangePasswordResult {
|
|
342
653
|
const passwordError = validatePasswordStrength(newPassword, "new password");
|
|
343
654
|
if (passwordError) return { ok: false, error: passwordError };
|
|
344
655
|
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
@@ -353,8 +664,9 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
|
|
|
353
664
|
// nudge goes away on next login. SET to 0 explicitly (rather than skip)
|
|
354
665
|
// so a future flag flip can't drift the state.
|
|
355
666
|
db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
|
|
356
|
-
const
|
|
357
|
-
|
|
667
|
+
const keep = opts.keepCliTokens === true;
|
|
668
|
+
const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
|
|
669
|
+
return { ok: true, revoked: r.login + r.cli, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}) };
|
|
358
670
|
}
|
|
359
671
|
|
|
360
672
|
/**
|
|
@@ -391,22 +703,61 @@ export function resetUserPassword(targetUsername: string, callerIsHubAdmin: bool
|
|
|
391
703
|
|
|
392
704
|
export function getNetworkMembers(networkId: string) {
|
|
393
705
|
return db.all<any>(
|
|
394
|
-
`SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name
|
|
706
|
+
`SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name, u.avatar_url,
|
|
707
|
+
CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
|
|
708
|
+
(SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count,
|
|
709
|
+
CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.task_access = 'all' THEN 'all' ELSE 'scoped' END AS task_access,
|
|
710
|
+
(SELECT COUNT(*) FROM network_member_project_grants pg WHERE pg.network_id = nm.network_id AND pg.user_id = nm.user_id) AS task_project_count,
|
|
711
|
+
(SELECT COUNT(*) FROM network_member_group_grants gg WHERE gg.network_id = nm.network_id AND gg.user_id = nm.user_id) AS agent_group_count,
|
|
712
|
+
nm.department_id
|
|
395
713
|
FROM network_members nm JOIN users u ON nm.user_id = u.user_id
|
|
396
714
|
WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
|
|
397
715
|
networkId);
|
|
398
716
|
}
|
|
399
717
|
|
|
718
|
+
/** 网络里的人类成员(给受限成员也能看的通讯录):只有身份字段,不含角色与授权。 */
|
|
719
|
+
export function getNetworkHumans(networkId: string) {
|
|
720
|
+
return db.all<{ user_id: string; username: string; display_name: string | null; avatar_url: string | null }>(
|
|
721
|
+
`SELECT u.user_id, u.username, u.display_name, u.avatar_url
|
|
722
|
+
FROM network_members nm JOIN users u ON nm.user_id = u.user_id
|
|
723
|
+
WHERE nm.network_id = ?1 ORDER BY COALESCE(NULLIF(u.display_name, ''), u.username), u.user_id`,
|
|
724
|
+
networkId);
|
|
725
|
+
}
|
|
726
|
+
|
|
400
727
|
export function getUserNetworkRole(userId: string, networkId: string): string | null {
|
|
401
728
|
const row = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
402
729
|
return row?.role || null;
|
|
403
730
|
}
|
|
404
731
|
|
|
405
|
-
|
|
732
|
+
const MEMBER_ROLES = new Set(["admin", "member", "viewer"]);
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* 用户名与本网络某个 Agent 的 alias 相同时不能入网:用户的私人 SSE 频道、inbox、
|
|
736
|
+
* 任务往来都按「用户名」寻址,与 alias 撞名会让受限成员收到那个 Agent 的流量。
|
|
737
|
+
* 只在入网时查(管理员 / 邀请码持有者才触发),不放在公开的 /api/auth/register 上,
|
|
738
|
+
* 免得注册接口变成 alias 探测器。
|
|
739
|
+
*/
|
|
740
|
+
export function usernameCollidesWithAgent(networkId: string, userId: string): boolean {
|
|
741
|
+
return !!db.get(
|
|
742
|
+
`SELECT 1 FROM users u WHERE u.user_id = ?2 AND (
|
|
743
|
+
EXISTS (SELECT 1 FROM sessions s WHERE s.network_id = ?1 AND s.alias = u.username)
|
|
744
|
+
OR EXISTS (SELECT 1 FROM nodes n WHERE n.network_id = ?1 AND n.alias = u.username))`,
|
|
745
|
+
networkId, userId,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// 新成员默认 agent_access='granted'(零 Agent 权限),owner/admin 角色不看这一列。
|
|
750
|
+
export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string, opts: { agentAccess?: "all" | "granted"; taskAccess?: TaskAccessMode } = {}): { ok: boolean; error?: string } {
|
|
751
|
+
if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role" };
|
|
752
|
+
const agentAccess = opts.agentAccess === "all" ? "all" : "granted";
|
|
753
|
+
// 任务范围:显式传值优先;否则 newMemberTaskAccess(role)(#746:member/viewer 'scoped',admin 'all')。
|
|
754
|
+
const taskAccess: TaskAccessMode = opts.taskAccess === "all" || opts.taskAccess === "scoped" ? opts.taskAccess : newMemberTaskAccess(role);
|
|
755
|
+
if (!db.get("SELECT 1 FROM users WHERE user_id = ?1", userId)) return { ok: false, error: "user not found" };
|
|
406
756
|
const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
407
757
|
if (existing) return { ok: false, error: "user already a member" };
|
|
408
|
-
|
|
409
|
-
|
|
758
|
+
if (usernameCollidesWithAgent(networkId, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
|
|
759
|
+
db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access, task_access) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
760
|
+
[networkId, userId, role, invitedBy || null, agentAccess, taskAccess]);
|
|
410
761
|
return { ok: true };
|
|
411
762
|
}
|
|
412
763
|
|
|
@@ -421,7 +772,13 @@ export function removeNetworkMember(networkId: string, userId: string): { ok: bo
|
|
|
421
772
|
const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
422
773
|
if (!member) return { ok: false, error: "not a member" };
|
|
423
774
|
if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
|
|
424
|
-
|
|
775
|
+
groupTx(() => { // RFC-042 §9.3:同 db.transaction,另在提交后推 group_membership_changed(移出网络 → 他所在每个群)
|
|
776
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
777
|
+
// #488 —— 他在这个网络里的令牌(节点令牌 / 邀请码令牌)一起吊销;resolveToken 也会拒非成员的令牌,这里让库里的状态如实。
|
|
778
|
+
db.run("UPDATE api_tokens SET revoked_at = datetime('now') WHERE network_id = ?1 AND user_id = ?2 AND revoked_at IS NULL", [networkId, userId]);
|
|
779
|
+
deleteAgentGrants(networkId, userId);
|
|
780
|
+
deleteTaskGrantsForMember(networkId, userId); removeMemberFromChatGroups(networkId, userId); // RFC-042:他在本网络所有群里的行(不论来源)。同一行:文档钉着 auth.ts 的行号
|
|
781
|
+
});
|
|
425
782
|
return { ok: true };
|
|
426
783
|
}
|
|
427
784
|
|
|
@@ -444,7 +801,10 @@ export function createInvite(networkId: string, createdBy: string, role: string
|
|
|
444
801
|
}
|
|
445
802
|
|
|
446
803
|
export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
|
|
447
|
-
const invite = db.get<any>(
|
|
804
|
+
const invite = db.get<any>(
|
|
805
|
+
"SELECT invite_code, network_id, role, created_by, max_uses, used_count, expires_at, created_at FROM network_invites WHERE invite_code = ?1",
|
|
806
|
+
inviteCode,
|
|
807
|
+
);
|
|
448
808
|
if (!invite) return { ok: false, error: "invalid invite code" };
|
|
449
809
|
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
|
|
450
810
|
if (invite.expires_at) {
|
|
@@ -454,14 +814,17 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
|
|
|
454
814
|
// Check not already member
|
|
455
815
|
const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", invite.network_id, userId);
|
|
456
816
|
if (existing) return { ok: false, error: "already a member of this network" };
|
|
817
|
+
if (usernameCollidesWithAgent(invite.network_id, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
|
|
457
818
|
// Add member + increment used count
|
|
458
|
-
db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
|
|
459
|
-
[invite.network_id, userId, invite.role, invite.created_by]);
|
|
819
|
+
db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access, task_access) VALUES (?1, ?2, ?3, ?4, 'granted', ?5)",
|
|
820
|
+
[invite.network_id, userId, invite.role, invite.created_by, newMemberTaskAccess(invite.role)]);
|
|
460
821
|
db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
|
|
822
|
+
// 受限成员不能持有网络令牌(resolveToken 会拒),别签一个用不了的。
|
|
823
|
+
if (isAgentRestricted(userId, invite.network_id)) return { ok: true, network_id: invite.network_id, role: invite.role };
|
|
461
824
|
// Auto-create a token for this network
|
|
462
825
|
const token = generateToken();
|
|
463
826
|
const tokenId = generateId("tok");
|
|
464
|
-
db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
827
|
+
db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
|
|
465
828
|
[tokenId, hashToken(token), userId, invite.network_id, "auto-join", "full"]);
|
|
466
829
|
return { ok: true, network_id: invite.network_id, role: invite.role };
|
|
467
830
|
}
|
|
@@ -469,8 +832,194 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
|
|
|
469
832
|
/** Get all networks a user is a member of (replaces owner-only query) */
|
|
470
833
|
export function getUserAllNetworks(userId: string) {
|
|
471
834
|
return db.all<any>(
|
|
472
|
-
`SELECT n
|
|
835
|
+
`SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role,
|
|
836
|
+
-- 多用户 Agent 权限:客户端据此显示「还没有被分配任何 Agent,请联系管理员」。
|
|
837
|
+
CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
|
|
838
|
+
-- 任务权限(RFC-038 §9):客户端据此显示只读提示与「还没有与你相关的任务」。
|
|
839
|
+
CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.task_access = 'all' THEN 'all' ELSE 'scoped' END AS task_access
|
|
473
840
|
FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
|
|
841
|
+
JOIN users u ON u.user_id = nm.user_id
|
|
474
842
|
WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
|
|
475
843
|
userId);
|
|
476
844
|
}
|
|
845
|
+
|
|
846
|
+
// ══════════════════════════════════════
|
|
847
|
+
// 多用户账号:管理员建号
|
|
848
|
+
// ══════════════════════════════════════
|
|
849
|
+
|
|
850
|
+
export type AdminCreateUserInput = {
|
|
851
|
+
username?: unknown;
|
|
852
|
+
password?: unknown;
|
|
853
|
+
display_name?: unknown;
|
|
854
|
+
email?: unknown;
|
|
855
|
+
network_id?: unknown;
|
|
856
|
+
role?: unknown;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
export type AdminCreateUserResult =
|
|
860
|
+
| { ok: true; user: AuthUser; network_id: string; membership: { network_id: string; role: string; agent_access: "granted" } | null }
|
|
861
|
+
| { ok: false; error: string; status: number };
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* 管理员(或某网络的 owner/admin,限于把人建进自己管的网络)代建账号。
|
|
865
|
+
* 走 register() 的全部规则(用户名、密码 ≥ 8 且不是弱密码、自动建个人网络),
|
|
866
|
+
* 但不签发令牌 —— 用户自己登录才拿令牌。
|
|
867
|
+
* 可选一步把新用户加进 network_id,默认 role=member、agent_access='granted'(零 Agent 权限)。
|
|
868
|
+
* 建号与入网在同一事务里:入网失败(如用户名与 Agent alias 撞名)时账号也不落库。
|
|
869
|
+
*/
|
|
870
|
+
export function adminCreateUser(input: AdminCreateUserInput, actor: { userId: string; isHubAdmin: boolean }): AdminCreateUserResult {
|
|
871
|
+
const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
|
|
872
|
+
const username = str(input.username);
|
|
873
|
+
const password = typeof input.password === "string" ? input.password : "";
|
|
874
|
+
const displayName = str(input.display_name) || undefined;
|
|
875
|
+
const email = str(input.email) || undefined;
|
|
876
|
+
const networkId = str(input.network_id) || null;
|
|
877
|
+
const role = str(input.role) || "member";
|
|
878
|
+
|
|
879
|
+
if (networkId) {
|
|
880
|
+
if (!db.get("SELECT 1 FROM networks WHERE network_id = ?1", networkId)) return { ok: false, error: "network_not_found", status: 404 };
|
|
881
|
+
if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role", status: 400 };
|
|
882
|
+
const actorRole = getUserNetworkRole(actor.userId, networkId);
|
|
883
|
+
if (!actor.isHubAdmin && actorRole !== "owner" && actorRole !== "admin") return { ok: false, error: "owner/admin required", status: 403 };
|
|
884
|
+
// 网络 admin 不能建出和自己平级的 admin;只有 owner / Hub 管理员可以。
|
|
885
|
+
if (role === "admin" && !actor.isHubAdmin && actorRole !== "owner") return { ok: false, error: "owner required to add admins", status: 403 };
|
|
886
|
+
} else if (!actor.isHubAdmin) {
|
|
887
|
+
return { ok: false, error: "admin required", status: 403 };
|
|
888
|
+
}
|
|
889
|
+
// 首个用户会被 register() 自动设成 Hub 管理员;代建路径上永远不会是首个用户(调用者本身就是用户)。
|
|
890
|
+
// 但仍显式拒绝,防止空库 + 旧令牌之类的边角把代建变成「造管理员」。
|
|
891
|
+
const userCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM users");
|
|
892
|
+
if (!userCount || userCount.cnt === 0) return { ok: false, error: "bootstrap_via_register", status: 400 };
|
|
893
|
+
|
|
894
|
+
class Rollback extends Error { constructor(readonly result: AdminCreateUserResult) { super("rollback"); } }
|
|
895
|
+
try {
|
|
896
|
+
return db.transaction(() => {
|
|
897
|
+
const created = register(username, password, email, displayName, { issueTokens: false });
|
|
898
|
+
if (!created.ok || !created.user) throw new Rollback({ ok: false, error: created.error || "register_failed", status: 400 });
|
|
899
|
+
if (!networkId) return { ok: true as const, user: created.user, network_id: created.network_id!, membership: null };
|
|
900
|
+
const added = addNetworkMember(networkId, created.user.user_id, role, actor.userId);
|
|
901
|
+
if (!added.ok) throw new Rollback({ ok: false, error: added.error || "add_member_failed", status: added.error === "username_collides_with_agent_alias" ? 409 : 400 });
|
|
902
|
+
return {
|
|
903
|
+
ok: true as const,
|
|
904
|
+
user: created.user,
|
|
905
|
+
network_id: created.network_id!,
|
|
906
|
+
membership: { network_id: networkId, role, agent_access: "granted" as const },
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (error instanceof Rollback) return error.result;
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Hub 管理员的用户列表:每个用户带上他所在的网络与角色、agent_access。不含密码哈希。 */
|
|
916
|
+
export function listUsersWithMemberships() {
|
|
917
|
+
const users = db.all<{ user_id: string; username: string; display_name: string | null; email: string | null; role: string; created_at: string }>(
|
|
918
|
+
"SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at",
|
|
919
|
+
);
|
|
920
|
+
const memberships = db.all<{ user_id: string; network_id: string; network_name: string | null; role: string; agent_access: string | null }>(
|
|
921
|
+
`SELECT nm.user_id, nm.network_id, n.network_name, nm.role, nm.agent_access
|
|
922
|
+
FROM network_members nm JOIN networks n ON n.network_id = nm.network_id`,
|
|
923
|
+
);
|
|
924
|
+
const byUser = new Map<string, Array<{ network_id: string; network_name: string | null; role: string; agent_access: "all" | "granted" }>>();
|
|
925
|
+
for (const m of memberships) {
|
|
926
|
+
const list = byUser.get(m.user_id) ?? [];
|
|
927
|
+
list.push({ network_id: m.network_id, network_name: m.network_name, role: m.role, agent_access: m.agent_access === "all" ? "all" : "granted" });
|
|
928
|
+
byUser.set(m.user_id, list);
|
|
929
|
+
}
|
|
930
|
+
return users.map((u) => ({ ...u, networks: byUser.get(u.user_id) ?? [] }));
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Hub 管理员删**别人的**网络(清理旧测试账号留下的个人网络)。只删空网络:
|
|
934
|
+
// 下面每张表里有一行就拒(409),并把计数原样回给调用方。表是按 schema 里所有带
|
|
935
|
+
// network_id 的表逐个分类的,不是凭记忆 —— 新加一张带 network_id 的表要在这里归一类。
|
|
936
|
+
// 阻断:真实内容,或会在删后重新长出节点的在途请求。
|
|
937
|
+
// 清理:只描述「这个网络」本身的元数据/历史,网络没了它们就悬空。
|
|
938
|
+
// 保留:audit_log(删除记录本身要留下)。
|
|
939
|
+
const NETWORK_CONTENT_CHECKS: Array<[label: string, sql: string]> = [
|
|
940
|
+
["nodes", "SELECT COUNT(*) AS cnt FROM nodes WHERE network_id = ?1"],
|
|
941
|
+
["sessions", "SELECT COUNT(*) AS cnt FROM sessions WHERE network_id = ?1"],
|
|
942
|
+
["tasks", "SELECT COUNT(*) AS cnt FROM tasks WHERE network_id = ?1"],
|
|
943
|
+
["inbox", "SELECT COUNT(*) AS cnt FROM inbox WHERE network_id = ?1"],
|
|
944
|
+
["user_inbox", "SELECT COUNT(*) AS cnt FROM user_inbox WHERE network_id = ?1"],
|
|
945
|
+
["requirements", "SELECT COUNT(*) AS cnt FROM requirements WHERE network_id = ?1"],
|
|
946
|
+
["requirement_projects", "SELECT COUNT(*) AS cnt FROM requirement_projects WHERE network_id = ?1"],
|
|
947
|
+
["scheduled_tasks", "SELECT COUNT(*) AS cnt FROM scheduled_tasks WHERE network_id = ?1"],
|
|
948
|
+
["providers", "SELECT COUNT(*) AS cnt FROM providers WHERE network_id = ?1"],
|
|
949
|
+
["network_secrets", "SELECT COUNT(*) AS cnt FROM network_secrets WHERE network_id = ?1"],
|
|
950
|
+
["skillhub_skills", "SELECT COUNT(*) AS cnt FROM skillhub_skills WHERE network_id = ?1"],
|
|
951
|
+
["side_chats", "SELECT COUNT(*) AS cnt FROM side_chats WHERE network_id = ?1"],
|
|
952
|
+
// #2144 —— 组是管理员手工建的配置,和 requirements 一样算内容:有组就拒,别替人删掉。
|
|
953
|
+
["agent_groups", "SELECT COUNT(*) AS cnt FROM agent_groups WHERE network_id = ?1"],
|
|
954
|
+
["pending_node_create_requests", "SELECT COUNT(*) AS cnt FROM node_create_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
|
|
955
|
+
["pending_node_start_requests", "SELECT COUNT(*) AS cnt FROM node_start_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
|
|
956
|
+
];
|
|
957
|
+
const AGENT_TEAM_TABLES = ["network_agent_teams", "network_agent_team_members"]; // #764
|
|
958
|
+
const NETWORK_CLEANUP_TABLES = [
|
|
959
|
+
"network_members", "network_member_agent_grants", "network_invites", "api_tokens",
|
|
960
|
+
"node_create_requests", "node_start_requests", "node_stop_requests", "node_rules_requests",
|
|
961
|
+
"node_config_updates", "rename_txn", "probe_results", "agent_telemetry", "completions",
|
|
962
|
+
"task_events", "task_terminal_events", "task_late_replies", "scheduled_task_runs", "external_schedule_edits",
|
|
963
|
+
// 标签颜色:网络本身的元数据(标签本体在 requirements 行里,那张表是阻断项)。
|
|
964
|
+
"network_tags",
|
|
965
|
+
// 组织架构(board #419):部门是网络本身的元数据。
|
|
966
|
+
"network_departments",
|
|
967
|
+
// Agent 团队(#764):Agent 自己的组织树,同样是网络元数据。
|
|
968
|
+
...AGENT_TEAM_TABLES,
|
|
969
|
+
];
|
|
970
|
+
|
|
971
|
+
function tableExists(name: string): boolean {
|
|
972
|
+
return db.dialect === "postgres"
|
|
973
|
+
? !!db.get("SELECT 1 AS x FROM information_schema.tables WHERE table_name = ?1", name)
|
|
974
|
+
: !!db.get("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?1", name);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export function countNetworkContents(networkId: string): Record<string, number> {
|
|
978
|
+
const counts: Record<string, number> = {};
|
|
979
|
+
for (const [label, sql] of NETWORK_CONTENT_CHECKS) {
|
|
980
|
+
const table = sql.match(/FROM (\w+)/)![1];
|
|
981
|
+
// 按需建表的模块(requirements / side-thread)在没用过的库上可能还没有这张表 —— 那就是 0 行。
|
|
982
|
+
if (!tableExists(table)) continue;
|
|
983
|
+
const cnt = db.get<{ cnt: number }>(sql, networkId)?.cnt ?? 0;
|
|
984
|
+
if (cnt > 0) counts[label] = cnt;
|
|
985
|
+
}
|
|
986
|
+
return counts;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
export type AdminDeleteNetworkResult =
|
|
990
|
+
| { ok: true; owner_id: string; network_name: string; cleaned: Record<string, number> }
|
|
991
|
+
| { ok: false; status: number; error: string; counts?: Record<string, number> };
|
|
992
|
+
|
|
993
|
+
// extraCounts:库外的内容(上传文件的索引在磁盘上),由调用方数好传进来,和库内计数一起判。
|
|
994
|
+
export function adminDeleteEmptyNetwork(networkId: string, extraCounts: Record<string, number> = {}): AdminDeleteNetworkResult {
|
|
995
|
+
if (networkId === "default") return { ok: false, status: 409, error: "the default network cannot be deleted" };
|
|
996
|
+
return db.transaction((): AdminDeleteNetworkResult => {
|
|
997
|
+
const net = db.get<{ owner_id: string; network_name: string }>("SELECT owner_id, network_name FROM networks WHERE network_id = ?1", networkId);
|
|
998
|
+
if (!net) return { ok: false, status: 404, error: "network not found" };
|
|
999
|
+
const counts = { ...countNetworkContents(networkId) };
|
|
1000
|
+
for (const [k, v] of Object.entries(extraCounts)) if (v > 0) counts[k] = v;
|
|
1001
|
+
if (Object.keys(counts).length > 0) {
|
|
1002
|
+
const summary = Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
1003
|
+
return { ok: false, status: 409, error: `network is not empty (${summary}); only empty networks can be deleted by a hub admin`, counts };
|
|
1004
|
+
}
|
|
1005
|
+
const cleaned: Record<string, number> = {};
|
|
1006
|
+
for (const table of NETWORK_CLEANUP_TABLES) {
|
|
1007
|
+
if (!tableExists(table)) continue;
|
|
1008
|
+
const changes = db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [networkId]).changes;
|
|
1009
|
+
if (changes > 0) cleaned[table] = changes;
|
|
1010
|
+
}
|
|
1011
|
+
for (const [table, changes] of Object.entries(deleteNetworkAgentGroups(networkId))) if (changes > 0) cleaned[table] = changes;
|
|
1012
|
+
db.run("DELETE FROM networks WHERE network_id = ?1", [networkId]);
|
|
1013
|
+
return { ok: true, owner_id: net.owner_id, network_name: net.network_name, cleaned };
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// #2144 —— Agent 分组(#2131)的三张表跟着网络一起删。agent_group_members 没有 network_id 列,
|
|
1018
|
+
// 只能按该网络的 group_id 删,所以必须在删 agent_groups 之前删它。owner 删网络与管理员删空网络共用。
|
|
1019
|
+
export function deleteNetworkAgentGroups(networkId: string): Record<string, number> {
|
|
1020
|
+
return {
|
|
1021
|
+
network_member_group_grants: db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1", [networkId]).changes,
|
|
1022
|
+
agent_group_members: db.run("DELETE FROM agent_group_members WHERE group_id IN (SELECT group_id FROM agent_groups WHERE network_id = ?1)", [networkId]).changes,
|
|
1023
|
+
agent_groups: db.run("DELETE FROM agent_groups WHERE network_id = ?1", [networkId]).changes,
|
|
1024
|
+
};
|
|
1025
|
+
}
|