delegation-blob-store.mjs
194 lines 6.3 KB
Raw
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor ⚠ breaking 2 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 import {
18 delegationAuthorityMarkerBlobKey,
19 delegationAuthorityMarkerFileName,
20 } from '../../lib/agent/delegation-authority-compat.mjs';
21
22 /** @typedef {{ get: (key: string, opts?: { type?: string }) => Promise<string|ArrayBuffer|null>, set: (key: string, value: string) => Promise<void> }} BlobStore */
23
24 export const DELEGATION_BLOB_FILES = [
25 DELEGATION_POLICY_FILE,
26 DELEGATION_IDENTITIES_FILE,
27 DELEGATION_CONSENTS_FILE,
28 DELEGATION_GRANTS_FILE,
29 DELEGATION_AUDIT_FILE,
30 ];
31
32 /**
33 * @param {string} filename
34 * @returns {string}
35 */
36 export function delegationBlobKey(filename) {
37 return `delegation/${filename}`;
38 }
39
40 /**
41 * Merge local and blob grants stores without losing fresher mints or revocations.
42 * Warm bridge lambdas may mint/revoke on disk before the blob read in the next handler
43 * reflects that write; blind overwrite caused flaky E2/E5 smokes.
44 *
45 * @param {string} localRaw
46 * @param {string} blobRaw
47 * @returns {string}
48 */
49 export function mergeGrantsStoreJson(localRaw, blobRaw) {
50 const parse = (raw) => {
51 if (typeof raw !== 'string' || !raw.trim()) return null;
52 try {
53 return JSON.parse(raw);
54 } catch {
55 return null;
56 }
57 };
58
59 const local = parse(localRaw);
60 const blob = parse(blobRaw);
61 if (!local && !blob) return blobRaw || localRaw || '';
62 if (!local) return blobRaw;
63 if (!blob) return localRaw;
64
65 /** @param {Record<string, unknown>} grant */
66 const grantScore = (grant) => (grant.revoked_at ? 2 : 1);
67
68 /** @param {Record<string, unknown>} a @param {Record<string, unknown>} b */
69 const pickGrant = (a, b) => {
70 const scoreA = grantScore(a);
71 const scoreB = grantScore(b);
72 if (scoreA !== scoreB) return scoreA > scoreB ? a : b;
73 const timeA = Date.parse(String(a.issued_at || '')) || 0;
74 const timeB = Date.parse(String(b.issued_at || '')) || 0;
75 return timeB >= timeA ? b : a;
76 };
77
78 if (!local.vaults) local.vaults = {};
79 for (const [vaultId, blobVault] of Object.entries(blob.vaults || {})) {
80 const localVault = local.vaults[vaultId];
81 if (!localVault || !Array.isArray(localVault.grants)) {
82 local.vaults[vaultId] = blobVault;
83 continue;
84 }
85 /** @type {Map<string, Record<string, unknown>>} */
86 const byId = new Map();
87 for (const grant of localVault.grants) {
88 if (grant && typeof grant.grant_id === 'string') byId.set(grant.grant_id, grant);
89 }
90 for (const grant of blobVault.grants || []) {
91 if (!grant || typeof grant.grant_id !== 'string') continue;
92 const existing = byId.get(grant.grant_id);
93 byId.set(grant.grant_id, existing ? pickGrant(existing, grant) : grant);
94 }
95 local.vaults[vaultId] = { grants: [...byId.values()] };
96 }
97
98 return JSON.stringify(local);
99 }
100
101 /**
102 * Load delegation store files from Blobs into DATA_DIR (hosted cold-start hydration).
103 *
104 * @param {BlobStore|null|undefined} blobStore
105 * @param {string} dataDir
106 */
107 export async function hydrateDelegationStoresFromBlob(blobStore, dataDir) {
108 if (!blobStore || typeof blobStore.get !== 'function') return;
109 fs.mkdirSync(dataDir, { recursive: true });
110 for (const filename of DELEGATION_BLOB_FILES) {
111 const fp = path.join(dataDir, filename);
112 try {
113 const raw = await blobStore.get(delegationBlobKey(filename), { type: 'text' });
114 if (typeof raw === 'string' && raw.trim()) {
115 if (filename === DELEGATION_GRANTS_FILE && fs.existsSync(fp)) {
116 const localRaw = fs.readFileSync(fp, 'utf8');
117 const merged = mergeGrantsStoreJson(localRaw, raw);
118 if (merged.trim()) {
119 fs.writeFileSync(fp, merged, 'utf8');
120 }
121 } else {
122 fs.writeFileSync(fp, raw, 'utf8');
123 }
124 }
125 } catch {
126 /* keep existing file or empty */
127 }
128 }
129
130 try {
131 const vaultIds = new Set(['default', 'Business']);
132 for (const vaultId of vaultIds) {
133 const markerKey = delegationAuthorityMarkerBlobKey(vaultId);
134 const markerRaw = await blobStore.get(markerKey, { type: 'text' });
135 if (typeof markerRaw !== 'string' || !markerRaw.trim()) continue;
136 fs.writeFileSync(path.join(dataDir, delegationAuthorityMarkerFileName(vaultId)), markerRaw, 'utf8');
137 let marker;
138 try {
139 marker = JSON.parse(markerRaw);
140 } catch {
141 continue;
142 }
143 if (marker && typeof marker.envelope_key === 'string' && marker.envelope_key.trim()) {
144 const envelopeRaw = await blobStore.get(marker.envelope_key, { type: 'text' });
145 if (typeof envelopeRaw === 'string' && envelopeRaw.trim()) {
146 const localEnvelopeName = path.basename(marker.envelope_key);
147 fs.writeFileSync(path.join(dataDir, localEnvelopeName), envelopeRaw, 'utf8');
148 }
149 }
150 }
151 } catch {
152 /* non-fatal — legacy stores remain authoritative when marker hydration fails */
153 }
154 }
155
156 /**
157 * Write delegation store files from DATA_DIR to Blobs after a mutation.
158 *
159 * @param {BlobStore|null|undefined} blobStore
160 * @param {string} dataDir
161 */
162 export async function persistDelegationStoresToBlob(blobStore, dataDir) {
163 if (!blobStore || typeof blobStore.set !== 'function') return;
164 for (const filename of DELEGATION_BLOB_FILES) {
165 const fp = path.join(dataDir, filename);
166 if (!fs.existsSync(fp)) continue;
167 try {
168 const raw = fs.readFileSync(fp, 'utf8');
169 if (raw.trim()) {
170 await blobStore.set(delegationBlobKey(filename), raw);
171 }
172 } catch {
173 /* non-fatal */
174 }
175 }
176 }
177
178 /**
179 * Run a delegation mutation with hosted Blob hydrate/persist when available.
180 *
181 * @template T
182 * @param {{
183 * blobStore: BlobStore|null|undefined,
184 * dataDir: string,
185 * run: () => T | Promise<T>,
186 * }} opts
187 * @returns {Promise<T>}
188 */
189 export async function withDelegationBlobSync(opts) {
190 await hydrateDelegationStoresFromBlob(opts.blobStore, opts.dataDir);
191 const result = await opts.run();
192 await persistDelegationStoresToBlob(opts.blobStore, opts.dataDir);
193 return result;
194 }
File History 1 commit
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor 2 days ago