@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
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 +933 -36
- 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 +130 -0
- package/src/rest-identity.ts +139 -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 +93 -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 +140 -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-sender-http.test.ts +143 -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 +3940 -325
- 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
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// 任务(需求卡)的人员权限 —— 「这个人在这个网络里能看见 / 能改 / 能删哪些卡」的唯一实现(RFC-038 §9)。
|
|
2
|
+
//
|
|
3
|
+
// network_members.task_access:
|
|
4
|
+
// 'all' —— 旧语义:网络里的卡全看见;非 viewer 全能改、全能删、能管项目。升级前的成员行全在这里
|
|
5
|
+
// (ALTER … DEFAULT 'all'),所以升级不收窄任何人。
|
|
6
|
+
// 'scoped' —— 只看见 ①负责人是我 ②参与人有我 ③我建的 ④所在项目授权给我 的卡;
|
|
7
|
+
// 能改:负责人是我 / 我建的 / 项目授权 can_edit;能删:负责人是我 / 我建的;不能管项目。
|
|
8
|
+
// viewer 只看 ④,什么都不能改。
|
|
9
|
+
// owner/admin 角色与 Hub 管理员不看这一列。节点令牌(Agent)不经过这里 —— 规则原样不动。
|
|
10
|
+
// RFC-040(#455)再加两个来源,和上面的取并集、只加不减:部门项目授权(授权给我的部门或它的上级 ⇒ 同按人授权),
|
|
11
|
+
// 负责人权力(我负责的部门子树里的卡:可看 / 可改 / 可删;viewer 当负责人不算)。见 department-heads.ts。
|
|
12
|
+
//
|
|
13
|
+
// 判定是 fail-closed 的:task_access 只有字面 'all' 才放开;role 只有 owner/admin 才豁免。
|
|
14
|
+
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
import { deleteDepartmentGrantsForProject, departmentGrantsForMember, headCardsFor, isDepartmentCard, type DepartmentCards } from "./department-heads.js";
|
|
17
|
+
|
|
18
|
+
// ── 表结构(放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次) ──
|
|
19
|
+
// ALTER 的 DEFAULT 'all' 让**升级前已有的**成员行全部落在旧语义 —— 升级不收窄任何人;新成员按下面的
|
|
20
|
+
// newMemberTaskAccess() 入库(#746:默认 'scoped')。项目授权:行存在 = 可看,can_edit=1 = 可改。
|
|
21
|
+
try { db.exec("ALTER TABLE network_members ADD COLUMN task_access TEXT NOT NULL DEFAULT 'all'"); } catch {}
|
|
22
|
+
db.exec(`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS network_member_project_grants (
|
|
24
|
+
network_id TEXT NOT NULL,
|
|
25
|
+
user_id TEXT NOT NULL,
|
|
26
|
+
project_id TEXT NOT NULL,
|
|
27
|
+
can_edit INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
created_by TEXT,
|
|
29
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
30
|
+
PRIMARY KEY (network_id, user_id, project_id)
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_project_grants_project ON network_member_project_grants(project_id);
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 新成员默认的任务范围(#746,owner 选方案 2):只看相关任务。入库路径全部显式写这一列,不靠列 DEFAULT:
|
|
37
|
+
* ① addNetworkMember —— POST /api/networks/:id/members、POST /api/admin/users(带 network_id)
|
|
38
|
+
* ② joinByInvite —— POST /api/networks/join(邀请码)
|
|
39
|
+
* owner 行(register 自动建网、createNetwork、db.ts 的 V3.13 迁移)不经过这里,照旧落列默认 'all'。
|
|
40
|
+
* 列 DEFAULT 仍是 'all'、不迁移老行 —— 升级前的成员一个都不收窄。显式 task_access 覆盖默认;
|
|
41
|
+
* 管理员随时可 PUT /api/networks/:id/members/:uid/task-grants {task_access:'all'} 放宽。
|
|
42
|
+
*/
|
|
43
|
+
export const NEW_MEMBER_TASK_ACCESS: TaskAccessMode = "scoped";
|
|
44
|
+
|
|
45
|
+
/** 新成员行要写入的 task_access。admin 角色本来就不受这一列约束,照旧存 'all'(降级成 member 时与今天行为一致)。 */
|
|
46
|
+
export function newMemberTaskAccess(role: string): TaskAccessMode {
|
|
47
|
+
return UNRESTRICTED_ROLES.has(role) ? "all" : NEW_MEMBER_TASK_ACCESS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TaskAccessMode = "all" | "scoped";
|
|
51
|
+
export type ProjectGrant = { project_id: string; can_edit: boolean };
|
|
52
|
+
|
|
53
|
+
const UNRESTRICTED_ROLES = new Set(["owner", "admin"]);
|
|
54
|
+
|
|
55
|
+
type MemberRow = { member_role: string | null; task_access: string | null; user_role: string | null };
|
|
56
|
+
|
|
57
|
+
function memberRow(userId: string, networkId: string): MemberRow | null {
|
|
58
|
+
return db.get<MemberRow>(
|
|
59
|
+
`SELECT nm.role AS member_role, nm.task_access, u.role AS user_role
|
|
60
|
+
FROM network_members nm JOIN users u ON u.user_id = nm.user_id
|
|
61
|
+
WHERE nm.network_id = ?1 AND nm.user_id = ?2`,
|
|
62
|
+
networkId, userId,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scopedRow(row: MemberRow | null): boolean {
|
|
67
|
+
if (!row) return false;
|
|
68
|
+
if (row.user_role === "admin") return false;
|
|
69
|
+
if (UNRESTRICTED_ROLES.has(String(row.member_role))) return false;
|
|
70
|
+
return row.task_access !== "all";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 这个用户在这个网络里是不是「只看相关任务」的成员。非成员返回 false(成员资格由调用方的 scope 管)。 */
|
|
74
|
+
export function isTaskScoped(userId: string, networkId: string | null | undefined): boolean {
|
|
75
|
+
if (!userId || !networkId) return false;
|
|
76
|
+
return scopedRow(memberRow(userId, networkId));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 该用户所有「只看相关任务」的网络;每个带上他是不是 viewer(viewer 只看授权项目)。 */
|
|
80
|
+
export function taskScopedNetworks(userId: string): Array<{ networkId: string; viewer: boolean }> {
|
|
81
|
+
if (!userId) return [];
|
|
82
|
+
return db.all<MemberRow & { network_id: string }>(
|
|
83
|
+
`SELECT nm.network_id, nm.role AS member_role, nm.task_access, u.role AS user_role
|
|
84
|
+
FROM network_members nm JOIN users u ON u.user_id = nm.user_id
|
|
85
|
+
WHERE nm.user_id = ?1`,
|
|
86
|
+
userId,
|
|
87
|
+
).filter(scopedRow).map((row) => ({ networkId: row.network_id, viewer: row.member_role === "viewer" }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getTaskAccessMode(networkId: string, userId: string): TaskAccessMode | null {
|
|
91
|
+
const row = db.get<{ task_access: string | null }>("SELECT task_access FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
92
|
+
if (!row) return null;
|
|
93
|
+
return row.task_access === "all" ? "all" : "scoped";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function listProjectGrants(networkId: string, userId: string): ProjectGrant[] {
|
|
97
|
+
return db.all<{ project_id: string; can_edit: number }>(
|
|
98
|
+
"SELECT project_id, can_edit FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2 ORDER BY project_id",
|
|
99
|
+
networkId, userId,
|
|
100
|
+
).map((row) => ({ project_id: row.project_id, can_edit: row.can_edit === 1 }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 卡片是不是「我的」 ──
|
|
104
|
+
// owner / participants / created_by_json 存的都是 JSON.stringify({kind, id})(requirements.ts 的 personRef / actorOf),
|
|
105
|
+
// 所以用子串匹配 `"kind":"user","id":"<uid>"`:SQLite 与 PostgreSQL 都支持 LIKE … ESCAPE,不依赖 json_each。
|
|
106
|
+
const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
107
|
+
export const userRefPattern = (userId: string) => `%${likeEscape(`"kind":"user","id":"${userId}"`)}%`;
|
|
108
|
+
|
|
109
|
+
type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null; parent_id?: string | null };
|
|
110
|
+
const refIs = (json: string | null, userId: string) => !!json && json.includes(`"kind":"user","id":"${userId}"`);
|
|
111
|
+
const ownsCard = (row: CardRow, userId: string) => refIs(row.owner_json, userId);
|
|
112
|
+
const createdCard = (row: CardRow, userId: string) => refIs(row.created_by_json, userId) || (!row.created_by_json && row.created_by === userId);
|
|
113
|
+
const participates = (row: CardRow, userId: string) => refIs(row.participants_json, userId);
|
|
114
|
+
|
|
115
|
+
function projectGrant(networkId: string, userId: string, projectId: string | null): ProjectGrant | null {
|
|
116
|
+
if (!projectId) return null;
|
|
117
|
+
const row = db.get<{ can_edit: number }>(
|
|
118
|
+
"SELECT can_edit FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2 AND project_id = ?3",
|
|
119
|
+
networkId, userId, projectId,
|
|
120
|
+
);
|
|
121
|
+
return row ? { project_id: projectId, can_edit: row.can_edit === 1 } : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 部门项目授权(RFC-040):null = 部门没授权这个项目;否则 = can_edit。 */
|
|
125
|
+
function departmentGrant(networkId: string, userId: string, projectId: string | null): boolean | null {
|
|
126
|
+
if (!projectId) return null;
|
|
127
|
+
const grants = departmentGrantsForMember(networkId, userId);
|
|
128
|
+
return grants.has(projectId) ? grants.get(projectId)! : null;
|
|
129
|
+
}
|
|
130
|
+
/** 这张卡是不是我负责的部门(含下级)的卡。 */
|
|
131
|
+
function headOf(userId: string, row: CardRow): boolean {
|
|
132
|
+
const cards = headCardsFor(row.network_id, userId);
|
|
133
|
+
return !!cards && isDepartmentCard(cards, row);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 调用者。userId = 按谁的任务权限判;node(RFC-041,#487)= 节点令牌按**主人**的权限判时带上节点:
|
|
138
|
+
* only=false(正常模式、执行开关打开)—— 主人能改的能改,另外「派给这个节点的」在主人看得见时也能改;
|
|
139
|
+
* only=true (受限模式)—— 只看、只改派给这个节点的卡(负责 Agent / 参与人 / 它建的,及这些卡的子任务)。
|
|
140
|
+
*/
|
|
141
|
+
export type TaskCaller = { userId: string; node?: { id: string; only: boolean } } | null;
|
|
142
|
+
|
|
143
|
+
export const nodeRefPattern = (nodeId: string) => `%${likeEscape(`"kind":"node","id":"${nodeId}"`)}%`;
|
|
144
|
+
const nodeRefIs = (json: string | null | undefined, nodeId: string) => !!json && json.includes(`"kind":"node","id":"${nodeId}"`);
|
|
145
|
+
const directlyAssigned = (row: Pick<CardRow, "agent_owner_json" | "participants_json" | "created_by_json">, nodeId: string) =>
|
|
146
|
+
nodeRefIs(row.agent_owner_json, nodeId) || nodeRefIs(row.participants_json, nodeId) || nodeRefIs(row.created_by_json, nodeId);
|
|
147
|
+
|
|
148
|
+
/** 这张卡派给了这个节点吗:负责 Agent / 参与人有它 / 它建的,或者父卡是。 */
|
|
149
|
+
export function cardAssignedToNode(row: CardRow, nodeId: string): boolean {
|
|
150
|
+
if (directlyAssigned(row, nodeId)) return true;
|
|
151
|
+
if (!row.parent_id) return false;
|
|
152
|
+
const parent = db.get<{ agent_owner_json: string | null; participants_json: string | null; created_by_json: string | null }>(
|
|
153
|
+
"SELECT agent_owner_json, participants_json, created_by_json FROM requirements WHERE requirement_id = ?1", row.parent_id);
|
|
154
|
+
return !!parent && directlyAssigned(parent, nodeId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 看得见这张卡吗。caller=null(节点令牌 / 管理员 / 旧的全局令牌)恒为 true。 */
|
|
158
|
+
export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
|
|
159
|
+
if (caller?.node?.only && !cardAssignedToNode(row, caller.node.id)) return false;
|
|
160
|
+
return canSeeTaskAsUser(caller, row);
|
|
161
|
+
}
|
|
162
|
+
function canSeeTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
|
|
163
|
+
if (!caller) return true;
|
|
164
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
165
|
+
if (!scopedRow(m)) return true;
|
|
166
|
+
if (projectGrant(row.network_id, caller.userId, row.project_id)) return true;
|
|
167
|
+
if (departmentGrant(row.network_id, caller.userId, row.project_id) !== null) return true;
|
|
168
|
+
if (m?.member_role === "viewer") return false;
|
|
169
|
+
return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || participates(row, caller.userId) || headOf(caller.userId, row);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 能改这张卡吗(PATCH / 勾子任务)。只对看得见的卡调用。 */
|
|
173
|
+
export function canEditTask(caller: TaskCaller, row: CardRow): boolean {
|
|
174
|
+
if (caller?.node) {
|
|
175
|
+
const assigned = cardAssignedToNode(row, caller.node.id) && canSeeTaskAsUser(caller, row);
|
|
176
|
+
return caller.node.only ? assigned : assigned || canEditTaskAsUser(caller, row);
|
|
177
|
+
}
|
|
178
|
+
return canEditTaskAsUser(caller, row);
|
|
179
|
+
}
|
|
180
|
+
function canEditTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
|
|
181
|
+
if (!caller) return true;
|
|
182
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
183
|
+
if (!scopedRow(m)) return true;
|
|
184
|
+
if (m?.member_role === "viewer") return false;
|
|
185
|
+
if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return true;
|
|
186
|
+
if (projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true) return true;
|
|
187
|
+
return departmentGrant(row.network_id, caller.userId, row.project_id) === true || headOf(caller.userId, row);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 这次能改,是不是**只**因为负责人身份(没有自己的 / 按人或部门授权的来源)。这种时候负责人只能把卡交给本部门的人
|
|
192
|
+
* (RFC-040 §1:改负责人只能改成本部门成员 / 其 Agent / 自己),返回本部门的人和节点;否则 null(不加限制)。
|
|
193
|
+
*/
|
|
194
|
+
export function headOnlyEditScope(caller: TaskCaller, row: CardRow): DepartmentCards | null {
|
|
195
|
+
if (!caller) return null;
|
|
196
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
197
|
+
if (!scopedRow(m) || m?.member_role === "viewer") return null;
|
|
198
|
+
if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return null;
|
|
199
|
+
if (projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true) return null;
|
|
200
|
+
if (departmentGrant(row.network_id, caller.userId, row.project_id) === true) return null;
|
|
201
|
+
const cards = headCardsFor(row.network_id, caller.userId);
|
|
202
|
+
return cards && isDepartmentCard(cards, row) ? cards : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 参与人能改的字段(Vincent 2026-10-01「参与人可以改,但是改了之后需要有个通知」):状态(列)和检查项。
|
|
207
|
+
* 只对 canEditTask 为 false 的 scoped 参与人生效;标题 / 描述 / 负责人 / 参与人 / 项目 / 优先级 / 日期 / 标签等仍只给
|
|
208
|
+
* 负责人 / 创建者 / 可改项目授权。
|
|
209
|
+
*/
|
|
210
|
+
export const PARTICIPANT_EDIT_FIELDS: readonly string[] = ["column", "checklist"];
|
|
211
|
+
|
|
212
|
+
/** 这个调用者是不是「只能以参与人身份改」这张卡(canEditTask 为 false、但他是卡上的参与人)。viewer 不算。 */
|
|
213
|
+
export function canParticipantEditTask(caller: TaskCaller, row: CardRow): boolean {
|
|
214
|
+
if (!caller || caller.node) return false; // 参与人身份是人的;节点按 canEditTask 判
|
|
215
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
216
|
+
if (!scopedRow(m) || m?.member_role === "viewer") return false;
|
|
217
|
+
return participates(row, caller.userId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 卡上有没有这个用户当参与人(不看权限模式;通知用)。 */
|
|
221
|
+
export function isParticipant(row: Pick<CardRow, "participants_json">, userId: string): boolean {
|
|
222
|
+
return refIs(row.participants_json, userId);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 能删这张卡吗。scoped 成员只能删负责人是自己或自己建的。 */
|
|
226
|
+
export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
|
|
227
|
+
if (!caller) return true;
|
|
228
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
229
|
+
if (!scopedRow(m)) return true;
|
|
230
|
+
if (m?.member_role === "viewer") return false;
|
|
231
|
+
return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || headOf(caller.userId, row);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 这次能删只因为负责人身份(RFC-040 Q4:记 requirement_deleted_by_leader + 私信卡的负责人)。 */
|
|
235
|
+
export function deletesAsHeadOnly(caller: TaskCaller, row: CardRow): boolean {
|
|
236
|
+
if (!caller) return false;
|
|
237
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
238
|
+
if (!scopedRow(m) || m?.member_role === "viewer") return false;
|
|
239
|
+
return !ownsCard(row, caller.userId) && !createdCard(row, caller.userId) && headOf(caller.userId, row);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 给客户端画「只读 / 不能删」用的每卡权限。只对 scoped 调用者返回(其余 null = 与今天一样全能)。
|
|
244
|
+
* 一个请求里按网络缓存成员行与项目授权,列表 500 张卡不会变成 500 × 3 次查询。
|
|
245
|
+
*/
|
|
246
|
+
// edit_fields:只在 edit=false、但调用者是这张卡的参与人时出现 —— 他能改的就是这几个字段(旧 App 不认这个键,照旧画只读)。
|
|
247
|
+
export type TaskPermissions = { edit: boolean; delete: boolean; edit_fields?: readonly string[] };
|
|
248
|
+
export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => TaskPermissions | null {
|
|
249
|
+
if (!caller) return () => null;
|
|
250
|
+
const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean>; deptGrants: Map<string, boolean>; head: DepartmentCards | null }>();
|
|
251
|
+
const ctxFor = (networkId: string) => {
|
|
252
|
+
let c = cache.get(networkId);
|
|
253
|
+
if (!c) {
|
|
254
|
+
const m = memberRow(caller.userId, networkId);
|
|
255
|
+
const scoped = scopedRow(m);
|
|
256
|
+
const viewer = m?.member_role === "viewer";
|
|
257
|
+
const grants = new Map<string, boolean>(scoped ? listProjectGrants(networkId, caller.userId).map((g) => [g.project_id, g.can_edit] as [string, boolean]) : []);
|
|
258
|
+
const deptGrants = scoped ? departmentGrantsForMember(networkId, caller.userId) : new Map<string, boolean>();
|
|
259
|
+
cache.set(networkId, c = { scoped, viewer, grants, deptGrants, head: scoped && !viewer ? headCardsFor(networkId, caller.userId) : null });
|
|
260
|
+
}
|
|
261
|
+
return c;
|
|
262
|
+
};
|
|
263
|
+
return (row: CardRow) => {
|
|
264
|
+
const c = ctxFor(row.network_id);
|
|
265
|
+
if (!c.scoped) return null;
|
|
266
|
+
if (c.viewer) return { edit: false, delete: false };
|
|
267
|
+
const head = !!c.head && isDepartmentCard(c.head, row);
|
|
268
|
+
const mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId) || head;
|
|
269
|
+
const edit = mine || (row.project_id ? c.grants.get(row.project_id) === true || c.deptGrants.get(row.project_id) === true : false);
|
|
270
|
+
if (!edit && participates(row, caller.userId)) return { edit, delete: mine, edit_fields: PARTICIPANT_EDIT_FIELDS };
|
|
271
|
+
return { edit, delete: mine };
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 项目列表的 viewer_can.edit 用:与 canUseProject 同一判据,但一个请求里只读一次授权表(列表最多 200 个项目)。
|
|
277
|
+
* 不受任务范围限制的调用者(节点 / Hub 管理员 / owner / admin / task_access='all')⇒ 每个项目都 true。
|
|
278
|
+
*/
|
|
279
|
+
export function projectUseResolver(caller: TaskCaller, networkId: string): (projectId: string) => boolean {
|
|
280
|
+
if (!caller || !isTaskScoped(caller.userId, networkId)) return () => true;
|
|
281
|
+
const editable = new Set(listProjectGrants(networkId, caller.userId).filter((g) => g.can_edit).map((g) => g.project_id));
|
|
282
|
+
for (const [projectId, canEdit] of departmentGrantsForMember(networkId, caller.userId)) if (canEdit) editable.add(projectId);
|
|
283
|
+
return (projectId) => editable.has(projectId);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 新建 / 挪卡时的目标项目:scoped 成员只能放进自己 can_edit 的项目(null = 不放项目,总是可以)。 */
|
|
287
|
+
export function canUseProject(caller: TaskCaller, networkId: string, projectId: string | null): boolean {
|
|
288
|
+
if (!caller || !projectId) return true;
|
|
289
|
+
if (!isTaskScoped(caller.userId, networkId)) return true;
|
|
290
|
+
return projectGrant(networkId, caller.userId, projectId)?.can_edit === true || departmentGrant(networkId, caller.userId, projectId) === true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 列表 / 搜索 / #N 查找的可见性子句。只对调用者 scoped 的网络加条件,别的网络原样放行。
|
|
295
|
+
* 追加在已经按网络过滤过的 `FROM requirements WHERE …` 后面。
|
|
296
|
+
*/
|
|
297
|
+
export function addTaskVisibilityScope(sql: string, params: unknown[], caller: TaskCaller): string {
|
|
298
|
+
const scoped = addTaskVisibilityScopeAsUser(sql, params, caller);
|
|
299
|
+
if (!caller?.node?.only) return scoped;
|
|
300
|
+
// 受限节点:再只留派给它的卡(及其子任务)。
|
|
301
|
+
const pat = params.push(nodeRefPattern(caller.node.id));
|
|
302
|
+
const mine = (t: string) => `${t}agent_owner_json LIKE ?${pat} ESCAPE '\\' OR ${t}participants_json LIKE ?${pat} ESCAPE '\\' OR ${t}created_by_json LIKE ?${pat} ESCAPE '\\'`;
|
|
303
|
+
return `${scoped} AND (${mine("")} OR parent_id IN (SELECT p.requirement_id FROM requirements p WHERE ${mine("p.")}))`;
|
|
304
|
+
}
|
|
305
|
+
function addTaskVisibilityScopeAsUser(sql: string, params: unknown[], caller: TaskCaller): string {
|
|
306
|
+
if (!caller) return sql;
|
|
307
|
+
const scoped = taskScopedNetworks(caller.userId);
|
|
308
|
+
if (!scoped.length) return sql;
|
|
309
|
+
// 参数只在真正用到时才压进去:PostgreSQL 的预编译语句里出现「没被引用的 $n」会直接报错
|
|
310
|
+
// (推不出类型),SQLite 不在乎 —— 只有 viewer 的网络时,「我的卡」那段模式参数就不该出现。
|
|
311
|
+
const uid = params.push(caller.userId);
|
|
312
|
+
const granted = `project_id IN (SELECT g.project_id FROM network_member_project_grants g WHERE g.user_id = ?${uid} AND g.network_id = requirements.network_id)`;
|
|
313
|
+
const members = scoped.filter((s) => !s.viewer).map((s) => s.networkId);
|
|
314
|
+
const viewers = scoped.filter((s) => s.viewer).map((s) => s.networkId);
|
|
315
|
+
const ph = (ids: string[]) => ids.map((id) => `?${params.push(id)}`).join(", ");
|
|
316
|
+
const all = scoped.map((s) => s.networkId);
|
|
317
|
+
const parts = [`network_id NOT IN (${ph(all)})`];
|
|
318
|
+
if (members.length) {
|
|
319
|
+
const pat = params.push(userRefPattern(caller.userId));
|
|
320
|
+
const mine = `owner_json LIKE ?${pat} ESCAPE '\\' OR participants_json LIKE ?${pat} ESCAPE '\\' OR created_by_json LIKE ?${pat} ESCAPE '\\' OR (created_by_json IS NULL AND created_by = ?${uid})`;
|
|
321
|
+
parts.push(`(network_id IN (${ph(members)}) AND (${mine} OR ${granted}))`);
|
|
322
|
+
}
|
|
323
|
+
if (viewers.length) parts.push(`(network_id IN (${ph(viewers)}) AND ${granted})`);
|
|
324
|
+
// RFC-040:部门项目授权(viewer 也有,和按人授权一样只给「看」)+ 负责人本部门的卡(viewer 没有)。只加析取项。
|
|
325
|
+
for (const s of scoped) {
|
|
326
|
+
const extra: string[] = [];
|
|
327
|
+
const deptProjects = [...departmentGrantsForMember(s.networkId, caller.userId).keys()];
|
|
328
|
+
if (deptProjects.length) extra.push(`project_id IN (${ph(deptProjects)})`);
|
|
329
|
+
const head = s.viewer ? null : headCardsFor(s.networkId, caller.userId);
|
|
330
|
+
if (head?.users.size) extra.push(`owner_json IN (${ph([...head.users].map((id) => JSON.stringify({ kind: "user", id })))})`);
|
|
331
|
+
if (head?.nodes.size) extra.push(`agent_owner_json IN (${ph([...head.nodes].map((id) => JSON.stringify({ kind: "node", id })))})`);
|
|
332
|
+
if (extra.length) parts.push(`(network_id = ?${params.push(s.networkId)} AND (${extra.join(" OR ")}))`);
|
|
333
|
+
}
|
|
334
|
+
return `${sql} AND (${parts.join(" OR ")})`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 项目列表的可见性:scoped 成员只看授权给自己的项目。 */
|
|
338
|
+
export function addProjectVisibilityScope(sql: string, params: unknown[], caller: TaskCaller, networkId: string): string {
|
|
339
|
+
if (!caller || !isTaskScoped(caller.userId, networkId)) return sql;
|
|
340
|
+
const uid = params.push(caller.userId);
|
|
341
|
+
const net = params.push(networkId);
|
|
342
|
+
const personal = `project_id IN (SELECT project_id FROM network_member_project_grants WHERE user_id = ?${uid} AND network_id = ?${net})`;
|
|
343
|
+
const deptProjects = [...departmentGrantsForMember(networkId, caller.userId).keys()];
|
|
344
|
+
if (!deptProjects.length) return `${sql} AND ${personal}`;
|
|
345
|
+
return `${sql} AND (${personal} OR project_id IN (${deptProjects.map((id) => `?${params.push(id)}`).join(", ")}))`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export type ReplaceTaskGrantsResult =
|
|
349
|
+
| { ok: true; task_access: TaskAccessMode; project_grants: ProjectGrant[] }
|
|
350
|
+
| { ok: false; error: string; status: number; detail?: unknown };
|
|
351
|
+
|
|
352
|
+
const MAX_PROJECT_GRANTS = 500;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 整体替换某成员的任务授权。字段不传 = 保持原样(旧客户端不会误清)。
|
|
356
|
+
* 项目必须是本网络的;任何一条不合格 ⇒ 整批拒绝,不做部分写入。
|
|
357
|
+
*/
|
|
358
|
+
export function replaceTaskGrants(input: { networkId: string; userId: string; taskAccess?: unknown; projectGrants?: unknown; actorUserId: string }): ReplaceTaskGrantsResult {
|
|
359
|
+
if (!db.get("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", input.networkId, input.userId)) {
|
|
360
|
+
return { ok: false, error: "member_not_found", status: 404 };
|
|
361
|
+
}
|
|
362
|
+
let mode: TaskAccessMode | undefined;
|
|
363
|
+
if (input.taskAccess !== undefined) {
|
|
364
|
+
if (input.taskAccess !== "all" && input.taskAccess !== "scoped") return { ok: false, error: "invalid_task_access", status: 400 };
|
|
365
|
+
mode = input.taskAccess;
|
|
366
|
+
}
|
|
367
|
+
let rows: Array<{ project_id: string; can_edit: number }> | undefined;
|
|
368
|
+
if (input.projectGrants !== undefined) {
|
|
369
|
+
if (!Array.isArray(input.projectGrants)) return { ok: false, error: "project_grants_must_be_array", status: 400 };
|
|
370
|
+
if (input.projectGrants.length > MAX_PROJECT_GRANTS) return { ok: false, error: "too_many_grants", status: 400, detail: { limit: MAX_PROJECT_GRANTS } };
|
|
371
|
+
const seen = new Set<string>();
|
|
372
|
+
rows = [];
|
|
373
|
+
for (const [index, raw] of input.projectGrants.entries()) {
|
|
374
|
+
const item = typeof raw === "string" ? { project_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as { project_id?: unknown; can_edit?: unknown } : {});
|
|
375
|
+
const projectId = typeof item.project_id === "string" ? item.project_id.trim() : "";
|
|
376
|
+
if (!projectId) return { ok: false, error: "project_grant_needs_project_id", status: 400, detail: { index } };
|
|
377
|
+
if (item.can_edit !== undefined && typeof item.can_edit !== "boolean") return { ok: false, error: "can_edit_must_be_boolean", status: 400, detail: { index } };
|
|
378
|
+
if (!db.get("SELECT 1 FROM requirement_projects WHERE project_id = ?1 AND network_id = ?2", projectId, input.networkId)) {
|
|
379
|
+
return { ok: false, error: "project_not_in_network", status: 400, detail: { index, project_id: projectId } };
|
|
380
|
+
}
|
|
381
|
+
if (seen.has(projectId)) continue;
|
|
382
|
+
seen.add(projectId);
|
|
383
|
+
rows.push({ project_id: projectId, can_edit: item.can_edit === true ? 1 : 0 });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
db.transaction(() => {
|
|
387
|
+
if (mode) db.run("UPDATE network_members SET task_access = ?1 WHERE network_id = ?2 AND user_id = ?3", [mode, input.networkId, input.userId]);
|
|
388
|
+
if (rows) {
|
|
389
|
+
db.run("DELETE FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2", [input.networkId, input.userId]);
|
|
390
|
+
for (const row of rows) {
|
|
391
|
+
db.run(
|
|
392
|
+
"INSERT INTO network_member_project_grants (network_id, user_id, project_id, can_edit, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
393
|
+
[input.networkId, input.userId, row.project_id, row.can_edit, input.actorUserId],
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
return { ok: true, task_access: getTaskAccessMode(input.networkId, input.userId) ?? "scoped", project_grants: listProjectGrants(input.networkId, input.userId) };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 成员被移出网络 / 项目被删除时清掉对应授权。 */
|
|
402
|
+
export function deleteTaskGrantsForMember(networkId: string, userId: string): void {
|
|
403
|
+
db.run("DELETE FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
404
|
+
}
|
|
405
|
+
export function deleteTaskGrantsForProject(projectId: string): void {
|
|
406
|
+
db.run("DELETE FROM network_member_project_grants WHERE project_id = ?1", [projectId]);
|
|
407
|
+
deleteDepartmentGrantsForProject(projectId);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 被拒的写入审计限频:同一 (用户, 卡) 每小时最多记一条,别让重试刷爆审计表。
|
|
411
|
+
const deniedSeen = new Map<string, number>();
|
|
412
|
+
const DENIED_WINDOW_MS = 60 * 60 * 1000;
|
|
413
|
+
export function shouldAuditDenied(userId: string, requirementId: string, now = Date.now()): boolean {
|
|
414
|
+
const key = `${userId}\u0000${requirementId}`;
|
|
415
|
+
const last = deniedSeen.get(key);
|
|
416
|
+
if (last !== undefined && now - last < DENIED_WINDOW_MS) return false;
|
|
417
|
+
deniedSeen.set(key, now);
|
|
418
|
+
if (deniedSeen.size > 10_000) for (const [k, t] of deniedSeen) if (now - t >= DENIED_WINDOW_MS) deniedSeen.delete(k);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
6
|
+
import { db } from "./db.js";
|
|
7
|
+
|
|
8
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-auth-origin-http-"));
|
|
9
|
+
let server: any;
|
|
10
|
+
let base = "";
|
|
11
|
+
let userToken = "";
|
|
12
|
+
let userName = "";
|
|
13
|
+
let nodeToken = "";
|
|
14
|
+
let networkId = "";
|
|
15
|
+
const TARGET = `auth-origin-http-target-${process.pid}`;
|
|
16
|
+
const TARGET_NODE_ID = `node_auth_origin_target_${process.pid}`;
|
|
17
|
+
const TARGET_RESUME_ID = `resume_auth_origin_target_${process.pid}`;
|
|
18
|
+
|
|
19
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
20
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
21
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
22
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
25
|
+
const username = `auth_origin_${Date.now()}`;
|
|
26
|
+
userName = username;
|
|
27
|
+
const registered = register(username, "AuthOriginTest123!", undefined, "seed");
|
|
28
|
+
expect(registered.ok).toBe(true);
|
|
29
|
+
userToken = registered.token!;
|
|
30
|
+
networkId = registered.network_id!;
|
|
31
|
+
const userId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [username])!.user_id;
|
|
32
|
+
const minted = createNetworkTokenForNode(userId, networkId, "auth-origin-node");
|
|
33
|
+
expect(minted.ok).toBe(true);
|
|
34
|
+
nodeToken = minted.token!;
|
|
35
|
+
db.run(
|
|
36
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
37
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
38
|
+
[TARGET_RESUME_ID, TARGET, TARGET_NODE_ID, networkId],
|
|
39
|
+
);
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
|
|
42
|
+
VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
|
|
43
|
+
[TARGET_NODE_ID, TARGET, networkId],
|
|
44
|
+
);
|
|
45
|
+
const mod: any = await import("./server.js");
|
|
46
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
47
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
48
|
+
}, 30_000);
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
try { server?.stop?.(true); } catch {}
|
|
52
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function postTask(token: string, from: string, marker: string) {
|
|
56
|
+
const response = await fetch(`${base}/api/task`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
alias: TARGET,
|
|
61
|
+
from,
|
|
62
|
+
network_id: networkId,
|
|
63
|
+
task: marker,
|
|
64
|
+
meta: {
|
|
65
|
+
source: "dashboard-chat",
|
|
66
|
+
client_request_id: `dreq_${"a".repeat(31)}${marker.endsWith("user") ? "1" : "2"}`,
|
|
67
|
+
auth_origin: "user",
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
const body = await response.json() as any;
|
|
72
|
+
expect([200, 202]).toContain(response.status);
|
|
73
|
+
const taskId = body.task_id ?? body.message_id;
|
|
74
|
+
expect(taskId).toBeTruthy();
|
|
75
|
+
const inbox = db.get<{ meta_json: string; task_id: string }>(
|
|
76
|
+
"SELECT meta_json, task_id FROM inbox WHERE id = ?1",
|
|
77
|
+
[taskId],
|
|
78
|
+
)!;
|
|
79
|
+
expect(inbox.task_id).toBe(taskId);
|
|
80
|
+
return JSON.parse(inbox.meta_json);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("POST /api/task server-authenticated origin", () => {
|
|
84
|
+
test("user token stamps user even when the client supplied another fact", async () => {
|
|
85
|
+
const meta = await postTask(userToken, userName, "origin-user");
|
|
86
|
+
expect(meta.auth_origin).toBe("user");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("node token cannot spoof Dashboard trust through REST metadata", async () => {
|
|
90
|
+
const meta = await postTask(nodeToken, "auth-origin-node", "origin-node");
|
|
91
|
+
expect(meta.auth_origin).toBe("node");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { stampTaskAuthOrigin } from "./task-auth-origin";
|
|
3
|
+
|
|
4
|
+
describe("stampTaskAuthOrigin", () => {
|
|
5
|
+
test("server auth fact overrides a client-spoofed origin", () => {
|
|
6
|
+
expect(stampTaskAuthOrigin({ source: "dashboard-chat", auth_origin: "user" }, "node")).toEqual({
|
|
7
|
+
source: "dashboard-chat",
|
|
8
|
+
auth_origin: "node",
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("preserves existing metadata fields", () => {
|
|
13
|
+
expect(stampTaskAuthOrigin({ client_request_id: "dreq_x", attachments: [] }, "user")).toEqual({
|
|
14
|
+
client_request_id: "dreq_x",
|
|
15
|
+
attachments: [],
|
|
16
|
+
auth_origin: "user",
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("does not manufacture metadata for old callers that supplied none", () => {
|
|
21
|
+
expect(stampTaskAuthOrigin(undefined, "legacy")).toBeUndefined();
|
|
22
|
+
expect(stampTaskAuthOrigin(null, "legacy")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type TaskAuthOrigin = "user" | "node" | "legacy";
|
|
2
|
+
|
|
3
|
+
/** Stamp task metadata from server authentication facts, overriding spoofed input. */
|
|
4
|
+
export function stampTaskAuthOrigin(meta: unknown, origin: TaskAuthOrigin): unknown {
|
|
5
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return meta;
|
|
6
|
+
return { ...(meta as Record<string, unknown>), auth_origin: origin };
|
|
7
|
+
}
|