external-agent-protocol-store.mjs
79 lines 2.9 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 import fs from 'fs';
2 import path from 'path';
3
4 export const PROTOCOL_IDEMPOTENCY_FILE = 'hub_external_protocol_idempotency.json';
5 export const PROTOCOL_OPS_LOG_FILE = 'hub_external_protocol_ops.json';
6
7 function loadIdempotencyStore(dataDir) {
8 const fp = path.join(dataDir, PROTOCOL_IDEMPOTENCY_FILE);
9 if (!fs.existsSync(fp)) return { vaults: {} };
10 try {
11 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
12 if (!j || typeof j !== 'object') return { vaults: {} };
13 return { vaults: j.vaults && typeof j.vaults === 'object' ? j.vaults : {} };
14 } catch {
15 return { vaults: {} };
16 }
17 }
18
19 function saveIdempotencyStore(dataDir, store) {
20 const fp = path.join(dataDir, PROTOCOL_IDEMPOTENCY_FILE);
21 fs.mkdirSync(path.dirname(fp), { recursive: true });
22 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
23 }
24
25 export async function checkIdempotency(dataDir, vaultId, taskId, verb, idempotencyKey) {
26 if (!idempotencyKey) return null;
27 const store = loadIdempotencyStore(dataDir);
28 const vault = store.vaults[vaultId];
29 if (!vault || !Array.isArray(vault.entries)) return null;
30
31 const now = Date.now();
32 // Cleanup expired entries while we're here
33 vault.entries = vault.entries.filter(e => new Date(e.expires_at).getTime() > now - 86400000);
34
35 const keyMatch = vault.entries.find(e => e.key === `${taskId}#${verb}#${idempotencyKey}`);
36 if (keyMatch) {
37 if (new Date(keyMatch.expires_at).getTime() > now) {
38 return { conflict: false, response: { ok: true, status: keyMatch.status, ...keyMatch.body } };
39 }
40 }
41
42 // Check if same task + verb but different key has succeeded
43 const actionMatch = vault.entries.find(e => e.key.startsWith(`${taskId}#${verb}#`) && e.key !== `${taskId}#${verb}#${idempotencyKey}`);
44 if (actionMatch && new Date(actionMatch.expires_at).getTime() > now && actionMatch.status >= 200 && actionMatch.status < 300) {
45 return { conflict: true };
46 }
47
48 return null;
49 }
50
51 export async function saveIdempotency(dataDir, vaultId, taskId, verb, idempotencyKey, result, ttlSeconds) {
52 if (!idempotencyKey) return;
53 const store = loadIdempotencyStore(dataDir);
54 if (!store.vaults[vaultId]) store.vaults[vaultId] = { entries: [] };
55
56 const now = new Date();
57 const expiresAt = new Date(now.getTime() + (ttlSeconds * 1000)).toISOString();
58
59 const entry = {
60 key: `${taskId}#${verb}#${idempotencyKey}`,
61 verb,
62 status: result.status || 200,
63 body: result.body || {},
64 expires_at: expiresAt,
65 created: now.toISOString()
66 };
67
68 // Remove older entry with same key if exists
69 store.vaults[vaultId].entries = store.vaults[vaultId].entries.filter(e => e.key !== entry.key);
70 store.vaults[vaultId].entries.push(entry);
71
72 saveIdempotencyStore(dataDir, store);
73 }
74
75 export function appendOperationalLog(dataDir, entry) {
76 const fp = path.join(dataDir, PROTOCOL_OPS_LOG_FILE);
77 fs.mkdirSync(path.dirname(fp), { recursive: true });
78 fs.appendFileSync(fp, JSON.stringify(entry) + '\n', 'utf8');
79 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago