src/platform/config-portability/config-migration.service.ts

Relationships

Depends on

No results matching.

Index

Methods

Constructor

constructor(registry: ConfigResourceRegistry)
Parameters :
Name Type Optional
registry ConfigResourceRegistry No

Methods

detectLegacyVersion
detectLegacyVersion(kind: ConfigResourceKind, spec: Spec)
Parameters :
Name Type Optional
kind ConfigResourceKind No
spec Spec No
Returns : number
isDocument
isDocument(input: unknown)
Parameters :
Name Type Optional
input unknown No
Returns : ConfigDocument
unwrapForLegacyImporter
unwrapForLegacyImporter(document: ConfigDocument)
Parameters :
Name Type Optional
document ConfigDocument No
Returns : Record<string, unknown>
upgrade
upgrade(input: ConfigDocument)
Parameters :
Name Type Optional
input ConfigDocument No
wrapLegacy
wrapLegacy(kind: ConfigResourceKind, spec: Spec, id?: string)
Parameters :
Name Type Optional
kind ConfigResourceKind No
spec Spec No
id string Yes
Returns : ConfigDocument
import { BadRequestException, Injectable } from "@nestjs/common";
import { CONFIG_RESOURCE_KINDS } from "./config-resource.types";
import type {
    ConfigDocument,
    ConfigMigrationIssue,
    ConfigMigrationResult,
    ConfigResourceKind,
} from "./config-resource.types";
import { ConfigResourceRegistry } from "./config-resource.registry";

type Spec = Record<string, any>;

interface ConfigMigration {
    id: string;
    kind: ConfigResourceKind;
    from: number;
    to: number;
    migrate(spec: Spec): { spec: Spec; issues?: ConfigMigrationIssue[] };
}

const migrateTrustListRefs = (
    spec: Spec,
    kind: ConfigResourceKind,
    resourceId: string,
): ConfigMigrationIssue[] => {
    const issues: ConfigMigrationIssue[] = [];
    const refs = spec.walletProviderTrustLists;
    if (Array.isArray(refs)) {
        spec.walletProviderTrustLists = refs.map(
            (entry: unknown, index: number) => {
                if (typeof entry !== "string") return entry;
                issues.push({
                    severity: "required-input",
                    code: "TRUST_LIST_VERIFIER_REQUIRED",
                    path: `/spec/walletProviderTrustLists/${index}`,
                    message:
                        "Provide verifierKey or verifierX509Der before import.",
                    resource: { kind, id: resourceId },
                });
                return { url: entry };
            },
        );
    }
    return issues;
};

const MIGRATIONS: ConfigMigration[] = [
    {
        id: "IssuanceConfig/1-to-2",
        kind: "IssuanceConfig",
        from: 1,
        to: 2,
        migrate(spec) {
            const clone = structuredClone(spec);
            return {
                spec: clone,
                issues: migrateTrustListRefs(
                    clone,
                    "IssuanceConfig",
                    "issuance",
                ),
            };
        },
    },
    {
        id: "PresentationConfig/1-to-2",
        kind: "PresentationConfig",
        from: 1,
        to: 2,
        migrate(spec) {
            const clone = structuredClone(spec);
            const issues: ConfigMigrationIssue[] = [];
            if (clone.webhook !== undefined) {
                issues.push({
                    severity: "required-input",
                    code: "WEBHOOK_ENDPOINT_REFERENCE_REQUIRED",
                    path: "/spec/webhook",
                    message:
                        "Create or select a webhook endpoint, set webhookEndpointId, and remove webhook.",
                    resource: {
                        kind: "PresentationConfig",
                        id: String(clone.id ?? "unknown"),
                    },
                });
            }

            const credentials = clone.dcql_query?.credentials;
            if (Array.isArray(credentials)) {
                for (
                    let credentialIndex = 0;
                    credentialIndex < credentials.length;
                    credentialIndex++
                ) {
                    const authorities =
                        credentials[credentialIndex]?.trusted_authorities;
                    if (!Array.isArray(authorities)) continue;
                    for (
                        let authorityIndex = 0;
                        authorityIndex < authorities.length;
                        authorityIndex++
                    ) {
                        const authority = authorities[authorityIndex];
                        if (
                            authority?.type !== "etsi_tl" ||
                            !Array.isArray(authority.values)
                        ) {
                            continue;
                        }
                        authority.values = authority.values.map(
                            (entry: unknown, valueIndex: number) => {
                                if (typeof entry !== "string") return entry;
                                issues.push({
                                    severity: "required-input",
                                    code: "TRUST_LIST_VERIFIER_REQUIRED",
                                    path: `/spec/dcql_query/credentials/${credentialIndex}/trusted_authorities/${authorityIndex}/values/${valueIndex}`,
                                    message:
                                        "Provide verifierKey, verifierX509Der, or replace the URL with trustListId before import.",
                                    resource: {
                                        kind: "PresentationConfig",
                                        id: String(clone.id ?? "unknown"),
                                    },
                                });
                                return { url: entry };
                            },
                        );
                    }
                }
            }
            return { spec: clone, issues };
        },
    },
    {
        id: "KeyChain/1-to-2",
        kind: "KeyChain",
        from: 1,
        to: 2,
        migrate(spec) {
            const clone = structuredClone(spec);
            const key = clone.key;
            delete clone.key;
            clone.keySource = { type: "private-jwk", jwk: key };
            return { spec: clone };
        },
    },
];

