delegation-blob-store.mjs
165 lines 5.1 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 /**
2 * Hosted bridge: persist delegation index JSON in Netlify Blobs (7C-L1c).
3 *
4 * Self-hosted bridge uses DATA_DIR files only. On Netlify, DATA_DIR is ephemeral;
5 * this module hydrates files from Blobs before delegation handlers and persists after writes.
6 */
7
8 import fs from 'fs';
9 import path from 'path';
10 import {
11 DELEGATION_POLICY_FILE,
12 DELEGATION_IDENTITIES_FILE,
13 DELEGATION_CONSENTS_FILE,
14 DELEGATION_GRANTS_FILE,
15 DELEGATION_AUDIT_FILE,
16 } from '../../lib/agent/delegation.mjs';
17
18 /** @typedef {{ get: (key: string, opts?: { type?: string }) => Promise<string|ArrayBuffer|null>, set: (key: string, value: string) => Promise<void> }} BlobStore */
19
20 export const DELEGATION_BLOB_FILES = [
21 DELEGATION_POLICY_FILE,
22 DELEGATION_IDENTITIES_FILE,
23 DELEGATION_CONSENTS_FILE,
24 DELEGATION_GRANTS_FILE,
25 DELEGATION_AUDIT_FILE,
26 ];
27
28 /**
29 * @param {string} filename
30 * @returns {string}
31 */
32 export function delegationBlobKey(filename) {
33 return `delegation/${filename}`;
34 }
35
36 /**
37 * Merge local and blob grants stores without losing fresher mints or revocations.
38 * Warm bridge lambdas may mint/revoke on disk before the blob read in the next handler
39 * reflects that write; blind overwrite caused flaky E2/E5 smokes.
40 *
41 * @param {string} localRaw
42 * @param {string} blobRaw
43 * @returns {string}
44 */
45 export function mergeGrantsStoreJson(localRaw, blobRaw) {
46 const parse = (raw) => {
47 if (typeof raw !== 'string' || !raw.trim()) return null;
48 try {
49 return JSON.parse(raw);
50 } catch {
51 return null;
52 }
53 };
54
55 const local = parse(localRaw);
56 const blob = parse(blobRaw);
57 if (!local && !blob) return blobRaw || localRaw || '';
58 if (!local) return blobRaw;
59 if (!blob) return localRaw;
60
61 /** @param {Record<string, unknown>} grant */
62 const grantScore = (grant) => (grant.revoked_at ? 2 : 1);
63
64 /** @param {Record<string, unknown>} a @param {Record<string, unknown>} b */
65 const pickGrant = (a, b) => {
66 const scoreA = grantScore(a);
67 const scoreB = grantScore(b);
68 if (scoreA !== scoreB) return scoreA > scoreB ? a : b;
69 const timeA = Date.parse(String(a.issued_at || '')) || 0;
70 const timeB = Date.parse(String(b.issued_at || '')) || 0;
71 return timeB >= timeA ? b : a;
72 };
73
74 if (!local.vaults) local.vaults = {};
75 for (const [vaultId, blobVault] of Object.entries(blob.vaults || {})) {
76 const localVault = local.vaults[vaultId];
77 if (!localVault || !Array.isArray(localVault.grants)) {
78 local.vaults[vaultId] = blobVault;
79 continue;
80 }
81 /** @type {Map<string, Record<string, unknown>>} */
82 const byId = new Map();
83 for (const grant of localVault.grants) {
84 if (grant && typeof grant.grant_id === 'string') byId.set(grant.grant_id, grant);
85 }
86 for (const grant of blobVault.grants || []) {
87 if (!grant || typeof grant.grant_id !== 'string') continue;
88 const existing = byId.get(grant.grant_id);
89 byId.set(grant.grant_id, existing ? pickGrant(existing, grant) : grant);
90 }
91 local.vaults[vaultId] = { grants: [...byId.values()] };
92 }
93
94 return JSON.stringify(local);
95 }
96
97 /**
98 * Load delegation store files from Blobs into DATA_DIR (hosted cold-start hydration).
99 *
100 * @param {BlobStore|null|undefined} blobStore
101 * @param {string} dataDir
102 */
103 export async function hydrateDelegationStoresFromBlob(blobStore, dataDir) {
104 if (!blobStore || typeof blobStore.get !== 'function') return;
105 fs.mkdirSync(dataDir, { recursive: true });
106 for (const filename of DELEGATION_BLOB_FILES) {
107 const fp = path.join(dataDir, filename);
108 try {
109 const raw = await blobStore.get(delegationBlobKey(filename), { type: 'text' });
110 if (typeof raw === 'string' && raw.trim()) {
111 if (filename === DELEGATION_GRANTS_FILE && fs.existsSync(fp)) {
112 const localRaw = fs.readFileSync(fp, 'utf8');
113 const merged = mergeGrantsStoreJson(localRaw, raw);
114 if (merged.trim()) {
115 fs.writeFileSync(fp, merged, 'utf8');
116 }
117 } else {
118 fs.writeFileSync(fp, raw, 'utf8');
119 }
120 }
121 } catch {
122 /* keep existing file or empty */
123 }
124 }
125 }
126
127 /**
128 * Write delegation store files from DATA_DIR to Blobs after a mutation.
129 *
130 * @param {BlobStore|null|undefined} blobStore
131 * @param {string} dataDir
132 */
133 export async function persistDelegationStoresToBlob(blobStore, dataDir) {
134 if (!blobStore || typeof blobStore.set !== 'function') return;
135 for (const filename of DELEGATION_BLOB_FILES) {
136 const fp = path.join(dataDir, filename);
137 if (!fs.existsSync(fp)) continue;
138 try {
139 const raw = fs.readFileSync(fp, 'utf8');
140 if (raw.trim()) {
141 await blobStore.set(delegationBlobKey(filename), raw);
142 }
143 } catch {
144 /* non-fatal */
145 }
146 }
147 }
148
149 /**
150 * Run a delegation mutation with hosted Blob hydrate/persist when available.
151 *
152 * @template T
153 * @param {{
154 * blobStore: BlobStore|null|undefined,
155 * dataDir: string,
156 * run: () => T | Promise<T>,
157 * }} opts
158 * @returns {Promise<T>}
159 */
160 export async function withDelegationBlobSync(opts) {
161 await hydrateDelegationStoresFromBlob(opts.blobStore, opts.dataDir);
162 const result = await opts.run();
163 await persistDelegationStoresToBlob(opts.blobStore, opts.dataDir);
164 return result;
165 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago