fuli-context 0.12.0 → 0.12.2
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 +14 -6
- package/README.zh-CN.md +9 -4
- package/dist/web/assets/{AboutPage-BAD0iPVX.js → AboutPage-Dized8Uz.js} +1 -1
- package/dist/web/assets/{AgentConversations-1iGTd4sz.js → AgentConversations-Ccy41mEA.js} +1 -1
- package/dist/web/assets/{AgentHand-D_RafKOM.js → AgentHand-UzVhbKUC.js} +1 -1
- package/dist/web/assets/{AgentName-B5LKQ09q.js → AgentName-DJpvkub2.js} +1 -1
- package/dist/web/assets/AgentProfilePage-CFViQ8kj.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-CBFTg_IE.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-Cv-vxw2C.css +1 -0
- package/dist/web/assets/{ConnectionsPage-Tr2rRVOZ.js → ConnectionsPage-uXcabab_.js} +1 -1
- package/dist/web/assets/EmployeeRecruitDialog-D8Hvhc-M.js +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-TBl_HNj6.js +3 -0
- package/dist/web/assets/KnowledgeEditDialog-HmiqLC-Y.js +1 -0
- package/dist/web/assets/KnowledgeInspector-Cj2yK4Qh.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-DT3BROZm.js +1 -0
- package/dist/web/assets/KnowledgePage-C4J4VueL.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-DA_dqVRG.js +4 -0
- package/dist/web/assets/{NotFoundPage-fetSnp0N.js → NotFoundPage-x_Yd59mm.js} +1 -1
- package/dist/web/assets/{OverviewPage-BD4yY2Xz.js → OverviewPage-3eZjQ26v.js} +1 -1
- package/dist/web/assets/OverviewPage-8LBN8Shi.css +1 -0
- package/dist/web/assets/PersonalProfilePage-CDQk096L.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-CJxp4Wyz.js +1 -0
- package/dist/web/assets/{ProjectAgentFirstTask-BcxqmKXQ.js → ProjectAgentFirstTask-BkRJ0zwO.js} +1 -1
- package/dist/web/assets/{ProjectAgentsPage-BWDJBESb.js → ProjectAgentsPage-BuNNqSEy.js} +8 -8
- package/dist/web/assets/{ProjectScopePicker-yodM7lF_.js → ProjectScopePicker-CiDxM2Yx.js} +1 -1
- package/dist/web/assets/PublicProjectsPage-ByOT559r.js +1 -0
- package/dist/web/assets/{ReviewPage-Cy0Rt4CO.js → ReviewPage-C0Vdr9uq.js} +1 -1
- package/dist/web/assets/RoundtablesPage-Drou-B81.css +1 -0
- package/dist/web/assets/RoundtablesPage-L3ShEWS3.js +1 -0
- package/dist/web/assets/SettingsPage-BE8pWbI6.css +1 -0
- package/dist/web/assets/SettingsPage-McYuinGz.js +1 -0
- package/dist/web/assets/{SharedJefaReportPage-C56kPciB.js → SharedJefaReportPage-Bg6lIk3E.js} +1 -1
- package/dist/web/assets/UiButton-x8hiHNRM.js +1 -0
- package/dist/web/assets/UiDialog-mjletK0y.js +1 -0
- package/dist/web/assets/{UiDisclosure-D0iMTplF.js → UiDisclosure-_IxeiexW.js} +1 -1
- package/dist/web/assets/{UiSegmented-DIJW6wv3.js → UiSegmented-WFfz9Evl.js} +1 -1
- package/dist/web/assets/{UiSelect-CMR0QqF6.js → UiSelect-K29jnV-1.js} +1 -1
- package/dist/web/assets/{WritingTastePage-QgNzssfF.js → WritingTastePage-DCULB-d2.js} +1 -1
- package/dist/web/assets/agent-pins-DS4mNsNt.js +1 -0
- package/dist/web/assets/agent-profile-Bh3MJGvb.css +1 -0
- package/dist/web/assets/{attention-store-B1DI8wmd.js → attention-store-CA5y8j1R.js} +1 -1
- package/dist/web/assets/catalog-D0VEfOAV.js +1 -0
- package/dist/web/assets/{client-CR-W5Wzp.js → client-DnwnUBQV.js} +1 -1
- package/dist/web/assets/{console-D7GHOMEC.js → console-Cw7TzOSC.js} +1 -1
- package/dist/web/assets/conversation-launcher-settings-Yc13viwr.js +1 -0
- package/dist/web/assets/{i18n-CVFhT_k7.js → i18n-oJFlt8hW.js} +3 -3
- package/dist/web/assets/{identity-CM6Dmy00.js → identity-DSGS4CZc.js} +1 -1
- package/dist/web/assets/index-BfHCcUL2.css +1 -0
- package/dist/web/assets/index-CxNER7Oo.js +5 -0
- package/dist/web/assets/{judgment-types-CJBEyJs1.js → judgment-types-DR931cZ7.js} +1 -1
- package/dist/web/assets/{model-WFhQZ3v_.js → model-BJ-UF4cs.js} +1 -1
- package/dist/web/assets/{personal-profile-graph-BwCh2M9G.js → personal-profile-graph-SOK0BR0w.js} +1 -1
- package/dist/web/assets/project-team-api-C_0qJk_B.js +1 -0
- package/dist/web/assets/{shared-report-CeIvrL9a.js → shared-report-CiX64ZiD.js} +1 -1
- package/dist/web/assets/useAgentRoster-B84mzZIg.js +1 -0
- package/dist/web/assets/{useApi-CROJJdhE-BYhbjaZB.js → useApi-CROJJdhE-7gt0QyQ5.js} +1 -1
- package/dist/web/assets/{vue-router-DaxrQwX3.js → vue-router-CCvPGaLD.js} +1 -1
- package/dist/web/index.html +15 -14
- package/dist/web/licenses/ant-design-icons.txt +22 -0
- package/docs/agent-conversations-and-collaboration.md +3 -3
- package/docs/agent-roundtable.md +33 -0
- package/docs/client-connections.md +108 -0
- package/docs/employee-agents.md +3 -3
- package/docs/project-agent-memory.md +6 -6
- package/graph-provider/fuli_graph/default_project_lead.py +1 -1
- package/graph-provider/fuli_graph/project_agent_delegations.py +70 -2
- package/graph-provider/fuli_graph/project_agent_memory_routes.py +18 -1
- package/graph-provider/fuli_graph/project_agent_remote_origins.py +154 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +1 -1
- package/graph-provider/fuli_graph/runtime.py +7 -0
- package/graph-provider/fuli_graph/store_project_agent_context.py +3 -2
- package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +1 -1
- package/graph-provider/fuli_graph/store_project_agent_task_reads.py +1 -1
- package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +5 -5
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +2 -2
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +2 -2
- package/graph-provider/fuli_graph/store_project_agents.py +6 -6
- package/graph-provider/fuli_graph/system_agents.py +12 -2
- package/graph-provider/fuli_graph/system_hr_identity.py +2 -2
- package/graph-provider/pyproject.toml +2 -1
- package/npm-shrinkwrap.json +280 -5
- package/package.json +6 -2
- package/skills/operating-fuli/SKILL.md +3 -3
- package/src/agent-peer/coordinator-http.js +117 -0
- package/src/agent-peer/coordinator-service.js +258 -0
- package/src/agent-peer/coordinator-store.js +95 -0
- package/src/agent-peer/device-identity.js +70 -0
- package/src/agent-peer/http.js +47 -0
- package/src/agent-peer/node-store.js +127 -0
- package/src/agent-peer/origin-proof.js +56 -0
- package/src/agent-peer/peer-port.js +140 -0
- package/src/agent-peer/peer-runtime.js +298 -0
- package/src/agent-peer/peer-transport.js +78 -0
- package/src/agent-peer/protocol.js +161 -0
- package/src/agent-peer/runner.js +251 -0
- package/src/agent-roundtable/http.js +17 -3
- package/src/agent-roundtable/service.js +272 -43
- package/src/agent-roundtable/tools.js +15 -1
- package/src/agent-roundtable/wake.js +12 -3
- package/src/agent-tools/graph-definitions.js +14 -5
- package/src/agent-tools/graph-handlers.js +1 -0
- package/src/agent-tools/interface-catalog.js +10 -1
- package/src/agent-tools/project-agent-definitions.js +1 -1
- package/src/agents/identity-receipt.js +1 -1
- package/src/cli/command-registry.js +3 -0
- package/src/cli/connect-command.js +31 -0
- package/src/cli/mcp-command.js +101 -0
- package/src/cli/peer-command.js +100 -0
- package/src/cli/setup-agent-selection.js +2 -2
- package/src/cli/setup-command.js +12 -5
- package/src/cli.js +16 -0
- package/src/employees/catalog/bole.json +2 -2
- package/src/graphiti/federated-application.js +4 -1
- package/src/graphiti/project-agent-control-plane.js +7 -0
- package/src/graphiti/project-agent-task-entry.js +4 -4
- package/src/graphiti/project-agent-team-context.js +16 -18
- package/src/graphiti/project-team-view.js +82 -0
- package/src/graphiti/provider-client.js +9 -0
- package/src/graphiti/task-entry-preferences.js +1 -1
- package/src/http/api-router.js +10 -1
- package/src/http/graph-api-router.js +7 -0
- package/src/mcp/runtime.js +2 -1
- package/src/mcp/tool-annotations.js +4 -3
- package/src/mcp/tool-profiles.js +36 -0
- package/src/mcp-server.js +2 -100
- package/src/server.js +14 -2
- package/src/setup/agent-installation-status.js +5 -3
- package/src/setup/agents.js +23 -3
- package/src/setup/claude-desktop-config.js +60 -0
- package/src/setup/client-connection.js +25 -0
- package/src/setup/paths.js +3 -1
- package/src/setup/setup.js +1 -1
- package/src/setup/stable-package-root.js +29 -0
- package/src/system/system-service.js +5 -0
- package/dist/web/assets/AgentProfilePage-CioOdkBM.js +0 -1
- package/dist/web/assets/AgentsDirectoryPage--1A9nvNA.js +0 -1
- package/dist/web/assets/AgentsDirectoryPage-C7OUQH9v.css +0 -1
- package/dist/web/assets/EmployeeRecruitDialog-1QevD4Qv.js +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-B0vQIgwA.js +0 -3
- package/dist/web/assets/KnowledgeEditDialog-ClnrfPZi.js +0 -1
- package/dist/web/assets/KnowledgeInspector-Te1hJZbx.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-CCCrWl6Y.js +0 -1
- package/dist/web/assets/KnowledgePage-DGOcBwr8.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-cKn1NCRe.js +0 -4
- package/dist/web/assets/OverviewPage-By-fUzSG.css +0 -1
- package/dist/web/assets/PersonalProfilePage-DGJVP_VB.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-D25HYMgD.js +0 -1
- package/dist/web/assets/PublicProjectsPage-CyNxVzW3.js +0 -1
- package/dist/web/assets/RoundtablesPage-BjVhvwMc.js +0 -1
- package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +0 -1
- package/dist/web/assets/SettingsPage-CCZqZtnH.js +0 -1
- package/dist/web/assets/SettingsPage-DYz5bTwa.css +0 -1
- package/dist/web/assets/UiDialog-CvDxg3B1.js +0 -1
- package/dist/web/assets/agent-pins-BcJ_qr8n.js +0 -1
- package/dist/web/assets/agent-profile-BZj3ezWe.css +0 -1
- package/dist/web/assets/catalog-DbXv0_bR.js +0 -1
- package/dist/web/assets/conversation-launcher-settings-Dj2GMzCI.js +0 -1
- package/dist/web/assets/index-CJ6Jmok9.css +0 -1
- package/dist/web/assets/index-CRa3GnYi.js +0 -5
- package/dist/web/assets/useAgentRoster-DCArHlxK.js +0 -1
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { certificateFingerprint, verifyDeviceSignature } from './device-identity.js';
|
|
4
|
+
import { isAuthorityKey } from './origin-proof.js';
|
|
5
|
+
import {
|
|
6
|
+
canonicalJson, encodeInvitation, PEER_LIMITS, PeerError, pemToDer, publicDirectoryEntry,
|
|
7
|
+
RESULT_OUTCOMES, sha256Hex, TERMINAL_STATUSES, validateAssertion,
|
|
8
|
+
} from './protocol.js';
|
|
9
|
+
|
|
10
|
+
const ONLINE_MS = 90_000;
|
|
11
|
+
const PAIRING_FAILURE_WINDOW_MS = 10 * 60_000;
|
|
12
|
+
const PAIRING_FAILURE_LIMIT = 10;
|
|
13
|
+
|
|
14
|
+
// Routes messages between paired devices. It never executes anything: each
|
|
15
|
+
// receiving device re-checks its own shares and identity authority.
|
|
16
|
+
export function createCoordinatorService({ store, identity, authority, name, clock = Date.now,
|
|
17
|
+
randomCode = () => randomBytes(32).toString('base64url'), idFactory = randomUUID, onTrustChange = () => {} }) {
|
|
18
|
+
const iso = (offsetMs = 0) => new Date(clock() + offsetMs).toISOString();
|
|
19
|
+
const pairingFailures = [];
|
|
20
|
+
if (!isAuthorityKey(authority)) throw new PeerError('authority_unavailable', 'The coordinator needs its local origin authority', 500);
|
|
21
|
+
store.upsertNode({ id: identity.fingerprint, name, certPem: identity.certPem, authority, at: iso() });
|
|
22
|
+
|
|
23
|
+
function createInvitation({ url }) {
|
|
24
|
+
const code = randomCode();
|
|
25
|
+
const expiresAt = iso(PEER_LIMITS.invitationSeconds * 1000);
|
|
26
|
+
store.createInvitation({ codeHash: sha256Hex(code), at: iso(), expiresAt });
|
|
27
|
+
return { invitation: encodeInvitation({ url, fingerprint: identity.fingerprint, code, expiresAt, name, authority,
|
|
28
|
+
certificate: pemToDer(identity.certPem).toString('base64') }), fingerprint: identity.fingerprint, expiresAt };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pair({ code, certPem, name: deviceName, authority: deviceAuthority }) {
|
|
32
|
+
const now = clock();
|
|
33
|
+
while (pairingFailures.length && pairingFailures[0] < now - PAIRING_FAILURE_WINDOW_MS) pairingFailures.shift();
|
|
34
|
+
if (pairingFailures.length >= PAIRING_FAILURE_LIMIT) throw new PeerError('pairing_rate_limited', 'Too many failed pairing attempts; try again later', 429);
|
|
35
|
+
const fingerprint = certificateFingerprint(certPem);
|
|
36
|
+
if (fingerprint === identity.fingerprint) throw new PeerError('invalid_pairing', 'A device cannot pair with itself', 400);
|
|
37
|
+
const codeHash = typeof code === 'string' && code.length <= 128 ? sha256Hex(code) : null;
|
|
38
|
+
const invitation = codeHash ? store.invitation(codeHash) : null;
|
|
39
|
+
const usable = invitation && !invitation.used_at && Date.parse(invitation.expires_at) > now;
|
|
40
|
+
if (!usable) {
|
|
41
|
+
pairingFailures.push(now);
|
|
42
|
+
throw new PeerError('invalid_pairing_code', 'This invitation is invalid, expired or already used', 403);
|
|
43
|
+
}
|
|
44
|
+
if (!isAuthorityKey(deviceAuthority)) throw new PeerError('invalid_pairing', 'The device did not present its origin authority', 400);
|
|
45
|
+
const label = cleanName(deviceName) || `FULI device ${fingerprint.slice(0, 8)}`;
|
|
46
|
+
store.transaction(() => {
|
|
47
|
+
if (!store.useInvitation(codeHash, fingerprint, iso())) throw new PeerError('invalid_pairing_code', 'This invitation was already used', 403);
|
|
48
|
+
store.upsertNode({ id: fingerprint, name: label, certPem,
|
|
49
|
+
authority: { keyId: deviceAuthority.keyId, publicKey: deviceAuthority.publicKey }, at: iso() });
|
|
50
|
+
});
|
|
51
|
+
onTrustChange();
|
|
52
|
+
return { nodeId: fingerprint, coordinatorNodeId: identity.fingerprint, coordinatorName: name };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function authenticate(nodeId) {
|
|
56
|
+
const node = store.node(nodeId);
|
|
57
|
+
if (!node || node.status !== 'active') throw new PeerError('device_not_trusted', 'This device is not paired or was revoked', 403);
|
|
58
|
+
store.touchNode(nodeId, iso());
|
|
59
|
+
return node;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function publishDirectory(nodeId, { agents }) {
|
|
63
|
+
if (!Array.isArray(agents) || agents.length > PEER_LIMITS.directoryAgents) throw new PeerError('invalid_directory', 'Invalid shared Agent list');
|
|
64
|
+
const entries = agents.map(publicDirectoryEntry);
|
|
65
|
+
store.setDirectory(nodeId, entries);
|
|
66
|
+
return { published: entries.length };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function directory(nodeId) {
|
|
70
|
+
const now = clock();
|
|
71
|
+
return { devices: store.nodes().filter((node) => node.status === 'active' && node.id !== nodeId).map((node) => ({
|
|
72
|
+
nodeId: node.id, name: node.name,
|
|
73
|
+
online: Boolean(node.last_seen_at) && now - Date.parse(node.last_seen_at) < ONLINE_MS,
|
|
74
|
+
agents: JSON.parse(node.directory_json),
|
|
75
|
+
})) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function submit(nodeId, { assertion, signature }) {
|
|
79
|
+
validateAssertion(assertion, { now: clock() });
|
|
80
|
+
if (assertion.from.node !== nodeId) throw new PeerError('sender_mismatch', 'The message sender is not this device', 403);
|
|
81
|
+
const sender = store.node(nodeId);
|
|
82
|
+
if (!verifyDeviceSignature(sender.cert_pem, assertion, signature)) throw new PeerError('invalid_signature', 'The message signature is invalid', 403);
|
|
83
|
+
const target = store.node(assertion.to.node);
|
|
84
|
+
if (!target || target.status !== 'active') throw new PeerError('device_unavailable', 'The receiving device is not paired', 404);
|
|
85
|
+
const shared = JSON.parse(target.directory_json)
|
|
86
|
+
.some((entry) => entry.bindingId === assertion.to.bindingId && entry.agentId === assertion.to.agentId);
|
|
87
|
+
if (!shared) throw new PeerError('agent_not_shared', 'That Agent is not shared by the receiving device', 404);
|
|
88
|
+
const assertionJson = canonicalJson(assertion);
|
|
89
|
+
return store.transaction(() => {
|
|
90
|
+
const key = messageKey(nodeId, assertion.messageId);
|
|
91
|
+
const existing = store.message(key);
|
|
92
|
+
if (existing) {
|
|
93
|
+
if (existing.assertion_json !== assertionJson) {
|
|
94
|
+
throw new PeerError('duplicate_message', 'A different message already uses this ID', 409);
|
|
95
|
+
}
|
|
96
|
+
return statusView(existing);
|
|
97
|
+
}
|
|
98
|
+
if (store.queuedCount(target.id) >= PEER_LIMITS.queuePerNode) throw new PeerError('queue_full', 'The receiving device has too many waiting messages', 429);
|
|
99
|
+
store.insertMessage({ id: key, senderNode: nodeId, targetNode: target.id,
|
|
100
|
+
bindingId: assertion.to.bindingId, assertionJson, signature, expiresAt: assertion.expiresAt, at: iso() });
|
|
101
|
+
return statusView(store.message(key));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function claim(nodeId) {
|
|
106
|
+
return store.transaction(() => {
|
|
107
|
+
const next = store.nextQueued(nodeId, iso());
|
|
108
|
+
if (!next) return null;
|
|
109
|
+
const attemptId = idFactory();
|
|
110
|
+
// A short acceptance window: the runner may only start after its acceptance is recorded.
|
|
111
|
+
store.updateMessage(next.id, { status: 'claimed', attempt_id: attemptId, accepted_attempt: null,
|
|
112
|
+
start_attempts: next.start_attempts + 1, lease_expires_at: iso(PEER_LIMITS.acceptSeconds * 1000) }, iso(), 'claimed', attemptId);
|
|
113
|
+
const sender = store.node(next.sender_node);
|
|
114
|
+
return { messageId: next.id, attemptId, leaseSeconds: PEER_LIMITS.leaseSeconds,
|
|
115
|
+
assertion: JSON.parse(next.assertion_json), signature: next.signature,
|
|
116
|
+
sender: { nodeId: sender.id, name: sender.name, certPem: sender.cert_pem,
|
|
117
|
+
authority: sender.authority_json ? JSON.parse(sender.authority_json) : null } };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function currentAttempt(nodeId, messageId, attemptId) {
|
|
122
|
+
const message = store.message(messageId);
|
|
123
|
+
if (!message || message.target_node !== nodeId) throw new PeerError('message_not_found', 'No such message for this device', 404);
|
|
124
|
+
if (message.attempt_id !== attemptId) throw new PeerError('attempt_not_current', 'This delivery attempt is no longer current', 409);
|
|
125
|
+
return message;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renew(nodeId, { messageId, attemptId, state }) {
|
|
129
|
+
if (!['accepted', 'starting', 'running'].includes(state)) throw new PeerError('invalid_state', 'Unknown attempt state');
|
|
130
|
+
return store.transaction(() => {
|
|
131
|
+
const message = currentAttempt(nodeId, messageId, attemptId);
|
|
132
|
+
if (!['claimed', 'running'].includes(message.status)) throw new PeerError('attempt_not_current', 'This delivery attempt is no longer active', 409);
|
|
133
|
+
if (!(Date.parse(message.lease_expires_at) > clock())) throw new PeerError('lease_expired', 'This delivery attempt lost its lease', 409);
|
|
134
|
+
const accepted = message.accepted_attempt === attemptId;
|
|
135
|
+
if (!accepted && state !== 'accepted') throw new PeerError('attempt_not_accepted', 'Accept the attempt before starting it', 409);
|
|
136
|
+
const changes = { lease_expires_at: iso(PEER_LIMITS.leaseSeconds * 1000) };
|
|
137
|
+
if (!accepted) changes.accepted_attempt = attemptId;
|
|
138
|
+
if (state === 'running' && message.status === 'claimed') changes.status = 'running';
|
|
139
|
+
store.updateMessage(messageId, changes, iso(), changes.status ? 'started' : accepted ? 'renewed' : 'accepted', attemptId);
|
|
140
|
+
return { cancel: message.cancel_requested === 1 || Date.parse(message.expires_at) <= clock() };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function result(nodeId, { messageId, attemptId, outcome, reply = null, error = null, via = null, verified = false }) {
|
|
145
|
+
if (!RESULT_OUTCOMES.has(outcome)) throw new PeerError('invalid_result', 'Unknown result outcome');
|
|
146
|
+
if (outcome === 'answered' && (typeof reply !== 'string' || !reply.trim() || reply.length > 64_000 || verified !== true)) {
|
|
147
|
+
throw new PeerError('invalid_result', 'An answer needs a verified reply');
|
|
148
|
+
}
|
|
149
|
+
return store.transaction(() => {
|
|
150
|
+
const message = currentAttempt(nodeId, messageId, attemptId);
|
|
151
|
+
if (TERMINAL_STATUSES.has(message.status) && message.status !== 'unknown') return statusView(message);
|
|
152
|
+
const detail = typeof error === 'string' ? error.slice(0, 120) : null;
|
|
153
|
+
if (outcome === 'never_started') {
|
|
154
|
+
// Only an attempt whose acceptance was never recorded is proof that nothing started.
|
|
155
|
+
// Once accepted, the receiver may have lost the acknowledgement, so the outcome is unknown.
|
|
156
|
+
const started = message.status === 'running'
|
|
157
|
+
|| store.events(messageId).some((event) => event.kind === 'started' && event.detail === attemptId);
|
|
158
|
+
if (started) throw new PeerError('attempt_started', 'This attempt already started', 409);
|
|
159
|
+
if (message.accepted_attempt === attemptId) {
|
|
160
|
+
store.updateMessage(messageId, { status: 'unknown', error: 'accepted_without_start', lease_expires_at: null },
|
|
161
|
+
iso(), 'unknown', attemptId);
|
|
162
|
+
} else releaseUnstarted(message, detail);
|
|
163
|
+
} else {
|
|
164
|
+
store.updateMessage(messageId, { status: outcome, error: outcome === 'answered' ? null : detail,
|
|
165
|
+
reply: outcome === 'answered' ? reply : null, via: typeof via === 'string' ? via.slice(0, 80) : null,
|
|
166
|
+
verified: verified === true ? 1 : 0, lease_expires_at: null }, iso(), outcome, attemptId);
|
|
167
|
+
}
|
|
168
|
+
return statusView(store.message(messageId));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function addressed(nodeId, messageId) {
|
|
173
|
+
if (typeof messageId !== 'string' || messageId.length > 128) return null;
|
|
174
|
+
const sent = store.message(messageKey(nodeId, messageId));
|
|
175
|
+
if (sent) return sent;
|
|
176
|
+
const received = store.message(messageId);
|
|
177
|
+
return received?.target_node === nodeId ? received : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function status(nodeId, { messageId }) {
|
|
181
|
+
const message = addressed(nodeId, messageId);
|
|
182
|
+
if (!message) {
|
|
183
|
+
throw new PeerError('message_not_found', 'No such message for this device', 404);
|
|
184
|
+
}
|
|
185
|
+
return statusView(message);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function cancel(nodeId, { messageId }) {
|
|
189
|
+
return store.transaction(() => {
|
|
190
|
+
const message = typeof messageId === 'string' ? store.message(messageKey(nodeId, messageId)) : null;
|
|
191
|
+
if (!message) throw new PeerError('message_not_found', 'No such message sent by this device', 404);
|
|
192
|
+
if (message.status === 'queued') store.updateMessage(message.id, { status: 'cancelled', error: 'cancelled' }, iso(), 'cancelled');
|
|
193
|
+
else if (['claimed', 'running'].includes(message.status)) store.updateMessage(message.id, { cancel_requested: 1 }, iso(), 'cancel_requested');
|
|
194
|
+
return statusView(store.message(message.id));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function revoke(nodeId) {
|
|
199
|
+
if (nodeId === identity.fingerprint) throw new PeerError('invalid_revoke', 'The coordinator cannot revoke itself');
|
|
200
|
+
store.transaction(() => {
|
|
201
|
+
store.revokeNode(nodeId, iso());
|
|
202
|
+
for (const message of store.openForNode(nodeId)) {
|
|
203
|
+
if (message.status === 'queued') store.updateMessage(message.id, { status: 'cancelled', error: 'device_revoked' }, iso(), 'revoked');
|
|
204
|
+
else store.updateMessage(message.id, { cancel_requested: 1 }, iso(), 'cancel_requested', 'device_revoked');
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
onTrustChange();
|
|
208
|
+
return { revoked: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function releaseUnstarted(message, detail) {
|
|
212
|
+
const changes = message.cancel_requested ? { status: 'cancelled', error: 'cancelled' }
|
|
213
|
+
: Date.parse(message.expires_at) <= clock() ? { status: 'expired', error: 'expired' }
|
|
214
|
+
: message.start_attempts >= PEER_LIMITS.maxStartAttempts ? { status: 'failed', error: detail ?? 'start_attempts_exhausted' }
|
|
215
|
+
: { status: 'queued', error: detail };
|
|
216
|
+
store.updateMessage(message.id, { ...changes, attempt_id: null, accepted_attempt: null, lease_expires_at: null },
|
|
217
|
+
iso(), `never_started:${changes.status}`, message.attempt_id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// An attempt the coordinator never accepted cannot have started, because a
|
|
221
|
+
// runner only starts after acceptance is confirmed. Once accepted, a lapsed
|
|
222
|
+
// lease is uncertain and becomes unknown, never "not started".
|
|
223
|
+
function sweep() {
|
|
224
|
+
store.transaction(() => {
|
|
225
|
+
for (const message of store.expiredQueued(iso())) store.updateMessage(message.id, { status: 'expired', error: 'expired' }, iso(), 'expired');
|
|
226
|
+
for (const message of store.staleLeases(iso())) {
|
|
227
|
+
if (message.accepted_attempt !== message.attempt_id) releaseUnstarted(message, 'not_accepted');
|
|
228
|
+
else store.updateMessage(message.id, { status: 'unknown', error: 'lease_expired', lease_expires_at: null }, iso(), 'unknown', message.attempt_id);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function devices() {
|
|
234
|
+
const now = clock();
|
|
235
|
+
return store.nodes().map((node) => ({ nodeId: node.id, name: node.name, status: node.status,
|
|
236
|
+
self: node.id === identity.fingerprint, pairedAt: node.paired_at, revokedAt: node.revoked_at,
|
|
237
|
+
online: node.id === identity.fingerprint || (Boolean(node.last_seen_at) && now - Date.parse(node.last_seen_at) < ONLINE_MS),
|
|
238
|
+
sharedAgents: JSON.parse(node.directory_json).length }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { createInvitation, pair, authenticate, publishDirectory, directory, submit, claim, renew, result,
|
|
242
|
+
status, cancel, revoke, sweep, devices, trustedCertificates: () => store.activeCertificates(),
|
|
243
|
+
openInvitations: () => store.openInvitations(iso()).length };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Message IDs are chosen by senders, so the coordinator keys them per sending device.
|
|
247
|
+
function messageKey(senderNode, messageId) {
|
|
248
|
+
return sha256Hex(`${senderNode}:${messageId}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function statusView(message) {
|
|
252
|
+
return { messageId: JSON.parse(message.assertion_json).messageId, status: message.status, reply: message.reply ?? null, error: message.error ?? null,
|
|
253
|
+
via: message.via ?? null, verified: message.verified === 1, updatedAt: message.updated_at };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function cleanName(value) {
|
|
257
|
+
return typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60) : '';
|
|
258
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
|
|
5
|
+
// Paired devices, one-use invitations and the cross-device message queue.
|
|
6
|
+
// Status changes are also appended to events, so history survives updates.
|
|
7
|
+
export function createCoordinatorStore(path = ':memory:') {
|
|
8
|
+
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
9
|
+
const database = new DatabaseSync(path);
|
|
10
|
+
database.exec(`
|
|
11
|
+
PRAGMA journal_mode = WAL;
|
|
12
|
+
PRAGMA busy_timeout = 5000;
|
|
13
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
14
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, cert_pem TEXT NOT NULL, status TEXT NOT NULL,
|
|
15
|
+
paired_at TEXT NOT NULL, revoked_at TEXT, last_seen_at TEXT, directory_json TEXT NOT NULL DEFAULT '[]',
|
|
16
|
+
authority_json TEXT
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS invitations (
|
|
19
|
+
code_hash TEXT PRIMARY KEY, created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
|
20
|
+
used_at TEXT, used_by TEXT
|
|
21
|
+
);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
23
|
+
id TEXT PRIMARY KEY, sender_node TEXT NOT NULL, target_node TEXT NOT NULL, binding_id TEXT NOT NULL,
|
|
24
|
+
assertion_json TEXT NOT NULL, signature TEXT NOT NULL, status TEXT NOT NULL,
|
|
25
|
+
attempt_id TEXT, accepted_attempt TEXT, start_attempts INTEGER NOT NULL DEFAULT 0, lease_expires_at TEXT,
|
|
26
|
+
cancel_requested INTEGER NOT NULL DEFAULT 0, reply TEXT, error TEXT, via TEXT, verified INTEGER,
|
|
27
|
+
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS messages_queue ON messages(target_node, status, created_at);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
31
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, message_id TEXT, node_id TEXT, kind TEXT NOT NULL,
|
|
32
|
+
detail TEXT, at TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
`);
|
|
35
|
+
const transaction = (work) => {
|
|
36
|
+
database.exec('BEGIN IMMEDIATE');
|
|
37
|
+
try { const result = work(); database.exec('COMMIT'); return result; }
|
|
38
|
+
catch (error) { database.exec('ROLLBACK'); throw error; }
|
|
39
|
+
};
|
|
40
|
+
const event = (at, { messageId = null, nodeId = null, kind, detail = null }) => database.prepare(
|
|
41
|
+
'INSERT INTO events(message_id, node_id, kind, detail, at) VALUES(?,?,?,?,?)').run(messageId, nodeId, kind, detail, at);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
transaction,
|
|
45
|
+
event,
|
|
46
|
+
node: (id) => database.prepare('SELECT * FROM nodes WHERE id = ?').get(id) ?? null,
|
|
47
|
+
nodes: () => database.prepare('SELECT * FROM nodes ORDER BY paired_at').all(),
|
|
48
|
+
activeCertificates: () => database.prepare("SELECT cert_pem FROM nodes WHERE status = 'active'").all().map((row) => row.cert_pem),
|
|
49
|
+
// Only pairing (a local owner action) records a device's origin authority.
|
|
50
|
+
upsertNode({ id, name, certPem, authority = null, at }) {
|
|
51
|
+
database.prepare(`INSERT INTO nodes(id, name, cert_pem, status, paired_at, authority_json) VALUES(?,?,?,'active',?,?)
|
|
52
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, cert_pem = excluded.cert_pem, status = 'active',
|
|
53
|
+
revoked_at = NULL, directory_json = '[]', paired_at = excluded.paired_at, authority_json = excluded.authority_json`)
|
|
54
|
+
.run(id, name, certPem, at, authority ? JSON.stringify(authority) : null);
|
|
55
|
+
event(at, { nodeId: id, kind: 'paired' });
|
|
56
|
+
},
|
|
57
|
+
revokeNode(id, at) {
|
|
58
|
+
database.prepare("UPDATE nodes SET status = 'revoked', revoked_at = ?, directory_json = '[]' WHERE id = ?").run(at, id);
|
|
59
|
+
event(at, { nodeId: id, kind: 'revoked' });
|
|
60
|
+
},
|
|
61
|
+
touchNode: (id, at) => database.prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?').run(at, id),
|
|
62
|
+
setDirectory: (id, entries) => database.prepare('UPDATE nodes SET directory_json = ? WHERE id = ?').run(JSON.stringify(entries), id),
|
|
63
|
+
createInvitation: ({ codeHash, at, expiresAt }) => database.prepare(
|
|
64
|
+
'INSERT INTO invitations(code_hash, created_at, expires_at) VALUES(?,?,?)').run(codeHash, at, expiresAt),
|
|
65
|
+
invitation: (codeHash) => database.prepare('SELECT * FROM invitations WHERE code_hash = ?').get(codeHash) ?? null,
|
|
66
|
+
openInvitations: (at) => database.prepare(
|
|
67
|
+
'SELECT * FROM invitations WHERE used_at IS NULL AND expires_at > ?').all(at),
|
|
68
|
+
useInvitation: (codeHash, nodeId, at) => database.prepare(
|
|
69
|
+
'UPDATE invitations SET used_at = ?, used_by = ? WHERE code_hash = ? AND used_at IS NULL').run(at, nodeId, codeHash).changes === 1,
|
|
70
|
+
insertMessage(row) {
|
|
71
|
+
database.prepare(`INSERT INTO messages(id, sender_node, target_node, binding_id, assertion_json, signature,
|
|
72
|
+
status, expires_at, created_at, updated_at) VALUES(?,?,?,?,?,?,'queued',?,?,?)`).run(
|
|
73
|
+
row.id, row.senderNode, row.targetNode, row.bindingId, row.assertionJson, row.signature, row.expiresAt, row.at, row.at);
|
|
74
|
+
event(row.at, { messageId: row.id, nodeId: row.senderNode, kind: 'queued' });
|
|
75
|
+
},
|
|
76
|
+
message: (id) => database.prepare('SELECT * FROM messages WHERE id = ?').get(id) ?? null,
|
|
77
|
+
queuedCount: (targetNode) => database.prepare(
|
|
78
|
+
"SELECT count(*) AS count FROM messages WHERE target_node = ? AND status IN ('queued','claimed','running')").get(targetNode).count,
|
|
79
|
+
nextQueued: (targetNode, at) => database.prepare(`SELECT * FROM messages WHERE target_node = ? AND status = 'queued'
|
|
80
|
+
AND expires_at > ? AND cancel_requested = 0 ORDER BY created_at LIMIT 1`).get(targetNode, at) ?? null,
|
|
81
|
+
updateMessage(id, changes, at, kind, detail = null) {
|
|
82
|
+
const columns = Object.keys(changes);
|
|
83
|
+
database.prepare(`UPDATE messages SET ${columns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`)
|
|
84
|
+
.run(...columns.map((column) => changes[column]), at, id);
|
|
85
|
+
event(at, { messageId: id, kind, detail });
|
|
86
|
+
},
|
|
87
|
+
staleLeases: (at) => database.prepare(
|
|
88
|
+
"SELECT * FROM messages WHERE status IN ('claimed','running') AND lease_expires_at <= ?").all(at),
|
|
89
|
+
expiredQueued: (at) => database.prepare("SELECT * FROM messages WHERE status = 'queued' AND expires_at <= ?").all(at),
|
|
90
|
+
openForNode: (nodeId) => database.prepare(`SELECT * FROM messages WHERE (target_node = ? OR sender_node = ?)
|
|
91
|
+
AND status IN ('queued','claimed','running')`).all(nodeId, nodeId),
|
|
92
|
+
events: (messageId) => database.prepare('SELECT * FROM events WHERE message_id = ? ORDER BY seq').all(messageId),
|
|
93
|
+
close: () => database.close(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { BasicConstraintsExtension, X509CertificateGenerator } from '@peculiar/x509';
|
|
3
|
+
import { createPrivateKey, randomBytes, sign, verify, webcrypto, X509Certificate } from 'node:crypto';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { canonicalJson, pemToDer, sha256Hex } from './protocol.js';
|
|
8
|
+
|
|
9
|
+
const KEY_FILE = 'device-key.pem';
|
|
10
|
+
const CERT_FILE = 'device-cert.pem';
|
|
11
|
+
const VALIDITY_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const ALGORITHM = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
|
|
13
|
+
|
|
14
|
+
// One key pair per installation. The private key never leaves this directory;
|
|
15
|
+
// other devices only ever see the self-signed certificate and its fingerprint.
|
|
16
|
+
export async function loadDeviceIdentity(directory) {
|
|
17
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
18
|
+
const keyPath = join(directory, KEY_FILE);
|
|
19
|
+
const certPath = join(directory, CERT_FILE);
|
|
20
|
+
if (!existsSync(keyPath) || !existsSync(certPath)) {
|
|
21
|
+
const created = await createDeviceCertificate();
|
|
22
|
+
writeFileSync(keyPath, created.keyPem, { mode: 0o600 });
|
|
23
|
+
chmodSync(keyPath, 0o600);
|
|
24
|
+
writeFileSync(certPath, created.certPem, { mode: 0o644 });
|
|
25
|
+
}
|
|
26
|
+
return deviceIdentity({ keyPem: readFileSync(keyPath, 'utf8'), certPem: readFileSync(certPath, 'utf8') });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function deviceIdentity({ keyPem, certPem }) {
|
|
30
|
+
const key = createPrivateKey(keyPem);
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
fingerprint: certificateFingerprint(certPem),
|
|
33
|
+
certPem,
|
|
34
|
+
tlsOptions: Object.freeze({ key: keyPem, cert: certPem }),
|
|
35
|
+
sign: (value) => sign('sha256', Buffer.from(canonicalJson(value)), key).toString('base64'),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function certificateFingerprint(certPem) {
|
|
40
|
+
return sha256Hex(pemToDer(certPem));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function verifyDeviceSignature(certPem, value, signature) {
|
|
44
|
+
if (typeof signature !== 'string' || signature.length > 512) return false;
|
|
45
|
+
try {
|
|
46
|
+
const certificate = new X509Certificate(certPem);
|
|
47
|
+
return verify('sha256', Buffer.from(canonicalJson(value)), certificate.publicKey, Buffer.from(signature, 'base64'));
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function createDeviceCertificate({ now = Date.now() } = {}) {
|
|
54
|
+
const keys = await webcrypto.subtle.generateKey(ALGORITHM, true, ['sign', 'verify']);
|
|
55
|
+
const certificate = await X509CertificateGenerator.createSelfSigned({
|
|
56
|
+
// A unique subject keeps several paired self-signed certificates unambiguous in one trust store.
|
|
57
|
+
name: `CN=FULI device ${randomBytes(8).toString('hex')}`,
|
|
58
|
+
serialNumber: `01${randomBytes(15).toString('hex')}`,
|
|
59
|
+
notBefore: new Date(now - 24 * 60 * 60 * 1000),
|
|
60
|
+
notAfter: new Date(now + VALIDITY_MS),
|
|
61
|
+
signingAlgorithm: ALGORITHM,
|
|
62
|
+
keys,
|
|
63
|
+
extensions: [new BasicConstraintsExtension(true, undefined, true)],
|
|
64
|
+
}, webcrypto);
|
|
65
|
+
const pkcs8 = Buffer.from(await webcrypto.subtle.exportKey('pkcs8', keys.privateKey));
|
|
66
|
+
return {
|
|
67
|
+
certPem: `${certificate.toString('pem').trim()}\n`,
|
|
68
|
+
keyPem: `-----BEGIN PRIVATE KEY-----\n${pkcs8.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END PRIVATE KEY-----\n`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readJson, sendJson } from '../http/response.js';
|
|
2
|
+
import { PeerError } from './protocol.js';
|
|
3
|
+
|
|
4
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
5
|
+
|
|
6
|
+
// The owner's LAN roundtable controls. They answer only on this computer's
|
|
7
|
+
// loopback console, even when the console itself is reachable on the LAN.
|
|
8
|
+
export async function handlePeerApiRequest({ request, response, url, peer }) {
|
|
9
|
+
if (!url.pathname.startsWith('/api/peer')) return false;
|
|
10
|
+
if (!peer) { sendJson(response, 404, { error: 'peer_unavailable', message: 'The LAN roundtable needs a local FULI installation' }); return true; }
|
|
11
|
+
const host = String(request.headers.host ?? '');
|
|
12
|
+
if (!LOOPBACK.has(request.socket?.remoteAddress) || !/^(127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) {
|
|
13
|
+
sendJson(response, 403, { error: 'local_owner_only', message: 'LAN roundtable settings can only be changed on this computer' });
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const result = await route(request, url.pathname, peer);
|
|
18
|
+
if (result === undefined) return false;
|
|
19
|
+
sendJson(response, 200, result);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (!(error instanceof PeerError)) throw error;
|
|
22
|
+
sendJson(response, error.status ?? 400, { error: error.code, message: error.message });
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function route(request, pathname, peer) {
|
|
28
|
+
const { method } = request;
|
|
29
|
+
if (pathname === '/api/peer' && method === 'GET') return peer.status();
|
|
30
|
+
if (pathname === '/api/peer/coordinator' && method === 'POST') {
|
|
31
|
+
const body = await readJson(request);
|
|
32
|
+
return peer.enableCoordinator({ host: body.host, port: body.port ?? 0, name: body.name });
|
|
33
|
+
}
|
|
34
|
+
if (pathname === '/api/peer/invitations' && method === 'POST') { await readJson(request); return peer.createInvitation(); }
|
|
35
|
+
if (pathname === '/api/peer/invitations/preview' && method === 'POST') {
|
|
36
|
+
return peer.previewInvitation((await readJson(request)).invitation);
|
|
37
|
+
}
|
|
38
|
+
if (pathname === '/api/peer/join' && method === 'POST') {
|
|
39
|
+
const body = await readJson(request);
|
|
40
|
+
return peer.join({ invitation: body.invitation, name: body.name });
|
|
41
|
+
}
|
|
42
|
+
if (pathname === '/api/peer/shares' && method === 'PUT') return peer.setShares({ shares: (await readJson(request)).shares });
|
|
43
|
+
const revoke = /^\/api\/peer\/devices\/([0-9a-f]{64})\/revoke$/.exec(pathname);
|
|
44
|
+
if (revoke && method === 'POST') { await readJson(request); return peer.revokeDevice(revoke[1]); }
|
|
45
|
+
if (pathname === '/api/peer/disable' && method === 'POST') { await readJson(request); return peer.disable(); }
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
|
|
5
|
+
// This device's side of the LAN roundtable: owner settings and shares, every
|
|
6
|
+
// receiving attempt (written before any process starts), the result outbox,
|
|
7
|
+
// and where messages this device sent were routed.
|
|
8
|
+
export function createNodeStore(path = ':memory:') {
|
|
9
|
+
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
10
|
+
const database = new DatabaseSync(path);
|
|
11
|
+
database.exec(`
|
|
12
|
+
PRAGMA journal_mode = WAL;
|
|
13
|
+
PRAGMA busy_timeout = 5000;
|
|
14
|
+
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
15
|
+
CREATE TABLE IF NOT EXISTS shares (
|
|
16
|
+
binding_id TEXT PRIMARY KEY, space_id TEXT NOT NULL, project_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
|
17
|
+
clients_json TEXT NOT NULL, working_directory TEXT, created_at TEXT NOT NULL, revoked_at TEXT
|
|
18
|
+
);
|
|
19
|
+
CREATE UNIQUE INDEX IF NOT EXISTS shares_active ON shares(space_id, project_id, agent_id) WHERE revoked_at IS NULL;
|
|
20
|
+
CREATE TABLE IF NOT EXISTS authorities (
|
|
21
|
+
node_id TEXT PRIMARY KEY, key_id TEXT NOT NULL, public_key TEXT NOT NULL, bound_by TEXT NOT NULL, bound_at TEXT NOT NULL
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS attempts (
|
|
24
|
+
message_id TEXT NOT NULL, attempt_id TEXT NOT NULL, state TEXT NOT NULL, binding_id TEXT,
|
|
25
|
+
detail TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(message_id, attempt_id)
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
28
|
+
message_id TEXT NOT NULL, attempt_id TEXT NOT NULL, payload_json TEXT NOT NULL,
|
|
29
|
+
created_at TEXT NOT NULL, delivered_at TEXT, PRIMARY KEY(message_id, attempt_id)
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS sent (
|
|
32
|
+
message_id TEXT PRIMARY KEY, target_node TEXT NOT NULL, binding_id TEXT NOT NULL, created_at TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
`);
|
|
35
|
+
const now = () => new Date().toISOString();
|
|
36
|
+
const transaction = (work) => {
|
|
37
|
+
database.exec('BEGIN IMMEDIATE');
|
|
38
|
+
try { const result = work(); database.exec('COMMIT'); return result; }
|
|
39
|
+
catch (error) { database.exec('ROLLBACK'); throw error; }
|
|
40
|
+
};
|
|
41
|
+
const shareKey = (share) => [share.spaceId, share.projectId, share.agentId].join('\u0000');
|
|
42
|
+
const shareView = (row) => row && ({ bindingId: row.binding_id, spaceId: row.space_id, projectId: row.project_id, agentId: row.agent_id,
|
|
43
|
+
clients: JSON.parse(row.clients_json), workingDirectory: row.working_directory ?? null, createdAt: row.created_at, revokedAt: row.revoked_at });
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
setting(key) {
|
|
47
|
+
const row = database.prepare('SELECT value_json FROM settings WHERE key = ?').get(key);
|
|
48
|
+
return row ? JSON.parse(row.value_json) : null;
|
|
49
|
+
},
|
|
50
|
+
setSetting(key, value) {
|
|
51
|
+
if (value === null) database.prepare('DELETE FROM settings WHERE key = ?').run(key);
|
|
52
|
+
else database.prepare(`INSERT INTO settings(key, value_json, updated_at) VALUES(?,?,?)
|
|
53
|
+
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`)
|
|
54
|
+
.run(key, JSON.stringify(value), now());
|
|
55
|
+
},
|
|
56
|
+
shares: () => database.prepare('SELECT * FROM shares WHERE revoked_at IS NULL ORDER BY created_at').all().map(shareView),
|
|
57
|
+
share: (bindingId) => shareView(database.prepare('SELECT * FROM shares WHERE binding_id = ? AND revoked_at IS NULL').get(bindingId)),
|
|
58
|
+
// Replaces the owner's share list; a removed share is kept as revoked history.
|
|
59
|
+
replaceShares(shares, idFactory) {
|
|
60
|
+
return transaction(() => {
|
|
61
|
+
const at = now();
|
|
62
|
+
const wanted = new Map(shares.map((share) => [shareKey(share), share]));
|
|
63
|
+
for (const row of database.prepare('SELECT * FROM shares WHERE revoked_at IS NULL').all()) {
|
|
64
|
+
const key = shareKey(shareView(row));
|
|
65
|
+
const next = wanted.get(key);
|
|
66
|
+
if (!next) database.prepare('UPDATE shares SET revoked_at = ? WHERE binding_id = ?').run(at, row.binding_id);
|
|
67
|
+
else {
|
|
68
|
+
database.prepare('UPDATE shares SET clients_json = ?, working_directory = ? WHERE binding_id = ?')
|
|
69
|
+
.run(JSON.stringify(next.clients), next.workingDirectory ?? null, row.binding_id);
|
|
70
|
+
wanted.delete(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const share of wanted.values()) {
|
|
74
|
+
database.prepare(`INSERT INTO shares(binding_id, space_id, project_id, agent_id, clients_json, working_directory, created_at)
|
|
75
|
+
VALUES(?,?,?,?,?,?,?)`).run(idFactory(), share.spaceId, share.projectId, share.agentId, JSON.stringify(share.clients),
|
|
76
|
+
share.workingDirectory ?? null, at);
|
|
77
|
+
}
|
|
78
|
+
return this.shares();
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
authority(nodeId) {
|
|
82
|
+
const row = database.prepare('SELECT * FROM authorities WHERE node_id = ?').get(nodeId);
|
|
83
|
+
return row ? { keyId: row.key_id, publicKey: row.public_key, boundBy: row.bound_by, boundAt: row.bound_at } : null;
|
|
84
|
+
},
|
|
85
|
+
// A device's origin key is bound once. Only the local owner re-pairing a
|
|
86
|
+
// device (forgetAuthority first) can bind a different key.
|
|
87
|
+
bindAuthority(nodeId, { keyId, publicKey }, boundBy) {
|
|
88
|
+
return transaction(() => {
|
|
89
|
+
const existing = this.authority(nodeId);
|
|
90
|
+
if (existing) return { bound: existing.keyId === keyId && existing.publicKey === publicKey, authority: existing };
|
|
91
|
+
database.prepare('INSERT INTO authorities(node_id, key_id, public_key, bound_by, bound_at) VALUES(?,?,?,?,?)')
|
|
92
|
+
.run(nodeId, keyId, publicKey, boundBy, now());
|
|
93
|
+
return { bound: true, authority: this.authority(nodeId) };
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
forgetAuthority: (nodeId) => database.prepare('DELETE FROM authorities WHERE node_id = ?').run(nodeId),
|
|
97
|
+
forgetAuthorities: () => database.prepare('DELETE FROM authorities').run(),
|
|
98
|
+
attemptsFor: (messageId) => database.prepare('SELECT * FROM attempts WHERE message_id = ? ORDER BY created_at').all(messageId),
|
|
99
|
+
recordAttempt({ messageId, attemptId, bindingId }) {
|
|
100
|
+
return transaction(() => {
|
|
101
|
+
const existing = database.prepare('SELECT * FROM attempts WHERE message_id = ? AND attempt_id = ?').get(messageId, attemptId);
|
|
102
|
+
if (existing) return { inserted: false, attempt: existing };
|
|
103
|
+
const at = now();
|
|
104
|
+
database.prepare(`INSERT INTO attempts(message_id, attempt_id, state, binding_id, created_at, updated_at)
|
|
105
|
+
VALUES(?,?, 'claimed', ?,?,?)`).run(messageId, attemptId, bindingId ?? null, at, at);
|
|
106
|
+
return { inserted: true, attempt: database.prepare('SELECT * FROM attempts WHERE message_id = ? AND attempt_id = ?').get(messageId, attemptId) };
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
setAttemptState: (messageId, attemptId, state, detail = null) => database.prepare(
|
|
110
|
+
'UPDATE attempts SET state = ?, detail = coalesce(?, detail), updated_at = ? WHERE message_id = ? AND attempt_id = ?')
|
|
111
|
+
.run(state, detail, now(), messageId, attemptId),
|
|
112
|
+
unfinishedAttempts: () => database.prepare(`SELECT a.* FROM attempts a WHERE a.state IN ('claimed','accepted','starting','running')
|
|
113
|
+
AND NOT EXISTS (SELECT 1 FROM outbox o WHERE o.message_id = a.message_id AND o.attempt_id = a.attempt_id)`).all(),
|
|
114
|
+
queueResult(messageId, attemptId, payload) {
|
|
115
|
+
database.prepare(`INSERT INTO outbox(message_id, attempt_id, payload_json, created_at) VALUES(?,?,?,?)
|
|
116
|
+
ON CONFLICT(message_id, attempt_id) DO NOTHING`).run(messageId, attemptId, JSON.stringify(payload), now());
|
|
117
|
+
},
|
|
118
|
+
pendingResults: () => database.prepare('SELECT * FROM outbox WHERE delivered_at IS NULL ORDER BY created_at').all()
|
|
119
|
+
.map((row) => ({ messageId: row.message_id, attemptId: row.attempt_id, payload: JSON.parse(row.payload_json) })),
|
|
120
|
+
markDelivered: (messageId, attemptId) => database.prepare(
|
|
121
|
+
'UPDATE outbox SET delivered_at = ? WHERE message_id = ? AND attempt_id = ?').run(now(), messageId, attemptId),
|
|
122
|
+
recordSent: ({ messageId, targetNode, bindingId }) => database.prepare(
|
|
123
|
+
'INSERT OR IGNORE INTO sent(message_id, target_node, binding_id, created_at) VALUES(?,?,?,?)').run(messageId, targetNode, bindingId, now()),
|
|
124
|
+
sent: (messageId) => database.prepare('SELECT * FROM sent WHERE message_id = ?').get(messageId) ?? null,
|
|
125
|
+
close: () => database.close(),
|
|
126
|
+
};
|
|
127
|
+
}
|