agent-credential-store.mjs
317 lines 9.9 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Phase C + Lane D — durable store for scoped REST agent credentials.
3 * Netlify: dedicated blob `gateway-agent-credentials` (not refresh-tokens-v1).
4 * Dev/test: JSON file under KNOWTATION_GATEWAY_DATA_DIR.
5 */
6
7 import fs from 'fs/promises';
8 import path from 'path';
9 import { fileURLToPath } from 'url';
10 import {
11 mintCredential,
12 verifyCredential,
13 revokeCredential,
14 rotateCredential,
15 listCredentialsForSub,
16 } from '../lib/agent-credential-core.mjs';
17
18 const BLOB_KEY = 'agent-credentials-v1';
19 const META_BLOB_KEY = 'agent-credentials-v1-meta';
20 const BLOB_GLOBAL = '__knowtation_gateway_agent_cred_blob';
21 export const AGENT_CREDENTIAL_STORE_INCONSISTENT = 'AGENT_CREDENTIAL_STORE_INCONSISTENT';
22 export const AGENT_CREDENTIAL_STORE_UNAVAILABLE = 'AGENT_CREDENTIAL_STORE_UNAVAILABLE';
23
24 let projectRoot;
25 try {
26 const __dirname = path.dirname(fileURLToPath(import.meta.url));
27 projectRoot = path.resolve(__dirname, '..', '..');
28 } catch (_) {
29 projectRoot = process.cwd();
30 }
31
32 function isNetlify() {
33 return typeof process.env.NETLIFY === 'string' && process.env.NETLIFY.length > 0;
34 }
35
36 function credentialFilePath() {
37 const dataDir = process.env.KNOWTATION_GATEWAY_DATA_DIR || path.join(projectRoot, 'data');
38 return path.join(dataDir, 'hosted_agent_credentials.json');
39 }
40
41 function metaFilePath() {
42 const dataDir = process.env.KNOWTATION_GATEWAY_DATA_DIR || path.join(projectRoot, 'data');
43 return path.join(dataDir, 'hosted_agent_credentials.meta.json');
44 }
45
46 function emptyEnvelope() {
47 return {
48 schema_version: 1,
49 credentials: {},
50 wipe_required: false,
51 wipe_reason: null,
52 wipe_set_at: null,
53 };
54 }
55
56 function wrapStoreError(e, fallbackCode = AGENT_CREDENTIAL_STORE_UNAVAILABLE) {
57 if (e && e.code === AGENT_CREDENTIAL_STORE_INCONSISTENT) return e;
58 const err = new Error(e && e.message ? e.message : 'agent credential store I/O failed');
59 err.code = e && e.code === AGENT_CREDENTIAL_STORE_UNAVAILABLE ? AGENT_CREDENTIAL_STORE_UNAVAILABLE : fallbackCode;
60 return err;
61 }
62
63 function inconsistentError(message = 'agent credential store inconsistent') {
64 const err = new Error(message);
65 err.code = AGENT_CREDENTIAL_STORE_INCONSISTENT;
66 return err;
67 }
68
69 /**
70 * @returns {{ kind: 'blob', store: object } | { kind: 'file' }}
71 */
72 function resolveStorageBackend() {
73 if (isNetlify()) {
74 const store = globalThis[BLOB_GLOBAL];
75 if (!store) {
76 const err = new Error('agent credential blob global missing on Netlify');
77 err.code = AGENT_CREDENTIAL_STORE_UNAVAILABLE;
78 throw err;
79 }
80 return { kind: 'blob', store };
81 }
82 const store = globalThis[BLOB_GLOBAL];
83 if (store) return { kind: 'blob', store };
84 return { kind: 'file' };
85 }
86
87 function normalizeCredentialRecords(credentials) {
88 const out = {};
89 if (!credentials || typeof credentials !== 'object') return out;
90 for (const [id, rec] of Object.entries(credentials)) {
91 if (typeof id === 'string' && rec && typeof rec === 'object' && typeof rec.token_hash === 'string') {
92 out[id] = rec;
93 }
94 }
95 return out;
96 }
97
98 /**
99 * @param {unknown} raw
100 */
101 function normalizeEnvelope(raw) {
102 if (!raw || typeof raw !== 'object') return emptyEnvelope();
103 const credentials =
104 raw.credentials && typeof raw.credentials === 'object'
105 ? normalizeCredentialRecords(raw.credentials)
106 : normalizeCredentialRecords(raw);
107 return {
108 schema_version: raw.schema_version === 1 ? 1 : 1,
109 credentials,
110 wipe_required: Boolean(raw.wipe_required),
111 wipe_reason: raw.wipe_reason == null ? null : String(raw.wipe_reason).slice(0, 128),
112 wipe_set_at: Number.isFinite(raw.wipe_set_at) ? raw.wipe_set_at : null,
113 };
114 }
115
116 /**
117 * @param {unknown} raw
118 */
119 function normalizeMeta(raw) {
120 if (!raw || typeof raw !== 'object' || raw.schema_version !== 1) return null;
121 return {
122 schema_version: 1,
123 nonempty_seen: Boolean(raw.nonempty_seen),
124 count: Number.isFinite(raw.count) ? raw.count : 0,
125 updated_at: Number.isFinite(raw.updated_at) ? raw.updated_at : 0,
126 };
127 }
128
129 async function readMeta(backend) {
130 try {
131 if (backend.kind === 'blob') {
132 const raw = await backend.store.get(META_BLOB_KEY, { type: 'json' });
133 return raw == null ? null : normalizeMeta(raw);
134 }
135 try {
136 const text = await fs.readFile(metaFilePath(), 'utf8');
137 return normalizeMeta(JSON.parse(text));
138 } catch (e) {
139 if (e && e.code === 'ENOENT') return null;
140 throw wrapStoreError(e);
141 }
142 } catch (e) {
143 throw wrapStoreError(e);
144 }
145 }
146
147 async function writeMeta(backend, count, updatedAt) {
148 const payload = {
149 schema_version: 1,
150 nonempty_seen: true,
151 count,
152 updated_at: updatedAt,
153 };
154 if (backend.kind === 'blob') {
155 await backend.store.setJSON(META_BLOB_KEY, payload);
156 return;
157 }
158 const filePath = metaFilePath();
159 await fs.mkdir(path.dirname(filePath), { recursive: true });
160 const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
161 await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 });
162 await fs.rename(tmpPath, filePath);
163 }
164
165 function assertNotInconsistent(envelope, meta) {
166 const count = Object.keys(envelope.credentials || {}).length;
167 if (meta && meta.nonempty_seen && count === 0) {
168 throw inconsistentError();
169 }
170 }
171
172 async function readEnvelope(backend, meta) {
173 try {
174 if (backend.kind === 'blob') {
175 const raw = await backend.store.get(BLOB_KEY, { type: 'json' });
176 if (raw == null) {
177 const envelope = emptyEnvelope();
178 assertNotInconsistent(envelope, meta);
179 return envelope;
180 }
181 const envelope = normalizeEnvelope(raw);
182 assertNotInconsistent(envelope, meta);
183 return envelope;
184 }
185 try {
186 const text = await fs.readFile(credentialFilePath(), 'utf8');
187 const envelope = normalizeEnvelope(JSON.parse(text));
188 assertNotInconsistent(envelope, meta);
189 return envelope;
190 } catch (e) {
191 if (e && e.code === AGENT_CREDENTIAL_STORE_INCONSISTENT) throw e;
192 if (e && e.code === 'ENOENT') {
193 if (meta && meta.nonempty_seen) throw inconsistentError();
194 return emptyEnvelope();
195 }
196 throw wrapStoreError(e);
197 }
198 } catch (e) {
199 if (e && e.code === AGENT_CREDENTIAL_STORE_INCONSISTENT) throw e;
200 throw wrapStoreError(e);
201 }
202 }
203
204 async function load() {
205 const backend = resolveStorageBackend();
206 const meta = await readMeta(backend);
207 const envelope = await readEnvelope(backend, meta);
208 return { envelope, meta, backend };
209 }
210
211 async function save(envelope, backend, meta) {
212 const credentials = envelope.credentials || {};
213 const count = Object.keys(credentials).length;
214 if (meta && meta.nonempty_seen && count === 0) {
215 throw inconsistentError();
216 }
217
218 const payload = {
219 schema_version: 1,
220 credentials,
221 wipe_required: Boolean(envelope.wipe_required),
222 wipe_reason: envelope.wipe_reason == null ? null : String(envelope.wipe_reason).slice(0, 128),
223 wipe_set_at: Number.isFinite(envelope.wipe_set_at) ? envelope.wipe_set_at : null,
224 };
225
226 try {
227 if (backend.kind === 'blob') {
228 await backend.store.setJSON(BLOB_KEY, payload);
229 } else {
230 const filePath = credentialFilePath();
231 await fs.mkdir(path.dirname(filePath), { recursive: true });
232 const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
233 await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 });
234 await fs.rename(tmpPath, filePath);
235 }
236 if (count > 0) {
237 await writeMeta(backend, count, Date.now());
238 }
239 } catch (e) {
240 throw wrapStoreError(e);
241 }
242 }
243
244 /**
245 * @returns {{
246 * mint: Function,
247 * verify: Function,
248 * revoke: Function,
249 * rotate: Function,
250 * list: Function,
251 * }}
252 */
253 export function createAgentCredentialStore() {
254 return {
255 mint: async (opts) => {
256 const { envelope, meta, backend } = await load();
257 const result = mintCredential(envelope.credentials, opts);
258 await save({ ...envelope, credentials: result.records }, backend, meta);
259 return {
260 credential: result.credential,
261 id: result.id,
262 name: result.record.name,
263 vault_ids: result.record.vault_ids,
264 scopes: result.record.scopes,
265 expires_at: result.record.expires_at,
266 created_at: result.record.created_at,
267 };
268 },
269 verify: async (credential, opts = {}) => {
270 const { envelope, meta, backend } = await load();
271 const result = verifyCredential(envelope.credentials, credential, opts);
272 try {
273 if (result.ok) {
274 await save({ ...envelope, credentials: result.records }, backend, meta);
275 } else if (result.records) {
276 await save({ ...envelope, credentials: result.records }, backend, meta);
277 }
278 } catch (saveErr) {
279 if (!result.ok && result.records) return result;
280 throw saveErr;
281 }
282 return result;
283 },
284 revoke: async (cid, sub) => {
285 const { envelope, meta, backend } = await load();
286 const result = revokeCredential(envelope.credentials, cid, sub);
287 if (result.revoked) await save({ ...envelope, credentials: result.records }, backend, meta);
288 return { ok: true, revoked: result.revoked };
289 },
290 rotate: async (cid, sub) => {
291 const { envelope, meta, backend } = await load();
292 const result = rotateCredential(envelope.credentials, cid, sub);
293 await save({ ...envelope, credentials: result.records }, backend, meta);
294 return {
295 credential: result.credential,
296 id: result.id,
297 name: result.record.name,
298 vault_ids: result.record.vault_ids,
299 scopes: result.record.scopes,
300 expires_at: result.record.expires_at,
301 created_at: result.record.created_at,
302 };
303 },
304 list: async (sub) => {
305 const { envelope } = await load();
306 return {
307 credentials: listCredentialsForSub(envelope.credentials, sub),
308 store: {
309 wipe_required: Boolean(envelope.wipe_required),
310 inconsistent: false,
311 },
312 };
313 },
314 };
315 }
316
317 export { BLOB_GLOBAL, BLOB_KEY, META_BLOB_KEY };
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago