import { dirname } from "node:path"; import { rootPath } from "./config"; import { createYamlFieldReader, readYamlRecord } from "./platform-infra-ops-library"; import type { PublicServiceExposure } from "./platform-infra-public-service"; import { validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy"; import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy"; export const GITEA_CONFIG_LABEL = "config/platform-infra/gitea.yaml"; const configFile = rootPath("config", "platform-infra", "gitea.yaml"); const configLabel = GITEA_CONFIG_LABEL; const y = createYamlFieldReader(configLabel); export interface GiteaTarget { id: string; route: string; namespace: string; role: string; enabled: boolean; createNamespace: boolean; storageClassName: string; publicExposureEnabled: boolean; webhookSync: GiteaTargetWebhookSync | null; } export interface GiteaTargetWebhookSync { publicPath: string; frpc: { proxyName: string; remotePort: number; }; } export interface GiteaConfig { version: number; kind: "platform-infra-gitea"; metadata: { id: string; owner: string; spec: string; relatedIssues: number[]; }; defaults: { targetId: string; }; migration: { role: string; replaces: string; parentConfigRef: string; envReusePolicy: string; buildPlane: string; runtimePlane: string; }; sourceAuthority: { enabled: boolean; stage: string; statusAuthority: string; firstCiConsumer: string; credentials: { sourceRoot: string; admin: GiteaAdminCredential; github: GiteaGithubCredential; }; githubProxy: { enabled: boolean; url: string; noProxy: string[]; }; webhookSync: GiteaWebhookSync; responsibilities: GiteaSourceResponsibility[]; repositories: GiteaMirrorRepository[]; }; targets: GiteaTarget[]; app: { name: string; statefulSetName: string; serviceName: string; replicas: number; image: { repository: string; tag: string; pullPolicy: "Always" | "IfNotPresent" | "Never"; }; service: { type: "ClusterIP"; httpPort: number; sshPort: number; }; server: { domain: string; rootUrl: string; sshDomain: string; protocol: "http" | "https"; startSshServer: boolean; }; publicExposure: PublicServiceExposure & { secretRoot: string }; database: { type: "sqlite3"; path: string; }; actions: { enabled: boolean; }; webhook: { allowedHostList: string; }; registration: { disabled: boolean; }; storage: { data: { size: string; mountPath: string }; config: { size: string; mountPath: string }; }; securityContext: { runAsUser: number; runAsGroup: number; fsGroup: number; }; resources: { requests: { cpu: string; memory: string }; limits: { cpu: string; memory: string }; }; probes: { healthPath: string; initialDelaySeconds: number; periodSeconds: number; timeoutSeconds: number; failureThreshold: number; }; }; validation: { waitTimeoutSeconds: number; healthPath: string; }; } export interface GiteaAdminCredential { sourceRef: string; format: "line-pair"; usernameLine: number; passwordLine: number; requiredFor: string[]; } export interface GiteaGithubCredential { transport: "https-token"; sourceRef: string; sourceKey: string; format: "env" | "raw-token"; requiredFor: string[]; gitFetchCredential: { apiVersion: "unidesk.ai/v1"; kind: "GitFetchCredential"; authMode: "github-https-token"; host: "github.com"; secretRef: { namespace: string; name: string; key: string; }; }; } export interface GiteaGithubPermissions { contents: "read"; metadata: "read"; webhooks: "read-write"; } export function githubTokenEnvNameForSecretKey(secretKey: string): string { return `UNIDESK_GITEA_GITHUB_TOKEN_${secretKey.replace(/[^A-Za-z0-9_]/gu, "_").toUpperCase()}`; } export interface GiteaWebhookSync { enabled: boolean; direction: "github-to-gitea"; responseBudgetMs: number; publicPath: string; events: string[]; ingressRetry: GiteaWebhookIngressRetry; secret: { sourceRef: string; sourceKey: string; createIfMissing: boolean; }; hookReconcile: { enabled: boolean; ownerTargetId: string; intervalMs: number; requestTimeoutMs: number; listMaxPages: number; failedDeliveryRecovery: { enabled: boolean; targetId: string; statePath: string; lookbackSeconds: number; historyPerHook: number; maxRedeliveriesPerCycle: number; cooldownMs: number; maxAttemptsPerDelivery: number; retryStatusCodes: number[]; stateRetentionSeconds: number; }; }; bridge: { deploymentName: string; serviceName: string; secretName: string; configMapName: string; image: string; replicas: number; httpPort: number; serviceAccountName: string; shutdownGraceMs: number; candidateGate: { enabled: boolean; configMapName: string; jobName: string; activeDeadlineSeconds: number; ttlSecondsAfterFinished: number; healthTimeoutMs: number; pollIntervalMs: number; }; inbox: { claimName: string; path: string; storageSize: string; maxBytes: number; committedRetentionSeconds: number; cleanupIntervalMs: number; }; retry: { maxAttempts: number; initialDelayMs: number; maxDelayMs: number; terminalRetryDelayMs: number; attemptTimeoutMs: number; scanIntervalMs: number; }; }; gitOpsDelivery: GiteaWebhookGitOpsDelivery; frpc: { proxyName: string; remotePort: number; }; } export interface GiteaWebhookGitOpsDelivery { enabled: boolean; targetId: string; readUrl: string; writeUrl: string; branch: string; sourceSnapshotPrefix: string; desiredManifestPath: string; bootstrapApplicationPath: string; releaseStatePath: string; application: { name: string; namespace: string; project: string; repoUrl: string; targetRevision: string; path: string; destinationNamespace: string; automated: boolean; }; author: { name: string; email: string; }; cas: { maxAttempts: number; }; } export interface GiteaSourceResponsibility { name: string; current: string; target: string; disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; } export interface GiteaMirrorRepository { key: string; targetId: string; credentialOverride: { github: GiteaGithubCredential & { permissions: GiteaGithubPermissions }; } | null; upstream: { repository: string; cloneUrl: string; branch: string; visibility: "public" | "private"; }; gitea: { owner: string; name: string; mirrorMode: "controlled-push"; publicRead: boolean; readUrl: string; }; gitops: { branch: string; flushDisposition: string; }; snapshot: { prefix: string; }; legacyGitMirror: { readUrl: string; configRef: string; disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; } | null; } export function readGiteaConfig(): GiteaConfig { const root = readYamlRecord>(configFile, "platform-infra-gitea"); const version = y.integerField(root, "version", ""); if (version !== 1) throw new Error(`${configLabel}.version must be 1`); const metadata = y.objectField(root, "metadata", ""); const defaults = y.objectField(root, "defaults", ""); const migration = y.objectField(root, "migration", ""); const sourceAuthority = y.objectField(root, "sourceAuthority", ""); const sourceCredentials = y.objectField(sourceAuthority, "credentials", "sourceAuthority"); const githubProxy = y.objectField(sourceAuthority, "githubProxy", "sourceAuthority"); const webhookSync = y.objectField(sourceAuthority, "webhookSync", "sourceAuthority"); const app = y.objectField(root, "app", ""); const image = y.objectField(app, "image", "app"); const service = y.objectField(app, "service", "app"); const server = y.objectField(app, "server", "app"); const database = y.objectField(app, "database", "app"); const actions = y.objectField(app, "actions", "app"); const webhook = y.objectField(app, "webhook", "app"); const registration = y.objectField(app, "registration", "app"); const storage = y.objectField(app, "storage", "app"); const dataStorage = y.objectField(storage, "data", "app.storage"); const configStorage = y.objectField(storage, "config", "app.storage"); const publicExposure = y.objectField(app, "publicExposure", "app"); const securityContext = y.objectField(app, "securityContext", "app"); const resources = y.objectField(app, "resources", "app"); const requests = y.objectField(resources, "requests", "app.resources"); const limits = y.objectField(resources, "limits", "app.resources"); const probes = y.objectField(app, "probes", "app"); const validation = y.objectField(root, "validation", ""); const parsed: GiteaConfig = { version, kind: "platform-infra-gitea", metadata: { id: y.stringField(metadata, "id", "metadata"), owner: y.stringField(metadata, "owner", "metadata"), spec: y.stringField(metadata, "spec", "metadata"), relatedIssues: y.numberArrayField(metadata, "relatedIssues", "metadata"), }, defaults: { targetId: y.stringField(defaults, "targetId", "defaults"), }, migration: { role: y.stringField(migration, "role", "migration"), replaces: y.stringField(migration, "replaces", "migration"), parentConfigRef: y.stringField(migration, "parentConfigRef", "migration"), envReusePolicy: y.stringField(migration, "envReusePolicy", "migration"), buildPlane: y.stringField(migration, "buildPlane", "migration"), runtimePlane: y.stringField(migration, "runtimePlane", "migration"), }, sourceAuthority: { enabled: y.booleanField(sourceAuthority, "enabled", "sourceAuthority"), stage: y.stringField(sourceAuthority, "stage", "sourceAuthority"), statusAuthority: y.stringField(sourceAuthority, "statusAuthority", "sourceAuthority"), firstCiConsumer: y.stringField(sourceAuthority, "firstCiConsumer", "sourceAuthority"), credentials: { sourceRoot: y.absolutePathField(sourceCredentials, "sourceRoot", "sourceAuthority.credentials"), admin: parseAdminCredential(y.objectField(sourceCredentials, "admin", "sourceAuthority.credentials"), "sourceAuthority.credentials.admin"), github: parseGithubCredential(y.objectField(sourceCredentials, "github", "sourceAuthority.credentials"), "sourceAuthority.credentials.github"), }, githubProxy: { enabled: y.booleanField(githubProxy, "enabled", "sourceAuthority.githubProxy"), url: urlField(githubProxy, "url", "sourceAuthority.githubProxy"), noProxy: y.stringArrayField(githubProxy, "noProxy", "sourceAuthority.githubProxy"), }, webhookSync: parseWebhookSync(webhookSync, "sourceAuthority.webhookSync"), responsibilities: y.arrayOfRecords(sourceAuthority.responsibilities, "sourceAuthority.responsibilities").map(parseResponsibility), repositories: y.arrayOfRecords(sourceAuthority.repositories, "sourceAuthority.repositories").map(parseMirrorRepository), }, targets: y.arrayOfRecords(root.targets, "targets").map(parseTarget), app: { name: y.kubernetesNameField(app, "name", "app"), statefulSetName: y.kubernetesNameField(app, "statefulSetName", "app"), serviceName: y.kubernetesNameField(app, "serviceName", "app"), replicas: positiveInteger(app, "replicas", "app"), image: { repository: y.stringField(image, "repository", "app.image"), tag: y.stringField(image, "tag", "app.image"), pullPolicy: y.enumField(image, "pullPolicy", "app.image", ["Always", "IfNotPresent", "Never"] as const), }, service: { type: y.enumField(service, "type", "app.service", ["ClusterIP"] as const), httpPort: y.portField(service, "httpPort", "app.service"), sshPort: y.portField(service, "sshPort", "app.service"), }, server: { domain: y.hostField(server, "domain", "app.server"), rootUrl: urlField(server, "rootUrl", "app.server"), sshDomain: y.hostField(server, "sshDomain", "app.server"), protocol: y.enumField(server, "protocol", "app.server", ["http", "https"] as const), startSshServer: y.booleanField(server, "startSshServer", "app.server"), }, publicExposure: parsePublicExposure(publicExposure, "app.publicExposure"), database: { type: y.enumField(database, "type", "app.database", ["sqlite3"] as const), path: y.absolutePathField(database, "path", "app.database"), }, actions: { enabled: y.booleanField(actions, "enabled", "app.actions"), }, webhook: { allowedHostList: y.stringField(webhook, "allowedHostList", "app.webhook"), }, registration: { disabled: y.booleanField(registration, "disabled", "app.registration"), }, storage: { data: { size: quantity(dataStorage, "size", "app.storage.data"), mountPath: y.absolutePathField(dataStorage, "mountPath", "app.storage.data") }, config: { size: quantity(configStorage, "size", "app.storage.config"), mountPath: y.absolutePathField(configStorage, "mountPath", "app.storage.config") }, }, securityContext: { runAsUser: positiveInteger(securityContext, "runAsUser", "app.securityContext"), runAsGroup: positiveInteger(securityContext, "runAsGroup", "app.securityContext"), fsGroup: positiveInteger(securityContext, "fsGroup", "app.securityContext"), }, resources: { requests: { cpu: y.stringField(requests, "cpu", "app.resources.requests"), memory: quantity(requests, "memory", "app.resources.requests") }, limits: { cpu: y.stringField(limits, "cpu", "app.resources.limits"), memory: quantity(limits, "memory", "app.resources.limits") }, }, probes: { healthPath: y.apiPathField(probes, "healthPath", "app.probes"), initialDelaySeconds: positiveInteger(probes, "initialDelaySeconds", "app.probes"), periodSeconds: positiveInteger(probes, "periodSeconds", "app.probes"), timeoutSeconds: positiveInteger(probes, "timeoutSeconds", "app.probes"), failureThreshold: positiveInteger(probes, "failureThreshold", "app.probes"), }, }, validation: { waitTimeoutSeconds: boundedTimeout(validation, "waitTimeoutSeconds", "validation"), healthPath: y.apiPathField(validation, "healthPath", "validation"), }, }; validateConfig(parsed); return parsed; } function parseAdminCredential(record: Record, path: string): GiteaAdminCredential { return { sourceRef: secretSourceRefField(record, "sourceRef", path), format: literalField(record, "format", path, ["line-pair"]), usernameLine: positiveInteger(record, "usernameLine", path), passwordLine: positiveInteger(record, "passwordLine", path), requiredFor: y.stringArrayField(record, "requiredFor", path), }; } function parseGithubCredential(record: Record, path: string): GiteaGithubCredential { const gitFetchCredential = y.objectField(record, "gitFetchCredential", path); const secretRef = y.objectField(gitFetchCredential, "secretRef", `${path}.gitFetchCredential`); return { transport: y.enumField(record, "transport", path, ["https-token"] as const), sourceRef: secretSourceRefField(record, "sourceRef", path), sourceKey: y.envKeyField(record, "sourceKey", path), format: optionalLiteralField(record, "format", path, ["env", "raw-token"] as const) ?? "env", requiredFor: y.stringArrayField(record, "requiredFor", path), gitFetchCredential: { apiVersion: y.enumField(gitFetchCredential, "apiVersion", `${path}.gitFetchCredential`, ["unidesk.ai/v1"] as const), kind: y.enumField(gitFetchCredential, "kind", `${path}.gitFetchCredential`, ["GitFetchCredential"] as const), authMode: y.enumField(gitFetchCredential, "authMode", `${path}.gitFetchCredential`, ["github-https-token"] as const), host: y.enumField(gitFetchCredential, "host", `${path}.gitFetchCredential`, ["github.com"] as const), secretRef: { namespace: y.kubernetesNameField(secretRef, "namespace", `${path}.gitFetchCredential.secretRef`), name: y.kubernetesNameField(secretRef, "name", `${path}.gitFetchCredential.secretRef`), key: y.stringField(secretRef, "key", `${path}.gitFetchCredential.secretRef`), }, }, }; } function parseWebhookSync(record: Record, path: string): GiteaWebhookSync { const ingressRetry = y.objectField(record, "ingressRetry", path); const secret = y.objectField(record, "secret", path); const hookReconcile = y.objectField(record, "hookReconcile", path); const failedDeliveryRecovery = y.objectField(hookReconcile, "failedDeliveryRecovery", `${path}.hookReconcile`); const bridge = y.objectField(record, "bridge", path); const candidateGate = y.objectField(bridge, "candidateGate", `${path}.bridge`); const inbox = y.objectField(bridge, "inbox", `${path}.bridge`); const gitOpsDelivery = y.objectField(record, "gitOpsDelivery", path); const frpc = y.objectField(record, "frpc", path); return { enabled: y.booleanField(record, "enabled", path), direction: y.enumField(record, "direction", path, ["github-to-gitea"] as const), responseBudgetMs: positiveInteger(record, "responseBudgetMs", path), publicPath: y.apiPathField(record, "publicPath", path), events: y.stringArrayField(record, "events", path), ingressRetry: { enabled: y.booleanField(ingressRetry, "enabled", `${path}.ingressRetry`), maxBodyBytes: positiveInteger(ingressRetry, "maxBodyBytes", `${path}.ingressRetry`), maxRetries: positiveInteger(ingressRetry, "maxRetries", `${path}.ingressRetry`), tryIntervalMs: positiveInteger(ingressRetry, "tryIntervalMs", `${path}.ingressRetry`), dialTimeoutMs: positiveInteger(ingressRetry, "dialTimeoutMs", `${path}.ingressRetry`), writeTimeoutMs: positiveInteger(ingressRetry, "writeTimeoutMs", `${path}.ingressRetry`), retryStatusCodes: y.numberArrayField(ingressRetry, "retryStatusCodes", `${path}.ingressRetry`), }, secret: { sourceRef: secretSourceRefField(secret, "sourceRef", `${path}.secret`), sourceKey: y.envKeyField(secret, "sourceKey", `${path}.secret`), createIfMissing: y.booleanField(secret, "createIfMissing", `${path}.secret`), }, hookReconcile: { enabled: y.booleanField(hookReconcile, "enabled", `${path}.hookReconcile`), ownerTargetId: y.stringField(hookReconcile, "ownerTargetId", `${path}.hookReconcile`), intervalMs: positiveInteger(hookReconcile, "intervalMs", `${path}.hookReconcile`), requestTimeoutMs: positiveInteger(hookReconcile, "requestTimeoutMs", `${path}.hookReconcile`), listMaxPages: positiveInteger(hookReconcile, "listMaxPages", `${path}.hookReconcile`), failedDeliveryRecovery: { enabled: y.booleanField(failedDeliveryRecovery, "enabled", `${path}.hookReconcile.failedDeliveryRecovery`), targetId: y.stringField(failedDeliveryRecovery, "targetId", `${path}.hookReconcile.failedDeliveryRecovery`), statePath: y.absolutePathField(failedDeliveryRecovery, "statePath", `${path}.hookReconcile.failedDeliveryRecovery`), lookbackSeconds: positiveInteger(failedDeliveryRecovery, "lookbackSeconds", `${path}.hookReconcile.failedDeliveryRecovery`), historyPerHook: positiveInteger(failedDeliveryRecovery, "historyPerHook", `${path}.hookReconcile.failedDeliveryRecovery`), maxRedeliveriesPerCycle: positiveInteger(failedDeliveryRecovery, "maxRedeliveriesPerCycle", `${path}.hookReconcile.failedDeliveryRecovery`), cooldownMs: positiveInteger(failedDeliveryRecovery, "cooldownMs", `${path}.hookReconcile.failedDeliveryRecovery`), maxAttemptsPerDelivery: positiveInteger(failedDeliveryRecovery, "maxAttemptsPerDelivery", `${path}.hookReconcile.failedDeliveryRecovery`), retryStatusCodes: y.numberArrayField(failedDeliveryRecovery, "retryStatusCodes", `${path}.hookReconcile.failedDeliveryRecovery`), stateRetentionSeconds: positiveInteger(failedDeliveryRecovery, "stateRetentionSeconds", `${path}.hookReconcile.failedDeliveryRecovery`), }, }, bridge: { deploymentName: y.kubernetesNameField(bridge, "deploymentName", `${path}.bridge`), serviceName: y.kubernetesNameField(bridge, "serviceName", `${path}.bridge`), secretName: y.kubernetesNameField(bridge, "secretName", `${path}.bridge`), configMapName: y.kubernetesNameField(bridge, "configMapName", `${path}.bridge`), image: y.stringField(bridge, "image", `${path}.bridge`), replicas: positiveInteger(bridge, "replicas", `${path}.bridge`), httpPort: y.portField(bridge, "httpPort", `${path}.bridge`), serviceAccountName: y.kubernetesNameField(bridge, "serviceAccountName", `${path}.bridge`), shutdownGraceMs: positiveInteger(bridge, "shutdownGraceMs", `${path}.bridge`), candidateGate: { enabled: y.booleanField(candidateGate, "enabled", `${path}.bridge.candidateGate`), configMapName: y.kubernetesNameField(candidateGate, "configMapName", `${path}.bridge.candidateGate`), jobName: y.kubernetesNameField(candidateGate, "jobName", `${path}.bridge.candidateGate`), activeDeadlineSeconds: positiveInteger(candidateGate, "activeDeadlineSeconds", `${path}.bridge.candidateGate`), ttlSecondsAfterFinished: positiveInteger(candidateGate, "ttlSecondsAfterFinished", `${path}.bridge.candidateGate`), healthTimeoutMs: positiveInteger(candidateGate, "healthTimeoutMs", `${path}.bridge.candidateGate`), pollIntervalMs: positiveInteger(candidateGate, "pollIntervalMs", `${path}.bridge.candidateGate`), }, inbox: { claimName: y.kubernetesNameField(inbox, "claimName", `${path}.bridge.inbox`), path: y.absolutePathField(inbox, "path", `${path}.bridge.inbox`), storageSize: quantity(inbox, "storageSize", `${path}.bridge.inbox`), maxBytes: positiveInteger(inbox, "maxBytes", `${path}.bridge.inbox`), committedRetentionSeconds: positiveInteger(inbox, "committedRetentionSeconds", `${path}.bridge.inbox`), cleanupIntervalMs: positiveInteger(inbox, "cleanupIntervalMs", `${path}.bridge.inbox`), }, retry: parseWebhookRetry(y.objectField(bridge, "retry", `${path}.bridge`), `${path}.bridge.retry`), }, gitOpsDelivery: parseWebhookGitOpsDelivery(gitOpsDelivery, `${path}.gitOpsDelivery`), frpc: { proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), }, }; } function parseWebhookGitOpsDelivery(record: Record, path: string): GiteaWebhookGitOpsDelivery { const application = y.objectField(record, "application", path); const author = y.objectField(record, "author", path); const cas = y.objectField(record, "cas", path); return { enabled: y.booleanField(record, "enabled", path), targetId: y.stringField(record, "targetId", path), readUrl: urlField(record, "readUrl", path), writeUrl: urlField(record, "writeUrl", path), branch: gitBranchField(record, "branch", path), sourceSnapshotPrefix: refPrefixField(record, "sourceSnapshotPrefix", path), desiredManifestPath: safeRelativePathField(record, "desiredManifestPath", path), bootstrapApplicationPath: safeRelativePathField(record, "bootstrapApplicationPath", path), releaseStatePath: safeRelativePathField(record, "releaseStatePath", path), application: { name: y.kubernetesNameField(application, "name", `${path}.application`), namespace: y.kubernetesNameField(application, "namespace", `${path}.application`), project: y.kubernetesNameField(application, "project", `${path}.application`), repoUrl: urlField(application, "repoUrl", `${path}.application`), targetRevision: gitBranchField(application, "targetRevision", `${path}.application`), path: safeRelativePathField(application, "path", `${path}.application`), destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", `${path}.application`), automated: y.booleanField(application, "automated", `${path}.application`), }, author: { name: y.stringField(author, "name", `${path}.author`), email: y.stringField(author, "email", `${path}.author`), }, cas: { maxAttempts: positiveInteger(cas, "maxAttempts", `${path}.cas`), }, }; } function parseWebhookRetry(record: Record, path: string): GiteaWebhookSync["bridge"]["retry"] { return { maxAttempts: positiveInteger(record, "maxAttempts", path), initialDelayMs: positiveInteger(record, "initialDelayMs", path), maxDelayMs: positiveInteger(record, "maxDelayMs", path), terminalRetryDelayMs: positiveInteger(record, "terminalRetryDelayMs", path), attemptTimeoutMs: positiveInteger(record, "attemptTimeoutMs", path), scanIntervalMs: positiveInteger(record, "scanIntervalMs", path), }; } function parsePublicExposure(record: Record, path: string): PublicServiceExposure & { secretRoot: string } { const dns = y.objectField(record, "dns", path); const frpc = y.objectField(record, "frpc", path); const pk01 = y.objectField(record, "pk01", path); const publicBaseUrl = httpsUrlField(record, "publicBaseUrl", path); const hostname = y.hostField(dns, "hostname", `${path}.dns`); if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${configLabel}.${path}.dns.hostname must match publicBaseUrl`); return { enabled: y.booleanField(record, "enabled", path), publicBaseUrl, secretRoot: y.absolutePathField(record, "secretRoot", path), dns: { hostname, expectedA: y.stringField(dns, "expectedA", `${path}.dns`), resolvers: y.stringArrayField(dns, "resolvers", `${path}.dns`), }, frpc: { deploymentName: y.kubernetesNameField(frpc, "deploymentName", `${path}.frpc`), secretName: y.kubernetesNameField(frpc, "secretName", `${path}.frpc`), secretKey: y.stringField(frpc, "secretKey", `${path}.frpc`), image: y.stringField(frpc, "image", `${path}.frpc`), serverAddr: y.hostField(frpc, "serverAddr", `${path}.frpc`), serverPort: y.portField(frpc, "serverPort", `${path}.frpc`), proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), localIP: y.hostField(frpc, "localIP", `${path}.frpc`), localPort: y.portField(frpc, "localPort", `${path}.frpc`), tokenSourceRef: secretSourceRefField(frpc, "tokenSourceRef", `${path}.frpc`), tokenSourceKey: y.envKeyField(frpc, "tokenSourceKey", `${path}.frpc`), }, pk01: { route: y.stringField(pk01, "route", `${path}.pk01`), caddyConfigPath: y.absolutePathField(pk01, "caddyConfigPath", `${path}.pk01`), caddyServiceName: y.stringField(pk01, "caddyServiceName", `${path}.pk01`), responseHeaderTimeoutSeconds: y.integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.pk01`), }, }; } function parseResponsibility(record: Record, index: number): GiteaSourceResponsibility { const path = `sourceAuthority.responsibilities[${index}]`; return { name: y.stringField(record, "name", path), current: y.stringField(record, "current", path), target: y.stringField(record, "target", path), disposition: y.enumField(record, "disposition", path, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), }; } function parseMirrorRepository(record: Record, index: number): GiteaMirrorRepository { const path = `sourceAuthority.repositories[${index}]`; const upstream = y.objectField(record, "upstream", path); const gitea = y.objectField(record, "gitea", path); const gitops = y.objectField(record, "gitops", path); const snapshot = y.objectField(record, "snapshot", path); const legacyGitMirror = record.legacyGitMirror === null || record.legacyGitMirror === undefined ? null : y.objectField(record, "legacyGitMirror", path); const credentialOverride = record.credentialOverride === undefined ? null : y.objectField(record, "credentialOverride", path); const githubOverride = credentialOverride === null ? null : y.objectField(credentialOverride, "github", `${path}.credentialOverride`); const githubPermissions = githubOverride === null ? null : y.objectField(githubOverride, "permissions", `${path}.credentialOverride.github`); return { key: y.stringField(record, "key", path), targetId: y.stringField(record, "targetId", path), credentialOverride: githubOverride === null || githubPermissions === null ? null : { github: { ...parseGithubCredential(githubOverride, `${path}.credentialOverride.github`), permissions: { contents: y.enumField(githubPermissions, "contents", `${path}.credentialOverride.github.permissions`, ["read"] as const), metadata: y.enumField(githubPermissions, "metadata", `${path}.credentialOverride.github.permissions`, ["read"] as const), webhooks: y.enumField(githubPermissions, "webhooks", `${path}.credentialOverride.github.permissions`, ["read-write"] as const), }, }, }, upstream: { repository: repositoryField(upstream, "repository", `${path}.upstream`), cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`), branch: y.stringField(upstream, "branch", `${path}.upstream`), visibility: upstream.visibility === undefined ? "public" : y.enumField(upstream, "visibility", `${path}.upstream`, ["public", "private"] as const), }, gitea: { owner: y.stringField(gitea, "owner", `${path}.gitea`), name: giteaRepoNameField(gitea, "name", `${path}.gitea`), mirrorMode: y.enumField(gitea, "mirrorMode", `${path}.gitea`, ["controlled-push"] as const), publicRead: y.booleanField(gitea, "publicRead", `${path}.gitea`), readUrl: urlField(gitea, "readUrl", `${path}.gitea`), }, gitops: { branch: y.stringField(gitops, "branch", `${path}.gitops`), flushDisposition: y.stringField(gitops, "flushDisposition", `${path}.gitops`), }, snapshot: { prefix: refPrefixField(snapshot, "prefix", `${path}.snapshot`), }, legacyGitMirror: legacyGitMirror === null ? null : { readUrl: urlField(legacyGitMirror, "readUrl", `${path}.legacyGitMirror`), configRef: y.stringField(legacyGitMirror, "configRef", `${path}.legacyGitMirror`), disposition: y.enumField(legacyGitMirror, "disposition", `${path}.legacyGitMirror`, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), }, }; } function parseTarget(record: Record, index: number): GiteaTarget { const path = `targets[${index}]`; const publicExposureRaw = record.publicExposure; if (publicExposureRaw !== undefined && (typeof publicExposureRaw !== "object" || publicExposureRaw === null || Array.isArray(publicExposureRaw))) { throw new Error(`${configLabel}.${path}.publicExposure must be an object`); } const webhookSyncRaw = record.webhookSync; if (webhookSyncRaw !== undefined && (typeof webhookSyncRaw !== "object" || webhookSyncRaw === null || Array.isArray(webhookSyncRaw))) { throw new Error(`${configLabel}.${path}.webhookSync must be an object`); } const publicExposure = publicExposureRaw as Record | undefined; return { id: y.stringField(record, "id", path), route: y.stringField(record, "route", path), namespace: y.kubernetesNameField(record, "namespace", path), role: y.stringField(record, "role", path), enabled: y.booleanField(record, "enabled", path), createNamespace: y.booleanField(record, "createNamespace", path), storageClassName: y.stringField(record, "storageClassName", path), publicExposureEnabled: publicExposure === undefined ? true : y.booleanField(publicExposure, "enabled", `${path}.publicExposure`), webhookSync: webhookSyncRaw === undefined ? null : parseTargetWebhookSync(webhookSyncRaw as Record, `${path}.webhookSync`), }; } function parseTargetWebhookSync(record: Record, path: string): GiteaTargetWebhookSync { const frpc = y.objectField(record, "frpc", path); return { publicPath: y.apiPathField(record, "publicPath", path), frpc: { proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), }, }; } function validateConfig(gitea: GiteaConfig): void { resolveTarget(gitea, gitea.defaults.targetId); const gitFetchCredential = gitea.sourceAuthority.credentials.github.gitFetchCredential; if (!gitea.sourceAuthority.credentials.github.requiredFor.includes("managed-repository-fetch")) { throw new Error(`${configLabel}.sourceAuthority.credentials.github.requiredFor must include managed-repository-fetch`); } if (!/^[A-Za-z0-9._-]+$/u.test(gitFetchCredential.secretRef.key)) { throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key must be a Kubernetes Secret data key`); } if (gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) { throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`); } if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) { throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`); } const credentialKeys = new Set([gitFetchCredential.secretRef.key]); const credentialEnvNames = new Set([githubTokenEnvNameForSecretKey(gitFetchCredential.secretRef.key)]); for (const repo of gitea.sourceAuthority.repositories) { const override = repo.credentialOverride?.github; if (override === undefined) continue; if (!override.requiredFor.includes("managed-repository-fetch") || !override.requiredFor.includes("github-head-observe") || !override.requiredFor.includes("github-hooks-list") || !override.requiredFor.includes("github-hooks-reconcile")) { throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.requiredFor must cover fetch, head observation and hook reconciliation`); } if (override.gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) { throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`); } const target = resolveTarget(gitea, repo.targetId); if (override.gitFetchCredential.secretRef.namespace !== target.namespace) { throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.namespace must match repository target namespace`); } const key = override.gitFetchCredential.secretRef.key; if (!/^[A-Za-z0-9._-]+$/u.test(key) || credentialKeys.has(key)) { throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.key must be a unique Kubernetes Secret data key`); } credentialKeys.add(key); const envName = githubTokenEnvNameForSecretKey(key); if (credentialEnvNames.has(envName)) { throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.key collides after environment variable normalization`); } credentialEnvNames.add(envName); } if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`); if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`); if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`); if (!/-rootless$/u.test(gitea.app.image.tag)) throw new Error(`${configLabel}.app.image.tag must use a rootless Gitea image`); if (gitea.app.service.type !== "ClusterIP") throw new Error(`${configLabel}.app.service.type must stay ClusterIP`); if (!gitea.app.actions.enabled) throw new Error(`${configLabel}.app.actions.enabled must stay explicit while Gitea is the source-authority service`); if (!gitea.app.registration.disabled) throw new Error(`${configLabel}.app.registration.disabled must be true for the internal POC service`); if (gitea.app.probes.healthPath !== gitea.validation.healthPath) throw new Error(`${configLabel}.app.probes.healthPath must match validation.healthPath`); if (gitea.app.server.rootUrl !== gitea.app.publicExposure.publicBaseUrl.replace(/\/?$/u, "/")) throw new Error(`${configLabel}.app.server.rootUrl must match app.publicExposure.publicBaseUrl for the Web UI`); if (gitea.sourceAuthority.webhookSync.enabled) { const events = new Set(gitea.sourceAuthority.webhookSync.events); if (!events.has("push")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.events must include push for GitHub -> Gitea source sync`); if (gitea.sourceAuthority.webhookSync.frpc.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel}.sourceAuthority.webhookSync.frpc.remotePort must differ from app.publicExposure.frpc.remotePort`); const ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry; const hookReconcile = gitea.sourceAuthority.webhookSync.hookReconcile; const bridge = gitea.sourceAuthority.webhookSync.bridge; if (bridge.replicas !== 1) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.replicas must be 1 because the durable inbox uses a single-writer Recreate deployment`); if (bridge.retry.terminalRetryDelayMs < bridge.retry.maxDelayMs) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.retry.terminalRetryDelayMs must be >= maxDelayMs`); if (hookReconcile.enabled) { const owner = resolveTarget(gitea, hookReconcile.ownerTargetId); if (!gitea.sourceAuthority.repositories.some((repo) => repo.targetId.toLowerCase() === owner.id.toLowerCase())) { throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.ownerTargetId must own at least one repository`); } if (hookReconcile.intervalMs < 30_000) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.intervalMs must be >= 30000`); if (hookReconcile.requestTimeoutMs > 30_000) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.requestTimeoutMs must be <= 30000`); if (hookReconcile.listMaxPages > 20) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.listMaxPages must be <= 20`); const recovery = hookReconcile.failedDeliveryRecovery; if (recovery.enabled) { if (recovery.targetId.toLowerCase() !== owner.id.toLowerCase()) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.targetId must equal the owning target`); const recoveryRoot = `${bridge.inbox.path.replace(/\/+$/u, "")}/hook-recovery/`; if (!recovery.statePath.startsWith(recoveryRoot)) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.statePath must be under ${recoveryRoot}`); if (recovery.historyPerHook > 100) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.historyPerHook must be <= 100`); if (recovery.lookbackSeconds > 259_200) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.lookbackSeconds must be <= 259200 because GitHub only supports redelivery for three days`); if (recovery.maxRedeliveriesPerCycle > 20) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.maxRedeliveriesPerCycle must be <= 20`); if (recovery.maxAttemptsPerDelivery > 10) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.maxAttemptsPerDelivery must be <= 10`); if (recovery.cooldownMs < hookReconcile.intervalMs) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.cooldownMs must be >= hookReconcile.intervalMs`); if (recovery.stateRetentionSeconds < recovery.lookbackSeconds) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.stateRetentionSeconds must be >= lookbackSeconds`); if (recovery.retryStatusCodes.length < 1 || recovery.retryStatusCodes.some((statusCode) => ![502, 503, 504].includes(statusCode))) { throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.retryStatusCodes may only contain 502, 503 and 504`); } } } if (ingressRetry.retryStatusCodes.length < 1 || ingressRetry.retryStatusCodes.some((statusCode) => ![502, 503, 504].includes(statusCode))) { throw new Error(`${configLabel}.sourceAuthority.webhookSync.ingressRetry.retryStatusCodes may only contain 502, 503 and 504`); } if (ingressRetry.enabled) { try { validateGiteaWebhookTiming(gitea.sourceAuthority.webhookSync.responseBudgetMs, ingressRetry); } catch (error) { throw new Error(`${configLabel}.sourceAuthority.webhookSync timing invalid: ${String((error as Error).message || error)}`); } } const delivery = gitea.sourceAuthority.webhookSync.gitOpsDelivery; if (delivery.enabled) { const target = resolveTarget(gitea, delivery.targetId); if (target.id !== "NC01") throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.targetId must be NC01`); if (delivery.application.targetRevision !== delivery.branch) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.targetRevision must match branch`); if (delivery.application.repoUrl !== delivery.readUrl) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.repoUrl must match readUrl`); if (dirname(delivery.desiredManifestPath) !== delivery.application.path) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must be directly under application.path`); if (!delivery.bootstrapApplicationPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.bootstrapApplicationPath must be under the existing unidesk-host bootstrap path`); if (delivery.desiredManifestPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must use an independent GitOps path`); if (!/^[^@\s]+@[^@\s]+$/u.test(delivery.author.email)) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.author.email must be an email address`); if (delivery.cas.maxAttempts > 5) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.cas.maxAttempts must be <= 5`); } } const webhookPaths = new Set(); const webhookPorts = new Set(); for (const route of webhookCaddyRoutes(gitea)) { if (webhookPaths.has(route.publicPath)) throw new Error(`${configLabel} webhook publicPath must be unique: ${route.publicPath}`); if (webhookPorts.has(route.remotePort)) throw new Error(`${configLabel} webhook frpc.remotePort must be unique: ${route.remotePort}`); if (route.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel} webhook frpc.remotePort must differ from app public exposure port: ${route.remotePort}`); webhookPaths.add(route.publicPath); webhookPorts.add(route.remotePort); } const repoKeys = new Set(); for (const repo of gitea.sourceAuthority.repositories) { if (repoKeys.has(repo.key)) throw new Error(`${configLabel}.sourceAuthority.repositories contains duplicate key ${repo.key}`); repoKeys.add(repo.key); resolveTarget(gitea, repo.targetId); const readUrl = new URL(repo.gitea.readUrl); if (readUrl.hostname !== `${gitea.app.serviceName}.${resolveTarget(gitea, repo.targetId).namespace}.svc.cluster.local`) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must use the internal k3s Gitea Service DNS`); if (readUrl.hostname === new URL(gitea.app.publicExposure.publicBaseUrl).hostname) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must not use the public Web UI hostname`); if (repo.upstream.visibility === "private" && repo.gitea.publicRead) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.publicRead must be false for a private upstream`); } } export function resolveTarget(gitea: GiteaConfig, targetId: string | null): GiteaTarget { const resolved = targetId ?? gitea.defaults.targetId; const target = gitea.targets.find((item) => item.id.toLowerCase() === resolved.toLowerCase()); if (target === undefined) throw new Error(`unknown gitea target ${resolved}; known targets: ${gitea.targets.map((item) => item.id).join(", ")}`); if (!target.enabled) throw new Error(`gitea target ${target.id} is disabled in ${configLabel}`); return target; } export function targetWebhookSync(gitea: GiteaConfig, target: GiteaTarget): GiteaWebhookSync { const base = gitea.sourceAuthority.webhookSync; if (target.webhookSync === null) return base; return { ...base, publicPath: target.webhookSync.publicPath, frpc: target.webhookSync.frpc, }; } export function webhookCaddyRoutes(gitea: GiteaConfig): Array<{ publicPath: string; remotePort: number }> { const sync = gitea.sourceAuthority.webhookSync; if (!sync.enabled) return []; const routes = new Map(); routes.set(sync.publicPath, { publicPath: sync.publicPath, remotePort: sync.frpc.remotePort }); for (const target of gitea.targets) { if (!target.enabled || target.webhookSync === null) continue; const targetSync = targetWebhookSync(gitea, target); routes.set(targetSync.publicPath, { publicPath: targetSync.publicPath, remotePort: targetSync.frpc.remotePort }); } return [...routes.values()]; } function positiveInteger(obj: Record, key: string, path: string): number { const value = y.integerField(obj, key, path); if (value < 1) throw new Error(`${configLabel}.${path}.${key} must be positive`); return value; } function boundedTimeout(obj: Record, key: string, path: string): number { const value = positiveInteger(obj, key, path); if (value > 55) throw new Error(`${configLabel}.${path}.${key} must fit the 60s trans short-connection budget`); return value; } function literalField(obj: Record, key: string, path: string, allowed: T[]): T { const value = y.stringField(obj, key, path); if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`); return value as T; } function optionalLiteralField(obj: Record, key: string, path: string, allowed: readonly T[]): T | null { if (obj[key] === undefined || obj[key] === null) return null; const value = y.stringField(obj, key, path); if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`); return value as T; } function quantity(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); if (!/^[0-9]+(?:m|Ki|Mi|Gi|Ti)?$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a Kubernetes quantity`); return value; } function secretSourceRefField(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); if (value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a source ref path without ..`); return value; } function repositoryField(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be owner/repo`); return value; } function giteaRepoNameField(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); if (!/^[A-Za-z0-9_.-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a Gitea repository name`); return value; } function gitCloneUrlField(obj: Record, key: string, path: string): string { const value = urlField(obj, key, path); if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be an official GitHub HTTPS clone URL`); return value; } function refPrefixField(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); if (!/^refs\/[A-Za-z0-9._/-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a git ref prefix`); return value.replace(/\/+$/u, ""); } function urlField(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); const parsed = new URL(value); if (!["http:", "https:"].includes(parsed.protocol) || parsed.search || parsed.hash) throw new Error(`${configLabel}.${path}.${key} must be an http(s) URL without query or hash`); return value; } function gitBranchField(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); if (!/^[A-Za-z0-9._/-]+$/u.test(value) || value.startsWith("/") || value.endsWith("/") || value.includes("..") || value.includes("//")) { throw new Error(`${configLabel}.${path}.${key} must be a safe Git branch name`); } return value; } function safeRelativePathField(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); if (value.startsWith("/") || value.endsWith("/") || value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") || !/^[A-Za-z0-9._/-]+$/u.test(value)) { throw new Error(`${configLabel}.${path}.${key} must be a safe relative path`); } return value; } function httpsUrlField(obj: Record, key: string, path: string): string { const value = urlField(obj, key, path); if (new URL(value).protocol !== "https:") throw new Error(`${configLabel}.${path}.${key} must be an https URL`); return value; }