agent-credential-store.mjs
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