delegation-authority-store.mjs
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db
docs: activate KNOWTATION- board identity and preserve livi…
Human
minor
⚠ breaking
3 days ago
| 1 | /** |
| 2 | * Transactional DelegationAuthorityStore (RHF-b-KN1). |
| 3 | * |
| 4 | * Hosted writes use Netlify Blobs CAS (`getWithMetadata` + `onlyIfMatch` / `onlyIfNew`). |
| 5 | * Local/tests use a file-backed CAS adapter with the same semantics. |
| 6 | * |
| 7 | * Forbidden: hydrate/mutate/persist blind overwrite for renewal, validate, revoke. |
| 8 | * |
| 9 | * @see ~/scooling/docs/reviews/2026-08-27-retail-helper-finish.md §B2–B7 |
| 10 | */ |
| 11 | |
| 12 | import fs from 'fs'; |
| 13 | import path from 'path'; |
| 14 | import { |
| 15 | createHash, |
| 16 | createHmac, |
| 17 | hkdfSync, |
| 18 | randomBytes, |
| 19 | timingSafeEqual, |
| 20 | } from 'crypto'; |
| 21 | |
| 22 | import { |
| 23 | DELEGATION_AUTHORITY_ENVELOPE_SCHEMA, |
| 24 | DELEGATION_AUTHORITY_MARKER_SCHEMA, |
| 25 | DELEGATION_AUTHORITY_UNAVAILABLE, |
| 26 | computeDelegationAuthorityStateHash, |
| 27 | delegationAuthorityMarkerBlobKey, |
| 28 | delegationAuthorityMarkerFileName, |
| 29 | validateDelegationAuthorityEnvelope, |
| 30 | validateDelegationAuthorityMarker, |
| 31 | } from './delegation-authority-compat.mjs'; |
| 32 | import { getTrustedCatalogIdentity } from './trusted-external-provider-catalog.mjs'; |
| 33 | import { |
| 34 | DELEGATION_CONSENT_SCHEMA, |
| 35 | DELEGATION_GRANT_SCHEMA, |
| 36 | DELEGATION_GRANT_MINT_SCHEMA, |
| 37 | DELEGATION_CONSENTS_FILE, |
| 38 | DELEGATION_GRANTS_FILE, |
| 39 | DELEGATION_IDENTITIES_FILE, |
| 40 | GRANT_BEARER_PREFIX, |
| 41 | GRANT_ID_PREFIX, |
| 42 | hashGrantBearer, |
| 43 | hashPrincipalRef, |
| 44 | grantForClient, |
| 45 | loadConsentsStore, |
| 46 | loadGrantsStore, |
| 47 | loadIdentitiesStore, |
| 48 | } from './delegation.mjs'; |
| 49 | |
| 50 | export const DELEGATION_VALIDATION_SCHEMA = 'knowtation.delegation_validation/v1'; |
| 51 | export const DELEGATION_ERROR_SCHEMA = 'knowtation.delegation_error/v1'; |
| 52 | export const HELPER_ACCESS_SCHEMA = 'knowtation.helper_access/v1'; |
| 53 | |
| 54 | export const DELEGATION_REQUEST_INVALID = 'DELEGATION_REQUEST_INVALID'; |
| 55 | export const DELEGATION_SESSION_REQUIRED = 'DELEGATION_SESSION_REQUIRED'; |
| 56 | export const DELEGATION_HELPER_CONSENT_REQUIRED = 'DELEGATION_HELPER_CONSENT_REQUIRED'; |
| 57 | export const DELEGATION_HELPER_ACTOR_DENIED = 'DELEGATION_HELPER_ACTOR_DENIED'; |
| 58 | export const DELEGATION_AUTHORITY_DENIED = 'DELEGATION_AUTHORITY_DENIED'; |
| 59 | export const DELEGATION_AUTHORITY_CONFLICT = 'DELEGATION_AUTHORITY_CONFLICT'; |
| 60 | export const DELEGATION_HELPER_RENEW_RATE_LIMITED = 'DELEGATION_HELPER_RENEW_RATE_LIMITED'; |
| 61 | export { DELEGATION_AUTHORITY_UNAVAILABLE }; |
| 62 | export const DELEGATION_AUTHORITY_CAPACITY = 'DELEGATION_AUTHORITY_CAPACITY'; |
| 63 | |
| 64 | export const UTC_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/; |
| 65 | |
| 66 | export const RETAIL_ACTOR_ID = 'agent_codex_retail'; |
| 67 | export const RENEW_TTL_SECONDS = 900; |
| 68 | export const RENEW_MAX_ACTIONS = 64; |
| 69 | export const RENEW_RATE_LIMIT = 12; |
| 70 | export const RENEW_RATE_WINDOW_MS = 5 * 60 * 1000; |
| 71 | |
| 72 | export const MAX_CONSENTS = 256; |
| 73 | export const MAX_GRANTS = 3072; |
| 74 | export const MAX_RATE_BUCKETS = 64; |
| 75 | export const MAX_AUDIT_OUTBOX = 128; |
| 76 | export const MAX_ENVELOPE_BYTES = 4 * 1024 * 1024; |
| 77 | export const CAS_MAX_RETRIES = 3; |
| 78 | export const RATE_BUCKET_CLOSED_PRUNE_MS = 10 * 60 * 1000; |
| 79 | |
| 80 | const ENVELOPE_SCHEMA_VERSION = 1; |
| 81 | const MARKER_SCHEMA_VERSION = 1; |
| 82 | const SHA256_PREFIX = 'sha256:'; |
| 83 | |
| 84 | /** |
| 85 | * Read options for authority CAS gets. |
| 86 | * |
| 87 | * Netlify Functions Lambda-compat (`connectLambda`) has no `uncachedEdgeURL`, so |
| 88 | * `consistency: 'strong'` throws `BlobsConsistencyError` and helper-access returns 503 |
| 89 | * while legacy grant list (eventual `get`) still succeeds. Match gateway-auth / |
| 90 | * CONNECT-GITHUB guidance: eventual on Lambda; strong only on persistent hosts. |
| 91 | * |
| 92 | * CAS write safety still comes from `getWithMetadata` etags + `onlyIfMatch` / `onlyIfNew`. |
| 93 | * |
| 94 | * @returns {{ type: 'text', consistency?: 'strong' }} |
| 95 | */ |
| 96 | export function authorityBlobGetOpts() { |
| 97 | const onLambda = Boolean(process.env.NETLIFY || process.env.AWS_LAMBDA_FUNCTION_NAME); |
| 98 | return onLambda |
| 99 | ? Object.freeze({ type: 'text' }) |
| 100 | : Object.freeze({ type: 'text', consistency: 'strong' }); |
| 101 | } |
| 102 | const HKDF_SALT = 'scooling-delegation-authority/v1'; |
| 103 | const HKDF_INFO = 'principal-subject-key'; |
| 104 | |
| 105 | /** |
| 106 | * @param {string} vaultId |
| 107 | * @returns {string} |
| 108 | */ |
| 109 | export function delegationAuthorityEnvelopeBlobKey(vaultId) { |
| 110 | return `delegation/authority/v1/${encodeURIComponent(vaultId)}/envelope`; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * @param {string} vaultId |
| 115 | * @returns {string} |
| 116 | */ |
| 117 | export function delegationAuthorityCandidateBlobKey(vaultId) { |
| 118 | return `delegation/authority/v1/${encodeURIComponent(vaultId)}/candidate`; |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * @param {string} vaultId |
| 123 | * @returns {string} |
| 124 | */ |
| 125 | export function delegationAuthorityEnvelopeFileName(vaultId) { |
| 126 | return `hub_delegation_authority_envelope_${vaultId}.json`; |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * @param {string} vaultId |
| 131 | * @returns {string} |
| 132 | */ |
| 133 | export function delegationAuthorityCandidateFileName(vaultId) { |
| 134 | return `hub_delegation_authority_candidate_${vaultId}.json`; |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * @param {unknown} value |
| 139 | * @returns {boolean} |
| 140 | */ |
| 141 | export function isStrictUtcTimestamp(value) { |
| 142 | if (typeof value !== 'string' || !UTC_TIMESTAMP_RE.test(value)) return false; |
| 143 | const ms = Date.parse(value); |
| 144 | return Number.isFinite(ms); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Optional empty expiry is allowed (no expiry). Nonempty malformed → invalid. |
| 149 | * |
| 150 | * @param {unknown} value |
| 151 | * @returns {{ ok: true, absent: boolean } | { ok: false }} |
| 152 | */ |
| 153 | export function parseOptionalStrictUtc(value) { |
| 154 | if (value == null || value === '') return { ok: true, absent: true }; |
| 155 | if (!isStrictUtcTimestamp(value)) return { ok: false }; |
| 156 | return { ok: true, absent: false }; |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Active when not revoked and not expired at now >= expires_at. |
| 161 | * |
| 162 | * @param {object} consent |
| 163 | * @param {number} [nowMs] |
| 164 | * @returns {boolean} |
| 165 | */ |
| 166 | export function isConsentActiveStrict(consent, nowMs = Date.now()) { |
| 167 | if (!consent || typeof consent !== 'object') return false; |
| 168 | if (consent.revoked_at) return false; |
| 169 | if (consent.expires_at) { |
| 170 | if (!isStrictUtcTimestamp(consent.expires_at)) return false; |
| 171 | if (nowMs >= Date.parse(consent.expires_at)) return false; |
| 172 | } |
| 173 | return true; |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * @param {object} grant |
| 178 | * @param {number} [nowMs] |
| 179 | * @returns {boolean} |
| 180 | */ |
| 181 | export function isGrantActiveStrict(grant, nowMs = Date.now()) { |
| 182 | if (!grant || typeof grant !== 'object') return false; |
| 183 | if (grant.revoked_at) return false; |
| 184 | if (!isStrictUtcTimestamp(grant.expires_at)) return false; |
| 185 | if (nowMs >= Date.parse(grant.expires_at)) return false; |
| 186 | const max = typeof grant.max_actions === 'number' ? grant.max_actions : Infinity; |
| 187 | const count = typeof grant.action_count === 'number' ? grant.action_count : 0; |
| 188 | if (count >= max) return false; |
| 189 | return true; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * @param {string} a |
| 194 | * @param {string} b |
| 195 | * @returns {boolean} |
| 196 | */ |
| 197 | export function constantTimeEqualHexOrString(a, b) { |
| 198 | if (typeof a !== 'string' || typeof b !== 'string') return false; |
| 199 | const ba = Buffer.from(a, 'utf8'); |
| 200 | const bb = Buffer.from(b, 'utf8'); |
| 201 | if (ba.length !== bb.length) return false; |
| 202 | return timingSafeEqual(ba, bb); |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * @param {string} principalRef |
| 207 | * @param {string} actorId |
| 208 | * @returns {string} |
| 209 | */ |
| 210 | export function principalActorKey(principalRef, actorId) { |
| 211 | return `${principalRef}\u0000${actorId}`; |
| 212 | } |
| 213 | |
| 214 | /** |
| 215 | * @param {Buffer|string} ikm |
| 216 | * @returns {Buffer} |
| 217 | */ |
| 218 | export function derivePrincipalSubjectKey(ikm) { |
| 219 | return Buffer.from(hkdfSync('sha256', ikm, HKDF_SALT, HKDF_INFO, 32)); |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * @param {string} value |
| 224 | * @returns {Buffer} |
| 225 | */ |
| 226 | function lengthPrefixedUtf8(value) { |
| 227 | const body = Buffer.from(value, 'utf8'); |
| 228 | const len = Buffer.alloc(4); |
| 229 | len.writeUInt32BE(body.length, 0); |
| 230 | return Buffer.concat([len, body]); |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * HKDF + HMAC principal-bound subject → 43-char unpadded base64url. |
| 235 | * |
| 236 | * @param {{ |
| 237 | * sessionSecret: string, |
| 238 | * sessionSecretPrevious?: string|null, |
| 239 | * uid: string, |
| 240 | * vaultId: string, |
| 241 | * actorId: string, |
| 242 | * }} input |
| 243 | * @returns {{ key_id: string, value: string }[]} |
| 244 | */ |
| 245 | export function buildAuthoritySubjects(input) { |
| 246 | const subjects = []; |
| 247 | const make = (secret, keyId) => { |
| 248 | const key = derivePrincipalSubjectKey(secret); |
| 249 | const h = createHmac('sha256', key); |
| 250 | h.update(Buffer.from('v1', 'utf8')); |
| 251 | h.update(lengthPrefixedUtf8(input.uid)); |
| 252 | h.update(lengthPrefixedUtf8(input.vaultId)); |
| 253 | h.update(lengthPrefixedUtf8(input.actorId)); |
| 254 | subjects.push({ key_id: keyId, value: h.digest('base64url') }); |
| 255 | }; |
| 256 | make(input.sessionSecret, 'current'); |
| 257 | if (typeof input.sessionSecretPrevious === 'string' && input.sessionSecretPrevious.trim()) { |
| 258 | make(input.sessionSecretPrevious.trim(), 'previous'); |
| 259 | } |
| 260 | return subjects; |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * @param {number} [byteLen] |
| 265 | * @returns {string} |
| 266 | */ |
| 267 | function randomIdToken(byteLen = 16) { |
| 268 | return randomBytes(byteLen).toString('base64url').replace(/[^a-z0-9]/gi, '').toLowerCase().slice(0, 24); |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * @param {string} prefix |
| 273 | * @param {number} [byteLen] |
| 274 | * @returns {string} |
| 275 | */ |
| 276 | function randomPrefixedId(prefix, byteLen = 16) { |
| 277 | const token = randomIdToken(byteLen); |
| 278 | return prefix + (token.length >= 8 ? token : token.padEnd(8, '0')); |
| 279 | } |
| 280 | |
| 281 | /** |
| 282 | * @param {unknown} err |
| 283 | * @param {number} status |
| 284 | * @param {string} code |
| 285 | * @param {string} [message] |
| 286 | */ |
| 287 | function fail(status, code, message) { |
| 288 | return { |
| 289 | ok: false, |
| 290 | status, |
| 291 | code, |
| 292 | error: message || code, |
| 293 | schema: DELEGATION_ERROR_SCHEMA, |
| 294 | }; |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * In-memory / file CAS backend compatible with Netlify Blobs conditional writes. |
| 299 | */ |
| 300 | export class MemoryCasBlobStore { |
| 301 | constructor() { |
| 302 | /** @type {Map<string, { data: string, etag: string, metadata: object }>} */ |
| 303 | this.map = new Map(); |
| 304 | this._seq = 0; |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * @param {string} key |
| 309 | * @param {{ type?: string, etag?: string }} [opts] |
| 310 | */ |
| 311 | async getWithMetadata(key, opts = {}) { |
| 312 | const entry = this.map.get(key); |
| 313 | if (!entry) return null; |
| 314 | if (opts.etag && opts.etag === entry.etag) { |
| 315 | return { data: null, etag: entry.etag, metadata: entry.metadata }; |
| 316 | } |
| 317 | return { data: entry.data, etag: entry.etag, metadata: entry.metadata }; |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * @param {string} key |
| 322 | * @param {{ type?: string, consistency?: string }} [opts] |
| 323 | */ |
| 324 | async get(key, _opts = {}) { |
| 325 | const entry = this.map.get(key); |
| 326 | if (!entry) return null; |
| 327 | return entry.data; |
| 328 | } |
| 329 | |
| 330 | /** |
| 331 | * @param {string} key |
| 332 | * @param {string} value |
| 333 | * @param {{ onlyIfMatch?: string, onlyIfNew?: boolean }} [opts] |
| 334 | */ |
| 335 | async set(key, value, opts = {}) { |
| 336 | const existing = this.map.get(key); |
| 337 | if (opts.onlyIfNew) { |
| 338 | if (existing) return { modified: false }; |
| 339 | } else if (opts.onlyIfMatch) { |
| 340 | if (!existing || existing.etag !== opts.onlyIfMatch) return { modified: false }; |
| 341 | } |
| 342 | const etag = `etag-${++this._seq}`; |
| 343 | this.map.set(key, { data: value, etag, metadata: {} }); |
| 344 | return { modified: true, etag }; |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * File-backed CAS for local DATA_DIR (etag sidecar). |
| 350 | */ |
| 351 | export class FileCasBlobStore { |
| 352 | /** |
| 353 | * @param {string} dataDir |
| 354 | */ |
| 355 | constructor(dataDir) { |
| 356 | this.dataDir = dataDir; |
| 357 | fs.mkdirSync(dataDir, { recursive: true }); |
| 358 | } |
| 359 | |
| 360 | /** |
| 361 | * @param {string} key |
| 362 | * @returns {string} |
| 363 | */ |
| 364 | _pathFor(key) { |
| 365 | const safe = key.replace(/[/\\]/g, '__'); |
| 366 | return path.join(this.dataDir, `cas_${safe}`); |
| 367 | } |
| 368 | |
| 369 | /** |
| 370 | * @param {string} key |
| 371 | * @returns {string} |
| 372 | */ |
| 373 | _etagPathFor(key) { |
| 374 | return `${this._pathFor(key)}.etag`; |
| 375 | } |
| 376 | |
| 377 | /** |
| 378 | * @param {string} key |
| 379 | * @param {{ type?: string, etag?: string }} [opts] |
| 380 | */ |
| 381 | async getWithMetadata(key, opts = {}) { |
| 382 | const fp = this._pathFor(key); |
| 383 | const ep = this._etagPathFor(key); |
| 384 | if (!fs.existsSync(fp)) return null; |
| 385 | let data; |
| 386 | let etag; |
| 387 | try { |
| 388 | data = fs.readFileSync(fp, 'utf8'); |
| 389 | etag = fs.existsSync(ep) ? fs.readFileSync(ep, 'utf8').trim() : 'etag-0'; |
| 390 | } catch { |
| 391 | return null; |
| 392 | } |
| 393 | if (opts.etag && opts.etag === etag) { |
| 394 | return { data: null, etag, metadata: {} }; |
| 395 | } |
| 396 | return { data, etag, metadata: {} }; |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * @param {string} key |
| 401 | * @param {{ type?: string }} [opts] |
| 402 | */ |
| 403 | async get(key, _opts = {}) { |
| 404 | const got = await this.getWithMetadata(key); |
| 405 | return got ? got.data : null; |
| 406 | } |
| 407 | |
| 408 | /** |
| 409 | * @param {string} key |
| 410 | * @param {string} value |
| 411 | * @param {{ onlyIfMatch?: string, onlyIfNew?: boolean }} [opts] |
| 412 | */ |
| 413 | async set(key, value, opts = {}) { |
| 414 | const fp = this._pathFor(key); |
| 415 | const ep = this._etagPathFor(key); |
| 416 | const exists = fs.existsSync(fp); |
| 417 | let currentEtag = null; |
| 418 | if (exists) { |
| 419 | try { |
| 420 | currentEtag = fs.readFileSync(ep, 'utf8').trim(); |
| 421 | } catch { |
| 422 | currentEtag = 'etag-0'; |
| 423 | } |
| 424 | } |
| 425 | if (opts.onlyIfNew) { |
| 426 | if (exists) return { modified: false }; |
| 427 | } else if (opts.onlyIfMatch) { |
| 428 | if (!exists || currentEtag !== opts.onlyIfMatch) return { modified: false }; |
| 429 | } |
| 430 | const nextEtag = `etag-${Date.now()}-${randomBytes(4).toString('hex')}`; |
| 431 | fs.writeFileSync(fp, value, 'utf8'); |
| 432 | fs.writeFileSync(ep, nextEtag, 'utf8'); |
| 433 | return { modified: true, etag: nextEtag }; |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | /** |
| 438 | * @param {object|null|undefined} blobStore |
| 439 | * @param {string} dataDir |
| 440 | * @returns {{ getWithMetadata: Function, set: Function, get?: Function }} |
| 441 | */ |
| 442 | export function resolveAuthorityCasStore(blobStore, dataDir) { |
| 443 | if ( |
| 444 | blobStore && |
| 445 | typeof blobStore.getWithMetadata === 'function' && |
| 446 | typeof blobStore.set === 'function' |
| 447 | ) { |
| 448 | return blobStore; |
| 449 | } |
| 450 | return new FileCasBlobStore(dataDir); |
| 451 | } |
| 452 | |
| 453 | /** |
| 454 | * @param {object} envelope |
| 455 | * @returns {object} |
| 456 | */ |
| 457 | export function sealEnvelopeStateHash(envelope) { |
| 458 | const next = { ...envelope }; |
| 459 | delete next.state_hash; |
| 460 | next.state_hash = computeDelegationAuthorityStateHash(next); |
| 461 | return next; |
| 462 | } |
| 463 | |
| 464 | /** |
| 465 | * @param {object} envelope |
| 466 | * @returns {{ ok: true } | { ok: false, reason: string }} |
| 467 | */ |
| 468 | export function validateEnvelopeInternalIntegrity(envelope) { |
| 469 | if (!envelope || typeof envelope !== 'object') return { ok: false, reason: 'missing' }; |
| 470 | if (envelope.schema !== DELEGATION_AUTHORITY_ENVELOPE_SCHEMA) { |
| 471 | return { ok: false, reason: 'schema' }; |
| 472 | } |
| 473 | for (const field of [ |
| 474 | 'identities_by_id', |
| 475 | 'consents_by_id', |
| 476 | 'grants_by_id', |
| 477 | 'grant_id_by_bearer_hash', |
| 478 | 'newest_active_consent_id_by_principal_actor', |
| 479 | 'rate_buckets_by_principal_actor', |
| 480 | 'audit_outbox_by_id', |
| 481 | ]) { |
| 482 | if (envelope[field] == null) continue; |
| 483 | if (typeof envelope[field] !== 'object' || Array.isArray(envelope[field])) { |
| 484 | return { ok: false, reason: field }; |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | for (const consent of Object.values(envelope.consents_by_id || {})) { |
| 489 | if (!consent || typeof consent !== 'object') return { ok: false, reason: 'consent' }; |
| 490 | if (!isStrictUtcTimestamp(consent.created)) return { ok: false, reason: 'consent.created' }; |
| 491 | const exp = parseOptionalStrictUtc(consent.expires_at); |
| 492 | if (!exp.ok) return { ok: false, reason: 'consent.expires_at' }; |
| 493 | if (consent.revoked_at != null && consent.revoked_at !== '') { |
| 494 | if (!isStrictUtcTimestamp(consent.revoked_at)) return { ok: false, reason: 'consent.revoked_at' }; |
| 495 | } |
| 496 | } |
| 497 | for (const grant of Object.values(envelope.grants_by_id || {})) { |
| 498 | if (!grant || typeof grant !== 'object') return { ok: false, reason: 'grant' }; |
| 499 | for (const field of ['issued_at', 'expires_at']) { |
| 500 | if (!isStrictUtcTimestamp(grant[field])) return { ok: false, reason: `grant.${field}` }; |
| 501 | } |
| 502 | if (grant.revoked_at != null && grant.revoked_at !== '') { |
| 503 | if (!isStrictUtcTimestamp(grant.revoked_at)) return { ok: false, reason: 'grant.revoked_at' }; |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | const expected = computeDelegationAuthorityStateHash(envelope); |
| 508 | if (expected !== envelope.state_hash) return { ok: false, reason: 'state_hash' }; |
| 509 | return { ok: true }; |
| 510 | } |
| 511 | |
| 512 | /** |
| 513 | * @param {object} envelope |
| 514 | * @returns {boolean} |
| 515 | */ |
| 516 | function underCapacity(envelope) { |
| 517 | const consents = Object.keys(envelope.consents_by_id || {}).length; |
| 518 | const grants = Object.keys(envelope.grants_by_id || {}).length; |
| 519 | const buckets = Object.keys(envelope.rate_buckets_by_principal_actor || {}).length; |
| 520 | const outbox = Object.keys(envelope.audit_outbox_by_id || {}).length; |
| 521 | if (consents > MAX_CONSENTS) return false; |
| 522 | if (grants > MAX_GRANTS) return false; |
| 523 | if (buckets > MAX_RATE_BUCKETS) return false; |
| 524 | if (outbox > MAX_AUDIT_OUTBOX) return false; |
| 525 | const serialized = JSON.stringify(envelope); |
| 526 | if (Buffer.byteLength(serialized, 'utf8') > MAX_ENVELOPE_BYTES) return false; |
| 527 | return true; |
| 528 | } |
| 529 | |
| 530 | /** |
| 531 | * @param {object} entry |
| 532 | * @returns {string} |
| 533 | */ |
| 534 | function hashAuditEvent(entry) { |
| 535 | const body = JSON.stringify({ |
| 536 | operation_id: entry.operation_id, |
| 537 | record_kind: entry.record_kind, |
| 538 | record_id: entry.record_id, |
| 539 | sequence: entry.sequence, |
| 540 | prior_audit_event_hash: entry.prior_audit_event_hash ?? null, |
| 541 | }); |
| 542 | return `${SHA256_PREFIX}${createHash('sha256').update(body, 'utf8').digest('hex')}`; |
| 543 | } |
| 544 | |
| 545 | /** |
| 546 | * Contiguous materializer: only last_materialized + 1 is eligible (freeze §B2). |
| 547 | * Writes external audit key with onlyIfNew; advances last_materialized on success. |
| 548 | * |
| 549 | * @param {object} envelope |
| 550 | * @param {{ getWithMetadata?: Function, set?: Function, get?: Function }} cas |
| 551 | * @returns {Promise<object>} |
| 552 | */ |
| 553 | export async function materializeAuditOutbox(envelope, cas) { |
| 554 | let next = { ...envelope, audit_outbox_by_id: { ...(envelope.audit_outbox_by_id || {}) } }; |
| 555 | const chainHeads = { ...(next.event_chain_heads_by_record || {}) }; |
| 556 | |
| 557 | const records = [ |
| 558 | ...Object.values(next.consents_by_id || {}).map((r) => ({ kind: 'consent', record: r })), |
| 559 | ...Object.values(next.grants_by_id || {}).map((r) => ({ kind: 'grant', record: r })), |
| 560 | ]; |
| 561 | |
| 562 | for (const { kind, record } of records) { |
| 563 | if (!record) continue; |
| 564 | const lastMat = record.last_materialized_audit_sequence || 0; |
| 565 | const targetSeq = lastMat + 1; |
| 566 | if ((record.audit_sequence || 0) < targetSeq) continue; |
| 567 | |
| 568 | const entry = Object.values(next.audit_outbox_by_id).find( |
| 569 | (e) => |
| 570 | e && |
| 571 | e.record_kind === kind && |
| 572 | e.record_id === record[kind === 'consent' ? 'consent_id' : 'grant_id'] && |
| 573 | e.sequence === targetSeq, |
| 574 | ); |
| 575 | if (!entry) continue; // out-of-order later entries remain pending |
| 576 | |
| 577 | const auditKey = `delegation/audit/v1/${entry.operation_id}`; |
| 578 | const eventHash = hashAuditEvent(entry); |
| 579 | const payload = JSON.stringify({ |
| 580 | schema: 'knowtation.delegation_audit_event/v1', |
| 581 | ...entry, |
| 582 | event_hash: eventHash, |
| 583 | }); |
| 584 | |
| 585 | if (cas && typeof cas.set === 'function') { |
| 586 | const existing = await cas.get(auditKey, authorityBlobGetOpts()).catch(() => null); |
| 587 | if (typeof existing === 'string' && existing.trim()) { |
| 588 | if (existing.trim() !== payload) { |
| 589 | // mismatched content blocks materialization for this record |
| 590 | continue; |
| 591 | } |
| 592 | } else { |
| 593 | const write = await cas.set(auditKey, payload, { onlyIfNew: true }); |
| 594 | if (write && write.modified === false) { |
| 595 | const again = await cas.get(auditKey, authorityBlobGetOpts()).catch(() => null); |
| 596 | if (typeof again !== 'string' || again.trim() !== payload) continue; |
| 597 | } |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | const recordKey = kind === 'consent' ? 'consent_id' : 'grant_id'; |
| 602 | const id = record[recordKey]; |
| 603 | if (kind === 'grant') { |
| 604 | next.grants_by_id = { |
| 605 | ...next.grants_by_id, |
| 606 | [id]: { |
| 607 | ...record, |
| 608 | last_materialized_audit_sequence: targetSeq, |
| 609 | pending_audit_count: Math.max(0, (record.pending_audit_count || 1) - 1), |
| 610 | }, |
| 611 | }; |
| 612 | } else { |
| 613 | next.consents_by_id = { |
| 614 | ...next.consents_by_id, |
| 615 | [id]: { |
| 616 | ...record, |
| 617 | last_materialized_audit_sequence: targetSeq, |
| 618 | pending_audit_count: Math.max(0, (record.pending_audit_count || 1) - 1), |
| 619 | }, |
| 620 | }; |
| 621 | } |
| 622 | chainHeads[`${kind}:${id}`] = eventHash; |
| 623 | const outbox = { ...next.audit_outbox_by_id }; |
| 624 | delete outbox[entry.operation_id]; |
| 625 | next.audit_outbox_by_id = outbox; |
| 626 | } |
| 627 | |
| 628 | next.event_chain_heads_by_record = chainHeads; |
| 629 | return next; |
| 630 | } |
| 631 | |
| 632 | /** |
| 633 | * @param {object} record |
| 634 | * @param {string} operationId |
| 635 | * @param {string|null} priorHash |
| 636 | * @returns {{ record: object, outboxEntry: object }} |
| 637 | */ |
| 638 | function bumpRecordAudit(record, operationId, priorHash) { |
| 639 | const audit_sequence = (record.audit_sequence || 0) + 1; |
| 640 | const pending_audit_count = (record.pending_audit_count || 0) + 1; |
| 641 | const last_materialized_audit_sequence = |
| 642 | typeof record.last_materialized_audit_sequence === 'number' |
| 643 | ? record.last_materialized_audit_sequence |
| 644 | : 0; |
| 645 | return { |
| 646 | record: { |
| 647 | ...record, |
| 648 | audit_sequence, |
| 649 | pending_audit_count, |
| 650 | last_materialized_audit_sequence, |
| 651 | }, |
| 652 | outboxEntry: { |
| 653 | operation_id: operationId, |
| 654 | sequence: audit_sequence, |
| 655 | prior_audit_event_hash: priorHash, |
| 656 | }, |
| 657 | }; |
| 658 | } |
| 659 | |
| 660 | /** |
| 661 | * Prune closed rate buckets older than 10 minutes; prune expired grants only when audit-complete. |
| 662 | * |
| 663 | * @param {object} envelope |
| 664 | * @param {number} nowMs |
| 665 | * @returns {object} |
| 666 | */ |
| 667 | export function pruneAuthorityEnvelope(envelope, nowMs = Date.now()) { |
| 668 | const next = structuredClone(envelope); |
| 669 | const buckets = { ...(next.rate_buckets_by_principal_actor || {}) }; |
| 670 | for (const [key, bucket] of Object.entries(buckets)) { |
| 671 | const stamps = Array.isArray(bucket?.renew_at_ms) ? bucket.renew_at_ms : []; |
| 672 | const live = stamps.filter((t) => nowMs - t < RENEW_RATE_WINDOW_MS); |
| 673 | if (live.length === 0) { |
| 674 | const closedAt = typeof bucket?.closed_at_ms === 'number' ? bucket.closed_at_ms : nowMs; |
| 675 | if (nowMs - closedAt >= RATE_BUCKET_CLOSED_PRUNE_MS) { |
| 676 | delete buckets[key]; |
| 677 | continue; |
| 678 | } |
| 679 | buckets[key] = { renew_at_ms: [], closed_at_ms: closedAt }; |
| 680 | } else { |
| 681 | buckets[key] = { renew_at_ms: live }; |
| 682 | } |
| 683 | } |
| 684 | next.rate_buckets_by_principal_actor = buckets; |
| 685 | |
| 686 | const grants = { ...(next.grants_by_id || {}) }; |
| 687 | const byHash = { ...(next.grant_id_by_bearer_hash || {}) }; |
| 688 | const outbox = next.audit_outbox_by_id || {}; |
| 689 | for (const [grantId, grant] of Object.entries(grants)) { |
| 690 | if (isGrantActiveStrict(grant, nowMs)) continue; |
| 691 | const pending = grant.pending_audit_count || 0; |
| 692 | const lastMat = grant.last_materialized_audit_sequence || 0; |
| 693 | const seq = grant.audit_sequence || 0; |
| 694 | if (pending !== 0) continue; |
| 695 | if (lastMat !== seq) continue; |
| 696 | const referenced = Object.values(outbox).some( |
| 697 | (e) => e && e.record_kind === 'grant' && e.record_id === grantId, |
| 698 | ); |
| 699 | if (referenced) continue; |
| 700 | const hash = grant.grant_bearer_hash; |
| 701 | delete grants[grantId]; |
| 702 | if (hash && byHash[hash] === grantId) delete byHash[hash]; |
| 703 | } |
| 704 | next.grants_by_id = grants; |
| 705 | next.grant_id_by_bearer_hash = byHash; |
| 706 | return next; |
| 707 | } |
| 708 | |
| 709 | /** |
| 710 | * Rebuild newest-active consent index. |
| 711 | * |
| 712 | * @param {object} envelope |
| 713 | * @param {number} [nowMs] |
| 714 | * @returns {object} |
| 715 | */ |
| 716 | export function rebuildNewestActiveConsentIndex(envelope, nowMs = Date.now()) { |
| 717 | /** @type {Record<string, string>} */ |
| 718 | const index = {}; |
| 719 | /** @type {Record<string, object[]>} */ |
| 720 | const groups = {}; |
| 721 | for (const consent of Object.values(envelope.consents_by_id || {})) { |
| 722 | if (!isConsentActiveStrict(consent, nowMs)) continue; |
| 723 | if (consent.scope !== 'personal') continue; |
| 724 | const key = principalActorKey(consent.principal_ref, consent.delegate_agent_id); |
| 725 | if (!groups[key]) groups[key] = []; |
| 726 | groups[key].push(consent); |
| 727 | } |
| 728 | for (const [key, list] of Object.entries(groups)) { |
| 729 | list.sort((a, b) => { |
| 730 | const ca = Date.parse(a.created); |
| 731 | const cb = Date.parse(b.created); |
| 732 | if (cb !== ca) return cb - ca; |
| 733 | return String(a.consent_id).localeCompare(String(b.consent_id)); |
| 734 | }); |
| 735 | index[key] = list[0].consent_id; |
| 736 | } |
| 737 | return { ...envelope, newest_active_consent_id_by_principal_actor: index }; |
| 738 | } |
| 739 | |
| 740 | /** |
| 741 | * Select active personal consent for principal+actor (newest created, then consent_id asc). |
| 742 | * |
| 743 | * @param {object} envelope |
| 744 | * @param {string} principalRef |
| 745 | * @param {string} actorId |
| 746 | * @param {number} [nowMs] |
| 747 | * @returns {object|null} |
| 748 | */ |
| 749 | export function selectActivePersonalConsent(envelope, principalRef, actorId, nowMs = Date.now()) { |
| 750 | const key = principalActorKey(principalRef, actorId); |
| 751 | const indexedId = envelope.newest_active_consent_id_by_principal_actor?.[key]; |
| 752 | if (indexedId) { |
| 753 | const c = envelope.consents_by_id?.[indexedId]; |
| 754 | if (c && isConsentActiveStrict(c, nowMs) && c.scope === 'personal') return c; |
| 755 | } |
| 756 | const candidates = Object.values(envelope.consents_by_id || {}).filter( |
| 757 | (c) => |
| 758 | c && |
| 759 | c.principal_ref === principalRef && |
| 760 | c.delegate_agent_id === actorId && |
| 761 | c.scope === 'personal' && |
| 762 | isConsentActiveStrict(c, nowMs), |
| 763 | ); |
| 764 | if (!candidates.length) return null; |
| 765 | candidates.sort((a, b) => { |
| 766 | const ca = Date.parse(a.created); |
| 767 | const cb = Date.parse(b.created); |
| 768 | if (cb !== ca) return cb - ca; |
| 769 | return String(a.consent_id).localeCompare(String(b.consent_id)); |
| 770 | }); |
| 771 | return candidates[0]; |
| 772 | } |
| 773 | |
| 774 | /** |
| 775 | * @param {object} catalogIdentity |
| 776 | * @returns {boolean} |
| 777 | */ |
| 778 | export function isRetailCodexIdentity(catalogIdentity) { |
| 779 | return Boolean( |
| 780 | catalogIdentity && |
| 781 | catalogIdentity.agent_id === RETAIL_ACTOR_ID && |
| 782 | catalogIdentity.kind === 'external_provider' && |
| 783 | catalogIdentity.provider === 'codex' && |
| 784 | catalogIdentity.scope_ceiling === 'personal' && |
| 785 | catalogIdentity.status === 'active', |
| 786 | ); |
| 787 | } |
| 788 | |
| 789 | /** |
| 790 | * @param {{ |
| 791 | * dataDir: string, |
| 792 | * vaultId: string, |
| 793 | * blobStore?: object|null, |
| 794 | * nowMs?: number, |
| 795 | * sessionSecret?: string, |
| 796 | * sessionSecretPrevious?: string|null, |
| 797 | * operatorAuthorizedMarker?: boolean, |
| 798 | * }} opts |
| 799 | */ |
| 800 | export function createDelegationAuthorityStore(opts) { |
| 801 | const dataDir = opts.dataDir; |
| 802 | const vaultId = opts.vaultId; |
| 803 | const cas = resolveAuthorityCasStore(opts.blobStore ?? null, dataDir); |
| 804 | const envelopeKey = delegationAuthorityEnvelopeBlobKey(vaultId); |
| 805 | const candidateKey = delegationAuthorityCandidateBlobKey(vaultId); |
| 806 | const markerKey = delegationAuthorityMarkerBlobKey(vaultId); |
| 807 | const hostedBlob = Boolean(opts.blobStore); |
| 808 | |
| 809 | /** |
| 810 | * @returns {Promise< |
| 811 | * | { ok: true, envelope: object, etag: string|null, mode: 'envelope' } |
| 812 | * | { ok: false, status: number, code: string, error: string, schema: string } |
| 813 | * >} |
| 814 | */ |
| 815 | async function readActiveEnvelope() { |
| 816 | let markerRaw; |
| 817 | try { |
| 818 | markerRaw = await cas.get(markerKey, authorityBlobGetOpts()); |
| 819 | } catch { |
| 820 | // Fail closed — never fall back to a stale local mirror (BV-1). |
| 821 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority marker read error'); |
| 822 | } |
| 823 | if (!markerRaw || (typeof markerRaw === 'string' && !markerRaw.trim())) { |
| 824 | // Local-only FileCas path may still read marker files. Hosted blob: absent = inactive. |
| 825 | if (hostedBlob) { |
| 826 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority envelope not active'); |
| 827 | } |
| 828 | const localMarker = path.join(dataDir, delegationAuthorityMarkerFileName(vaultId)); |
| 829 | if (!fs.existsSync(localMarker)) { |
| 830 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority envelope not active'); |
| 831 | } |
| 832 | let marker; |
| 833 | try { |
| 834 | marker = JSON.parse(fs.readFileSync(localMarker, 'utf8')); |
| 835 | } catch { |
| 836 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority marker unreadable'); |
| 837 | } |
| 838 | const mv = validateDelegationAuthorityMarker(marker, vaultId); |
| 839 | if (!mv.ok) return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, mv.reason); |
| 840 | const localEnvPath = path.join( |
| 841 | dataDir, |
| 842 | path.basename(marker.envelope_key) || delegationAuthorityEnvelopeFileName(vaultId), |
| 843 | ); |
| 844 | if (!fs.existsSync(localEnvPath)) { |
| 845 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority envelope missing'); |
| 846 | } |
| 847 | let envelope; |
| 848 | try { |
| 849 | envelope = JSON.parse(fs.readFileSync(localEnvPath, 'utf8')); |
| 850 | } catch { |
| 851 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority envelope unreadable'); |
| 852 | } |
| 853 | const ev = validateDelegationAuthorityEnvelope(envelope, marker, vaultId); |
| 854 | if (!ev.ok) return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, ev.reason); |
| 855 | const iv = validateEnvelopeInternalIntegrity(envelope); |
| 856 | if (!iv.ok) return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, iv.reason); |
| 857 | // Promote into CAS so subsequent writes always have an etag (no etag:null mutate). |
| 858 | const seeded = await cas.set(envelopeKey, JSON.stringify(envelope), { onlyIfNew: true }); |
| 859 | const got = await cas.getWithMetadata(envelopeKey, authorityBlobGetOpts()).catch(() => null); |
| 860 | return { |
| 861 | ok: true, |
| 862 | envelope, |
| 863 | etag: got?.etag || seeded?.etag || null, |
| 864 | mode: 'envelope', |
| 865 | }; |
| 866 | } |
| 867 | |
| 868 | let marker; |
| 869 | try { |
| 870 | marker = typeof markerRaw === 'string' ? JSON.parse(markerRaw) : markerRaw; |
| 871 | } catch { |
| 872 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority marker parse error'); |
| 873 | } |
| 874 | const mv = validateDelegationAuthorityMarker(marker, vaultId); |
| 875 | if (!mv.ok) return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, mv.reason); |
| 876 | |
| 877 | let got; |
| 878 | try { |
| 879 | got = await cas.getWithMetadata(marker.envelope_key || envelopeKey, authorityBlobGetOpts()); |
| 880 | } catch { |
| 881 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority envelope read error'); |
| 882 | } |
| 883 | if (!got || got.data == null) { |
| 884 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority envelope missing'); |
| 885 | } |
| 886 | let envelope; |
| 887 | try { |
| 888 | envelope = typeof got.data === 'string' ? JSON.parse(got.data) : got.data; |
| 889 | } catch { |
| 890 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority envelope parse error'); |
| 891 | } |
| 892 | const ev = validateDelegationAuthorityEnvelope(envelope, marker, vaultId); |
| 893 | if (!ev.ok) return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, ev.reason); |
| 894 | const iv = validateEnvelopeInternalIntegrity(envelope); |
| 895 | if (!iv.ok) return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, iv.reason); |
| 896 | return { ok: true, envelope, etag: got.etag ?? null, mode: 'envelope' }; |
| 897 | } |
| 898 | |
| 899 | /** |
| 900 | * @param {(envelope: object) => |
| 901 | * | { ok: true, envelope: object, result: object } |
| 902 | * | { ok: false, status: number, code: string, error?: string } |
| 903 | * } transform |
| 904 | */ |
| 905 | async function mutateEnvelope(transform) { |
| 906 | let attempt = 0; |
| 907 | while (attempt < CAS_MAX_RETRIES) { |
| 908 | attempt += 1; |
| 909 | const loaded = await readActiveEnvelope(); |
| 910 | if (!loaded.ok) return loaded; |
| 911 | |
| 912 | let working = pruneAuthorityEnvelope(loaded.envelope, opts.nowMs ?? Date.now()); |
| 913 | working = rebuildNewestActiveConsentIndex(working, opts.nowMs ?? Date.now()); |
| 914 | |
| 915 | const transformed = transform(working); |
| 916 | if (!transformed.ok) { |
| 917 | return fail(transformed.status, transformed.code, transformed.error || transformed.code); |
| 918 | } |
| 919 | |
| 920 | let next = transformed.envelope; |
| 921 | if (!underCapacity(next)) { |
| 922 | return fail(507, DELEGATION_AUTHORITY_CAPACITY, 'Authority envelope at capacity'); |
| 923 | } |
| 924 | |
| 925 | const priorHash = loaded.envelope.state_hash; |
| 926 | next = { |
| 927 | ...next, |
| 928 | revision: (loaded.envelope.revision || 0) + 1, |
| 929 | previous_state_hash: priorHash, |
| 930 | }; |
| 931 | next = sealEnvelopeStateHash(next); |
| 932 | |
| 933 | const iv = validateEnvelopeInternalIntegrity(next); |
| 934 | if (!iv.ok) { |
| 935 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, `Envelope invalid after transform: ${iv.reason}`); |
| 936 | } |
| 937 | |
| 938 | const payload = JSON.stringify(next); |
| 939 | if (Buffer.byteLength(payload, 'utf8') > MAX_ENVELOPE_BYTES) { |
| 940 | return fail(507, DELEGATION_AUTHORITY_CAPACITY, 'Authority envelope exceeds size limit'); |
| 941 | } |
| 942 | |
| 943 | try { |
| 944 | let etag = loaded.etag; |
| 945 | if (!etag) { |
| 946 | // Recover etag via strong read — never blind-overwrite. |
| 947 | const fresh = await cas.getWithMetadata(envelopeKey, authorityBlobGetOpts()).catch(() => null); |
| 948 | if (fresh && fresh.etag) { |
| 949 | etag = fresh.etag; |
| 950 | } else { |
| 951 | const created = await cas.set(envelopeKey, payload, { onlyIfNew: true }); |
| 952 | if (!created || created.modified === false) { |
| 953 | const jitter = 5 + Math.floor(Math.random() * 20) * attempt; |
| 954 | await new Promise((r) => setTimeout(r, jitter)); |
| 955 | continue; |
| 956 | } |
| 957 | let drained = await materializeAuditOutbox(next, cas); |
| 958 | drained = { |
| 959 | ...drained, |
| 960 | revision: (next.revision || 0) + 1, |
| 961 | previous_state_hash: next.state_hash, |
| 962 | }; |
| 963 | drained = sealEnvelopeStateHash(drained); |
| 964 | if (created.etag) { |
| 965 | await cas.set(envelopeKey, JSON.stringify(drained), { onlyIfMatch: created.etag }); |
| 966 | } |
| 967 | next = drained; |
| 968 | if (!hostedBlob) { |
| 969 | const localPath = path.join(dataDir, path.basename(envelopeKey)); |
| 970 | fs.writeFileSync(localPath, JSON.stringify(next), 'utf8'); |
| 971 | } |
| 972 | return { ok: true, ...transformed.result, envelope: next }; |
| 973 | } |
| 974 | } |
| 975 | const write = await cas.set(envelopeKey, payload, { onlyIfMatch: etag }); |
| 976 | if (!write || write.modified === false) { |
| 977 | const jitter = 5 + Math.floor(Math.random() * 20) * attempt; |
| 978 | await new Promise((r) => setTimeout(r, jitter)); |
| 979 | continue; |
| 980 | } |
| 981 | // Materialize only AFTER authority CAS (crash before CAS => no external audit). |
| 982 | let drained = await materializeAuditOutbox(next, cas); |
| 983 | drained = { |
| 984 | ...drained, |
| 985 | revision: (next.revision || 0) + 1, |
| 986 | previous_state_hash: next.state_hash, |
| 987 | }; |
| 988 | drained = sealEnvelopeStateHash(drained); |
| 989 | const drainPayload = JSON.stringify(drained); |
| 990 | const drainEtag = write.etag; |
| 991 | if (drainEtag) { |
| 992 | const drainWrite = await cas.set(envelopeKey, drainPayload, { onlyIfMatch: drainEtag }); |
| 993 | if (drainWrite && drainWrite.modified !== false) { |
| 994 | next = drained; |
| 995 | } |
| 996 | // If drain CAS conflicts, pending outbox remains — resumable next mutation. |
| 997 | } else { |
| 998 | next = drained; |
| 999 | await cas.set(envelopeKey, drainPayload).catch(() => {}); |
| 1000 | } |
| 1001 | if (!hostedBlob) { |
| 1002 | const localPath = path.join(dataDir, path.basename(envelopeKey)); |
| 1003 | fs.writeFileSync(localPath, JSON.stringify(next), 'utf8'); |
| 1004 | } |
| 1005 | } catch { |
| 1006 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Authority envelope write error'); |
| 1007 | } |
| 1008 | |
| 1009 | return { ok: true, ...transformed.result, envelope: next }; |
| 1010 | } |
| 1011 | return fail(409, DELEGATION_AUTHORITY_CONFLICT, 'Authority envelope conflict'); |
| 1012 | } |
| 1013 | |
| 1014 | /** |
| 1015 | * @param {string} uid |
| 1016 | * @param {string} actorId |
| 1017 | */ |
| 1018 | async function readHelperAccess(uid, actorId) { |
| 1019 | const actor = typeof actorId === 'string' ? actorId.trim() : ''; |
| 1020 | if (actor !== RETAIL_ACTOR_ID) { |
| 1021 | return fail(403, DELEGATION_HELPER_ACTOR_DENIED, 'Helper actor denied'); |
| 1022 | } |
| 1023 | const catalog = getTrustedCatalogIdentity(RETAIL_ACTOR_ID); |
| 1024 | if (!isRetailCodexIdentity(catalog)) { |
| 1025 | return fail(403, DELEGATION_HELPER_ACTOR_DENIED, 'Helper actor denied'); |
| 1026 | } |
| 1027 | const loaded = await readActiveEnvelope(); |
| 1028 | if (!loaded.ok) return loaded; |
| 1029 | const principal = hashPrincipalRef(uid); |
| 1030 | const nowMs = opts.nowMs ?? Date.now(); |
| 1031 | const consent = selectActivePersonalConsent(loaded.envelope, principal, actor, nowMs); |
| 1032 | if (!consent) { |
| 1033 | return { |
| 1034 | ok: true, |
| 1035 | payload: { |
| 1036 | schema: HELPER_ACCESS_SCHEMA, |
| 1037 | actor_agent_id: RETAIL_ACTOR_ID, |
| 1038 | state: 'consent_required', |
| 1039 | }, |
| 1040 | }; |
| 1041 | } |
| 1042 | const gKey = principalActorKey(principal, actor); |
| 1043 | const indexedGrantId = loaded.envelope.active_grant_id_by_principal_actor?.[gKey]; |
| 1044 | let activeGrant = indexedGrantId ? loaded.envelope.grants_by_id?.[indexedGrantId] : null; |
| 1045 | if (!activeGrant || !isGrantActiveStrict(activeGrant, nowMs) || activeGrant.principal_ref !== principal) { |
| 1046 | activeGrant = null; |
| 1047 | } |
| 1048 | return { |
| 1049 | ok: true, |
| 1050 | payload: { |
| 1051 | schema: HELPER_ACCESS_SCHEMA, |
| 1052 | actor_agent_id: RETAIL_ACTOR_ID, |
| 1053 | state: activeGrant ? 'ready' : 'renewable', |
| 1054 | }, |
| 1055 | }; |
| 1056 | } |
| 1057 | |
| 1058 | /** |
| 1059 | * @param {string} uid |
| 1060 | * @param {string} actorId |
| 1061 | */ |
| 1062 | async function renewPersonal(uid, actorId) { |
| 1063 | const actor = typeof actorId === 'string' ? actorId.trim() : ''; |
| 1064 | if (actor !== RETAIL_ACTOR_ID) { |
| 1065 | return fail(403, DELEGATION_HELPER_ACTOR_DENIED, 'Helper actor denied'); |
| 1066 | } |
| 1067 | const catalog = getTrustedCatalogIdentity(RETAIL_ACTOR_ID); |
| 1068 | if (!isRetailCodexIdentity(catalog)) { |
| 1069 | return fail(403, DELEGATION_HELPER_ACTOR_DENIED, 'Helper actor denied'); |
| 1070 | } |
| 1071 | const principal = hashPrincipalRef(uid); |
| 1072 | const nowMs = opts.nowMs ?? Date.now(); |
| 1073 | |
| 1074 | return mutateEnvelope((envelope) => { |
| 1075 | const consent = selectActivePersonalConsent(envelope, principal, actor, nowMs); |
| 1076 | if (!consent) { |
| 1077 | return { |
| 1078 | ok: false, |
| 1079 | status: 403, |
| 1080 | code: DELEGATION_HELPER_CONSENT_REQUIRED, |
| 1081 | error: 'Helper consent required', |
| 1082 | }; |
| 1083 | } |
| 1084 | |
| 1085 | const bucketKey = principalActorKey(principal, actor); |
| 1086 | const buckets = { ...(envelope.rate_buckets_by_principal_actor || {}) }; |
| 1087 | const bucket = buckets[bucketKey] || { renew_at_ms: [] }; |
| 1088 | const recent = (bucket.renew_at_ms || []).filter((t) => nowMs - t < RENEW_RATE_WINDOW_MS); |
| 1089 | if (recent.length >= RENEW_RATE_LIMIT) { |
| 1090 | return { |
| 1091 | ok: false, |
| 1092 | status: 429, |
| 1093 | code: DELEGATION_HELPER_RENEW_RATE_LIMITED, |
| 1094 | error: 'Helper renew rate limited', |
| 1095 | }; |
| 1096 | } |
| 1097 | if ( |
| 1098 | Object.keys(buckets).length >= MAX_RATE_BUCKETS && |
| 1099 | !buckets[bucketKey] |
| 1100 | ) { |
| 1101 | return { |
| 1102 | ok: false, |
| 1103 | status: 507, |
| 1104 | code: DELEGATION_AUTHORITY_CAPACITY, |
| 1105 | error: 'Rate bucket capacity', |
| 1106 | }; |
| 1107 | } |
| 1108 | |
| 1109 | if (Object.keys(envelope.grants_by_id || {}).length >= MAX_GRANTS) { |
| 1110 | return { |
| 1111 | ok: false, |
| 1112 | status: 507, |
| 1113 | code: DELEGATION_AUTHORITY_CAPACITY, |
| 1114 | error: 'Grant capacity', |
| 1115 | }; |
| 1116 | } |
| 1117 | |
| 1118 | const issuedAt = new Date(nowMs).toISOString(); |
| 1119 | if (!isStrictUtcTimestamp(issuedAt)) { |
| 1120 | return { |
| 1121 | ok: false, |
| 1122 | status: 503, |
| 1123 | code: DELEGATION_AUTHORITY_UNAVAILABLE, |
| 1124 | error: 'Clock unavailable', |
| 1125 | }; |
| 1126 | } |
| 1127 | const expiresAt = new Date(nowMs + RENEW_TTL_SECONDS * 1000).toISOString(); |
| 1128 | const grantId = randomPrefixedId(GRANT_ID_PREFIX); |
| 1129 | const bearer = randomPrefixedId(GRANT_BEARER_PREFIX, 24); |
| 1130 | const bearerHash = hashGrantBearer(bearer); |
| 1131 | const operationId = randomBytes(16).toString('hex'); |
| 1132 | const chainKey = `grant:${grantId}`; |
| 1133 | const priorHash = envelope.event_chain_heads_by_record?.[chainKey] ?? null; |
| 1134 | |
| 1135 | let grant = { |
| 1136 | schema: DELEGATION_GRANT_SCHEMA, |
| 1137 | grant_id: grantId, |
| 1138 | consent_id: consent.consent_id, |
| 1139 | actor_agent_id: actor, |
| 1140 | principal_ref: principal, |
| 1141 | scope: 'personal', |
| 1142 | expires_at: expiresAt, |
| 1143 | revoked_at: null, |
| 1144 | max_actions: RENEW_MAX_ACTIONS, |
| 1145 | action_count: 0, |
| 1146 | issued_at: issuedAt, |
| 1147 | grant_bearer_hash: bearerHash, |
| 1148 | audit_sequence: 0, |
| 1149 | last_materialized_audit_sequence: 0, |
| 1150 | pending_audit_count: 0, |
| 1151 | }; |
| 1152 | |
| 1153 | const bumped = bumpRecordAudit(grant, operationId, priorHash); |
| 1154 | grant = bumped.record; |
| 1155 | |
| 1156 | if (Object.keys(envelope.audit_outbox_by_id || {}).length >= MAX_AUDIT_OUTBOX) { |
| 1157 | return { |
| 1158 | ok: false, |
| 1159 | status: 507, |
| 1160 | code: DELEGATION_AUTHORITY_CAPACITY, |
| 1161 | error: 'Audit outbox full', |
| 1162 | }; |
| 1163 | } |
| 1164 | |
| 1165 | const grants_by_id = { ...(envelope.grants_by_id || {}), [grantId]: grant }; |
| 1166 | const grant_id_by_bearer_hash = { |
| 1167 | ...(envelope.grant_id_by_bearer_hash || {}), |
| 1168 | [bearerHash]: grantId, |
| 1169 | }; |
| 1170 | const active_grant_id_by_principal_actor = { |
| 1171 | ...(envelope.active_grant_id_by_principal_actor || {}), |
| 1172 | [bucketKey]: grantId, |
| 1173 | }; |
| 1174 | const audit_outbox_by_id = { |
| 1175 | ...(envelope.audit_outbox_by_id || {}), |
| 1176 | [operationId]: { |
| 1177 | operation_id: operationId, |
| 1178 | record_kind: 'grant', |
| 1179 | record_id: grantId, |
| 1180 | sequence: bumped.outboxEntry.sequence, |
| 1181 | prior_audit_event_hash: priorHash, |
| 1182 | created_at: issuedAt, |
| 1183 | }, |
| 1184 | }; |
| 1185 | buckets[bucketKey] = { renew_at_ms: [...recent, nowMs] }; |
| 1186 | |
| 1187 | const next = { |
| 1188 | ...envelope, |
| 1189 | grants_by_id, |
| 1190 | grant_id_by_bearer_hash, |
| 1191 | active_grant_id_by_principal_actor, |
| 1192 | audit_outbox_by_id, |
| 1193 | rate_buckets_by_principal_actor: buckets, |
| 1194 | }; |
| 1195 | |
| 1196 | return { |
| 1197 | ok: true, |
| 1198 | envelope: next, |
| 1199 | result: { |
| 1200 | payload: { |
| 1201 | schema: DELEGATION_GRANT_MINT_SCHEMA, |
| 1202 | grant: grantForClient(grant), |
| 1203 | bearer, |
| 1204 | expires_at: expiresAt, |
| 1205 | }, |
| 1206 | }, |
| 1207 | }; |
| 1208 | }); |
| 1209 | } |
| 1210 | |
| 1211 | /** |
| 1212 | * @param {{ |
| 1213 | * uid: string, |
| 1214 | * bearer: string, |
| 1215 | * actorId: string, |
| 1216 | * visitHandle: string, |
| 1217 | * }} input |
| 1218 | */ |
| 1219 | async function validateAndConsume(input) { |
| 1220 | const actor = typeof input.actorId === 'string' ? input.actorId.trim() : ''; |
| 1221 | const bearer = typeof input.bearer === 'string' ? input.bearer.trim() : ''; |
| 1222 | const visitHandle = typeof input.visitHandle === 'string' ? input.visitHandle.trim() : ''; |
| 1223 | if (!bearer || !actor || !visitHandle) { |
| 1224 | return fail(400, DELEGATION_REQUEST_INVALID, 'Invalid validation request'); |
| 1225 | } |
| 1226 | if (actor !== RETAIL_ACTOR_ID) { |
| 1227 | return fail(403, DELEGATION_AUTHORITY_DENIED, 'Authority denied'); |
| 1228 | } |
| 1229 | if (!/^[A-Za-z0-9_-]{43}$/.test(visitHandle)) { |
| 1230 | return fail(400, DELEGATION_REQUEST_INVALID, 'Invalid visit handle'); |
| 1231 | } |
| 1232 | const catalog = getTrustedCatalogIdentity(RETAIL_ACTOR_ID); |
| 1233 | if (!isRetailCodexIdentity(catalog)) { |
| 1234 | return fail(403, DELEGATION_AUTHORITY_DENIED, 'Authority denied'); |
| 1235 | } |
| 1236 | if (!opts.sessionSecret) { |
| 1237 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Session secret unavailable'); |
| 1238 | } |
| 1239 | |
| 1240 | const principal = hashPrincipalRef(input.uid); |
| 1241 | const bearerHash = hashGrantBearer(bearer); |
| 1242 | const nowMs = opts.nowMs ?? Date.now(); |
| 1243 | |
| 1244 | const mutated = await mutateEnvelope((envelope) => { |
| 1245 | const grantId = envelope.grant_id_by_bearer_hash?.[bearerHash]; |
| 1246 | if (!grantId) { |
| 1247 | return { ok: false, status: 403, code: DELEGATION_AUTHORITY_DENIED, error: 'Authority denied' }; |
| 1248 | } |
| 1249 | const grant = envelope.grants_by_id?.[grantId]; |
| 1250 | if (!grant || !constantTimeEqualHexOrString(grant.grant_bearer_hash, bearerHash)) { |
| 1251 | return { ok: false, status: 403, code: DELEGATION_AUTHORITY_DENIED, error: 'Authority denied' }; |
| 1252 | } |
| 1253 | if ( |
| 1254 | grant.principal_ref !== principal || |
| 1255 | grant.actor_agent_id !== actor || |
| 1256 | grant.scope !== 'personal' || |
| 1257 | !isGrantActiveStrict(grant, nowMs) |
| 1258 | ) { |
| 1259 | return { ok: false, status: 403, code: DELEGATION_AUTHORITY_DENIED, error: 'Authority denied' }; |
| 1260 | } |
| 1261 | const consent = envelope.consents_by_id?.[grant.consent_id]; |
| 1262 | if ( |
| 1263 | !consent || |
| 1264 | !isConsentActiveStrict(consent, nowMs) || |
| 1265 | consent.principal_ref !== principal || |
| 1266 | consent.delegate_agent_id !== actor || |
| 1267 | consent.scope !== 'personal' |
| 1268 | ) { |
| 1269 | return { ok: false, status: 403, code: DELEGATION_AUTHORITY_DENIED, error: 'Authority denied' }; |
| 1270 | } |
| 1271 | |
| 1272 | const operationId = randomBytes(16).toString('hex'); |
| 1273 | const issuedAt = new Date(nowMs).toISOString(); |
| 1274 | const chainKey = `grant:${grantId}`; |
| 1275 | const priorHash = envelope.event_chain_heads_by_record?.[chainKey] ?? null; |
| 1276 | let nextGrant = { |
| 1277 | ...grant, |
| 1278 | action_count: (grant.action_count || 0) + 1, |
| 1279 | }; |
| 1280 | const bumped = bumpRecordAudit(nextGrant, operationId, priorHash); |
| 1281 | nextGrant = bumped.record; |
| 1282 | |
| 1283 | if (Object.keys(envelope.audit_outbox_by_id || {}).length >= MAX_AUDIT_OUTBOX) { |
| 1284 | return { |
| 1285 | ok: false, |
| 1286 | status: 507, |
| 1287 | code: DELEGATION_AUTHORITY_CAPACITY, |
| 1288 | error: 'Audit outbox full', |
| 1289 | }; |
| 1290 | } |
| 1291 | |
| 1292 | const grants_by_id = { ...(envelope.grants_by_id || {}), [grantId]: nextGrant }; |
| 1293 | const audit_outbox_by_id = { |
| 1294 | ...(envelope.audit_outbox_by_id || {}), |
| 1295 | [operationId]: { |
| 1296 | operation_id: operationId, |
| 1297 | record_kind: 'grant', |
| 1298 | record_id: grantId, |
| 1299 | sequence: bumped.outboxEntry.sequence, |
| 1300 | prior_audit_event_hash: priorHash, |
| 1301 | created_at: issuedAt, |
| 1302 | }, |
| 1303 | }; |
| 1304 | |
| 1305 | return { |
| 1306 | ok: true, |
| 1307 | envelope: { ...envelope, grants_by_id, audit_outbox_by_id }, |
| 1308 | result: { consumed: true }, |
| 1309 | }; |
| 1310 | }); |
| 1311 | |
| 1312 | if (!mutated.ok) return mutated; |
| 1313 | |
| 1314 | const subjects = buildAuthoritySubjects({ |
| 1315 | sessionSecret: opts.sessionSecret, |
| 1316 | sessionSecretPrevious: opts.sessionSecretPrevious ?? null, |
| 1317 | uid: input.uid, |
| 1318 | vaultId, |
| 1319 | actorId: actor, |
| 1320 | }); |
| 1321 | |
| 1322 | return { |
| 1323 | ok: true, |
| 1324 | payload: { |
| 1325 | schema: DELEGATION_VALIDATION_SCHEMA, |
| 1326 | authority_subjects: subjects, |
| 1327 | }, |
| 1328 | }; |
| 1329 | } |
| 1330 | |
| 1331 | /** |
| 1332 | * @param {string} uid |
| 1333 | * @param {string} consentId |
| 1334 | */ |
| 1335 | async function revokeConsent(uid, consentId) { |
| 1336 | const id = typeof consentId === 'string' ? consentId.trim() : ''; |
| 1337 | if (!id) return fail(400, DELEGATION_REQUEST_INVALID, 'consent_id required'); |
| 1338 | const principal = hashPrincipalRef(uid); |
| 1339 | const nowMs = opts.nowMs ?? Date.now(); |
| 1340 | const issuedAt = new Date(nowMs).toISOString(); |
| 1341 | return mutateEnvelope((envelope) => { |
| 1342 | const consent = envelope.consents_by_id?.[id]; |
| 1343 | if (!consent) return { ok: false, status: 404, code: DELEGATION_REQUEST_INVALID, error: 'unknown consent' }; |
| 1344 | if (consent.principal_ref !== principal) { |
| 1345 | return { ok: false, status: 403, code: DELEGATION_HELPER_ACTOR_DENIED, error: 'Consent principal mismatch' }; |
| 1346 | } |
| 1347 | if (consent.revoked_at) { |
| 1348 | return { ok: true, envelope, result: { payload: { schema: DELEGATION_ERROR_SCHEMA, code: 'already_revoked' } } }; |
| 1349 | } |
| 1350 | const operationId = randomBytes(16).toString('hex'); |
| 1351 | const priorHash = envelope.event_chain_heads_by_record?.[`consent:${id}`] ?? null; |
| 1352 | const bumped = bumpRecordAudit( |
| 1353 | { ...consent, revoked_at: issuedAt }, |
| 1354 | operationId, |
| 1355 | priorHash, |
| 1356 | ); |
| 1357 | if (Object.keys(envelope.audit_outbox_by_id || {}).length >= MAX_AUDIT_OUTBOX) { |
| 1358 | return { ok: false, status: 507, code: DELEGATION_AUTHORITY_CAPACITY, error: 'Audit outbox full' }; |
| 1359 | } |
| 1360 | const consents_by_id = { ...envelope.consents_by_id, [id]: bumped.record }; |
| 1361 | const audit_outbox_by_id = { |
| 1362 | ...(envelope.audit_outbox_by_id || {}), |
| 1363 | [operationId]: { |
| 1364 | operation_id: operationId, |
| 1365 | record_kind: 'consent', |
| 1366 | record_id: id, |
| 1367 | sequence: bumped.outboxEntry.sequence, |
| 1368 | prior_audit_event_hash: priorHash, |
| 1369 | created_at: issuedAt, |
| 1370 | }, |
| 1371 | }; |
| 1372 | return { |
| 1373 | ok: true, |
| 1374 | envelope: rebuildNewestActiveConsentIndex({ ...envelope, consents_by_id, audit_outbox_by_id }, nowMs), |
| 1375 | result: { payload: { revoked: true, consent_id: id } }, |
| 1376 | }; |
| 1377 | }); |
| 1378 | } |
| 1379 | |
| 1380 | /** |
| 1381 | * Privileged grant revoke (operator / admin path). |
| 1382 | * |
| 1383 | * @param {string} _privilegedActor |
| 1384 | * @param {string} grantId |
| 1385 | */ |
| 1386 | async function revokeGrant(_privilegedActor, grantId) { |
| 1387 | const id = typeof grantId === 'string' ? grantId.trim() : ''; |
| 1388 | if (!id) return fail(400, DELEGATION_REQUEST_INVALID, 'grant_id required'); |
| 1389 | const nowMs = opts.nowMs ?? Date.now(); |
| 1390 | const issuedAt = new Date(nowMs).toISOString(); |
| 1391 | return mutateEnvelope((envelope) => { |
| 1392 | const grant = envelope.grants_by_id?.[id]; |
| 1393 | if (!grant) return { ok: false, status: 404, code: DELEGATION_REQUEST_INVALID, error: 'unknown grant' }; |
| 1394 | if (grant.revoked_at) { |
| 1395 | return { ok: true, envelope, result: { payload: { revoked: true, grant_id: id } } }; |
| 1396 | } |
| 1397 | const operationId = randomBytes(16).toString('hex'); |
| 1398 | const priorHash = envelope.event_chain_heads_by_record?.[`grant:${id}`] ?? null; |
| 1399 | const bumped = bumpRecordAudit( |
| 1400 | { ...grant, revoked_at: issuedAt }, |
| 1401 | operationId, |
| 1402 | priorHash, |
| 1403 | ); |
| 1404 | if (Object.keys(envelope.audit_outbox_by_id || {}).length >= MAX_AUDIT_OUTBOX) { |
| 1405 | return { ok: false, status: 507, code: DELEGATION_AUTHORITY_CAPACITY, error: 'Audit outbox full' }; |
| 1406 | } |
| 1407 | const grants_by_id = { ...envelope.grants_by_id, [id]: bumped.record }; |
| 1408 | const audit_outbox_by_id = { |
| 1409 | ...(envelope.audit_outbox_by_id || {}), |
| 1410 | [operationId]: { |
| 1411 | operation_id: operationId, |
| 1412 | record_kind: 'grant', |
| 1413 | record_id: id, |
| 1414 | sequence: bumped.outboxEntry.sequence, |
| 1415 | prior_audit_event_hash: priorHash, |
| 1416 | created_at: issuedAt, |
| 1417 | }, |
| 1418 | }; |
| 1419 | const active = { ...(envelope.active_grant_id_by_principal_actor || {}) }; |
| 1420 | const gKey = principalActorKey(grant.principal_ref, grant.actor_agent_id); |
| 1421 | if (active[gKey] === id) delete active[gKey]; |
| 1422 | return { |
| 1423 | ok: true, |
| 1424 | envelope: { |
| 1425 | ...envelope, |
| 1426 | grants_by_id, |
| 1427 | audit_outbox_by_id, |
| 1428 | active_grant_id_by_principal_actor: active, |
| 1429 | }, |
| 1430 | result: { payload: { revoked: true, grant_id: id } }, |
| 1431 | }; |
| 1432 | }); |
| 1433 | } |
| 1434 | |
| 1435 | /** |
| 1436 | * Snapshot legacy stores into a candidate envelope (ignored until marker). |
| 1437 | * Does not activate production reads. |
| 1438 | * |
| 1439 | * @returns {Promise<object>} |
| 1440 | */ |
| 1441 | async function createOrVerifyCandidate() { |
| 1442 | const existingRaw = await cas.get(candidateKey, { type: 'text' }).catch(() => null); |
| 1443 | if (typeof existingRaw === 'string' && existingRaw.trim()) { |
| 1444 | let existing; |
| 1445 | try { |
| 1446 | existing = JSON.parse(existingRaw); |
| 1447 | } catch { |
| 1448 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Candidate parse error'); |
| 1449 | } |
| 1450 | const iv = validateEnvelopeInternalIntegrity(existing); |
| 1451 | if (!iv.ok) { |
| 1452 | return fail(409, DELEGATION_AUTHORITY_CONFLICT, 'Mismatched candidate envelope'); |
| 1453 | } |
| 1454 | return { |
| 1455 | ok: true, |
| 1456 | state: 'candidate_verified', |
| 1457 | lineage_id: existing.lineage_id, |
| 1458 | origin_snapshot_hash: existing.origin_snapshot_hash, |
| 1459 | }; |
| 1460 | } |
| 1461 | |
| 1462 | const identities = loadIdentitiesStore(dataDir); |
| 1463 | const consents = loadConsentsStore(dataDir); |
| 1464 | const grants = loadGrantsStore(dataDir); |
| 1465 | const vaultIdentities = identities.vaults?.[vaultId]?.identities || []; |
| 1466 | const vaultConsents = consents.vaults?.[vaultId]?.consents || []; |
| 1467 | const vaultGrants = grants.vaults?.[vaultId]?.grants || []; |
| 1468 | |
| 1469 | /** @type {Record<string, object>} */ |
| 1470 | const identities_by_id = {}; |
| 1471 | for (const id of vaultIdentities) { |
| 1472 | if (!id || typeof id !== 'object') { |
| 1473 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Malformed identity in snapshot'); |
| 1474 | } |
| 1475 | if (id.agent_id === RETAIL_ACTOR_ID) { |
| 1476 | return fail(409, DELEGATION_AUTHORITY_CONFLICT, 'Reserved catalog id collision'); |
| 1477 | } |
| 1478 | identities_by_id[id.agent_id] = { ...id }; |
| 1479 | } |
| 1480 | |
| 1481 | /** @type {Record<string, object>} */ |
| 1482 | const consents_by_id = {}; |
| 1483 | for (const c of vaultConsents) { |
| 1484 | if (!c || typeof c !== 'object') { |
| 1485 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Malformed consent in snapshot'); |
| 1486 | } |
| 1487 | if (!isStrictUtcTimestamp(c.created)) { |
| 1488 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Malformed consent timestamp'); |
| 1489 | } |
| 1490 | const exp = parseOptionalStrictUtc(c.expires_at); |
| 1491 | if (!exp.ok) { |
| 1492 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Malformed consent expiry'); |
| 1493 | } |
| 1494 | consents_by_id[c.consent_id] = { |
| 1495 | ...c, |
| 1496 | audit_sequence: 0, |
| 1497 | last_materialized_audit_sequence: 0, |
| 1498 | pending_audit_count: 0, |
| 1499 | }; |
| 1500 | } |
| 1501 | |
| 1502 | /** @type {Record<string, object>} */ |
| 1503 | const grants_by_id = {}; |
| 1504 | /** @type {Record<string, string>} */ |
| 1505 | const grant_id_by_bearer_hash = {}; |
| 1506 | /** @type {Record<string, string>} */ |
| 1507 | const active_grant_id_by_principal_actor = {}; |
| 1508 | const nowMs = opts.nowMs ?? Date.now(); |
| 1509 | for (const g of vaultGrants) { |
| 1510 | if (!g || typeof g !== 'object') { |
| 1511 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Malformed grant in snapshot'); |
| 1512 | } |
| 1513 | if (!isStrictUtcTimestamp(g.issued_at) || !isStrictUtcTimestamp(g.expires_at)) { |
| 1514 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Malformed grant timestamp'); |
| 1515 | } |
| 1516 | grants_by_id[g.grant_id] = { |
| 1517 | ...g, |
| 1518 | audit_sequence: 0, |
| 1519 | last_materialized_audit_sequence: 0, |
| 1520 | pending_audit_count: 0, |
| 1521 | }; |
| 1522 | if (typeof g.grant_bearer_hash === 'string') { |
| 1523 | grant_id_by_bearer_hash[g.grant_bearer_hash] = g.grant_id; |
| 1524 | } |
| 1525 | if (isGrantActiveStrict(g, nowMs) && g.scope === 'personal') { |
| 1526 | const gKey = principalActorKey(g.principal_ref, g.actor_agent_id); |
| 1527 | const existingId = active_grant_id_by_principal_actor[gKey]; |
| 1528 | if (!existingId) { |
| 1529 | active_grant_id_by_principal_actor[gKey] = g.grant_id; |
| 1530 | } else { |
| 1531 | const existing = grants_by_id[existingId]; |
| 1532 | const a = Date.parse(existing?.issued_at || '') || 0; |
| 1533 | const b = Date.parse(g.issued_at) || 0; |
| 1534 | if (b >= a) active_grant_id_by_principal_actor[gKey] = g.grant_id; |
| 1535 | } |
| 1536 | } |
| 1537 | } |
| 1538 | |
| 1539 | const snapshotBody = JSON.stringify({ |
| 1540 | identities: vaultIdentities, |
| 1541 | consents: vaultConsents, |
| 1542 | grants: vaultGrants, |
| 1543 | }); |
| 1544 | const origin_snapshot_hash = `${SHA256_PREFIX}${createHash('sha256').update(snapshotBody, 'utf8').digest('hex')}`; |
| 1545 | const lineage_id = `lineage_${randomIdToken(12)}`; |
| 1546 | |
| 1547 | let envelope = { |
| 1548 | schema: DELEGATION_AUTHORITY_ENVELOPE_SCHEMA, |
| 1549 | schema_version: ENVELOPE_SCHEMA_VERSION, |
| 1550 | vault_id: vaultId, |
| 1551 | lineage_id, |
| 1552 | origin_snapshot_hash, |
| 1553 | revision: 0, |
| 1554 | previous_state_hash: null, |
| 1555 | identities_by_id, |
| 1556 | consents_by_id, |
| 1557 | newest_active_consent_id_by_principal_actor: {}, |
| 1558 | grants_by_id, |
| 1559 | grant_id_by_bearer_hash, |
| 1560 | active_grant_id_by_principal_actor, |
| 1561 | rate_buckets_by_principal_actor: {}, |
| 1562 | audit_outbox_by_id: {}, |
| 1563 | event_chain_heads_by_record: {}, |
| 1564 | }; |
| 1565 | envelope = rebuildNewestActiveConsentIndex(envelope, opts.nowMs ?? Date.now()); |
| 1566 | envelope = sealEnvelopeStateHash(envelope); |
| 1567 | |
| 1568 | const iv = validateEnvelopeInternalIntegrity(envelope); |
| 1569 | if (!iv.ok) { |
| 1570 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, `Candidate invalid: ${iv.reason}`); |
| 1571 | } |
| 1572 | |
| 1573 | const payload = JSON.stringify(envelope); |
| 1574 | const write = await cas.set(candidateKey, payload, { onlyIfNew: true }); |
| 1575 | if (!write || write.modified === false) { |
| 1576 | // Race: re-read and verify |
| 1577 | return createOrVerifyCandidate(); |
| 1578 | } |
| 1579 | |
| 1580 | // Mirror candidate to local file for inspectability (still ignored by readers). |
| 1581 | try { |
| 1582 | fs.writeFileSync( |
| 1583 | path.join(dataDir, delegationAuthorityCandidateFileName(vaultId)), |
| 1584 | payload, |
| 1585 | 'utf8', |
| 1586 | ); |
| 1587 | } catch { |
| 1588 | /* non-fatal */ |
| 1589 | } |
| 1590 | |
| 1591 | const readBack = await cas.get(candidateKey, { type: 'text' }); |
| 1592 | if (typeof readBack !== 'string' || !readBack.trim()) { |
| 1593 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Candidate read-back failed'); |
| 1594 | } |
| 1595 | let verified; |
| 1596 | try { |
| 1597 | verified = JSON.parse(readBack); |
| 1598 | } catch { |
| 1599 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Candidate read-back parse failed'); |
| 1600 | } |
| 1601 | if ( |
| 1602 | verified.origin_snapshot_hash !== origin_snapshot_hash || |
| 1603 | verified.state_hash !== envelope.state_hash |
| 1604 | ) { |
| 1605 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Candidate hash verification failed'); |
| 1606 | } |
| 1607 | |
| 1608 | return { |
| 1609 | ok: true, |
| 1610 | state: 'candidate_created', |
| 1611 | lineage_id, |
| 1612 | origin_snapshot_hash, |
| 1613 | envelope_key: envelopeKey, |
| 1614 | }; |
| 1615 | } |
| 1616 | |
| 1617 | /** |
| 1618 | * Activate candidate via immutable marker. Requires explicit operator authorization. |
| 1619 | * |
| 1620 | * @param {{ operatorAuthorized: boolean }} auth |
| 1621 | */ |
| 1622 | async function activateMarker(auth) { |
| 1623 | if (!auth || auth.operatorAuthorized !== true) { |
| 1624 | return fail(403, DELEGATION_HELPER_ACTOR_DENIED, 'Marker activation requires operator authorization'); |
| 1625 | } |
| 1626 | // Dual gate: call-site operatorAuthorized AND store option or env after Tier-3. |
| 1627 | // Production must never set these without explicit operator authorization. |
| 1628 | const storeAuthorized = opts.operatorAuthorizedMarker === true; |
| 1629 | const envAuthorized = process.env.RHF_AUTHORITY_MARKER_AUTHORIZED === '1'; |
| 1630 | if (!storeAuthorized && !envAuthorized) { |
| 1631 | return fail( |
| 1632 | 403, |
| 1633 | DELEGATION_HELPER_ACTOR_DENIED, |
| 1634 | 'Marker activation blocked — Tier-3 authorization required (operatorAuthorizedMarker or RHF_AUTHORITY_MARKER_AUTHORIZED)', |
| 1635 | ); |
| 1636 | } |
| 1637 | |
| 1638 | const existingMarker = await cas.get(markerKey, { type: 'text' }).catch(() => null); |
| 1639 | if (typeof existingMarker === 'string' && existingMarker.trim()) { |
| 1640 | let marker; |
| 1641 | try { |
| 1642 | marker = JSON.parse(existingMarker); |
| 1643 | } catch { |
| 1644 | return fail(409, DELEGATION_AUTHORITY_CONFLICT, 'Mismatched marker'); |
| 1645 | } |
| 1646 | const mv = validateDelegationAuthorityMarker(marker, vaultId); |
| 1647 | if (!mv.ok) return fail(409, DELEGATION_AUTHORITY_CONFLICT, 'Mismatched marker'); |
| 1648 | return { ok: true, state: 'marker_already_active', marker }; |
| 1649 | } |
| 1650 | |
| 1651 | const candidateResult = await createOrVerifyCandidate(); |
| 1652 | if (!candidateResult.ok) return candidateResult; |
| 1653 | |
| 1654 | const candidateRaw = await cas.get(candidateKey, { type: 'text' }); |
| 1655 | if (typeof candidateRaw !== 'string' || !candidateRaw.trim()) { |
| 1656 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Candidate missing for activation'); |
| 1657 | } |
| 1658 | let candidate; |
| 1659 | try { |
| 1660 | candidate = JSON.parse(candidateRaw); |
| 1661 | } catch { |
| 1662 | return fail(503, DELEGATION_AUTHORITY_UNAVAILABLE, 'Candidate unreadable'); |
| 1663 | } |
| 1664 | |
| 1665 | // Promote candidate → envelope key (onlyIfNew), then marker. |
| 1666 | const envWrite = await cas.set(envelopeKey, candidateRaw, { onlyIfNew: true }); |
| 1667 | if (envWrite && envWrite.modified === false) { |
| 1668 | const existingEnv = await cas.get(envelopeKey, { type: 'text' }); |
| 1669 | if (typeof existingEnv === 'string' && existingEnv.trim()) { |
| 1670 | let existing; |
| 1671 | try { |
| 1672 | existing = JSON.parse(existingEnv); |
| 1673 | } catch { |
| 1674 | return fail(409, DELEGATION_AUTHORITY_CONFLICT, 'Envelope conflict'); |
| 1675 | } |
| 1676 | if ( |
| 1677 | existing.lineage_id !== candidate.lineage_id || |
| 1678 | existing.origin_snapshot_hash !== candidate.origin_snapshot_hash |
| 1679 | ) { |
| 1680 | return fail(409, DELEGATION_AUTHORITY_CONFLICT, 'Envelope conflict'); |
| 1681 | } |
| 1682 | } |
| 1683 | } |
| 1684 | |
| 1685 | const marker = { |
| 1686 | schema: DELEGATION_AUTHORITY_MARKER_SCHEMA, |
| 1687 | vault_id: vaultId, |
| 1688 | envelope_key: envelopeKey, |
| 1689 | envelope_schema_version: MARKER_SCHEMA_VERSION, |
| 1690 | lineage_id: candidate.lineage_id, |
| 1691 | origin_snapshot_hash: candidate.origin_snapshot_hash, |
| 1692 | }; |
| 1693 | const markerWrite = await cas.set(markerKey, JSON.stringify(marker), { onlyIfNew: true }); |
| 1694 | if (!markerWrite || markerWrite.modified === false) { |
| 1695 | const again = await cas.get(markerKey, { type: 'text' }); |
| 1696 | if (typeof again === 'string' && again.trim()) { |
| 1697 | try { |
| 1698 | const m = JSON.parse(again); |
| 1699 | if ( |
| 1700 | m.lineage_id === marker.lineage_id && |
| 1701 | m.origin_snapshot_hash === marker.origin_snapshot_hash |
| 1702 | ) { |
| 1703 | return { ok: true, state: 'marker_already_active', marker: m }; |
| 1704 | } |
| 1705 | } catch { |
| 1706 | /* fall through */ |
| 1707 | } |
| 1708 | } |
| 1709 | return fail(409, DELEGATION_AUTHORITY_CONFLICT, 'Marker conflict'); |
| 1710 | } |
| 1711 | |
| 1712 | try { |
| 1713 | fs.writeFileSync( |
| 1714 | path.join(dataDir, delegationAuthorityMarkerFileName(vaultId)), |
| 1715 | JSON.stringify(marker), |
| 1716 | 'utf8', |
| 1717 | ); |
| 1718 | fs.writeFileSync( |
| 1719 | path.join(dataDir, path.basename(envelopeKey)), |
| 1720 | candidateRaw, |
| 1721 | 'utf8', |
| 1722 | ); |
| 1723 | } catch { |
| 1724 | /* CAS is authoritative when available */ |
| 1725 | } |
| 1726 | |
| 1727 | return { ok: true, state: 'marker_activated', marker }; |
| 1728 | } |
| 1729 | |
| 1730 | return { |
| 1731 | readHelperAccess, |
| 1732 | renewPersonal, |
| 1733 | validateAndConsume, |
| 1734 | revokeConsent, |
| 1735 | revokeGrant, |
| 1736 | createOrVerifyCandidate, |
| 1737 | activateMarker, |
| 1738 | readActiveEnvelope, |
| 1739 | mutateEnvelope, |
| 1740 | cas, |
| 1741 | envelopeKey, |
| 1742 | candidateKey, |
| 1743 | markerKey, |
| 1744 | }; |
| 1745 | } |
| 1746 | |
| 1747 | /** |
| 1748 | * Test helper: write marker + sealed envelope into a CAS store and local files. |
| 1749 | * |
| 1750 | * @param {{ |
| 1751 | * dataDir: string, |
| 1752 | * vaultId: string, |
| 1753 | * cas?: MemoryCasBlobStore, |
| 1754 | * envelopeOverrides?: object, |
| 1755 | * }} input |
| 1756 | */ |
| 1757 | export async function seedActiveAuthorityEnvelope(input) { |
| 1758 | const cas = input.cas || new MemoryCasBlobStore(); |
| 1759 | const vaultId = input.vaultId; |
| 1760 | let envelope = { |
| 1761 | schema: DELEGATION_AUTHORITY_ENVELOPE_SCHEMA, |
| 1762 | schema_version: 1, |
| 1763 | vault_id: vaultId, |
| 1764 | lineage_id: 'lineage_test_kn1', |
| 1765 | origin_snapshot_hash: |
| 1766 | 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', |
| 1767 | revision: 0, |
| 1768 | previous_state_hash: null, |
| 1769 | identities_by_id: {}, |
| 1770 | consents_by_id: {}, |
| 1771 | newest_active_consent_id_by_principal_actor: {}, |
| 1772 | grants_by_id: {}, |
| 1773 | grant_id_by_bearer_hash: {}, |
| 1774 | active_grant_id_by_principal_actor: {}, |
| 1775 | rate_buckets_by_principal_actor: {}, |
| 1776 | audit_outbox_by_id: {}, |
| 1777 | event_chain_heads_by_record: {}, |
| 1778 | ...(input.envelopeOverrides || {}), |
| 1779 | }; |
| 1780 | envelope = rebuildNewestActiveConsentIndex(envelope); |
| 1781 | envelope = sealEnvelopeStateHash(envelope); |
| 1782 | |
| 1783 | const envelopeKey = delegationAuthorityEnvelopeBlobKey(vaultId); |
| 1784 | const markerKey = delegationAuthorityMarkerBlobKey(vaultId); |
| 1785 | await cas.set(envelopeKey, JSON.stringify(envelope)); |
| 1786 | const marker = { |
| 1787 | schema: DELEGATION_AUTHORITY_MARKER_SCHEMA, |
| 1788 | vault_id: vaultId, |
| 1789 | envelope_key: envelopeKey, |
| 1790 | envelope_schema_version: 1, |
| 1791 | lineage_id: envelope.lineage_id, |
| 1792 | origin_snapshot_hash: envelope.origin_snapshot_hash, |
| 1793 | }; |
| 1794 | await cas.set(markerKey, JSON.stringify(marker)); |
| 1795 | fs.mkdirSync(input.dataDir, { recursive: true }); |
| 1796 | fs.writeFileSync( |
| 1797 | path.join(input.dataDir, delegationAuthorityMarkerFileName(vaultId)), |
| 1798 | JSON.stringify(marker), |
| 1799 | ); |
| 1800 | fs.writeFileSync(path.join(input.dataDir, path.basename(envelopeKey)), JSON.stringify(envelope)); |
| 1801 | return { cas, envelope, marker }; |
| 1802 | } |
File History
1 commit
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db
docs: activate KNOWTATION- board identity and preserve livi…
Human
minor
⚠
3 days ago