@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.140
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-department-http.test.ts +148 -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 +423 -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 +604 -51
- 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/chain-attempt-dedup.test.ts +447 -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 +49 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +199 -7
- package/src/create-node-validate.ts +232 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +88 -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 +109 -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 +1111 -42
- 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 +276 -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/env-transport-gate-http.test.ts +146 -0
- package/src/env-transport-gate.ts +74 -0
- package/src/env-transport-policy.ts +21 -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/host-supervisor-identity-http.test.ts +152 -0
- package/src/host-supervisor-identity.ts +92 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-catalog-read-http.test.ts +187 -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/human-inbox.ts +42 -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/list-host-supervisors.test.ts +5 -4
- 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 +1083 -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 +135 -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-secret-rest-http.test.ts +190 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-identity.ts +35 -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 +442 -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/password-network-tokens.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 +38 -10
- 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 +184 -0
- package/src/report-status-secret-keys.test.ts +140 -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 +131 -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 +357 -0
- package/src/requirements-stats.ts +195 -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 +141 -0
- package/src/rest-projections.ts +70 -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-backoff-http.test.ts +193 -0
- package/src/scheduled-backoff.ts +64 -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-outcome-guidance-http.test.ts +139 -0
- package/src/scheduled-outcome-guidance.ts +9 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-requirement-wake-http.test.ts +264 -0
- package/src/scheduled-requirement-wake.ts +125 -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 +312 -0
- package/src/scheduled-stuck.ts +186 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +819 -0
- package/src/scheduled-work-outcome-http.test.ts +154 -0
- package/src/scheduled-work-outcome.ts +50 -0
- package/src/scoped-secrets.ts +139 -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 +5398 -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 -2
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-activity.ts +60 -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 +4008 -355
- 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,139 @@
|
|
|
1
|
+
import './require-explicit-test-db';
|
|
2
|
+
import { afterAll, beforeEach, expect, test } from 'bun:test';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { db } from './db';
|
|
7
|
+
import { registerTools } from './tools';
|
|
8
|
+
import { vaultGet } from './vault';
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { reconcileProviderSnapshots, secureProviderTransport } from '../../agent-node/src/runtime/daemon-provider-snapshot';
|
|
11
|
+
import { DaemonProviderStore } from '../../agent-network/src/daemon-provider-store';
|
|
12
|
+
const net = 'net_hub907', user = 'u_hub907', daemonId = 'n_hub907', alias = 'daemon907', token = 'tok_hub907';
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), 'hub907-')), key = 'TEST-ONLY-shared-provider-key';
|
|
14
|
+
function cleanup() {
|
|
15
|
+
for (const t of ['daemon_provider_writes', 'daemon_provider_applications', 'daemon_provider_logins']) db.run(`DELETE FROM ${t} WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)`, [net]);
|
|
16
|
+
for (const t of ['hub_provider_bindings', 'hub_provider_configs', 'network_secrets', 'daemon_provider_snapshots', 'nodes', 'api_tokens', 'network_members']) db.run(`DELETE FROM ${t} WHERE network_id=?1`, [net]);
|
|
17
|
+
db.run('DELETE FROM networks WHERE network_id=?1', [net]); db.run('DELETE FROM users WHERE user_id=?1', [user]);
|
|
18
|
+
}
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
cleanup(); rmSync(join(root, '.anet'), { recursive: true, force: true });
|
|
21
|
+
process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32);
|
|
22
|
+
db.run("INSERT INTO users(user_id,username,password_hash,role) VALUES(?1,?1,'x','user')", [user]);
|
|
23
|
+
db.run('INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,?1,?2)', [net, user]);
|
|
24
|
+
db.run("INSERT INTO network_members(user_id,network_id,role) VALUES(?1,?2,'owner')", [user, net]);
|
|
25
|
+
db.run('INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot) VALUES(?1,?2,?2,?3,?4)', [daemonId, alias, net, JSON.stringify({ role: 'host_supervisor' })]);
|
|
26
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES(?1,?2,?3,'network',?4,?5)", [token, user, net, `node:${alias}`, `hash_${token}`]);
|
|
27
|
+
const dir = join(root, '.anet/nodes', alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
28
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', node_id: daemonId, network_id: net }), { mode: 0o600 });
|
|
29
|
+
});
|
|
30
|
+
afterAll(() => { cleanup(); rmSync(root, { recursive: true, force: true }); });
|
|
31
|
+
function client(kind = 'human') {
|
|
32
|
+
const handlers: Record<string, any> = {}, server = new McpServer({ name: 'test907', version: '0' }) as any;
|
|
33
|
+
const tool = server.tool.bind(server), register = server.registerTool.bind(server);
|
|
34
|
+
server.tool = (name: string, desc: any, shape: any, handler: any) => { handlers[name] = handler; return tool(name, desc, shape, handler); };
|
|
35
|
+
server.registerTool = (name: string, config: any, handler: any) => { handlers[name] = handler; return register(name, config, handler); };
|
|
36
|
+
registerTools(server, undefined, kind === 'daemon' ? net : null, kind === 'anonymous' ? null : user, alias, kind === 'daemon', kind === 'daemon' ? token : null);
|
|
37
|
+
return async (name: string, args: any = {}) => JSON.parse((await handlers[name](args)).content[0].text);
|
|
38
|
+
}
|
|
39
|
+
const provider = () => ({ id: 'shared', label: 'Shared model', enabled: true, protocol: 'openai-responses', baseUrl: 'https://provider.example/v1', models: ['fixture-model'], key });
|
|
40
|
+
const save = (human: ReturnType<typeof client>, value = provider(), revision = 0) => human('daemon_provider_snapshot', { action: 'hub_put', id: value.id, network_id: net, revision, provider: value });
|
|
41
|
+
const bind = (human: ReturnType<typeof client>, target = daemonId, revision = 1) => human('daemon_provider_snapshot', { action: 'hub_bind', network_id: net, id: 'shared', revision, provider: { daemon_node_id: target, daemon_revision: 0, confirm: true } });
|
|
42
|
+
test('HTTP Hub and API endpoint support save, explicit bind and exact private-store readback', async () => {
|
|
43
|
+
expect(secureProviderTransport('http://hub907.example.test')).toBe(true);
|
|
44
|
+
expect(secureProviderTransport('ftp://hub907.example.test')).toBe(false);
|
|
45
|
+
expect(secureProviderTransport('http://user:password@hub907.example.test')).toBe(false);
|
|
46
|
+
const human = client(), daemon = client('daemon');
|
|
47
|
+
const config = { ...provider(), baseUrl: 'http://provider.example/v1' };
|
|
48
|
+
const saved = await save(human, config); expect(saved.ok).toBe(true);
|
|
49
|
+
expect(saved.provider.baseUrl).toBe(config.baseUrl);
|
|
50
|
+
const queued = await bind(human); expect(queued.ok).toBe(true);
|
|
51
|
+
await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId,
|
|
52
|
+
serverUrl: 'http://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
|
|
53
|
+
const receipt = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
|
|
54
|
+
expect(receipt.status).toBe('succeeded');
|
|
55
|
+
expect(receipt.snapshot.providers[0].runtimes[0].auth[0].baseUrl).toBe(config.baseUrl);
|
|
56
|
+
expect(JSON.stringify([saved, queued, receipt])).not.toContain(key);
|
|
57
|
+
const store = new DaemonProviderStore(join(root, '.anet/nodes', alias));
|
|
58
|
+
await store.withApiKey({ revision: 1, providerId: queued.provider_reference.daemon_provider_id, authId: 'hub_key', model: 'fixture-model' }, async actual => {
|
|
59
|
+
expect(actual.baseUrl).toBe(config.baseUrl); expect(actual.key).toBe(key);
|
|
60
|
+
}); // Exact synthetic key in local callback, no external model request.
|
|
61
|
+
for (const baseUrl of ['ftp://provider.example/v1', 'http://user:pass@provider.example/v1', 'http://provider.example/v1?key=secret']) {
|
|
62
|
+
expect(await save(human, { ...provider(), id: 'invalid', baseUrl })).toMatchObject({ ok: false, error: 'invalid_hub_provider' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
test('existing names-only vault key → Provider reference → actual daemon store, without re-entering key', async () => {
|
|
66
|
+
const human = client(), daemon = client('daemon');
|
|
67
|
+
expect(await human('upsert_network_secret', { network_id: net, key: 'SHARED_API_KEY', value: key })).toMatchObject({ ok: true });
|
|
68
|
+
const { key: ignored, ...config } = provider();
|
|
69
|
+
const saved = await save(human, { ...config, secretKeyRef: 'SHARED_API_KEY' } as any);
|
|
70
|
+
expect(saved.provider).toMatchObject({ secret_key_ref: 'SHARED_API_KEY', credential_present: true });
|
|
71
|
+
expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).toBe('SHARED_API_KEY');
|
|
72
|
+
const legacy = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net });
|
|
73
|
+
expect(legacy.providers[0].secret_key_ref).toBeUndefined(); // older strict client shape unchanged
|
|
74
|
+
const modern = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net, provider: { include_secret_refs: true } });
|
|
75
|
+
expect(modern.secret_reference_supported).toBe(true); expect(modern.providers[0].secret_key_ref).toBe('SHARED_API_KEY');
|
|
76
|
+
expect(JSON.stringify([saved, legacy, modern])).not.toContain(key);
|
|
77
|
+
const queued = await bind(human); expect(queued.ok).toBe(true);
|
|
78
|
+
await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId, serverUrl: 'https://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
|
|
79
|
+
const receipt = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
|
|
80
|
+
expect(receipt.status).toBe('succeeded'); expect(receipt.snapshot.providers[0].runtimes[0].auth[0].credential_present).toBe(true);
|
|
81
|
+
const store = new DaemonProviderStore(join(root, '.anet/nodes', alias));
|
|
82
|
+
await store.withApiKey({ revision: receipt.snapshot.revision, providerId: queued.provider_reference.daemon_provider_id, authId: 'hub_key', model: 'fixture-model' }, async profile => {
|
|
83
|
+
expect(profile.key).toBe(key); expect(profile.baseUrl).toBe(config.baseUrl);
|
|
84
|
+
}); // synthetic local callback only; no model/vendor request
|
|
85
|
+
expect(JSON.stringify(receipt)).not.toContain(key);
|
|
86
|
+
// Switching back to Provider-owned plaintext never changes the reusable named key.
|
|
87
|
+
await save(human, { ...provider(), key: 'TEST-ONLY-provider-specific' }, 1);
|
|
88
|
+
expect(vaultGet(net, 'SHARED_API_KEY')).toBe(key);
|
|
89
|
+
expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).not.toBe('SHARED_API_KEY');
|
|
90
|
+
});
|
|
91
|
+
test('missing or ambiguous references do not save; explicit ref replacement updates the stored reference', async () => {
|
|
92
|
+
const human = client(); const { key: ignored, ...config } = provider();
|
|
93
|
+
expect(await save(human, { ...config, secretKeyRef: 'MISSING_KEY' } as any)).toMatchObject({ ok: false, error: 'provider_secret_reference_missing' });
|
|
94
|
+
expect(await save(human, { ...provider(), secretKeyRef: 'SHARED_API_KEY' } as any)).toMatchObject({ ok: false, error: 'invalid_provider_secret_reference' });
|
|
95
|
+
await save(human);
|
|
96
|
+
await human('upsert_network_secret', { network_id: net, key: 'SECOND_API_KEY', value: 'TEST-ONLY-second-key' });
|
|
97
|
+
expect((await save(human, { ...config, secretKeyRef: 'SECOND_API_KEY' } as any, 1)).provider.secret_key_ref).toBe('SECOND_API_KEY');
|
|
98
|
+
expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).toBe('SECOND_API_KEY');
|
|
99
|
+
});
|
|
100
|
+
test('actual Hub tool atomically saves encrypted Provider; metadata read and conflict never echo key', async () => {
|
|
101
|
+
const human = client(); const result = await save(human); expect(result.ok).toBe(true);
|
|
102
|
+
expect(result.provider).toMatchObject({ id: 'shared', revision: 1, source: 'hub', credential_present: true, compatible_runtimes: ['codex-tui'] });
|
|
103
|
+
const read = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net }); expect(read.providers).toHaveLength(1);
|
|
104
|
+
expect(JSON.stringify([result, read])).not.toContain(key);
|
|
105
|
+
expect(await save(human)).toMatchObject({ ok: false, error: 'hub_provider_revision_conflict' });
|
|
106
|
+
const secret = db.get<any>('SELECT ciphertext FROM network_secrets WHERE network_id=?1', net);
|
|
107
|
+
expect(Buffer.from(secret.ciphertext).toString()).not.toContain(key);
|
|
108
|
+
});
|
|
109
|
+
test('Hub → actual daemon queue → pinned CLI store → redacted receipt, not runtime application', async () => {
|
|
110
|
+
const human = client(), daemon = client('daemon'); await save(human);
|
|
111
|
+
const queued = await bind(human); expect(queued.ok).toBe(true); expect(queued.status).toBe('pending');
|
|
112
|
+
expect(JSON.stringify(queued)).not.toContain(key);
|
|
113
|
+
await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId, serverUrl: 'https://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
|
|
114
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
|
|
115
|
+
expect(read.status).toBe('succeeded'); expect(read.snapshot.revision).toBe(1);
|
|
116
|
+
expect(read.snapshot.providers[0]).toMatchObject({ id: queued.provider_reference.daemon_provider_id, label: 'Hub · Shared model' });
|
|
117
|
+
expect(read.snapshot.providers[0].runtimes[0].auth[0]).toMatchObject({ id: 'hub_key', models: ['fixture-model'], application: 'not_applied', verification: 'not_checked' });
|
|
118
|
+
expect(JSON.stringify(read)).not.toContain(key);
|
|
119
|
+
});
|
|
120
|
+
test('scope/role boundaries: no node or anonymous writes, no viewer distribution or foreign daemon', async () => {
|
|
121
|
+
expect(await save(client('daemon'))).toMatchObject({ ok: false, error: 'user_token_required' });
|
|
122
|
+
expect(await save(client('anonymous'))).toMatchObject({ ok: false, error: 'user_token_required' });
|
|
123
|
+
const human = client(); await save(human);
|
|
124
|
+
expect(await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: 'foreign' })).toMatchObject({ ok: false, error: 'provider_access_denied' });
|
|
125
|
+
expect(await bind(human, 'foreign')).toMatchObject({ ok: false, error: 'daemon_not_found' });
|
|
126
|
+
db.run("UPDATE network_members SET role='viewer' WHERE network_id=?1 AND user_id=?2", [net, user]);
|
|
127
|
+
expect(await bind(human)).toMatchObject({ ok: false, error: 'provider_access_denied' });
|
|
128
|
+
expect(await save(human)).toMatchObject({ ok: false, error: 'provider_owner_required' });
|
|
129
|
+
});
|
|
130
|
+
test('protocol mismatch never queues Codex; editing source cancels pending distribution, not installed copies', async () => {
|
|
131
|
+
const human = client(); await save(human, { ...provider(), protocol: 'openai-chat-completions' });
|
|
132
|
+
expect(await bind(human)).toMatchObject({ ok: false, error: 'runtime_protocol_not_supported' });
|
|
133
|
+
await save(human, provider(), 1);
|
|
134
|
+
const queued = await bind(human, daemonId, 2);
|
|
135
|
+
expect(queued.ok).toBe(true); await save(human, { ...provider(), enabled: false }, 2);
|
|
136
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
|
|
137
|
+
expect(read).toMatchObject({ status: 'failed', error: 'hub_provider_source_changed' });
|
|
138
|
+
expect(db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', queued.request_id)).toBeNull();
|
|
139
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// #907: real network-scoped catalog + explicit dispatch; not a Provider proxy.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { db } from './db';
|
|
4
|
+
import { getUserNetworkRole } from './auth';
|
|
5
|
+
import { vaultUpsert, vaultGet, vaultListKeys, VaultError } from './vault';
|
|
6
|
+
import { requestProviderWrite } from './daemon-provider-snapshots';
|
|
7
|
+
type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
|
|
8
|
+
type Config = { id: string; label: string; enabled: boolean; protocol: 'openai-responses' | 'openai-chat-completions' | 'anthropic-messages'; baseUrl: string; models: string[] };
|
|
9
|
+
const denied = (error: string) => ({ ok: false as const, error });
|
|
10
|
+
const idValid = (id: unknown) => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id);
|
|
11
|
+
function auth(h: Human, write: boolean, owner = false) {
|
|
12
|
+
if (h.isNode || !h.userId || !h.networkId) return denied('user_token_required');
|
|
13
|
+
const role = getUserNetworkRole(h.userId, h.networkId);
|
|
14
|
+
if (!role || (owner && role !== 'owner') || (write && !['owner', 'admin'].includes(role))) return denied(owner ? 'provider_owner_required' : 'provider_access_denied');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function validConfig(value: any): value is Config {
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
19
|
+
|| Object.keys(value).some(k => !['id', 'label', 'enabled', 'protocol', 'baseUrl', 'models', 'key', 'secretKeyRef'].includes(k))
|
|
20
|
+
|| !idValid(value.id) || typeof value.label !== 'string' || !value.label.trim() || value.label.length > 100
|
|
21
|
+
|| /[\x00-\x1f\x7f]/.test(value.label) || typeof value.enabled !== 'boolean'
|
|
22
|
+
|| !['openai-responses', 'openai-chat-completions', 'anthropic-messages'].includes(value.protocol)
|
|
23
|
+
|| typeof value.baseUrl !== 'string' || value.baseUrl.length > 2048
|
|
24
|
+
|| !Array.isArray(value.models) || !value.models.length || value.models.length > 200
|
|
25
|
+
|| new Set(value.models).size !== value.models.length
|
|
26
|
+
|| value.models.some((m: unknown) => typeof m !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(m))) return false;
|
|
27
|
+
try { const u = new URL(value.baseUrl); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password && !u.search && !u.hash; }
|
|
28
|
+
catch { return false; }
|
|
29
|
+
}
|
|
30
|
+
type Row = { revision: number; config_json: string; secret_key_ref: string; updated_at: number };
|
|
31
|
+
function row(networkId: string, id: string) {
|
|
32
|
+
return db.get<Row>('SELECT revision,config_json,secret_key_ref,updated_at FROM hub_provider_configs WHERE network_id=?1 AND provider_id=?2', networkId, id);
|
|
33
|
+
}
|
|
34
|
+
function publicRow(networkId: string, stored: Row, exposeRef = false) {
|
|
35
|
+
const config = JSON.parse(stored.config_json) as Config;
|
|
36
|
+
return { ...config, ...(exposeRef ? { secret_key_ref: stored.secret_key_ref } : {}), revision: stored.revision, updated_at: stored.updated_at, source: 'hub',
|
|
37
|
+
credential_present: vaultListKeys(networkId).includes(stored.secret_key_ref), verification: 'not_checked',
|
|
38
|
+
compatible_runtimes: config.protocol === 'openai-responses' ? ['codex-tui'] : [] };
|
|
39
|
+
}
|
|
40
|
+
export function readHubProviders(h: Human, exposeRefs = false) {
|
|
41
|
+
const refusal = auth(h, false); if (refusal) return refusal;
|
|
42
|
+
const rows = db.all<Row>('SELECT revision,config_json,secret_key_ref,updated_at FROM hub_provider_configs WHERE network_id=?1 ORDER BY provider_id', h.networkId!);
|
|
43
|
+
return { ok: true as const, network_id: h.networkId!, source: 'hub' as const,
|
|
44
|
+
...(exposeRefs ? { secret_reference_supported: true } : {}), providers: rows.map(r => publicRow(h.networkId!, r, exposeRefs)) };
|
|
45
|
+
}
|
|
46
|
+
export function putHubProvider(h: Human, revision: number, input: unknown) {
|
|
47
|
+
// This operation stores/rotates a shared secret: same OWNER boundary as vault writes.
|
|
48
|
+
const refusal = auth(h, true, true); if (refusal) return refusal;
|
|
49
|
+
if (!Number.isSafeInteger(revision) || revision < 0 || revision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
|
|
50
|
+
if (!validConfig(input)) return denied('invalid_hub_provider');
|
|
51
|
+
const value = input as Config & { key?: string; secretKeyRef?: string };
|
|
52
|
+
if (value.key !== undefined && (typeof value.key !== 'string' || !value.key || value.key.length > 8192 || /[\s\x00-\x1f\x7f]/.test(value.key))) return denied('invalid_provider_key');
|
|
53
|
+
if (value.secretKeyRef !== undefined && (typeof value.secretKeyRef !== 'string' || !/^[A-Z][A-Z0-9_]{0,63}$/.test(value.secretKeyRef)
|
|
54
|
+
|| value.key !== undefined)) return denied('invalid_provider_secret_reference');
|
|
55
|
+
if (value.secretKeyRef && !vaultListKeys(h.networkId!).includes(value.secretKeyRef)) return denied('provider_secret_reference_missing');
|
|
56
|
+
const old = row(h.networkId!, value.id);
|
|
57
|
+
if ((old?.revision ?? 0) !== revision) return denied('hub_provider_revision_conflict');
|
|
58
|
+
const previous = old ? JSON.parse(old.config_json) as Config : null;
|
|
59
|
+
if (!value.key && !value.secretKeyRef && (!old || previous?.baseUrl !== value.baseUrl || previous?.protocol !== value.protocol)) return denied('provider_key_required');
|
|
60
|
+
// A new plaintext key belongs to this Provider. Never overwrite a separately
|
|
61
|
+
// named shared key merely because the previous config referenced it.
|
|
62
|
+
const privateRef = `HUB_PROVIDER_${createHash('sha256').update(JSON.stringify([h.networkId, value.id])).digest('hex').slice(0, 32).toUpperCase()}`;
|
|
63
|
+
const ref = value.secretKeyRef ?? (value.key ? privateRef : old!.secret_key_ref);
|
|
64
|
+
const { key, secretKeyRef, ...config } = value;
|
|
65
|
+
try {
|
|
66
|
+
db.transaction(() => {
|
|
67
|
+
if ((row(h.networkId!, value.id)?.revision ?? 0) !== revision) throw new Error('hub_provider_revision_conflict');
|
|
68
|
+
if (key) vaultUpsert(h.networkId!, ref, key);
|
|
69
|
+
db.run(`INSERT INTO hub_provider_configs(network_id,provider_id,revision,config_json,secret_key_ref,updated_at) VALUES(?1,?2,?3,?4,?5,?6)
|
|
70
|
+
ON CONFLICT(network_id,provider_id) DO UPDATE SET revision=excluded.revision,config_json=excluded.config_json,secret_key_ref=excluded.secret_key_ref,updated_at=excluded.updated_at`,
|
|
71
|
+
[h.networkId!, value.id, revision + 1, JSON.stringify(config), ref, Date.now()]);
|
|
72
|
+
// Cancel still-pending dispatches when the source is edited/rotated/disabled.
|
|
73
|
+
// Already installed copies are NOT remotely revoked by this change.
|
|
74
|
+
db.run(`UPDATE daemon_provider_snapshots SET status='failed',error='hub_provider_source_changed'
|
|
75
|
+
WHERE status='pending' AND request_id IN (SELECT request_id FROM hub_provider_bindings WHERE network_id=?1 AND provider_id=?2)`, [h.networkId!, value.id]);
|
|
76
|
+
db.run(`DELETE FROM daemon_provider_writes WHERE request_id IN (SELECT b.request_id FROM hub_provider_bindings b
|
|
77
|
+
JOIN daemon_provider_snapshots s ON s.request_id=b.request_id WHERE b.network_id=?1 AND b.provider_id=?2 AND s.status!='pending')`, [h.networkId!, value.id]);
|
|
78
|
+
});
|
|
79
|
+
return { ok: true as const, network_id: h.networkId!, provider: publicRow(h.networkId!, row(h.networkId!, value.id)!, secretKeyRef !== undefined) };
|
|
80
|
+
} catch (error) { return denied(error instanceof VaultError ? error.code : 'hub_provider_save_failed'); }
|
|
81
|
+
}
|
|
82
|
+
export function bindHubProvider(h: Human, input: { id: string; revision: number; daemon_node_id: string; daemon_revision: number; confirm: boolean }) {
|
|
83
|
+
const refusal = auth(h, true); if (refusal) return refusal;
|
|
84
|
+
if (!input || !idValid(input.id) || input.confirm !== true || !Number.isSafeInteger(input.revision)
|
|
85
|
+
|| !Number.isSafeInteger(input.daemon_revision) || input.daemon_revision < 0) return denied('invalid_hub_provider_binding');
|
|
86
|
+
const stored = row(h.networkId!, input.id);
|
|
87
|
+
if (!stored || stored.revision !== input.revision) return denied('hub_provider_revision_conflict');
|
|
88
|
+
const config = JSON.parse(stored.config_json) as Config;
|
|
89
|
+
if (!config.enabled) return denied('hub_provider_disabled');
|
|
90
|
+
// Never silently treat Chat Completions/Anthropic as Codex Responses.
|
|
91
|
+
if (config.protocol !== 'openai-responses') return denied('runtime_protocol_not_supported');
|
|
92
|
+
const targetId = `hub_${createHash('sha256').update(JSON.stringify([h.networkId, input.id])).digest('hex').slice(0, 24)}`;
|
|
93
|
+
try {
|
|
94
|
+
const key = vaultGet(h.networkId!, stored.secret_key_ref); if (!key) return denied('provider_key_missing');
|
|
95
|
+
const queued = requestProviderWrite(h, input.daemon_node_id, input.daemon_revision, { id: targetId,
|
|
96
|
+
label: `Hub · ${config.label}`.slice(0, 100), enabled: true, runtimes: [{ runtime: 'codex-tui', auth: [
|
|
97
|
+
{ id: 'hub_key', kind: 'api_key', models: config.models, baseUrl: config.baseUrl, key },
|
|
98
|
+
] }] });
|
|
99
|
+
if (!queued.ok) return queued;
|
|
100
|
+
db.run(`INSERT INTO hub_provider_bindings(request_id,network_id,provider_id,provider_revision,daemon_node_id,daemon_provider_id,created_at)
|
|
101
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7)`, [queued.request_id, h.networkId!, input.id, input.revision, input.daemon_node_id, targetId, Date.now()]);
|
|
102
|
+
return { ...queued, daemon_node_id: input.daemon_node_id,
|
|
103
|
+
provider_reference: { network_id: h.networkId!, id: input.id, revision: input.revision, daemon_provider_id: targetId } };
|
|
104
|
+
} catch (error) { return denied(error instanceof VaultError ? error.code : 'hub_provider_binding_failed'); }
|
|
105
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
3
|
+
|
|
4
|
+
const RAW = "2026-08-30 21:11:24"; // sessions.last_seen_at 的真实取值
|
|
5
|
+
const TRUE_UTC = Date.UTC(2026, 7, 30, 21, 11, 24);
|
|
6
|
+
|
|
7
|
+
describe("parseHubTimestamp (#1650)", () => {
|
|
8
|
+
test("🔴 非 UTC 时区下也必须解出同一个 UTC 时刻", () => {
|
|
9
|
+
// `bun test` 把测试进程时区固定成 UTC(getTimezoneOffset() === 0)。
|
|
10
|
+
// 在 UTC 下,「补 Z」和「不补 Z」解析出**同一个数** —— 这个函数存在的那个
|
|
11
|
+
// 缺陷在测试进程里**结构上不可见**。所以这一条必须开 TZ≠UTC 的子进程。
|
|
12
|
+
// 实测:去掉补 Z,本文件其余各条**全绿**。
|
|
13
|
+
const child = `import { parseHubTimestamp } from ${JSON.stringify(import.meta.dir + "/hub-timestamp.ts")};
|
|
14
|
+
process.stdout.write(new Date().getTimezoneOffset() + "," + parseHubTimestamp(${JSON.stringify(RAW)}));`;
|
|
15
|
+
const r = Bun.spawnSync(["bun", "-e", child], { env: { ...process.env, TZ: "Asia/Shanghai" } });
|
|
16
|
+
const [offset, parsed] = r.stdout.toString().trim().split(",");
|
|
17
|
+
// 🔴 先断言控制生效:子进程真的不在 UTC。否则 TZ 被忽略时这条又变成空测试。
|
|
18
|
+
expect(Number(offset)).not.toBe(0);
|
|
19
|
+
expect(parsed).toBe(String(TRUE_UTC));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("已带 Z / ±HH:MM 的串不再追加时区", () => {
|
|
23
|
+
expect(parseHubTimestamp("2026-08-30T21:11:24Z")).toBe(TRUE_UTC);
|
|
24
|
+
expect(parseHubTimestamp("2026-08-30T22:11:24+01:00")).toBe(TRUE_UTC);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("拿不到就返回 null,不返回一个看起来合理的数", () => {
|
|
28
|
+
for (const bad of [undefined, null, "", " ", 0, 1788124284000, {}, [], "2026-08-30", "垃圾"]) {
|
|
29
|
+
expect(parseHubTimestamp(bad as unknown)).toBeNull();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("🔴 INTEGER 列(epoch 毫秒)不归它管 —— 传数字必须返回 null,而不是悄悄接受", () => {
|
|
34
|
+
// external_schedule_edits.* 是 INTEGER。如果有人把本函数用在那儿,
|
|
35
|
+
// 应当立刻拿到 null 而不是一个错的时刻。
|
|
36
|
+
expect(parseHubTimestamp(TRUE_UTC)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1650 — hub 的 TEXT 时间戳列是 SQLite `datetime('now')` 写的,
|
|
3
|
+
* 形如 `2026-08-30 21:11:24`:**UTC,但不带任何时区标记**。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 `new Date(raw)` / `Date.parse(raw)` 对这种串按**本地时间**解析(ES 规范:
|
|
6
|
+
* 带时间的形式无偏移时视为本地时间),误差恰好等于主机时区偏移,**而且不报错**:
|
|
7
|
+
*
|
|
8
|
+
* TZ=UTC 误差 0h
|
|
9
|
+
* TZ=Asia/Shanghai 误差 -8h (看起来更陈旧)
|
|
10
|
+
* TZ=America/Los_Angeles 误差 +7h (看起来更新鲜 ← 朝好的一侧错)
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **只对 TEXT 列用它。** 同一个字段名在不同表里可能是不同类型:
|
|
13
|
+
* sessions.last_seen_at / licenses.expires_at → TEXT(要用本函数)
|
|
14
|
+
* external_schedule_edits.expires_at 等 4 列 → INTEGER(epoch 毫秒,直接 new Date 就对)
|
|
15
|
+
* 按字段名判会判错 —— 见 #1650 里的更正。
|
|
16
|
+
*
|
|
17
|
+
* 与 agent-network 侧的同名函数是**有意的重复**:server 包不依赖 agent-network,
|
|
18
|
+
* 仓里也没有共享 lib。两边各自带测试。
|
|
19
|
+
*/
|
|
20
|
+
export function parseHubTimestamp(raw: unknown): number | null {
|
|
21
|
+
if (typeof raw !== "string") return null;
|
|
22
|
+
const t = raw.trim();
|
|
23
|
+
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(t)) return null;
|
|
24
|
+
const hasZone = /([Zz]|[+-]\d{2}:?\d{2})$/.test(t);
|
|
25
|
+
const ms = Date.parse(t.replace(" ", "T") + (hasZone ? "" : "Z"));
|
|
26
|
+
return Number.isFinite(ms) ? ms : null;
|
|
27
|
+
}
|
package/src/human-dm.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// 多用户账号:人与人私信(同一网络里的两个用户之间)。
|
|
2
|
+
//
|
|
3
|
+
// 投递复用 user_inbox(与 send_desktop_message 同一张表、同一条 /events/users/me 推送、同一套
|
|
4
|
+
// /api/messages?scope=user 未读与 ack),另外记下 sender_user_id —— 由 Hub 按发信人的用户令牌写入,
|
|
5
|
+
// 不是客户端声称的 from_session。会话记录按 (收件人, sender_user_id) 双向取,所以发信人看得到自己发的。
|
|
6
|
+
//
|
|
7
|
+
// 与 Agent 权限无关:受限成员(看不到任何 Agent 的成员)照样能和网络里的其他人私信。
|
|
8
|
+
|
|
9
|
+
import { db, uuidv4 } from "./db.js";
|
|
10
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
11
|
+
import { isAgentRestricted } from "./agent-access.js";
|
|
12
|
+
import { pushUserEvent, hasUserSubscribers } from "./push.js";
|
|
13
|
+
import { existsSync, readFileSync } from "fs";
|
|
14
|
+
import { DM_FILE_SCOPE, FILE_ID_REGEX, indexEntryPath, validateAttachments, validateIndexEntry, type UploadIndexEntry } from "./uploads.js";
|
|
15
|
+
import { restrictedMemberCanUseFile } from "./restricted-files.js";
|
|
16
|
+
import { redactMessageRow } from "./redact-tokens.js";
|
|
17
|
+
|
|
18
|
+
export const HUMAN_DM_KIND = "human_dm";
|
|
19
|
+
export const MAX_DM_CHARS = 10_000;
|
|
20
|
+
|
|
21
|
+
export type HumanDmSendInput = {
|
|
22
|
+
networkId: string;
|
|
23
|
+
sender: { userId: string; username: string };
|
|
24
|
+
toUserId?: unknown;
|
|
25
|
+
toUsername?: unknown;
|
|
26
|
+
message?: unknown;
|
|
27
|
+
attachments?: unknown;
|
|
28
|
+
clientRequestId?: unknown;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type HumanDmSendResult =
|
|
32
|
+
| { ok: true; message: Record<string, unknown>; delivered: boolean }
|
|
33
|
+
| { ok: false; error: string; status: number };
|
|
34
|
+
|
|
35
|
+
export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
|
|
36
|
+
const { networkId, sender } = input;
|
|
37
|
+
if (!getUserNetworkRole(sender.userId, networkId)) return { ok: false, error: "not a member of this network", status: 403 };
|
|
38
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
39
|
+
if (text.length > MAX_DM_CHARS) return { ok: false, error: "message_too_long", status: 400 };
|
|
40
|
+
|
|
41
|
+
const byId = typeof input.toUserId === "string" && input.toUserId
|
|
42
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", input.toUserId)
|
|
43
|
+
: null;
|
|
44
|
+
const byName = typeof input.toUsername === "string" && input.toUsername
|
|
45
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", input.toUsername)
|
|
46
|
+
: null;
|
|
47
|
+
if (byId && byName && byId.user_id !== byName.user_id) return { ok: false, error: "dm_target_mismatch", status: 400 };
|
|
48
|
+
const target = byId ?? byName;
|
|
49
|
+
// 不存在与不在本网络同一个错误,不给用户名探测留差异。
|
|
50
|
+
if (!target || !getUserNetworkRole(target.user_id, networkId)) return { ok: false, error: "dm_target_not_in_network", status: 404 };
|
|
51
|
+
if (target.user_id === sender.userId) return { ok: false, error: "dm_to_self", status: 400 };
|
|
52
|
+
|
|
53
|
+
const attachments = validateAttachments(input.attachments);
|
|
54
|
+
if (!attachments.ok) return { ok: false, error: "bad_attachments", status: 400 };
|
|
55
|
+
if (!text.trim() && attachments.attachments.length === 0) return { ok: false, error: "message_required", status: 400 };
|
|
56
|
+
const restricted = isAgentRestricted(sender.userId, networkId);
|
|
57
|
+
for (const a of attachments.attachments) {
|
|
58
|
+
const fileId = (a as { file_id?: string }).file_id;
|
|
59
|
+
if (!fileId) continue;
|
|
60
|
+
const entry = networkFileEntry(networkId, fileId);
|
|
61
|
+
const usable = restricted
|
|
62
|
+
? restrictedMemberCanUseFile(sender.userId, sender.username, networkId, fileId)
|
|
63
|
+
: !!entry;
|
|
64
|
+
if (!usable) return { ok: false, error: "attachment_not_accessible", status: 403 };
|
|
65
|
+
// 私信文件只能由看得见它的人转发:否则任何成员把别人私信里的 file_id 塞进一条自己发出的私信,
|
|
66
|
+
// 就成了「私信参与者」而解锁它。上传者本人、以及已经在某段私信里收到/发出过它的人可以转。
|
|
67
|
+
if (entry?.scope === DM_FILE_SCOPE && entry.owner_id !== sender.userId && !dmParticipantSeesFile(sender.userId, networkId, fileId)) {
|
|
68
|
+
return { ok: false, error: "attachment_not_accessible", status: 403 };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const clientRequestId = typeof input.clientRequestId === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(input.clientRequestId)
|
|
73
|
+
? input.clientRequestId : null;
|
|
74
|
+
// 同一个气泡重试:按 (发信人, client_request_id) 定出同一个 message_id,重投不产生第二条。
|
|
75
|
+
const messageId = clientRequestId
|
|
76
|
+
? `dm_${sender.userId}_${clientRequestId}`.slice(0, 120)
|
|
77
|
+
: `dm_${uuidv4()}`;
|
|
78
|
+
const metaJson = attachments.attachments.length ? JSON.stringify({ attachments: attachments.attachments }) : null;
|
|
79
|
+
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
insertDmRow({ messageId, networkId, targetUserId: target.user_id, sender, title: null, text, metaJson });
|
|
82
|
+
db.run(
|
|
83
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
84
|
+
VALUES (?1, ?2, 'human_dm_sent', 'user', ?3, ?4, ?5)`,
|
|
85
|
+
[sender.userId, sender.username, target.user_id, JSON.stringify({ message_id: messageId, attachments: attachments.attachments.length }), networkId],
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
const row = db.get<Record<string, unknown>>(
|
|
89
|
+
`SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
|
|
90
|
+
FROM user_inbox WHERE message_id = ?1`,
|
|
91
|
+
messageId,
|
|
92
|
+
)!;
|
|
93
|
+
// 推送在提交之后(与 send_desktop_message 同一条规矩:回滚的消息不能先被看见)。
|
|
94
|
+
const delivered = pushDmEvent({ messageId, networkId, targetUserId: target.user_id, sender, title: null, text, metaJson });
|
|
95
|
+
return { ok: true, message: redactMessageRow(row), delivered };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type DmRow = { messageId: string; networkId: string; targetUserId: string; sender: { userId: string; username: string }; title: string | null; text: string; metaJson: string | null };
|
|
99
|
+
|
|
100
|
+
function insertDmRow(m: DmRow): void {
|
|
101
|
+
db.run(
|
|
102
|
+
// created_at 带毫秒:同一秒里一来一回的两条私信要能排出先后(秒级的 datetime('now') 排不出)。
|
|
103
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id, created_at)
|
|
104
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'info', ?8, ?9, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
105
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
106
|
+
[m.messageId, m.networkId, m.targetUserId, m.sender.username, HUMAN_DM_KIND, m.title, m.text, m.metaJson, m.sender.userId],
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 推 desktop_message 到收件人的 /events/users/me。返回推送时他有没有在线的订阅。 */
|
|
111
|
+
function pushDmEvent(m: DmRow): boolean {
|
|
112
|
+
const delivered = hasUserSubscribers(m.networkId, m.targetUserId);
|
|
113
|
+
pushUserEvent(m.networkId, m.targetUserId, {
|
|
114
|
+
type: "desktop_message",
|
|
115
|
+
message_id: m.messageId,
|
|
116
|
+
kind: HUMAN_DM_KIND,
|
|
117
|
+
from: m.sender.username,
|
|
118
|
+
from_user_id: m.sender.userId,
|
|
119
|
+
title: m.title,
|
|
120
|
+
message: m.text,
|
|
121
|
+
severity: "info",
|
|
122
|
+
created_at: new Date().toISOString(),
|
|
123
|
+
...(m.metaJson ? { meta: JSON.parse(m.metaJson) } : {}),
|
|
124
|
+
});
|
|
125
|
+
return delivered;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Hub 代某人发的一条私信(参与人改了任务的通知,见 requirement-notify.ts):与人发的私信同一张表、同一条推送,
|
|
130
|
+
* 所以 App 按私信展示(顶部提示 + 人员列表未读 + 与他的会话里能看到),不用新渠道。
|
|
131
|
+
* 不校验正文 / 附件(调用方生成),也不记 human_dm_sent 审计(这不是他亲手发的)。
|
|
132
|
+
* 收件人或发信人已不在网络里 → 不发。返回 false = 没发。
|
|
133
|
+
*/
|
|
134
|
+
export function sendNoticeDm(m: DmRow): boolean {
|
|
135
|
+
if (m.targetUserId === m.sender.userId) return false;
|
|
136
|
+
if (!getUserNetworkRole(m.sender.userId, m.networkId) || !getUserNetworkRole(m.targetUserId, m.networkId)) return false;
|
|
137
|
+
insertDmRow(m);
|
|
138
|
+
pushDmEvent(m);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 把一条还没读的通知私信改写成合并后的正文(同一段时间里的连续改动并成一条)。
|
|
144
|
+
* 收件人已经读过(acked)或那行没了 → false,调用方另发一条新的。
|
|
145
|
+
* 只改库、再推一次同一个 message_id:App 按 message_id 去重,不会第二次弹;会话列表下次拉到的是新正文。
|
|
146
|
+
*/
|
|
147
|
+
export function rewriteUnreadNoticeDm(m: DmRow): boolean {
|
|
148
|
+
const row = db.get<{ acked: number | boolean | null }>("SELECT acked FROM user_inbox WHERE message_id = ?1 AND user_id = ?2", m.messageId, m.targetUserId);
|
|
149
|
+
if (!row || row.acked) return false;
|
|
150
|
+
db.run("UPDATE user_inbox SET content = ?1, meta_json = ?2 WHERE message_id = ?3 AND user_id = ?4", [m.text, m.metaJson, m.messageId, m.targetUserId]);
|
|
151
|
+
pushDmEvent(m);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。返回它的索引条目,不属于 → null。 */
|
|
156
|
+
export function networkFileEntry(networkId: string, fileId: string): UploadIndexEntry | null {
|
|
157
|
+
if (!FILE_ID_REGEX.test(fileId)) return null;
|
|
158
|
+
const path = indexEntryPath(fileId);
|
|
159
|
+
if (!path || !existsSync(path)) return null;
|
|
160
|
+
try {
|
|
161
|
+
const entry = JSON.parse(readFileSync(path, "utf8"));
|
|
162
|
+
return validateIndexEntry(entry) && entry.file_id === fileId && entry.network_id === networkId ? entry : null;
|
|
163
|
+
} catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 这个用户是不是某段带着这个文件的私信里的一方(收到的或发出的)。私信文件(scope=dm)的下载只认它
|
|
168
|
+
* 和上传者本人(server.ts authorizeFileDownload)。LIKE 只做预筛,真正的判定按 attachments 键解析。
|
|
169
|
+
*/
|
|
170
|
+
export function dmParticipantSeesFile(userId: string, networkId: string, fileId: string): boolean {
|
|
171
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
172
|
+
const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
|
|
173
|
+
const rows = db.all<{ meta_json: string | null }>(
|
|
174
|
+
`SELECT meta_json FROM user_inbox
|
|
175
|
+
WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}' AND (user_id = ?2 OR sender_user_id = ?2)
|
|
176
|
+
AND meta_json LIKE ?3 ESCAPE '\\' LIMIT 50`,
|
|
177
|
+
networkId, userId, needle,
|
|
178
|
+
);
|
|
179
|
+
return rows.some((row) => {
|
|
180
|
+
try {
|
|
181
|
+
const list = JSON.parse(row.meta_json ?? "null")?.attachments;
|
|
182
|
+
return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
|
|
183
|
+
} catch { return false; }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 我和某人的私信记录(双向),新的在前。before = 上一页最后一条的 created_at。 */
|
|
188
|
+
export function listDmThread(networkId: string, meUserId: string, otherUserId: string, limit = 50, before?: string | null) {
|
|
189
|
+
const params: unknown[] = [networkId, meUserId, otherUserId];
|
|
190
|
+
let sql = `SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
|
|
191
|
+
FROM user_inbox
|
|
192
|
+
WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}'
|
|
193
|
+
AND ((user_id = ?2 AND sender_user_id = ?3) OR (user_id = ?3 AND sender_user_id = ?2))`;
|
|
194
|
+
if (before) { params.push(before); sql += ` AND created_at < ?${params.length}`; }
|
|
195
|
+
params.push(Math.max(1, Math.min(limit, 200)));
|
|
196
|
+
sql += ` ORDER BY created_at DESC, rowid DESC LIMIT ?${params.length}`;
|
|
197
|
+
return db.all<Record<string, unknown>>(sql, ...params).map((row) => ({
|
|
198
|
+
...redactMessageRow(row),
|
|
199
|
+
direction: row.sender_user_id === meUserId ? "out" : "in",
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 我在这个网络里的私信会话:每个对方一行,最后一条时间 + 我这边的未读数。 */
|
|
204
|
+
export function listDmThreads(networkId: string, meUserId: string) {
|
|
205
|
+
return db.all<{ other_user_id: string; last_at: string; unread: number }>(
|
|
206
|
+
`SELECT other_user_id, MAX(created_at) AS last_at, SUM(unread) AS unread FROM (
|
|
207
|
+
SELECT sender_user_id AS other_user_id, created_at, CASE WHEN acked = 0 THEN 1 ELSE 0 END AS unread
|
|
208
|
+
FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = '${HUMAN_DM_KIND}' AND sender_user_id IS NOT NULL
|
|
209
|
+
UNION ALL
|
|
210
|
+
SELECT user_id AS other_user_id, created_at, 0 AS unread
|
|
211
|
+
FROM user_inbox WHERE network_id = ?1 AND sender_user_id = ?2 AND kind = '${HUMAN_DM_KIND}'
|
|
212
|
+
) GROUP BY other_user_id ORDER BY last_at DESC`,
|
|
213
|
+
networkId, meUserId,
|
|
214
|
+
).map((r) => ({ other_user_id: r.other_user_id, last_at: r.last_at, unread: Number(r.unread ?? 0) }));
|
|
215
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
/** Metadata only: the body of an ordinary blocked task is never fetched by this scan. */
|
|
4
|
+
export function isAuthenticatedHumanInboxRow(row: { type: string; meta_json: unknown }): boolean {
|
|
5
|
+
if (row.type !== "task" && row.type !== "broadcast") return false;
|
|
6
|
+
let parsed: unknown;
|
|
7
|
+
try { parsed = typeof row.meta_json === "string" ? JSON.parse(row.meta_json) : null; }
|
|
8
|
+
catch { return false; }
|
|
9
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
10
|
+
const meta = parsed as Record<string, unknown>;
|
|
11
|
+
return meta.auth_origin === "user" && meta.source === "dashboard-chat"
|
|
12
|
+
&& typeof meta.client_request_id === "string" && /^dreq_[a-f0-9]{32}$/.test(meta.client_request_id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Caller supplies the already-authorized recipient/network WHERE clause. Both DB dialects
|
|
16
|
+
* support this scalar keyset; no JSON SQL extension, OFFSET, or arbitrary depth cutoff. */
|
|
17
|
+
export function selectHumanInboxIds(whereSql: string, params: unknown[], limit: number) {
|
|
18
|
+
const ids: string[] = [];
|
|
19
|
+
let cursor: { priority_rank: number; created_at: string; fifo_id: number } | undefined;
|
|
20
|
+
let examined = 0;
|
|
21
|
+
const rank = "CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END";
|
|
22
|
+
while (ids.length < limit) {
|
|
23
|
+
const pageParams = [...params];
|
|
24
|
+
let sql = `SELECT id, type, meta_json, created_at, rowid AS fifo_id, ${rank} AS priority_rank ${whereSql}`;
|
|
25
|
+
if (cursor) {
|
|
26
|
+
const n = pageParams.length + 1;
|
|
27
|
+
sql += ` AND (${rank}, created_at, rowid) > (?${n}, ?${n + 1}, ?${n + 2})`;
|
|
28
|
+
pageParams.push(cursor.priority_rank, cursor.created_at, cursor.fifo_id);
|
|
29
|
+
}
|
|
30
|
+
sql += ` ORDER BY ${rank}, created_at, rowid LIMIT 256`;
|
|
31
|
+
const page = db.all<{ id: string; type: string; meta_json: unknown; priority_rank: number; created_at: string; fifo_id: number }>(sql, ...pageParams);
|
|
32
|
+
if (!page.length) break;
|
|
33
|
+
for (const row of page) {
|
|
34
|
+
examined++;
|
|
35
|
+
if (isAuthenticatedHumanInboxRow(row)) ids.push(row.id);
|
|
36
|
+
if (ids.length === limit) break;
|
|
37
|
+
}
|
|
38
|
+
cursor = page[page.length - 1];
|
|
39
|
+
if (page.length < 256) break;
|
|
40
|
+
}
|
|
41
|
+
return { ids, examined };
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
3
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
4
|
+
import { resolveCanonicalAlias } from "./rename.js";
|
|
5
|
+
|
|
6
|
+
type InboxCaller = {
|
|
7
|
+
tokenId?: string | null;
|
|
8
|
+
userId?: string | null;
|
|
9
|
+
networkId?: string | null;
|
|
10
|
+
nodeToken: boolean;
|
|
11
|
+
identityRejected: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Inbox reads and ACKs act as the recipient, not as a dispatch target. */
|
|
15
|
+
export function resolveInboxCaller(alias: string, caller: InboxCaller):
|
|
16
|
+
{ ok: true; alias: string } | { ok: false; error: string } {
|
|
17
|
+
const denied = { ok: false as const, error: "inbox_identity_mismatch" };
|
|
18
|
+
if (caller.identityRejected) return denied;
|
|
19
|
+
const requested = alias.trim();
|
|
20
|
+
if (caller.nodeToken) {
|
|
21
|
+
const node = resolveNodeCaller(caller.tokenId);
|
|
22
|
+
if (node.ok) {
|
|
23
|
+
if (node.networkId !== caller.networkId) return denied;
|
|
24
|
+
const canonical = resolveCanonicalAlias(node.networkId, requested).alias;
|
|
25
|
+
return canonical === node.alias ? { ok: true, alias: node.alias } : denied;
|
|
26
|
+
}
|
|
27
|
+
// Registration tokens are network-scoped human tokens, not node tokens.
|
|
28
|
+
// Missing/foreign/ambiguous node identities never fall back to the issuer.
|
|
29
|
+
if (node.reason !== "not_a_node_token" || !caller.tokenId) return denied;
|
|
30
|
+
const token = db.get<{ name: string | null }>(
|
|
31
|
+
"SELECT name FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL", caller.tokenId,
|
|
32
|
+
);
|
|
33
|
+
if (!token || token.name?.startsWith("node:")) return denied;
|
|
34
|
+
}
|
|
35
|
+
// Preserve open-development and legacy global-token mode.
|
|
36
|
+
if (!caller.userId) return { ok: true, alias: requested };
|
|
37
|
+
const user = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", caller.userId);
|
|
38
|
+
if (user?.username === requested) return { ok: true, alias: requested };
|
|
39
|
+
if (!caller.networkId) return { ok: false, error: "network_id_required" };
|
|
40
|
+
const role = getUserNetworkRole(caller.userId, caller.networkId);
|
|
41
|
+
if (!role) return denied;
|
|
42
|
+
const canonical = resolveCanonicalAlias(caller.networkId, requested).alias;
|
|
43
|
+
if (role === "owner" || role === "admin") return { ok: true, alias: canonical };
|
|
44
|
+
const owned = db.get<{ node_id: string }>(
|
|
45
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2 AND owner_user_id = ?3 LIMIT 1",
|
|
46
|
+
canonical, caller.networkId, caller.userId,
|
|
47
|
+
);
|
|
48
|
+
return owned ? { ok: true, alias: canonical } : denied;
|
|
49
|
+
}
|