// Copyright 2024-2026 Tymofii Pidlisnyi. Apache-2.0 license. See LICENSE.
// ══════════════════════════════════════════════════════════════════
// CyclesKeyResolver: reference KeyResolver implementation (M3)
// ══════════════════════════════════════════════════════════════════
// A single resolver that handles three locator families behind ONE
// interface:
//   - did:key   : self-certifying, no network (existing fromDIDKey).
//   - did:web   : fetch the DID Document (existing resolveDIDWeb),
//                 select a verificationMethod by fragment.
//   - did:cycles / direct jwksUrl : fetch a JWKS, select an Ed25519
//                 key by kid (this module's did-cycles mapping).
//
// The existing did:key/did:web logic in core/did-interop.ts is reused
// UNCHANGED and registered behind this interface. No existing function
// is modified.
//
// This is a REFERENCE resolver, not a hosted service. It holds an
// in-process TTL cache with distinct hit/miss handling, an explicit
// fail-open vs fail-closed policy defaulting to fail-closed, and a
// fetch timeout mirroring the did:web resolver. It does not aggregate
// across tenants, expose an endpoint, or alert.
// ══════════════════════════════════════════════════════════════════
import { fromDIDKey, resolveDIDWeb } from '../../core/did-interop.js';
import { multibaseToHex } from '../../core/did.js';
import { asJWKS, isDIDCycles, parseDIDCycles, selectKey, } from './did-cycles.js';
import { decodeBase64Url, bytesToHex } from './base64url.js';
import { DEFAULT_CACHE_POLICY, DEFAULT_TIMEOUT_MS, } from './types.js';
/** Scope-of-claim for a successful key resolution. */
function resolvedScope() {
    return {
        asserts: 'aps:key.resolve. At resolution time, the named DID or JWKS endpoint asserted this Ed25519 public key under the requested kid.',
        does_not_assert: [
            'the key is uncompromised or under sole control of the named party',
            'the endpoint asserts the same key at any later time',
            'any signature made with the key is authorized',
        ],
        capture_mode: 'gateway_observed',
        completeness: 'complete',
        self_attested: false,
    };
}
/** Scope-of-claim for a degraded (fail-open, unreachable) outcome. */
function degradedScope() {
    return {
        asserts: 'aps:key.resolve:degraded. The key endpoint was unreachable and a fail-open policy produced a non-rejecting result with NO key material.',
        does_not_assert: [
            'any public key was resolved',
            'a positive verification (a degraded result MUST NOT be read as valid)',
            'the endpoint or key exists at all',
        ],
        capture_mode: 'unknown',
        completeness: 'best_effort',
        self_attested: false,
    };
}
export class CyclesKeyResolver {
    failurePolicy;
    timeoutMs;
    cachePolicy;
    fetchImpl;
    now;
    cache = new Map();
    constructor(config = {}) {
        this.failurePolicy = config.failurePolicy ?? 'closed';
        this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
        this.cachePolicy = { ...DEFAULT_CACHE_POLICY, ...(config.cache ?? {}) };
        // Bind to preserve `this` when a global fetch is used.
        this.fetchImpl = config.fetchImpl ?? ((...a) => fetch(...a));
        this.now = config.now ?? Date.now;
    }
    canResolve(locator) {
        if (locator.jwksUrl)
            return true;
        const did = locator.did;
        if (!did)
            return false;
        return (did.startsWith('did:key:') ||
            did.startsWith('did:web:') ||
            did.startsWith('did:cycles:'));
    }
    async resolve(locator) {
        if (!locator || (!locator.did && !locator.jwksUrl)) {
            return this.fail('unsupported', 'locator must carry a did or a jwksUrl');
        }
        const did = locator.did;
        // did:key: self-certifying, no network, no cache needed.
        if (did && did.startsWith('did:key:')) {
            return this.resolveDidKey(did);
        }
        // did:web: fetch the DID Document, pick a verificationMethod.
        if (did && did.startsWith('did:web:')) {
            return this.resolveDidWeb(did, locator.kid);
        }
        // did:cycles or a direct JWKS URL: JWKS path with caching.
        if ((did && isDIDCycles(did)) || locator.jwksUrl) {
            return this.resolveJwks(locator);
        }
        return this.fail('unsupported', `unsupported locator: ${did ?? locator.jwksUrl}`);
    }
    // ── did:key (existing logic, unchanged) ──────────────────────────
    resolveDidKey(did) {
        try {
            // Strip any DID-URL fragment before handing to fromDIDKey.
            const core = did.split('#')[0];
            const hex = fromDIDKey(core);
            if (!/^[0-9a-f]{64}$/i.test(hex)) {
                return this.fail('malformed', 'did:key did not decode to a 32-byte key');
            }
            return {
                ok: true,
                status: 'resolved',
                publicKeyHex: hex.toLowerCase(),
                cacheHit: false,
                scope_of_claim: resolvedScope(),
            };
        }
        catch (err) {
            return this.fail('malformed', `did:key parse failed: ${safeMsg(err)}`);
        }
    }
    // ── did:web (existing resolveDIDWeb, unchanged) ──────────────────
    async resolveDidWeb(did, explicitKid) {
        const cacheKey = `web:${did}|kid:${explicitKid ?? ''}`;
        const cached = this.readCache(cacheKey);
        if (cached)
            return cached;
        const hashIndex = did.indexOf('#');
        const fragment = hashIndex >= 0 ? did.slice(hashIndex + 1) : undefined;
        const core = hashIndex >= 0 ? did.slice(0, hashIndex) : did;
        const wantKid = this.reconcileKid(fragment, explicitKid);
        if (wantKid === AMBIGUOUS) {
            return this.fail('ambiguous', 'did-url fragment and explicit kid disagree');
        }
        let doc;
        try {
            doc = (await resolveDIDWeb(core));
        }
        catch (err) {
            return this.unreachable(cacheKey, `did:web fetch failed: ${safeMsg(err)}`);
        }
        const methods = doc.verificationMethod;
        if (!Array.isArray(methods) || methods.length === 0) {
            return this.storeFail(cacheKey, 'malformed', 'did:web document has no verificationMethod');
        }
        const selected = this.selectVerificationMethod(methods, core, wantKid);
        if ('error' in selected) {
            return this.storeFail(cacheKey, selected.error.status, selected.error.reason);
        }
        const res = {
            ok: true,
            status: 'resolved',
            publicKeyHex: selected.hex,
            kid: selected.kid,
            cacheHit: false,
            scope_of_claim: resolvedScope(),
        };
        this.writeCache(cacheKey, res, this.cachePolicy.ttlMs);
        return res;
    }
    selectVerificationMethod(methods, coreDid, wantKid) {
        const candidates = [];
        for (const m of methods) {
            if (!m || typeof m !== 'object')
                continue;
            const vm = m;
            const id = typeof vm.id === 'string' ? vm.id : '';
            const hex = vmToHex(vm);
            if (hex)
                candidates.push({ id, hex });
        }
        if (candidates.length === 0) {
            return { error: { status: 'malformed', reason: 'no usable Ed25519 verificationMethod' } };
        }
        if (wantKid !== undefined) {
            // Match the fragment against the verificationMethod id fragment.
            const matches = candidates.filter(c => fragmentOf(c.id) === wantKid);
            if (matches.length === 0) {
                return { error: { status: 'not_found', reason: `no verificationMethod #${wantKid}` } };
            }
            if (matches.length > 1) {
                return { error: { status: 'ambiguous', reason: `duplicate verificationMethod #${wantKid}` } };
            }
            return { hex: matches[0].hex, kid: wantKid };
        }
        if (candidates.length > 1) {
            return { error: { status: 'ambiguous', reason: 'multiple verificationMethods and no fragment' } };
        }
        void coreDid;
        return { hex: candidates[0].hex, kid: fragmentOf(candidates[0].id) };
    }
    // ── did:cycles / direct JWKS (this module's mapping) ─────────────
    async resolveJwks(locator) {
        let jwksUrl;
        let fragmentKid;
        if (locator.did && isDIDCycles(locator.did)) {
            try {
                const parsed = parseDIDCycles(locator.did);
                jwksUrl = parsed.jwksUrl;
                fragmentKid = parsed.kid;
            }
            catch (err) {
                return this.fail('unsupported', `did:cycles parse failed: ${safeMsg(err)}`);
            }
        }
        else if (locator.jwksUrl) {
            jwksUrl = locator.jwksUrl;
        }
        else {
            return this.fail('unsupported', 'no did:cycles or jwksUrl present');
        }
        // HTTPS only.
        if (!jwksUrl.startsWith('https://')) {
            return this.fail('unsupported', 'JWKS endpoint must be https');
        }
        const wantKid = this.reconcileKid(fragmentKid, locator.kid);
        if (wantKid === AMBIGUOUS) {
            return this.fail('ambiguous', 'did:cycles fragment and explicit kid disagree');
        }
        const cacheKey = `jwks:${jwksUrl}|kid:${wantKid ?? ''}`;
        const cached = this.readCache(cacheKey);
        if (cached)
            return cached;
        // Fetch.
        let response;
        try {
            response = await this.fetchImpl(jwksUrl, {
                headers: { Accept: 'application/jwk-set+json, application/json' },
                signal: AbortSignal.timeout(this.timeoutMs),
                redirect: 'error',
            });
        }
        catch (err) {
            return this.unreachable(cacheKey, `JWKS fetch failed: ${safeMsg(err)}`);
        }
        if (!response.ok) {
            return this.unreachable(cacheKey, `JWKS endpoint returned HTTP ${response.status}`);
        }
        let body;
        try {
            body = await response.json();
        }
        catch {
            // A 200 with a non-JSON body is treated as unreachable/transient:
            // the endpoint answered but did not deliver a parseable document.
            return this.unreachable(cacheKey, 'JWKS body was not valid JSON');
        }
        const jwks = asJWKS(body);
        if (!jwks) {
            // Loaded but structurally wrong: fail-closed even under fail-open.
            return this.storeFail(cacheKey, 'malformed', 'JWKS missing non-empty keys[] array');
        }
        const selection = selectKey(jwks, wantKid);
        if (!selection.ok) {
            return this.storeFail(cacheKey, selection.status, selection.reason);
        }
        const res = {
            ok: true,
            status: 'resolved',
            publicKeyHex: selection.publicKeyHex,
            kid: selection.kid,
            cacheHit: false,
            scope_of_claim: resolvedScope(),
        };
        this.writeCache(cacheKey, res, this.cachePolicy.ttlMs);
        return res;
    }
    // ── kid reconciliation ───────────────────────────────────────────
    /** Reconcile a DID-URL fragment kid with an explicit locator.kid.
     *  Returns the agreed kid, undefined if neither is set, or the
     *  AMBIGUOUS sentinel when the two are both set and disagree. */
    reconcileKid(fragment, explicit) {
        if (fragment !== undefined && explicit !== undefined) {
            return fragment === explicit ? fragment : AMBIGUOUS;
        }
        return fragment ?? explicit;
    }
    // ── cache ────────────────────────────────────────────────────────
    readCache(key) {
        const entry = this.cache.get(key);
        if (!entry)
            return undefined;
        if (this.now() >= entry.expiresAt) {
            this.cache.delete(key);
            return undefined;
        }
        // Re-anchor LRU ordering on hit.
        this.cache.delete(key);
        this.cache.set(key, entry);
        return { ...entry.resolution, cacheHit: true };
    }
    writeCache(key, resolution, ttlMs) {
        if (ttlMs <= 0)
            return;
        // Never cache a key with its transient cacheHit flag set true.
        const stored = { ...resolution, cacheHit: false };
        this.cache.set(key, { expiresAt: this.now() + ttlMs, resolution: stored });
        this.evictIfNeeded();
    }
    evictIfNeeded() {
        while (this.cache.size > this.cachePolicy.maxEntries) {
            const oldest = this.cache.keys().next().value;
            if (oldest === undefined)
                break;
            this.cache.delete(oldest);
        }
    }
    /** Inspect current cache size (observability / tests). */
    cacheSize() {
        return this.cache.size;
    }
    /** Clear the cache (operational reset / tests). */
    clearCache() {
        this.cache.clear();
    }
    // ── failure helpers ──────────────────────────────────────────────
    fail(status, reason) {
        return { ok: false, status, reason, cacheHit: false };
    }
    storeFail(cacheKey, status, reason) {
        const res = this.fail(status, reason);
        // Cache negative results briefly so a miss is not re-fetched in a
        // hot loop. A cached miss is NEVER promoted to a key on read.
        this.writeCache(cacheKey, res, this.cachePolicy.negativeTtlMs);
        return res;
    }
    /**
     * Handle an unreachable / transient-network condition under the
     * configured failure policy.
     *  - fail-closed (default): no key; status 'unreachable'; rejects.
     *  - fail-open: a documented-degraded result with NO key material and
     *    degraded=true. It MUST NOT be read as a positive verification.
     * In both modes the result carries no publicKeyHex.
     */
    unreachable(cacheKey, reason) {
        if (this.failurePolicy === 'open') {
            const res = {
                ok: false,
                status: 'unreachable',
                degraded: true,
                reason: `fail-open degraded: ${reason}`,
                cacheHit: false,
                scope_of_claim: degradedScope(),
            };
            // Cache the degraded miss briefly too.
            this.writeCache(cacheKey, res, this.cachePolicy.negativeTtlMs);
            return res;
        }
        const res = {
            ok: false,
            status: 'unreachable',
            degraded: false,
            reason: `fail-closed: ${reason}`,
            cacheHit: false,
        };
        this.writeCache(cacheKey, res, this.cachePolicy.negativeTtlMs);
        return res;
    }
}
// ── module-private helpers ─────────────────────────────────────────
const AMBIGUOUS = Symbol('kid-ambiguous');
function safeMsg(err) {
    return err instanceof Error ? err.message : String(err);
}
/** Fragment after the first '#', or undefined. */
function fragmentOf(id) {
    const i = id.indexOf('#');
    return i >= 0 ? id.slice(i + 1) : undefined;
}
/**
 * Extract a 32-byte Ed25519 public key (hex) from a DID-Document
 * verificationMethod, supporting both publicKeyMultibase (the shape
 * passportToDIDDocument emits) and publicKeyJwk (OKP/Ed25519).
 * Returns null if the method does not carry a usable Ed25519 key.
 */
function vmToHex(vm) {
    // publicKeyMultibase (Ed25519VerificationKey2020 / multibase).
    if (typeof vm.publicKeyMultibase === 'string') {
        try {
            const hex = multibaseToHex(vm.publicKeyMultibase);
            if (/^[0-9a-f]{64}$/i.test(hex))
                return hex.toLowerCase();
        }
        catch {
            // fall through to publicKeyJwk
        }
    }
    // publicKeyJwk (OKP/Ed25519).
    const jwk = vm.publicKeyJwk;
    if (jwk && typeof jwk === 'object') {
        const k = jwk;
        if (k.kty === 'OKP' && k.crv === 'Ed25519' && typeof k.x === 'string') {
            const bytes = decodeBase64Url(k.x);
            if (bytes && bytes.length === 32)
                return bytesToHex(bytes);
        }
    }
    return null;
}
//# sourceMappingURL=resolver.js.map