@Injectable()
export class ConfigMigrationService {
    constructor(private readonly registry: ConfigResourceRegistry) {}

    isDocument(input: unknown): input is ConfigDocument {
        if (!input || typeof input !== "object") return false;
        const candidate = input as Record<string, unknown>;
        return (
            typeof candidate.apiVersion === "string" &&
            typeof candidate.kind === "string" &&
            CONFIG_RESOURCE_KINDS.includes(
                candidate.kind as ConfigResourceKind,
            ) &&
            !!candidate.metadata &&
            typeof candidate.metadata === "object" &&
            !!candidate.spec &&
            typeof candidate.spec === "object"
        );
    }

    detectLegacyVersion(kind: ConfigResourceKind, spec: Spec): number {
        if (kind === "PresentationConfig") {
            if (spec.webhook !== undefined) return 1;
            const credentials = spec.dcql_query?.credentials;
            if (
                Array.isArray(credentials) &&
                credentials.some((credential: any) =>
                    credential?.trusted_authorities?.some(
                        (authority: any) =>
                            authority?.type === "etsi_tl" &&
                            authority.values?.some(
                                (value: unknown) => typeof value === "string",
                            ),
                    ),
                )
            ) {
                return 1;
            }
            return 2;
        }
        if (kind === "IssuanceConfig") {
            return spec.walletProviderTrustLists?.some?.(
                (entry: unknown) => typeof entry === "string",
            )
                ? 1
                : 2;
        }
        if (kind === "KeyChain") return spec.keySource ? 2 : 1;
        return 1;
    }

    wrapLegacy(
        kind: ConfigResourceKind,
        spec: Spec,
        id?: string,
    ): ConfigDocument {
        const definition = this.registry.get(kind);
        const version = this.detectLegacyVersion(kind, spec);
        const resourceId =
            id ??
            String(
                spec.id ?? spec.clientId ?? definition.singletonId ?? "unknown",
            );
        return {
            apiVersion: this.registry.apiVersion(kind, version),
            kind,
            metadata: { id: resourceId, generation: 1 },
            spec,
        };
    }

    upgrade(input: ConfigDocument): ConfigMigrationResult {
        const definition = this.registry.get(input.kind);
        let version: number;
        try {
            version = this.registry.parseApiVersion(
                input.kind,
                input.apiVersion,
            );
        } catch (error) {
            throw new BadRequestException(String(error));
        }
        if (version > definition.currentVersion) {
            throw new BadRequestException(
                `${input.kind} version ${version} is newer than supported version ${definition.currentVersion}`,
            );
        }

        let document = structuredClone(input);
        const issues: ConfigMigrationIssue[] = [];
        const applied: string[] = [];
        while (version < definition.currentVersion) {
            const migration = MIGRATIONS.find(
                (candidate) =>
                    candidate.kind === input.kind && candidate.from === version,
            );
            if (!migration || migration.to !== version + 1) {
                throw new BadRequestException(
                    `No migration registered for ${input.kind} v${version} to v${version + 1}`,
                );
            }
            const result = migration.migrate(document.spec as Spec);
            document.spec = result.spec;
            issues.push(...(result.issues ?? []));
            applied.push(migration.id);
            version = migration.to;
            document.apiVersion = this.registry.apiVersion(input.kind, version);
        }

        return { document, issues, migrations: applied };
    }

    unwrapForLegacyImporter(document: ConfigDocument): Record<string, unknown> {
        const spec = structuredClone(document.spec) as Spec;
        if (
            document.kind === "KeyChain" &&
            spec.keySource?.type === "private-jwk"
        ) {
            spec.key = spec.keySource.jwk;
            delete spec.keySource;
        }
        if (
            spec.id === undefined &&
            document.kind !== "Client" &&
            !this.registry.get(document.kind).singletonId
        ) {
            spec.id = document.metadata.id;
        }
        return spec;
    }
}

results matching ""

    No results matching ""